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
--privilegedtrừ 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 (
USERtrong Dockerfile) - [ ] Không có secret trong env var hoặc image layers
- [ ]
--read-onlynếu app không cần ghi disk - [ ]
no-new-privileges:truetrong 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.

