Three questions were left open at the end of the previous lesson. How do we make the schema migration run automatically before bookings-api starts, instead of as a separate Job? How do we stop bookings-api from going into CrashLoopBackOff when it starts before bookings-postgres? How do we export PostgreSQL metrics without touching the official image?
All three have the same answer: by putting more than one container in the same pod.
Since lesson 02-01 we have known that a pod is a group of containers sharing a network namespace — and therefore localhost —, volumes and a life cycle. Until now we had not made use of that capability: every Rutas Norte pod has a single container. In this lesson we will use it with judgement, which is the hard part: most badly designed multi-container pods come from lumping two applications together because "they are related", and that always ends badly.
Contents
- When a pod needs several containers and when that is a mistake
- Init containers: sequential preparation before start-up
- Init container use cases in Rutas Norte
- Diagnosing failed init containers
- Native sidecars: init containers with
restartPolicy: Always - The three classic patterns: sidecar, ambassador and adapter
- Sidecar pattern: a metrics exporter alongside
bookings-postgres - Ambassador pattern: the connection to the payment gateway
- Adapter pattern: normalising the
notifications-workerlog - Start-up and termination order, and the real cost
- When a pod needs several containers and when that is a mistake
The golden rule, stated precisely:
Two processes go in the same pod when they are so tightly coupled that it makes no sense to scale, update or run them separately, and one of them exists solely to serve the other.
The immediate consequence of sharing a pod is that they share a fate: they are scheduled on the same node, they scale together, they restart together and they are updated together. If bookings-api needs 5 replicas and notifications-worker needs 2, putting them in the same pod forces you to have 5 of each. That is a design mistake, not an optimisation.
| Situation | Same pod? | Why |
|---|---|---|
bookings-api + its metrics exporter |
Yes | The exporter is useless without its application; they scale together |
bookings-api + notifications-worker |
No | They scale differently, they are deployed differently, they are two applications |
notifications-worker + a log adapter |
Yes | The adapter processes the local file of that particular process |
web-store + bookings-postgres |
No | Radically different life cycles and storage needs |
| Main process + a proxy towards an external service | Yes | The proxy is local infrastructure for the process |
| A log collector for the whole node | No | That is a DaemonSet (06-02) |
Three sanity-check questions before adding a container to an existing pod:
- Do they always need to scale together? If the answer is no, they are two different workloads.
- Does the second one serve only the first? If other pods need it too, it is a separate Service.
- Do they need to share
localhostor a file system? If not, there is no technical reason to put them together.
Kubernetes offers two categories of auxiliary container:
initContainers: they run first, in order, one after another, and must finish successfully.- Auxiliary containers that accompany the main one throughout the pod's life: the sidecars.
- Init containers: sequential preparation before start-up
An init container is a container that runs to completion before any main container starts. If there are several, they run in the order in which they appear in the manifest, each waiting for the previous one to finish successfully.
graph LR A[Pod scheduled] --> I1[initContainer 1<br/>wait-for-postgres] I1 -->|exit 0| I2[initContainer 2<br/>migrate-schema] I2 -->|exit 0| C[containers<br/>the api starts] I1 -->|exit != 0| R1[initContainer restarted] R1 --> I1
Properties that set them apart from normal containers:
| Property | Init container | Main container |
|---|---|---|
| When it runs | Before all the main ones | After all the init ones |
| Execution | Sequential, one after another | All in parallel |
| Must it finish | Yes, with code 0 | No; finishing is an anomaly |
| If it fails | Retried according to the pod's restartPolicy |
Restarted according to restartPolicy |
| Probes | No readinessProbe or startupProbe allowed |
Yes |
| Image | Usually a different one, with more tools | The application's |
That last row is more useful than it looks: the init container can use an image with psql, curl or git that the production image does not have, without fattening the final image or widening its attack surface.
Basic syntax:
spec:
template:
spec:
initContainers:
- name: first
image: busybox:1.36
command: ["sh", "-c", "echo preparing; sleep 2"]
- name: second
image: busybox:1.36
command: ["sh", "-c", "echo ready"]
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:2.5.0Init containers also consume resources and take part in the calculation of what the pod asks the scheduler for. The effective formula is:
pod request = max( largest request among the initContainers ,
sum of the requests of the containers )That is, an init container asking for 2 GiB forces the scheduler to find a node with 2 GiB free, even if the application only needs 256 MiB. It pays to keep them light.
- Init container use cases in Rutas Norte
Case 1: waiting for bookings-postgres to accept connections
When an environment is brought up from scratch, bookings-api may start before the database is ready, fail to connect, exit and go into CrashLoopBackOff. It eventually recovers, but the start-up takes minutes and the events fill up with noise that masks real problems.
initContainers:
- name: wait-for-postgres
image: postgres:16.4
command:
- /bin/sh
- -c
- |
echo "Waiting for bookings-postgres..."
until pg_isready -h bookings-postgres -p 5432 -U "${PGUSER}" -t 3; do
echo " no answer yet; retrying in 2 s"
sleep 2
done
echo "bookings-postgres is accepting connections"
env:
- name: PGUSER
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: username
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 100m
memory: 128Mipg_isready is a utility from the PostgreSQL image itself that returns 0 if the server accepts connections. The loop deliberately has no attempt limit: the pod will sit in Init:0/1 indefinitely, which in kubectl get pods is a crystal-clear signal of "the database is not available", far better than a CrashLoopBackOff whose cause you have to go and dig out of the logs.
An honest design note: this does not replace the application handling reconnection. If bookings-postgres goes down two hours later, the init container is no longer around to help. It is a convenience for start-up, not a guarantee of resilience.
Case 2: running the schema migration
Here we pick up the question left open by lesson 06-03. The migration can live in an init container instead of a separate Job:
initContainers:
- name: wait-for-postgres
# ... as above ...
- name: migrate-schema
image: registry.rutasnorte.example/bookings-api-migrations:2.5.0
command: ["/app/migrate", "--to=2.5.0"]
env:
- name: PGHOST
value: bookings-postgres
- name: PGUSER
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: password
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256MiThe obvious advantage: the migration is coupled to the deployment. It is impossible to deploy the 2.5.0 code without having migrated, because the application container does not start unless the init container finishes successfully.
But there is an important trap you need to know about:
With 4 replicas of
bookings-api, the migration init container runs 4 times, potentially in parallel during an update.
Consequences and mitigations:
- The migration must be idempotent and protected by a lock.
IF NOT EXISTSis not enough: two simultaneousALTER TABLEs can block each other. A serious migration tool (Flyway, Liquibase,golang-migrate) takes an application lock in the database and the other instances wait. - If the migration takes minutes, every replica pays that wait, and the deployment drags on.
| Approach | Advantage | Drawback |
|---|---|---|
| Separate Job (06-03) | Runs once; explicit control | You have to remember to launch it first; it can be forgotten |
| initContainer in the Deployment | Impossible to deploy without migrating | Runs once per replica; requires a lock and idempotency |
Recommendation for Rutas Norte: a separate Job for large or destructive migrations (indexes over millions of rows, type changes), an initContainer for the small, idempotent day-to-day migrations.
Case 3: preparing files in a shared emptyDir
web-store serves static HTML from nginx, and the footer template changes with the environment. Instead of building three images, an init container generates the file on a shared volume:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-store
namespace: rutas-norte-pro
labels:
app: web-store
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
replicas: 3
selector:
matchLabels:
app: web-store
environment: pro
template:
metadata:
labels:
app: web-store
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
automountServiceAccountToken: false
initContainers:
- name: prepare-content
image: busybox:1.36
command:
- sh
- -c
- |
set -eu
cp /templates/index.html /public/index.html
sed -i "s|__ENVIRONMENT__|${ENVIRONMENT}|g" /public/index.html
sed -i "s|__NODE__|${NODE}|g" /public/index.html
echo "Content prepared for the ${ENVIRONMENT} environment"
env:
- name: ENVIRONMENT
value: "pro"
- name: NODE
valueFrom:
fieldRef:
fieldPath: spec.nodeName
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
volumeMounts:
- name: templates
mountPath: /templates
readOnly: true
- name: public
mountPath: /public
containers:
- name: nginx
image: nginx:1.27.2-alpine
ports:
- name: http
containerPort: 80
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 300m
memory: 128Mi
volumeMounts:
- name: public
mountPath: /usr/share/nginx/html
readOnly: true
volumes:
- name: templates
configMap:
name: web-store-templates
- name: public
emptyDir: {}The mechanism is exactly the emptyDir of 05-01: an empty volume created with the pod and shared by all its containers. The init container writes, nginx reads it read-only. When the pod dies, the emptyDir disappears, and it does not matter: it is regenerated on the next start-up.
- Diagnosing failed init containers
Init containers have their own states in the STATUS column, and knowing how to read them saves a lot of time.
STATUS |
Meaning |
|---|---|
Init:0/2 |
Running the first init container of two; none completed |
Init:1/2 |
The first finished successfully; running the second |
Init:Error |
An init container exited with a non-zero code and restartPolicy: Never |
Init:CrashLoopBackOff |
An init container fails repeatedly with restartPolicy: Always |
PodInitializing |
All the init ones finished; starting the main ones |
Running |
Everything up and running |
A real case: bookings-api stuck because the database does not answer.
Init:0/2 sustained for four minutes: the first init container (wait-for-postgres) is still in its loop.
Waiting for bookings-postgres...
no answer yet; retrying in 2 s
no answer yet; retrying in 2 s
no answer yet; retrying in 2 sThe key is -c <name>. Without that flag, kubectl logs tries to read the main container, which does not exist yet, and answers with a confusing error.
Another case: the migration fails.
Applying migration 2.5.0...
ERROR: column "sales_channel" of relation "bookings" already exists (SQLSTATE 42701)
migration failedDiagnosis: the migration is not idempotent. The IF NOT EXISTS was missing.
Useful commands for inspecting init containers:
# Names of a pod's init containers
kubectl get pod <pod> -n <ns> \
-o jsonpath='{range .spec.initContainers[*]}{.name}{"\n"}{end}'
# Detailed status of each one
kubectl get pod <pod> -n <ns> -o jsonpath='{.status.initContainerStatuses}' | python3 -m json.tool
# The full view, with the "Init Containers" section separated out
kubectl describe pod <pod> -n <ns>In the describe output, the init containers appear in an Init Containers: block before the Containers: block, each with its state, its exit code and its termination reason.
- Native sidecars: init containers with
restartPolicy: Always
restartPolicy: AlwaysFor years, a sidecar was simply "one more container in the containers list". It worked, but it had two serious flaws that Kubernetes 1.29 fixed and that have been stable since 1.33.
The two problems with the classic sidecar
Problem 1: there is no start-up order. All the containers in containers start in parallel. If the sidecar is a proxy through which the application has to reach the network, and the application starts before the proxy, the first requests fail.
Problem 2: Jobs never finished. A Job whose pod has a sidecar was a problem with no clean solution. The main container finishes successfully, but the sidecar stays alive, so the pod never reaches Succeeded and the Job hangs indefinitely. The only way out was ugly workarounds: sentinel files in an emptyDir, or having the main container kill the sidecar through the API.
The solution: restartPolicy: Always in an init container
initContainers:
- name: metrics-exporter
image: prometheuscommunity/postgres-exporter:v0.16.0
restartPolicy: Always # <- this turns it into a native sidecar
ports:
- name: metrics
containerPort: 9187That single field completely changes the container's semantics:
| Normal init container | Native sidecar (restartPolicy: Always) |
Container in containers |
|
|---|---|---|---|
| When it starts | In order, before everything | In order, before the main ones | In parallel with the others |
| Does it block the next one? | Yes, until it finishes | No: it only has to be started | Not applicable |
| Duration | It finishes | Lives as long as the pod | Lives as long as the pod |
| If it exits | Move on to the next | It is restarted | It is restarted |
| When the pod ends | Already gone | Stopped after the main ones | Stopped in parallel |
| Does it block a Job from finishing? | No | No | Yes |
The four practical consequences, in order of importance:
- It starts before the main containers, guaranteeing that the proxy or the exporter is ready when the application starts working.
- It stays alive for the whole life of the pod and is restarted if it dies, which a normal init container does not do.
- It is terminated after the main containers, so a log sidecar captures the last messages of the shutdown.
- It does not stop a Job from finishing. When the containers in
containersare done, the kubelet stops the sidecars and the pod reachesSucceeded. This unblocks all batch work with sidecars: a CronJob with a proxy, with a metrics exporter or with a log adapter simply works.
A clarification about ordering: the pod does not wait for the sidecar to finish (it never will), but for it to be started — and to pass its startupProbe if it has one. Unlike normal init containers, sidecars do support probes, the subject of lesson 07-01.
A simple decision rule: if the auxiliary container has to be ready before the application, or if the pod belongs to a Job, use a native sidecar. In every other case, a normal container in containers is still perfectly valid.
- The three classic patterns: sidecar, ambassador and adapter
The three names come from the foundational article by Brendan Burns and Dave Oppenheimer on design patterns for distributed systems. They differ in which way the flow goes and what they transform.
| Pattern | What it does | Direction of flow | Example in Rutas Norte |
|---|---|---|---|
| Sidecar | Adds a capability the application lacks, without modifying it | Sideways: it observes or complements | Metrics exporter for bookings-postgres |
| Ambassador | Mediates the outbound connection to an external service | Application → outside | Proxy towards pagos.proveedorexterno.example |
| Adapter | Normalises the application's output into a standard format | Application → outside, transforming | Converting the notifications-worker proprietary log to JSON |
Another way of remembering it:
- The sidecar adds something.
- The ambassador simplifies what the application sees of the outside world.
- The adapter simplifies what the outside world sees of the application.
All three rely on the same two mechanisms, which you already know from 02-01:
- Shared
localhost: all the containers in the pod share the network namespace, so they talk to each other over127.0.0.1without going through the cluster network, without DNS and with no appreciable latency. Corollary: two containers in the same pod cannot use the same port. - Shared volumes: an
emptyDirmounted in both allows files to be passed along. It is the channel used by the adapter pattern.
graph TB
subgraph POD[Pod]
direction LR
APP[Main container]
SC[Sidecar<br/>adds a capability]
EM[Ambassador<br/>outbound proxy]
AD[Adapter<br/>normalises the format]
APP <-->|localhost| SC
APP -->|localhost:8080| EM
APP -->|emptyDir| AD
end
EM -->|TLS + retries| EXT[pagos.proveedorexterno.example]
SC -->|:9187/metrics| PROM[Prometheus 07-03]
AD -->|stdout JSON| LOGS[Collector 06-02]
- Sidecar pattern: a metrics exporter alongside
bookings-postgres
bookings-postgresThe postgres:16.4 image does not expose metrics in Prometheus format. Modifying it would be a mistake: we would lose the official updates and would have to maintain an image of our own. The solution is a sidecar that connects to PostgreSQL over localhost, runs status queries and publishes the result at /metrics.
We add the sidecar to the StatefulSet we built in 06-01:
# k8s/base/bookings-postgres-statefulset.yaml (fragment)
spec:
template:
spec:
serviceAccountName: bookings-postgres
automountServiceAccountToken: false
initContainers:
- name: metrics-exporter
image: prometheuscommunity/postgres-exporter:v0.16.0
restartPolicy: Always # native sidecar
ports:
- name: metrics
containerPort: 9187
env:
# localhost: the sidecar shares the network of the PostgreSQL container
- name: DATA_SOURCE_URI
value: "localhost:5432/bookings?sslmode=disable"
- name: DATA_SOURCE_USER
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: username
- name: DATA_SOURCE_PASS
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: password
resources:
requests:
cpu: 20m
memory: 48Mi
limits:
cpu: 100m
memory: 96Mi
securityContext:
runAsNonRoot: true
runAsUser: 65534
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
containers:
- name: postgres
image: postgres:16.4
# ... the rest the same as in 06-01 ...Points that explain the pattern:
DATA_SOURCE_URI: localhost:5432: there is no service name and no DNS. The sidecar talks to PostgreSQL through the pod's loopback. That is the fundamental advantage: minimal latency, no need to expose port 5432 to the cluster network for this, and no need for an extra NetworkPolicy, because the traffic never leaves the pod.- Native sidecar: it starts before PostgreSQL. Strictly speaking it is not essential here, but it guarantees that we do not lose the metrics of the first seconds of life, which are precisely those of the start-up and the WAL recovery.
- Modest, separate resources: 20m of CPU and 48 MiB. They are added to PostgreSQL's in the scheduler's calculation.
- Its own hardening: the sidecar runs as
nobodyand with a read-only file system. It needs nothing more, and this way a failure in the exporter does not compromise the database container.
An important note on QoS: in 03-05 we established that bookings-postgres is of class Guaranteed. For the pod to stay that way, all of its containers — sidecar included — must have requests equal to limits. With the values above (20m/100m, 48Mi/96Mi) the pod would drop to Burstable. If we want to keep Guaranteed, we have to make them equal:
It is an unintuitive consequence of adding sidecars that is worth keeping in mind.
The Service that exposes the metrics:
apiVersion: v1
kind: Service
metadata:
name: bookings-postgres-metrics
namespace: rutas-norte-pro
labels:
app: bookings-postgres
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
selector:
app: bookings-postgres
environment: pro
ports:
- name: metrics
port: 9187
targetPort: metricsChecking it:
kubectl exec -n rutas-norte-pro bookings-postgres-0 -c metrics-exporter -- \
wget -qO- localhost:9187/metrics | grep -E '^pg_up|^pg_stat_database_numbackends' | head -3pg_up 1 confirms that the exporter can connect; numbackends gives the active connections. These metrics are the ones Prometheus will collect in lesson 07-03; here we have only built the source.
- Ambassador pattern: the connection to the payment gateway
bookings-api takes payments through pagos.proveedorexterno.example, an external service with the usual complications: mutual TLS with a client certificate, retries with backoff, a circuit breaker for when the provider is slow, a request-per-second limit and a different test endpoint in each environment.
Putting all that logic into bookings-api means writing it in Node.js, maintaining it, testing it and doing it all again if a second provider appears tomorrow. The ambassador takes it out of the code: a local proxy that listens on localhost and takes care of everything.
# k8s/base/bookings-api-deployment.yaml (fragment)
spec:
template:
spec:
serviceAccountName: bookings-api
automountServiceAccountToken: false
initContainers:
- name: payments-ambassador
image: envoyproxy/envoy:v1.31.3
restartPolicy: Always # native sidecar: it must be ready before the API
args: ["-c", "/etc/envoy/envoy.yaml", "--log-level", "warn"]
ports:
- name: payments-local
containerPort: 8081
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
runAsNonRoot: true
runAsUser: 65534
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: ambassador-config
mountPath: /etc/envoy
readOnly: true
- name: payments-certificates
mountPath: /etc/certificates
readOnly: true
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:2.5.0
env:
# The application speaks plain HTTP to localhost. Nothing more.
- name: PAYMENT_GATEWAY_URL
value: "http://127.0.0.1:8081"
ports:
- name: http
containerPort: 3000
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
volumes:
- name: ambassador-config
configMap:
name: payments-ambassador-config
- name: payments-certificates
secret:
secretName: payments-client-certificate
defaultMode: 0400And the proxy configuration, boiled down to the essentials:
# k8s/base/payments-ambassador-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: payments-ambassador-config
namespace: rutas-norte-pro
data:
envoy.yaml: |
static_resources:
listeners:
- name: payments_local
address:
socket_address: { address: 127.0.0.1, port_value: 8081 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: payments
route_config:
virtual_hosts:
- name: payments
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: external_gateway
timeout: 8s
retry_policy:
retry_on: "5xx,connect-failure,reset"
num_retries: 3
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: external_gateway
connect_timeout: 3s
type: LOGICAL_DNS
circuit_breakers:
thresholds:
- max_connections: 50
max_pending_requests: 20
load_assignment:
cluster_name: external_gateway
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: pagos.proveedorexterno.example
port_value: 443
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
sni: pagos.proveedorexterno.example
common_tls_context:
tls_certificates:
- certificate_chain: { filename: /etc/certificates/tls.crt }
private_key: { filename: /etc/certificates/tls.key }What Rutas Norte has gained:
| Responsibility | Before: in bookings-api |
Now: in the ambassador |
|---|---|---|
| Mutual TLS with a client certificate | Node.js code + file handling | Declarative configuration |
| Retries on 5xx and dropped connections | A library and custom logic | retry_policy |
| Circuit breaker | A library and custom logic | circuit_breakers |
| Timeouts | Constants scattered through the code | timeout in one place |
| A different endpoint per environment | An environment variable and conditionals | One ConfigMap per environment |
| Certificate rotation | Redeploying the application | Changing the Secret |
And above all: bookings-api makes a plain HTTP POST to http://127.0.0.1:8081/charges and that is that. In local development testing, that endpoint can be a simulator; the application cannot tell the difference.
The NetworkPolicy from 04-06 that authorises egress to the gateway still applies to the pod, not to the container, so it does not change: the whole pod needs permission to reach external port 443.
A necessary clarification: if this idea is applied to all the traffic of all the pods, with a control plane distributing the configuration, it is no longer called an ambassador but a service mesh (Istio, Linkerd). That is the territory of lesson 08-04; here we solve one specific case without adopting a whole platform.
- Adapter pattern: normalising the
notifications-worker log
notifications-worker lognotifications-worker is a legacy component that writes to a file, with a format of its own and multi-line traces when there is an exception:
2026-08-05 03:14:22 | SEND_OK | booking=4471 | [email protected] | ms=312
2026-08-05 03:14:25 | SEND_ERR | booking=4472 | [email protected] | cause=SMTP timeout
at smtp.send (smtp.js:88)
at queue.process (queue.js:41)The DaemonSet log collector from 06-02 reads the containers' standard output, not arbitrary files, and even if it read them, that format is not queryable: there are no fields, and an exception is split into three unrelated entries.
The adapter solves both problems: it reads the file from a shared volume, joins the continuation lines and emits structured JSON on its own standard output, where the collector does pick it up.
# k8s/base/notifications-worker-deployment.yaml (fragment)
spec:
template:
spec:
automountServiceAccountToken: false
initContainers:
- name: log-adapter
image: fluent/fluent-bit:3.1.9
restartPolicy: Always # native sidecar: it stops AFTER the worker
resources:
requests:
cpu: 30m
memory: 48Mi
limits:
cpu: 100m
memory: 96Mi
volumeMounts:
- name: worker-logs
mountPath: /logs
readOnly: true
- name: adapter-config
mountPath: /fluent-bit/etc
readOnly: true
containers:
- name: worker
image: registry.rutasnorte.example/notifications-worker:1.9.3
env:
- name: LOG_FILE
value: /logs/notifications.log
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
volumeMounts:
- name: worker-logs
mountPath: /logs
volumes:
- name: worker-logs
emptyDir:
sizeLimit: 256Mi # without a limit, a runaway log fills the node's disk
- name: adapter-config
configMap:
name: log-adapter-config# k8s/base/log-adapter-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: log-adapter-config
namespace: rutas-norte-pro
data:
fluent-bit.conf: |
[SERVICE]
Flush 3
Log_Level error
Parsers_File parsers.conf
[INPUT]
Name tail
Path /logs/notifications.log
Tag notifications
Parser worker_rutasnorte
Multiline.parser worker_trace
Refresh_Interval 5
[FILTER]
Name record_modifier
Match notifications
Record component notifications-worker
Record environment pro
[OUTPUT]
Name stdout
Match notifications
Format json_lines
parsers.conf: |
[PARSER]
Name worker_rutasnorte
Format regex
Regex ^(?<time>[\d-]+ [\d:]+) \| (?<level>\w+) \| booking=(?<booking>\d+) \| recipient=(?<recipient>[^ ]+) \|(?<rest>.*)$
Time_Key time
Time_Format %Y-%m-%d %H:%M:%S
[MULTILINE_PARSER]
Name worker_trace
Type regex
Flush_Timeout 1000
Rule "start_state" "^\d{4}-\d{2}-\d{2} " "cont"
Rule "cont" "^ at " "cont"The result on the adapter's standard output, which is what the node collector takes away:
{"date":1754363665.0,"level":"SEND_ERR","booking":"4472","recipient":"[email protected]","rest":" cause=SMTP timeout\n at smtp.send (smtp.js:88)\n at queue.process (queue.js:41)","component":"notifications-worker","environment":"pro"}The exception travels whole in a single record, with its fields separated and tagged with the component and the environment. In 07-05 this will allow queries such as "all the SEND_ERRs for booking 4472".
Here the native sidecar contributes something concrete and important: because it is terminated after the main container, it captures the last messages notifications-worker writes during its orderly shutdown on SIGTERM (02-01). With a normal container in containers, both receive SIGTERM at the same time and those last lines — often the ones explaining why it stopped — are lost.
The sizeLimit: 256Mi on the emptyDir is not optional: an emptyDir with no limit writes to the node's disk until it fills it, and then the node goes into disk-pressure and evicts other people's pods.
- Start-up and termination order, and the real cost
The complete sequence
With init containers and native sidecars, a pod's life cycle looks like this:
sequenceDiagram
participant K as kubelet
participant I as normal initContainers
participant S as sidecars (restartPolicy Always)
participant C as main containers
K->>I: starts them in order; waits for each to finish successfully
I-->>K: exit 0
K->>S: starts them in order; waits for them to be started
S-->>K: started (and startupProbe passed if there is one)
K->>C: starts them all in parallel
Note over C: the pod's working life
K->>C: SIGTERM to the main ones
C-->>K: finished (or the grace period expired)
K->>S: SIGTERM to the sidecars, in reverse order
S-->>K: finished
Points to remember:
- Normal init containers run sequentially and to completion.
- Native sidecars start in the declared order, and only need to be started.
- The main containers all start at once, with no ordering guarantee among them.
- On termination, the main ones first; then the sidecars, in reverse order.
terminationGracePeriodSecondsbelongs to the pod, not to each container: it is the total budget for the whole shutdown.
The real cost
Each sidecar is one more container for every pod, and that multiplication is easy to underestimate. Rutas Norte's production figures:
| Component | Replicas | Sidecar | Sidecar CPU | Sidecar memory | Total CPU | Total memory |
|---|---|---|---|---|---|---|
bookings-api |
6 | Payments ambassador | 50m | 64Mi | 300m | 384Mi |
notifications-worker |
3 | Log adapter | 30m | 48Mi | 90m | 144Mi |
bookings-postgres |
1 | Metrics exporter | 100m | 96Mi | 100m | 96Mi |
| Total | 0.49 CPU | 624Mi |
Half a core and 600 MiB in auxiliary containers alone. During a bank-holiday peak, with bookings-api autoscaled to 20 replicas (09-01), the ambassador alone is 1 CPU and 1.25 GiB.
And there are less visible costs:
- Every sidecar is an image that has to be maintained, scanned and updated (08-05). Three sidecars are three more supply chains.
- Every sidecar can fail and, with
restartPolicy: Always, go into a restart loop dragging the whole pod with it. - Every sidecar adds time to the start-up, and the native ones add it sequentially before the application.
- Debugging gets harder: every
kubectl logsandkubectl execnow needs the-cflag.
Sanity-check questions before adding a sidecar:
- Could a DaemonSet do it, one per node instead of one per pod? For node logs and metrics, almost always yes.
- Could the application itself do it with a library? Sometimes five lines of code replace a 60 MiB container.
- Does the sidecar justify its cost multiplied by the maximum number of replicas? Work out the worst case, not the usual one.
Common Mistakes and Tips
Forgetting -c <container> in kubectl logs and kubectl exec. With several containers, kubectl demands to know which one. The error a container name must be specified is unambiguous, but when there are init containers the message can be misleading because the main container does not exist yet.
Putting an init container into an infinite loop with no visibility. An until ... done without an echo leaves the pod in Init:0/1 with no log explaining the wait. Always print something on each iteration.
Heavy init containers. The scheduler reserves the maximum among the init containers, so one asking for 2 GiB forces it to find a node with 2 GiB free even if the application needs 256 MiB. Keep them small.
Migrations in an initContainer without a lock. With N replicas, the migration runs N times, potentially in parallel. Without idempotency and without an application lock, the result is a half-migrated database. Use a migration tool that takes a lock, or a separate Job (06-03).
Two containers in the same pod listening on the same port. They share the network namespace, so the second one fails with address already in use. Keep a record of which port each auxiliary container uses (3000 the API, 8081 the ambassador, 9187 the exporter...).
A classic sidecar in a Job's pod. This is the historic trap: the Job never finishes. The solution in 1.29+ is a native sidecar with restartPolicy: Always as an init container.
An emptyDir without sizeLimit for logs. A log growing out of control fills the node's disk and causes disk-pressure, evicting pods that had nothing to do with it. Always set sizeLimit.
Breaking the QoS class by adding a sidecar. A pod is Guaranteed only if all of its containers have requests == limits. Adding a sidecar with different values downgrades the pod to Burstable and changes its eviction priority (03-05).
Tip: name your containers after their function, not their technology. payments-ambassador is far more useful in an alert at three in the morning than envoy.
Tip: kubectl describe pod is the best view. It shows Init Containers: and Containers: in separate blocks, with the state, the exit code and the reason for each. It is faster than chaining jsonpath expressions.
Tip: kubectl logs --all-containers=true dumps the whole pod at once, useful for reconstructing the sequence of a troublesome start-up.
Exercises
Exercise 1: an init container that waits for a dependency
In rutas-norte-dev, create a Deployment api-demo with one replica of nginx:1.27.2-alpine and an init container that waits for a Service called dependency-demo to exist and answer. Apply the Deployment before creating the dependency and watch the pod's state. Then create the dependency (a Deployment and a Service with nginx) and check that api-demo starts.
Exercise 2: adapter pattern with a shared emptyDir
In rutas-norte-dev, create a Deployment worker-demo with:
- A main container
workerthat every 5 seconds writes a line in a proprietary format to/logs/output.log(for example2026-08-05 10:00:00 | SEND_OK | booking=4471). - A native sidecar
adapter(an init container withrestartPolicy: Always) that follows that file and emits each line on its standard output with the prefix[adapted]. - A shared
emptyDirwithsizeLimit: 64Mi.
Verify that the sidecar starts before the main one and that it emits the lines.
Exercise 3: a native sidecar in a Job
In rutas-norte-dev, create a Job report-with-sidecar whose pod has a main container that takes 15 seconds and finishes, and a native sidecar that writes something every 3 seconds indefinitely. Check that the Job does reach Complete. Then reason about what would have happened if the sidecar had been declared in containers instead of as an init container with restartPolicy: Always.
Solutions
Solution 1
# /tmp/api-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-demo
namespace: rutas-norte-dev
labels:
app: api-demo
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
replicas: 1
selector:
matchLabels:
app: api-demo
environment: dev
template:
metadata:
labels:
app: api-demo
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
automountServiceAccountToken: false
initContainers:
- name: wait-for-dependency
image: busybox:1.36
command:
- sh
- -c
- |
echo "Waiting for dependency-demo:80..."
until wget -q -T 2 -O /dev/null http://dependency-demo:80 2>/dev/null; do
echo " no answer yet; retrying in 3 s"
sleep 3
done
echo "dependency-demo is available"
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 50m
memory: 32Mi
containers:
- name: nginx
image: nginx:1.27.2-alpine
ports:
- containerPort: 80
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128MiInit:0/1: the init container is running and none has completed.
Now the dependency:
kubectl create deployment dependency-demo -n rutas-norte-dev --image=nginx:1.27.2-alpine
kubectl label deployment dependency-demo -n rutas-norte-dev app=dependency-demo environment=dev --overwrite
kubectl expose deployment dependency-demo -n rutas-norte-dev --port=80
kubectl wait --for=condition=ready pod -l app=api-demo -n rutas-norte-dev --timeout=120s
kubectl get pods -n rutas-norte-dev -l app=api-demoThe pod went from Init:0/1 to PodInitializing and then to Running with no restarts at all. That RESTARTS: 0 is the improvement over letting the application go into CrashLoopBackOff while it waits.
Solution 2
# /tmp/worker-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: worker-demo
namespace: rutas-norte-dev
labels:
app: worker-demo
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
replicas: 1
selector:
matchLabels:
app: worker-demo
environment: dev
template:
metadata:
labels:
app: worker-demo
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
automountServiceAccountToken: false
initContainers:
- name: adapter
image: busybox:1.36
restartPolicy: Always # native sidecar
command:
- sh
- -c
- |
echo "[adapter] started before the worker"
touch /logs/output.log
tail -F /logs/output.log | while read -r LINE; do
echo "[adapted] $LINE"
done
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 50m
memory: 32Mi
volumeMounts:
- name: logs
mountPath: /logs
containers:
- name: worker
image: busybox:1.36
command:
- sh
- -c
- |
N=4471
while true; do
echo "$(date '+%Y-%m-%d %H:%M:%S') | SEND_OK | booking=$N" >> /logs/output.log
N=$(( N + 1 ))
sleep 5
done
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
volumeMounts:
- name: logs
mountPath: /logs
volumes:
- name: logs
emptyDir:
sizeLimit: 64Mikubectl apply -f /tmp/worker-demo.yaml
kubectl wait --for=condition=ready pod -l app=worker-demo -n rutas-norte-dev --timeout=120s
POD=$(kubectl get pod -n rutas-norte-dev -l app=worker-demo -o jsonpath='{.items[0].metadata.name}')
kubectl logs -n rutas-norte-dev "$POD" -c adapter --tail=4[adapter] started before the worker
[adapted] 2026-08-05 19:14:02 | SEND_OK | booking=4471
[adapted] 2026-08-05 19:14:07 | SEND_OK | booking=4472
[adapted] 2026-08-05 19:14:12 | SEND_OK | booking=4473The first line confirms the ordering: the sidecar printed its start-up message before the worker wrote anything, because native sidecars start before the containers in containers. The main container, on the other hand, prints nothing on its standard output:
All of its logging goes to the file, and it only reaches the node collector thanks to the adapter. That is precisely the purpose of the pattern.
Solution 3
# /tmp/report-with-sidecar.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: report-with-sidecar
namespace: rutas-norte-dev
labels:
app: occupancy-reports
environment: dev
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 3600
template:
metadata:
labels:
app: occupancy-reports
environment: dev
spec:
restartPolicy: Never
automountServiceAccountToken: false
initContainers:
- name: batch-metrics
image: busybox:1.36
restartPolicy: Always # native sidecar: does NOT stop the Job from finishing
command:
- sh
- -c
- 'while true; do echo "[metrics] heartbeat $(date +%H:%M:%S)"; sleep 3; done'
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 50m
memory: 32Mi
containers:
- name: generator
image: busybox:1.36
command:
- sh
- -c
- 'echo "generating the occupancy report..."; sleep 15; echo "report generated"'
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mikubectl apply -f /tmp/report-with-sidecar.yaml
kubectl wait --for=condition=complete job/report-with-sidecar -n rutas-norte-dev --timeout=120s
kubectl get job report-with-sidecar -n rutas-norte-devThe Job reaches Complete in 19 seconds even though the sidecar was still printing heartbeats indefinitely.
POD=$(kubectl get pod -n rutas-norte-dev -l job-name=report-with-sidecar -o jsonpath='{.items[0].metadata.name}')
kubectl logs -n rutas-norte-dev "$POD" -c batch-metrics --tail=3
kubectl get pod "$POD" -n rutas-norte-dev[metrics] heartbeat 19:22:14
[metrics] heartbeat 19:22:17
[metrics] heartbeat 19:22:20
NAME READY STATUS RESTARTS AGE
report-with-sidecar-4kx2p 0/2 Completed 0 45sThe pod is Completed with both of its containers stopped.
What would have happened with the sidecar in containers: the generator container would have finished successfully after 15 seconds, but batch-metrics would have stayed alive. A pod only reaches the Succeeded phase when all of its containers have finished, so the pod would have sat indefinitely in Running with 1/2 containers ready, and the Job at 0/1 completions forever. Only activeDeadlineSeconds would have cut it short, and with a Failed state.
That was exactly the historic problem native sidecars solved.
# Clean-up
kubectl delete -f /tmp/report-with-sidecar.yaml
kubectl delete -f /tmp/worker-demo.yaml
kubectl delete -f /tmp/api-demo.yaml
kubectl delete deployment,service dependency-demo -n rutas-norte-devConclusion
A pod with several containers is a powerful tool and an easy one to misuse. The rule that governs it is that processes should share a pod only when they are so tightly coupled that it makes no sense to scale or deploy them separately, and when one exists to serve the other.
Init containers run in order, to completion, before any main container starts, and in Rutas Norte they let us wait for bookings-postgres, apply small idempotent migrations, and prepare content in a shared emptyDir. Their states — Init:0/2, Init:Error, Init:CrashLoopBackOff — are diagnoses in themselves, and kubectl logs -c <name> is the command to internalise.
The native sidecars of Kubernetes 1.29+ are init containers with restartPolicy: Always: they start before the main containers, live for the whole pod, restart if they die, stop after the main ones and — the thing that settled a years-old problem — do not stop a Job from finishing.
The three classic patterns are distinguished by the direction of the flow: the sidecar adds a capability (the bookings-postgres metrics exporter that Prometheus will consume in 07-03); the ambassador mediates the outbound connection (the proxy that handles mutual TLS, retries and circuit breaking against pagos.proveedorexterno.example); the adapter normalises what the application produces (the JSON converter for the proprietary notifications-worker log). All three rely on localhost and shared volumes, and all three cost resources multiplied by the number of pods, something you have to work out for the worst case of scaling.
So far we have decided what runs and how each pod is composed. The question we have not touched is where: until now the kube-scheduler has placed our pods wherever it liked, and that has been enough. But bookings-postgres ought to be on a node with an SSD, the bookings-api replicas ought not to share a node — if that node goes down, the whole API goes with it —, and analytics workloads ought not to compete with ticket sales. All of that is controlled with affinity, taints and tolerations, and it is the subject of the next lesson: Scheduling.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
