Cách dùng nhiều tệp Docker Compose hiệu quả
Tìm hiểu cách Docker Compose hợp nhất tệp, cơ chế tự động tải compose.override.yaml và lỗi ghi đè ports phổ biến. Hướng dẫn cấu hình tách biệt môi trường dev và prod.
Cách Compose xử lý nhiều tệp tin
Docker Compose có thể xây dựng một dự án từ nhiều tệp tin. Nó đọc các tệp theo thứ tự nhận được và hợp nhất chúng thành một mô hình duy nhất, vì vậy tệp tin sau sẽ ghi đè lên bất kỳ giá trị nào bị xung đột. Có hai cơ chế thực hiện việc này từ CLI: một tệp ghi đè mà Compose tự động tải và cờ -f mà bạn truyền vào thủ công. Cơ chế thứ ba nằm ngay bên trong tệp tin, đó là phần tử include, và nó hoạt động khác biệt so với hai cơ chế trên.
Việc hợp nhất không phải là ghi đè đơn thuần. Các mapping được hợp nhất theo từng key, các sequence được nối thêm vào, và một tập hợp nhỏ các trường sẽ được thay thế toàn bộ. Sự khác biệt đó là nơi phát sinh các bất ngờ, và danh sách ports là thứ khiến hầu hết mọi người mắc lỗi.
Mọi nội dung dưới đây giả định bạn đang dùng Compose v2, plugin docker compose thay vì tập lệnh docker-compose cũ. Chạy docker compose version để kiểm tra. Nếu bạn chưa viết tệp Compose nào, hãy bắt đầu với hướng dẫn cơ bản về Docker Compose rồi quay lại đây.
Tệp ghi đè mà Compose tự động tải
Chạy docker compose up mà không có cờ -f, Compose sẽ tìm kiếm trong thư mục làm việc hiện tại và các thư mục cha của nó để tìm compose.yaml hoặc docker-compose.yaml. Nếu một tệp ghi đè nằm cạnh tệp cơ sở, Compose sẽ tự động tải tệp đó sau.
ls compose.yaml compose.override.yaml
docker compose up -dKhi có cả hai tệp, kết quả tương đương với việc bạn tự nhập thủ công.
docker compose -f compose.yaml -f compose.override.yaml up -dCác tên mà Compose nhận diện là compose.override.yaml, compose.override.yml, và các tên cũ hơn là docker-compose.override.yml và docker-compose.override.yaml. Bất kỳ tên nào khác, ví dụ như compose.dev.yaml, chỉ được tải khi bạn chỉ định nó bằng -f.
Ngay khi bạn truyền vào một -f, việc tự động tải sẽ dừng lại. docker compose -f compose.yaml up chỉ đọc đúng tệp đó và bỏ qua tệp ghi đè; đây là đặc tính mà mô hình dev và prod trong phần sau của hướng dẫn này dựa vào.
Điều này có hai mặt trên server. Một tệp ghi đè bị bỏ lại trong thư mục triển khai sẽ được tải bởi mọi lệnh docker compose chạy từ thư mục đó, bao gồm cả lệnh mà cron job của bạn thực thi. Đó là cách một stack production vô tình thực hiện bind-mount một thư mục mã nguồn mà không ai muốn đưa lên. Hãy chạy docker compose config sau mỗi lần triển khai và đọc kết quả trả về.
Thứ tự với -f và cách giải quyết các đường dẫn tương đối
Compose xây dựng cấu hình theo thứ tự bạn cung cấp các tệp, và các tệp sau sẽ ghi đè hoặc bổ sung cho các tệp trước đó. Từ trái sang phải, tệp cuối cùng sẽ có quyền ưu tiên cao nhất.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -dMọi lệnh trong dự án đó đều cần cùng một danh sách tệp. Chạy up với hai tệp và logs với một tệp sẽ khiến bạn làm việc với một mô hình hợp nhất khác, đây là cách nhanh nhất để gặp lỗi dịch vụ mà Compose báo là không tồn tại. Thay vào đó, hãy thiết lập danh sách một lần bằng biến môi trường COMPOSE_FILE.
export COMPOSE_FILE=compose.yaml:compose.prod.yaml
docker compose config
docker compose up -dDấu phân cách là : trên Linux, và COMPOSE_PATH_SEPARATOR sẽ thay đổi nó. COMPOSE_FILE cũng có thể nằm trong tệp .env của dự án, giúp nó trở thành một phần của mã nguồn thay vì nằm trong lịch sử shell của bạn. Bất kỳ thiết lập nào được chỉ định rõ ràng trên dòng lệnh đều sẽ ghi đè lên biến môi trường.
Bây giờ là quy tắc gây lỗi cho bind mount. Khi bạn sử dụng nhiều tệp với -f, tất cả các đường dẫn tương đối trong tất cả các tệp đó đều được giải quyết dựa trên thư mục của tệp đầu tiên, không phải dựa trên tệp chứa chúng. Viết ./data:/var/lib/postgresql/data bên trong deploy/prod/compose.prod.yaml thì Compose vẫn tìm ./data ở vị trí cạnh tệp cơ sở. Docker sau đó tạo một thư mục trống tại đường dẫn sai đó và container khởi chạy mà không có dữ liệu bên trong, điều này trông giống như mất dữ liệu nhưng thực tế không phải vậy. Hãy truyền --project-directory để tự thiết lập đường dẫn cơ sở, hoặc sử dụng include, tùy chọn này sẽ giải quyết từng tệp dựa trên thư mục riêng của nó.
Tên dự án được lấy từ cùng thư mục cơ sở đó, vì vậy việc thay đổi tệp nào đứng đầu có thể đổi tên dự án. Một dự án được đổi tên đồng nghĩa với tên container mới và tên volume mới, còn volume cũ vẫn nằm trên đĩa dưới tên cũ. Hãy cố định nó bằng khóa name: ở cấp cao nhất trong tệp cơ sở.
name: myappCác trường nào được gộp, các trường nào bị thay thế
Compose thực hiện gộp dựa trên kiểu giá trị, không dựa trên tên trường.
- Các trường đơn giá trị bị thay thế.
image,command,entrypointvàmem_limitsẽ lấy giá trị sau cùng hoàn toàn. Bạn không thể nối thêm một đối số vàocommand, vì ghi đè sẽ viết lại toàn bộ dòng đó. - Các ánh xạ (mapping) được gộp theo từng khóa.
environment,labels,volumesvàdevicesgiữ lại mọi khóa từ cả hai tệp, và tệp sau cùng sẽ ghi đè lên bất kỳ khóa nào xuất hiện ở cả hai tệp. Đối vớienvironmentvàlabels, khóa chính là tên biến hoặc tên nhãn. Đối vớivolumesvàdevices, khóa chính là đường dẫn container. - Các chuỗi (sequence) được nối thêm.
dns,dns_search,expose,tmpfsvàexternal_linksđược nối với nhau. Một tệp cơ sở chứaexpose: ["3000"]khi gộp với một tệp ghi đè chứa["4000", "5000"]sẽ tạo ra["3000", "4000", "5000"].
Bốn chuỗi có chứa khóa định danh, vì vậy các mục khớp với khóa đó sẽ được gộp thay vì nối thêm. volumes, secrets và configs khớp dựa trên target. ports khớp dựa trên sự kết hợp của ip, target, published và protocol.
Hãy đọc kỹ quy tắc ports đó hai lần, vì đó là cái bẫy. Hai mục port chỉ được coi là cùng một mục khi cả bốn phần đó đều khớp nhau. Thay đổi bất kỳ phần nào trong đó và Compose sẽ coi đó là một port thứ hai, không liên quan, nên nó sẽ giữ lại cả hai.
Tại sao port của bạn vẫn được publish sau khi override
Một file cơ sở publish một service trên mọi interface:
services:
web:
image: nginx:1.27
ports:
- "8080:80"Một file override được viết để bind nó chỉ vào localhost, vì một reverse proxy sẽ đứng trước nó:
services:
web:
ports:
- "127.0.0.1:8080:80"Hãy kiểm tra kết quả trước khi cho rằng nó đã hoạt động.
docker compose -f compose.yaml -f compose.prod.yaml configCả hai mục đều xuất hiện trong kết quả đầu ra. Phần ip khác nhau, 0.0.0.0 so với 127.0.0.1, vì vậy chúng là hai port khác nhau đối với quá trình merge, và binding công khai mà bạn cố gắng xóa vẫn còn trong model. Điều này quan trọng trên Docker hơn các nơi khác, vì một port được publish sẽ được ghi vào iptables trước các quy tắc firewall của bạn. Cơ chế này được đề cập trong tại sao các port Docker được publish lại vượt qua ufw.
Có hai cách sửa. Cách tường minh là sử dụng tag !override, nó thay thế toàn bộ thuộc tính và bỏ qua các quy tắc merge:
services:
web:
ports: !override
- "127.0.0.1:8080:80"!override yêu cầu Compose v2.24.4 hoặc mới hơn. Cách sửa di động không cần tag nào cả: hãy giữ ports hoàn toàn bên ngoài file cơ sở và chỉ khai báo nó trong các file dành riêng cho môi trường. Không có gì để merge nghĩa là không có gì bị lộ. Đó là mô hình được sử dụng trong ví dụ thực tế dưới đây.
Xóa một giá trị trong bộ tệp cơ sở
!reset xóa một thuộc tính, đưa nó về giá trị mặc định hoặc null. Nó nhận một giá trị và bỏ qua giá trị đó, vì vậy hãy nhập một giá trị hợp lệ và để trống.
services:
web:
ports: !reset []
environment:
DEBUG: !reset null!reset yêu cầu Compose v2.24 trở lên. Hãy sử dụng nó khi bạn không thể chỉnh sửa tệp cơ sở, ví dụ như một fragment của nhà cung cấp mà bạn kéo về.
include, dành cho các stack được lắp ghép từ nhiều phần
include đưa một ứng dụng Compose khác vào mô hình của bạn. Đây là một phần tử cấp cao nhất, không phải là một flag.
include:
- path: ../commons/compose.yamlMỗi đường dẫn trong include được tải như một mô hình ứng dụng Compose riêng biệt, với thư mục dự án riêng của nó, vì vậy các đường dẫn tương đối bên trong tệp đó sẽ được phân giải dựa trên thư mục của chính tệp đó. Đó là sự khác biệt thực sự so với -f, và là lý do tại sao include là công cụ phù hợp khi đoạn mã nằm trong một thư mục khác hoặc một repository khác.
Dạng đầy đủ chấp nhận các tùy chọn con.
include:
- path:
- ../monitoring/compose.yaml
- ../monitoring/compose.vps.yaml
project_directory: ../monitoring
env_file: ../monitoring/.envpath chấp nhận một danh sách, và các tệp đó được hợp nhất với nhau theo các quy tắc thông thường trước khi kết quả tham gia vào mô hình của bạn. project_directory thiết lập đường dẫn cơ sở được sử dụng để phân giải các đường dẫn tương đối trong tệp được bao gồm. env_file cung cấp cho tệp được bao gồm các biến riêng để nội suy, điều này ngăn chặn một đoạn mã dùng chung tự ý đọc tệp .env của dự án bạn. include yêu cầu Compose v2.20.0 hoặc mới hơn.
Các tên tài nguyên trùng lặp giữa tệp của bạn và tệp được bao gồm sẽ được báo lỗi thay vì tự động hợp nhất, và đây là hành vi có chủ đích. Để thay đổi một thứ gì đó mà tệp được bao gồm đã khai báo, hãy đặt thay đổi đó vào compose.override.yaml: phần ghi đè được áp dụng cho mô hình đã lắp ghép, vì vậy nó có thể tác động đến các tài nguyên được bao gồm mà không gây xung đột với chúng.
Tóm tắt ngắn gọn: include kết hợp các ứng dụng riêng biệt, -f xếp chồng cấu hình lên một ứng dụng duy nhất.
Tách biệt môi trường dev và prod trên một VPS
Đây là toàn bộ mô hình trong ba tệp tin. Tệp cơ sở khai báo các thiết lập chung cho mọi môi trường và không publish bất kỳ port nào.
name: myapp
services:
app:
image: ghcr.io/example/app:1.4.2
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
LOG_LEVEL: info
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:Điều kiện depends_on giúp ứng dụng chờ đợi một database phản hồi thay vì chỉ chờ một container đang chạy, chi tiết tại healthchecks và các điều kiện depends_on. POSTGRES_PASSWORD được nội suy từ tệp .env của dự án, tệp này không bao giờ được đưa vào git. Xem các tệp env và Compose secrets để biết các biến thể an toàn hơn.
Tiếp theo là compose.override.yaml, tệp mà Compose tự động tải. Đây là tệp dành cho lập trình viên.
services:
app:
build: .
command: npm run dev
environment:
LOG_LEVEL: debug
ports:
- "3000:3000"
volumes:
- ./src:/app/src
db:
ports:
- "127.0.0.1:5432:5432"Trên laptop, lệnh docker compose up đơn thuần sẽ gộp hai tệp này lại. command thay thế giá trị mặc định của image vì nó là giá trị đơn. LOG_LEVEL thay thế info vì environment gộp theo key. Bind mount và hai port được publish là các phần bổ sung thuần túy, và port của database được bind vào localhost để laptop trên mạng chung không mở PostgreSQL ra toàn bộ mạng nội bộ.
Cuối cùng là compose.prod.yaml. Tên của nó không nằm trong danh sách Compose tự động tìm kiếm, nên nó không bao giờ bị tải nhầm.
services:
app:
ports:
- "127.0.0.1:8000:3000"
deploy:
resources:
limits:
memory: 512MTrên VPS, bạn chỉ định cả hai tệp, và việc chỉ định này chính là cách loại trừ tệp override.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml psps sẽ liệt kê cả hai service đang chạy, với db hiển thị (healthy). Vì bạn đã truyền -f, compose.override.yaml đã không được đọc, nên lệnh dev, source bind mount và port công khai 3000 không thể ảnh hưởng đến môi trường production mặc dù tệp vẫn nằm trong cùng thư mục. Port 8000 chỉ nằm trên localhost, sẵn sàng cho một proxy: xem chạy nhiều ứng dụng sau Traefik khi bạn thêm service thứ hai.
Thiết lập COMPOSE_FILE=compose.yaml:compose.prod.yaml trong .env của server và các lệnh còn lại của bạn sẽ quay trở lại là docker compose logs -f app đơn giản.
Đọc model đã hợp nhất trước khi bạn triển khai
docker compose config in ra model đã được hợp nhất và nội suy hoàn toàn. Đây không phải là bản xem trước. Đây là đầu vào chính xác mà Compose sẽ thực thi, vì vậy khi kết quả đầu ra không khớp với kỳ vọng của bạn, kết quả đầu ra đó mới là đúng.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml config --no-interpolate
docker compose -f compose.yaml -f compose.prod.yaml config --services--no-interpolate giữ nguyên ${VAR} chưa được mở rộng. Hãy sử dụng nó trước khi dán đầu ra vào bất kỳ đâu, vì config thông thường sẽ in mọi secret đã giải mã dưới dạng văn bản thuần. --services chỉ liệt kê các tên service, đây là cách nhanh chóng để xác nhận include đã lấy đúng những gì bạn mong đợi.
Các chế độ lỗi và những gì bạn sẽ thấy
no configuration file provided: not found. Compose không tìm thấy nội dung để đọc. Bạn đang ở ngoài thư mục dự án, hoặc COMPOSE_FILE chỉ định một đường dẫn không tồn tại. Compose tìm kiếm các thư mục cha để lấy tệp cơ sở mặc định, nhưng nó không tìm kiếm tệp bạn tự đặt tên ở bất kỳ đâu khác.
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string. Việc nội suy (interpolation) được giải quyết dựa trên tệp .env của dự án và môi trường shell, và thư mục dự án ở đây là thư mục của tệp -f đầu tiên. Việc triển khai từ một thư mục khác với thư mục chứa .env sẽ gây ra cảnh báo này và sau đó là cơ sở dữ liệu từ chối mọi kết nối.
Chỉnh sửa ghi đè của bạn không hiển thị trong docker compose config. Hoặc là bạn đã truyền -f, lệnh này tắt tính năng tự động tải ghi đè, hoặc Compose đã tìm thấy compose.yaml trong một thư mục cha và tệp ghi đè của bạn không nằm cạnh nó. Chạy docker compose config mà không có đối số nào khác sẽ cho bạn biết mô hình nào mà Compose thực sự đang xây dựng.
Một bind mount bị trống và Docker đã tạo một thư mục mà bạn không yêu cầu. Đường dẫn tương đối đã được giải quyết dựa trên thư mục của tệp đầu tiên. Hãy sửa đường dẫn, truyền --project-directory, hoặc di chuyển đoạn mã ra sau include.
Các container quay trở lại với tên mới và một volume trông có vẻ trống. Tên dự án đã thay đổi, vì tên dự án tuân theo thư mục của tệp đầu tiên. Thêm name: ở cấp cao nhất vào tệp cơ sở và việc đặt tên sẽ không thay đổi nữa. Volume cũ vẫn ở đó dưới tiền tố cũ, và docker volume ls sẽ hiển thị nó.
Một port bạn đã xóa trong phần ghi đè vẫn đang mở. Việc hợp nhất ports đã thực hiện nối thêm thay vì thay thế. Xác nhận bằng docker compose config, sau đó sử dụng !override hoặc di chuyển ports ra khỏi tệp cơ sở.
FAQ
Compose có tự động tải compose.override.yaml không?
Có, khi bạn chạy docker compose mà không dùng flag -f. Compose sẽ tìm trong thư mục làm việc và các thư mục cha của nó tệp compose.yaml hoặc docker-compose.yaml, nếu có một tệp ghi đè nằm cạnh đó, tệp này sẽ được tải thứ hai. Các tên được nhận diện là compose.override.yaml, compose.override.yml, docker-compose.override.yml và docker-compose.override.yaml. Việc truyền bất kỳ -f nào sẽ vô hiệu hóa tính năng này, vì vậy docker compose -f compose.yaml up chỉ đọc một tệp duy nhất.
Thứ tự hợp nhất của nhiều tệp -f là gì?
Từ trái sang phải. Compose xây dựng cấu hình theo thứ tự bạn cung cấp các tệp, và mỗi tệp sẽ ghi đè hoặc bổ sung vào các tệp trước đó, do đó tệp cuối cùng trong dòng lệnh sẽ quyết định các giá trị khi có xung đột. Danh sách tệp giống nhau phải được sử dụng cho mọi lệnh trong dự án đó, đây chính là mục đích của COMPOSE_FILE=compose.yaml:compose.prod.yaml.
Tại sao port của tôi vẫn được publish sau khi tôi đã ghi đè nó?
Vì các mục ports được xác định bởi toàn bộ tập hợp ip, target, published và protocol. Một bản ghi đè 127.0.0.1:8080:80 so với tệp gốc 8080:80 khác nhau ở phần ip, nên Compose coi đó là một port thứ hai và giữ lại cả hai. Chạy docker compose config và bạn sẽ thấy hai mục này. Sử dụng ports: !override trên Compose v2.24.4 hoặc mới hơn, hoặc giữ ports bên ngoài tệp gốc để không có gì để hợp nhất.
Sự khác biệt giữa include và -f là gì?
-f xếp chồng nhiều tệp lên một ứng dụng, và mọi đường dẫn tương đối trong mỗi tệp đều được phân giải dựa trên thư mục của tệp đầu tiên. include kéo một ứng dụng Compose riêng biệt vào, và mỗi đường dẫn được bao gồm sẽ giữ thư mục dự án riêng của nó, vì vậy các đường dẫn tương đối của nó được phân giải dựa trên chính nó. Sử dụng -f cho các lớp môi trường của stack của bạn, và include cho một thành phần được duy trì ở nơi khác. include yêu cầu Compose v2.20.0 hoặc mới hơn.
Làm thế nào để xóa một giá trị mà tệp gốc đã thiết lập?
Sử dụng thẻ !reset trên Compose v2.24 hoặc mới hơn. Viết ports: !reset [] hoặc MY_VAR: !reset null trong tệp ghi đè và thuộc tính đó sẽ quay trở lại giá trị mặc định hoặc về null. Giá trị bạn gán cho thẻ là bắt buộc nhưng sẽ bị bỏ qua. Nếu bạn muốn thay thế một thuộc tính thay vì xóa nó, !override sẽ thực hiện việc đó, và nó yêu cầu phiên bản v2.24.4 hoặc mới hơn.