Container and Kubernetes Security

Securing the runtime platform end to end — hardened images, least-privilege workloads, default-deny networks, and admission control as the gate that decides what is ever allowed to run.

Everything a DevSecOps pipeline does before deployment — scanning source, signing artifacts, writing policy — buys you nothing if the runtime happily accepts whatever shows up. The runtime platform is where an attacker actually lives: inside a running container, on a shared kernel, one misconfigured securityContext away from the node. This post walks the platform layer in the order it fails: the image you ship, the container it becomes, the Kubernetes workload that schedules it, the admission gate that decides whether it runs at all, and the runtime detection that catches what slips through.

The through-line is four verbs: harden the image, least-privilege the workload, default-deny the network, enforce at admission. Each layer assumes the one before it failed.


Image security: ship the smallest thing that works

An image is your attack surface frozen in time. Every package, shell, and interpreter you bake in is something an attacker can use after they get code execution. The single highest-leverage move is to ship less.

Minimal and distroless base images. A full ubuntu or debian base carries a package manager, a shell, coreutils, and hundreds of libraries your app never calls — each a potential CVE and a ready-made toolkit for post-exploitation. Google’s distroless images (gcr.io/distroless/...) strip all of that: no shell, no apt, just your runtime and its direct dependencies. alpine is a middle ground — tiny, but it still ships busybox and a shell. Fewer packages means fewer CVEs to scan, patch, and lose sleep over.

Multi-stage builds. Compile in a fat builder stage, then copy only the finished artifact into a minimal final stage. Your compiler, build tools, and source never reach production. For a Go binary this is nearly free; for interpreted languages you copy the app plus its resolved dependencies into a slim runtime.

Run as non-root. By default a container process runs as UID 0. If that process is compromised, the attacker is root inside the container — and root inside is far closer to root on the host than most people assume (more on that below). Create an unprivileged user and switch to it.

No secrets baked in. A secret in a Dockerfile, a build ARG, or a bundled config file is permanent — it lives in the image layers forever, readable by anyone who can docker pull and docker history. Inject secrets at runtime instead (covered in the secrets section).

Here is a hardened multi-stage image that pulls all of this together:

# ---- build stage: has the toolchain, never ships ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# static binary so the final stage needs no libc
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/server

# ---- final stage: distroless, non-root, minimal ----
FROM gcr.io/distroless/static-debian12:nonroot
# the :nonroot tag runs as an unprivileged UID by default
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]

The gotcha: pin base images by digest, not by a moving tag. golang:1.23 and even distroless/static-debian12:nonroot are mutable — the same tag points at different bytes next week. An attacker (or a compromised upstream) who repoints a tag changes what you build without changing your Dockerfile. Pin the immutable digest — FROM golang:1.23@sha256:<digest> — so your build is reproducible and a poisoned tag can’t silently swap your base out from under you. Update the digest deliberately, through review.

Scan images for CVEs in CI and at the registry

A pinned, minimal image still contains dependencies with known vulnerabilities — and new CVEs get published for images you built months ago. Scan continuously with a real scanner: Trivy (Aqua) and Grype (Anchore) both read the image’s package manifest and OS metadata, match it against vulnerability databases, and report CVEs by severity. This is the recap of the supply-chain post (post 4): the scan belongs in CI so a vulnerable build never merges, and in the registry so newly disclosed CVEs in already-published images surface without a rebuild.

# fail the CI build if fixable HIGH/CRITICAL vulns are present
trivy image --severity HIGH,CRITICAL --ignore-unfixed \
  --exit-code 1 registry.example.com/app@sha256:<digest>

# Grype does the equivalent; both also read SBOMs and filesystems
grype registry.example.com/app@sha256:<digest>

Scanning tells you what’s wrong. It does not stop a vulnerable image from running — that’s a job for admission control, later in this post.


The container isolation model: a container is not a VM

This is the mental model that everything else depends on. A container is not a security boundary in the way a virtual machine is. A VM has its own kernel and is isolated by the hypervisor. A container is just a normal Linux process on the host, dressed up by two kernel features:

Both are enforced by the host’s single, shared kernel. Every container on a node runs against the same kernel as the host and every other container. That is the crucial difference from a VM: there is no second kernel between the container and the machine.

The gotcha: because the kernel is shared, a privileged or root container that escapes owns the node — and often the cluster. A --privileged container, a container with dangerous Linux capabilities, or one that mounts the host filesystem or Docker socket has a short path from “code execution in the container” to “root on the host.” A kernel vulnerability that a normal process couldn’t reach becomes exploitable from inside a container with enough privileges. Treat container escape as a when, not an if, and make the escaped process as weak as possible.

You weaken it with four levers, all available in the container runtime and in Kubernetes:


Kubernetes: least-privilege the workload

Kubernetes doesn’t change the isolation model — it orchestrates it at scale, which means every weak default is now a weak default replicated across a fleet. Four areas need deliberate hardening.

Pod Security Standards

Kubernetes defines three built-in Pod Security Standards that codify the container levers above:

The built-in Pod Security Admission controller enforces these per-namespace via labels, in three modes: enforce (reject violating pods), audit (log them), and warn (return a warning to the user). Start with warn/audit to find violations, then flip to enforce.

Here is a Deployment pod spec that satisfies the restricted standard:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 3
  selector:
    matchLabels: { app: app }
  template:
    metadata:
      labels: { app: app }
    spec:
      automountServiceAccountToken: false   # don't hand every pod an API token
      serviceAccountName: app-sa            # a dedicated, least-privilege identity
      securityContext:                      # pod-level defaults
        runAsNonRoot: true
        runAsUser: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: app
          image: registry.example.com/app@sha256:<digest>   # pinned by digest
          securityContext:                  # container-level hardening
            allowPrivilegeEscalation: false
            privileged: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          resources:                        # cgroup limits — cap blast radius
            requests: { cpu: "100m", memory: "128Mi" }
            limits:   { cpu: "500m", memory: "256Mi" }
          volumeMounts:
            - { name: tmp, mountPath: /tmp } # writable scratch, since root FS is RO
      volumes:
        - name: tmp
          emptyDir: {}

RBAC least-privilege

Kubernetes RBAC controls what identities can do against the API server. The recurring failure is convenience-driven over-permissioning: binding a ServiceAccount to cluster-admin because a narrower Role was annoying to write. Don’t. Grant each ServiceAccount only the verbs (get, list, watch, create…) on only the resources it needs, scoped to a namespace with a Role/RoleBinding rather than a cluster-wide ClusterRole wherever possible. Most application pods need no API access at all — which is why the spec above sets automountServiceAccountToken: false. A stolen token that can do nothing is a much smaller problem than one that can create pods anywhere.

NetworkPolicies: default-deny

The gotcha: the default Kubernetes network is flat — any pod can reach any other pod in the cluster, across namespaces, with no restriction. A single compromised pod can then scan and pivot to your database, your internal APIs, everything. The fix is a default-deny posture: apply a NetworkPolicy that denies all ingress and egress in a namespace, then add narrow allow-rules for the traffic that must flow. Egress deny matters as much as ingress — it’s what stops a compromised pod from phoning home or exfiltrating data.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: payments
spec:
  podSelector: {}                 # every pod in the namespace
  policyTypes: ["Ingress", "Egress"]
  # no ingress/egress rules = deny everything; add explicit allows separately

Note that NetworkPolicies are only enforced if your CNI plugin supports them (Calico, Cilium, and others do; some do not). A policy with no enforcing CNI is a comment, not a control.

Secrets handling

The gotcha: Kubernetes Secrets are base64-encoded, not encrypted. base64 is an encoding, trivially reversible — anyone with read access to the Secret object, or to the underlying etcd datastore, reads the plaintext. By default etcd stores them unencrypted at rest. Two fixes, ideally both: enable encryption at rest for Secrets in the API server (backed by a real KMS provider — cloud KMS, HashiCorp Vault Transit — so the encryption key isn’t itself sitting in the cluster), and pull secrets from a dedicated secrets manager using External Secrets Operator or the Secrets Store CSI Driver rather than committing Secret manifests to Git. This is the recap of the secrets post (post 5): the store is the source of truth, Kubernetes just projects a short-lived copy into the pod. Also lock down RBAC on Secret objects — get/list on secrets is a credential-theft primitive.


Admission control: the policy-as-code gate

Here is the leak that undoes everything above. The gotcha: if you scan images in CI but let the cluster run whatever image is referenced, the gate leaks. Someone applies a manifest pointing at an unscanned, unsigned, or :latest image — or a pod with privileged: true — and CI never sees it. Enforcement has to live where pods are actually admitted: the Kubernetes API server’s admission phase.

Admission controllers intercept every create/update request to the API server before the object is persisted, and can reject it. This is where policy-as-code (post 6) meets the runtime: instead of hoping every manifest author remembers the rules, you encode them as policy and the cluster enforces them uniformly. Two tools dominate:

Typical policies to enforce at admission, tying the whole post together:

Conceptually, a Kyverno validate policy reads: “for every Pod, spec.containers[*].securityContext.privileged must be false or absent; otherwise deny with this message.” A Gatekeeper equivalent is a ConstraintTemplate whose Rego emits a violation when the field is true. Run policies in audit/warn first to see what would break, then switch to enforce. The exact CRD fields differ between the two — consult the current docs rather than trusting a remembered snippet.

Admission control is the single most important runtime control because it’s the only one that decides what is ever allowed to exist in the cluster. Every other control hardens what’s already running; this one keeps the bad thing out.


Runtime threat detection: assume something got through

Even a hardened, admission-gated cluster can be breached — a zero-day, a supply-chain compromise, a leaked credential. Runtime detection is your last layer: watching behavior and alerting when a running container does something it shouldn’t. Falco (a CNCF project) is the standard open-source tool. It taps kernel syscalls (via eBPF or a kernel module) and evaluates them against rules, flagging things like:

Falco turns your hardening assumptions into detections: because you shipped distroless and read-only, any shell or unexpected write is high-signal, not noise. Wire its alerts into your incident response so a detection triggers a human, or an automated response (kill the pod, cordon the node), fast.


The layered picture

The layered picture
Layer Control What it stops
Image Distroless, multi-stage, non-root, pin by digest Bloated attack surface, poisoned base tags
Image Trivy / Grype scan in CI + registry Known CVEs shipping to prod
Container Drop caps, read-only FS, no privileged, seccomp/AppArmor A compromised process escaping to the node
Kubernetes Pod Security Standards (restricted) Insecure pod configs across the fleet
Kubernetes RBAC least-privilege A stolen token doing damage
Kubernetes Default-deny NetworkPolicies Lateral movement and exfiltration
Kubernetes KMS encryption / External Secrets Plaintext secret theft from etcd
Admission OPA Gatekeeper / Kyverno Unsigned, unscanned, privileged workloads running
Runtime Falco Post-breach behavior in a live container

Key takeaways


Further reading