Lesson 17 of 32 · Level 3 — Application delivery
Deploy an app end to end
Ship one application properly, from Deployment to Service to Ingress: probes, resources and rolling updates on the Deployment, ports and selectors on the Service, host rules on the Ingress, and a hop-by-hop method to find exactly where a request stops.
The three layers
Ingress "podinfo.127.0.0.1.nip.io/ → Service podinfo:80"
│
Service "port 80 → pods with app=podinfo, targetPort 9898"
│
Deployment "3 replicas of podinfo, healthy, with resources, rolling updates"
Each layer has one job, and each can be checked on its own. This lesson builds all three for one app, carefully, on the ingress-enabled kind cluster from lesson 05.
Opening a lemonade stand: the Deployment is the team of sellers (and a rule that there are always three of them). The Service is the stand's fixed name board, so customers don't care which seller serves them. The Ingress is the signpost at the park entrance saying "Lemonade → this way". If customers can't find lemonade, you check the signpost, then the name board, then whether any sellers are actually working.
Layer 1: the Deployment
# deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: podinfo
labels: { app: podinfo }
spec:
replicas: 3
selector:
matchLabels: { app: podinfo }
strategy:
type: RollingUpdate
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
template:
metadata:
labels: { app: podinfo }
spec:
containers:
- name: podinfo
image: ghcr.io/stefanprodan/podinfo:6.7.0
ports:
- name: http
containerPort: 9898
readinessProbe:
httpGet: { path: /readyz, port: http }
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: http }
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 128Mi }
- Readiness decides when a pod gets traffic; liveness restarts a stuck container. Keep liveness about the process only (see SRE & Production Incident Response, lesson 03).
maxUnavailable: 0, maxSurge: 1: a new pod must be Ready before an old one is removed.- Requests let the scheduler place pods sensibly (lesson 23 goes deeper).
Layer 2: the Service
# svc.yaml
apiVersion: v1
kind: Service
metadata:
name: podinfo
spec:
selector: { app: podinfo } # must match the pod labels
ports:
- name: http
port: 80 # what clients use
targetPort: http # the container port (by name: 9898)
Using the port name (http) as targetPort means the Service keeps working if the container port number changes.
Layer 3: the Ingress
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: podinfo
spec:
ingressClassName: nginx
rules:
- host: podinfo.127.0.0.1.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: podinfo, port: { name: http } }
$ kubectl create namespace shop-web
$ kubectl apply -n shop-web -f deploy.yaml -f svc.yaml -f ingress.yaml
$ kubectl rollout status deploy/podinfo -n shop-web
$ curl -s http://podinfo.127.0.0.1.nip.io/ | head -5
{
"hostname": "podinfo-7c9d8f6b8-x2kqp",
"version": "6.7.0",
Run the curl a few times: the hostname changes as the Service spreads requests across pods.
Roll out, roll back
$ kubectl set image deploy/podinfo podinfo=ghcr.io/stefanprodan/podinfo:6.7.1 -n shop-web
$ kubectl rollout status deploy/podinfo -n shop-web
$ kubectl rollout history deploy/podinfo -n shop-web
$ kubectl rollout undo deploy/podinfo -n shop-web
In real teams, the image change is a Git commit and GitOps applies it (see GitOps with Argo CD). Choose image tags that exist in your registry; the versions above are examples.
Debug hop by hop
When "the site doesn't work", walk the path from the inside out, and stop at the first failing hop:
| Hop | Command | Healthy looks like |
|---|---|---|
| 1. Pods | kubectl get pods -n shop-web |
All Running, READY 1/1 |
| 2. Endpoints | kubectl get endpointslices -n shop-web |
Pod IPs listed on port 9898 |
| 3. Service in-cluster | kubectl run t --rm -it --image=busybox -n shop-web -- wget -qO- http://podinfo |
JSON response |
| 4. Ingress | kubectl describe ingress podinfo -n shop-web |
Right host/path/backend, ADDRESS set |
| 5. Outside | curl -v http://podinfo.127.0.0.1.nip.io/ |
HTTP 200 |
Most outages in this path are label/selector mismatches, wrong ports, failing readiness probes, or a missing ingressClassName.
Try it: break each layer
- Deploy the app as above and confirm step 5 works.
- Change the Service selector to
app: podinfo-x; walk the hops and find where it breaks (which status code does NGINX return?). Fix it. - Set
targetPort: 8080; observe the difference between "no endpoints" and "connection refused". Fix it. - Make the readiness probe path
/nope; watch a rollout stall withmaxUnavailable: 0while the old pods keep serving. Roll back. - Remove
ingressClassNameand see whether your controller still picks it up (depends on whether a default IngressClass is set).
Going deeper: production-ready defaults
- Add a PodDisruptionBudget and topology spread so drains and node failures don't take all replicas at once.
- Package the three manifests as a Helm chart or Kustomize base with per-environment overlays (see GitOps with Argo CD, lesson 04).
- Add HPA on CPU or requests per second (lesson 24), and alerts on the Ingress error rate (see Observability with OpenTelemetry, lesson 06).
Recap
- Deployment (probes, resources, rolling strategy) → Service (selector, port/targetPort) → Ingress (class, host, path).
- Readiness gates traffic;
maxUnavailable: 0+maxSurge: 1keeps capacity during rollouts. - Debug inside out: pods → endpoints → Service in-cluster → Ingress → outside.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.