Skip to main content
RunBook Academy

← All labs in Kubernetes

Lab · advanced · ~120 min

Lab 23: Ship container logs off the node

B · Nested virtualisationA · Physical hardware

Objectives

  • Trace one log line from a container stdout to the file the kubelet writes, and read the CRI log format it is stored in
  • Force a kubelet log rotation and measure what kubectl logs can no longer show you
  • Show that deleting a Pod destroys its logs, and that a shipped copy survives
  • Deploy a Fluent Bit DaemonSet and a Loki sink, with the RBAC and the Pod Security exception each genuinely needs
  • Query the shipped logs over the Loki HTTP API and count them with LogQL rather than eyeballing a dashboard
  • Take the sink down deliberately and measure the gap, then explain why the position database and the rotation window decide the answer

Prerequisites

Objective

By the end of this lab you will have followed one log line from a container’s stdout to a file on a node, watched the kubelet delete that file while the workload was still running, and built the pipeline that would have kept a copy.

The thing worth taking away is smaller and more useful than “install a logging stack”. It is that kubectl logs is not a log system. It is a read of a file on one node, with a size cap, a file-count cap, and a lifetime tied to the Pod object. Every one of those three limits is reachable in a normal week, and each one loses data silently — no error, no event, no gap marker, just a line that is no longer there when somebody asks for it.

You will finish by taking the sink down on purpose and measuring exactly how many lines you lost, because “we have centralised logging” is a claim that deserves a number.

Architecture

There is no log agent inside the container and no log API in Kubernetes. The entire pipeline is a file, a symlink, and a process that reads it.

flowchart TB
    APP["container process<br/>writes to stdout"] --> CRI["containerd<br/>adds timestamp, stream, tag"]
    CRI --> FILE["/var/log/pods/NAMESPACE_POD_UID/CONTAINER/0.log"]
    FILE --> LINK["/var/log/containers/*.log<br/>symlink"]
    FILE --> KLOG["kubelet log API<br/>serves kubectl logs"]
    LINK --> FB["Fluent Bit DaemonSet<br/>tail + position DB"]
    FB --> ENRICH["kubernetes filter<br/>asks the API server whose Pod this is"]
    ENRICH --> LOKI["Loki<br/>labels + chunks"]
    KUBELET["kubelet rotation<br/>containerLogMaxSize / MaxFiles"] -.->|deletes| FILE
    GC["Pod deletion + kubelet GC"] -.->|deletes the directory| FILE

The two dotted arrows are the whole problem. Both of them remove data that kubectl logs was serving a moment earlier, neither of them tells anybody, and the collector is in a race with both.

Requirements

  • A disposable kubeadm cluster: one control-plane node and two workers, Kubernetes 1.34.x, containerd as the CRI. The cluster built in Lab 01 is exactly right.
  • kubectl 1.34.x, jq and curl on your workstation.
  • SSH to at least one worker node, with sudo. Tasks 2 and 3 read files under /var/log that no API exposes.
  • At least 1 GiB of free memory on one worker for Loki, and roughly 1 GiB of free disk on every worker. Task 1 deliberately makes one container emit 200,000 log lines; the kubelet’s default caps hold at most five files of 10 MiB per container, so the space it occupies at any moment is bounded even though the total written is larger.
  • Cluster-admin, because the collector needs a ClusterRole and the logging namespace needs a Pod Security exception.
  • The manifests below pin busybox:1.36, grafana/loki:3.5 and fluent/fluent-bit:3.2. Confirm all three tags resolve before you start, and record the versions you actually got — a logging pipeline is mostly configuration, and configuration keys move between major versions.

Scenario

An application fails at 02:14 with a stack trace. The on-call engineer is paged at 02:40, opens a terminal at 02:45, and runs kubectl logs.

Three things have happened in those thirty-one minutes. The container restarted, so the interesting output is behind --previous. The restart loop produced enough output to roll the log file over twice. And at 02:52 a cluster autoscaler replaced the node, which deleted the Pod object, which means --previous now returns nothing at all.

Nothing was broken. No component misbehaved. Every one of those three behaviours is documented, default, and correct. The logs are simply gone, and the incident review will record that the root cause could not be established.

Your job is to reproduce each of the three losses on purpose, so that you can say what your cluster’s real retention is, and then to build the pipeline that changes the answer.

Tasks

Task 1: Deploy something that logs in a countable way

Most logging labs use a workload that emits English. Use one that emits sequence numbers instead: the point of everything below is to count what arrived, and you cannot count prose.

chatty.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: logging-lab
  labels:
    # This workload has no reason to fail restricted, so hold it there.
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: chatty
  namespace: logging-lab
spec:
  replicas: 1
  selector:
    matchLabels:
      app: chatty
  template:
    metadata:
      labels:
        app: chatty
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: chatty
          image: busybox:1.36
          command: ["/bin/sh", "-c"]
          args:
            - |
              # A bounded burst: enough to force rotation, not enough to
              # fill the node. Each line carries its own sequence number so
              # that a missing line is countable rather than a feeling.
              i=1
              budget=200000
              pad="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
              while [ "$i" -le "$budget" ]; do
                echo "seq=$i level=info msg=synthetic-log-line pad=$pad"
                i=$((i + 1))
              done
              echo "seq=BURST-COMPLETE budget=$budget"
              # Then a slow heartbeat, so there is always something recent.
              while true; do
                echo "seq=heartbeat ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
                sleep 15
              done
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
            readOnlyRootFilesystem: true
          resources:
            requests:
              cpu: 50m
              memory: 16Mi
            limits:
              memory: 64Mi
WORKDIR="$HOME/k8s-logging-lab"
mkdir -p "$WORKDIR"
cd "$WORKDIR"

kubectl apply -f chatty.yaml
kubectl rollout status deployment/chatty -n logging-lab --timeout=120s

POD=$(kubectl get pod -n logging-lab -l app=chatty -o jsonpath='{.items[0].metadata.name}')
NODE=$(kubectl get pod -n logging-lab "$POD" -o jsonpath='{.spec.nodeName}')
echo "POD=$POD NODE=$NODE" | tee pod-and-node.txt

Write those two values down. Every command in the next two tasks needs them, and the Pod name changes the moment anything restarts.

Task 2: Find the file, and read what is actually in it

Wait until the burst has finished — kubectl logs -n logging-lab "$POD" | tail -1 shows the BURST-COMPLETE line or a heartbeat — then go and look at the storage.

cd "$HOME/k8s-logging-lab"

# Substitute the address of the node named in pod-and-node.txt:
NODE_ADDR=192.0.2.12

ssh "$NODE_ADDR" 'sudo ls -l /var/log/containers/ | grep chatty' | tee log-path-trace.txt
Read-only / Safeworker
$ sudo ls -l /var/log/containers/ | grep chatty
lrwxrwxrwx 1 root root 96 Aug 19 09:12 chatty-7d4b8f9c6-lm2xq_logging-lab_chatty-2f1a....log -> /var/log/pods/logging-lab_chatty-7d4b8f9c6-lm2xq_9c3f.../chatty/0.log

Illustrative output

Two directories, one file. /var/log/containers holds symlinks whose filenames encode pod, namespace and container, which is why every log collector on earth globs that directory rather than the real one — the metadata is in the name. /var/log/pods holds the actual file, in a directory named for the namespace, the Pod and the Pod’s UID.

Now read a raw line. This is the part people are surprised by:

cd "$HOME/k8s-logging-lab"

# Substitute the address of the node named in pod-and-node.txt:
NODE_ADDR=192.0.2.12

# Resolve the symlink to the file it actually points at, then read one line
# of that file. Two steps, so you can see both halves.
LOGFILE=$(ssh "$NODE_ADDR" 'sudo readlink -f "$(sudo ls -d /var/log/containers/chatty*.log | head -1)"')
echo "$LOGFILE" | tee -a log-path-trace.txt

ssh "$NODE_ADDR" "sudo head -1 '$LOGFILE'" | tee -a log-path-trace.txt
Read-only / Safeworker
$ sudo head -1 /var/log/pods/logging-lab_chatty-7d4b8f9c6-lm2xq_9c3f.../chatty/0.log
2026-08-19T09:12:41.118273941Z stdout F seq=1 level=info msg=synthetic-log-line pad=xxxxxxxx...

Illustrative output

The application wrote seq=1 level=info .... What is on disk has three fields in front of it: an RFC 3339 timestamp with nanoseconds, the stream name (stdout or stderr), and a tag that is F for a full line or P for a partial one. That is the CRI logging format, written by containerd, and it is why a collector needs a parser: without one, every field you care about is buried inside a string that also contains a timestamp you already have.

The P tag matters more than it looks. A line longer than the runtime’s buffer — commonly 16 KB — is split, and the pieces are tagged P until the last, which is tagged F. A stack trace or a large JSON document arrives as several records unless the collector reassembles them, which is the single most common reason a logging pipeline shows truncated errors.

Task 3: Measure how far back your cluster can actually look

The burst wrote 200,000 lines, and on disk each one is larger than what the application emitted because the CRI prefix is added to every line. Before looking at the result, find out what the kubelet’s caps on this node really are — do not assume the documented defaults, because a node built by somebody else may not have them.

The kubelet serves its own effective configuration, and you can read it through the API server without touching the node:

cd "$HOME/k8s-logging-lab"

NODE=$(kubectl get pod -n logging-lab -l app=chatty \
  -o jsonpath='{.items[0].spec.nodeName}')

kubectl get --raw "/api/v1/nodes/$NODE/proxy/configz" \
  | jq '.kubeletconfig | {containerLogMaxSize, containerLogMaxFiles}' \
  | tee rotation-evidence.txt
Read-only / Safeworkstation
$ kubectl get --raw "/api/v1/nodes/$NODE/proxy/configz" | jq '.kubeletconfig | {containerLogMaxSize, containerLogMaxFiles}'
{
"containerLogMaxSize": "10Mi",
"containerLogMaxFiles": 5
}

Illustrative output

Those two numbers are the entire local retention policy for every container on that node. Now look at what they produced:

# Substitute the address of the node named in pod-and-node.txt:
NODE_ADDR=192.0.2.12

ssh "$NODE_ADDR" 'sudo ls -lh /var/log/pods/logging-lab_chatty-*/chatty/' \
  | tee -a rotation-evidence.txt

Read the listing rather than trusting a description of it. Record the exact names your kubelet produced and whether the rotated files are compressed — both are implementation details that have changed between releases, and compression matters, because a compressed rotated file holds far more history than its size suggests when the log lines are repetitive. What is stable is the shape: one current file, a bounded number of older ones, and nothing at all beyond that.

Now ask what kubectl logs can still show you:

cd "$HOME/k8s-logging-lab"
POD=$(kubectl get pod -n logging-lab -l app=chatty -o jsonpath='{.items[0].metadata.name}')

echo "first line kubectl can return:" | tee -a rotation-evidence.txt
kubectl logs -n logging-lab "$POD" | head -1 | tee -a rotation-evidence.txt

echo "lines kubectl can return:" | tee -a rotation-evidence.txt
kubectl logs -n logging-lab "$POD" | wc -l | tee -a rotation-evidence.txt

The application emitted 200,000 numbered lines and said so on the last line of the burst. Compare that with the sequence number on the first line kubectl logs gives you back.

Whatever number you get is the answer for this cluster, and it is the number worth recording — not the one in anybody’s design document. It is a product of three things: the container’s log rate, the two caps you just read, and whether your kubelet’s log reader follows rotated files at all. That last point is a version-dependent implementation detail, which is exactly why the task is to measure it rather than to be told it.

If the first line you get back is not seq=1, the missing lines existed, were correct, were never replicated anywhere, and are now unrecoverable — removed by a mechanism that exists to stop a chatty container filling a node’s disk, which is a mechanism you want. If you do get seq=1, the burst simply did not exceed this node’s local budget, and the useful exercise is to work out how many lines it would have taken.

Now the failure that no cap protects you from:

cd "$HOME/k8s-logging-lab"
POD=$(kubectl get pod -n logging-lab -l app=chatty -o jsonpath='{.items[0].metadata.name}')

kubectl delete pod -n logging-lab "$POD"
sleep 5

kubectl logs -n logging-lab "$POD" 2>&1 | tee -a rotation-evidence.txt

NotFound. Not “no logs” — the Pod object is gone, so there is nothing to ask about. The kubelet’s garbage collection removes the directory under /var/log/pods shortly afterwards. Confirm it:

# Substitute the address of the node named in pod-and-node.txt:
NODE_ADDR=192.0.2.12

ssh "$NODE_ADDR" 'sudo ls -d /var/log/pods/logging-lab_chatty-* 2>/dev/null || echo "directory removed"'

This is the loss that matters, because the events that delete Pods are exactly the events you investigate afterwards: an eviction, a node replacement, a rollout, a kubectl delete pod by the person debugging. The log’s lifetime is tied to the object whose failure you are trying to explain.

Task 4: Build the sink

Loki first, so the collector has somewhere to send to.

loki.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: logging
  labels:
    # The collector in Task 5 mounts /var/log from the host, which baseline
    # forbids. The exception is deliberate, scoped to this namespace, and
    # left visible: audit and warn stay on at baseline so every Pod creation
    # here announces what it is doing.
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/audit: baseline
    pod-security.kubernetes.io/warn: baseline
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: loki
  namespace: logging
spec:
  replicas: 1
  selector:
    matchLabels:
      app: loki
  template:
    metadata:
      labels:
        app: loki
    spec:
      securityContext:
        # The upstream image runs as UID 10001 and needs to own its data
        # directory. fsGroup makes the emptyDir writable by that GID.
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
      containers:
        - name: loki
          image: grafana/loki:3.5
          # No -config.file argument: the image ships a single-binary
          # filesystem configuration at /etc/loki/local-config.yaml and uses
          # it by default. Correct for a lab. Wrong for production, where
          # storage, retention and limits are all decisions you must make.
          ports:
            - name: http
              containerPort: 3100
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            initialDelaySeconds: 20
            periodSeconds: 10
          volumeMounts:
            - name: data
              mountPath: /loki
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              memory: 1Gi
      volumes:
        - name: data
          # An emptyDir: the log store dies with the Pod. That is honest for
          # a lab and is the single biggest difference from a real install.
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: loki
  namespace: logging
spec:
  selector:
    app: loki
  ports:
    - name: http
      port: 3100
      targetPort: http
cd "$HOME/k8s-logging-lab"

kubectl apply -f loki.yaml
kubectl rollout status deployment/loki -n logging --timeout=180s
kubectl get pods -n logging -o wide

If the Pod crash-loops, read its logs and then read the configuration it is actually using — the bundled file names the paths it expects to own, and a permissions failure will name one of them:

kubectl logs -n logging deploy/loki --tail=20
kubectl exec -n logging deploy/loki -- cat /etc/loki/local-config.yaml

That second command is the habit worth keeping. When a container ships its own default configuration, read the default rather than guessing at it.

Task 5: Build the collector

The collector needs three things that this course has already covered separately: a ServiceAccount with RBAC, because the Kubernetes filter asks the API server which Pod each log file belongs to; a hostPath mount, which is why the namespace carries a Pod Security exception; and a position database on a writable path, which is what makes a restart resume instead of duplicating or skipping.

fluent-bit.yaml:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: fluent-bit
  namespace: logging
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: fluent-bit
rules:
  # The minimum the kubernetes filter needs to turn a filename into
  # namespace, pod, container and labels. No write verbs, no secrets.
  - apiGroups: [""]
    resources: ["pods", "namespaces"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: fluent-bit
subjects:
  - kind: ServiceAccount
    name: fluent-bit
    namespace: logging
roleRef:
  kind: ClusterRole
  name: fluent-bit
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: fluent-bit-config
  namespace: logging
data:
  fluent-bit.conf: |
    [SERVICE]
        Flush         1
        Log_Level     info
        Daemon        Off
        Parsers_File  parsers.conf

    [INPUT]
        Name              tail
        Tag               kube.*
        Path              /var/log/containers/*.log
        Parser            cri
        DB                /var/lib/fluent-bit/positions.db
        Mem_Buf_Limit     5MB
        Skip_Long_Lines   On
        Refresh_Interval  5

    [FILTER]
        Name                kubernetes
        Match               kube.*
        Kube_URL            https://kubernetes.default.svc:443
        Kube_Tag_Prefix     kube.var.log.containers.
        Merge_Log           On
        Keep_Log            Off

    [OUTPUT]
        Name       loki
        Match      kube.*
        Host       loki.logging.svc.cluster.local
        Port       3100
        labels     job=fluent-bit
        label_keys $kubernetes['namespace_name'],$kubernetes['pod_name'],$kubernetes['container_name']
  parsers.conf: |
    [PARSER]
        Name        cri
        Format      regex
        Regex       ^(?<time>[^ ]+) (?<stream>stdout|stderr) (?<logtag>[^ ]*) ?(?<log>.*)$
        Time_Key    time
        Time_Format %Y-%m-%dT%H:%M:%S.%L%z
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluent-bit
  namespace: logging
spec:
  selector:
    matchLabels:
      app: fluent-bit
  template:
    metadata:
      labels:
        app: fluent-bit
    spec:
      serviceAccountName: fluent-bit
      # A log collector must run everywhere, including on nodes that are
      # tainted for other reasons - a node you cannot collect from is a node
      # whose incidents you cannot investigate.
      tolerations:
        - operator: Exists
      containers:
        - name: fluent-bit
          image: fluent/fluent-bit:3.2
          volumeMounts:
            - name: config
              mountPath: /fluent-bit/etc/fluent-bit.conf
              subPath: fluent-bit.conf
            - name: config
              mountPath: /fluent-bit/etc/parsers.conf
              subPath: parsers.conf
            - name: varlog
              mountPath: /var/log
              readOnly: true
            - name: positions
              mountPath: /var/lib/fluent-bit
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              memory: 200Mi
      volumes:
        - name: config
          configMap:
            name: fluent-bit-config
        - name: varlog
          # /var/log covers both the symlinks in /var/log/containers and
          # their targets in /var/log/pods. Read-only: the collector has no
          # business writing here.
          hostPath:
            path: /var/log
            type: Directory
        - name: positions
          # Writable, and on the host on purpose. A position database in an
          # emptyDir is lost when the collector restarts, and the collector
          # then either re-sends everything or skips to the end.
          hostPath:
            path: /var/lib/fluent-bit
            type: DirectoryOrCreate

Note the two subPath mounts rather than mounting the ConfigMap over /fluent-bit/etc. Mounting the directory would hide the other files the image ships there, and the failure is confusing: the process starts, reads a configuration that is missing pieces, and behaves almost correctly.

cd "$HOME/k8s-logging-lab"

kubectl apply -f fluent-bit.yaml 2>&1 | tee -a fluent-bit-apply.txt
kubectl rollout status daemonset/fluent-bit -n logging --timeout=180s
kubectl get pods -n logging -o wide

kubectl logs -n logging -l app=fluent-bit --tail=20

The apply prints a Pod Security warning naming the hostPath volume, and then succeeds. That is the exception doing its job: enforcement is off in this namespace, visibility is not, and the warning appears every time a Pod is created here.

Task 6: Query the sink, and prove the logs outlive the Pod

Open a port-forward and leave it running in its own terminal:

kubectl port-forward -n logging svc/loki 3100:3100

In a second terminal:

cd "$HOME/k8s-logging-lab"

curl -s http://127.0.0.1:3100/ready; echo

# Which labels exist? These are the ones label_keys produced, plus job.
curl -s http://127.0.0.1:3100/loki/api/v1/labels | jq -r '.data[]'

# Values for one of them, to confirm the enrichment worked.
curl -s http://127.0.0.1:3100/loki/api/v1/label/namespace_name/values | jq -r '.data[]'

If namespace_name is missing, the Kubernetes filter did not enrich the records — go back to the collector’s logs before doing anything else. A pipeline that ships lines without identity is a pipeline you cannot query.

Now read some lines back:

cd "$HOME/k8s-logging-lab"

NOW=$(date +%s)
START=$(( NOW - 3600 ))

curl -sG http://127.0.0.1:3100/loki/api/v1/query_range \
  --data-urlencode 'query={namespace_name="logging-lab"}' \
  --data-urlencode "start=${START}000000000" \
  --data-urlencode "end=${NOW}000000000" \
  --data-urlencode 'limit=5' \
  | jq -r '.data.result[].values[][1]'

Loki’s range API takes Unix nanoseconds, which is why the shell arithmetic above pads with nine zeroes. Every timestamp in this pipeline is nanosecond precision, from the CRI line onward, and mixing units is the most common reason a query returns nothing when the data is definitely there.

Now the payoff. Delete the Pod, then ask both systems the same question:

cd "$HOME/k8s-logging-lab"

POD=$(kubectl get pod -n logging-lab -l app=chatty -o jsonpath='{.items[0].metadata.name}')
echo "deleting $POD"
kubectl delete pod -n logging-lab "$POD"
sleep 10

# The cluster has forgotten.
kubectl logs -n logging-lab "$POD" 2>&1 | head -1

# The sink has not.
NOW=$(date +%s); START=$(( NOW - 3600 ))
curl -sG http://127.0.0.1:3100/loki/api/v1/query_range \
  --data-urlencode "query={pod_name=\"$POD\"}" \
  --data-urlencode "start=${START}000000000" \
  --data-urlencode "end=${NOW}000000000" \
  --data-urlencode 'limit=3' \
  | jq -r '.data.result[].values[][1]'

kubectl logs returns NotFound for a Pod that no longer exists. The same lines come back from Loki, addressed by the dead Pod’s name, because the identity was attached to the record at collection time rather than looked up at query time. That is the entire value proposition of the pipeline, in two commands.

Task 7: Break the sink and measure the gap

“We have centralised logging” should come with a number. Get one.

cd "$HOME/k8s-logging-lab"

# Count what Loki holds for the namespace right now.
curl -sG http://127.0.0.1:3100/loki/api/v1/query \
  --data-urlencode 'query=count_over_time({namespace_name="logging-lab"}[1h])' \
  | jq -r '.data.result[] | .value[1]' | tee gap-measurement.txt

count_over_time is a LogQL range aggregation: it counts the entries matching the stream selector over the window, server-side. Use it rather than paging through query_range — the range API is capped at a few thousand entries per request, so counting by fetching is both slow and wrong.

Now take the sink away and generate a burst while it is down:

Service impact possibleworkstation
$ kubectl scale deployment/loki -n logging --replicas=0
cd "$HOME/k8s-logging-lab"

kubectl rollout restart deployment/chatty -n logging-lab
kubectl rollout status deployment/chatty -n logging-lab --timeout=120s

# Let the burst run against a dead sink.
sleep 60
kubectl logs -n logging -l app=fluent-bit --tail=10

kubectl scale deployment/loki -n logging --replicas=1
kubectl rollout status deployment/loki -n logging --timeout=180s

Restart the port-forward — it died with the Pod — then count again:

cd "$HOME/k8s-logging-lab"

curl -sG http://127.0.0.1:3100/loki/api/v1/query \
  --data-urlencode 'query=count_over_time({namespace_name="logging-lab"}[1h])' \
  | jq -r '.data.result[] | .value[1]' | tee -a gap-measurement.txt

# What is the lowest sequence number that survived the outage?
NOW=$(date +%s); START=$(( NOW - 1800 ))
curl -sG http://127.0.0.1:3100/loki/api/v1/query_range \
  --data-urlencode 'query={namespace_name="logging-lab"} |= "seq="' \
  --data-urlencode "start=${START}000000000" \
  --data-urlencode "end=${NOW}000000000" \
  --data-urlencode 'direction=forward' \
  --data-urlencode 'limit=1' \
  | jq -r '.data.result[].values[][1]' | tee -a gap-measurement.txt

Compute it and write it down. For the defaults, 5 files of 10 MiB is 50 MiB per container; a container logging 1 MiB per minute survives about fifty minutes of sink outage, and one logging 10 MiB per minute survives five.

Validation

Run these against the finished state. Each proves an outcome rather than restating a step.

cd "$HOME/k8s-logging-lab"

# 1. The collector is running on every node.
kubectl get daemonset fluent-bit -n logging \
  -o jsonpath='{.status.numberReady}/{.status.desiredNumberScheduled}'; echo

# 2. Identity is attached to the records, not inferred at query time.
curl -s http://127.0.0.1:3100/loki/api/v1/labels | jq -r '.data[]' \
  | grep -q 'namespace_name' && echo "PASS: enrichment worked"

# 3. Logs exist in the sink for a Pod that no longer exists.
kubectl get pods -n logging-lab -o name
grep -q 'seq=' gap-measurement.txt && echo "PASS: shipped lines are readable"

# 4. The sink can count, server-side.
curl -sG http://127.0.0.1:3100/loki/api/v1/query \
  --data-urlencode 'query=count_over_time({job="fluent-bit"}[5m])' \
  | jq -r '.data.result[] | .value[1]'

# 5. The collector's RBAC is read-only.
kubectl auth can-i create pods \
  --as=system:serviceaccount:logging:fluent-bit && echo "FAIL: collector can write"

# 6. The exception namespace still warns.
kubectl apply -f fluent-bit.yaml --dry-run=server 2>&1 | grep -q 'Warning' \
  && echo "PASS: the hostPath exception is still visible"

Check 4 must return a rising number on repeated runs — a static count means the collector has stopped and nothing has told you. Check 5 must print nothing before the echo, because kubectl auth can-i exits non-zero on no; if you see FAIL, the collector has write access it does not need.

Expected Outcome

k8s-logging-lab/
├── chatty.yaml
├── fluent-bit.yaml
├── fluent-bit-apply.txt
├── gap-measurement.txt
├── log-path-trace.txt
├── loki.yaml
├── pod-and-node.txt
└── rotation-evidence.txt

On the cluster: a logging-lab namespace at enforce: restricted running one workload that logs to stdout; a logging namespace at enforce: privileged with baseline audit and warn, running one Loki and one Fluent Bit per node; and a query that returns log lines for a Pod the cluster has forgotten.

Production notes

This lab maps onto two changes with different risk profiles, and a third thing that is not a change at all.

Deploying the collector is a per-node change with host access. A DaemonSet with a hostPath mount and a cluster-wide read of Pods and Namespaces touches every node and reads metadata from every workload. It belongs in a change window, with the RBAC in the change record — a log collector is a legitimate reason for a cluster-wide read grant and it is still a cluster-wide read grant. The rollout should be staged: one node first, verify lines arrive with correct labels, then the rest. tolerations matter here in a way they do not for most workloads, because the node you cannot collect from is the node whose incident you cannot reconstruct.

Deploying the sink is a capacity decision dressed as an install. The emptyDir in this lab hides every question that matters: where chunks live, how long they are kept, what the per-tenant ingestion limit is, and what happens when the object store is unreachable. Answer those before the first production log line, because retention applied retroactively does not bring anything back.

Measuring your local buffer is not a change, and it is the highest-value hour in this list. Take your containerLogMaxSize, multiply by containerLogMaxFiles, divide by the log rate of your noisiest container, and you have the length of sink outage your cluster survives without loss. Most teams have never computed it, and most are surprised by how small it is.

Hold is a legitimate outcome here too. If the collector cannot be given its hostPath mount because a policy forbids it, do not weaken the policy under time pressure to get the rollout done. Record the blocker, name the namespace exception that would be needed, name the owner, and stop. An exception granted in a hurry and never reviewed is how privileged namespaces multiply.

Troubleshooting

Fluent Bit runs but ships nothing. Check the tail input matched files: kubectl exec -n logging ds/fluent-bit -- ls /var/log/containers | head. An empty listing means the hostPath mount is wrong or the node uses a different path.

Records arrive without namespace_name or pod_name. The Kubernetes filter could not enrich them, usually because Kube_Tag_Prefix does not match the tag the tail input generated. The tag is derived from the path, so Path /var/log/containers/*.log with Tag kube.* produces kube.var.log.containers.FILENAME, and the prefix must match exactly, trailing dot included.

Loki returns an empty result for a query you know should match. Check the time range first — the range API takes nanoseconds, and a range in seconds silently matches nothing. Then check the label name with /loki/api/v1/labels rather than assuming it.

Loki crash-loops on startup. Read its logs, then read /etc/loki/local-config.yaml inside the container to see which paths it expects to own. A permissions error names the directory; fsGroup on the Pod is what makes the emptyDir writable by the image’s user.

The port-forward keeps dying. It is one TCP session to one Pod. If Loki restarts, the forward drops. Repeated drops mean Loki is being OOM-killed; check the restart count before blaming the network.

Log lines appear twice. Either two collectors are running — check for a second logging stack — or the position database was lost and Fluent Bit re-read from the start of the file.

A multi-line stack trace arrives as several unrelated records. That is the CRI P/F split. Fluent Bit’s multiline support reassembles them; the minimal configuration in this lab does not, which is a deliberate omission so that you see the raw behaviour first.

Cleanup

Order matters slightly: remove the collector before the sink, so it does not spend the interval retrying against a Service that no longer exists.

Step 1. Remove the workloads:

cd "$HOME/k8s-logging-lab"

kubectl delete -f fluent-bit.yaml --ignore-not-found
kubectl delete -f loki.yaml --ignore-not-found
kubectl delete -f chatty.yaml --ignore-not-found

kubectl get ns logging logging-lab 2>&1 | grep -q NotFound \
  && echo "namespaces removed"

Deleting fluent-bit.yaml removes the ClusterRole and ClusterRoleBinding as well as the namespaced objects, because they are in the same file. Confirm it, because a cluster-wide read grant left behind by a deleted collector is exactly the orphan that an RBAC audit finds two years later:

kubectl get clusterrole fluent-bit 2>&1 | grep -q NotFound \
  && kubectl get clusterrolebinding fluent-bit 2>&1 | grep -q NotFound \
  && echo "cluster-scoped RBAC removed"

Step 2. The collector left a position database on every node, and the kubelet’s own log files are unaffected by any of this. Remove the first:

Destructiveworker
$ sudo rm -rf /var/lib/fluent-bit

Step 3. Confirm the cluster is back where it started:

# Substitute the address of the node named in pod-and-node.txt:
NODE_ADDR=192.0.2.12

ssh "$NODE_ADDR" 'sudo ls -d /var/log/pods/logging* 2>/dev/null || echo "no lab pod log directories remain"'
kubectl get namespaces -o name | grep -E 'logging' || echo "no lab namespaces remain"

Keep log-path-trace.txt, rotation-evidence.txt and gap-measurement.txt — they are the deliverables, and the third one is the number to quote next time someone says the logs will be there.

What You Learned

  • kubectl logs is a file read, not a log system. One file per container instance, on one node, with a size cap, a file-count cap, and a lifetime tied to the Pod object.
  • The three losses are independent and all silent. Rotation drops the oldest lines, a container restart pushes the previous instance behind --previous, and Pod deletion removes everything. None of them produces an error, an event, or a gap marker.
  • The stored format is not what the application wrote. Timestamp, stream, and an F/P tag come first, which is why a parser is mandatory and why long lines arrive in pieces.
  • Identity is attached at collection time. That is the reason the shipped copy can be queried by the name of a Pod that no longer exists, and the reason the collector needs RBAC.
  • Your local buffer is a computable number. Size times file count divided by log rate is how long a sink outage your cluster survives, and it is usually much shorter than people assume.
  • A log collector is the one system that cannot report its own failure. Monitor its output rate, because silence from a logging pipeline is indistinguishable from quiet.
  • Label cardinality is the sink’s cost model. Three deliberate labels, and everything else inside the line where a filter can still reach it.

Deliverables

  • · log-path-trace.txt: the symlink, its target, and the first raw line of the file, showing the CRI timestamp/stream/tag prefix
  • · rotation-evidence.txt: the file listing after rotation and the earliest sequence number kubectl logs could still return
  • · fluent-bit.yaml and loki.yaml: the collector and sink as applied, including the RBAC and the namespace labels
  • · gap-measurement.txt: lines the application emitted, lines Loki received, and the difference across a deliberate sink outage
  • · A one-page note answering: for how long, on this cluster, is a log line recoverable after the Pod that wrote it is deleted

Verification status

Last reviewed
2026-08-19
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.