From ddeb7e75152ff539e7d7ad5385cac4df2bad5365 Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Thu, 29 Oct 2026 11:52:34 +0100 Subject: [PATCH 1/2] infra: add the API manifests (2 pods, probes, zero-downtime rollout) --- k8s/app/deployment.yaml | 79 +++++++++++++++++++++++++++++++++++++++++ k8s/app/ingress.yaml | 29 +++++++++++++++ k8s/app/service.yaml | 15 ++++++++ 3 files changed, 123 insertions(+) create mode 100644 k8s/app/deployment.yaml create mode 100644 k8s/app/ingress.yaml create mode 100644 k8s/app/service.yaml diff --git a/k8s/app/deployment.yaml b/k8s/app/deployment.yaml new file mode 100644 index 0000000..7c86e92 --- /dev/null +++ b/k8s/app/deployment.yaml @@ -0,0 +1,79 @@ +# The Relay API (step 1.7). Applied only by the pipeline, through relay-deploy, which +# replaces __GIT_SHA__ with the commit being deployed. Don't apply this file by hand. +# +# Deployment: "keep 2 copies of the API running, and replace them one at a time." +apiVersion: apps/v1 +kind: Deployment +metadata: + name: relay-api + namespace: relay + labels: + app.kubernetes.io/name: relay-api +spec: + # Two pods from day one, behind one Service (VG1). In Phase 8 the autoscaler (HPA) + # takes over the count and this line goes away. + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: relay-api # manage the pods that carry this label... + # Rolling update: start 1 new pod, wait until it's ready, then stop 1 old one, and repeat. + # There are never fewer than 2 ready pods, so a deploy drops no requests (VG2). + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app.kubernetes.io/name: relay-api # ...and give every new pod this label + spec: + # Run as the image's "bun" user. Kubernetes can only check that a user isn't root + # when it gets a number, so the uid is written out (1000, seen in step 1.4). + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault # the container runtime's default syscall filter + containers: + - name: api + image: ghcr.io/devinder-dev/relay-api:__GIT_SHA__ + ports: + - name: http # probes, the Service and the Ingress use this name + containerPort: 8080 + # readiness: "may this pod get traffic?" No → out of the Service, but kept running. + # liveness: "is it alive?" No, 3 times → restarted. Never checks the database. + readinessProbe: + httpGet: + path: /health/ready + port: http + periodSeconds: 5 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health/live + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + failureThreshold: 3 + + # Before SIGTERM, wait 5 s, so Traefik stops sending this pod traffic first. + # Then server.ts closes the app: running requests finish, new ones get 503. + lifecycle: + preStop: + sleep: + seconds: 5 + # requests = guaranteed (the HPA calculates its % from cpu later). + # limits = the ceiling: above 256 MiB of memory the container is restarted. + # The 1.3 load test peaked at 93 MB. + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] diff --git a/k8s/app/ingress.yaml b/k8s/app/ingress.yaml new file mode 100644 index 0000000..ccb43fe --- /dev/null +++ b/k8s/app/ingress.yaml @@ -0,0 +1,29 @@ +# Ingress: the route from https://api.myrelay.tech to the API's Service (step 1.7). +# It takes over the host from the hello app, which is removed by hand at the first +# deploy (step 1.8): two Ingresses for the same host would compete in Traefik. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: relay-api + namespace: relay + annotations: + # cert-manager sees this and gets a certificate for the host. Secrets belong to one + # namespace, so this is a new certificate for "relay", not the hello app's copy. + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: traefik + tls: + - hosts: + - api.myrelay.tech + secretName: api-myrelay-tech-tls + rules: + - host: api.myrelay.tech + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: relay-api + port: + name: http diff --git a/k8s/app/service.yaml b/k8s/app/service.yaml new file mode 100644 index 0000000..a57efaa --- /dev/null +++ b/k8s/app/service.yaml @@ -0,0 +1,15 @@ +# Service: a stable name ("relay-api") inside the cluster for the API pods (step 1.7). +# Pods come and go during a rollout; the Service always points at the ready ones. +apiVersion: v1 +kind: Service +metadata: + name: relay-api + namespace: relay +spec: + selector: + app.kubernetes.io/name: relay-api # the label the Deployment gives its pods + ports: + - name: http + port: 80 # the Service listens on 80... + targetPort: http # ...and forwards to the pods' port named "http" (8080) + # The metrics port (9090) is added in Phase 7, when /metrics exists. From a32563ab1b1e7950d97333a069a5f7f275110094 Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Thu, 29 Oct 2026 13:18:07 +0100 Subject: [PATCH 2/2] infra: no service account token and a read-only filesystem for the API pods --- k8s/app/deployment.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/k8s/app/deployment.yaml b/k8s/app/deployment.yaml index 7c86e92..2ac0b6e 100644 --- a/k8s/app/deployment.yaml +++ b/k8s/app/deployment.yaml @@ -28,6 +28,8 @@ spec: labels: app.kubernetes.io/name: relay-api # ...and give every new pod this label spec: + # The API never talks to Kubernetes, so its pods get no ServiceAccount token at all. + automountServiceAccountToken: false # Run as the image's "bun" user. Kubernetes can only check that a user isn't root # when it gets a number, so the uid is written out (1000, seen in step 1.4). securityContext: @@ -66,7 +68,8 @@ spec: seconds: 5 # requests = guaranteed (the HPA calculates its % from cpu later). # limits = the ceiling: above 256 MiB of memory the container is restarted. - # The 1.3 load test peaked at 93 MB. + # The 1.3 load test peaked at 93 MB. No CPU limit on purpose: it would slow the API + # down even when the server has CPU to spare; the request is what scheduling uses. resources: requests: cpu: 100m @@ -75,5 +78,6 @@ spec: memory: 256Mi securityContext: allowPrivilegeEscalation: false + readOnlyRootFilesystem: true # nothing in the image can be changed at runtime capabilities: drop: ["ALL"]