KubernetesContainersIntermediateOrchestrationHealth ChecksDeployments

Kubernetes Deployments, Services & Ingress

Jerry RajaonarivoUpdated Sep 30, 20267 min read

This covers the Deployment, Service, and Ingress settings, rollout strategy, readiness/liveness probes, and a PodDisruptionBudget, that decide whether a bad release is a non-event or an outage.

The Deployment: replicas, resources, and rollout strategy

The Deployment is the controller that keeps N copies of your pod running and manages the transition between versions. The part people skip is resources and strategy.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout-api
  labels:
    app: checkout-api
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: checkout-api
  template:
    metadata:
      labels:
        app: checkout-api
    spec:
      containers:
        - name: checkout-api
          image: registry.example.com/checkout-api:1.8.2
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              memory: "512Mi"

maxUnavailable: 0 with maxSurge: 1 means Kubernetes always spins up one extra pod before tearing down an old one, so you never drop below full capacity, at the cost of a slightly slower rollout and briefly running N+1 pods, worth it every time for anything client-facing. The opposite setting (maxUnavailable: 1, maxSurge: 0) kills a pod before its replacement is ready, which is exactly how a "routine deploy" becomes a capacity incident on a service already close to its limits.

On resources, set requests to what the container needs under normal load. The scheduler uses them to place pods, and the HPA computes utilization as a percentage of them. I don't set a CPU limit. CPU is compressible, so a CPU limit only throttles the container when the node has idle cycles to spare, and the CPU request already guarantees its share under contention. Memory works differently. A container that breaches its memory limit gets OOMKilled, so set a memory limit and leave headroom above the request.

Readiness vs. liveness probes: two different questions

Mixing these two up causes more production incidents than anything else on this list: the two probes look identical in YAML but do opposite things when they fail.

  • Readiness probe: "should this pod receive traffic right now?" Failing it removes the pod from the Service's endpoint list; it keeps running, just out of rotation.
  • Liveness probe: "is this process alive enough to keep, or should it be killed and restarted?" Failing it kills the container.
yaml
          readinessProbe:
            httpGet:
              path: /healthz/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /healthz/live
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 10
            failureThreshold: 3

Never point your liveness probe at an endpoint that checks downstream dependencies (database, cache, third-party API): if the database has a slow minute, a liveness probe checking DB connectivity kills every pod at once, turning a database blip into a full outage. Readiness can check downstream health; liveness should only check whether its own process is responsive.

Set the failureThreshold × periodSeconds window too short on a liveness probe and it'll restart a pod that's merely slow under load, not broken, dropping in-flight requests. If the whole fleet hits the same load spike, that becomes a self-inflicted crash loop: the "fix" for slowness is repeatedly killing the pods trying to catch up. When in doubt, give liveness a longer, more forgiving window than readiness.

The Service: how traffic actually finds a pod

Pods are disposable and get new IPs constantly; a Service gives them a stable virtual IP and DNS name, using a label selector to decide membership.

yaml
apiVersion: v1
kind: Service
metadata:
  name: checkout-api
spec:
  type: ClusterIP
  selector:
    app: checkout-api
  ports:
    - port: 80
      targetPort: 8080
      protocol: TCP

The selector here (app: checkout-api) has to match the labels on the pod template in the Deployment. The Deployment's own metadata doesn't count. This is the copy-paste bug I see most often. Someone renames the Deployment, forgets to update the pod template labels, and the Service ends up with zero endpoints and no error anywhere. When I hear "the Service isn't working", the first command I run is kubectl get endpointslices -l kubernetes.io/service-name=checkout-api. It replaces kubectl get endpoints, which prints a deprecation warning since Kubernetes 1.33. In my experience, a slice with no endpoints almost always means a selector mismatch.

The Ingress: TLS termination and routing

ClusterIP only routes traffic inside the cluster. An Ingress maps an external hostname to that internal Service and, paired with cert-manager, handles TLS termination without you touching a certificate by hand.

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: checkout-api
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - api.example.com
      secretName: checkout-api-tls
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: checkout-api
                port:
                  number: 80

ingressClassName: nginx and the nginx.ingress.kubernetes.io/* annotations target the community ingress-nginx controller, which was retired in March 2026 and no longer receives releases or security fixes. The Ingress API itself still works with other controllers, but for a new cluster I'd go straight to Gateway API (below). If you still run ingress-nginx, plan the migration so you don't end up with an unpatched edge proxy in front of production.

The cert-manager.io/cluster-issuer annotation triggers cert-manager to request and renew a certificate automatically, storing it in the checkout-api-tls Secret named under tls.secretName, so certificate expiry stops being something a human has to track.

The Gateway API equivalent

Gateway API is the successor to Ingress. The cluster operator owns the Gateway (listeners, TLS), and each team attaches its own HTTPRoute. Here is the same routing, with TLS still issued by cert-manager:

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: public
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  gatewayClassName: gke-l7-global-external-managed # use your implementation's GatewayClass
  listeners:
    - name: https
      hostname: api.example.com
      protocol: HTTPS
      port: 443
      tls:
        mode: Terminate
        certificateRefs:
          - name: checkout-api-tls
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: checkout-api
spec:
  parentRefs:
    - name: public
      sectionName: https
  hostnames:
    - api.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: checkout-api
          port: 80

Check two things before copying this. First, the gatewayClassName depends on your controller. kubectl get gatewayclass lists what's installed, and the one above is GKE's managed external load balancer. Second, cert-manager only watches Gateways once its Gateway API support is enabled (config.gatewayAPI.enabled: true in the Helm values of current releases).

The full request path, including a rolling update:

PodDisruptionBudget: surviving node drains

Everything above protects you during a rollout you control, not a node drain: a cluster autoscaler scaling down, a node cordoned for maintenance, an underlying VM replaced. Without a PodDisruptionBudget, Kubernetes is free to evict every pod of your Deployment on a single node at once, even down to zero replicas.

yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: checkout-api-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: checkout-api

minAvailable: 2 tells the eviction API never to voluntarily take this Deployment below 2 ready pods. Node drains, autoscaler scale-downs and kubectl drain all go through that API and respect it. With 4 healthy replicas, that still allows two pods to be evicted at the same time. If you want evictions strictly one at a time, use maxUnavailable: 1 instead of minAvailable. It also stays correct when the HPA changes the replica count. Most teams skip this object until a routine node upgrade takes a service down.

None of these objects are complicated individually. Teams get burned shipping them once, copying the YAML from a tutorial, and never coming back to add probes, a rollout strategy, or a PDB before real traffic hits. Add them up front, and a rollout gets boring. In production, boring beats interesting.

GO FURTHER

Want to actually run this in production?

This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.

This tutorial

  • Core architecture & key concepts
  • Illustrative code snippets
  • The reasoning behind each decision

1:1 mentoring

  • Working sessions on your own environment
  • Direct answers to the edge cases you're hitting
  • Feedback on your actual implementation
  • Ongoing support as you build it out
Get in TouchReply within 1 business day.

Related Tutorials