Image Builds (BuildKit)
Por que o build roda dentro do cluster, e como o BuildKit remoto builda multi-arch sem QEMU
Por que não é docker build num runner de CI
docker build clássico usa o daemon Docker local para processar as instruções do Dockerfile sequencialmente, numa arquitetura só — a do host que está rodando o build. Para produzir uma imagem que roda em amd64 e arm64 (necessário aqui — finatto-k3s-worker-01 é arm64, ver orchestration-fundamentals), a alternativa mais comum em CI hospedado é emulação via QEMU: um runner amd64 finge ser arm64, instrução por instrução, e isso é lento — a ordem de 50 minutos por build de release, medido neste projeto antes da mudança.
BuildKit e builders nativos por arquitetura
BuildKit é o motor de build usado por trás de docker build desde as versões recentes do Docker, mas pode rodar como serviço standalone (buildkitd), separado do daemon Docker, e aceitar conexões remotas. Diferente do docker build clássico, BuildKit compila o Dockerfile para um grafo de baixo nível chamado LLB (Low-Level Build definition) — uma representação intermediária que permite paralelizar estágios independentes e, principalmente aqui, direcionar cada estágio para um builder diferente.
Esse cluster roda dois buildkitd, um por arquitetura, no namespace buildkit (cluster/buildkit/buildkit.yaml: buildkitd-amd64 no node amd64, buildkitd-arm64 no worker arm64). Build nativo — sem emulação — em cada um.
# scripts/app-build.sh
BUILDKIT_HOST="kube-pod://$pod?namespace=buildkit" buildctl build \
--frontend dockerfile.v0 \
--local context="$work/src" \
--opt build-arg:PHP_VERSION="$PHP_VERSION" \
--output "type=image,name=$IMAGE:$TAG-$arch,push=true" \
--export-cache "type=registry,ref=$IMAGE:cache-$arch,mode=max" \
--import-cache "type=registry,ref=$IMAGE:cache-$arch"
kube-pod:// é o transporte: buildctl (cliente) fala com um buildkitd remoto através de um pod específico do cluster via kubectl exec-like tunneling, sem precisar expor o daemon numa porta de rede. --export-cache/--import-cache gravam e leem camadas de build de um registro remoto (<imagem>:cache-<arch>) — builds subsequentes reusam camada que não mudou, mesmo rodando num builder diferente ou depois de o pod anterior ter sido reciclado (cada buildkitd faz garbage collection ao atingir 10GB local, então o cache em registry é o que sobrevive entre limpezas).
Merge do manifest multi-arch
Depois de buildkitd-amd64 e buildkitd-arm64 publicarem <imagem>:<tag>-amd64 e <imagem>:<tag>-arm64 como imagens separadas, um terceiro passo funde as duas num manifest list — um único <imagem>:<tag> que aponta para as duas variantes, com o runtime de container escolhendo a certa na hora de pull de acordo com a arquitetura do node. app-build.sh faz isso com regctl, crane ou docker buildx imagetools, o que estiver disponível:
regctl index create "$IMAGE:$TAG" \
--ref "$IMAGE:$TAG-amd64" --ref "$IMAGE:$TAG-arm64"
Sem esse passo, $IMAGE:$TAG sozinho não existiria — só as duas tags por arquitetura, e cada cliente teria que saber pedir a variante certa manualmente. É esse manifest list combinado que apps/<nome>.yaml referencia em image.tag.
Por que dentro do cluster, e não como parte da CI de cada app
O cluster já tem hardware arm64 pago e ocioso (finatto-k3s-worker-01). Rodar o build ali, nativo, elimina a emulação — o motivo direto da queda de ~50 minutos para poucos minutos por release. A arquitetura mista do cluster é uma propriedade da infraestrutura, não de nenhuma aplicação específica: manter o build aqui significa que nenhum repositório de aplicação (license-manager, checklist-mobile) precisa saber que existe um node arm64 em outro datacenter — eles só declaram source.repository e build.extensions em apps/<nome>.yaml, e a plataforma resolve o resto. O CI de cada aplicação continua sendo o gate de qualidade (lint, testes) — app-build.sh consulta os checks do commit via gh antes de buildar e recusa buildar um commit com CI vermelho, a menos que rode com SKIP_CI_CHECK=1.