← DevSecOps← DevSecOps
DevSecOpsDevSecOps19 Th7, 2026Jul 19, 202627 phút đọc22 min read

Bảo mật Container & KubernetesContainer & Kubernetes Security

Thuộc bộ kiến thức DevSecOps Roadmap.

Tổng quan

Container và Kubernetes thay đổi cách phần mềm được triển khai, nhưng đồng thời cũng thay đổi bề mặt tấn công (attack surface). Một container không phải là một VM thu nhỏ — nó là một tập hợp các process bình thường trên host, được cô lập bởi các cơ chế của kernel chứ không phải bằng ảo hóa phần cứng. Chính điều này chi phối hầu hết các control bảo mật container: vì các container dùng chung kernel của host, một lỗ hổng container escape hay một exploit kernel có thể ảnh hưởng tới cả host lẫn mọi container khác đang chạy trên đó. Kubernetes sau đó bổ sung thêm một lớp hoàn toàn mới: một API cấp cluster có thể tạo, sửa, và xóa workload, với mô hình authentication, authorization, và network riêng — và API này “mở theo mặc định” (open by default) trừ khi bạn chủ động khóa lại.

Note này coi bảo mật container và Kubernetes là một bài toán liên tục, đi theo vòng đời workload từ lúc build đến lúc chạy:

  1. Build time — image chứa gì (base image, package, layer) và có chứa CVE đã biết hay không.
  2. Ship time — image đi từ registry đến cluster như thế nào: có được ký (sign) không, có được pull từ nguồn tin cậy không, có được scan trước khi cho phép chạy không.
  3. Run time — container đang chạy và cluster xung quanh nó cho phép nó làm gì: quyền user, quyền truy cập filesystem, syscall của kernel, khả năng reach mạng, và quyền API.

Đây là một trường hợp cụ thể của các chủ đề shift-leftsupply chain security rộng hơn đã được đề cập ở các note khác trong roadmap DevSecOps này, áp dụng vào stack container/Kubernetes. Note này bổ sung cho ../kubernetes/en/08-security.md — note đó đi sâu hơn về bảo mật cluster Kubernetes (mô hình 4Cs, authentication, RBAC, admission control) từ góc nhìn platform engineering. Ở đây trọng tâm là góc nhìn của người làm DevSecOps: cần scan gì, cần enforce gì, và cần kiểm tra gì trong pipeline hoặc audit.

Kiến thức nền tảng

Image layers và mô hình shared kernel

Một container image là một chồng các layer chỉ đọc (read-only), mỗi layer là một diff filesystem sinh ra từ một instruction trong Dockerfile (FROM, RUN, COPY, …). Khi chạy, container engine thêm một layer ghi (writable) mỏng lên trên (thông qua union filesystem như overlayfs) và khởi động một hoặc nhiều process bên trong một tập các cơ chế cô lập của kernel Linux:

Cơ chế kernelCô lập cái gì
Namespaces (pid, net, mnt, uts, ipc, user)Những gì process nhìn thấy — cây process riêng, network stack riêng, mount table riêng, hostname riêng
cgroupsNhững gì process được tiêu thụ — giới hạn CPU, memory, I/O
CapabilitiesNhững thao tác kernel vốn chỉ dành cho root mà process được phép thực hiện, mà không cần full root
seccompNhững syscall mà process được phép gọi
LSM (AppArmor / SELinux)Mandatory access control chi tiết trên file, network, capabilities

Với kernel, mọi process trong container vẫn chỉ là một process Linux bình thường — nó chỉ được bọc trong các namespace kể trên. Đó chính là khác biệt then chốt so với virtual machine (VM):

Virtual MachineContainer
Ranh giới cô lậpẢo hóa phần cứng; mỗi VM chạy kernel riêngNamespaces/cgroups trên một kernel host dùng chung
Bề mặt tấn công nếu bị compromiseCần hypervisor escape mới chạm được host hoặc VM khác (hiếm, được kiểm soát chặt)Một kernel exploit hoặc misconfiguration (ví dụ chạy root với capability thừa) có thể chạm host trực tiếp
Thời gian khởi động / mật độVài giây, nặng hơn (mỗi VM một OS đầy đủ)Vài mili giây, nhẹ (dùng chung kernel, userspace riêng)
Hệ quả thực tếCô lập mặc định mạnhMức độ cô lập chỉ mạnh bằng cấu hình bạn thiết lập — non-root, drop capability, seccomp, read-only root FS không phải là tùy chọn thêm, mà chính là thứ khiến cô lập container có ý nghĩa

Đây là lý do vì sao “container mặc định không phải là một ranh giới bảo mật” là một cảnh báo phổ biến: hai container trên cùng một node giống hai process trên cùng một máy Linux hơn là hai máy riêng biệt. Hãy coi node là ranh giới tin cậy thật sự, và coi mỗi control bảo mật container bên dưới là một cách thu hẹp những gì một process bị compromise trong kernel dùng chung đó có thể làm.

Chạy non-root và dùng base image tối giản

Hai bước hardening có hiệu quả cao nhất mà chi phí thấp nhất:

Một Dockerfile multi-stage đã hardening cho service Go, kết hợp cả hai:

# --- build stage: có đầy đủ toolchain, không bao giờ được ship ---
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/app

# --- final stage: distroless, không shell, không package manager ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot          # UID 65532, đã là non-root sẵn trong base image này
ENTRYPOINT ["/app"]

Và cách hardening tương đương trên image nền Debian/Alpine khi không thể dùng distroless hoàn toàn (ví dụ cần shell hoặc dynamic library):

FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app \
    && apk add --no-cache dumb-init \
    && rm -rf /var/cache/apk/*
WORKDIR /app
COPY --chown=app:app package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=app:app . .
USER app                       # không bao giờ chạy bằng root
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

Một số thói quen build-time khác nên duy trì:

Image scanning

Image scanning kiểm tra các layer của image so với các database lỗ hổng đã biết (CVE) trong package hệ điều hành và dependency ngôn ngữ, cộng thêm misconfiguration (thực hành Dockerfile sai, secret hardcode, key bị lộ).

Những gì một scanner thực sự kiểm tra:

Các công cụ phổ biến:

Công cụLoạiGhi chú
Trivy (Aqua Security)OSS, tất cả trong mộtScan image, filesystem, git repo, IaC, và manifest Kubernetes; một binary static duy nhất; rất nhanh; lựa chọn mặc định phổ biến nhất trong CI hiện nay
Grype (Anchore)OSS, scan image/SBOMĐi kèm Syft (sinh SBOM); phù hợp khi scan lại một SBOM đã sinh sẵn thay vì scan lại toàn bộ image
ClairOSS, dạng server/APIChạy như một service mà registry (Harbor, Quay) gọi để scan image vừa push; phù hợp với scan tích hợp registry hơn là dùng CLI đơn lẻ
Docker ScoutThương mại (có bản miễn phí)Tích hợp sẵn trong docker scan / Docker Desktop; đánh giá policy dựa trên metadata Docker Hub
Snyk ContainerThương mạiHướng dẫn khắc phục (dependency-to-fix) mạnh, tích hợp với kết quả SCA từ cùng nhà cung cấp

Pattern scan-in-CI — fail build khi có phát hiện High/Critical, nhưng không chặn với các phát hiện chỉ mang tính thông tin:

# .github/workflows/image-scan.yml
name: Build and scan image
on: [push, pull_request]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: '1'          # fail pipeline nếu có CVE high/critical
          ignore-unfixed: true    # không fail với CVE chưa có bản vá

      - name: Upload results to code scanning
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

Hai lưu ý thực tế: (1) scan sớm (trên mọi PR, đối với image đã build, không chỉ lúc deploy) để bắt được base image có lỗ hổng trước khi merge, và (2) cũng nên scan định kỳ ngay trong registry — một image build sạch hôm nay có thể trở thành có lỗ hổng vào ngày mai khi một CVE mới được công bố cho một package đã nằm sẵn trong đó, mà không có commit mới nào để kích hoạt scan trên PR.

Bảo mật container runtime

Scanning bao phủ những gì nằm trong image; các control runtime giới hạn những gì một container đang chạy được phép làm, điều này quan trọng ngay cả với một image “sạch” (zero-day vẫn tồn tại) và là defense-in-depth thiết yếu phòng khi scanner bỏ sót.

Góc nhìn tương đương với Docker Compose / docker run:

docker run \
  --read-only \
  --tmpfs /tmp \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --security-opt no-new-privileges:true \
  --security-opt seccomp=default.json \
  --memory=256m --cpus=0.5 \
  --user 1000:1000 \
  myapp:latest

Tương đương và phổ biến hơn trong production, securityContext của Kubernetes:

apiVersion: v1
kind: Pod
metadata:
  name: hardened-app
spec:
  securityContext:                  # cấp pod: áp dụng cho tất cả container
    runAsNonRoot: true
    runAsUser: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: myregistry.example.com/myapp@sha256:abcd1234...
      securityContext:               # cấp container: override/bổ sung cho cấp pod
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
          add: ["NET_BIND_SERVICE"]   # chỉ khi cần bind port <1024, nếu không thì drop hết
      resources:
        requests: { cpu: "100m", memory: "128Mi" }
        limits:   { cpu: "500m", memory: "256Mi" }
      volumeMounts:
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: tmp
      emptyDir: {}

Khái niệm chính

Bề mặt tấn công của Kubernetes

Kubernetes là một hệ thống phân tán với nhiều thành phần mà attacker có thể nhắm tới, mỗi thành phần có phạm vi ảnh hưởng khác nhau nếu bị compromise:

Thành phầnVai tròVì sao là mục tiêu
API serverCổng vào của toàn bộ cluster; mọi lệnh kubectl và hành động của controller đều đi qua đâyMục tiêu giá trị nhất — bất kỳ ai có thể giao tiếp với nó với quyền RBAC đủ lớn đều có thể tạo, đọc, hoặc xóa gần như mọi thứ trong cluster
kubeletAgent trên node chạy Pod, expose một API trên mỗi node (dùng cho log, exec, port-forward)Một kubelet API bị lộ hoặc thiếu xác thực cho phép attacker chạy lệnh tùy ý trực tiếp trên node đó, bỏ qua hoàn toàn API server
etcdLưu toàn bộ state của cluster, bao gồm cả Secrets (chỉ base64-encode, không mã hóa, trừ khi cấu hình encryption-at-rest)Truy cập trực tiếp vào etcd tương đương với compromise toàn bộ cluster — ai có quyền đọc etcd đều đọc được mọi Secret trong cluster dưới dạng plaintext
Container runtime / CRI socketThực thi container trên nodeTruy cập vào runtime socket (ví dụ /var/run/docker.sock được mount vào một Pod) là một con đường container escape/leo thang quyền đã được biết rõ
Admission webhookChặn và mutate/validate các request APIMột webhook cấu hình sai hoặc không sẵn sàng có thể fail open (bỏ qua policy đáng ra phải enforce) hoặc fail closed (denial of service cho toàn cluster)

Khóa chặt các thành phần này chính là nội dung của ../kubernetes/en/08-security.md (authentication, RBAC, admission control chi tiết); note này tóm tắt hai control mà một kỹ sư DevSecOps sẽ cấu hình và audit thường xuyên nhất — RBAC và NetworkPolicy — cộng thêm các control hướng supply chain.

RBAC cho quyền truy cập cluster

RBAC trả lời câu hỏi “identity này được phép làm gì.” Nguyên tắc cốt lõi: least privilege, mặc định theo namespace, không dùng wildcard. Một ví dụ tối giản, dễ audit — một CI/CD deployer bị giới hạn trong một namespace, một tập verb, một tập resource:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: staging
  name: ci-deployer
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "patch", "update"]     # không "delete", không "create" kind mới
  - apiGroups: [""]
    resources: ["pods", "pods/log"]
    verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  namespace: staging
  name: ci-deployer-binding
subjects:
  - kind: ServiceAccount
    name: ci-bot
    namespace: staging
roleRef:
  kind: Role
  name: ci-deployer
  apiGroup: rbac.authorization.k8s.io

Checklist audit riêng cho RBAC: không có ClusterRoleBinding tới cluster-admin cho bất kỳ ai ngoài một nhóm admin break-glass nhỏ; không có binding tới ServiceAccount default; không có wildcard ("*") trong resources hay verbs ngoài các role platform-admin thực sự; automountServiceAccountToken: false cho các Pod không bao giờ gọi API. Các công cụ như kubectl-who-canrbac-lookup giúp tìm binding quá rộng ở quy mô lớn.

Network policy để phân đoạn pod-to-pod

Mặc định, networking của Kubernetes là phẳng và default-allow: bất kỳ Pod nào cũng có thể reach bất kỳ Pod nào khác ở bất kỳ namespace nào trên bất kỳ port nào, miễn là CNI plugin không enforce khác đi. NetworkPolicy là cách bạn biến điều này thành default-deny và chỉ cho phép rõ ràng đúng lưu lượng mà một service thực sự cần — tương đương RBAC nhưng cho traffic east-west.

# 1. Default-deny toàn bộ ingress và egress trong namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: prod
spec:
  podSelector: {}          # áp dụng cho mọi Pod trong namespace
  policyTypes: ["Ingress", "Egress"]
---
# 2. Chỉ cho phép Pod API nhận traffic từ frontend,
#    và chỉ reach database trên đúng port + DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-frontend-to-api
  namespace: prod
spec:
  podSelector:
    matchLabels: { app: api }
  policyTypes: ["Ingress", "Egress"]
  ingress:
    - from:
        - podSelector: { matchLabels: { app: frontend } }
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - podSelector: { matchLabels: { app: database } }
      ports:
        - protocol: TCP
          port: 5432
    - to:                                    # cho phép phân giải DNS
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53

Lưu ý quan trọng: NetworkPolicy cần một CNI plugin hỗ trợ nó (Calico, Cilium, Weave; không phải kiểu networking bridge/kubenet cơ bản mà một số managed cluster dùng mặc định) — cần áp dụng một plugin phù hợp và xác minh việc enforce thực sự hoạt động, đừng chỉ giả định.

Pod Security Standards và admission control

Kubernetes thay thế PodSecurityPolicy đã deprecated bằng Pod Security Standards (PSS), được enforce thông qua controller Pod Security Admission (PSA) built-in (hoặc, cho policy phong phú hơn, dùng OPA/Gatekeeper/Kyverno). PSS định nghĩa ba mức:

MứcHành vi
PrivilegedKhông giới hạn — gần như không có policy; chỉ dành cho workload tin cậy, thiết yếu của cluster
BaselineChặn các leo thang quyền đã biết: không container privileged, không host namespace (hostNetwork, hostPID, hostIPC), không mount host path nhạy cảm, capability bị hạn chế
RestrictedHardening nặng: yêu cầu thêm runAsNonRoot, chặn leo thang quyền, yêu cầu seccomp RuntimeDefault trở lên, drop toàn bộ (ALL) capability theo mặc định

Được enforce theo từng namespace thông qua label — không cần CRD, không cần webhook cho việc enforce baseline built-in:

apiVersion: v1
kind: Namespace
metadata:
  name: prod
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/audit: restricted     # ghi log vi phạm mà không chặn, hữu ích khi đang migrate
    pod-security.kubernetes.io/warn: restricted       # cảnh báo ở phía client (kubectl) mà không chặn

Với những gì vượt ngoài phạm vi PSS (ví dụ “image phải đến từ myregistry.example.com”, “không dùng tag :latest”, “mọi Deployment phải có resource limit”), dùng một policy engine dưới dạng validating admission webhookOPA/Gatekeeper (constraint/constraint template dựa trên Rego) hoặc Kyverno (policy viết bằng YAML Kubernetes thuần, nhìn chung được coi là dễ viết hơn). Các engine này nằm trong chuỗi admission sau khi RBAC authorization đã pass và trước khi object được ghi vào etcd, nên chúng có thể từ chối thẳng các object không tuân thủ.

Quản lý secrets trong Kubernetes

Một object Secret gốc chỉ base64-encode, không mã hóa, theo mặc định — bất kỳ ai đọc được object Secret qua API (hoặc đọc trực tiếp etcd) đều có thể decode nó dễ dàng. Hệ quả và cách khắc phục thực tế:

Image pull policy và trusted registry

Supply chain: ký image và enforce policy

Đây là một lát cắt hẹp hơn, tập trung vào container, của các thực hành CI/CD và supply chain security rộng hơn được đề cập ở ./12-cicd-and-supply-chain-security.md.

CIS Benchmarks cho Docker và Kubernetes

CIS (Center for Internet Security) Benchmarks là các hướng dẫn hardening cấu hình được đồng thuận rộng rãi, trung lập về nhà cung cấp. Có một CIS Docker Benchmark và một CIS Kubernetes Benchmark (cộng thêm các biến thể riêng cho cloud như EKS, GKE, AKS, vì control plane managed khác với control plane tự quản lý). Mỗi benchmark là một checklist được đánh số các khuyến nghị (“1.1.1 Ensure the container host has been hardened,” “5.2.5 Minimize the admission of containers with allowPrivilegeEscalation”), mỗi khuyến nghị có trạng thái chấm điểm (Scored/Not Scored) và bước khắc phục.

Trong thực tế, bạn không đọc PDF rồi tick tay từng mục — bạn chạy một công cụ benchmark tự động nhắm vào cluster hoặc host đang chạy:

# chạy kube-bench như một Job nhắm vào một node, dùng chính config của node đó
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl logs job/kube-bench

CIS Benchmarks cũng là nền tảng cho nhiều bộ mapping tuân thủ (SOC 2, PCI-DSS, các control FedRAMP thường trích dẫn CIS làm baseline control kỹ thuật), nên việc pass các benchmark này thường vừa là một cải thiện bảo mật vừa là một yêu cầu audit.

Best Practices

Checklist hardening — danh sách audit thực tế trải dài từ build, ship, đến run:

Khu vựcThực hànhVì sao
Image buildDùng base image tối giản/distroless, pin theo digestGiảm bề mặt tấn công và loại bỏ rủi ro tag bị thay đổi
Image buildMulti-stage build; không bao giờ ship build tool/sourceCompiler và shell là bề mặt tấn công không cần thiết trong prod
Image buildKhông secret trong ARG/COPY; dùng BuildKit secret mountSecret bị đóng gói vẫn tồn tại trong lịch sử layer kể cả sau khi xóa
Image buildChạy non-root (USER), tránh binary sudo/setuidGiới hạn phạm vi ảnh hưởng của một process bị compromise
ScanningScan mọi image trong CI; fail khi có High/CriticalBắt được CVE đã biết trước khi merge/deploy
ScanningScan lại định kỳ các image đã có sẵn trong registryCVE mới được công bố sau khi image đã build và pass scan sạch
RuntimereadOnlyRootFilesystem: true trừ khi thực sự cần ghiChặn việc sửa đổi lúc runtime / thả payload
Runtimecapabilities: drop: [ALL], chỉ add lại những gì đã chứng minh cần thiếtGiảm thiểu các thao tác kernel tương đương root có sẵn
RuntimeseccompProfile: RuntimeDefault (hoặc chặt hơn) cho mọi workloadChặn syscall nguy hiểm với chi phí gần như bằng không
RuntimeĐặt requestslimits CPU/memory cho mọi containerNgăn một workload gây DoS do cạn kiệt tài nguyên
ClusterEnforce Pod Security Standards (restricted cho namespace prod)Đưa các control runtime ở trên vào toàn cluster, không phải từng manifest riêng lẻ
ClusterNetworkPolicy default-deny theo namespace, cho phép rõ ràngNgăn di chuyển ngang (lateral movement) giữa workload bị compromise và workload lân cận
ClusterRBAC least-privilege; không wildcard; audit binding thường xuyênGiới hạn những gì một identity/ServiceAccount bị compromise có thể làm
ClusterautomountServiceAccountToken: false nơi không gọi APILoại bỏ một credential mà attacker không cần phải đánh cắp
SecretsMã hóa etcd at rest; ưu tiên external secret store + sync operatorChỉ base64 Secret thôi không đảm bảo confidentiality
Supply chainKý image (cosign); enforce verify chữ ký ở admissionĐảm bảo chỉ artifact do bạn build mới thực sự chạy được
Supply chainAllow-list registry; pin image production theo digestNgăn pull từ nguồn không tin cậy hoặc tag bị trỏ lại
ComplianceChạy kube-bench / docker-bench-security thường xuyên, không chỉ một lầnCấu hình có thể trôi dạt (drift) theo thời gian; benchmark bắt được các regression

Note này cố ý chồng lấn, thay vì lặp lại, với ba trang liên quan: đọc ../kubernetes/en/08-security.md để hiểu đầy đủ mô hình authentication/authorization/admission của Kubernetes, ./09-security-testing-tools.md để biết image/IaC scanning nằm ở đâu trong toolchain SAST/DAST/SCA rộng hơn, ./11-cloud-security.md để biết managed Kubernetes (EKS/GKE/AKS) chuyển một phần trách nhiệm này sang cloud provider như thế nào, và ./12-cicd-and-supply-chain-security.md để biết về signing, provenance, và hardening pipeline ngoài phạm vi image.

Tài liệu tham khảo

Part of the DevSecOps Roadmap knowledge base.

Overview

Containers and Kubernetes changed how software ships, but they also changed the attack surface. A container is not a lightweight VM — it is a set of regular processes on the host, isolated by kernel features rather than by hardware virtualization. That single fact drives almost every container security control: because containers share the host kernel, a container escape or kernel exploit can compromise the host and every other container on it. Kubernetes then adds a second layer entirely: a cluster-wide API that can create, mutate, and destroy workloads, with its own authentication, authorization, and network model — and that API is “open by default” unless you deliberately lock it down.

This note treats container and Kubernetes security as one continuous problem, following the workload from build to runtime:

  1. Build time — what goes into the image (base image, packages, layers) and whether it contains known vulnerabilities.
  2. Ship time — how the image gets from a registry to a cluster: is it signed, is it pulled from a trusted source, is it scanned before it is allowed to run.
  3. Run time — what the running container and the surrounding cluster allow it to do: user privileges, filesystem access, kernel syscalls, network reachability, and API permissions.

This is a specific case of the broader shift-left and supply chain security themes covered elsewhere in this DevSecOps roadmap, applied to the container/Kubernetes stack. It complements ../kubernetes/en/08-security.md, which covers Kubernetes cluster security (the 4Cs model, authentication, RBAC, admission control) in more depth from the platform-engineering angle. Here the focus is the DevSecOps practitioner’s view: what to scan, what to enforce, and what to check in a pipeline or audit.

Fundamentals

Image layers and the shared-kernel model

A container image is a stack of read-only layers, each one a filesystem diff produced by a Dockerfile instruction (FROM, RUN, COPY, …). At runtime, the container engine adds a thin writable layer on top (via a union filesystem such as overlayfs) and starts one or more processes inside a set of Linux kernel isolation primitives:

Kernel featureWhat it isolates
Namespaces (pid, net, mnt, uts, ipc, user)What the process can see — its own process tree, network stack, mount table, hostname
cgroupsWhat the process can consume — CPU, memory, I/O limits
CapabilitiesWhich root-only kernel operations the process may perform, without needing full root
seccompWhich syscalls the process may invoke at all
LSM (AppArmor / SELinux)Fine-grained mandatory access control on files, network, capabilities

Every containerized process on a host is still, from the kernel’s point of view, a regular Linux process — it is just wrapped in these namespaces. That is the crucial difference from a virtual machine:

Virtual MachineContainer
Isolation boundaryHardware-virtualized; each VM runs its own kernelNamespaces/cgroups on one shared host kernel
Attack surface if compromisedHypervisor escape needed to reach host or other VMs (rare, heavily scrutinized)A kernel exploit or misconfiguration (e.g., running as root with extra capabilities) can reach the host directly
Startup / densitySeconds, heavier (full OS per VM)Milliseconds, lightweight (shared kernel, own userspace)
Practical implicationStrong default isolationIsolation is only as strong as your configuration — non-root, dropped capabilities, seccomp, read-only root FS are not optional extras, they are what makes container isolation meaningful

This is why “containers are not a security boundary by default” is a common warning: two containers on the same node are closer to two processes on the same Linux box than to two separate machines. Treat the node as the real trust boundary, and treat every container security control below as narrowing what a compromised process inside that shared kernel can do.

Running as non-root and minimal base images

Two of the highest-leverage, lowest-cost hardening steps:

A hardened multi-stage Dockerfile for a Go service, combining both:

# --- build stage: has the full toolchain, never shipped ---
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/app

# --- final stage: distroless, no shell, no package manager ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot          # UID 65532, already non-root in this base image
ENTRYPOINT ["/app"]

And the equivalent hardening on a Debian/Alpine-based image where you cannot go fully distroless (e.g., you need a shell or dynamic libraries):

FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app \
    && apk add --no-cache dumb-init \
    && rm -rf /var/cache/apk/*
WORKDIR /app
COPY --chown=app:app package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=app:app . .
USER app                       # never run as root
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

Other build-time habits worth keeping:

Image scanning

Image scanning inspects an image’s layers against databases of known vulnerabilities (CVEs) in OS packages and language dependencies, plus misconfigurations (bad Dockerfile practices, hardcoded secrets, exposed keys).

What a scanner actually checks:

Tools at a glance:

ToolTypeNotes
Trivy (Aqua Security)OSS, all-in-oneScans images, filesystems, git repos, IaC, and Kubernetes manifests; single static binary; very fast; the most common default in CI today
Grype (Anchore)OSS, image/SBOM scannerPairs with Syft (SBOM generation); good for scanning an SBOM you already generated rather than re-scanning the image
ClairOSS, server/API-basedRuns as a service that registries (Harbor, Quay) query to scan pushed images; better fit for registry-integrated scanning than ad hoc CLI use
Docker ScoutCommercial (free tier)Built into docker scan / Docker Desktop; policy evaluation against Docker Hub metadata
Snyk ContainerCommercialStrong dependency-to-fix guidance, integrates with SCA findings from the same vendor

Scan-in-CI pattern — fail the build on high/critical findings, but do not block on informational ones:

# .github/workflows/image-scan.yml
name: Build and scan image
on: [push, pull_request]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: '1'          # fail the pipeline on high/critical CVEs
          ignore-unfixed: true    # don't fail on CVEs with no available fix yet

      - name: Upload results to code scanning
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

Two practical notes: (1) scan early (on every PR, against the built image, not just at deploy time) so a vulnerable base image is caught before merge, and (2) also scan periodically in the registry — an image built clean today can become vulnerable tomorrow when a new CVE is disclosed against a package already baked into it, with no new commit to trigger a PR scan.

Container runtime security

Scanning covers what’s in the image; runtime controls limit what a running container can do, which matters even for a clean image (zero-days exist) and is essential defense-in-depth against a scanner miss.

Docker Compose / docker run equivalent view:

docker run \
  --read-only \
  --tmpfs /tmp \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --security-opt no-new-privileges:true \
  --security-opt seccomp=default.json \
  --memory=256m --cpus=0.5 \
  --user 1000:1000 \
  myapp:latest

The equivalent, and more commonly used in production, Kubernetes securityContext:

apiVersion: v1
kind: Pod
metadata:
  name: hardened-app
spec:
  securityContext:                  # pod-level: applies to all containers
    runAsNonRoot: true
    runAsUser: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: myregistry.example.com/myapp@sha256:abcd1234...
      securityContext:               # container-level: overrides/adds to pod-level
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
          add: ["NET_BIND_SERVICE"]   # only if binding <1024, otherwise drop entirely
      resources:
        requests: { cpu: "100m", memory: "128Mi" }
        limits:   { cpu: "500m", memory: "256Mi" }
      volumeMounts:
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: tmp
      emptyDir: {}

Key Concepts

The Kubernetes attack surface

Kubernetes is a distributed system with several components an attacker can target, each with a different blast radius if compromised:

ComponentRoleWhy it’s a target
API serverFront door to the whole cluster; every kubectl command and controller action goes through itThe single most valuable target — anyone who can talk to it with sufficient RBAC permissions can create, read, or destroy almost anything in the cluster
kubeletNode agent that runs Pods, exposes an API on each node (used for logs, exec, port-forward)An exposed or under-authenticated kubelet API lets an attacker run arbitrary commands on that node directly, bypassing the API server entirely
etcdStores all cluster state, including Secrets (base64-encoded, not encrypted, unless encryption-at-rest is configured)Direct etcd access is equivalent to full cluster compromise — anyone with etcd read access can read every Secret in the cluster in plaintext
Container runtime / CRI socketExecutes containers on the nodeAccess to the runtime socket (e.g., /var/run/docker.sock mounted into a Pod) is a well-known container escape/privilege escalation path
Admission webhooksIntercept and mutate/validate API requestsA misconfigured or unavailable webhook can either fail open (bypassing the policy it should enforce) or fail closed (denial of service for the whole cluster)

Locking these down is the substance of ../kubernetes/en/08-security.md (authentication, RBAC, admission control in depth); this note summarizes the two controls a DevSecOps engineer will configure and audit most often — RBAC and NetworkPolicy — plus the supply-chain-facing ones.

RBAC for cluster access

RBAC answers “what may this identity do.” The core rule of thumb: least privilege, namespaced by default, no wildcards. A minimal, auditable example — a CI/CD deployer restricted to one namespace, one verb set, one set of resources:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: staging
  name: ci-deployer
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "patch", "update"]     # no "delete", no "create" of new kinds
  - apiGroups: [""]
    resources: ["pods", "pods/log"]
    verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  namespace: staging
  name: ci-deployer-binding
subjects:
  - kind: ServiceAccount
    name: ci-bot
    namespace: staging
roleRef:
  kind: Role
  name: ci-deployer
  apiGroup: rbac.authorization.k8s.io

Audit checklist for RBAC specifically: no ClusterRoleBinding to cluster-admin for anything other than a small break-glass admin group; no bindings to the default ServiceAccount; no wildcard ("*") in resources or verbs outside genuine platform-admin roles; automountServiceAccountToken: false on Pods that never call the API. Tools like kubectl-who-can and rbac-lookup help find over-broad bindings at scale.

Network policies for pod-to-pod segmentation

By default, Kubernetes networking is flat and default-allow: any Pod can reach any other Pod in any namespace on any port, provided the CNI plugin doesn’t enforce otherwise. A NetworkPolicy is how you make this default-deny and explicitly allow only the traffic a service actually needs — the network equivalent of RBAC for east-west traffic.

# 1. Default-deny all ingress and egress in the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: prod
spec:
  podSelector: {}          # applies to every Pod in the namespace
  policyTypes: ["Ingress", "Egress"]
---
# 2. Explicitly allow the API Pods to receive traffic only from the frontend,
#    and to reach only the database on its port + DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-frontend-to-api
  namespace: prod
spec:
  podSelector:
    matchLabels: { app: api }
  policyTypes: ["Ingress", "Egress"]
  ingress:
    - from:
        - podSelector: { matchLabels: { app: frontend } }
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - podSelector: { matchLabels: { app: database } }
      ports:
        - protocol: TCP
          port: 5432
    - to:                                    # allow DNS resolution
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53

Important caveat: NetworkPolicy requires a CNI plugin that implements it (Calico, Cilium, Weave; not the basic kubenet/bridge networking some managed clusters default to) — apply one and verify enforcement, don’t assume it works.

Pod Security Standards and admission control

Kubernetes replaced the deprecated PodSecurityPolicy with Pod Security Standards (PSS), enforced via the built-in Pod Security Admission (PSA) controller (or, for richer policy, OPA/Gatekeeper/Kyverno). PSS defines three levels:

LevelBehavior
PrivilegedUnrestricted — effectively no policy; only for trusted, cluster-critical workloads
BaselineBlocks known privilege escalations: no privileged containers, no host namespaces (hostNetwork, hostPID, hostIPC), no host path mounts of sensitive paths, restricted capabilities
RestrictedHeavily hardened: additionally requires runAsNonRoot, blocks privilege escalation, requires seccomp RuntimeDefault or stricter, drops ALL capabilities by default

Enforced per namespace via labels — no CRDs, no webhooks required for the baseline built-in enforcement:

apiVersion: v1
kind: Namespace
metadata:
  name: prod
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/audit: restricted     # log violations without blocking, useful during migration
    pod-security.kubernetes.io/warn: restricted       # warn client-side (kubectl) without blocking

For anything beyond what PSS covers (e.g., “images must come from myregistry.example.com”, “no :latest tags”, “every Deployment must have resource limits”), use a policy engine as a validating admission webhookOPA/Gatekeeper (Rego-based constraints/constraint templates) or Kyverno (native Kubernetes YAML policies, generally considered easier to author). These sit in the admission chain after RBAC authorization and before the object is persisted to etcd, so they can reject non-compliant objects outright.

Secrets management in Kubernetes

A native Secret object is base64-encoded, not encrypted, by default — anyone who can read the Secret object via the API (or read etcd directly) can trivially decode it. Practical implications and mitigations:

Image pull policies and trusted registries

Supply chain: signed images and policy enforcement

This is a narrower, container-focused slice of the broader CI/CD and supply chain security practices covered in ./12-cicd-and-supply-chain-security.md.

CIS Benchmarks for Docker and Kubernetes

The CIS (Center for Internet Security) Benchmarks are consensus-based, vendor-neutral configuration hardening guides. There is a CIS Docker Benchmark and a CIS Kubernetes Benchmark (plus cloud-specific variants for EKS, GKE, AKS, since managed control planes differ from self-managed ones). Each benchmark is a numbered checklist of recommendations (“1.1.1 Ensure the container host has been hardened,” “5.2.5 Minimize the admission of containers with allowPrivilegeEscalation”), each tagged with a scoring status (Scored/Not Scored) and remediation steps.

In practice, you don’t read the PDF and check boxes by hand — you run an automated benchmark tool against a live cluster or host:

# run kube-bench as a Job against a node, using the node's config
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl logs job/kube-bench

CIS Benchmarks are also the basis for many compliance mappings (SOC 2, PCI-DSS, FedRAMP controls often cite CIS as the technical control baseline), so passing them is frequently both a security improvement and an audit requirement.

Best Practices

Hardening checklist — a practical audit list spanning build, ship, and run:

AreaPracticeWhy
Image buildUse minimal/distroless base images, pin by digestReduces attack surface and eliminates tag-mutation risk
Image buildMulti-stage builds; never ship build tools/sourceCompilers and shells are unnecessary attack surface in prod
Image buildNo secrets in ARG/COPY; use BuildKit secret mountsBaked-in secrets persist in layer history even after deletion
Image buildRun as non-root (USER), avoid sudo/setuid binariesLimits blast radius of a compromised process
ScanningScan every image in CI; fail on High/CriticalCatches known CVEs before merge/deploy
ScanningRe-scan images already in the registry on a scheduleNew CVEs are disclosed after an image is built and passes clean
RuntimereadOnlyRootFilesystem: true unless writes are requiredBlocks runtime tampering / payload drops
Runtimecapabilities: drop: [ALL], add back only what’s proven neededMinimizes root-equivalent kernel operations available
RuntimeseccompProfile: RuntimeDefault (or stricter) on every workloadBlocks dangerous syscalls at near-zero cost
RuntimeSet CPU/memory requests and limits on every containerPrevents resource-exhaustion DoS from one workload
ClusterEnforce Pod Security Standards (restricted in prod namespaces)Bakes in the above runtime controls cluster-wide, not per-manifest
ClusterDefault-deny NetworkPolicy per namespace, allow explicitlyStops lateral movement between compromised and adjacent workloads
ClusterLeast-privilege RBAC; no wildcards; audit bindings regularlyLimits what a compromised identity/ServiceAccount can do
ClusterautomountServiceAccountToken: false where the API isn’t calledRemoves a credential an attacker doesn’t need to steal
SecretsEncrypt etcd at rest; prefer external secret stores + sync operatorBase64 Secrets alone are not confidentiality
Supply chainSign images (cosign); enforce signature verification at admissionEnsures only artifacts you built actually run
Supply chainAllow-list registries; pin production images by digestPrevents pulling from untrusted sources or a repointed tag
ComplianceRun kube-bench / docker-bench-security regularly, not onceConfiguration drifts; benchmarks catch regressions

This note deliberately overlaps with, rather than duplicates, three related pages: read ../kubernetes/en/08-security.md for the full Kubernetes authentication/authorization/admission model, ./09-security-testing-tools.md for how image/IaC scanning fits into the broader SAST/DAST/SCA toolchain, ./11-cloud-security.md for how managed Kubernetes (EKS/GKE/AKS) shifts some of this responsibility to the cloud provider, and ./12-cicd-and-supply-chain-security.md for signing, provenance, and pipeline hardening beyond the image itself.

References