Phần 54/60 của sê-ri Docker và Docker Compose. Phần trước đã đo phạm vi dự án Compose và cái gì còn sống sót sau
down. Bài này đi tiếp sang việc nhân bản một service —docker compose up --scale— và đo xem lưu lượng thật sự được chia như thế nào giữa các bản sao.
Giới thiệu
docker compose up --scale api=3 chạy ba container cho cùng một service chỉ trong một câu lệnh. Câu hỏi thực dụng hơn là: sau khi có ba container, ai đứng ra chia request cho chúng? Compose không tự dựng một load balancer nào cả — không có Envoy, không có mesh, không có gì chạy ở giữa. Cơ chế duy nhất đang hoạt động là DNS round robin trong embedded DNS server của Docker (127.0.0.11). Bài này đo trực tiếp cơ chế đó: chia được bao nhiêu, chia đều tới mức nào, và có tình huống nào nó hoàn toàn không chia gì cả.
Toàn bộ số đo dưới đây chạy trên Docker 29.7.2 / Compose v5.4.0, dùng một service Alpine tối giản trả về hostname của chính nó qua HTTP.
Nhân bản một service
Compose file chỉ cần một dòng để service có thể scale — không khai container_name cố định, vì tên cố định chỉ dùng được cho một container duy nhất:
services:
api:
build: .
expose:
- "8000"
docker compose up -d --build --scale api=3
Kết quả: ba container compose-scale-test-api-1, -2, -3, cùng nằm trên một network do Compose tạo, cùng trả lời cho một cái tên DNS duy nhất: api.
NAME PORTS
compose-scale-test-api-1 8000/tcp
compose-scale-test-api-2 8000/tcp
compose-scale-test-api-3 8000/tcp
Hostname không phải tên Compose đặt
Điều đầu tiên đáng chú ý — và đi ngược trực giác — là mỗi container không thấy HOSTNAME của mình là api-1 hay api-2. docker inspect cho thấy hostname mặc định là chính short ID của container:
container=/compose-scale-test-api-1 id=58e4e4773364 hostname=58e4e4773364
container=/compose-scale-test-api-2 id=d44d67468687 hostname=d44d67468687
container=/compose-scale-test-api-3 id=9d0b6c5c6378 hostname=9d0b6c5c6378
Compose đặt tên api-1, api-2, api-3 ở tầng Docker Engine (tên container, và cả DNS alias trong network) — nhưng biến môi trường HOSTNAME mà tiến trình bên trong container đọc được là short ID, không phải cái tên đó. Nếu code của bạn cần biết "mình là replica số mấy" để ghi log hay chọn shard, đọc hostname() bên trong tiến trình sẽ không cho câu trả lời hữu ích — phải tự truyền vào bằng biến môi trường, kiểu docker compose up --scale api=3 kết hợp với một entrypoint gán ID ngoài, hoặc tra qua Docker API từ bên ngoài.
Cổng host cố định: nổ ngay khi scale
Nếu service khai ports: "8080:8000" — một cổng host cố định — thì replica đầu tiên lên bình thường, nhưng cái thứ hai nổ ngay:
services:
api:
build: .
ports:
- "8080:8000"
Error response from daemon: failed to set up container networking:
driver failed programming external connectivity on endpoint compose-scale-test-api-2:
Bind for 0.0.0.0:8080 failed: port is already allocated
Dễ hiểu vì sao: ports: ánh xạ vào cổng của host, mà một cổng host chỉ có thể được một tiến trình lắng nghe. Ba replica cùng đòi 8080 là vô lý. Cách sửa đúng là dùng dải cổng, để Compose tự gán mỗi replica một cổng host riêng theo thứ tự:
services:
api:
build: .
ports:
- "8080-8082:8000"
NAME PORTS
compose-scale-test-api-1 0.0.0.0:8080->8000/tcp
compose-scale-test-api-2 0.0.0.0:8081->8000/tcp
compose-scale-test-api-3 0.0.0.0:8082->8000/tcp
Nhưng cách này chỉ hợp lý khi bạn có một reverse proxy hoặc load balancer bên ngoài biết cả ba cổng đó. Bên trong network của Compose thì không cần ports: chút nào — container khác gọi qua expose và tên DNS api là đủ, đó cũng là cách bài này đo tiếp ở phần dưới.
Đo DNS round robin
Docker chạy một DNS server nội bộ ở 127.0.0.11 cho mỗi container trong network do Compose tạo. Khi một container tra cứu tên api, DNS server này trả về địa chỉ IP của cả ba replica, nhưng đổi thứ tự mỗi lần được hỏi. Tra năm lần liên tiếp từ một container client riêng:
for i in 1 2 3 4 5; do
docker run --rm --network compose-scale-test_default curlimages/curl:8.10.1 \
sh -c "getent hosts api"
done
172.19.0.2 api
172.19.0.3 api
172.19.0.4 api
172.19.0.2 api
172.19.0.3 api
Thứ tự IP đứng đầu xoay vòng đều đặn: .2 → .3 → .4 → .2 → .3. Đây chính là round robin ở tầng DNS — không có gì phức tạp hơn thế, không theo dõi tải hiện tại của từng container, không biết container nào đang bận hơn.
Đo phân bố traffic thật
DNS xoay vòng không tự động có nghĩa là traffic HTTP cũng chia đều — còn phụ thuộc việc client có resolve DNS lại cho mỗi request hay không. Chạy 30 lần curl liên tiếp, mỗi lần là một tiến trình curl mới (nên mỗi lần tự resolve DNS lại từ đầu):
docker run --rm --network compose-scale-test_default curlimages/curl:8.10.1 sh -c '
for i in $(seq 1 30); do curl -s --max-time 2 http://api:8000/; echo; done
' | sort | uniq -c
11 2eaf10ee5d96
12 c1c4422ecfa7
7 cc2f78a0cdb3
30 request chia thành 11/12/7 cho ba replica — không tuyệt đối bằng nhau, nhưng đủ gần đều để dùng được trong thực tế cho traffic nội bộ, miễn client thật sự resolve DNS mới cho từng kết nối.
Khi client giữ kết nối: round robin biến mất
Đây là phần quan trọng nhất bài, vì nó đi ngược lời khuyên "cứ dùng DNS round robin của Docker là đủ cân bằng tải". Resolve một lần, lấy IP, rồi gửi thẳng sáu request tới đúng IP đó — không resolve lại:
docker run --rm --network compose-scale-test_default curlimages/curl:8.10.1 sh -c '
IP=$(getent hosts api | head -1 | awk "{print \$1}")
echo "Resolved once to: $IP"
for i in $(seq 1 6); do curl -s --max-time 2 http://$IP:8000/; echo; done
'
Resolved once to: 172.19.0.3
c1c4422ecfa7
c1c4422ecfa7
c1c4422ecfa7
c1c4422ecfa7
c1c4422ecfa7
c1c4422ecfa7
Cả sáu request đều rơi vào đúng một container. Không có gì bất ngờ về mặt kỹ thuật — DNS chỉ tham gia lúc resolve tên thành IP, còn sau đó kết nối cứ đi thẳng tới IP đã có. Nhưng hệ quả thực tế thì đáng nhớ: HTTP client nào dùng connection pool hoặc keep-alive (hầu hết thư viện HTTP hiện đại đều mặc định bật) sẽ giữ nguyên kết nối tới một replica trong suốt vòng đời của pool đó, bất kể pool có mở hàng chục request. DNS round robin của Docker chỉ chia đều khi mỗi request là một kết nối TCP mới toanh — đúng cho curl gọi lặp trong loop, sai cho phần lớn service-to-service traffic thật trong sản xuất.
Khi nào round robin của Compose là đủ, khi nào không
| Tình huống | DNS round robin có chia tải không |
|---|---|
Job ngắn hạn, mỗi lần gọi mở kết nối mới (cron gọi curl, script batch) |
Có, đo được ở trên |
HTTP client dùng connection pool / keep-alive (hầu hết SDK, requests.Session, axios mặc định) |
Không — dính chết vào một replica cho tới khi pool đóng |
| gRPC hoặc bất kỳ giao thức nào giữ kết nối lâu dài | Không, cùng lý do |
| Cần biết replica nào đang xử lý bao nhiêu request để cân bằng theo tải thực | Không — DNS không biết gì về tải, chỉ xoay vòng thứ tự |
Với ba tình huống cuối, câu trả lời không nằm trong Compose. Compose không có khái niệm load balancer built-in như Swarm (deploy.replicas bị bỏ qua hoàn toàn khi chạy bằng docker compose, chỉ có tác dụng ở chế độ Swarm) — cần một reverse proxy đứng giữa (nginx, Traefik, hoặc HAProxy) mới cân bằng được theo kết nối thay vì theo DNS lookup. Đó là chủ đề để đo riêng, không đoán trước ở đây.
Bài viết liên quan
- 172.19 khác 172.20, nhưng volume thì sống: đo phạm vi dự án Compose và cái gì còn lại sau down
- 8080 và 8081 cùng mở: đo cách nhiều tệp Docker Compose gộp lại, và chỗ danh sách bị cộng dồn
- 1.20 rụng thành 1.2, và .env không tự vào container: đo bốn lớp biến môi trường trong Compose
Thử ba mươi giây
Nhân bản một service tối thiểu và tự nhìn thứ tự DNS xoay vòng, không cần build gì phức tạp:
mkdir /tmp/scale-demo && cd /tmp/scale-demo
cat > docker-compose.yml << 'EOF'
services:
api:
image: alpine:3.20
command: sh -c "while true; do sleep 3600; done"
expose:
- "8000"
EOF
docker compose up -d --scale api=3
for i in 1 2 3; do
docker run --rm --network scale-demo_default curlimages/curl:8.10.1 getent hosts api
done
docker compose down
Thứ tự IP trả về ở mỗi lần lặp chính là round robin đang chạy thật, không phải lý thuyết.