Chuyển tới nội dung chính

15.4 — 3. Docker Compose

Tóm tắt

Compose biến "cài 5 thứ rồi cấu hình chúng nói chuyện với nhau" thành docker compose up. Hai điều quyết định nó chạy đúng hay không. depends_on không chờ dịch vụ sẵn sàng — nó chỉ chờ container khởi động; SQL Server mất 30 giây để sẵn sàng nhận kết nối, nên API khởi động ngay và chết vì không kết nối được. Sửa bằng condition: service_healthy cộng healthcheck thật. Thứ hai: đừng publish cổng của database ra host — trong mạng Compose, các container gọi nhau bằng tên dịch vụ, và ports: "1433:1433" mở database ra toàn mạng của bạn. Và nhớ: Compose là công cụ một máy; nhiều máy thì cần Swarm, Kubernetes, hoặc dịch vụ quản lý.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Viết compose.yaml cho stack nhiều dịch vụ.
  • Dùng healthcheck để thứ tự khởi động đúng.
  • Cấu hình mạng và cổng an toàn.
  • Dùng override file cho dev và production.
  • Biết giới hạn của Compose.

Nội dung bài học​

15.4.1 — depends_on không đủ​

# SAI — API khởi động khi SQL Server CHƯA sẵn sàng
services:
api:
depends_on:
- sqlserver

depends_on chỉ đảm bảo container sqlserver được khởi động trước — không đảm bảo SQL Server đã sẵn sàng nhận kết nối. SQL Server mất 20–40 giây để khởi tạo; API khởi động sau 2 giây và chết vì không kết nối được.

# DUNG — cho den khi HEALTHY
services:
api:
depends_on:
sqlserver:
condition: service_healthy
redis:
condition: service_healthy

sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q 'SELECT 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s

Hai chi tiết: $$ để Compose không thay biến mà để shell trong container xử lý; và start_period: 30s cho SQL Server thời gian khởi tạo mà thất bại không tính.

Ngay cả vậy, ứng dụng vẫn nên tự chịu lỗi. Database có thể restart bất cứ lúc nào sau khi API đã lên, nên retry kết nối là bắt buộc (bài 14.10):

options.UseSqlServer(conn, sql => sql.EnableRetryOnFailure(
maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null));

15.4.2 — Mạng và cổng​

services:
api:
ports:
- "8080:8080" # CHI API mo ra host
environment:
- ConnectionStrings__Default=Server=sqlserver;Database=Crm;...
- ConnectionStrings__Redis=redis:6379

sqlserver:
# KHÔNG có "ports" — chỉ truy cập được từ trong mạng Compose

redis:
# KHÔNG có "ports"

Compose tạo một mạng mặc định, và các container gọi nhau bằng tên dịch vụ — sqlserver, redis. Không cần biết IP.

ports chỉ cần khi bạn muốn truy cập từ host. Publish cổng SQL Server nghĩa là:

  • Database nghe trên mọi giao diện mạng của host.
  • Với máy có IP công khai, đó là database mở ra Internet.
  • Tường lửa của host là thứ duy nhất chặn — và nó thường được cấu hình sai.

Cần truy cập từ máy dev thì giới hạn vào localhost:

ports:
- "127.0.0.1:1433:1433" # CHỈ localhost, không ra ngoài

Đây là khác biệt quan trọng mà nhiều người không biết: "1433:1433" bind vào 0.0.0.0.

Tách mạng khi muốn chặt hơn:

services:
api:
networks: [frontend, backend]
sqlserver:
networks: [backend] # KHÔNG tiếp xúc frontend

networks:
frontend:
backend:
internal: true # không có truy cập Internet

internal: true chặn cả truy cập ra ngoài — hữu ích cho database không cần gọi Internet.

15.4.3 — Cấu hình và biến môi trường​

services:
api:
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__Default=Server=sqlserver;Database=Crm;User Id=sa;Password=${MSSQL_SA_PASSWORD};TrustServerCertificate=True
- ConnectionStrings__Redis=redis:6379
env_file:
- .env
# .env — KHÔNG commit
MSSQL_SA_PASSWORD=Str0ng!Passw0rd
REDIS_PASSWORD=An0ther!Str0ng
JWT_SECRET=...
# .gitignore
.env

Nhớ hai dấu gạch dưới cho khoá phân cấp (bài 8.7).

.env không phải giải pháp secret thật — nó chỉ giữ giá trị ra khỏi file YAML đã commit. Với production, dùng Docker secret hoặc secret của orchestrator:

services:
api:
secrets:
- db_password
environment:
- ConnectionStrings__Default=Server=sqlserver;...;Password_File=/run/secrets/db_password

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

Docker secret được mount thành file trong /run/secrets/, không nằm trong biến môi trường — nên nó không lộ qua docker inspect hay /proc/<pid>/environ.

Nhưng .NET không tự đọc file secret; bạn phải tự nạp:

var passwordFile = Environment.GetEnvironmentVariable("DB_PASSWORD_FILE");
if (passwordFile is not null && File.Exists(passwordFile))
connectionString = connectionString.Replace("{PASSWORD}", File.ReadAllText(passwordFile).Trim());

15.4.4 — Override file​

compose.yaml                  # chung cho mọi môi trường
compose.override.yaml # TỰ ĐỘNG áp khi chạy local
compose.prod.yaml # áp tường minh cho production
# compose.yaml — định nghĩa cơ bản
services:
api:
image: myrepo/crm-api:${TAG:-latest}
restart: unless-stopped
# compose.override.yaml — chỉ cho dev, TỰ ĐỘNG được đọc
services:
api:
build: . # build tu ma nguon
environment:
- ASPNETCORE_ENVIRONMENT=Development
ports:
- "8080:8080"
sqlserver:
ports:
- "127.0.0.1:1433:1433" # để kết nối bằng SSMS
# compose.prod.yaml — áp tường minh
services:
api:
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
docker compose up -d                                        # dev (tự động đọc override)
docker compose -f compose.yaml -f compose.prod.yaml up -d # production

compose.override.yaml được đọc tự động khi bạn chạy docker compose up không có -f. Đó là lý do nó hợp cho cấu hình dev — và cũng là lý do phải khai -f tường minh ở production, nếu không bạn vô tình áp cấu hình dev.

logging với max-size là cấu hình hay bị quên: không có nó, file log Docker lớn vô hạn và có thể làm đầy đĩa host. Đây là nguyên nhân phổ biến của "server hết dung lượng" sau vài tháng.

15.4.5 — Compose ở production​

Compose dùng được ở production, nhưng chỉ trong một phạm vi hẹp:

HợpKhông hợp
Một máy chủNhiều máy
Chấp nhận downtime khi deployCần zero-downtime
Ít hơn ~10 dịch vụChục dịch vụ trở lên
Không cần tự scaleCần scale theo tải

Deploy với Compose có downtime:

docker compose pull
docker compose up -d # container CU dung, container MOI len

Giữa hai bước đó có vài giây API không phục vụ. Với nhiều ứng dụng nội bộ, đó là chấp nhận được.

Muốn giảm downtime, thêm reverse proxy và chạy hai bản:

services:
api:
deploy:
replicas: 2

Nhưng deploy.replicas chỉ có tác dụng với Swarm mode; với docker compose up thường, bạn phải dùng --scale:

docker compose up -d --scale api=2

Và khi đó không được publish cổng cố định — hai container không thể cùng bind cổng 8080. Cần một reverse proxy (Traefik, Nginx, Caddy) làm cân bằng tải phía trước.

Ba lệnh vận hành cần biết:

docker compose logs -f api --tail 100
docker compose ps # trang thai, ca healthcheck
docker compose down # dung, GIU volume
docker compose down -v # dừng, XOÁ volume — CẨN THẬN

docker compose down -v xoá mọi dữ liệu trong volume. Chạy nhầm lệnh này trên máy chủ có database là mất toàn bộ dữ liệu.

15.4.6 — Rà lại code của bạn​

Danh sách rà soát Docker Compose

  • •depends_on dùng condition service_healthy, không chỉ liệt kê tên.
  • •Mọi dịch vụ phụ thuộc đều có healthcheck thật với start_period.
  • •Ứng dụng vẫn có retry kết nối database dù đã có healthcheck.
  • •Database và cache KHÔNG publish cổng ra host.
  • •Cổng cần cho dev được bind vào 127.0.0.1, không phải 0.0.0.0.
  • •File .env nằm trong .gitignore.
  • •Production dùng Docker secret hoặc secret của orchestrator, không dùng .env.
  • •Có cấu hình logging với max-size để log không làm đầy đĩa.
  • •Production chạy với -f tường minh, không dựa vào override tự động.
  • •Container production có giới hạn bộ nhớ và CPU.
  • •Biết rõ docker compose down -v sẽ xoá dữ liệu.

Bài tập áp dụng​

Bài 1 — depends_on không chờ dịch vụ sẵn sàng​

Dựng stack API cộng SQL Server chỉ với depends_on dạng danh sách. Chạy docker compose up và xác nhận API chết vì không kết nối được. Thêm healthcheck và kiểm chứng.

Tiêu chí hoàn thành: bạn phân biệt được "container đã khởi động" và "dịch vụ đã sẵn sàng", và nêu được vì sao healthcheck cũng chưa phải giải pháp đầy đủ.

Gợi ý và lời giải — Bài 1

Gợi ý. SQL Server mất bao lâu từ lúc tiến trình khởi động tới lúc nhận được kết nối đầu tiên?

Lời giải — bản có lỗi:

services:
api:
build: .
depends_on:
- sqlserver # chỉ chờ container KHỞI ĐỘNG
environment:
ConnectionStrings__Default: "Server=sqlserver;Database=Crm;User Id=sa;Password=${SA_PASSWORD};TrustServerCertificate=true"

sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: ${SA_PASSWORD}
docker compose up
sqlserver_1  | SQL Server is starting up...
api_1 | Microsoft.Data.SqlClient.SqlException (0x80131904):
api_1 | A network-related or instance-specific error occurred while
api_1 | establishing a connection to SQL Server.
api_1 | Unhandled exception. Application startup exception.
api_1 exited with code 134

API khởi động sau chưa đầy một giây; SQL Server cần khoảng 20–40 giây để sẵn sàng.

Phân biệt hai trạng thái:

Container đã khởi động:  tiến trình PID 1 đang chạy
-> Docker biết điều này ngay lập tức

Dịch vụ đã sẵn sàng: tiến trình đã nghe cổng, đã nạp xong dữ liệu,
đã phục hồi log, và trả lời được truy vấn
-> chỉ DỊCH VỤ mới biết, Docker không có cách nào biết

depends_on dạng danh sách chỉ bảo đảm thứ tự khởi động, không bảo đảm gì về tình trạng sẵn sàng. Và đó là giới hạn có lý do: Docker không biết "sẵn sàng" nghĩa là gì với một image tuỳ ý. Với SQL Server đó là nhận được truy vấn; với RabbitMQ là các exchange đã được tạo; với một ứng dụng là migration đã chạy xong.

Thời gian khởi động điển hình:

Dịch vụTừ khởi động tới sẵn sàng
Redisdưới 1 giây
PostgreSQL2–5 giây
RabbitMQ10–20 giây
SQL Server20–40 giây
Elasticsearch30–60 giây

Bản sửa — healthcheck cộng condition:

services:
api:
build: .
depends_on:
sqlserver:
condition: service_healthy # chờ healthcheck PASS

sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: ${SA_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q 'SELECT 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
sqlserver_1  | SQL Server is now ready for client connections.
api_1 | Now listening on: http://[::]:8080

Bốn tham số và ý nghĩa:

Tham sốNghĩa
intervalBao lâu kiểm tra một lần
timeoutMột lần kiểm tra chờ tối đa bao lâu
retriesThất bại liên tiếp bao nhiêu lần thì coi là unhealthy
start_periodKhoảng đầu mà thất bại không bị tính vào retries

start_period là tham số hay bị bỏ sót nhất. Không có nó, SQL Server sẽ bị đánh dấu unhealthy sau 10 lần thất bại trong 100 giây đầu — trước cả khi nó kịp sẵn sàng.

Vì sao healthcheck cũng chưa phải giải pháp đầy đủ — bốn lý do, và đây là phần chính của bài:

1. Healthcheck chỉ chạy lúc khởi động stack. Nếu SQL Server restart sau khi API đã chạy, Compose không khởi động lại API:

t=0    stack lên, API chờ SQL Server healthy rồi khởi động — OK
t=1h SQL Server restart vì OOM
t=1h API vẫn chạy, nhưng mọi truy vấn thất bại
-> depends_on không giúp gì ở đây

2. Nó không tồn tại ngoài Compose. Kubernetes không có depends_on. Pod khởi động theo thứ tự tuỳ ý, và một pod có thể khởi động lại bất cứ lúc nào.

3. Nó làm chậm khởi động. Chờ SQL Server healthy nghĩa là API mất 40 giây để lên, kể cả khi API có thể khởi động trước và kết nối sau.

4. Nó không xử lý được mất kết nối tạm thời — mạng chập chờn, failover, scale.

Giải pháp thật: ứng dụng phải chịu được việc dịch vụ phụ thuộc chưa sẵn sàng. Đây là yêu cầu kiến trúc, không phải cấu hình:

// 1. Retry cho lỗi kết nối thoáng qua — hoạt động ở MỌI môi trường
builder.Services.AddDbContext<CrmDbContext>(o =>
o.UseSqlServer(conn, sql => sql.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null)));
// 2. Đừng chạm database lúc khởi động — để nó lên rồi kết nối khi có request đầu tiên
// Nếu buộc phải chạy migration lúc khởi động, hãy retry tường minh:
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () => await db.Database.MigrateAsync(ct));
// 3. Health check báo Unhealthy khi database chết -> nền tảng không gửi traffic
builder.Services.AddHealthChecks()
.AddDbContextCheck<CrmDbContext>("database", tags: ["ready"]);

Ba thứ này cùng nhau giải quyết vấn đề ở mọi môi trường, và chúng khiến depends_on trở thành một tiện ích cho dev chứ không phải một phụ thuộc.

Trên Kubernetes, tương đương gần nhất là init container:

initContainers:
- name: cho-database
image: busybox:1.36
command: ['sh', '-c', 'until nc -z sqlserver 1433; do echo chờ; sleep 2; done']

Nhưng nó có cùng giới hạn số 1: chỉ chạy lúc pod khởi động. Nếu database chết sau đó, init container không chạy lại.

Kết luận đáng mang theo:

depends_on và init container làm cho việc khởi động dễ chịu hơn. Chúng không thay thế được việc ứng dụng phải chịu được lỗi.

Cách kiểm chứng dứt khoát: dừng database khi ứng dụng đang chạy, rồi bật lại. Nếu ứng dụng tự hồi phục mà không cần restart, bạn đã làm đúng. Nếu không, depends_on chỉ đang che giấu vấn đề cho tới lần sự cố đầu tiên.


Bài 2 — Cổng mở ra internet​

Chạy docker compose có "1433:1433" trên máy có IP công khai, rồi từ máy khác thử telnet <ip> 1433. Đổi sang 127.0.0.1:1433:1433 và kiểm chứng lại.

Tiêu chí hoàn thành: bạn giải thích được vì sao tường lửa không chặn được cổng Docker, và biết cách kiểm tra cổng nào đang mở ra ngoài.

Gợi ý và lời giải — Bài 2

Gợi ý. Docker và ufw cùng ghi vào iptables. Quy tắc của ai được đánh giá trước?

Lời giải:

services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
ports:
- "1433:1433" # nghe trên MỌI giao diện
# Từ một máy khác
telnet <ip-cong-khai> 1433
Trying <ip-cong-khai>...
Connected to <ip-cong-khai>.
Escape character is '^]'.

Database của bạn đang mở ra internet. Và điều gây sốc nhất: nó mở kể cả khi tường lửa đang bật và chặn cổng 1433.

sudo ufw status
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
80,443/tcp ALLOW Anywhere

Cổng 1433 không có trong danh sách cho phép, nhưng vẫn kết nối được.

Vì sao tường lửa không chặn được cổng Docker. Vì Docker và ufw cùng ghi vào iptables, nhưng ở các chuỗi khác nhau, và chuỗi của Docker được đánh giá trước:

Gói tin đến
│
├─> bảng nat, chuỗi PREROUTING
│ └─> DOCKER <- Docker chèn DNAT ở đây, chuyển hướng vào container
│
├─> bảng filter, chuỗi FORWARD
│ ├─> DOCKER-USER <- chuỗi dành cho NGƯỜI DÙNG, đánh giá TRƯỚC
│ └─> DOCKER <- quy tắc của Docker
│
└─> bảng filter, chuỗi INPUT
└─> ufw <- quy tắc của ufw ở ĐÂY

Gói tin tới container đi qua chuỗi FORWARD, không đi qua INPUT. Mà ufw chỉ quản lý INPUT. Nên quy tắc của ufw không bao giờ được đánh giá cho traffic tới container.

Đây không phải bug mà là hệ quả của việc Docker tự quản lý phần mạng của nó. Nhưng nó là một trong những nguồn lộ dịch vụ phổ biến nhất trên VPS.

Ba cách sửa:

1. Bind vào localhost — cách đơn giản và đúng nhất:

services:
sqlserver:
ports:
- "127.0.0.1:1433:1433" # chỉ máy chủ truy cập được
telnet <ip-cong-khai> 1433
telnet: Unable to connect to remote host: Connection refused

Từ máy chủ vẫn dùng được, để chạy migration hoặc mở SSMS qua SSH tunnel:

ssh -L 1433:localhost:1433 user@server

2. Đừng map cổng nếu không cần. API và database nói chuyện qua mạng nội bộ của Compose, không cần ports gì cả:

services:
api:
ports:
- "127.0.0.1:8080:8080" # chỉ API cần ra ngoài, và cũng qua Nginx
environment:
ConnectionStrings__Default: "Server=sqlserver;Database=Crm;..."
# ^^^^^^^^^ tên dịch vụ, không phải localhost

sqlserver:
# KHÔNG có ports — chỉ container khác trong cùng mạng truy cập được

Đây là cấu hình nên dùng mặc định: chỉ những dịch vụ thật sự cần tiếp xúc bên ngoài mới có ports, và chúng bind vào 127.0.0.1 để Nginx làm cổng vào duy nhất.

3. Dùng chuỗi DOCKER-USER nếu buộc phải mở ra ngoài:

sudo iptables -I DOCKER-USER -i eth0 ! -s 10.0.0.0/8 -p tcp --dport 1433 -j DROP

DOCKER-USER là chuỗi duy nhất mà Docker không ghi đè khi khởi động lại. Quy tắc đặt ở các chuỗi khác sẽ biến mất sau systemctl restart docker.

Kiểm tra cổng nào đang mở ra ngoài:

# 1. Cổng đang nghe, và trên giao diện nào
sudo ss -tlnp | grep -v "127.0.0.1\|::1"
LISTEN  0  4096  0.0.0.0:1433   0.0.0.0:*  users:(("docker-proxy",pid=2841))
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=2903))
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=891))

0.0.0.0 nghĩa là mọi giao diện, tức mở ra ngoài. 127.0.0.1 là chỉ máy chủ.

# 2. Cổng Docker đang map
docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES        PORTS
sqlserver 0.0.0.0:1433->1433/tcp, :::1433->1433/tcp <- MỞ RA NGOÀI
api 127.0.0.1:8080->8080/tcp <- chỉ localhost
# 3. Xác minh từ BÊN NGOÀI — đây là kiểm tra duy nhất đáng tin
nmap -Pn -p 22,80,443,1433,5432,6379,27017 <ip-cong-khai>
PORT      STATE    SERVICE
22/tcp open ssh
80/tcp open http
443/tcp open https
1433/tcp filtered ms-sql-s
5432/tcp closed postgresql
6379/tcp closed redis

Chạy nmap từ một máy khác là cách duy nhất biết chắc, vì nó kiểm tra kết quả thật chứ không kiểm tra cấu hình.

Danh sách cổng không bao giờ được mở ra internet:

1433  SQL Server        3306  MySQL          5432  PostgreSQL
6379 Redis 27017 MongoDB 9200 Elasticsearch
5672 RabbitMQ 15672 RabbitMQ UI 2375 Docker API (!!)

Cổng 2375 đáng sợ nhất: Docker API không xác thực ở cổng đó, và ai kết nối được thì có toàn quyền root trên máy chủ.

Redis và MongoDB đứng thứ hai: cả hai theo mặc định không yêu cầu mật khẩu. Các bot quét internet tìm chúng liên tục, và thời gian từ lúc mở cổng tới lúc bị khai thác thường tính bằng phút, không phải ngày.

Một quy tắc đơn giản để không bao giờ mắc lỗi này:

Mọi mục ports trong docker-compose.yml phải có tiền tố 127.0.0.1:, trừ cổng của reverse proxy.

Và một kiểm tra tự động:

#!/usr/bin/env bash
# Cảnh báo nếu có cổng nào bind ra mọi giao diện, ngoài 80 và 443
if grep -E '^\s+- "[0-9]+:' docker-compose*.yml | grep -vE '"(80|443):'; then
echo "Có cổng bind ra mọi giao diện — hãy thêm tiền tố 127.0.0.1:"
exit 1
fi

Bài 3 — Log làm đầy đĩa​

Chạy container ghi log liên tục không có max-size, và theo dõi kích thước file trong /var/lib/docker/containers/.

Tiêu chí hoàn thành: bạn giải thích được vì sao đầy đĩa gây hậu quả nặng hơn dự đoán, và biết cấu hình mặc định ở đúng chỗ.

Gợi ý và lời giải — Bài 3

Gợi ý. Khi đĩa đầy, những thành phần nào khác cần ghi vào đĩa?

Lời giải:

docker run -d --name noisy crm-api
watch -n 10 'du -sh /var/lib/docker/containers/*/*-json.log'
Sau 1 giờ:    218M
Sau 6 giờ: 1.3G
Sau 24 giờ: 5.2G
Sau 7 ngày: 36G
df -h /
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1 40G 39G 0.1G 100% /

Docker không giới hạn log mặc định với driver json-file. File lớn cho tới khi hết đĩa.

Vì sao đầy đĩa gây hậu quả nặng hơn dự đoán — vì nó phá mọi thứ cùng một lúc:

Đĩa đầy
├─> Database không ghi được transaction log -> mọi lệnh ghi thất bại
├─> Ứng dụng không ghi được file tạm -> upload, xuất file lỗi
├─> systemd không ghi được journal -> MẤT LUÔN bằng chứng chẩn đoán
├─> Docker không pull được image mới -> không deploy được
├─> Docker không khởi động container mới -> không restart được để cứu
├─> SSH có thể không đăng nhập được -> không vào máy được
└─> apt, yum không chạy được -> không cài công cụ để sửa

Hai dòng cuối là chỗ biến một sự cố thành một sự cố kéo dài: bạn mất khả năng vào máy để sửa. Và dòng thứ ba nghĩa là khi vào được, log giải thích chuyện gì đã xảy ra cũng không còn.

Thêm một đặc điểm: đầy đĩa không suy giảm dần. Ở 95% mọi thứ bình thường; ở 100% mọi thứ hỏng cùng lúc. Không có giai đoạn cảnh báo tự nhiên.

Cấu hình giới hạn — ba chỗ, theo thứ tự ưu tiên:

1. Mặc định toàn máy chủ — chỗ quan trọng nhất, vì nó áp cho cả container bạn quên cấu hình:

// /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3",
"compress": "true"
}
}
sudo systemctl restart docker

Mỗi container tối đa 30 MB log, xoay vòng qua 3 file.

Quan trọng: cấu hình này chỉ áp cho container được tạo SAU khi restart Docker. Container đang chạy giữ nguyên cấu hình cũ:

docker inspect <container> --format '{{.HostConfig.LogConfig}}'
{json-file map[]}          <- không có giới hạn, phải tạo lại container

2. Trong Compose, cho từng dịch vụ:

x-logging: &mac-dinh-log
driver: json-file
options:
max-size: "10m"
max-file: "3"

services:
api:
build: .
logging: *mac-dinh-log

sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
logging: *mac-dinh-log

Dùng anchor YAML (&/*) để không lặp lại cấu hình — và để không ai thêm dịch vụ mới mà quên.

3. Chuyển hẳn sang driver khác — đúng đắn nhất cho production:

{
"log-driver": "local",
"log-opts": { "max-size": "10m", "max-file": "3" }
}

Driver local mặc định có giới hạn, nén tốt hơn, và ghi nhanh hơn json-file. Đổi lại, docker logs vẫn hoạt động nhưng công cụ đọc trực tiếp file JSON thì không.

Hoặc gửi log ra hệ thống tập trung:

logging:
driver: loki
options:
loki-url: "http://loki:3100/loki/api/v1/push"

Dọn dẹp khi đã lỡ:

# Xem container nào ngốn nhiều nhất
sudo du -h /var/lib/docker/containers/*/*-json.log | sort -rh | head -10

# Cắt file log mà không cần dừng container
sudo truncate -s 0 /var/lib/docker/containers/<id>/<id>-json.log

Dùng truncate, không dùng rm: xoá file trong khi Docker đang giữ file descriptor sẽ khiến dung lượng không được giải phóng cho tới khi container dừng — và bạn vẫn đầy đĩa, nhưng giờ không thấy file nào để xoá.

Và dọn cả những thứ khác của Docker:

docker system df
TYPE            TOTAL   ACTIVE   SIZE     RECLAIMABLE
Images 47 3 28.4GB 24.1GB (84%)
Containers 12 3 1.2GB 980MB (81%)
Local Volumes 18 2 8.7GB 7.9GB (90%)
Build Cache 284 0 12.3GB 12.3GB (100%)
docker system prune -af --volumes    # CẨN THẬN: --volumes xoá cả volume không dùng

Chạy docker system df trước và đọc kỹ — --volumes có thể xoá dữ liệu database của một container đã dừng.

An toàn hơn, chạy định kỳ:

# Chỉ xoá image và build cache cũ hơn 7 ngày, không đụng volume
docker image prune -af --filter "until=168h"
docker builder prune -af --filter "until=168h"

Giám sát — và đặt ngưỡng đủ sớm để còn kịp hành động:

# /etc/cron.d/kiem-tra-dia
0 * * * * root df -h / | awk 'NR==2 && $5+0 > 80 {print "Đĩa đầy " $5}' | \
xargs -r -I{} curl -s -X POST -d "text={}" $SLACK_WEBHOOK

Ngưỡng 80% cho bạn khoảng thời gian thật để xử lý. Ngưỡng 95% thường là quá muộn, vì tốc độ ghi log không tuyến tính — một sự cố làm tăng lượng log, và 95% tới 100% có thể chỉ mất vài phút.

Và giảm lượng log sinh ra ngay từ đầu — thường là cách tốt nhất:

{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
}

Dòng cuối đáng chú ý: ở mức Information, EF Core ghi mọi câu SQL ra log. Trên một API xử lý 100 request mỗi giây, đó là vài GB mỗi ngày cho thứ gần như không ai đọc.

Tự kiểm tra​

Câu hỏi thường gặp

depends_on đảm bảo điều gì?

Chỉ đảm bảo container phụ thuộc được khởi động trước, không đảm bảo dịch vụ bên trong đã sẵn sàng. SQL Server mất hàng chục giây để nhận kết nối, nên API khởi động ngay sẽ chết. Phải dùng condition service_healthy cộng một healthcheck thật.

Có healthcheck rồi thì ứng dụng còn cần retry kết nối không?

Có. Healthcheck chỉ lo thứ tự lúc khởi động, còn database có thể restart bất cứ lúc nào sau khi API đã lên. EnableRetryOnFailure của EF Core xử lý trường hợp đó.

Vì sao không publish cổng database ra host?

Trong mạng Compose, các container gọi nhau bằng tên dịch vụ nên không cần publish. Publish khiến database nghe trên mọi giao diện mạng của host, và với máy có IP công khai thì đó là database mở ra Internet. Cần truy cập từ máy dev thì bind vào 127.0.0.1.

Docker secret khác biến môi trường thế nào?

Secret được mount thành file trong run/secrets thay vì nằm trong biến môi trường, nên nó không lộ qua docker inspect hay proc environ. Nhưng .NET không tự đọc file secret, bạn phải tự nạp trong code.

compose.override.yaml hoạt động ra sao?

Nó được đọc tự động khi chạy docker compose up không có tham số -f, nên hợp cho cấu hình dev. Chính vì vậy ở production phải khai -f tường minh, nếu không bạn vô tình áp cấu hình dev lên môi trường thật.

Khi nào Compose không còn đủ?

Khi cần chạy trên nhiều máy, cần zero-downtime, có hàng chục dịch vụ, hoặc cần tự scale theo tải. Compose là công cụ một máy; deploy bằng nó luôn có vài giây downtime trừ khi thêm reverse proxy và chạy nhiều bản.

Kết luận​

Ba điều đáng nhớ nhất:

  1. depends_on không chờ dịch vụ sẵn sàng. Cần service_healthy cộng healthcheck thật.
  2. Đừng publish cổng database. Container gọi nhau bằng tên dịch vụ.
  3. Cấu hình max-size cho log, nếu không nó sẽ làm đầy đĩa host.

Tham khảo​

Điều hướng​