Bảo mật Docker Container: Best Practices Cho Developer

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

Chạy thử ngay trong 5 phút

Trước khi đi vào lý thuyết, mình muốn bạn thấy ngay sự khác biệt. Chạy lệnh này để kiểm tra container đang chạy với quyền gì:

# Kiểm tra user đang chạy trong container
docker exec -it ten_container whoami

# Nếu ra 'root' → container của bạn đang có vấn đề bảo mật

Nếu kết quả là root, bài này dành cho bạn. Chạy với quyền root trong container giống như để cửa trước nhà không khóa — bình thường thì không sao, nhưng nếu có lỗ hổng trong app, kẻ tấn công có thể leo thang lên host dễ dàng.

Fix nhanh nhất: thêm USER vào Dockerfile:

FROM node:20-alpine

# Tạo user riêng, không dùng root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# Chuyển sang user appuser trước khi chạy app
USER appuser

CMD ["node", "server.js"]

Build lại, chạy lại, whoami sẽ trả về appuser. Xong. Bạn vừa đóng một trong những lỗ hổng phổ biến nhất mà nhiều team vẫn đang bỏ qua.

Tại sao isolation của Docker chưa đủ?

Docker dùng Linux namespaces và cgroups để tách biệt container — nghe có vẻ chắc chắn, nhưng đây là software isolation, không phải hardware. Container vẫn dùng chung kernel với host. CVE-2019-5736 (lỗ hổng runc) và CVE-2020-15257 (Containerd) là hai ví dụ điển hình: khai thác runtime vulnerability có thể cho phép attacker thoát khỏi container và chiếm quyền host — gọi là container escape.

Mình đã gặp trường hợp này khi review code một dự án cũ: container chạy root, mount /var/run/docker.sock vào trong, và có RCE vulnerability trong app. Ba thứ đó cộng lại là thảm họa — attacker kiểm soát được toàn bộ Docker daemon trên host, không cần khai thác thêm gì.

Nguyên tắc least privilege

Mỗi container chỉ nên có đúng những quyền nó cần. Không hơn. Checklist cơ bản:

  • Non-root user: Luôn dùng user thường trong container
  • Read-only filesystem: Mount filesystem read-only nếu app không cần ghi
  • Drop capabilities: Bỏ bớt Linux capabilities không cần thiết
  • No privileged mode: Không bao giờ dùng --privileged trừ khi thực sự bắt buộc

Read-only filesystem

Theo kinh nghiệm thực tế, khoảng 80% web app Node.js/Python không cần ghi ra disk lúc runtime — chỉ cần thêm tmpfs cho /tmp là đủ. Nếu attacker có RCE nhưng không ghi được file, họ rất khó thiết lập persistence:

# Chạy container với filesystem read-only
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /var/run \
  -p 3000:3000 \
  your-app:latest

# Hoặc trong docker-compose.yml
services:
  app:
    image: your-app:latest
    read_only: true
    tmpfs:
      - /tmp
      - /var/run

--tmpfs tạo vùng nhớ tạm trong RAM cho những thư mục app cần ghi (temp files, PID files).

Nâng cao: Network isolation và Secret management

Network isolation với custom bridge network

Mặc định, tất cả container trong cùng Docker host đều nhìn thấy nhau qua bridge network. Nginx thấy database, app thấy redis, monitoring thấy tất cả — không có ranh giới nào. Khi bạn có 5–10 service, đây là bề mặt tấn công khá lớn.

Khi mình chuyển từ docker-compose v1 sang v2 cho toàn bộ stack, mình tận dụng luôn để tách network rõ ràng hơn — quá trình khá smooth và đây là pattern mình đang dùng:

version: '3.8'

services:
  nginx:
    image: nginx:alpine
    networks:
      - frontend
    ports:
      - "80:80"

  app:
    image: your-app:latest
    networks:
      - frontend   # Nhận request từ nginx
      - backend    # Nói chuyện với database

  db:
    image: postgres:15
    networks:
      - backend    # CHỈ app mới access được, nginx không thấy
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

networks:
  frontend:
  backend:
    internal: true  # Không có internet access từ backend network

secrets:
  db_password:
    file: ./secrets/db_password.txt

Với config này, nginx không thể kết nối thẳng vào database — phải qua app layer. Nếu nginx bị compromise, attacker không nhảy thẳng vào DB được.

Secret management — Đừng để secret trong environment variable

Env var tiện nhưng có vấn đề nghiêm trọng: bất kỳ process con nào cũng inherit và đọc được. Chưa kể docker inspect ten_container sẽ dump ra toàn bộ env vars — ai có quyền chạy lệnh đó trên host là đọc được DB password. Crash dumps và application logs cũng là nguồn leak phổ biến không kém.

Docker Secrets (cho Swarm) hoặc mount file là cách tốt hơn:

# Tạo secret từ file
echo "super_secret_password" | docker secret create db_password -

# Secret được mount tại /run/secrets/db_password trong container
# App đọc từ file thay vì env var
# Trong app Python: đọc secret từ file
import os

def get_db_password():
    secret_path = '/run/secrets/db_password'
    if os.path.exists(secret_path):
        with open(secret_path, 'r') as f:
            return f.read().strip()
    # Fallback cho dev environment
    return os.environ.get('DB_PASSWORD', '')

Drop Linux capabilities

Container mặc định có một số Linux capabilities không cần thiết cho web app thông thường. Drop hết, chỉ giữ lại cái cần:

services:
  app:
    image: your-app:latest
    cap_drop:
      - ALL          # Drop toàn bộ capabilities
    cap_add:
      - NET_BIND_SERVICE  # Chỉ thêm lại cái cần (ví dụ: bind port < 1024)
    security_opt:
      - no-new-privileges:true  # Ngăn process leo thang privilege

Tips thực tế khi làm dự án thật

Scan image trước khi deploy

Base image luôn có vulnerabilities — câu hỏi chỉ là có bao nhiêu CRITICAL. Dùng Trivy để biết trước khi push lên production:

# Cài Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin

# Scan image
trivy image your-app:latest

# Chỉ quan tâm HIGH và CRITICAL
trivy image --severity HIGH,CRITICAL your-app:latest

# Dùng trong CI/CD, fail pipeline nếu có CRITICAL
trivy image --exit-code 1 --severity CRITICAL your-app:latest

Dùng distroless hoặc Alpine để giảm attack surface

Image càng nhỏ, tool càng ít, attacker càng khó làm gì sau khi vào được container. Node.js 20 Alpine khoảng 180MB; distroless còn nhỏ hơn và không có shell — không có bash, không có curl, không có gì để pivot:

# Multi-stage build: build trên image đầy đủ, chạy trên distroless
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# Stage cuối: không có shell, không có package manager
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app .
USER nonroot
CMD ["server.js"]

Không mount Docker socket vào container

Lỗi này xuất hiện khá nhiều trong tutorial cũ — mount /var/run/docker.sock để container gọi Docker API. Nghe tiện, nhưng trên production thì đừng. Ai access được socket là kiểm soát được toàn bộ Docker daemon, tương đương root trên host.

Thay thế: dùng Portainer Agent (có bài riêng trên blog này) hoặc Docker API với TLS authentication.

Giới hạn resource

Không trực tiếp là security nhưng giúp giảm thiệt hại khi bị tấn công DoS:

services:
  app:
    image: your-app:latest
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          memory: 256M

Checklist nhanh trước khi deploy

  • [ ] Container chạy non-root user (USER trong Dockerfile)
  • [ ] Không có secret trong env var hoặc image layers
  • [ ] --read-only nếu app không cần ghi disk
  • [ ] no-new-privileges:true trong security_opt
  • [ ] Network isolation: database không expose ra ngoài
  • [ ] Scan image với Trivy, không có CRITICAL vulnerability
  • [ ] Base image dùng alpine hoặc distroless
  • [ ] Không mount /var/run/docker.sock
  • [ ] Resource limits đã cấu hình

Bảo mật container không phải setup một lần là xong. Mình scan lại image mỗi 2 tuần và bắt buộc scan trước mỗi release — thực tế phần lớn CRITICAL vulnerability đến từ base image, không phải code của mình. Mỗi lần update dependency hoặc thêm service mới, nhớ chạy lại checklist. Base image có security update? Rebuild ngay, đừng để qua sprint sau.

Share: