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

  1. When a pod needs several containers and when that is a mistake
  2. Init containers: sequential preparation before start-up
  3. Init container use cases in Rutas Norte
  4. Diagnosing failed init containers
  5. Native sidecars: init containers with restartPolicy: Always
  6. The three classic patterns: sidecar, ambassador and adapter
  7. Sidecar pattern: a metrics exporter alongside bookings-postgres
  8. Ambassador pattern: the connection to the payment gateway
  9. Adapter pattern: normalising the notifications-worker log
  10. Start-up and termination order, and the real cost

  1. 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:

  1. Do they always need to scale together? If the answer is no, they are two different workloads.
  2. Does the second one serve only the first? If other pods need it too, it is a separate Service.
  3. Do they need to share localhost or 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.

  1. 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.0

Init 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.

  1. 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: 128Mi

pg_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: 256Mi

The 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 EXISTS is not enough: two simultaneous ALTER 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.

  1. 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.

kubectl get pods -n rutas-norte-pro -l app=bookings-api
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.

NAME                            READY   STATUS     RESTARTS   AGE
bookings-api-6c8f9d4b7-jm2xq    0/1     Init:0/2   0          4m

Init:0/2 sustained for four minutes: the first init container (wait-for-postgres) is still in its loop.

kubectl logs -n rutas-norte-pro bookings-api-6c8f9d4b7-jm2xq -c wait-for-postgres --tail=4
Waiting for bookings-postgres...
  no answer yet; retrying in 2 s
  no answer yet; retrying in 2 s
  no answer yet; retrying in 2 s

The 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.

NAME                            READY   STATUS       RESTARTS      AGE
bookings-api-7f4d2b9c8-k3n5p    0/1     Init:Error   3 (52s ago)   3m
kubectl logs -n rutas-norte-pro bookings-api-7f4d2b9c8-k3n5p -c migrate-schema --previous
Applying migration 2.5.0...
ERROR: column "sales_channel" of relation "bookings" already exists (SQLSTATE 42701)
migration failed

Diagnosis: 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.

  1. Native sidecars: init containers with restartPolicy: Always

For 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: 9187

That 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:

  1. It starts before the main containers, guaranteeing that the proxy or the exporter is ready when the application starts working.
  2. It stays alive for the whole life of the pod and is restarted if it dies, which a normal init container does not do.
  3. It is terminated after the main containers, so a log sidecar captures the last messages of the shutdown.
  4. It does not stop a Job from finishing. When the containers in containers are done, the kubelet stops the sidecars and the pod reaches Succeeded. 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.

  1. 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 over 127.0.0.1 without 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 emptyDir mounted 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]

  1. Sidecar pattern: a metrics exporter alongside bookings-postgres

The 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 nobody and 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:

          resources:
            requests:
              cpu: 100m
              memory: 96Mi
            limits:
              cpu: 100m
              memory: 96Mi

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: metrics

Checking 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 -3
pg_up 1
pg_stat_database_numbackends{datname="bookings"} 14

pg_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.

  1. 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: 0400

And 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.

  1. Adapter pattern: normalising the notifications-worker log

notifications-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:

kubectl logs -n rutas-norte-pro deployment/notifications-worker -c log-adapter --tail=1
{"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.

  1. 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:

  1. Normal init containers run sequentially and to completion.
  2. Native sidecars start in the declared order, and only need to be started.
  3. The main containers all start at once, with no ordering guarantee among them.
  4. On termination, the main ones first; then the sidecars, in reverse order.
  5. terminationGracePeriodSeconds belongs 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 logs and kubectl exec now needs the -c flag.

Sanity-check questions before adding a sidecar:

  1. Could a DaemonSet do it, one per node instead of one per pod? For node logs and metrics, almost always yes.
  2. Could the application itself do it with a library? Sometimes five lines of code replace a 60 MiB container.
  3. 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 worker that every 5 seconds writes a line in a proprietary format to /logs/output.log (for example 2026-08-05 10:00:00 | SEND_OK | booking=4471).
  • A native sidecar adapter (an init container with restartPolicy: Always) that follows that file and emits each line on its standard output with the prefix [adapted].
  • A shared emptyDir with sizeLimit: 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: 128Mi
kubectl apply -f /tmp/api-demo.yaml
kubectl get pods -n rutas-norte-dev -l app=api-demo
NAME                        READY   STATUS     RESTARTS   AGE
api-demo-5b7c9d6f4-x8k2m    0/1     Init:0/1   0          35s

Init:0/1: the init container is running and none has completed.

kubectl logs -n rutas-norte-dev -l app=api-demo -c wait-for-dependency --tail=3
Waiting for dependency-demo:80...
  no answer yet; retrying in 3 s
  no answer yet; retrying in 3 s

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-demo
NAME                        READY   STATUS    RESTARTS   AGE
api-demo-5b7c9d6f4-x8k2m    1/1     Running   0          2m10s
kubectl logs -n rutas-norte-dev -l app=api-demo -c wait-for-dependency --tail=1
dependency-demo is available

The 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: 64Mi
kubectl 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=4473

The 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:

kubectl logs -n rutas-norte-dev "$POD" -c worker --tail=3
(no 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: 64Mi
kubectl 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-dev
NAME                  STATUS     COMPLETIONS   DURATION   AGE
report-with-sidecar   Complete   1/1           19s        25s

The 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          45s

The 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-dev

Conclusion

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

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved