Container & Kubernetes Security
Table of Contents#
- Why Containers Need Their Own Security Discussion
- Container Image Scanning
- Image Scanning in Practice: Trivy
- Dockerfile Hardening — A Line-by-Line Walkthrough
- Distroless and Minimal Base Images
- Container Runtime Isolation — What a Container Actually Is
- Kubernetes Security — The Big Picture
- RBAC — Role-Based Access Control
- A Full Worked RBAC Example
- Pod Security Standards
- Network Policies — Zero Trust Inside the Cluster
- Admission Controllers and OPA Gatekeeper
- Secrets in Kubernetes — A Preview
- Runtime Security: Falco
- A Full Kubernetes Security Checklist
- Common Mistakes
- Worked Practice Problems
- Summary and What's Next
Why Containers Need Their Own Security Discussion#
Containers introduce a genuinely new set of security questions on top of everything in Parts 1-2. It's not enough to know your application code is secure (SAST) or your dependencies are clean (SCA) — you also need to ask: is the container image itself safe, and is it running with more power than it needs?
Diagram
Container Image Scanning#
A container image isn't just your application — it's an entire filesystem, usually built on top of a base OS image (like ubuntu or alpine) that comes with its own collection of OS-level packages, which can themselves have known vulnerabilities, completely separate from anything SCA (Part 2) would catch in your application's own dependency manifest.
Diagram
The key insight: image scanning specifically targets the base OS and runtime layers — the part SCA and SAST don't look at at all. This is genuinely its own category of tooling, worth knowing as distinct from Part 2's SAST/SCA.
Image Scanning in Practice: Trivy#
Trivy (by Aqua Security) is the most widely used open-source container image scanner — fast, simple, and a very commonly expected tool to have hands-on familiarity with.
# Scan a container image for known vulnerabilities trivy image nginx:1.25.0 # Only show HIGH and CRITICAL severity findings trivy image --severity HIGH,CRITICAL nginx:1.25.0 # Fail the command (useful for CI gating) if any CRITICAL vuln is found trivy image --exit-code 1 --severity CRITICAL myapp:latest # Scan for misconfigurations in a Dockerfile itself, not just the built image trivy config ./Dockerfile # Scan a Kubernetes manifest for misconfigurations too trivy config ./k8s-manifests/ # Generate a Software Bill of Materials (SBOM) — covered fully in Part 5 trivy image --format cyclonedx --output sbom.json myapp:latest
A realistic CI gate using Trivy:
# .github/workflows/image-scan.yml - name: Build image run: docker build -t myapp:${{ github.sha }} . - name: Scan image with Trivy run: | trivy image --exit-code 1 --severity CRITICAL,HIGH \ --ignore-unfixed myapp:${{ github.sha }}
The --ignore-unfixed flag is worth knowing specifically: it excludes vulnerabilities that don't yet have an available patch — because failing a build over a CVE that literally cannot be fixed right now just blocks all progress for no actionable benefit. This is a practical, real-world tuning decision, not just a flag to memorize.
Dockerfile Hardening — A Line-by-Line Walkthrough#
A "before and after" comparison is one of the most effective ways to demonstrate real, hands-on Dockerfile security knowledge in an interview.
Before — An Insecure Dockerfile#
FROM ubuntu:latest RUN apt-get update && apt-get install -y python3 python3-pip COPY . /app WORKDIR /app RUN pip install -r requirements.txt ENV AWS_SECRET_KEY=AKIAabcdef1234567890 EXPOSE 8080 CMD ["python3", "app.py"]
After — A Hardened Dockerfile#
# Pin an EXACT version, not "latest" — reproducible, and you know # exactly what CVEs apply to this specific tag FROM python:3.12.3-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Multi-stage build — the final image doesn't carry build tools, # compilers, or anything else only needed to INSTALL dependencies FROM python:3.12.3-slim WORKDIR /app COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages COPY . . # Create and switch to a non-root user — NEVER run as root RUN useradd --create-home appuser USER appuser # Secrets are injected at RUNTIME (see Part 4), never baked into # the image with ENV or hardcoded values EXPOSE 8080 CMD ["python3", "app.py"]
What Changed, and Why#
| Change | Why It Matters |
|---|---|
Pinned exact version (3.12.3-slim) instead of latest | latest is a moving target — the exact same Dockerfile can produce a completely different, unpredictable image weeks later, with different (possibly vulnerable) package versions, and no reproducibility for debugging |
| Multi-stage build | The final image doesn't include compilers/build tools that were only needed to install dependencies — smaller image, smaller attack surface |
slim base instead of full ubuntu | Fewer OS packages installed = fewer possible CVEs to scan and worry about |
Non-root USER appuser | If an attacker exploits the app, they're running as an unprivileged user, not root — dramatically limits what they can do inside the container |
No hardcoded secrets (ENV AWS_SECRET_KEY=... removed) | A secret baked into an image layer is permanently embedded in the image's history, extractable by anyone who can pull it — covered fully in Part 4 |
A great, concrete interview line: "The two changes that matter most for real-world risk reduction are: never run as root, and never bake secrets into image layers — everything else (multi-stage builds, minimal base images) reduces attack surface, but those two specifically prevent a contained compromise from becoming a much bigger one."
Distroless and Minimal Base Images#
An even more aggressive hardening technique worth naming: distroless images (popularized by Google's gcr.io/distroless project) contain only your application and its direct runtime dependencies — no shell, no package manager, no OS utilities at all.
Diagram
# Example: a distroless final stage for a Go binary FROM golang:1.22 AS builder WORKDIR /app COPY . . RUN CGO_ENABLED=0 go build -o myapp . FROM gcr.io/distroless/static-debian12 COPY --from=builder /app/myapp /myapp USER nonroot:nonroot ENTRYPOINT ["/myapp"]
The tradeoff worth naming: distroless images are harder to debug directly (you can't docker exec into them and poke around with a shell, because there isn't one) — teams typically compensate with better external observability (logs, traces, from the Observability tutorial series) instead of relying on live shell debugging inside production containers.
Container Runtime Isolation — What a Container Actually Is#
A frequently-tested conceptual question: "is a container as isolated as a virtual machine?" The honest answer is no, and understanding why is genuinely important.
Diagram
Plain-English explanation of the isolation mechanisms:
- Namespaces: give each container its own view of things like process IDs, network interfaces, and mounted filesystems — so it looks isolated from the container's own perspective, even though it's sharing the same underlying kernel.
- cgroups (control groups): limit how much CPU, memory, and other resources a container can actually consume — this is what prevents one noisy container from starving every other container on the same host (directly connects to the Saturation/USE concepts from the Monitoring Methodologies series).
- seccomp: restricts which low-level kernel system calls a container is even allowed to make, shrinking the attack surface available to an attacker who does gain code execution inside it.
Why this matters practically: because containers share the host kernel, a serious kernel vulnerability can theoretically allow a "container escape" — code running inside a container gaining access to the host or to other containers. This is exactly why running containers as non-root, using minimal images, and applying Pod Security Standards (below) all matter — they're layered defenses specifically because container isolation is real but genuinely weaker than a VM's.
Kubernetes Security — The Big Picture#
Kubernetes adds several more distinct security layers on top of container-level concerns.
Diagram
RBAC — Role-Based Access Control#
RBAC controls who (or what) can perform which actions on which Kubernetes resources. The core building blocks:
Diagram
- Role: namespace-scoped permissions (e.g., "can list/get pods, but only in the
checkoutnamespace"). - ClusterRole: cluster-wide permissions (or reusable across namespaces).
- RoleBinding: grants a Role to a subject, within one namespace.
- ClusterRoleBinding: grants a ClusterRole to a subject, cluster-wide.
A Full Worked RBAC Example#
A realistic, minimal-privilege setup for a CI/CD deployment pipeline's ServiceAccount — showing exactly the kind of least-privilege thinking interviewers want to see.
# 1. Create a dedicated ServiceAccount for the CI/CD pipeline apiVersion: v1 kind: ServiceAccount metadata: name: ci-deployer namespace: checkout --- # 2. Define a Role with ONLY the specific permissions needed — # NOT cluster-admin, NOT wildcard access apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: deployer-role namespace: checkout rules: - apiGroups: ["apps"] resources: ["deployments"] verbs: ["get", "list", "update", "patch"] - apiGroups: [""] resources: ["pods"] verbs: ["get", "list"] # NOTE: deliberately NOT including "delete", "create" on secrets, # or access to OTHER namespaces — least privilege --- # 3. Bind the Role to the ServiceAccount apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: ci-deployer-binding namespace: checkout subjects: - kind: ServiceAccount name: ci-deployer namespace: checkout roleRef: kind: Role name: deployer-role apiGroup: rbac.authorization.k8s.io
# Verify what a ServiceAccount can actually do — a genuinely # useful, commonly-used auditing command kubectl auth can-i update deployments \ --as=system:serviceaccount:checkout:ci-deployer \ -n checkout # yes kubectl auth can-i delete secrets \ --as=system:serviceaccount:checkout:ci-deployer \ -n checkout # no
The core principle worth stating explicitly: least privilege — grant exactly the permissions needed for the task, in exactly the namespace needed, and nothing more. A CI pipeline that only ever needs to update Deployments in one namespace should never be granted cluster-admin "just in case" — that turns a compromised CI credential (a very real, common attack vector) into a full cluster compromise instead of a contained one.
Pod Security Standards#
Kubernetes defines three built-in Pod Security Standards (replacing the older, now-deprecated PodSecurityPolicy) that control what privilege level a pod is allowed to run with.
Diagram
A pod spec enforcing the "Restricted" level's key settings directly:
apiVersion: v1 kind: Pod metadata: name: secure-app spec: securityContext: runAsNonRoot: true runAsUser: 1000 seccompProfile: type: RuntimeDefault containers: - name: app image: myapp:1.2.3 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL
| Setting | What It Prevents |
|---|---|
runAsNonRoot: true | Refuses to start the pod at all if the container image would run as root |
allowPrivilegeEscalation: false | Prevents a process from gaining more privileges than its parent (e.g., via setuid binaries) |
readOnlyRootFilesystem: true | The container's filesystem can't be modified at runtime — a common technique to limit what a compromised process can actually do (e.g., can't drop and execute a malicious binary) |
capabilities: drop: [ALL] | Removes ALL Linux capabilities by default, only adding back the few specific ones actually needed (least privilege applied at the kernel-capability level) |
Enforcing this at the namespace level via labels (built into modern Kubernetes, no extra tooling required):
kubectl label namespace checkout \ pod-security.kubernetes.io/enforce=restricted \ pod-security.kubernetes.io/audit=restricted
Network Policies — Zero Trust Inside the Cluster#
By default, Kubernetes allows any pod to talk to any other pod, across the entire cluster, with no restrictions at all. This is a genuinely important, commonly-tested fact — and a real security gap in the default configuration.
Diagram
A NetworkPolicy that only allows the checkout service to receive traffic from the api-gateway pods, and denies everything else:
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: checkout-allow-from-gateway namespace: checkout spec: podSelector: matchLabels: app: checkout-service policyTypes: - Ingress ingress: - from: - podSelector: matchLabels: app: api-gateway ports: - protocol: TCP port: 8080
Important, commonly-tested caveat: NetworkPolicies require a CNI (Container Network Interface) plugin that actually supports enforcing them — e.g., Calico, Cilium — the default kubenet networking in some setups does not enforce NetworkPolicies at all, meaning the YAML above would silently do nothing. Always verify the cluster's CNI plugin actually supports NetworkPolicy enforcement before relying on it — a genuinely practical gotcha worth naming.
Admission Controllers and OPA Gatekeeper#
Admission controllers intercept requests to the Kubernetes API before an object is actually created/updated, letting you enforce policy automatically — a direct, concrete application of the "security as code" idea from Part 1.
Diagram
A real OPA/Gatekeeper constraint (using its ConstraintTemplate + Constraint pattern) blocking the :latest tag:
# ConstraintTemplate: defines the REUSABLE policy logic (Rego) apiVersion: templates.gatekeeper.sh/v1 kind: ConstraintTemplate metadata: name: k8sdisallowedtags spec: crd: spec: names: kind: K8sDisallowedTags targets: - target: admission.k8s.gatekeeper.sh rego: | package k8sdisallowedtags violation[{"msg": msg}] { image := input.review.object.spec.containers[_].image endswith(image, ":latest") msg := sprintf("image '%v' uses the disallowed ':latest' tag", [image]) } --- # Constraint: APPLIES the policy above to actual resources apiVersion: constraints.gatekeeper.sh/v1beta1 kind: K8sDisallowedTags metadata: name: no-latest-tag spec: match: kinds: - apiGroups: ["apps"] kinds: ["Deployment"]
Why this matters practically, tying back to Part 1's "security as code" idea: instead of a wiki page saying "please don't use :latest tags," this policy is automatically, mechanically enforced on every single deployment attempt, cluster-wide, with zero reliance on any human remembering to check.
Secrets in Kubernetes — A Preview#
Kubernetes has a built-in Secret object, but it's genuinely important to know its real limitation: by default, Kubernetes Secrets are only base64-encoded, not encrypted, at rest in etcd (unless encryption-at-rest is explicitly configured).
# Base64 is NOT encryption — anyone with etcd access, # or sufficient RBAC permissions, can trivially decode this kubectl get secret my-secret -o jsonpath='{.data.password}' | base64 -d
This is exactly why Part 4 of this series covers dedicated secrets management tools (like HashiCorp Vault) in depth — native Kubernetes Secrets are a starting point, not a complete solution, for genuinely sensitive data.
Runtime Security: Falco#
Everything above is about preventing a problem before it happens. Falco (a CNCF project) represents a different, complementary layer: detecting suspicious behavior in a running cluster, in real time.
Diagram
# A simplified example Falco rule: alert if a shell is spawned # inside any container in the 'production' namespace - rule: Unexpected shell in production container desc: A shell was spawned inside a production container condition: > spawned_process and container and k8s.ns.name = "production" and proc.name in (bash, sh, zsh) output: > Shell spawned in production container (user=%user.name container=%container.name command=%proc.cmdline) priority: WARNING
Why this matters, tying back to the "defense in depth" idea: even with perfect RBAC, network policies, and hardened images, a genuinely novel attack could still get through — Falco's job is to catch it while it's happening, based on behavior (an unexpected shell spawning) rather than a known, pre-identified pattern, which is exactly the kind of "unknown unknown" detection that static scanning (SAST/SCA) fundamentally can't provide.
A Full Kubernetes Security Checklist#
A practical, memorable summary worth having ready for an interview:
Diagram
Common Mistakes#
| Mistake | Why It's Wrong | Fix |
|---|---|---|
| Running containers as root | If compromised, the attacker has root inside the container, dramatically increasing what they can do, including potential container escape attempts | Always set a non-root USER in the Dockerfile and runAsNonRoot: true in the pod spec |
Using :latest image tags in production | Unreproducible, unpredictable, and can silently pull in new vulnerabilities on every restart | Pin exact, immutable image versions/digests |
| Assuming Kubernetes Secrets are encrypted by default | They're only base64-encoded at rest by default — trivially reversible | Enable etcd encryption-at-rest, and/or use a dedicated secrets manager (Part 4) |
| Granting cluster-admin to CI/CD service accounts "to be safe" | Turns a compromised CI credential (a very real attack vector) into a full cluster compromise | Apply least-privilege RBAC scoped to exactly what the pipeline needs |
| Assuming NetworkPolicies work with any CNI plugin | Some CNI plugins don't enforce them at all — the policy silently does nothing | Verify the cluster's CNI plugin explicitly supports NetworkPolicy enforcement (e.g., Calico, Cilium) |
| Relying only on prevention (scanning, RBAC), with no runtime detection | Misses genuinely novel attacks that don't match any known pattern | Add runtime behavioral monitoring (Falco or equivalent) as a complementary, defense-in-depth layer |
Worked Practice Problems#
Problem 1: A security review finds that a production namespace has zero NetworkPolicies defined. What's the actual risk, in concrete terms, and how would you fix it?
Answer: By default, every pod in that namespace (and potentially the whole cluster, depending on other namespaces' policies) can freely communicate with every other pod — meaning if any single pod is compromised (e.g., via a vulnerable dependency exploited through the internet-facing frontend), the attacker can immediately attempt to reach far more sensitive internal services (databases, internal APIs) with zero network-level friction. Fix: implement default-deny NetworkPolicies for the namespace, then add explicit, narrow allow rules only for the specific pod-to-pod communication paths actually required — verifying first that the cluster's CNI plugin supports NetworkPolicy enforcement at all.
Problem 2: A CI/CD pipeline's ServiceAccount currently has cluster-admin bound to it, because "it was easier to get everything working." What's the real risk, and what would a properly scoped alternative look like?
Answer: Cluster-admin means anyone who compromises the CI/CD pipeline's credentials (a genuinely common attack vector, e.g., via a compromised third-party GitHub Action or a leaked token) instantly has full control over the entire cluster — every namespace, every secret, the ability to delete anything. A properly scoped alternative: create a namespace-specific Role granting only the exact verbs (get/list/update/patch) on the exact resources (Deployments, and only Deployments) the pipeline actually needs to perform its job, bound via a RoleBinding scoped to only the namespace(s) it deploys to — verifiable directly with kubectl auth can-i against the ServiceAccount.
Problem 3: Falco fires an alert: "a shell was spawned inside a production checkout-service container." The on-call engineer initially dismisses it as a false positive since "nothing else looks wrong." What would you want them to actually do, and why?
Answer: Investigate before dismissing — this is exactly the kind of anomalous, behavior-based signal that prevention-focused tooling (scanning, RBAC) can't provide, and it's specifically the class of alert Falco exists to surface: something happening that doesn't match expected, normal behavior for that workload (a checkout service shouldn't normally have anyone spawning an interactive shell inside it at all). I'd want them to at minimum check recent access logs/audit logs for who/what triggered it, and treat it as a potential active compromise until proven otherwise, rather than assuming it's noise — dismissing genuinely novel, behavior-based security signals without investigation is exactly how real intrusions go undetected.
Summary and What's Next#
- Containers introduce security questions beyond application code: what's inside the image itself (base OS/runtime layers), and how much privilege does the running container actually have.
- Container image scanning (e.g., Trivy) specifically targets base OS/runtime CVEs that SAST/SCA (Part 2) don't cover.
- Dockerfile hardening essentials: pin exact versions (never
:latest), use multi-stage builds and minimal/distroless base images, run as a non-root user, and never bake secrets into image layers. - Containers are not as isolated as VMs — they share the host kernel via namespaces/cgroups/seccomp, which is why running as non-root and minimizing images both matter as layered defenses against a potential container escape.
- Kubernetes RBAC should always follow least privilege — a compromised, over-privileged ServiceAccount (especially in CI/CD) turns a contained incident into a full cluster compromise.
- Pod Security Standards (Restricted level: non-root, no privilege escalation, dropped capabilities, read-only filesystem) constrain what a running pod is even allowed to do.
- Kubernetes has no network isolation between pods by default — NetworkPolicies implement zero-trust, default-deny communication, but only work if the cluster's CNI plugin actually supports enforcing them.
- Admission controllers (OPA Gatekeeper) enforce policy automatically at deploy time — a direct, concrete implementation of "security as code."
- Native Kubernetes Secrets are only base64-encoded by default, not encrypted — a real gap that Part 4 addresses with dedicated secrets management tooling.
- Runtime security tools (Falco) provide a complementary, behavior-based detection layer for genuinely novel attacks that prevention-focused tooling can't catch by pattern-matching alone.
Continue to Part 4 (04-secrets-management-and-iam.md) for a full deep dive into secrets management (Vault, KMS), secret scanning, and least-privilege IAM design.