0%
Deploying Complex Applications

Complex Applications

Putting the pieces together: an entry point, quotas, and elasticity.

15-20 min

Deploying Complex Applications

You now know how to write a Deployment, expose it, inject its configuration, and give it a volume. A real application, however, is made up of several services that have to talk to each other, be reachable from the internet under a single domain name, avoid starving one another, and absorb a traffic spike without human intervention.

Three new problems appear at this scale. The first is the entry point: creating one LoadBalancer Service per microservice means as many billed public addresses, with no path-based routing and no shared certificate. The second is resource sharing: with no declared constraints, a service that leaks memory can bring down neighboring Pods on the same node. The third is elasticity: sizing for the peak means paying for the peak all the time.

The fourth problem is not technical but organizational, and it is often the one that bites fastest. An application with five services amounts to about thirty manifests, to be duplicated for each environment while changing three values. Copy-paste then becomes a major source of outages: someone edits the staging file and forgets the production one.

This lesson assembles the building blocks in a complete example, then addresses that last point with a first look at Helm. The goal is not to add new concepts, but to show how the ones you already know fit together.

The Reference Application

Three components in a dedicated namespace: a PostgreSQL database, an API, and a web interface. The namespace is not decorative — it carries the quotas and access rules, and lets you delete everything in one command.

apiVersion: v1
kind: Namespace
metadata:
  name: boutique
  labels:
    environnement: production

The Database

A StatefulSet with its headless Service, exactly following the pattern from the previous lesson: serviceName: postgres, clusterIP: None, and a 10 GiB volumeClaimTemplates. Two additions specific to a complete deployment, to be inserted in the container:

          # A generous reservation: a database scheduled on an already
          # loaded node will be slow without any alert firing.
          resources:
            requests:
              cpu: "250m"
              memory: "512Mi"
            limits:
              memory: "1Gi"
          # pg_isready prevents the API from receiving traffic before initialization.
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "boutique", "-d", "boutique"]
            initialDelaySeconds: 10
            periodSeconds: 5

The API

This is the most instructive manifest: it brings together configuration, secret, probes, and resources.

apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
  namespace: boutique
data:
  APP_ENV: "production"
  LOG_LEVEL: "info"
  # The Service name is enough: internal DNS resolves it within the namespace.
  DB_HOST: "postgres"
  DB_PORT: "5432"
  DB_NAME: "boutique"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: boutique
  labels:
    app: api
    tier: backend
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0     # capacity never drops during the switchover
      maxSurge: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
        tier: backend
    spec:
      containers:
        - name: api
          image: exemple/boutique-api:1.4.0
          ports:
            - containerPort: 8080
              name: http
          envFrom:
            - configMapRef:
                name: api-config
            - secretRef:
                name: db-credentials
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              memory: "256Mi"
          readinessProbe:
            httpGet:
              path: /ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: boutique
spec:
  type: ClusterIP
  selector:
    app: api
  ports:
    - port: 8080
      targetPort: 8080
      name: http

The Web Interface

Same structure: a Deployment with two replicas and a ClusterIP Service on port 80. Only one point needs attention, and it often trips people up:

          env:
            # The browser is NOT inside the cluster: it can't resolve
            # http://api:8080. The URL must be a public path on the Ingress.
            - name: API_BASE_URL
              value: "/api"

Only code running inside the cluster benefits from internal DNS. Browser JavaScript necessarily goes through the public entry point — hence the next section.

The Ingress

An Ingress is an HTTP routing rule: a domain name, paths, and target Services. A single entry point serves the whole application.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: boutique
  namespace: boutique
  annotations:
    # Rewrites /api/produits to /produits before forwarding.
    # This annotation is SPECIFIC to ingress-nginx.
    nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - boutique.example.com
      secretName: boutique-tls     # Secret of type kubernetes.io/tls
  rules:
    - host: boutique.example.com
      http:
        paths:
          - path: /api(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: api
                port:
                  number: 8080
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80

The point to understand before you spend an hour debugging: the Ingress object does nothing on its own. It is an inert declaration until an ingress controller has read it. Without an installed controller, apply succeeds and nothing gets routed.

minikube addons enable ingress
kubectl get pods -n ingress-nginx     # is the controller running?
kubectl get ingress -n boutique       # empty ADDRESS = rule not picked up
minikube ip                           # the address to put in /etc/hosts

A second, more structural caveat: annotations are specific to each controller. nginx.ingress.kubernetes.io/rewrite-target means nothing to Traefik, HAProxy, or the AWS Load Balancer Controller, which have their own prefixes and their own syntax. Only the rules structure is standardized; rewriting, timeouts, rate limiting, and HTTPS redirection are all dialect. This divergence is what motivated the Gateway API, which is expected to eventually replace Ingress.

There are three pathType values: Exact (strict match), Prefix (by path segments, the common case), and ImplementationSpecific, which is delegated to the controller and required for capture patterns like the one above.

Resource Requests and Limits

Two values, two distinct roles. Confusing them produces outages that are hard to interpret.

          resources:
            requests:            # RESERVATION: used for scheduling
              cpu: "100m"        # 100 millicores = 0.1 core
              memory: "128Mi"
            limits:              # CAP: used to contain runaway usage
              memory: "256Mi"

requests is what the scheduler uses to choose a node: it adds up the requests of the Pods already present and places a new Pod only if the sum stays under the node’s capacity. A Pod without requests is placed anywhere, including on an already saturated node.

limits is the cap enforced at runtime, and CPU and memory react to it very differently:

  • Exceeding the CPU limit causes throttling: the container is slowed down. The application becomes slow, with no error and no restart — a sneaky symptom, often wrongly blamed on the network or the database.
  • Exceeding the memory limit causes an OOMKilled: the container is killed immediately. Memory is not compressible.
kubectl describe pod api-7d4b8c9f5-xk2mp | grep -A5 "Last State"
kubectl top pods -n boutique     # actual consumption, if metrics-server is present
kubectl top nodes

Hence the recommended practice, counterintuitive at first: a memory limit, but no CPU limit — which is the choice made in the manifests above. The memory limit protects the node from a leaking neighbor, because memory saturation triggers mass evictions. A CPU limit, on the other hand, leaves processor capacity unused when an application could be using it: the request already guarantees its share under contention.

The namespace can enforce guardrails, rather than depending on the discipline of each manifest author:

apiVersion: v1
kind: LimitRange
metadata:
  name: valeurs-par-defaut
  namespace: boutique
spec:
  limits:
    - type: Container
      default:                 # limits applied if absent from the Pod
        memory: "256Mi"
      defaultRequest:          # requests applied if absent from the Pod
        cpu: "100m"
        memory: "128Mi"
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: quota-boutique
  namespace: boutique
spec:
  hard:
    requests.cpu: "4"
    requests.memory: "8Gi"
    limits.memory: "16Gi"
    persistentvolumeclaims: "10"

A consequence to anticipate: as soon as a ResourceQuota covers a compute resource, every Pod in the namespace must declare the corresponding value, otherwise its creation is rejected. The LimitRange is precisely what keeps this rule from breaking existing deployments.

Horizontal Autoscaling

The HorizontalPodAutoscaler adjusts the number of replicas based on an observed metric.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
  namespace: boutique
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70     # 70% of the REQUEST, not the limit
  behavior:
    scaleUp:                         # scale up fast to absorb a spike
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
    scaleDown:                       # scale down slowly, otherwise it oscillates
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60

The decisive detail is in the comment: averageUtilization: 70 applies to the request, not the limit. With requests.cpu: 100m, scaling triggers at an average consumption of 70 millicores. A poorly calibrated request therefore makes autoscaling erratic: too low, and it keeps adding replicas; too high, and it never reacts.

Two strict prerequisites explain nearly every HPA that “doesn’t work”: metrics-server must be installed, and the targeted containers must declare requests for the chosen metric — without a CPU request, the percentage can’t be computed.

minikube addons enable metrics-server
kubectl get hpa -n boutique       # TARGETS showing <unknown> = metrics missing
kubectl describe hpa api -n boutique

Finally, note that the HPA adds Pods but does not create nodes: if the cluster is full, the new Pods stay Pending. Adding machine capacity is the job of the Cluster Autoscaler or Karpenter, which are specific to the cloud provider.

Deploying and Verifying Everything

The creation order doesn’t need to be orchestrated: reconciliation loops converge, and a Pod that doesn’t yet find its dependency will be restarted.

kubectl apply -f k8s/ --recursive
kubectl rollout status deployment/api -n boutique
kubectl rollout status statefulset/postgres -n boutique
kubectl get all -n boutique
kubectl get events -n boutique --sort-by=.metadata.creationTimestamp

Diagnosing a multi-service application always follows the same path, from the outside in:

kubectl describe ingress boutique -n boutique   # 1. is the rule picked up?
kubectl get endpoints -n boutique               # 2. do the Services point to Pods?
kubectl get pods -n boutique                    # 3. READY 3/3, not just Running?
kubectl port-forward -n boutique service/api 8080:8080   # 4. bypass the Ingress

This progression isolates the failure in four commands. If port-forward works but the Ingress doesn’t, the problem is in the routing and not in the application — saving you an hour of reading application logs.

A First Look at Helm

These manifests work. The problem arrives with the second environment: the same thing with two replicas, a different domain name, and a staging image. Copy-paste creates two sources of truth that will drift apart.

Helm is a package manager for Kubernetes. A chart bundles parameterized manifests whose values are extracted into a separate file: Chart.yaml describes the package, values.yaml holds the default values, and templates/ contains the manifests.

# values.yaml
api:
  image:
    repository: exemple/boutique-api
    tag: "1.4.0"
  replicas: 3
ingress:
  host: boutique.example.com
# templates/deployment-api.yaml — excerpt
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-api
  namespace: {{ .Release.Namespace }}
spec:
  replicas: {{ .Values.api.replicas }}
  template:
    spec:
      containers:
        - name: api
          image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
# Check the rendered output WITHOUT sending anything to the cluster: always do this
helm template boutique ./boutique -f values-production.yaml

helm install boutique ./boutique -n boutique --create-namespace
helm upgrade boutique ./boutique -n boutique -f values-production.yaml
helm history boutique -n boutique
helm rollback boutique 1 -n boutique

What Helm brings: one set of manifests for several environments, versioned releases, and rollback at the scale of the entire application — whereas kubectl rollout undo handles only one object at a time. The cost, and it needs to be stated plainly: templating hurts readability. A heavily parameterized chart turns into an exercise in template logic where you can no longer see the YAML it produces. That is why alternatives like Kustomize, which layers patches on top of YAML that stays readable, have found their audience. Make it a habit to run helm template before any helm upgrade: you will see what the cluster will actually receive.

Key Takeaways

  • Ingress consolidates a single HTTP entry point for all your Services. It does nothing without an ingress controller installed, and its annotations are specific to each controller: only the rules structure is portable.
  • requests is used for scheduling, limits for runtime enforcement. Exceeding the CPU limit silently slows the container down (throttling); exceeding the memory limit kills the container (OOMKilled). Hence the common practice: always set a memory limit, rarely a CPU limit.
  • The HPA works as a percentage of the request, not the limit. It requires metrics-server and declared requests, and it adds Pods without creating nodes.
  • Troubleshoot from the outside in: Ingress, then endpoints, then Pod status, then port-forward to bypass routing and isolate the faulty layer.
  • Helm avoids duplicating manifests across environments and provides a global rollback. The price is readability: always check the rendered output with helm template before applying.

Going further: the topics that naturally follow this course are RBAC, NetworkPolicies, observability (Prometheus, Grafana), and continuous deployment with GitOps. The official documentation at kubernetes.io/fr/docs/concepts/ remains the first reference to consult.

Comments

Comments are powered by GitHub Discussions

Sign in with GitHub to join the discussion

Link copied!