Saltar a contenido

El motor de integración continua

El motor de integración continua (internal/pipeline, dirigido por internal/platform) convierte una confirmación en imágenes probadas. Tiene tres partes: un planeador que convierte una especificación en un grafo de pasos, un corredor que ejecuta el grafo con un límite de pasos simultáneos, y un ejecutor que corre cada paso como un pod de Kubernetes.

flowchart LR
    spec[rendimiento.yaml] --> plan[Plan]
    plan --> steps[[pasos: grafo dirigido]]
    steps --> runner[Runner<br/>≤ MAX_PARALLEL_STEPS]
    runner --> exec[KubeExecutor]
    exec --> pod1[pod: api:test]
    exec --> pod2[pod: api:build]
    exec --> pod3[pod: web:build]
    pod2 & pod3 --> bk[grupo de BuildKit] --> reg[(registro)]

Planear: Plan

pipeline.Plan(app, registry, spec) devuelve los pasos de una ejecución:

  • por cada servicio construido desde el repositorio (no una image: ya hecha): un paso build, y antes un paso test si el servicio tiene test:. La construcción depende de las pruebas;
  • por cada tarea programada con su propio path: un paso de construcción (job-<name>:build);
  • por cada tarea: un paso task (<name>:task) que depende de las construcciones y tareas de su after: (Tareas);
  • los pasos de servicios distintos son independientes, así que corren en paralelo.

Cada Step lleva lo que su pod necesita: la carpeta, la imagen y el comando de pruebas, o el nombre de la imagen a enviar (registry/<app>-<service>), el Dockerfile, el constructor elegido (dockerfile, railpack o automático), el comando de arranque de Railpack y los argumentos de construcción.

Ejecutar el grafo: Runner

Runner.Run arranca una gorrutina por paso. Cada una espera a los pasos de los que depende (un canal cerrado por paso avisa "terminado"), luego toma un lugar de un semáforo, un canal con búfer de tamaño MAX_PARALLEL_STEPS (4) compartido por todas las ejecuciones, así el clúster nunca corre más pasos que esos a la vez. Si una dependencia no tuvo éxito, el paso se marca como omitido sin ejecutarse.

// Runner executes run DAGs with a global cap on concurrent steps, because
// builds on Raspberry Pi nodes are CPU and memory bound.
type Runner struct {
    Exec     Executor
    Recorder Recorder
    sem      chan struct{}
}

// NewRunner returns a Runner that runs at most maxParallel steps at a time across all runs.
func NewRunner(exec Executor, rec Recorder, maxParallel int) *Runner {
    if maxParallel < 1 {
        maxParallel = 1
    }
    return &Runner{Exec: exec, Recorder: rec, sem: make(chan struct{}, maxParallel)}
}

El corredor no sabe nada de Kubernetes ni de Postgres: habla con un Executor (ejecuta un paso) y un Recorder (guarda el estado y los registros). En producción son el KubeExecutor y la plataforma; en las pruebas son imitaciones que anotan el orden de ejecución. Eso permite que run_test.go compruebe los límites de simultaneidad y las omisiones en milisegundos.

Ejecutar un paso: el pod de construcción

KubeExecutor.Execute corre cada paso como un pod en rendimiento-builds:

flowchart LR
    subgraph pod[pod run-42-api-build-x7k2p]
        direction LR
        clone[inicio: clonar<br/>alpine/git<br/>descarga superficial de la confirmación] --> plan[inicio: planear<br/>CLI de railpack<br/>¿Dockerfile o Railpack?]
        plan --> step[paso<br/>cliente moby/buildkit<br/>buildctl build … push]
    end
    secret[(Secret run-42-…<br/>token de GitHub, 1 hora)] -.-> clone
    ws[(emptyDir /workspace)] --- clone & plan & step
    step -- tcp :1234 --> bk[demonio de BuildKit]
    step -- mensaje de terminación --> digest[huella sha256]
  1. Se crea para el pod un Secret con un token de instalación nuevo (válido una hora); el contenedor de clonado lo lee.
  2. clone (contenedor de inicio): git fetch --depth 1 de exactamente la confirmación en /workspace/src.
  3. plan (contenedor de inicio, solo en pasos de construcción): decide el constructor y, para Railpack, escribe su plan de construcción.
  4. step: en unas pruebas, la imagen de pruebas del servicio corre el comando de pruebas en la carpeta del servicio; en una tarea, la imagen de la tarea corre su comando con su entorno y sus secretos (copiados al secreto propio del pod y ocultos en el registro); en una construcción, buildctl manda la construcción a BuildKit, que envía la imagen. La huella de la imagen se escribe en /dev/termination-log y el ejecutor la lee del estado del pod: sin analizar registros.
  5. Los registros de cada contenedor se van pasando al registro del paso conforme ocurren.
  6. El pod y el secreto se borran, pase lo que pase.

Los guiones, tal como se ejecutan:

// planScript picks the builder: the Dockerfile when the folder has one (or
// it is required), Railpack otherwise, which then writes its build plan.
// Extra arguments ("--env K=V" pairs) go to railpack prepare.
const planScript = `set -eu
mkdir -p /workspace/plan && cd "$CONTEXT"
if [ "$BUILDER" = dockerfile ] || { [ -z "$BUILDER" ] && [ -f "$DOCKERFILE" ]; }; then
  echo "builder: Dockerfile ($DOCKERFILE)"
  echo dockerfile > /workspace/plan/builder
  exit 0
fi
if [ -z "$BUILDER" ]; then
  echo "builder: Railpack (no $DOCKERFILE in this folder; add one or set build.builder to take control)"
else
  echo "builder: Railpack"
fi
set -- prepare "$CONTEXT" --plan-out /workspace/plan/railpack-plan.json --error-missing-start "$@"
if [ -n "$START" ]; then set -- "$@" --start-cmd "$START"; fi
railpack "$@"
echo railpack > /workspace/plan/builder
`

const cloneScript = `set -eu
mkdir -p /workspace/src && cd /workspace/src
git init -q
git remote add origin "$CLONE_URL"
if [ -n "${GIT_TOKEN:-}" ]; then
  git config http.extraHeader "Authorization: basic $(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 | tr -d '\n')"
fi
git fetch -q --depth 1 origin "$REF"
git checkout -q FETCH_HEAD
echo "checked out $(git rev-parse HEAD)"
`

// The digest is written to the termination log so the executor can read it
// from the pod status without parsing logs.
const buildScript = `set -eu
if [ "$(cat /workspace/plan/builder 2>/dev/null || echo dockerfile)" = railpack ]; then
  set -- --frontend gateway.v0 --opt source="$RAILPACK_FRONTEND" \
    --local context="$CONTEXT" --local dockerfile=/workspace/plan
else
  set -- --frontend dockerfile.v0 --local context="$CONTEXT" --local dockerfile="$CONTEXT" \
    --opt filename="$DOCKERFILE" --opt build-arg:GIT_SHA="$GIT_SHA" "$@"
fi
buildctl --addr "$BUILDKIT_ADDR" build "$@" \
  --output "type=image,name=$IMAGE:$TAG,push=true$INSECURE" \
  --import-cache "type=registry,ref=$IMAGE:buildcache$INSECURE" \
  --export-cache "type=registry,ref=$IMAGE:buildcache,mode=max$INSECURE" \
  --metadata-file /tmp/metadata.json
grep -o '"containerimage.digest": *"sha256:[a-f0-9]*"' /tmp/metadata.json | grep -o 'sha256:[a-f0-9]*' > /dev/termination-log
`

Barandales de los pods de construcción

  • Sin credenciales de Kubernetes (automountServiceAccountToken: false).
  • Aislamiento de red (NetworkPolicy isolate-builds): solo DNS, BuildKit en el puerto 1234 e internet público; ni otras aplicaciones, ni bases de datos, ni la API de Kubernetes, ni la red doméstica. El código de un repositorio (unas pruebas, una línea RUN) no puede alcanzar nada más.
  • Exclusión de nodos (BUILD_EXCLUDE_NODES): aquí se enumeran los nodos que no pueden aplicar políticas de red o que están reservados para otro trabajo. El plano de control queda excluido por su marca (taint).
  • Un plazo (STEP_TIMEOUT, 45 minutos) en cada pod.
  • Reintentos de errores pasajeros de la API (transientRetry): crear el secreto o el pod se reintenta ante "database is locked", errores 5xx y tiempos agotados, que ocurren cuando el plano de control está ocupado.
  • Sufijos aleatorios en los nombres de los pods, así una ejecución reintentada nunca choca con los pods que dejó su antecesora interrumpida.

Detección de cambios: construir solo lo que cambió

En un envío a la rama principal, Platform.changes compara la confirmación nueva con la confirmación de la última versión (la API de comparación de GitHub). Con esa lista de archivos, reusable conserva la imagen de cada servicio cuya carpeta (y rutas de watch:) no cambió; sus pasos muestran reutilizada. skippedTasks omite tareas de la misma manera, y además omite las tareas when: deploy en otras ramas. Cualquier duda significa una construcción completa:

  • ejecuciones manuales y construcciones de otras ramas;
  • no hay versión anterior, o no fue de una confirmación completa;
  • una diferencia truncada (GitHub enumera como máximo 300 archivos);
  • un cambio al propio rendimiento.yaml;
  • un servicio en la raíz del repositorio (cualquier cambio lo toca).

Alrededor de una ejecución

  • Comprobaciones: startCheck crea una comprobación de GitHub "en curso" en la confirmación, con una liga a la ejecución; finishCheck la completa con éxito, fallo o cancelación.
  • Registros y actualizaciones en vivo: el registrador de la plataforma (platform/recorder.go) acumula la salida de cada paso, la agrega a la fila del paso en Postgres (conservando el último 1 MB, donde están los errores) y la publica en el concentrador de eventos, que la pasa a los navegadores abiertos con eventos enviados por el servidor.
  • Cancelar: Platform.Cancel cancela el contexto de la ejecución; los pods en marcha se borran y los pasos en espera quedan omitidos.
  • Reinicios: las ejecuciones que un proceso muerto dejó en running vuelven a la cola al arrancar (RequeueOrphans, como máximo dos veces), y se borran los pods sobrantes (KubeExecutor.Cleanup).
  • Versiones: cuando cada paso obligatorio de una ejecución de despliegue tuvo éxito (una tarea optional fallida no cuenta), platform.release registra las imágenes por huella (las nuevas, las reutilizadas y los servicios con image: ya hecha, tal como vienen) y actualiza el objeto App. Ahí pasa la estafeta al controlador.

Dónde cambiar qué

Para… Cambie
agregar un tipo nuevo de paso Kind y Plan en plan.go, el contenedor en KubeExecutor.pod, el campo de la especificación en internal/spec; los pasos task (task.go) son el ejemplo más reciente
correr más pasos a la vez MAX_PARALLEL_STEPS (cuide la memoria de las Pi)
cambiar lo que puede alcanzar un pod de construcción deploy/networkpolicy.yaml
aceptar un registro o una autenticación nuevos las opciones --output y de caché en buildScript, y las credenciales como un secreto montado en el contenedor del paso