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
.runnerpersistido 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 manualprune: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/.credentialsen 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 -edeja continuar y confunde el diagnóstico). .runnerdañ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: Unconfineda 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: 1000a 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).