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

ci-blueprintsblueprints/<app>/*.ymluma biblioteca: não roda pipeline próprioorders.gitlab-ci.ymlbilling.gitlab-ci.ymlcatalog.gitlab-ci.ymlinclude · ref: v2.3.0repositórios de aplicação — cada pipeline é um include finopipeline do orders (push em candidate / main)imagesreleasedeliveryalertspushregistry de imagensorders/api:<sha>docker push :sha, :ringpipeline do fleet (só roda quando disparado)applyset imagerollout statustriggerROLLOUT_SET · strategy: dependnorthcluster · namespace por anelsouthcluster · namespace por anelkubectl -npullTracejado: uma referência resolvida na criação do pipeline, não uma execução. Azul: a passagem de bastão entre os dois pipelines.
Três tipos de repositório, dois pipelines. Os repositórios de aplicação incluem a biblioteca de blueprints; o pipeline deles assa as imagens e entrega um conjunto de rollout ao pipeline do fleet, que é o único que fala com os clusters.

Há três tipos de repositório e só dois pipelines:

RepositórioGuardaSeu pipeline
platform/ci-blueprintsA biblioteca: uma pasta por app, cinco arquivos YAML cadaNenhum. Ele só é included
orders, billing, …O código da aplicação e um .gitlab-ci.yml de uma dúzia de linhasAssa imagens, libera, passa o bastão ao pipeline da frota, alerta
platform/fleetManifests 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 raw
# 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 time

O 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

a biblioteca (blueprints/orders/)routine.ymlstages · branch→anel · retryimages.ymljob oculto .bakerelease.ymldigest, re-tag na maindelivery.ymljob oculto .deliveralerts.ymlalerta falha / sucessoo consumidororders/.gitlab-ci.ymlinclude:project: ci-blueprintsref: v2.3.0file:- routine.yml- images.yml- release.yml- delivery.yml- alerts.ymlvariables:SHIP_WORKER: "true"quatro linhas de include,um bloco de variáveis —nada mais.includeo pipeline que se materializaimages · release · delivery · alertscandidate → RING=previewmain → RING=livebake_apiUNIT=apibake_webUNIT=webbake_workerUNIT=workerparalelos; worker só com SHIP_WORKERreleasecrane · dotenvum job, todas as unidadesdeliver_northCLUSTER=northdeliver_southCLUSTER=southum trigger por clusteralert_failurewhen: on_failurevira× 3 unidades× 2 clustersTracejado: um arquivo trazido pelo include. Job tracejado: criado só quando a flag de envio está ligada.
Como cinco arquivos de blueprint viram um pipeline concreto. Os jobs ocultos da biblioteca são estendidos em um job por unidade e um por cluster; o arquivo do consumidor só lista includes e flags.

routine.yml: o que uma branch significa

routine.yml raw
# 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

images.yml raw
# 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

delivery.yml raw
# 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

branch candidate → anel previewbranch main → anel livebake3 unitspush:<sha> · :previewtriggerset :<sha>roll outpreviewsem bakeestágio puladoreleasecrane tagtriggerset @digestroll outlivemesmo digest:preview → :liveO anel live nunca assa. Ele libera a imagem que já rodou em preview, por digest, e faz o rollout dela.
Preview assa; live libera. A branch main re-etiqueta o digest de preview e faz o rollout — os bytes que rodaram em preview são os bytes que entram no ar.
release.yml raw
# 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 jobs

A 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/
fleet.gitlab-ci.yml raw
# 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
        }
      done

Quatro 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

alerts.yml raw
# 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:

alert-success.sh raw
# 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" || true

A 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çãoOndePor que ali
Branch → anelroutine.yml na bibliotecaUma verdade para todas as apps
Credenciais do registryAs CI_REGISTRY_* do próprio GitLabNunca digitadas em lugar nenhum
Kubeconfig por clusterVariável do tipo arquivo em platform/fleet, protegidaSó o pipeline da frota alcança um cluster
SLACK_WEBHOOK_URLVariável mascarada em cada consumidorCada app é dona do seu canal
SHIP_<UNIDADE>variables: no arquivo do consumidorVisível no diff que liga a unidade
Versão da bibliotecaref: no include do consumidorAtualização é um commit revisado, não uma surpresa

O que nos mordeu, para não morder você

SintomaCausaRegra
Rollout verde, imagem inalteradaUnidade 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 nadaAdicionar uma unidade toca três lugares: job de bake, entrada no conjunto, label no manifest
Cinco unidades fazendo rollout uma após a outraresource_group sem a app na chaveA chave nomeia exatamente o que não pode se sobrepor
Comando falhou, sem erro, job continuaOUT=$(cmd 2>&1) sob set -e: a atribuição engole o status de saídaUse o comando como condição de um if, capture a saída dentro
Bake instável “consertado” pelo retryscript_failure em default.retryRetry para infraestrutura, nunca para código
Um passo pulado porque um Deployment “estava ausente” — não estavaOscilação de rede indistinguível de NotFoundTrês estados, parar no terceiro
Clone de um repo de 30 MB para rodar kubectl applyGIT_STRATEGY padrão num job que nunca lê a árvoreGIT_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.