Docs · Glidepath
Pipeline performance
Pipeline performance review, 2026-10-08, implemented and closed 2026-10-09. What a stage costs now, what changed and why, and the levers that are still open. The measurements come from Tekton Results: 1,596 PipelineRuns and 7,414 TaskRuns from 2026-09-17 to 2026-10-09, read through the relay proxy (see Measuring it again).
The shape of a stage
Every stage Pipeline (build, test, deploy, release) has the same frame:
| Pod | What it does |
|---|---|
preflight |
Mints the chain id and traceparent, begins the stage span, sends pipelinerun.started, clones the app repo when the stage has a workspace, validates cicd.yaml (or passes forwarded config_json through). One step. |
| the stage’s own tasks | build-source, build-image, run-tests, deploy-manifests, open-release-pr, … |
debrief (finally) |
Ends the stage span and, on the flow’s last stage, the flow-root span; notifies Slack and Backstage; sends the stage’s domain event and pipelinerun.finished. Every step is onError: continue. |
This replaced eleven single-purpose Tasks per stage (clone, validate, span start and end, notify, events). A smoke-fn build went from 13 pods and 258-409 s to 4 pods and 129 s.
Two limits shaped that design. Keep both in mind when adding work to preflight or debrief:
- Task results share the termination message. The kubelet gives a pod 12 KiB of termination messages in
total, split across every container including Tekton’s two init containers. A Task with N steps gets
12288/(N+2) bytes per step, not 4 KiB.
preflightwrites 17 results, so it is one step and emitsconfig-jsoncompactly. - A compact JSON step result substitutes as an empty string. Tekton 1.15 parses a step result that looks
like a JSON object or array as that type, so
$(steps.x.results.y)in a string param becomes"".debriefhands JSON between steps base64-encoded (*-b64params on the StepActions).
Shared services and settings
| What | Where | Why |
|---|---|---|
trivy-server |
glidepath-control-plane, platform-system |
Holds Trivy’s vulnerability DB (~120 MiB). image-scan runs one JSON analysis against it and renders the log table with trivy convert. If the server is down the scan runs standalone, as before. Scan step: 4 s against the server, 14 s standalone. Its tag must equal TRIVY_VERSION in catalog/toolbox/Dockerfile. |
| Argo CD refresh after a Ground deploy | deploy-manifests; grant in glidepath-app templates/env/argocd-refresh-rbac.yaml |
argocd-apps polls git every 120 s plus up to 60 s of jitter. deploy-manifests annotates <app>-<env> with argocd.argoproj.io/refresh=hard after its push; Argo CD picks it up in about 4 s. |
pipeline-runner-application-refresh-only |
glidepath-control-plane ValidatingAdmissionPolicy |
RBAC cannot limit a patch to one field. The policy lets a pipeline-runner ServiceAccount change only the refresh and hangar.io/dora-* annotations on an Application, never its project, destination, sync operation or labels. |
coschedule: disabled |
Cluster repo 50-platform-cicd/tekton-operator/tektonconfig.yaml |
No Affinity Assistant StatefulSet and placeholder pod before each build and test run (10-25 s each). Use pipelineruns on a multi-node cluster with network-attached ReadWriteOnce storage. |
| Dependency caches | build-cache-<app> PVC |
One directory per tool (npm, yarn, Maven, …), not one per lockfile hash, so a lockfile change keeps the warm cache. run-tests mounts the npm/yarn cache too. |
installation.md covers the install-time side of each.
Step resources
Every Tekton container now carries a CPU and memory request (2026-10-09). Before that no step set one, so every step pod was BestEffort. The kubelet puts all BestEffort pods in one cgroup with the minimum CPU weight, so under load the whole pipeline got less CPU than any single pod with a request. The Semgrep scan step ranged from 5 to 239 s on repos of a few files, following node CPU load.
| Where | Request | Applies to |
|---|---|---|
TektonConfig options.configMaps.config-defaults (cluster repo) |
25m CPU, 32Mi | Every container that sets none, including Tekton’s init containers |
build-source run-build-script, run-tests unit-test, build-artifact build |
250m, 256Mi | The app’s own build and test work |
build-image build-and-push |
500m, 512Mi | Image build; both platforms in parallel in one step |
sast-scan scan |
200m, 256Mi | Semgrep |
image-scan scan |
100m, 256Mi | Trivy client (the server does the DB work) |
generate-sbom generate-and-attest |
100m, 128Mi | Trivy SBOM + cosign |
These are requests only, with no limits, so nothing is throttled or OOM-killed because of them. A request is
a scheduling reservation, though, and a pod’s step requests add up even though its steps run one at a time.
The dev node had about 3.2 of its 11 cores unrequested when this was set. One build at its widest
(build-source, unit-test and sast-scan in parallel) reserves about 0.8 cores. Several builds at once can
leave a pod Pending until another finishes. That queueing is the intended trade, and it counts against the
TaskRun timeout. Raise a step’s request only together with the node’s headroom.
Release gates
A gitops release PR runs eight gate PipelineRuns.
- The image gates do not clone.
sast,image-scan,sbomand the image half ofprovenanceshare theresolve-promoted-imageStepAction: the PR’s base, the files changed between that base and the head commit, and the one manifest, all over the GitHub API. A release branch with more than one commit resolves too.verify-commit-signaturereads the PR’s commits for the trailers the same way, andvalidate-valuesfetches the PR head one commit deep. - Release Record PRs do not trigger gates. Tower’s
release-record-*branches only addreleases/<app>@<version>.yaml. They used to matchstartsWith("release-")and made up 135 of 284 failedextract-promoted-imageruns. image-promotionholds no pod. The polling stub was retired (ADR-0025); the real check (ADR-0029, catalogpromotion.enabled) is started by the last required gate’sfinallyand runs once, in the catalog namespace (registries.md).
Details are in release-guardrails.md.
Other changes
- The write-side Tasks (
deploy-manifests,open-release-pr,bump-manifest-pr, bothdeliver-onboarding-filesclones) clone one commit deep.resolve-deploy-targetreadscicd.yamlover the contents API. An unknown revision still fails the Task. - Semgrep runs with
--metrics=off. The rule packs are still fetched from the registry. The fetch takes under a second. The packs are under the Semgrep Rules License v1.0, so they are not vendored into this public repo or the public toolbox image. - A push whose HEAD is a merge commit no longer hides a real change from Pipelines-as-Code. The push
exemption reads every commit in the push (
body.commits), not PaC’s HEAD-onlyfiles.all. - The merge of an app’s first onboarding PR builds (2026-10-17): it is recognised by a commit adding
.tekton/onboarding-resync.yaml, which only the first onboarding does. Before, every file in it was exempt and a new app waited for someone to push code.hack/ci/build-exemption-celchecks the expression with cel-go against 15 push payloads in catalog-ci, including that acicd.yamlchange never builds, directly or through its resync.
Still open
- amd64 builds under QEMU (review item 1, partly addressed).
build-imagemoved from kaniko to BuildKit on 2026-10-17 (ADR-0026): both platforms build in parallel in one step, and a Containerfile that compiles in aFROM --platform=$BUILDPLATFORMstage cross-compiles natively instead of emulating (a Go scaffold-shaped build for both platforms took 37 s). RUN steps in target-platform stages still emulate, so an app that builds inside its Containerfile without$BUILDPLATFORM(Backstage: 11 of its 25 minutes inbuild-imageunder kaniko) is still slow until its Containerfile opts in. Deriving the platforms from the clusters an app deploys to still needs anarchfield in the cluster registry (ADR-0024). - The dev cluster’s control plane is under pressure.
kube-schedulerandkube-controller-managerrestart on lease timeouts with node memory around 86%. That sets the floor under every number here. - No PaC
concurrency_limit. A burst of pushes starts every build at once.
Measuring it again
Tekton Results holds every finished run; the relay in platform-system proxies its REST API:
kubectl get --raw "/api/v1/namespaces/platform-system/services/tekton-results-relay:8080/proxy/apis/results.tekton.dev/v1alpha2/parents/-/results/-/records?page_size=500&filter=data_type=='tekton.dev/v1.PipelineRun'"
Records are base64 JSON in .records[].data.value. Page with page_token=<next_page_token>. Use
data_type=='tekton.dev/v1.TaskRun' for per-task and per-step timings
(.status.steps[].terminated.startedAt/finishedAt).