Phần 52/60 của sê-ri Docker và Docker Compose. Phần 51 đo bốn lớp biến môi trường trong đúng một tệp docker-compose.yml; bài này tách project ra nhiều tệp — docker-compose.override.yml, -f nhiều lần, profiles: — và đo xem khi hai tệp cùng khai một service thì cái gì thắng, và cái gì lặng lẽ cộng dồn.

Giới thiệu

Thêm docker-compose.override.yml để đổi cổng cho môi trường dev là thao tác rất quen: sửa một dòng ports:, tưởng cổng cũ biến mất, cổng mới thay vào. Bài này dựng đúng tình huống đó và đo bằng docker compose config cùng docker ps thật — kết quả không phải "cổng mới thay cổng cũ", mà là cả hai cổng cùng mở. Không phải lỗi, đó là quy tắc gộp của Compose: một số trường bị ghi đè, một số trường cộng dồn, và ranh giới giữa hai nhóm không nằm ở chỗ trực giác hay đoán.

Thiết lập đo

Môi trường: Docker Compose v5.4.0, Engine 29.7.2. Mọi phép đo dưới đây chạy trên đúng một image:

FROM alpine:3.20

Đọc kết quả gộp bằng docker compose config (in ra cấu hình sau khi hợp nhất, chưa chạy gì), và xác nhận lại bằng docker compose up -d + docker ps khi cần biết điều gì thật sự chạy.

Cách các tệp được gộp

Ba cách đưa nhiều tệp vào cùng một lần chạy, cho ra đúng một kết quả gộp giống nhau:

# 1. Tự động: có docker-compose.override.yml cạnh docker-compose.yml là tự nạp thêm
docker compose up -d

# 2. Tường minh: liệt kê từng tệp bằng -f, theo đúng thứ tự muốn gộp
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d

# 3. Biến môi trường: liệt kê tên tệp cách nhau bằng dấu hai chấm
COMPOSE_FILE="docker-compose.yml:docker-compose.staging.yml" docker compose up -d

Cả ba cách đo ra cùng một docker compose config. Điểm chung: tệp liệt kê sau ghi đè tệp liệt kê trước — không phải tệp nào "quan trọng hơn", chỉ là thứ tự xử lý từ trái qua phải (hoặc trên xuống nếu dùng COMPOSE_FILE nhiều dòng).

Trường vô hướng (scalar): ghi đè hoàn toàn

# docker-compose.yml
services:
  app:
    image: alpine:3.20
    command: ["sleep", "3600"]
# docker-compose.override.yml
services:
  app:
    command: ["echo", "overridden"]
docker compose config
services:
  app:
    command:
      - echo
      - overridden
    image: alpine:3.20

command là một trường có giá trị đơn (dù viết dưới dạng mảng), tệp sau thay thế toàn bộ giá trị của tệp trước — không có sleep 3600 sót lại. image, restart, container_name hoạt động y hệt.

ports: cộng dồn, không thay thế

Đây là chỗ dễ vấp nhất. Tệp gốc mở cổng dev, tệp override định đổi sang cổng khác:

# docker-compose.yml
services:
  app:
    image: alpine:3.20
    command: ["sleep", "3600"]
    ports:
      - "8080:80"
# docker-compose.override.yml
services:
  app:
    ports:
      - "8081:80"
docker compose up -d
docker ps --filter "name=app" --format "{{.Ports}}"
0.0.0.0:8080->80/tcp, 0.0.0.0:8081->80/tcp, [::]:8080->80/tcp, [::]:8081->80/tcp

Container thật sự lắng nghe trên cả hai cổng host, không phải chỉ 8081. ports: là danh sách, và Compose gộp danh sách bằng cách nối thêm, không phải thay thế theo vị trí hay theo cổng container. Muốn 8081 thay hẳn 8080 thì phải xoá dòng 8080:80 khỏi tệp gốc — không có cách nào "ghi đè một phần tử danh sách" bằng tệp override.

Có một ngoại lệ: nếu hai tệp khai đúng cùng một chuỗi ("8080:80" ở cả hai tệp), Compose nhận ra trùng lặp và chỉ giữ một bản — đã đo lại, docker compose config không nhân đôi phần tử giống hệt nhau. Nhưng chỉ cần khác một ký tự (cổng container khác, hay thêm giao thức) là cả hai cùng tồn tại.

environment (dạng danh sách): gộp theo khoá, không phải theo vị trí

# docker-compose.yml
services:
  app:
    image: alpine:3.20
    environment:
      - FOO=base
# docker-compose.override.yml
services:
  app:
    environment:
      - BAR=override
docker compose config
    environment:
      BAR: override
      FOO: base

Cả FOO lẫn BAR đều có mặt — mặc dù environment: viết bằng cú pháp danh sách (- KEY=value), Compose vẫn hiểu nó là một bảng ánh xạ (khoá → giá trị) khi gộp, không phải một dãy nối đuôi như ports. Khai lại cùng khoá ở tệp sau thì giá trị sau thắng, đúng cơ chế đã đo ở phần 51 — chỉ khác là giờ hai giá trị đó nằm ở hai tệp thay vì hai khối trong cùng một tệp.

Bài học ở đây: cùng là cú pháp danh sách trong YAML, nhưng Compose gộp mỗi trường theo một quy tắc riêng tuỳ ý nghĩa của trường đó — không có một luật "danh sách luôn cộng dồn" áp dụng đều.

volumes: cộng dồn theo target, trừ khi trùng đường dẫn đích

# docker-compose.yml
services:
  app:
    image: alpine:3.20
    command: ["sleep", "3600"]
    volumes:
      - ./data:/data
# docker-compose.override.yml
services:
  app:
    volumes:
      - ./data2:/data
docker compose config
    volumes:
      - type: bind
        source: /tmp/compose-test/data2
        target: /data
        bind: {}

Chỉ còn một volume — ./data2:/data, không phải hai volume cùng gắn vào /data (làm vậy là vô nghĩa, hai thư mục vật lý không thể cùng chiếm một điểm mount). Đo bằng cách chạy thật:

echo goc > data/marker.txt
echo override > data2/marker.txt
docker compose run --rm app cat /data/marker.txt
override

Vậy volumes: vẫn là danh sách cộng dồn như ports: (hai target khác nhau thì cả hai cùng tồn tại — có thể tự kiểm bằng cách đổi target ở tệp override thành /more), nhưng khi hai phần tử trỏ cùng một target, Compose coi đó là "sửa lại phần tử cũ" và chỉ giữ bản sau — âm thầm khác với ports:, nơi hai cổng khác nhau luôn cộng dồn dù cùng container port.

profiles: cộng dồn qua từng tệp, kể cả khi tệp sau chỉ ghi tên khác

profiles: cũng là danh sách, và cũng cộng dồn — nhưng hệ quả của việc này dễ gây bất ngờ hơn hẳn ports:, vì nó quyết định service có được khởi động hay không.

# docker-compose.yml
services:
  debug-tool:
    image: alpine:3.20
    command: ["sleep", "3600"]
    profiles:
      - debug
# docker-compose.staging.yml
services:
  debug-tool:
    profiles:
      - staging

Nhìn tệp docker-compose.staging.yml một mình, dễ đoán nó "đổi" profile của debug-tool từ debug sang staging. Đo thật bằng config (thêm --profile để Compose chịu in ra service bị ẩn — mặc định docker compose config cũng lọc theo profile đang bật, giống up):

docker compose -f docker-compose.yml -f docker-compose.staging.yml \
  --profile debug --profile staging config
  debug-tool:
    profiles:
      - debug
      - staging

Cả hai profile cùng có mặt. Hệ quả: cờ --profile debug một mình vẫn kích hoạt được debug-tool, dù tệp override chỉ nhắc tới staging:

docker compose -f docker-compose.yml -f docker-compose.staging.yml --profile debug up -d
docker compose ps --format "{{.Service}}"
debug-tool

Nếu ý định thật là "ở staging thì đổi hẳn service này sang profile khác, không dùng profile debug nữa", tệp override theo kiểu cộng dồn này không làm được việc đó — muốn thay hẳn profiles: phải xoá dòng đó khỏi tệp gốc, không có cách viết "ghi đè danh sách" nào khác.

Một hành vi liên quan, đo được khi gọi thẳng tên service mà quên bật profile: docker compose up -d (không tham số) chỉ đưa app lên, debug-tool bị ẩn; nhưng docker compose up -d debug-tool — gọi đích danh — vẫn đưa nó lên bình thường, docker compose ps xác nhận cả hai đang chạy. profiles: chỉ lọc service khi Compose tự dựng danh sách "toàn bộ project"; gọi thẳng tên trên dòng lệnh là yêu cầu tường minh, không bị lọc. Dễ hiểu nhầm profiles: là "khoá" service lại — thực ra nó chỉ ẩn khỏi nhóm mặc định.

Bảng tổng hợp

Trường Kiểu gộp Đo được
command, image, restart Ghi đè hoàn toàn sleep 3600echo overridden, không sót
environment (danh sách hoặc bảng) Gộp theo khoá, trùng khoá thì tệp sau thắng FOOBAR cùng có mặt
ports Cộng dồn theo toàn bộ chuỗi, trừ khi trùng y hệt 8080:808081:80 cùng mở thật
volumes Cộng dồn, nhưng trùng target thì tệp sau thay tệp trước Hai target khác nhau: cả hai còn; cùng target /data: chỉ còn bản sau
profiles Cộng dồn qua từng tệp debug (tệp gốc) + staging (tệp sau) → service mang cả hai

Khi nào không nên dùng nhiều tệp Compose kiểu này

Cộng dồn hợp lý khi ý định thật sự là "thêm" — mở thêm cổng debug, gắn thêm volume cấu hình riêng cho local, gắn thêm profile để một service xuất hiện ở nhiều môi trường. Nó không phù hợp khi ý định là "thay hẳn": đổi cổng production sang cổng khác, đổi hẳn danh sách volume, đổi hẳn profile. Với những trường hợp thay hẳn, hoặc xoá trường đó khỏi tệp gốc rồi khai lại đầy đủ ở tệp override, hoặc tách hẳn hai biến thể thành hai docker-compose.yml độc lập không dùng chung base — trộn cả hai cách trong một project là nguồn gốc của những cổng mở thừa không ai để ý.

Lỗi thường gặp

Tưởng override thay thế cổng, thực ra cộng dồn

Đúng như số đo ở trên: ports: trong override không "sửa" cổng cũ, nó thêm cổng mới bên cạnh. Triệu chứng dễ nhận: docker ps báo nhiều cổng hơn số dòng ports: bạn nghĩ mình đã khai, hoặc lỗi "address already in use" khi cổng cũ (tưởng đã bị thay) vẫn đang bị một container khác chiếm.

Đặt profiles: ở tệp override để "chuyển môi trường" cho service

Không xoá được profile cũ theo cách này — chỉ thêm profile mới vào danh sách đang có. Muốn đổi hẳn, xoá profiles: khỏi tệp gốc.

Thử ba mươi giây

Dán vào bất kỳ project nào có docker-compose.ymldocker-compose.override.yml, xem đúng những gì thật sự được gộp trước khi chạy:

docker compose config | grep -A5 "ports:\|profiles:"

So với những gì bạn nghĩ override đang làm — lệch chỗ nào là danh sách đang cộng dồn ở đúng chỗ đó.

Bài viết liên quan