Audit Logging¶
The API server records who did what, to which object, and whether it was
allowed. It is also the destination for the audit half of the Pod Security
Admission labels in pod-security.yaml:
without it, findings at the stricter audit level go nowhere.
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 |
The policy decides everything¶
--audit-log-path on its own does nothing: without --audit-policy-file the
API server declines to open a log, silently. 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; the majority of requests |
| Leases, Events | None |
Renewed every few seconds by every component |
Reads by the control plane and by system:nodes |
None |
Not the reads anyone looks for; dropping them makes the next row affordable |
| Secrets, ConfigMaps, TokenReviews | Metadata |
Reads included: a read is the interesting verb |
pods/exec, pods/attach, pods/portforward |
RequestResponse |
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 an unencrypted copy of every Secret in the cluster, in a flat file beside the etcd that was encrypted at rest to prevent exactly that.
Where the log lives¶
| Property | Value |
|---|---|
| Path | /var/log/kubernetes/audit/audit.log |
| Filesystem | root, ext4, 50 GB |
--audit-log-maxsize |
100 (MB) |
--audit-log-maxbackup |
10 |
--audit-log-maxage |
30 (days) |
| Worst-case footprint | ~1.1 GB per control-plane node |
The root filesystem persists across reboots, so the log survives on the node. Alloy still tails it into Loki, which is the copy that matters: a log stored only on the node is unavailable in precisely the situation where the node is what failed.
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"
The last query answers "what would break if I tightened enforce on this
namespace" from evidence, for the whole retention window. Exec, Secret reads
by a person, RBAC writes, 403 bursts and anonymous requests also fire alerts
from Loki's ruler — see Logging.
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
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 missing file does not start. Step 3 before step 4, one node at a time, confirming the API server comes back before moving on.
-
Regenerate the Ignition configs so a rebuild gets this too:
-
Create the directories on the node:
-
Place the policy file:
-
Re-render the API server static pod from the updated config:
-
Watch the kubelet restart the static pod:
-
Update the
kubeadm-configConfigMap inkube-systemso the flags survive the nextkubeadm upgradeor control-plane join — kubeadm reads the ConfigMap on those paths, not/opt/kubeadm-config.yaml: