Hình dung sự khác nhau giữa căn nhà bạn đang ở và căn nhà nghỉ bạn khoá cửa để đó mấy tháng. Ở căn nhà đang ở, mất điện thì bạn tự bật lại cầu dao — lúc nào cũng có người ở console. Căn nhà nghỉ thì phải cài đặt để mọi thứ tự hồi phục sau khi cúp điện: tủ lạnh tự chạy lại, lò sưởi tự lên — trừ đúng những thứ bạn cố ý khoá lại trước khi đi (van nước đóng cho mùa đông) thì phải ở yên trạng thái tắt. Chạy Compose trên máy chủ thật đúng là căn nhà nghỉ đó: không ai ngồi canh lúc 3 giờ sáng điện chập chờn. Compose chạy được trên máy chủ, chỉ là nó không tự nói cho bạn biết mấy điều. Tôi dựng năm dịch vụ với năm cấu hình restart: khác nhau rồi khởi động lại daemon — mô phỏng đúng một lần máy chủ reboot.

restart: Dừng tay trước? Sau khi daemon lên lại
(không khai) không Nằm im
on-failure không Nằm im
always không Tự lên
unless-stopped không Tự lên
unless-stopped có Nằm im (đúng ý)

Hai dòng đầu là hai cách mất dịch vụ sau mỗi lần reboot.

Không khai gì là mặc định, và mặc định nghĩa là container không bao giờ tự lên lại. Bạn up -d một lần, mọi thứ chạy hàng tháng, rồi nhà cung cấp bảo trì hạ tầng và sáng hôm sau website biến mất.

on-failure cũng không cứu bạn, và đây là chỗ dễ hiểu nhầm nhất. Nghe tên thì tưởng "hỏng thì dậy", nhưng khi máy chủ tắt, Docker gửi SIGTERM và container thoát với mã 0 — đó là thoát bình thường, không phải lỗi. on-failure để yên, đúng như đặc tả của nó.

unless-stopped nhớ đúng ý định của bạn. Dịch vụ bị bạn dừng tay để bảo trì thì sau reboot vẫn nằm im; dịch vụ đang chạy thì lên lại. Đó là hành vi đúng cho gần như mọi thứ trên máy chủ, và là lý do phần 28 khuyên dùng nó thay cho always.

Nên dòng đầu tiên bạn thêm vào mọi service trên máy chủ:

services:
  web:
    image: vi-du:1.0
    restart: unless-stopped

Cập nhật gây gián đoạn bao lâu

Câu hỏi thứ hai của máy chủ thật: gõ docker compose up -d để đưa phiên bản mới lên thì người dùng mất dịch vụ bao lâu? Tôi cho một bộ dò gọi 50 lần mỗi giây trong lúc cập nhật.

Lệnh Kết quả
up -d khi cấu hình không đổi 447 lần gọi, 0 lần hỏng
up -d sau khi đổi cấu hình 518 lần gọi, 5 lần hỏng — gián đoạn 0,59 s
compose restart 401 lần gọi, 4 lần hỏng — gián đoạn 0,56 s

Dòng đầu xác nhận điều phần 49 đã đo: up -d khi không có gì đổi là hoàn toàn vô hại, Compose so config-hash rồi bỏ qua. Chạy nó trong cron cũng được.

Hai dòng sau cho cùng một con số vì cùng một việc: container cũ dừng, container mới lên. Compose không có cập nhật cuốn chiếu — không có giai đoạn hai container cùng chạy.

Và 0,6 giây đó là của nginx. Ứng dụng Java khởi động 30 giây thì gián đoạn là 30 giây. Muốn không gián đoạn thì cần thứ khác: một reverse proxy phía trước cùng thao tác đổi cổng, hoặc Swarm, hoặc Kubernetes. Đừng mong Compose làm việc đó.

Những gì nên khác so với máy phát triển

Ghim phiên bản image, đừng dùng latest.

image: nginx:1.27.3-alpine     # khong phai nginx:latest

latest nghĩa là hai máy chủ up -d cách nhau một tuần chạy hai phiên bản khác nhau, và bạn không có cách nào biết. Phần 4 đã đo được rằng chỉ có digest mới thật sự bất biến.

Đừng build: trên máy chủ. Build ở CI, đẩy lên registry, máy chủ chỉ pull. Build trên máy chủ nghĩa là máy chủ cần mã nguồn, cần công cụ build, và cần đủ đĩa cho build cache — thứ mà phần 45 đo được là vùng phình nhanh nhất.

Giới hạn nhật ký. Mặc định json-file không xoay vòng, và phần 46 đo được 53,5 MB log vô hình với mọi lệnh đếm dung lượng:

logging:
  driver: local
  options: { max-size: "10m", max-file: "3" }

Bind cổng vào loopback rồi để nginx ra ngoài. ports: "127.0.0.1:8080:8080". Lý do đầy đủ ở phần 35: luật ufw không chạm được vào cổng Docker publish.

Đặt giới hạn tài nguyên. Một container rò bộ nhớ sẽ kéo cả máy chủ xuống nếu không có trần:

deploy:
  resources:
    limits: { memory: 512M, cpus: "1.0" }

Đưa Compose vào systemd

Để cả hệ thống lên cùng máy chủ mà không phụ thuộc vào việc ai đó up -d bằng tay:

[Unit]
Description=He thong cua toi
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/hethong
ExecStart=/usr/bin/docker compose up -d --wait
ExecStop=/usr/bin/docker compose down

[Install]
WantedBy=multi-user.target

Cờ --wait đáng chú ý: nó khiến lệnh chờ tới khi mọi dịch vụ có healthcheck báo khoẻ, đúng cơ chế mà phần 50 đã đo. Không có nó, systemd coi như xong ngay khi container được tạo.

Nói vậy nhưng nếu mọi service đều có restart: unless-stopped thì Docker tự lo phần khởi động rồi — unit systemd chủ yếu có ích để systemctl stop dừng gọn cả hệ thống.

Muốn biết hệ thống của mình có sống sót qua reboot không mà không cần reboot thật, soi chính sách restart của từng service:

docker compose ps --format '{{.Service}}' | while read s; do
  p=$(docker inspect "$(docker compose ps -q "$s")" \
       --format '{{.HostConfig.RestartPolicy.Name}}')
  printf '%-20s %s\n' "$s" "${p:-KHONG KHAI}"
done

Dòng nào ra no, KHONG KHAI hoặc on-failure là dòng sẽ không tự lên lại sau lần reboot tới. Sửa thành unless-stopped rồi docker compose up -d — mất đúng 0,6 giây gián đoạn, một lần.

Mẫu số chung

Bài học đầu tiên, chính là khoảng cách giữa hai căn nhà: "chạy được" và "chạy một mình trên sản xuất" là hai mức khác nhau, và cái khoảng cách vô hình ấy toàn là những thứ chỉ lộ ra khi không có ai ở console. Tự lên lại sau reboot, xoay vòng log, trần tài nguyên, ghim phiên bản, thứ tự khởi động — chẳng cái nào bạn cần tới khi đang ngồi canh máy, và tất cả đều là thứ giết bạn lúc 3 giờ sáng. Cùng khoảng cách "làm được lúc có người vs làm được lúc bỏ mặc" ở khắp nơi: một script chạy ngon trong terminal nhưng hỏng trong cron (không TTY, không biến môi trường), mã chạy đúng dưới debugger mà đổ khi chạy tự do, một bản demo so với dịch vụ 24/7. Nguyên tắc: đánh giá mức sẵn sàng sản xuất bằng các chế độ vắng mặt và hỏng hóc, không bằng đường-đi-thuận — hỏi "nếu mất điện, nếu hết đĩa, nếu không ai đụng tới suốt sáu tháng, cái này có tự đứng dậy không?".

Điều thứ hai, về chọn công cụ: biết cái trần cấu trúc của công cụ, và đừng đòi nó làm thứ nó không được thiết kế để làm. Compose không có cập nhật cuốn chiếu — mô hình của nó là dừng-cũ-rồi- lên-mới, nên gián đoạn đúng bằng thời gian khởi động ứng dụng (0,6 giây với nginx, nhưng 30 giây với một app Java khởi động chậm), và không cấu hình nào trong Compose xoá được điều đó. Cái cần cho "không gián đoạn" là một lớp khác: reverse proxy đổi cổng, Swarm, hay Kubernetes. Cùng chuyện "nhận ra yêu cầu vượt trần công cụ" ở khắp nơi: SQLite tuyệt cho một tiến trình nhưng không thay được CSDL cụm, cron không phải một trình lập lịch có trạng thái, một load balancer không phải service mesh. Nguyên tắc: khớp công cụ với yêu cầu, và khi một yêu cầu (zero-downtime, HA thật) chạm trần của công cụ hiện tại thì đó là tín hiệu đổi công cụ, không phải vặn thêm cấu hình — ép một công cụ làm việc ngoài thiết kế của nó thường tốn công hơn và giòn hơn là cầm đúng công cụ ngay từ đầu.

Phần sau đo docker compose watch, cơ chế đồng bộ mã mà Compose mới đưa vào.