Notes
Listen
Listen. A push, pull request, tag, schedule or button press triggers a pipeline. The pipeline is defined in a YAML file inside the repository, as jobs with dependencies. Jobs run in isolation, logs stream live, and the pass or fail status goes back to the pull request. Builds produce artifacts, and deploys support approvals, canaries and rollback.
Qualities. Feedback must be fast: a job should start within ten seconds, and a typical pull request should finish in about ten minutes. Jobs run other people's code, so isolation is a security requirement, not a nice-to-have. Secrets must never appear in logs or reach pull requests from forks. A lost runner should cause a retry, never a pipeline stuck forever.
- trigger on push, PR, tag,
- schedule, or by hand
- pipeline defined in the repo
- (YAML): stages, jobs, needs
- run jobs in isolation
- live logs, pass/fail status
- back on the PR
- store artifacts
- deploy with approvals,
- canary, rollback
- fast: job starts in ≤ 10 s,
- typical PR feedback ≤ 10 min
- isolation: one job cannot see
- another tenant's code
- secrets never in logs, never
- given to untrusted forks
- reliable: a lost runner
- means a retry, not a hang
- out of scope: the git host
Ask scale
Scale, for a hosted service. A million pipeline runs a day with five jobs each is five million jobs, about sixty starts a second. With an average job of eight minutes, Little's law says around twenty-nine thousand jobs run at any moment, near ninety thousand at the weekday peak. Logs add five terabytes a day and artifacts fifty, so deduplication matters.
1M pipeline runs/day × 5 jobs = 5M jobs/day ≈ 60 starts/s average job 8 min = 480 s concurrent ≈ 60 × 480 ≈ 29,000 weekday peak ×3 ≈ 90,000 runners logs: 1 MB/job → 5 TB/day artifacts: 50 MB/run → 50 TB/day keep 30 days ≈ 1.5 PB (dedupe!)
Define API
The API. Signed webhooks from the git host start runs, and users can also start one by ref and commit. Clients read run status, follow live logs over server-sent events, cancel runs and approve deploys. Runners use their own protocol: they long poll to claim a job, then report state and extend their lease.
POST /v1/hooks/git (signed)
POST /v1/repos/{r}/runs { ref, sha }
GET /v1/runs/{id}
GET /v1/jobs/{id}/logs?follow=1
(server-sent events)
POST /v1/runs/{id}:cancel
POST /v1/deploys/{id}:approve
runner protocol:
POST /v1/runner/claim (long poll)
PATCH /v1/jobs/{id} {state, lease} Data model
Data model. Runs and jobs are relational, with clear states and relationships, so they live in SQL. Each job lists the jobs it needs, the runner labels it requires, and its lease. Logs are written as chunks to object storage. Artifacts are stored by the hash of their content, so identical outputs are stored only once.
runs (SQL) run_id, repo, sha, ref, trigger, status, created_at jobs (SQL) job_id, run_id, name, needs[] labels, state, runner_id, lease_until, attempt logs: chunks in object storage artifacts: blobs by SHA-256 (same content stored once)
High-level design
The high-level design. A webhook arrives; the pipeline service fetches the YAML at that commit, turns it into a graph of jobs and saves them. Jobs with no unfinished dependencies go onto the queue. A runner claims one, checks out the code, runs the steps, streams logs, uploads artifacts and reports the result.
Deep dive: the DAG
Deep dive one: the graph. Lint, unit and end-to-end tests run in parallel; build needs all three; deploy needs build. When a job finishes, the service checks each child: if all its needs passed, enqueue it; if any failed, skip it. A matrix expands one job into several, one per runtime version. And a new push to the same branch cancels the older run, which saves a surprising amount of compute.
lint ─┐
unit ─┼─→ build ─→ deploy-stg
e2e ─┘ └→ deploy-prod
(approval)
job done → for each child:
all needs passed? → enqueue
any failed? → skip child
matrix: unit × {node18, node20}
new push on branch → cancel
the older run Deep dive: runners
Runners pull work instead of being pushed to. Each one long polls for jobs matching its labels, such as Linux with eight CPUs. Pulling means a company can run its own runners behind its firewall and still connect. While running, the runner holds a lease and heartbeats; if it disappears, the job is requeued. Per-organisation limits keep one tenant from taking every runner.
- runners long-poll for jobs
- matching their labels
- → works behind firewalls
- (self-hosted runners)
- lease + heartbeat while
- running; no heartbeat →
- job requeued (attempt + 1)
- per-org concurrency limits
Deep dive: isolation
Deep dive two: isolation. Every job is untrusted code. Run each one in a fresh micro virtual machine, such as Firecracker, that boots in a fraction of a second and is destroyed when the job ends, so nothing leaks to the next tenant. Plain containers share the host kernel, which is a weaker boundary. Add CPU, memory, time and network limits.
Secrets. Store them encrypted, inject them only when the job starts, and mask their values in the log stream. Pull requests from forks get no secrets at all, or anyone could print them. Better still, let jobs reach the cloud through OIDC: the job receives a token valid for minutes, scoped to one repository and branch, so there is no long-lived key to steal.
- each job: new micro-VM
- (e.g. Firecracker) or VM
- no state left behind
- for the next tenant
- containers alone share the
- host kernel → weaker
- network egress rules,
- CPU/memory/time limits
- stored encrypted (KMS)
- injected as env at job start
- masked in log output
- fork PRs: no secrets at all
- cloud access via OIDC:
- job gets a token valid for
- minutes, scoped to one repo
- and branch → no long-lived
- keys to steal
Deep dive: logs
Logs. The runner sends numbered chunks every second or every sixty-four kilobytes. The log service pushes them to anyone watching, over server-sent events, and appends them to object storage. Because chunks are numbered, a viewer who reconnects asks for everything after chunk forty-one and sees no gaps. Old logs expire after ninety days.
- runner sends numbered chunks
- every ~1 s or 64 KB
- log service:
- fans out to live viewers
- (server-sent events)
- appends to object storage
- viewer reconnects with
- "from chunk 41" → no gaps
- logs kept 90 days
Deep dive: speed
Deep dive three: speed. Most build time repeats work. Cache dependencies keyed by the hash of the lockfile, and container layers by digest. Keep a warm pool of booted machines so jobs start in seconds. Split a forty-minute test suite across eight runners for five minutes. In a monorepo, build and test only what the change actually affects.
- cache by content key:
- deps: hash(lockfile)
- image layers: by digest
- warm pool of booted VMs
- → start in seconds
- shard tests: 40 min suite
- ÷ 8 runners ≈ 5 min
- monorepo: build only what
- the change affects
Deep dive: deploys
Deploys. Build once and promote the same image digest through staging, a canary and production, so what you tested is exactly what you ship. The canary takes five percent of traffic for about fifteen minutes while the system compares error rate and p ninety-nine against the current version, rolling back on its own if they are worse. Lock each environment to one deploy at a time. See Concepts episode 39.
- build once → immutable image
- promote the same digest:
- staging → canary → prod
- canary: 5% for 15 min
- error rate, p99 vs baseline
- worse → automatic rollback
- one deploy per environment
- at a time (lock)
- approval gate for prod
Trade-offs
Failure modes. Webhooks get lost, so also poll the git host for new commits now and then. A dead runner means a lease expiry and a retry. Flaky tests are the biggest real pain: retry once, but track each test's flake rate and quarantine the worst. If the cache is down, builds are slower but still correct. A bad deploy is caught by the canary.
Monitor queue wait at p ninety-five for each runner label, run duration, success and flake rates, cache hit rate, runner utilisation and cost, and how often deploys roll back. At ten times the scale, shard the pipeline database by organisation, add regional runner pools near the code, and autoscale runners on queue depth.
- webhook lost → poll the git
- host for new commits
- runner dies → lease expires,
- job retried
- flaky test → retry once,
- track flake rate per test
- cache down → slower, not
- broken
- bad deploy → canary catches
- it, auto rollback
- monitor:
- queue wait p95 per label
- run duration, success rate
- flake rate, cache hit rate
- runner utilisation and cost
- deploy rollback rate
- at 10x:
- shard pipeline DB by org
- regional runner pools
- autoscale on queue depth
Say it like this
In the interview: a webhook turns the repository's YAML into a graph of jobs, and ready jobs go to a queue by label. Runners pull work, hold leases and heartbeat, inside a fresh micro-VM per job. Secrets are masked, short-lived and never given to forks. Logs stream as numbered chunks. Caching, warm pools and test sharding keep it fast, and one immutable artifact is promoted through a canary.
- webhook → YAML → job DAG
- ready jobs → queue by label
- runners pull, lease, heartbeat
- fresh micro-VM per job
- secrets: masked, scoped,
- OIDC, none for forks
- logs: numbered chunks → SSE
- cache + warm pool + sharding
- build once, promote digest,
- canary + auto rollback