Audit Logging¶
The API server records who did what, to which object, and whether it was allowed. This page covers what is recorded, the policy that decides it, where the log ends up, and how to read it.
At a glance¶
| Policy file | /etc/kubernetes/audit/policy.yaml, written by Ignition from ansible/templates/butane_node_config.yaml.j2 |
| Log file | /var/log/kubernetes/audit/audit.log on each control-plane node |
| Durable copy | Loki, via Alloy, on Ceph |
| Query it with | {job="kubernetes-audit"} in Grafana |
| Worst-case disk | ~1.1 GB per control-plane node |
Until this was configured the API server recorded none of it: --audit-log-path
was unset, so no audit log existed at all.
That absence had a second consequence that is easy to miss. Every namespace in
pod-security.yaml carries an audit label
set to a stricter level than it enforces — and the destination for an audit
finding is the API server audit log. Without one, half of that design was
writing to nowhere. The warn half still reached whoever ran kubectl apply;
the record nobody was watching in real time, which is the half that matters
afterwards, did not exist.
The policy decides everything¶
--audit-log-path on its own does nothing. Without --audit-policy-file the
API server declines to open a log, and the failure is silent — no error, no
file, an audit configuration that looks present in the manifest and produces
nothing. The policy lives in
ansible/templates/butane_node_config.yaml.j2 and is written by Ignition to
/etc/kubernetes/audit/policy.yaml.
Rules are evaluated top to bottom and the first match wins, so the order is the design:
| Matched | Level | Why |
|---|---|---|
/healthz*, /livez*, /readyz*, /version, /metrics, /openapi* |
None |
Polled continuously by kubelets, probes and Prometheus; together the majority of requests the cluster serves |
| Leases, Events | None |
Renewed every few seconds by every component; would bury everything else |
Reads by the control plane and by system:nodes |
None |
Not the reads anyone goes looking for — and dropping them is what makes the next row affordable |
| Secrets, ConfigMaps, TokenReviews | Metadata |
Reads included: a read is the interesting verb, and a write-only policy misses it |
pods/exec, pods/attach, pods/portforward |
RequestResponse |
The difference between knowing someone exec'd into a pod and knowing which pod, as which user, running what |
| Everything else read-only | None |
|
| Everything that changes state | Metadata |
Also the level that carries the Pod Security Admission annotations |
Never raise the Secret rule above Metadata
At Request or RequestResponse the request body is recorded, and for a
Secret the body is the credential. The audit log would become a second,
unencrypted copy of every Secret in the cluster, in a flat file, sitting
beside the etcd that was encrypted at rest to prevent
precisely that. It is a one-word change and it undoes the section above it.
Where the log actually lives¶
The audit log is written to the root filesystem, which on an installed node is 125 GB of ext4 rather than the tmpfs the nodes used to run from. That is what makes the CIS rotation numbers affordable:
| Property | Value |
|---|---|
| Path | /var/log/kubernetes/audit/audit.log |
| Filesystem | root, ext4, 125 GB |
--audit-log-maxsize |
100 (MB) |
--audit-log-maxbackup |
10 |
--audit-log-maxage |
30 (days) |
| Worst-case footprint | ~1.1 GB of disk per control-plane node |
| Durable copy | Loki, on Ceph |
The filesystem is written once, on the install, and persists from then on — so the audit log now survives a reboot on its own. Alloy still tails it into Loki, and that is still where the copy that matters lives: an audit log stored only on the node is unavailable in precisely the situation where the node is what failed, and unqueryable next to everything else in the meantime.
This used to be a CIS deviation
--audit-log-maxbackup was 2 when /var/log was part of the tmpfs root
the nodes ran from, because ten 100 MB files would have reserved 1.1 GB of
the RAM etcd was running in. Installing to disk
removed the objection, and check 1.2.18 now passes along with 1.2.16,
1.2.17 and 1.2.19.
Reading it¶
Audit events reach Loki as JSON with job="kubernetes-audit", plus verb and
audit_level as labels. Everything else stays in the line, where | json
can reach it:
# Who read Secrets, and which ones
{job="kubernetes-audit"} | json | objectRef_resource="secrets"
# Every exec into a running container
{job="kubernetes-audit", verb="create"} | json | objectRef_subresource="exec"
# Pod Security Admission violations that were audited but not enforced
{job="kubernetes-audit"} |= "pod-security.kubernetes.io/audit-violations"
That last query is the one the PSA labels were always meant to feed. It answers
"what would break if I tightened enforce on this namespace" from evidence
rather than from a dry run, and it answers it for the whole retention window
rather than for the moment you happened to look.
Applying it to a running cluster¶
Changing the templates changes what a newly provisioned node gets. A running
control-plane node keeps the kube-apiserver.yaml static pod manifest that
kubeadm rendered at init time, and nothing rewrites it on its own.
Get the policy file there before you re-render the manifest
An API server started with --audit-policy-file pointing at a file that does not exist does not start. On a three-node control plane that is survivable; doing it to all three at once is not. Step 2 before step 3, and one node at a time, confirming the API server comes back before moving on.
# 1. Regenerate the Ignition configs so a rebuild gets this too
make config
# 2. Place the policy file
ssh core@<node> sudo mkdir -p /etc/kubernetes/audit /var/log/kubernetes/audit
ssh core@<node> sudo chmod 0700 /etc/kubernetes/audit /var/log/kubernetes/audit
scp output/.../policy.yaml core@<node>:/tmp/policy.yaml
ssh core@<node> sudo install -m 0600 /tmp/policy.yaml /etc/kubernetes/audit/policy.yaml
# 3. Re-render the API server static pod from the updated config
ssh core@<node> sudo kubeadm init phase control-plane apiserver \
--config /opt/kubeadm-config.yaml
# 4. The kubelet restarts the static pod within seconds
kubectl -n kube-system get pod kube-apiserver-<node> -w
Then update the kubeadm-config ConfigMap in kube-system so the flags survive
the next kubeadm upgrade or control-plane join — the ConfigMap is what kubeadm
reads on those paths, not /opt/kubeadm-config.yaml.