Mặc định, Docker container chạy với một tập capabilities Linux nhất định — ít hơn root thật, nhưng vẫn đủ để làm nhiều thứ nguy hiểm nếu app bên trong bị compromise. Vấn đề là nhiều Dockerfile và Compose file ngoài kia vô tư dùng --privileged, CAP_SYS_ADMIN, hoặc chạy process dưới quyền root — thường là copy-paste từ tutorial mà không ai đọc lại. Nếu app của bạn có lỗ hổng RCE (ví dụ một dependency bị supply chain attack), attacker từ trong container có thể mount host filesystem và escape ra ngoài.

Container không phải VM. Namespace isolation là layer thứ nhất, nhưng nó không phải security boundary tuyệt đối. Bài này đi qua từng lớp bảo vệ: capabilities, seccomp, AppArmor, read-only filesystem, user namespace remap, đến rootless mode — để bạn bảo vệ container từ trong ra ngoài, layer by layer.


  flowchart TD
    subgraph Defense["Các lớp phòng thủ (từ trong ra ngoài)"]
        direction TB
        L1["🔒 no-new-privileges<br/>Ngăn process gain thêm quyền"]
        L2["🔒 Capability dropping<br/>Bỏ hết, chỉ add cái cần"]
        L3["🔒 Seccomp profile<br/>Whitelist syscall được gọi"]
        L4["🔒 AppArmor / SELinux<br/>MAC — giới hạn file, network, capability"]
        L5["🔒 Read-only filesystem<br/>Ngăn ghi file, modify binary"]
        L6["🔒 User namespace remap<br/>UID 0 → UID 100000+ trên host"]
        L7["🔒 Rootless mode<br/>Docker daemon không cần root"]
    end
    Attacker["Attacker"] -->|"RCE exploit"| Container["Container"]
    Container --> L1 --> L2 --> L3 --> L4 --> L5 --> L6 --> L7
    L7 --> Host["Host OS"]

1. Capabilities — chia root thành từng miếng nhỏ

Linux kernel truyền thống có một “superuser” — UID 0 — với toàn quyền. Nhưng từ Linux 2.2 (1999), kernel đã có capabilities: chia quyền root thành hơn 40 capability nhỏ, mỗi cái kiểm soát một hành động cụ thể.

Mặc định, Docker cấp cho container 14 capabilities:

CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FSETID, CAP_FOWNER, CAP_MKNOD,
CAP_NET_RAW, CAP_SETGID, CAP_SETUID, CAP_SETFCAP, CAP_SETPCAP,
CAP_NET_BIND_SERVICE, CAP_SYS_CHROOT, CAP_KILL, CAP_AUDIT_WRITE

14 này đã ít hơn nhiều so với 40+ của root thật, nhưng vẫn còn quá nhiều cho hầu hết ứng dụng. Một web server Node.js chỉ cần CAP_NET_BIND_SERVICE (bind port < 1024) và CAP_KILL (kill process trong container). Một database PostgreSQL cần thêm CAP_IPC_LOCK (lock shared memory). Không web server nào cần CAP_SYS_ADMIN, CAP_NET_RAW, hay CAP_MKNOD.

Drop ALL, chỉ add cái cần

Best practice của Docker security là: bỏ hết capabilities mặc định, chỉ thêm đúng cái cần.

# Drop tất cả capabilities, chỉ giữ NET_BIND_SERVICE
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx

# Trong Docker Compose
services:
  web:
    image: nginx:alpine
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
Làm sao biết app cần capability nào? Chạy container với --cap-drop=ALL, app sẽ crash hoặc báo lỗi “Operation not permitted”. Tra log để tìm syscall bị từ chối. Hoặc dùng strace -c (hoặc perf trace) từ bên ngoài container để xem app gọi syscall gì. Cách thực tế nhất: chạy test suite với --cap-drop=ALL, thêm từng capability đến khi test xanh.

Một số capability nguy hiểm không bao giờ nên cấp cho production container:

CapabilityTại sao nguy hiểm
CAP_SYS_ADMIN“New root” — cho phép mount, umount, pivot_root, namespace tùy ý
CAP_SYS_PTRACEptrace() process khác, đọc memory, inject code
CAP_SYS_MODULELoad/unload kernel module
CAP_NET_ADMINThay đổi network config, iptables, routing
CAP_SYS_RAWIOTruy cập I/O ports trực tiếp
CAP_SYS_BOOTReboot hệ thống
--privileged tắt tất cả security. Khi chạy docker run --privileged, Docker tắt AppArmor, bỏ hết seccomp restrictions, cấp tất cả capabilities, và cho phép truy cập tất cả devices trên host. Không có lý do chính đáng nào để dùng --privileged trong production — ngay cả khi cần mount filesystem hay quản lý network, luôn có --cap-add, --device, hoặc --security-opt cụ thể hơn.
Socket security cũng là vấn đề capabilities. Mount Docker socket (/var/run/docker.sock) vào container cho phép container gọi Docker API — đồng nghĩa với root trên host. Đây là lý do không bao giờ bind mount socket vào container, dù có capability gì đi nữa. Xem thêm Phần 1: Kiến trúc Docker & CLI context để hiểu socket security model.

2. Seccomp — whitelist syscall

Seccomp (Secure Computing Mode) là một Linux kernel feature cho phép filter syscall mà process được gọi. Docker đi kèm với default seccomp profile block khoảng 44 trong số hơn 300 syscall — các syscall nguy hiểm như:

  • mount, umount2, pivot_root — thao tác mount namespace
  • ptrace — debug/trace process khác
  • reboot, kexec_load — reboot kernel
  • add_key, request_key — thao tác kernel keyring
  • bpf — load BPF program vào kernel

Kiểm tra seccomp profile hiện tại

# Xem seccomp profile container đang dùng
docker inspect <container> | jq '.[0].HostConfig.SecurityOpt'

# Kiểm tra syscall bị block
# Chạy container với strace (cần CAP_SYS_PTRACE):
docker run --rm -it --cap-add=SYS_PTRACE alpine strace -c sleep 1

Custom seccomp profile

Khi default profile quá rộng (hoặc app cần syscall đặc biệt), bạn có thể tạo custom profile:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": [
        "accept",
        "bind",
        "close",
        "connect",
        "epoll_ctl",
        "epoll_wait",
        "exit_group",
        "fstat",
        "futex",
        "getpid",
        "getrandom",
        "lseek",
        "mmap",
        "mprotect",
        "munmap",
        "nanosleep",
        "openat",
        "read",
        "recvfrom",
        "sendto",
        "write",
        "writev"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}
docker run --security-opt seccomp=custom-profile.json myapp
Seccomp không phải magic bullet. Seccomp chỉ filter syscall — nó không filter argument của syscall. Ví dụ: nếu write được allow, attacker vẫn có thể write() vào bất kỳ fd đang mở nào. Seccomp nên là layer thứ hai (defense in depth), không phải layer duy nhất. Luôn kết hợp với capabilities, read-only FS, và user namespace.

3. AppArmor / SELinux — Mandatory Access Control

Capabilities và seccomp giới hạn cái gì process làm được. AppArmor (Ubuntu/Debian) và SELinux (RHEL/CentOS/Fedora) giới hạn ở đâulên cái gì process được truy cập. Đây là Mandatory Access Control (MAC) — lớp bảo vệ áp đặt từ kernel, process không thể tự thoát ra.

Docker tự động áp docker-default AppArmor profile khi host có AppArmor enabled (mặc định trên Ubuntu). Profile này giới hạn container truy cập:

  • Filesystem: chỉ đọc/ghi trong phạm vi container’s rootfs, /proc, /sys giới hạn
  • Network: giới hạn socket type, protocol
  • Capabilities: chặn các capability nguy hiểm dù có --cap-add
  • Mount: cấm mount filesystem mới
  • ptrace: cấm ptrace() process khác

Tạo custom AppArmor profile

# File: /etc/apparmor.d/containers/docker-nginx
#include <tunables/global>

profile docker-nginx flags=(attach_disconnected,mediate_deleted) {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  # Chỉ cho phép đọc trong /etc/nginx, /usr/share/nginx
  /etc/nginx/** r,
  /usr/share/nginx/** r,

  # Cho phép ghi log
  /var/log/nginx/*.log w,

  # Cấm mọi thứ khác
  deny /** w,
  deny /bin/** x,
  deny /sbin/** x,
}
# Load profile
sudo apparmor_parser -r -W /etc/apparmor.d/containers/docker-nginx

# Chạy container với profile
docker run --security-opt apparmor=docker-nginx nginx

Kiểm tra AppArmor status trên host:

sudo aa-status                   # Xem tất cả profile đang load
sudo aa-status | grep docker     # Xem Docker profiles
docker inspect <container> | jq '.[0].AppArmorProfile'  # Profile của container

Nếu output là "" (empty string) hoặc "unconfined", container đang chạy không có AppArmor.


4. Read-only filesystem — container không được ghi

Container lý tưởng cho production nên là immutable: không ghi gì sau khi start. Attacker sau khi exploit RCE thường cần ghi file để: tải payload xuống, sửa binary/system config, cài backdoor, hoặc tạo cron job.

--read-only làm cho root filesystem của container thành read-only — ngay cả root trong container cũng không ghi được:

docker run --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64M \
  --tmpfs /run:rw,noexec,nosuid,size=64M \
  --tmpfs /var/log:rw,noexec,nosuid,size=128M \
  nginx:alpine

Trong Docker Compose:

services:
  web:
    image: nginx:alpine
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64M
      - /run:rw,noexec,nosuid,size=64M
      - /var/log:rw,noexec,nosuid,size=128M

Một số lưu ý khi dùng --read-only:

  • Tmpfs cho /tmp/run: nhiều app cần /tmp để ghi temporary file, socket file. Mount chúng dưới dạng tmpfs với noexec,nosuid để chặn attacker tạo executable trong tmpfs.
  • Volume cho data: database, upload directory, cache cần ghi thì mount volume cụ thể — không cho ghi cả FS.
  • App config immutable: config qua environment variable hoặc config map mount read-only, không cần ghi config file runtime.
Một số app ghi temporary file vào thư mục lạ. Python Flask ghi session cookie vào /tmp, nhưng Django ghi vào app directory. PostgreSQL ghi vào data directory (phải gắn volume). Kiểm tra kỹ app của bạn ghi vào đâu trước khi bật --read-only — tốt nhất làm một integration test với --read-only để bắt lỗi ngay lúc build.

5. User namespace remap — UID 0 trong container không phải root trên host

Đây là một trong những tính năng bảo mật mạnh nhất của Docker, nhưng ít người biết đến. User namespace remap map UID/GID trong container sang UID/GID khác trên host.

Cơ chế: khi enable --userns-remap=default:

  • UID 0 (root) trong container → UID 100000 trên host (không có quyền gì)
  • UID 1 trong container → UID 100001 trên host
  • Cứ tiếp tục như vậy

Nếu attacker escape container với UID 0, trên host hắn chỉ là UID 100000 — không có quyền đọc file của user khác, không chạy được lệnh root.

Cấu hình

# 1. Thêm vào /etc/docker/daemon.json
{
  "userns-remap": "default"
}

# 2. Restart Docker
sudo systemctl restart docker

# 3. Kiểm tra
docker run --rm alpine id
# uid=0(root) gid=0(root) groups=0(root)
# Nhưng trên host, các file container tạo ra thuộc về UID 100000

Hạn chế

User namespace remap không tương thích với một số feature:

  • Không dùng được với --pid=host, --net=host
  • Không dùng --privileged
  • Bind mount từ host cần chmod để UID mapped có quyền
  • Một số storage driver không hỗ trợ (overlay2 thì được)
Tại sao user namespace remap ít được dùng. Historical: feature này gây conflict với volume permission (file tạo bởi container thuộc về UID mapped, mount ra host không đọc được). Workaround: dùng docker run -v /host/path:/container/path:Z (SELinux label) hoặc chmod 777 — nhưng cách này phá hỏng chính cái security boundary bạn vừa tạo. Cách tốt nhất là pre-create directory với đúng UID trên host, hoặc dùng named volume thay vì bind mount.

6. Rootless mode — Docker daemon không cần root

Rootless mode là bước tiến xa nhất: Docker daemon chạy dưới quyền user thường, không cần root, không cần sudo, không cần setuid binary. Toàn bộ Docker stack — daemon, containerd, runc — chạy trong user namespace của user gọi nó.

Thành phần thay thế:

Thành phần rootfulThay thế rootless
Networking (iptables, bridge)slirp4netns — userspace TCP/IP stack
Storage (overlay2 với kernel)fuse-overlayfs — FUSE-based overlay
cgroup managementcgroup v2 delegation (systemd user slice)

Cài đặt rootless Docker

# Ubuntu/Debian
sudo apt install docker-ce-rootless-extras uidmap

# Tạo rootless context
dockerd-rootless-setuptool.sh install

# Set environment variables (thêm vào ~/.bashrc)
export PATH=/usr/bin:$PATH
export DOCKER_HOST=unix:///run/user/1000/docker.sock

# Verify
docker run --rm hello-world
docker info | grep -i rootless
# rootless: true

Rootless Docker Compose

# Chạy Docker Compose trong rootless mode
docker compose up -d

# Hoặc dùng Docker context
docker context use rootless
docker compose up -d
Rootless mode và port binding. Với rootless Docker, container không dùng iptables để forward port. slirp4netns chỉ expose port lên localhost của user, không lên network interface của host. Nếu cần container listen trên port 80/443 public, rootless Docker cần reverse proxy (như Caddy, NGINX) chạy riêng làm frontend.
Podman đã rootless by default. Nếu bạn muốn rootless container mà không phải cấu hình Docker rootless, Podman chạy rootless ngay từ đầu — không cần daemon, không cần setuid binary. Xem Podman: khi nào nên dùng thay Docker để đánh giá trade-off khi chuyển từ Docker sang Podman.

7. no-new-privileges — khóa cửa sau

no-new-privileges là một flag nhỏ nhưng quan trọng. Khi set, kernel đảm bảo process của container và tất cả child process của nó không bao giờ gain thêm privileges — dù có setuid binary, file capability, hay exec() chương trình khác.

docker run --security-opt no-new-privileges myapp

Kịch bản nó ngăn chặn: attacker exploit RCE trong app, phát hiện trong container có sudo (được setuid root), chạy sudo su → có root trong container → exploit tiếp. Với no-new-privileges, setuid bit bị kernel ignore — sudo không hoạt động.

Trong Docker Compose:

services:
  web:
    image: myapp
    security_opt:
      - no-new-privileges:true
Luôn bật no-new-privileges cho production container. Nó không có tác dụng phụ đáng kể nào, không ảnh hưởng performance, và chặn một class tấn công phổ biến. Chỉ tắt khi container thực sự cần chạy setuid binary (rất hiếm, hầu như chỉ là tool debug như ping).

8. Security checklist cho production container

Trước khi deploy container lên production, mình chạy qua checklist 10 điểm sau. Mỗi điểm “fail” phải có lý do rõ ràng tại sao không fix được — không có “tại nó chạy được”.

✓ Checklist

[ ] 1. Không dùng --privileged
[ ] 2. cap_drop: ALL, chỉ cap_add cái cần
[ ] 3. security_opt: no-new-privileges:true
[ ] 4. read_only: true (với tmpfs cho /tmp, /run, /var/log nếu cần)
[ ] 5. Dockerfile có USER non-root (UID != 0)
[ ] 6. AppArmor hoặc SELinux profile không phải "unconfined"
[ ] 7. Không bind mount docker.sock vào container
[ ] 8. Không dùng --pid=host, --net=host, --ipc=host trừ khi bắt buộc
[ ] 9. Image được scan vulnerability (Trivy, Snyk, Docker Scout)
[ ] 10. Resource limits: --memory, --cpus, --pids-limit

Dockerfile mẫu cho production

# Production Dockerfile với đầy đủ security hardening
FROM node:20.11.1-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --production --ignore-scripts
COPY src/ ./src/

FROM node:20.11.1-alpine
# 1. Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# 2. Không copy dev dependencies
COPY --from=build --chown=appuser:appgroup /app /app
WORKDIR /app

# 3. HEALTHCHECK (xem Phần 8)
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

# 4. Không chạy dưới root
EXPOSE 3000
CMD ["node", "src/server.js"]

Chạy với đầy đủ security options:

docker run -d \
  --name prod-web \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64M \
  --tmpfs /run:rw,noexec,nosuid,size=64M \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --security-opt no-new-privileges:true \
  --security-opt apparmor=docker-default \
  --memory=512M \
  --cpus=1 \
  --pids-limit=100 \
  myapp:latest
Resource limits cũng là security. Không set memory limit, một container bị memory leak có thể OOM toàn bộ host. Không set --pids-limit, fork bomb trong container có thể cạn PID space của kernel. Không set CPU limit, một container mining crypto có thể chiếm 100% CPU. Security không chỉ là ngăn attacker — còn là ngăn bug của chính mình thành incident.

Tổng kết

Bảo mật container là một bài toán defense in depth — mỗi layer bảo vệ một attack vector khác nhau, và layer nào cũng có thể là layer cuối cùng cứu team bạn khỏi incident.

LayerNgăn chặn gìĐộ khó triển khaiImpact
no-new-privilegessetuid escalationThấp (1 flag)Trung bình
Capability droppingLạm dụng quyền rootThấp (cần test)Cao
Seccomp profileSyscall nguy hiểmTrung bình (custom profile)Cao
AppArmor/SELinuxTruy cập trái phép file/networkCao (custom profile)Rất cao
Read-only FSGhi file, modify binaryThấp (cần tmpfs config)Cao
User namespaceContainer escape root = unprivileged host UIDTrung bình (volume permission)Rất cao
Rootless modeDaemon compromise = user-level accessCao (migration effort)Rất cao

Không cần làm tất cả một lúc. Bắt đầu từ những thứ dễ nhất, impact cao nhất: drop capabilities, bật no-new-privileges, set read-only FS. Rồi từ từ tiến lên user namespace và rootless mode. Mỗi layer thêm vào là một lý do nữa để attacker bỏ cuộc.


Câu hỏi hay gặp

Q: Container mình cần ping, mà ping cần CAP_NET_RAW. Có nên cấp không?

ping dùng ICMP socket, cần CAP_NET_RAW. Nhưng trong production, container không nên cần ping — đó là tool debug. Thay vì cấp capability, dùng HTTP health check (curl hoặc wget) để kiểm tra connectivity. Nếu thực sự cần ICMP, dùng --cap-add=NET_RAW thay vì bỏ --cap-drop=ALL.

Q: latest tag có phải security risk không?

Có, nhưng không phải trực tiếp. latest tag làm mất reproducibility — build hôm nay khác build ngày mai. Nguy hiểm hơn: nếu attacker compromise một base image trên Docker Hub và push tag latest mới, container của bạn sẽ pull image độc hại mà không ai nhận ra. Luôn pin version exact như node:20.11.1-alpine, kết hợp với image digest pinning (node:20.11.1-alpine@sha256:...) cho critical service.

Q: Rootless mode có chậm hơn rootful không?

fuse-overlayfs chậm hơn overlay2 kernel-native khoảng 10-20% cho I/O intensive workload, và slirp4netns chậm hơn iptables-based bridge network khoảng 5-15%. Nhưng với web app thông thường, latency overhead này không đáng kể so với lợi ích bảo mật. Nếu bạn chạy database production với rootless mode, benchmark trước khi migrate.

Q: Làm sao kiểm tra container hiện tại đã đủ an toàn chưa?

Chạy Docker Bench Security — công cụ của CIS (Center for Internet Security) audit container configuration theo hơn 100 check:

docker run --rm --net host --pid host --userns host \
  --cap-add audit_control \
  -v /etc:/etc:ro \
  -v /usr/bin/docker:/usr/bin/docker:ro \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -v /usr/lib/systemd:/usr/lib/systemd:ro \
  -v /var/lib:/var/lib:ro \
  docker/docker-bench-security

Mỗi dòng [WARN] là một điểm cần fix. Không cần đạt 100% — nhưng nên biết mình fail ở đâu và tại sao.

Bài tiếp theo (Reference): Phần 15: Docker troubleshooting: 20 vấn đề thường gặp, catalogue diagnostic pattern cho các lỗi Docker phổ biến — từ “port already in use” đến “no space left on device” và “container restart loop”.