Three apps, each with three or four deployable units. Two rings — preview and
live. More than one Kubernetes cluster, run by different teams. The first
version of that delivery was what it always is: a .gitlab-ci.yml copied from
the last project, edited until green, and never the same twice. A year later no
two pipelines agreed on what a branch meant, and a fix in one never reached
the others.
What follows is the architecture that replaced it. Names, paths and numbers are illustrative; the shape is the thing.
The shape
There are three kinds of repository and only two pipelines:
| Repository | Holds | Its pipeline |
|---|---|---|
platform/ci-blueprints | The library: one folder per app, five YAML files each | None. It is only ever included |
orders, billing, … | The application code and a .gitlab-ci.yml of a dozen lines | Bakes images, releases, hands off to the fleet pipeline, alerts |
platform/fleet | Kubernetes manifests, <cluster>/<app>/<ring>/ | Runs only when triggered. Applies, sets images, waits for the rollout |
Nothing in an app repository knows how to reach a cluster. Nothing in the fleet repository knows how to bake an image. The hand-off between them is a single JSON variable, and that boundary is what keeps both sides simple.
The consumer: a dozen lines
# 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 timeThe ref is a tag. main is for trying a change in one app before you cut
v2.4.0 and move the others. The file list is explicit on purpose: the
names are a contract. Adding a sixth file means every consumer adds a line;
renaming one means every consumer breaks — so files are added, never renamed.
The SHIP_* flags are how a new unit, or a new library version, rolls in one
piece at a time. A unit whose flag is off has no job at all, not a skipped one.
From five files to a pipeline
routine.yml: what a branch means
# 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
}Three decisions live here and nowhere else. A branch maps to exactly one
ring, and a branch that maps to nothing creates no pipeline — the when: never at the end of the workflow: block is not decoration. Retries cover
infrastructure and not code: adding script_failure would rerun a genuinely
broken bake three times and hide the defect behind a green badge on the
fourth. And the shell library is shared with !reference, so the retry
with backoff and the three-state probe are written once. That third state
matters: a network flap during kubectl get used to look exactly like
“absent”, and a job would carry on with the wrong assumption. Not being able
to tell is its own answer, and the caller stops on it.
images.yml: one hidden job, one concrete job per unit
# 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"'The hidden job does the work; each concrete job is two variables. They run in
parallel, since nothing in bake_web depends on bake_api. Every image is
pushed twice: as :<sha>, which is what will be rolled out and never moves,
and as :preview, a floating pointer to “what the preview ring runs now” that
the release step will read.
delivery.yml: the hand-off
# 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 makes the parent wait for the child and fail if it fails —
the app pipeline’s badge tells the truth about the rollout, not just the bake.
One delivery job per cluster is the whole multi-cluster story: same fleet
repository, same variables, different CLUSTER.
ROLLOUT_SET is the contract across the boundary: which unit, which
container name inside the pod, which image. The fleet side needs nothing else
to update a deployment.
Live is a release, not a 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 jobsThe main branch skips the bake stage entirely. Rebuilding from the same
commit would probably produce the same image; releasing by digest produces
the same image by definition. The dotenv report is how the digests reach the
delivery jobs — GitLab expands $API_IMAGE inside ROLLOUT_SET before it
hands the variable to the child pipeline.
The fleet pipeline
Manifests live per cluster, per app, per ring, with a common/ folder for the
ConfigMap and Secret every unit reads. Namespaces follow one convention,
<app>-<ring>, so nobody has to declare them:
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
}
doneFour things are doing the real work here.
It only runs when triggered. A push to the fleet repository changes files and nothing else; the cluster changes when an app pipeline says so, with a rollout set attached.
apply and set image do not fight. The versioned manifest says
image: …/api:preview; the live object ends up with …/api@sha256:…. The
next apply looks like it should revert that — it does not. Client-side apply
patches only the fields that changed between the last-applied manifest and
the new one, and the image line is identical in both, so the digest set by
the pipeline survives. Break that premise by editing the tag in the manifest
and you get a second rollout per delivery.
resource_group serialises per target. Two pipelines for the same app
and ring queue instead of racing. The group key includes the app so that
orders and billing on the same cluster still roll out in parallel.
A failed rollout explains itself. describe and the last hundred lines
of logs go into the job output before it exits red — the person who opens
the job at 2 a.m. should not need cluster access to see why.
Telling someone
# 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"On failure, always: branch, ring, author, commit, one button. On success, one line from the rollout job with the digest that went live:
# 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" || trueThe webhook URL is a masked, protected CI/CD variable on the consumer project; a project without one simply logs that it skipped the alert.
Where each setting lives
| Setting | Where | Why there |
|---|---|---|
| Branch → ring | routine.yml in the library | One truth for every app |
| Registry credentials | GitLab’s own CI_REGISTRY_* | Never typed anywhere |
| Kubeconfig per cluster | File-type variable on platform/fleet, protected | Only the fleet pipeline can reach a cluster |
SLACK_WEBHOOK_URL | Masked variable on each consumer | Each app owns its channel |
SHIP_<UNIT> | variables: in the consumer’s file | Visible in the diff that turns a unit on |
| Library version | ref: in the consumer’s include | Upgrades are a reviewed commit, not a surprise |
What bit us, so it does not bite you
| Symptom | Cause | Rule |
|---|---|---|
| Rollout green, image unchanged | New unit added to images.yml and ROLLOUT_SET but the manifest lacks the unit: label, so set image -l matched nothing | Adding a unit touches three places: bake job, rollout-set entry, manifest label |
| Five units rolling out one after another | resource_group without the app in its key | The key names exactly what must not overlap |
| A failed command, no error, job continues | OUT=$(cmd 2>&1) under set -e: the assignment swallows the exit status | Use the command as the condition of an if, capture output inside |
| A flaky bake “fixed” by retry | script_failure in default.retry | Retry infrastructure, never code |
| A step skipped because a Deployment “was absent” — it was not | Network flap indistinguishable from NotFound | Three states, stop on the third |
Clone of a 30 MB repo to run kubectl apply | Default GIT_STRATEGY on a job that never reads the tree | GIT_STRATEGY: none when the working tree is not needed |
None of this is exotic GitLab. It is include, hidden jobs, workflow:
rules, one multi-project trigger and a JSON variable — arranged so that adding
the fourth app is a folder in the library and a dozen lines in the app, and so
that the live ring only ever runs bytes that preview already ran.