Três aplicações, cada uma com três ou quatro unidades implantáveis. Dois
anéis — preview e live. Mais de um cluster Kubernetes, operados por times
diferentes. A primeira versão dessa entrega foi o que sempre é: um
.gitlab-ci.yml copiado do projeto anterior, editado até ficar verde, e nunca
igual duas vezes. Um ano depois, dois pipelines não concordavam sobre o que
uma branch significava, e uma correção em um nunca chegava aos outros.
O que segue é a arquitetura que substituiu isso. Nomes, caminhos e números são ilustrativos; a forma é o que importa.
A forma
Há três tipos de repositório e só dois pipelines:
| Repositório | Guarda | Seu pipeline |
|---|---|---|
platform/ci-blueprints | A biblioteca: uma pasta por app, cinco arquivos YAML cada | Nenhum. Ele só é included |
orders, billing, … | O código da aplicação e um .gitlab-ci.yml de uma dúzia de linhas | Assa imagens, libera, passa o bastão ao pipeline da frota, alerta |
platform/fleet | Manifests do Kubernetes, <cluster>/<app>/<anel>/ | Só roda quando disparado. Aplica, troca imagens, espera o rollout |
Nada no repositório de uma aplicação sabe chegar a um cluster. Nada no repositório da frota sabe assar uma imagem. A passagem de bastão entre os dois é uma única variável JSON, e essa fronteira é o que mantém os dois lados simples.
O consumidor: uma dúzia de linhas
# orders/.gitlab-ci.yml — the whole file
include:
- project: platform/ci-blueprints
ref: v2.3.0 # a tag, never main
file:
- blueprints/orders/routine.yml
- blueprints/orders/images.yml
- blueprints/orders/release.yml
- blueprints/orders/delivery.yml
- blueprints/orders/alerts.yml
variables:
SHIP_WORKER: "true" # units opt in one at a timeO ref é uma tag. main serve para experimentar uma mudança em uma app antes
de cortar a v2.4.0 e migrar as outras. A lista de arquivos é explícita de
propósito: os nomes são um contrato. Adicionar um sexto arquivo significa
que cada consumidor acrescenta uma linha; renomear um significa que cada
consumidor quebra — então arquivos são adicionados, nunca renomeados.
As flags SHIP_* são como uma unidade nova, ou uma versão nova da biblioteca,
entra uma peça de cada vez. Uma unidade com a flag desligada não tem job
nenhum, não um job pulado.
De cinco arquivos a um pipeline
routine.yml: o que uma branch significa
# blueprints/orders/routine.yml — what a branch means, for every consumer
stages: [images, release, delivery, alerts]
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "candidate"'
variables: { RING: preview }
- if: '$CI_COMMIT_BRANCH == "main"'
variables: { RING: live }
- when: never # anything else creates no pipeline
# Infrastructure retries only. script_failure is deliberately
# absent: a real defect would run three times and look flaky.
default:
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
- api_failure
- scheduler_failure
# Shell library, pulled into jobs with `!reference [.lib, shell]`.
.lib:
shell: |
# probe: 0 = present · 1 = absent · 2 = unknown (caller must stop)
probe() { # $1=namespace $2=kind $3=name
for n in 1 2 3; do
out=$(kubectl -n "$1" get "$2" "$3" -o name 2>&1 >/dev/null) \
&& return 0
case "$out" in *NotFound*) return 1 ;; esac
sleep $((n * 5))
done
echo "unknown: $2/$3 in $1" >&2; return 2
}
attempt() {
for n in 1 2 3; do "$@" && return 0; sleep $((n * 5)); done
return 1
}Três decisões moram aqui e em nenhum outro lugar. Uma branch mapeia para
exatamente um anel, e uma branch que não mapeia para nada não cria pipeline
— o when: never no fim do bloco workflow: não é decoração. Retries
cobrem infraestrutura, não código: acrescentar script_failure reexecutaria
um bake genuinamente quebrado três vezes e esconderia o defeito atrás de um
selo verde na quarta. E a biblioteca de shell é compartilhada com
!reference, então o retry com backoff e a sonda de três estados são
escritos uma vez. Esse terceiro estado importa: uma oscilação de rede durante
um kubectl get era indistinguível de “ausente”, e o job seguia com a
premissa errada. Não conseguir saber é uma resposta em si, e quem chama para
nela.
images.yml: um job oculto, um job concreto por unidade
# blueprints/orders/images.yml — bake one image per unit
.bake:
stage: images
image: docker:27
services: [docker:27-dind]
rules:
- if: '$RING == "preview"' # the live ring never bakes
variables:
IMAGE: $CI_REGISTRY_IMAGE/$UNIT
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" \
--password-stdin "$CI_REGISTRY"
- docker build -t "$IMAGE:$CI_COMMIT_SHA" \
--build-arg RING="$RING" \
-f "$SRC/Dockerfile" "$SRC"
- docker push "$IMAGE:$CI_COMMIT_SHA"
- docker tag "$IMAGE:$CI_COMMIT_SHA" "$IMAGE:$RING"
- docker push "$IMAGE:$RING"
bake_api:
extends: .bake
variables: { UNIT: api, SRC: services/api }
bake_web:
extends: .bake
variables: { UNIT: web, SRC: web }
bake_worker:
extends: .bake
variables: { UNIT: worker, SRC: services/worker }
rules:
- if: '$RING == "preview" && $SHIP_WORKER == "true"'O job oculto faz o trabalho; cada job concreto são duas variáveis. Rodam em
paralelo, já que nada em bake_web depende de bake_api. Toda imagem é
publicada duas vezes: como :<sha>, que é o que será implantado e nunca se
move, e como :preview, um ponteiro flutuante para “o que o anel preview roda
agora” que o passo de release vai ler.
delivery.yml: a passagem de bastão
# blueprints/orders/delivery.yml — hand the rollout set to the fleet
.deliver:
stage: delivery
rules:
- if: '$RING =~ /^(preview|live)$/'
trigger:
project: platform/fleet
strategy: depend # wait for the child; inherit its result
forward: { pipeline_variables: true }
variables:
APP: orders
RING: $RING
ORIGIN_SHA: $CI_COMMIT_SHA
ROLLOUT_SET: |
[
{"unit":"api", "container":"api", "image":"$API_IMAGE"},
{"unit":"web", "container":"web", "image":"$WEB_IMAGE"},
{"unit":"worker", "container":"worker", "image":"$WORKER_IMAGE"}
]
deliver_north: { extends: .deliver, variables: { CLUSTER: north } }
deliver_south: { extends: .deliver, variables: { CLUSTER: south } }strategy: depend faz o pai esperar o filho e falhar se ele falhar — o selo
do pipeline da aplicação diz a verdade sobre o rollout, não só sobre o bake.
Um job de entrega por cluster é toda a história de multi-cluster: mesmo
repositório de frota, mesmas variáveis, CLUSTER diferente.
ROLLOUT_SET é o contrato através da fronteira: qual unidade, qual nome de
container dentro do pod, qual imagem. O lado da frota não precisa de mais
nada para atualizar um deployment.
Live é release, não rebuild
# blueprints/orders/release.yml
# On main it re-tags preview as live; on candidate it only resolves digests.
release:
stage: release
image: gcr.io/go-containerregistry/crane:debug
script:
- |
for unit in api web worker; do
img="$CI_REGISTRY_IMAGE/$unit"
if [ "$RING" = "live" ]; then
digest=$(crane digest "$img:preview") # what preview runs now
crane tag "$img@$digest" live # same bytes, new name
else
digest=$(crane digest "$img:$CI_COMMIT_SHA")
fi
key=$(echo "$unit" | tr a-z A-Z)
echo "${key}_IMAGE=$img@$digest" >> images.env
done
artifacts:
reports: { dotenv: images.env } # *_IMAGE reach the delivery jobsA branch main pula o estágio de bake inteiro. Reconstruir do mesmo commit
provavelmente produziria a mesma imagem; liberar por digest produz a mesma
imagem por definição. O relatório dotenv é como os digests chegam aos jobs de
entrega — o GitLab expande $API_IMAGE dentro do ROLLOUT_SET antes de
entregar a variável ao pipeline filho.
O pipeline da frota
Os manifests vivem por cluster, por app, por anel, com uma pasta common/
para o ConfigMap e o Secret que toda unidade lê. Os namespaces seguem uma
convenção, <app>-<anel>, então ninguém precisa declará-los:
platform/fleet/
└── north/
└── orders/
├── common/
│ ├── preview/ configmap.yaml secret.yaml
│ └── live/
├── preview/ api.yaml web.yaml worker.yaml
└── live/
# platform/fleet/.gitlab-ci.yml — the only pipeline that touches a cluster
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "pipeline"' # an app pipeline fired it
- if: '$CI_PIPELINE_SOURCE == "web"' # or someone pressed Run
- when: never
rollout:
stage: rollout
image: bitnami/kubectl:1.31
resource_group: $CLUSTER/$APP/$RING # one rollout at a time per target
script:
- |
set -euo pipefail
# one file-type variable per cluster: KUBECONFIG_NORTH, ...
eval "export KUBECONFIG=\$KUBECONFIG_$(echo "$CLUSTER" | tr a-z A-Z)"
NS="$APP-$RING" # namespaces follow one convention
DIR="$CLUSTER/$APP/$RING"
kubectl -n "$NS" apply -f "$CLUSTER/$APP/common/$RING/"
kubectl -n "$NS" apply -f "$DIR/"
echo "$ROLLOUT_SET" \
| jq -c '.[] | select(.image | test("@sha256"))' \
| while read -r entry; do
unit=$(jq -r .unit <<<"$entry")
container=$(jq -r .container <<<"$entry")
image=$(jq -r .image <<<"$entry")
kubectl -n "$NS" set image deployment \
-l "app=$APP,unit=$unit" "$container=$image"
done
for d in $(kubectl -n "$NS" get deploy -l "app=$APP" -o name); do
kubectl -n "$NS" rollout status "$d" --timeout=10m || {
kubectl -n "$NS" describe "$d"
kubectl -n "$NS" logs "$d" --all-containers --tail=100 || true
exit 1
}
doneQuatro coisas fazem o trabalho de verdade aqui.
Só roda quando disparado. Um push no repositório da frota muda arquivos e nada mais; o cluster muda quando um pipeline de aplicação manda, com um conjunto de rollout anexado.
apply e set image não brigam. O manifest versionado diz
image: …/api:preview; o objeto vivo termina com …/api@sha256:…. O próximo
apply parece que vai reverter isso — não reverte. O apply client-side só
inclui no patch os campos que mudaram entre o último manifest aplicado e o
novo, e a linha da imagem é idêntica nos dois, então o digest definido pelo
pipeline sobrevive. Quebre essa premissa editando a tag no manifest e você
ganha um segundo rollout por entrega.
resource_group serializa por alvo. Dois pipelines para a mesma app e
anel entram em fila em vez de disputar. A chave do grupo inclui a app para que
orders e billing no mesmo cluster ainda façam rollout em paralelo.
Um rollout que falha se explica. describe e as últimas cem linhas de
log vão para a saída do job antes de ele sair vermelho — quem abre o job às
duas da manhã não deveria precisar de acesso ao cluster para ver o porquê.
Avisando alguém
# blueprints/orders/alerts.yml
alert_failure:
stage: alerts
image: alpine:3.20
rules:
- when: on_failure
script:
- apk add --no-cache curl jq >/dev/null
- |
[ -n "${SLACK_WEBHOOK_URL:-}" ] \
|| { echo "no SLACK_WEBHOOK_URL; skipping"; exit 0; }
title=":red_circle: orders — failed on $CI_COMMIT_REF_NAME"
msg=$(printf '%s' "$CI_COMMIT_MESSAGE" | head -c 300)
PAYLOAD=$(jq -n --arg title "$title ($RING)" \
--arg who "${GITLAB_USER_NAME:-?}" --arg sha "$CI_COMMIT_SHORT_SHA" \
--arg msg "$msg" --arg url "$CI_PIPELINE_URL" '{
blocks: [
{ type: "header",
text: { type: "plain_text", text: $title } },
{ type: "section", fields: [
{ type: "mrkdwn", text: ("*Author*\n" + $who) },
{ type: "mrkdwn", text: ("*Commit*\n`" + $sha + "`") } ] },
{ type: "section",
text: { type: "mrkdwn", text: $msg } },
{ type: "actions", elements: [
{ type: "button", url: $url,
text: { type: "plain_text", text: "Open pipeline" } } ] }
] }')
curl -sS -o /dev/null -w "slack %{http_code}\n" -X POST \
-H 'Content-type: application/json' \
-d "$PAYLOAD" "$SLACK_WEBHOOK_URL"Na falha, sempre: branch, anel, autor, commit, um botão. No sucesso, uma linha do job de rollout com o digest que entrou no ar:
# end of the fleet rollout job — success only; a failure exited above
digest=$(kubectl -n "$NS" get deploy -l "app=$APP,unit=api" \
-o jsonpath='{.items[0].spec.template.spec.containers[0].image}' \
| sed 's/.*@sha256://' | head -c 12)
jq -n --arg t ":large_green_circle: orders → $RING on $CLUSTER" \
--arg d "api @ $digest…" --arg u "$CI_JOB_URL" \
'{blocks:[{type:"section",text:{type:"mrkdwn",
text:("*"+$t+"*\n"+$d+" <"+$u+"|job>")}}]}' \
| curl -sS -o /dev/null -X POST -H 'Content-type: application/json' \
-d @- "$SLACK_WEBHOOK_URL" || trueA URL do webhook é uma variável de CI/CD mascarada e protegida no projeto consumidor; um projeto sem ela apenas registra que pulou o alerta.
Onde mora cada configuração
| Configuração | Onde | Por que ali |
|---|---|---|
| Branch → anel | routine.yml na biblioteca | Uma verdade para todas as apps |
| Credenciais do registry | As CI_REGISTRY_* do próprio GitLab | Nunca digitadas em lugar nenhum |
| Kubeconfig por cluster | Variável do tipo arquivo em platform/fleet, protegida | Só o pipeline da frota alcança um cluster |
SLACK_WEBHOOK_URL | Variável mascarada em cada consumidor | Cada app é dona do seu canal |
SHIP_<UNIDADE> | variables: no arquivo do consumidor | Visível no diff que liga a unidade |
| Versão da biblioteca | ref: no include do consumidor | Atualização é um commit revisado, não uma surpresa |
O que nos mordeu, para não morder você
| Sintoma | Causa | Regra |
|---|---|---|
| Rollout verde, imagem inalterada | Unidade nova adicionada em images.yml e no ROLLOUT_SET, mas o manifest não tem o label unit:, então set image -l não achou nada | Adicionar uma unidade toca três lugares: job de bake, entrada no conjunto, label no manifest |
| Cinco unidades fazendo rollout uma após a outra | resource_group sem a app na chave | A chave nomeia exatamente o que não pode se sobrepor |
| Comando falhou, sem erro, job continua | OUT=$(cmd 2>&1) sob set -e: a atribuição engole o status de saída | Use o comando como condição de um if, capture a saída dentro |
| Bake instável “consertado” pelo retry | script_failure em default.retry | Retry para infraestrutura, nunca para código |
| Um passo pulado porque um Deployment “estava ausente” — não estava | Oscilação de rede indistinguível de NotFound | Três estados, parar no terceiro |
Clone de um repo de 30 MB para rodar kubectl apply | GIT_STRATEGY padrão num job que nunca lê a árvore | GIT_STRATEGY: none quando a árvore de trabalho não é necessária |
Nada disso é GitLab exótico. É include, jobs ocultos, regras de workflow:,
um trigger multi-projeto e uma variável JSON — arranjados para que adicionar a
quarta app seja uma pasta na biblioteca e uma dúzia de linhas na app, e para
que o anel live só rode bytes que o preview já rodou.