Skip to content

infra: add the API manifests (2 pods, probes, zero-downtime rollout) - #15

Merged
devinder-dev merged 2 commits into
mainfrom
infra/api-manifests
Oct 29, 2026
Merged

devinder-dev merged 2 commits into
mainfrom
infra/api-manifests

Conversation

@devinder-dev

@devinder-dev devinder-dev commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Step 1.7: the API's Kubernetes manifests in k8s/app/. This PR doesn't deploy anything. The pipeline's deploy job comes in 1.8, once #12 (the image) and #14 (rollback) are merged.

What's in it

  • deployment.yaml:
    • 2 pods (VG1).
    • Rolling update with maxSurge: 1, maxUnavailable: 0, so there are never fewer than 2 ready pods (VG2).
    • Image relay-api:__GIT_SHA__, filled in by relay-deploy.
    • Probes, and a preStop sleep of 5 s.
    • Requests 100m CPU and 128Mi memory, limit 256Mi (the 1.3 load test peaked at 93 MB).
    • Runs as uid 1000 (not root), with no privilege escalation, all capabilities dropped, and the default seccomp profile.
  • service.yaml: port 80 → the pods' port named http.
  • ingress.yaml: api.myrelay.tech, with a letsencrypt-prod certificate in namespace relay.

The probes

  • Readiness: /health/ready every 5 s. After 3 failures (about 15 s), the pod is taken out of the Service but keeps running.
  • Liveness: /health/live every 20 s, starting after 15 s. After 3 failures (about 60 s), the container is restarted.

Liveness is the more patient one because a restart is more drastic than losing traffic. It also never checks the database: a database outage must not restart every pod.

Why runAsUser: 1000

The image says USER bun, which is a name. Kubernetes can only verify runAsNonRoot against a number. With just the name, the pod would fail with CreateContainerConfigError.

Tested

  • A server-side dry run as the deployer account itself (sudo -u deploy kubectl … --dry-run=server): all three files were accepted. A dry run is authorized like a real request, so this proves the pipeline may apply exactly these files.
  • The probe paths against the real image. A loop reads every path: from deployment.yaml and curls the running container: 200 /health/ready, 200 /health/live. The dry run alone can't catch a wrong path: it also passed with /ready and /health, which both answer 404.

At the first deploy (1.8)

The hello app in default also claims api.myrelay.tech, so we delete it by hand at that moment, because two Ingresses for one host would compete. cert-manager then issues a new certificate for namespace relay.

Added after an independent review of Phases 0–1

  • automountServiceAccountToken: false: the API never talks to Kubernetes, so its pods get no token at all.
  • readOnlyRootFilesystem: true: tested with the real image (--read-only --cap-drop ALL --user 1000:1000). /health/ready, /health/live and /version answer 200, and docker stop exits 0 in 0.15 s.
  • A comment on why there's no CPU limit: a limit would throttle the API even when the server has CPU to spare. The request is what scheduling, and later the HPA, use.
  • The server-side dry run still passes.

@AbdikhaniAbdirashid AbdikhaniAbdirashid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The manifests are careful, the comments explain the reasoning rather
than restating the YAML, and everything I could check holds up. Five things below,
none blocking.

What I verified against the cluster and the code

  • Namespace relay exists, letsencrypt-prod is Ready, and the hello Ingress
    really does hold api.myrelay.tech — so the hand-over you describe for 1.8 is a
    real step, not a hypothetical.
  • preStop.sleep needs Kubernetes 1.29+; we're on v1.36.5, fine.
  • /health/ready does not touch the database yet, and config.ts has defaults
    for PORT, GIT_SHA and LOG_LEVEL. So pods can go ready with no env block and
    no Postgres, which is what this PR needs today.
  • server.ts listens on 0.0.0.0:8080, matching containerPort: 8080, handles
    SIGTERM via app.close(), and the Dockerfile's exec-form CMD makes Bun PID 1, so
    it actually receives the signal.
  • The image pulls anonymously. I tried ghcr.io/devinder-dev/relay-api:<main sha> with no credentials and got a manifest back, so the package is already
    public and no imagePullSecret is needed. That was the biggest deploy risk, and
    it's already handled.
  • runAsUser: 1000 is right. I pulled the 3.4 kB layer from
    oven/bun:1.4.4-slim that carries /etc/passwd and read it:
    bun:x:1000:1000::/home/bun:/bin/sh. Matches runAsUser and runAsGroup.

Five things

  1. The namespace isn't in git. relay was created by hand. These manifests use
    it but don't create it, so kubectl apply -f k8s/app/ against a rebuilt cluster
    fails on the first file. Does the runbook in #17 cover it, or should a
    namespace.yaml live here? It's the one gap between "the cluster works" and
    "the repo can rebuild the cluster".

  2. Readiness and the database, in Phase 2. Harmless today. But once
    /health/ready checks Postgres, periodSeconds: 5 × failureThreshold: 3 means
    both pods leave the Service about 15 s into any database blip, and Traefik
    answers 503 for everything. Readiness tied to a shared dependency turns a slow
    database into a total outage. Worth deciding before Phase 2 rather than after:
    either keep the DB check out of readiness, or raise failureThreshold a lot.

  3. Two replicas on one node. I checked, the cluster is a single node, so both
    pods always land together. That's not a flaw — we have one VPS — but it means
    the two replicas buy us process-level resilience and zero-downtime rollouts, not
    high availability. I'd rather we both say that precisely than get caught
    claiming HA.

  4. No startupProbe. initialDelaySeconds: 15 is plenty for Bun today. When
    the database connection lands in Phase 2 and startup gets slower, liveness can
    start killing a pod that is merely still booting. A startupProbe is the clean
    fix and costs three lines.

  5. terminationGracePeriodSeconds is implicit. The default 30 s minus the 5 s
    preStop leaves 25 s for in-flight requests, and the webhook timeout in
    CONTEXT.md is 10 s, so the numbers work. But those three values are coupled and
    nothing says so. One comment, or set it explicitly, so a future change to one
    doesn't silently break the other.

One thing I liked

Keeping metrics on a separate port 9090 rather than the http port. The Ingress is
path: / Prefix, so metrics on the http port would have been exposed to the
internet, against our own rule in CONTEXT.md. Easy to get wrong, and you didn't.

Questions

  1. For 1.8: can deleting the hello Ingress go in the runbook, or does it have to
    stay a manual step at deploy time? A manual step we only do once is the kind of
    thing we'll have forgotten by the demo.
  2. readOnlyRootFilesystem: true with no emptyDir for /tmp — does Bun ever
    write there under load? You tested the health endpoints; I'm wondering about the
    k6 run in Phase 8.

@devinder-dev

Copy link
Copy Markdown
Owner Author

Thanks, checking /etc/passwd in the base image layer is a better proof of runAsUser: 1000 than my id in a running container. Answers:

  1. The namespace is in git: it's the first object in k8s/platform/deployer.yaml (merged in infra: add the deploy path (deploy user, forced command, limited account) #13), together with the deployer account. It sits in platform/ on purpose. The deployer account may not create namespaces, so k8s/app/ can't contain one. The rebuild order applies the platform files first (infra/server/README.md in infra: capture the server's base setup, a rebuild runbook and the firewall fix #17, step 3, then k8s/README.md), so k8s/app/ always finds relay.

  2. Readiness and the database: agreed, and it's the right moment to raise it. Tying readiness to a shared dependency means one slow database takes every pod out of the Service at once. I'm putting it on the Phase 2 list as a decision to make before the DB check is written. My leaning: keep the database out of readiness, so a pod with the DB down still answers with our own 503 error shape and request id, instead of Traefik's empty 503.

  3. Two replicas on one node: agreed, and I'll word it exactly like that in CONTEXT.md and in the defense. Two replicas give us zero-downtime rollouts and protection against one crashing process. They are not high availability: if the node goes down, everything is down.

  4. startupProbe: agreed. It belongs in the Phase 2 change that makes startup connect to Postgres. On the Phase 2 list.

  5. terminationGracePeriodSeconds: agreed, those three numbers are coupled. In the next change to deployment.yaml I'll set terminationGracePeriodSeconds: 30 explicitly, with a comment: 5 s preStop, then up to 25 s for running requests, and our longest request is a 10 s webhook.

Your questions

  1. Deleting hello: it's a one-time switch-over, done at the first deploy, long before the demo. infra: deploy every merge to main and check that it's live #16 deletes k8s/hello/ from git, and its description lists the steps (the Ingress, the Service, the Deployment and the leftover TLS Secret). After that there's nothing left to remember.
  2. readOnlyRootFilesystem under load: I tested it instead of guessing. I ran the image with --read-only --cap-drop ALL --user 1000:1000 and sent 60,000 requests, 50 at a time (/health/ready, /version and a 404 route): 0 failed, and no write errors in the log. /tmp really is read-only, and nothing tried to use it. We'll check again under k6 in Phase 8. If anything ever needs to write, an emptyDir on /tmp is a four-line fix.

Merging now. Thanks for the careful review.

@devinder-dev
devinder-dev merged commit a78934c into main Oct 29, 2026
@devinder-dev
devinder-dev deleted the infra/api-manifests branch October 29, 2026 14:01
devinder-dev added a commit that referenced this pull request Oct 29, 2026
Step 1.8: every merge to main is deployed, then checked from outside.
Rebased onto main now that #12, #14 and #15 are merged. **Merge only
after the switch-over below.**

## What changes
- **`api.yml` → `pipeline.yml`.** It now deploys too, and the
dashboard's jobs will join it later (`needs:` only works inside one
workflow file).
- **No path filter on `main`.** Every merge is a release: a deploy needs
an image tagged with exactly the merged commit, even when only
`k8s/app/` or the docs changed.
- **New job `deploy-script-test`** on every PR and merge. It runs
`infra/server/test-relay-deploy.sh` (relay-deploy's gates and rollback,
with fake git and kubectl) and `shellcheck` on the server scripts. The
deploy needs it, so a broken deploy script never reaches the server.
- **New job `deploy`**, after `api-image` and `deploy-script-test`, only
on a merge to main:
- **`environment: production`:** the only way to get `DEPLOY_SSH_KEY`,
and only on main.
  - **`permissions: {}`:** no GitHub access at all.
- **`timeout-minutes: 30`:** the worst case is the server's lock (15
min, #19) plus a rollout and a rollback.
- **Step 1:** writes the key and our server's `known_hosts`, then `ssh …
deploy@<vps> "$GITHUB_SHA"` (`StrictHostKeyChecking=yes`,
`BatchMode=yes`). The exit code of `relay-deploy` becomes the step's.
- **Step 2:** asks `https://api.myrelay.tech/version` until it answers
this commit, for up to 36 × 5 s, because the first deploy also waits for
a new certificate.
- **`timeout-minutes: 10`** on `api-test` and `api-image` (from the #12
review).
- **`k8s/hello/` is deleted**, together with its row in `k8s/README.md`.
The API's Ingress takes over `api.myrelay.tech`.

## Tested
- `actionlint` 1.7.7 (with shellcheck on the `run:` scripts): no
findings.
- The new job's two steps, run locally: `26 passed, 0 failed`, and
`shellcheck` is clean.
- The version check, run on its own with `bash -eo pipefail` against a
local container:
  - the right SHA → passes;
  - an old SHA → keeps asking, then fails;
  - nothing answering → "no answer" on every try, then fails.
- The rebase was rehearsed in a throwaway clone first: the 3 commits
replayed onto main without conflicts.

## Already done before merging
- #12, #14 and #15 merged.
- `deployer.yaml` applied: the deployer can read events, and still can't
read Secrets or delete.
- `relay-deploy` installed, byte-identical to main.
- The image is pullable without credentials (ADR 0004, #18), and the
server has already pulled `relay-api:a8274c1`.

## Switch-over (right before merging)
1. Delete the hello app from `default`: the Ingress, Service and
Deployment, plus its leftover TLS Secret `api-myrelay-tech-tls`.
`api.myrelay.tech` is down for about 1–2 minutes, once.
2. Merge → the pipeline tests, builds, deploys → `/version` answers the
commit.

## After the first deploy
- **A real rollback:** deploy #15's merge commit `a78934c` by hand. It
has no image, so the rollout fails and `relay-deploy` redeploys the last
good commit, while a curl loop shows only 200s.
- **Zero downtime:** a load loop during a normal deploy (merging #19)
should show no failed requests.
- Then make `api-test` and `deploy-script-test` required checks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants