Skip to content

SVC-GH-RUNNER: GitHub Actions runner + buildkitd (ns platform)

¿Qué es?

Runner de GitHub Actions self-hosted (siempre-on, 1 pod) registrado al repo platform-docs-runtime, más buildkitd — un daemon BuildKit rootless en el cluster que recibe los builds vía docker buildx --driver remote. Entre los dos ejecutan el pipeline de CI de imágenes: checkout → build → push a zot (SVC-REGISTRY), emitiendo el digest que se pinnea en el deployment revisado (regla RC6: el manifest referencia el artefacto EXACTO revisado).

URL / acceso

Acceso Cómo
Runner en GitHub (admin) Settings → Actions → Runners de platform-docs-runtime; API: gh api repos/wetopi-platform/platform-docs-runtime/actions/runners
Workflow CI (disparo manual) gh workflow run build-docs-runtime.yml --ref main -R wetopi-platform/platform-docs-runtime
Buildkitd (desde el cluster) tcp://buildkit.platform.svc.cluster.local:1234 (gRPC, ClusterIP)
Buildkitd UI n/a (daemon gRPC; sin HTTP UI)
Logs del runner en vivo kubectl -n platform logs -f deploy/gh-runner-docs-runtime -c runner
Argo apps gh-runner, buildkitd (argocd.wetopi.net, project platform)

⚠ NO expuesto a internet (ClusterIP-only, mismo invariante RC-5 que zot: NodePort prohibido en edge-WAN). El runner hace long-poll SALIENTE hacia GitHub (github.com/api.github.com); nadie entra a él.

Labels del runner: self-hosted, Linux, X64, wetopi → los jobs usan runs-on: [self-hosted, wetopi].

¿Por qué este montaje?

  • Self-hosted: builds con cache y red internas, push directo al registry interno; sin minutos facturados ni egress de imágenes.
  • buildkitd REMOTO (no dind, no driver docker-container): el pod del runner no tiene daemon docker (invariante no-dind/privileged/host-sockets, review 2026-09-13 — el workflow original era el bug latente #6). Patrón validado por el reviewer: buildkitd rootless pod + --driver remote.
  • Registration token, no PAT: auto-registro al arrancar con token efímero (~1h); ningún standing credential del operador en el cluster. Con .runner persistido en PVC, los reinicios no consumen tokens nuevos.

¿Cómo está desplegado?

Git-owned via Argo (decisión del operador 2026-09-13: "tanto zot como github-runner los quiero en argocd controlados"):

  • Manifiestos: platform-registry@main (kubernetes/runner/, kubernetes/buildkitd/).
  • Applications: platform-gitops/apps/gh-runner.yaml + apps/buildkitd.yaml (include-glob exacto por fichero, Ley 4; syncPolicy manual prune:false selfHeal:false — OutOfSync+Healthy es estado normal).
  • Secret apply-time: gh-runner-config (RUNNER_TOKEN) en ns platform. El token se CONSUME en el arranque (queda en .runner/.credentials en el PVC); el Secret es material de re-registro, no credencial permanente.

Estado verificado 2026-09-14: apps gh-runner + buildkitd Synced+Healthy; pods Running 0 restarts; runner online en GitHub con labels correctos; workflow real de CI completado (run 34794621593 → docs-runtime:sha-248d0002 en zot).

Componentes:

Objeto Detalle
Deployment gh-runner-docs-runtime ghcr.io/actions/actions-runner:2.337.0 (docker CLI + buildx + git; user runner uid 1001), Recreate
PVC gh-runner-home 5Gi ceph-rbd → /home/runner (dist + .runner/.credentials)
initContainer dist-seed siembra el dist oficial en el PVC (idempotente)
Secret gh-runner-config RUNNER_TOKEN (apply-time)
Deployment buildkitd moby/buildkit:v0.17.3-rootless, uid 1000, --oci-worker-no-process-sandbox, seccomp+AppArmor Unconfined, fsGroup pod 1000
PVC buildkit-cache 30Gi ceph-rbd → cache de capas
Service buildkit ClusterIP :1234
ConfigMap buildkitd-config buildkitd.toml: [registry."zot.platform.svc.cluster.local:5000"] http = true

Manifiestos (copiados verbatim de platform-registry@main, sin secretos)

# kubernetes/runner/deployment.yaml
# GitHub Actions self-hosted runner (repo platform-docs-runtime), siempre-on.
# Imagen oficial upstream (docker CLI + buildx + git, user runner uid 1001).
# Registro (review 2026-09-13, wetopi-reviewer): la imagen oficial NO lee env
# de registro (RUNNER_LABELS/GITHUB_URL/RUNNER_TOKEN es convención de imágenes
# de terceros) y args sueltos sustituirían el CMD (/bin/bash) → crash.
# Forma correcta: command bash -lc + config.sh --unattended (idempotente con
# .runner persistido en PVC) + exec run.sh.
# Labels: config.sh añade self-hosted+Linux+x64 automáticamente; --labels wetopi
# añade el custom que exige el workflow (runs-on: [self-hosted, wetopi]).
# --replace: re-admite el mismo nombre tras recreate del pod.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gh-runner-docs-runtime
  namespace: platform
  labels: { app: gh-runner, repo: platform-docs-runtime }
spec:
  replicas: 1
  strategy: { type: Recreate }   # RWO del PVC + registro exclusivo del runner
  selector:
    matchLabels: { app: gh-runner }
  template:
    metadata:
      labels: { app: gh-runner }
    spec:
      securityContext:
        fsGroup: 1001   # kubelet chown del PVC a runner (rbd CSI fsGroupPolicy)
      containers:
        - name: runner
          image: ghcr.io/actions/actions-runner:2.337.0   # multi-arch, user runner uid 1001
          command: ["bash", "-lc"]
          args:
            - |
              test -f .runner || ./config.sh --url https://github.com/wetopi-platform/platform-docs-runtime \
                --token "$RUNNER_TOKEN" --labels wetopi --unattended --replace
              exec ./run.sh
          env:
            - name: RUNNER_TOKEN   # NO "GITHUB_TOKEN": colisiona con el token auto-inyectado de Actions en los steps
              valueFrom:
                secretKeyRef: { name: gh-runner-config, key: RUNNER_TOKEN }
          volumeMounts:
            - { name: runner-home, mountPath: /home/runner }
          resources:
            requests: { cpu: 250m, memory: 512Mi }
            limits: { memory: 2Gi }
      volumes:
        - name: runner-home
          persistentVolumeClaim: { claimName: gh-runner-home }
      initContainers:
        # El PVC monta sobre /home/runner y OCULTA los binarios del image layer.
        # Siembra el dist oficial en el PVC una vez (idempotente: si ya hay
        # config.sh, no toca nada y preserva .runner/.credentials).
        - name: dist-seed
          image: ghcr.io/actions/actions-runner:2.337.0
          command: ["bash", "-lc"]
          args:
            - |
              if [ ! -e /data/config.sh ]; then
                cp -a /home/runner/. /data/
                echo "dist seeded to PVC"
              else
                echo "dist already present, preserving .runner state"
              fi
          volumeMounts:
            - { name: runner-home, mountPath: /data }
# kubernetes/runner/pvc.yaml
# Estado del runner (.runner/.credentials) en PVC: sobrevive reinicios del pod
# sin consumir un registration token nuevo (expira ~1h). RWO + Recreate.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: gh-runner-home
  namespace: platform
spec:
  storageClassName: ceph-rbd
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 5Gi
# kubernetes/buildkitd/deployment.yaml
# BuildKit daemon remoto (review 2026-09-13): el pod del runner NO tiene daemon
# docker ni dind (invariante: no privileged/dind/host sockets). El workflow usa
# docker buildx --driver remote tcp://buildkit.platform.svc:1234.
# Rootless (moby/buildkit-*-rootless, uid 1000): --oci-worker-no-process-sandbox
# evita el stack underflow del runtime rootless de buildkit (R2 reviewer).
apiVersion: apps/v1
kind: Deployment
metadata:
  name: buildkitd
  namespace: platform
  labels: { app: buildkitd }
spec:
  replicas: 1
  strategy: { type: Recreate }   # RWO del cache PVC
  selector:
    matchLabels: { app: buildkitd }
  template:
    metadata:
      labels: { app: buildkitd }
    spec:
      # fsGroup a nivel POD: kubelet chown/chmod del volumen RBD (root:root por
      # defecto) al grupo 1000 — sin esto buildkitd.lock da permission denied
      # (live 2026-09-13). El contenedor ya corre como runAsUser 1000.
      securityContext:
        fsGroup: 1000
        fsGroupChangePolicy: Always
      containers:
        - name: buildkitd
          image: moby/buildkit:v0.17.3-rootless
          args: ["--addr", "tcp://0.0.0.0:1234", "--oci-worker-no-process-sandbox"]
          ports: [{ containerPort: 1234, name: tcp }]
          securityContext:
            runAsUser: 1000
            runAsGroup: 1000
            seccompProfile: { type: Unconfined }
            # k8s >= 1.30 (cluster v1.36.3): el default AppArmor del runtime
            # bloquea los mount() de rootlesskit ("failed to share mount
            # point: /: permission denied") — verificado live 2026-09-13.
            appArmorProfile: { type: Unconfined }
          volumeMounts:
            - { name: config, mountPath: /home/user/.config/buildkit }   # buildkitd rootless busca ~/.config/buildkit/buildkitd.toml
            - { name: cache, mountPath: /home/user/.local/share/buildkit }
          readinessProbe:
            tcpSocket: { port: 1234 }
            periodSeconds: 5
          livenessProbe:
            tcpSocket: { port: 1234 }
            initialDelaySeconds: 10
            periodSeconds: 15
          resources:
            requests: { cpu: 100m, memory: 256Mi }
            limits: { memory: 2Gi }
      volumes:
        - name: config
          configMap: { name: buildkitd-config }
        - name: cache
          persistentVolumeClaim: { claimName: buildkit-cache }
# kubernetes/buildkitd/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: buildkit-cache
  namespace: platform
spec:
  storageClassName: ceph-rbd
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 30Gi
# kubernetes/buildkitd/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: buildkitd-config
  namespace: platform
data:
  buildkitd.toml: |
    # Zot es cluster-internal sobre HTTP (ClusterIP, sin TLS). buildx del
    # cliente reenvía credenciales al daemon; auth NO vive aquí.
    [registry."zot.platform.svc.cluster.local:5000"]
      http = true
# kubernetes/buildkitd/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: buildkit
  namespace: platform
spec:
  type: ClusterIP
  selector: { app: buildkitd }
  ports:
    - { name: tcp, port: 1234, targetPort: 1234 }

¿Cómo se opera?

Registro del runner (ciclo de vida del registration token):

# 1) token efímero (PAT org con admin del repo)
gh api -X POST repos/wetopi-platform/platform-docs-runtime/actions/runners/registration-token --jq .token
# 2) actualizar el Secret (por stdin, nunca argv de chat)
kubectl -n platform create secret generic gh-runner-config \
  --from-literal=RUNNER_TOKEN=$(cat /tmp/token) --dry-run=client -o yaml | kubectl apply -f -
# 3) recrear el pod (strategy Recreate)
kubectl -n platform delete pod -l app=gh-runner
# 4) verificar: pod Running + online en GitHub con labels correctos
gh api repos/wetopi-platform/platform-docs-runtime/actions/runners

Con .runner en el PVC, los restarts normales NO consumen token (el pod ve .runner y va directo a run.sh). Token nuevo solo en (re)configuración: cambio de repo/labels, PVC nuevo, o --replace tras borrar el runner en GitHub. El token EXPIRA (~1h): 404 en el registro = token muerto, pedir fresco, sin retry-loop.

Sync de las Argo apps (manual-sync):

kubectl -n argocd patch application gh-runner --type merge \
  -p '{"operation":{"sync":{"revisions":["main"]}}}'
kubectl -n argocd patch application buildkitd --type merge \
  -p '{"operation":{"sync":{"revisions":["main"]}}}'

Cómo dispara builds: push a main de platform-docs-runtime tocando kubernetes/Dockerfile, docs-requirements.txt o .dockerignore, o manual (gh workflow run build-docs-runtime.yml --ref main). El workflow corre con runs-on: [self-hosted, wetopi], usa el builder remoto (docker buildx create --driver remote tcp://buildkit.platform.svc:1234 --name wetopi-builder --use), escribe las creds zot en ~/.docker/config.json (buildx las reenvía al daemon) y pushea con --push --metadata-file; el digest queda en $GITHUB_STEP_SUMMARY para el digest-pin del manifest.

Ver un build en vivo: kubectl -n platform logs -f deploy/gh-runner-docs-runtime -c runner (listener + steps) o gh run view <id> --log-failed.

¿Cómo se rompe y se arregla?

  • 404 en el registro (POST api.github.com/actions/runner-registration): token EXPIRADO (~1h). Token fresco + delete pod. NO retry-loop.
  • "Not configured. Run config.sh": el config falló antes — leer el log COMPLETO del pod (el error de config aparece antes de la línea de run.sh; un script sin set -e deja continuar y confunde el diagnóstico).
  • .runner dañado o repo/labels nuevos: borrar .runner* del PVC (pod efímero con el mismo mount: rm /data/.runner*) + token fresco → config.sh reconfigura. NO borrar el PVC entero (el dist se conserva).
  • Upgrade de versión del runner: el dist viejo del PVC PERSISTE (seed idempotente no sobreescribe) → al subir versión: limpiar el PVC o versionar (gh-runner-home-v2). Pendiente decidir patrón definitivo.
  • buildkitd CrashLoop failed to share mount point: /: AppArmor default del runtime bloquea mount() de rootlesskit → appArmorProfile: Unconfined a nivel container (k8s ≥1.30; cluster v1.36.3; seccomp Unconfined NO basta).
  • buildkitd CrashLoop buildkitd.lock: permission denied: PVC RBD llega root:root → fsGroup: 1000 a nivel POD + fsGroupChangePolicy: Always.
  • Workflow falla requires 1 argument: buildx acepta UN solo contexto; no dejar dos posicionales.
  • imagetools inspect muere contra zot ("server gave HTTP response to HTTPS client"): el digest se lee de --metadata-file (containerimage.digest), no de la CLI del cliente (ya corregido en el workflow).
  • Argo OutOfSync (gh-runner/buildkitd): normal en manual-sync; sync con operation-patch, nunca re-apply del Application.
  • Egress del nodo: github.com, api.github.com, ghcr.io, registry-1.docker.io (verificado 2026-09-14 OK; el pull de bases docker.io funciona).

¿Qué depende de él?

  • SVC-DOCS-RUNTIME: su imagen se construye aquí; digest pinneado en platform-docs-runtime@main servido desde zot.
  • SVC-REGISTRY: destino de los pushes.
  • Futuros repos con runs-on: [self-hosted, wetopi]: hoy el runner pertenece al repo platform-docs-runtime; un segundo repo = otro Deployment con el registration token de ese repo.

Observabilidad

  • Runner online + labels: gh api repos/wetopi-platform/platform-docs-runtime/actions/runners.
  • Pods/apps: kubectl -n platform get pods (buildkitd, gh-runner, zot) + estado de las 3 apps en argocd.
  • Contrato del builder: readiness tcpSocket:1234 — pod Ready = daemon escuchando; si Ready=false estable, revisar logs (mount/lock/apparmor).
  • Sin dashboards propios aún (candidato: métricas de builds en SVC-OBSERVABILITY).