Phần 49/60 của sê-ri Docker và Docker Compose. Bốn tám phần trước bàn về image, layer, volume và mạng của một container đơn lẻ — phần 48 đo tốc độ ổ mạng, phần 47 đo ba cách chia sẻ dữ liệu giữa các container. Từ bài này, ta ghép nhiều container lại bằng Compose thay vì gõ tay nhiều dòng docker run.

Giới thiệu

docker run cho một container thì gõ tay được. Hai container cần nói chuyện với nhau, một cái phải chờ cái kia sẵn sàng, rồi dọn sạch cả hai khi xong việc — gõ tay bắt đầu phiền. Đó là lúc cần docker-compose.yml.

Bài này dựng file Compose đầu tiên với đúng hai dịch vụ: một api và một worker gọi sang nó. Dùng alpine:3.20 cho cả hai — không tải image nào khác — vì mục tiêu là đo cơ chế lên và xuống của Compose, không đo băng thông mạng khi kéo image. Đổi sang nginx/redis thì số đo vẫn đúng nguyên lý, chỉ cộng thêm thời gian pull.

Tệp docker-compose.yml đầu tiên

services:
  api:
    image: alpine:3.20
    command: sh -c "while true; do echo 'chao tu api' | nc -l -p 9000; done"
    healthcheck:
      test: ["CMD", "nc", "-z", "localhost", "9000"]
      interval: 1s
      timeout: 2s
      retries: 5

  worker:
    image: alpine:3.20
    depends_on:
      api:
        condition: service_healthy
    command: sh -c "while true; do nc api 9000; sleep 3; done"

api mở cổng 9000 bằng nc và trả về một dòng chữ mỗi lần có kết nối. worker gọi sang bằng đúng tên dịch vụ — nc api 9000 — không cần biết địa chỉ IP. Compose tự tạo một mạng bridge riêng cho project (docker-compose-tep-dau-tien_default) và tự đăng ký DNS nội bộ cho từng dịch vụ, đó là lý do worker gọi được api bằng tên chứ không phải bằng IP.

Chi tiết đáng chú ý nhất trong file này là condition: service_healthy. Không có nó, depends_on: [api] chỉ đảm bảo container api đã được tạo và Started — không đảm bảo tiến trình bên trong đã sẵn sàng nhận kết nối. Với nc khởi động gần như tức thì thì khác biệt này khó thấy, nhưng với một service thật (database khởi tạo schema, ứng dụng Java khởi động JVM) thì worker có thể chạy trước và bị từ chối kết nối ngay lượt gọi đầu. condition: service_healthy bắt Compose chờ đúng tín hiệu "healthy" từ healthcheck rồi mới start worker.

Chạy docker compose up -d --wait rồi xem log xác nhận dữ liệu chảy được qua mạng:

docker compose up -d --wait
docker compose logs worker --no-log-prefix
chao tu api
chao tu api

Đo thời gian lên

Môi trường đo: Docker Compose v5.4.0, Engine 29.7.2, Docker Desktop 4.87.0 trên macOS. Ảnh alpine:3.20 đã có sẵn trong máy — số đo dưới đây là "lên ấm" (image không cần pull), lặp lại ba lần bằng lệnh time:

time docker compose up -d --wait
Lần Thời gian
1 2,27s
2 2,34s
3 2,27s

--wait không chỉ tạo container rồi trả lại dấu nhắc ngay — nó đợi đến khi mọi service có healthcheck chuyển sang Healthy mới thoát. Không dùng --wait, lệnh up -d trả về gần như ngay lập tức nhưng worker có thể vẫn đang trong hàng chờ "Waiting" phía sau, và script gọi tiếp theo (ví dụ curl kiểm tra) có thể chạy trước khi hệ thống thật sự sẵn sàng.

Đo thời gian xuống — và bất ngờ đầu tiên

time docker compose down
Lần Thời gian
1 6,42s
2 6,38s
3 6,36s

Xuống chậm hơn lên gần gấp ba, dù việc xuống nghe có vẻ chỉ là "giết tiến trình rồi xoá container" — lẽ ra phải nhanh hơn dựng mới. Log cho thấy lý do đầu tiên:

Container worker Stopping
Container worker Stopped
Container worker Removing
Container worker Removed
Container api Stopping
Container api Stopped
...

Compose dừng tuần tự: worker trước, api sau — đúng thứ tự ngược của depends_on lúc khởi động. Đối chứng bằng hai service không có depends_on với nhau, down chỉ mất 3,37 giây — gần đúng thời gian dừng của một container, vì lúc đó Compose dừng cả hai song song, không có gì buộc phải tuần tự.

Vì sao mỗi container mất ~3,1 giây để dừng

Tách riêng từng container ra đo bằng docker stop:

time docker stop <container-id>

Ra đúng 3,1 giây mỗi lần, kể cả với container chỉ chạy sleep infinity — không có gì để dọn dẹp, không kết nối nào đang mở. Nguyên nhân: shsleep chạy làm PID 1 bên trong container, và tiến trình PID 1 trong một namespace Linux không tự động nhận xử lý mặc định cho tín hiệu trừ khi chính nó cài đặt handler. SIGTERM gửi tới không bị chặn về mặt kỹ thuật, nhưng cũng không có gì xử lý nó — tiến trình cứ chạy tiếp. docker stop phải đợi hết thời gian ân hạn rồi mới gửi SIGKILL.

Xác nhận đúng là đang đợi ân hạn chứ không phải lỗi khác, bằng cách rút ngắn nó:

time docker stop -t 1 <id>   # 1,12s
time docker stop -t 0 <id>   # 0,11s

Thời gian giảm đúng theo cờ -t. Nhưng có một điều đo ra mà chưa lý giải được: tài liệu Docker ghi thời gian ân hạn mặc định là 10 giây, còn ở đây đo được đúng 3,1 giây mỗi lần, lặp lại nhiều lần vẫn ra cùng một số. Không tìm thấy cấu hình nào trong daemon.json hay docker inspect giải thích được con số này trên máy đang chạy demo. Nói thẳng là chưa biết vì sao lệch khỏi 10 giây tài liệu ghi, không đoán bừa nguyên nhân.

Áp dụng vào file Compose ở trên: docker compose down -t 0 chỉ mất 0,39 giây thay vì 6,4 giây — hợp lý khi đang lặp code lúc dev (sửa file, down, up lại nhiều chục lần một ngày cộng dồn đáng kể), nhưng không nên dùng -t 0 khi container còn việc dở — ví dụ một service đang ghi file hoặc đang giữ transaction, SIGKILL ngay lập tức không cho nó có cơ hội dọn dẹp.

Lỗi thường gặp

Lỗi cú pháp YAML vì dấu hai chấm trong chuỗi lệnh

Bản nháp đầu của file này từng thử dựng phản hồi HTTP thủ công bằng printf, kiểu:

command: sh -c "printf 'Content-Length: 15\r\n\r\n...'"

Compose từ chối ngay với lỗi mapping values are not allowed in this context. Nguyên nhân: command: không có dấu ngoặc bao ngoài là một plain scalar của YAML, và YAML coi : (hai chấm rồi cách) ở bất cứ đâu trong dòng đó là dấu hiệu bắt đầu một mapping mới — kể cả khi nó nằm giữa một chuỗi văn bản. Cách sửa: bọc toàn bộ giá trị trong dấu nháy đơn hoặc nháy kép của YAML. Đây là lỗi rất dễ gặp khi lệnh command chứa header HTTP, URL có :, hay bất kỳ chuỗi nào có dấu hai chấm theo sau là khoảng trắng.

dependency failed to start: ... exited (127)

Gặp lỗi này khi command gọi một chương trình không có trong image — ví dụ gõ nhầm busybox httpd trong khi bản Alpine 3.20 dùng ở đây build busybox không kèm applet httpd. Kiểm tra applet nào có sẵn bằng docker run --rm alpine:3.20 busybox --list trước khi viết command dựa vào nó, thay vì đoán.

Thử ba mươi giây

Dán tệp docker-compose.yml ở trên vào một thư mục trống rồi so hai lệnh:

time docker compose up -d --wait && time docker compose down

So kết quả down với time docker compose down -t 0 chạy ngay sau lượt up kế tiếp — chênh lệch giữa hai số đó chính là cái giá của việc chờ ân hạn SIGTERM mà không tiến trình nào trong container xử lý.

Bài viết liên quan