Complex Applications
Putting the pieces together: an entry point, quotas, and elasticity.
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
rulesstructure is portable. requestsis used for scheduling,limitsfor 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-serverand declaredrequests, and it adds Pods without creating nodes. - Troubleshoot from the outside in: Ingress, then endpoints, then Pod status, then
port-forwardto 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 templatebefore 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