On Kubernetes
A Deployment, a Service and two probes are enough for static assets, and the only real risk is a mid-rollout chunk mismatch.
kubectl set image deployment/web web=registry.example.com/web:1.4.2On Kubernetes the image from the previous lesson becomes two objects. A Deployment says how many copies of the image to run and how to replace them when the image changes. A Service gives those copies one stable address inside the cluster, which an Ingress or a cloud load balancer points at from the outside.
Why this is easier than a backend
A pod serving static files holds no database connection, runs no migration, keeps no session in memory and has no long request to drain. Every pod is byte-for-byte identical and interchangeable. So the rolling update has little to go wrong: start a new pod, wait for its readiness probe to pass, remove an old one, repeat. Resource requests are small, because nginx handing out cached files does almost nothing.
Probes
A readiness probe is a check Kubernetes uses to decide whether a pod should receive traffic. A liveness probe decides whether to restart it. For static assets an HTTP GET of /index.html answers both questions: if nginx responds, the files are there. There is nothing to warm up, so a startup probe is unnecessary. Keep the paths out of the SPA fallback, or a probe will pass against a rewritten response even when the file is missing.
What trips people up
During a rollout, two pods serve two different builds. A browser can load index.html from the new pod and then request a hashed chunk that the Service routes to the old pod, which does not have it. maxUnavailable: 0 keeps the site up but does not fix this. Carry the previous build's assets into the new image, or put a CDN in front of both.
Example
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const buildId: string = import.meta.env.VITE_BUILD_ID ?? 'dev'
const newBuild = ref<string | null>(null)
let timer: number | undefined
async function check(): Promise<void> {
try {
const res = await fetch('/version.json', { cache: 'no-store' })
if (!res.ok) return
const data = (await res.json()) as { buildId: string }
if (data.buildId !== buildId) newBuild.value = data.buildId
} catch {
// Offline, or mid-rollout. Try again on the next tick.
}
}
function reload(): void {
window.location.reload()
}
onMounted(() => {
void check()
timer = window.setInterval(() => void check(), 60_000)
})
onUnmounted(() => {
window.clearInterval(timer)
})
</script>
<template>
<p v-if="newBuild" class="update-bar" role="status">
Build {{ newBuild }} is live.
<button type="button" @click="reload">Reload</button>
</p>
</template>
<style scoped>
.update-bar {
padding: 0.5rem 1rem;
background: #fef3c7;
color: #78350f;
}
</style>When to use it
- Running the frontend image in the same cluster as the API so one Ingress serves both.
- Scaling the asset tier up before a launch without touching the backend.
- Rolling a frontend release forward and confirming it is serving before announcing it.
More examples
k8s/deployment.yaml
The whole frontend deployment. `maxUnavailable: 0` with `maxSurge: 1` means a new pod is always ready before an old one goes away, so the site never drops below three serving replicas. The image tag is the release identifier, which is what makes `kubectl rollout undo` possible later. Pin a tag rather than using `latest`: with `latest`, Kubernetes has no record of what the previous version was.
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app: web
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: registry.example.com/web:1.4.2
ports:
- name: http
containerPort: 80
readinessProbe:
httpGet:
path: /index.html
port: http
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet:
path: /index.html
port: http
periodSeconds: 20
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mik8s/service.yaml
The Service matches pods by the `app: web` label from the Deployment's pod template, not by the Deployment's own name. Getting that selector wrong gives a Service with no endpoints, which looks exactly like a crashed app from the outside. `ClusterIP` is right when an Ingress terminates TLS and routes by hostname; the Service only has to be reachable inside the cluster.
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: ClusterIP
selector:
app: web
ports:
- name: http
port: 80
targetPort: httpRolling out and checking
`rollout status` blocks until every replica is updated and ready, or exits non-zero on the timeout, which makes it the right last step in a deploy job. Run this pair by hand the first few times so you can see the readiness probe gating each pod. If `rollout status` times out, the new pods are failing their probe and the old ones are still serving, so the site is up and you have time to look.
# Roll forward to a new image, then watch it land.
kubectl set image deployment/web web=registry.example.com/web:1.4.2
kubectl rollout status deployment/web --timeout=120s
# What is actually serving right now.
kubectl get pods -l app=web -o wide
# The recorded revisions, which is what a rollback walks back through.
kubectl rollout history deployment/web
Discussion