SeccompProfile manifest you
commit to git. Once you apply that CR, the controller on every node writes the
profile file it describes and reports back in the CR’s status. kguardian never
applies a profile on its own, never edits workloads, and never writes a file
that is not backed by a CR you own — see
policy as code.
Ownership split. You own the
SeccompProfile object: its contents, its
version history, and the decision to enforce. kguardian guarantees that the
file it describes exists at a known path on every node, tells you when that is
true, and tells you when what it observes has drifted from what you committed.The lifecycle
1
Capture
The controller’s eBPF probe records syscalls per pod at the cluster’s
capture tier (
syscalls.captureLevel, default full). Only full is
complete enough for a profile — see capture tiers.2
Review
Each pod is attributed to its top-level controller (ReplicaSet →
Deployment, Job → CronJob) and the broker keeps a monotonic union of every
syscall seen for that workload. Inspect it in the UI or via
GET /seccomp/profiles. A profile built from a partial tier is flagged.3
Export
Export the
SeccompProfile manifest (UI, or
GET /seccomp/profiles/{ns}/{kind}/{name}/export). It arrives with
defaultAction: SCMP_ACT_LOG and a workloadRef so kguardian can keep
reporting on it.4
Commit and apply
Review the YAML like any other manifest, commit it, apply it — by hand or
through your GitOps pipeline. This is the only step that changes cluster
state, and it is yours.
5
Reference
With
seccomp.distribute=true, every controller writes
kguardian/<namespace>/<name>.json to its node and the CR’s status turns
Ready. Add the Localhost reference to the pod template once.6
Promote
Once the denial signal confirms clean — the CR’s
DenialsObserved
condition is False and status.denials.observed is 0 — change
defaultAction to SCMP_ACT_ERRNO in git and apply. The file is
rewritten on every node; it applies to each pod on its next restart. An
unset status.denials, or DenialsObserved: Unknown, means “no data”,
not “clean” — do not promote on either of those. See
promote to enforcing below.7
Watch for drift and denials
Two signals matter after that, and they answer different questions. When
the workload starts making a syscall your CR does not allow, the CR’s
Drift condition and the UI say so — re-export, diff, commit. When the
kernel actually denies (or, in audit mode, logs) a syscall against a
loaded filter, that shows up as a denial — DenialsObserved, the
kguardian_seccomp_denials_total metric, GET /seccomp/denials. See
Denials vs Drift
for why both exist.Capture tiers
The controller traces syscalls at one of five tiers, set cluster-wide in Helm:full costs about the same CPU as low: the probe de-duplicates per pod
network namespace inside BPF, so each syscall crosses into userspace once per
pod. Choose a lower tier only if you never intend to ship profiles.
kguardian.dev/seccomp-record: "true" is
an alias for full:
Enable distribution
distribute adds a hostPath mount of <kubeletRoot>/seccomp to the controller
DaemonSet, grants it read/watch on seccompprofiles plus patch on their
status, and starts a cluster-wide watch on SeccompProfile objects. For each
CR the controller renders the standard seccomp JSON from spec, compares it
byte-for-byte with <kubeletRoot>/seccomp/kguardian/<namespace>/<name>.json,
and writes atomically when they differ. Deleting the CR deletes the file
— that is the one case where kguardian removes something from a node, and it
only ever follows an explicit deletion of yours.
installCRDs renders the CRD as part of the release (with
helm.sh/resource-policy: keep) so helm upgrade picks up schema changes,
which Helm’s crds/ mechanism never does. The trade-off is that
helm uninstall leaves the CRD — and every SeccompProfile — in place; delete
it by hand if that is what you want. Set it to false if you manage CRDs
separately and apply charts/kguardian/files/kguardian.dev_seccompprofiles.yaml
yourself. (The older AuditNetworkPolicy CRDs still ship in crds/; they
predate this pattern and have not needed a schema upgrade.)
Review and export
List workloads with an observed syscall set, their capture completeness, and whether a CR already covers them:cr: null means no SeccompProfile references this workload yet. Export one:
?name= renames the CR and ?defaultAction= changes the action; the UI’s
editor stages syscall additions and removals before export. Review it, commit
it, apply it:
Ready / CaptureComplete / Drift
conditions — is in the CRD reference.
Reference the profile
Reference the path — at pod level, or on a single container. The name is the CR’s name, so it never changes when the contents do:The kubelet reads the profile at container start. A changed file does
nothing to running pods; it applies on the next restart. That is what makes
rewriting in place safe — and what makes an over-tight enforcing profile
surface later than you might expect.
Edit a profile
The CR is yours. Add a syscall the scanner never caught, remove one you want gone, change the action — in git, then apply. Every controller rewrites the file on the watch event; the app team changes nothing.Drift condition flips to
True with the names, and GET /seccomp/profiles shows them under
cr.drift.missing. Re-export and diff to fold them in — the UI shows the
add/remove lines against your current CR before the YAML.
Promote to enforcing
SCMP_ACT_LOG logs disallowed syscalls to the kernel audit log
(type=SECCOMP in dmesg / auditd) and blocks nothing. Run a full usage
cycle in this mode — for a CronJob, several runs — and confirm the workload
is not tripping the profile before promoting.
Before this release, “confirm” meant reading node kernel logs by hand — the
audit trail existed but kguardian never read it back. It now does: the
controller runs a kprobe on the kernel’s
audit_seccomp() (the function
every loggable seccomp verdict passes through), attributes each one to a pod
and workload, and the broker turns it into the same denial signal described
in Denials vs Drift.
The denial signal is what gates promotion now — it is the in-cluster,
queryable, alertable replacement for “grep dmesg on every node.”Unknown means “no data”, never “clean”:
NoDenialData is the broker answering and having nothing to say: the CR
predates this feature, seccomp.denials.enabled is off cluster-wide, or no
node’s kprobe loaded (missing CONFIG_AUDITSYSCALL). Two sibling Unknown reasons
exist for the same “no data” verdict for a different cause:
NoWorkloadRef (the CR has none) and NoObservations (nothing reported
for this workload at all yet) — the same pair CaptureComplete already
uses for the equivalent situations.
Before any of that, read refreshedAt. status.denials is a settled
reading, not a live counter: every node polls the broker separately, so
rather than have the fleet rewrite the CR on every denial, a node
republishes the block only when its reading says something strictly
stronger than the published one — a syscall or a verdict not listed there,
or an order of magnitude more events — and otherwise leaves it alone until
it is more than 15 minutes old. So observed trails live activity by up to
15 minutes, in one direction: a count climbing inside one order of
magnitude, a count falling as the retention window prunes, and a workload
going quiet all wait for that refresh. A cleared workload therefore reads
clean late rather than early, which is the safe direction for a promotion
gate — but GET /seccomp/denials is the live, per-event view when you want
the current number.
None of those three is the sharp edge, though. A broker that cannot be
reached doesn’t turn DenialsObserved Unknown at all — the reconciler
skips the status write entirely that pass and every condition, including
DenialsObserved and status.denials, is left exactly as it was. A CR can
sit on False/NoDenials/{"observed": 0} for hours into a broker outage
and look precisely as clean as it did before the outage started, because
nothing ever told it otherwise. This is the actual limit on using the CR
alone as a promotion gate: it tells you the last answer the broker gave, not
whether that answer is still current. If the stakes are high enough that a
stale “clean” would matter, don’t stop at the CR — check the controller
pods’ own logs for failed POSTs to the broker, and confirm the broker
itself is healthy (GET /health) before you promote.
Only once you have an explicit DenialsObserved: False with
status.denials.observed: 0 should you change one line and apply:
Metrics and alerting
Denial capture is on by default (seccomp.denials.enabled, default true)
— it needs no configuration to start attributing kernel verdicts to pods and
storing them in the broker:
broker.seccomp.denials.retention.days (default
30) and exposes three Prometheus metrics on the existing /metrics
endpoint: kguardian_seccomp_denials_total (a counter, labelled
workload_namespace/workload_kind/workload/action — deliberately
not by syscall, which would multiply cardinality by roughly the syscall
table size), kguardian_seccomp_denial_workloads (distinct workloads with at
least one denial in the retention window), and
kguardian_seccomp_denial_rows_total (rows ingested since broker start).
/metrics never queries Postgres on the scrape path — it reads a cached
aggregate, refreshed every broker.seccomp.denials.metricsIntervalSeconds
(default 15s, 5s floor). That timer is deliberately separate from
retention.intervalSeconds above (default 3600s): KguardianSeccompEnforcingProfileDenials
fires on a 5-minute increase() window, and if the cache only refreshed on
the retention loop’s hourly cadence, the metric backing that alert could lag
reality by most of an hour — turning a “page right now” alert into one that
notices well after the fact. Lower metricsIntervalSeconds for faster
alerting if 15s isn’t tight enough for your stakes.
broker.metrics.serviceMonitor.enabled(or your own scrape config) — without a scrape, the metrics exist in-process but Prometheus never sees them.broker.networkPolicy.allowMetricsFrom— only relevant ifbroker.networkPolicy.enabledis alsotrue. That NetworkPolicy is ingress-only and default-deny by construction; a Prometheus that isn’t listed here is blocked at the network layer before the scrape toggle above even matters. Add your Prometheus’s namespace/pod selector here the same way you would for any other broker scraper.broker.metrics.prometheusRule.enabled— renders the alert rules themselves. A prometheus-operator installation needs to select thisPrometheusRulethe same way it selects theServiceMonitor(usually a sharedrelease: kube-prometheus-stacklabel on both — set it viabroker.metrics.serviceMonitor.labelsandbroker.metrics.prometheusRule.labels).
Things to know
- One file per CR.
kguardian/<namespace>/<name>.json, rewritten in place. No hash-named copies accumulate; version history lives in git, where the CR does. - The observed set only grows. kguardian keeps a monotonic union — a
syscall the workload stops making stays observed. Your CR is the place to
narrow;
cr.drift.extralists what your CR allows that was never observed. - Per-pod, not per-container. Attribution is by network namespace, which all containers in a pod share. An injected sidecar adds its syscalls to the workload’s observed set. The profile is a safe superset; per-container profiles are not supported.
workloadRefis optional. Without it the CR is still distributed, butCaptureCompleteandDriftareUnknown— kguardian has nothing to compare against. Several CRs may reference one workload; the summary shows the newest and acrCount.- Pods from an older controller have no recorded capture level, report as
unknown, and count as incomplete. A CronJob between runs or a workload scaled to zero is complete if every contributing pod wasfull. - New nodes get every CR’s file as soon as the controller there starts.
- Mixed architectures. A workload on both
amd64andarm64nodes gets both inspec.architectures. Syscall names are resolved per architecture at controller startup. - The CLI still works.
kubectl kguardian gen seccompwrites the same profile as plain seccomp JSON for use outside the CR flow. - Denials age out; the observed syscall set never does. Unlike the
monotonic syscall union above,
seccomp_denialsrows are pruned afterbroker.seccomp.denials.retention.days(default 30). A workload with a cleanDenialsObservedtoday may simply have aged out an old denial, not necessarily never produced one — checklastSeeninGET /seccomp/denialsif that distinction matters for an audit. - Denial capture doesn’t need
syscalls.captureLevel: full, or evenseccomp.distribute. It reads the kernel’s own verdict on a loaded filter, not traced syscalls, so it captures denials at any capture tier, and for a profile applied outside kguardian’s distribution path entirely (hand-applied, or shipped by another tool). Pod-to-workload attribution works the same way it does for the observed syscall set — it needs noSeccompProfileCR orworkloadRef— but rolling the denial signal up onto a specific CR’sstatus.denialsstill needs that CR’sworkloadRefto match, the same asCaptureCompleteandDriftdo.