第一篇 《如何在Linux部署Docker&Compose》 里,我们用一份最基础的 docker-compose.yml 把 Nginx 和 Redis 跑了起来。真到了日常用的时候,问题很快就来了:数据库密码写死在 compose 文件里,换台机器就要改一遍;应用比数据库先启动,数据库还没初始化完,应用连不上直接报错退出;同一份配置想在测试机和正式机上跑,端口和镜像标签又不一样……这篇就专门解决这三个问题:环境变量(配置和文件分离)、健康检查 + 启动顺序(让服务按正确的节奏起来)、profiles 与多文件(一份配置多处用)。全篇的例子都可以直接抄。
一、环境变量三件套:先分清谁是谁
Compose 里跟「变量」有关的东西有三个,新手最容易混:environment、env_file、项目根目录的 .env 文件。它们的作用完全不同,先看对比表:
| 方式 | 写在哪 | 作用 | 会不会进容器 |
|---|---|---|---|
.env 文件 | 项目目录(和 compose 文件同级) | 给 compose 文件本身做变量插值,替换 ${...} 占位符 | 不会,它的值不会自动变成容器环境变量 |
env_file | compose 里服务的属性,指向一个或多个 env 文件 | 把文件里的键值对批量注入容器内部的环境变量 | 会 |
environment | compose 里服务的属性 | 在 compose 文件里直接写键值对,注入容器内部的环境变量 | 会 |
一句话记忆:.env 是给 compose 文件看的,env_file 和 environment 是给容器看的。
1. .env 文件:只负责插值
在 compose 文件同目录建一个 .env:
NGINX_PORT=8080IMAGE_TAG=1.27DB_PASSWORD=example_password_123compose 文件里用 ${...} 引用它:
services: web: image: nginx:${IMAGE_TAG} ports: - "${NGINX_PORT}:80"运行 docker compose up -d 时,Compose 会先读 .env,把 ${IMAGE_TAG} 换成 1.27、${NGINX_PORT} 换成 8080,再去执行。注意:NGINX_PORT 这个变量不会出现在容器内部,你在容器里执行 printenv 是看不到它的——它只参与了拼凑 compose 文件这一步。
2. env_file:批量把变量塞进容器
应用真正要读的配置(数据库地址、密码、开关等)一般这样给:
DATABASE_URL=postgres://app:secret@db:5432/appdbLOG_LEVEL=infoFEATURE_X=onservices: app: image: myapp:latest env_file: - app.env容器启动后,在容器内执行 printenv 就能看到 DATABASE_URL 等变量。一个服务可以挂多个 env_file,写成列表即可;多个文件里有同名变量时,后写的文件覆盖先写的。
env_file 里也可以做插值(Compose 2.24 以后支持,需要在文件里用 ${...} 并配合 env_file 的长语法 required 等属性,一般场景用不到,先知道有这回事)。
3. environment:直接写在 compose 里
services: app: image: myapp:latest environment: - TZ=Asia/Shanghai - LOG_LEVEL=debug - EMPTY_VALUE= - PASSTHROUGH_VAR两种写法:上面这种列表写法里,KEY=VALUE 是直接赋值;只写 KEY 不带等号(如 PASSTHROUGH_VAR)表示把宿主机当前 shell 里的同名变量透传进容器,宿主机没有这个变量时它在容器里就是空值(并会给出一条警告)。也可以写成映射写法,效果一样:
environment: TZ: Asia/Shanghai LOG_LEVEL: debug4. 三者优先级
当同一个变量在多处出现时,谁说了算:
- 注入容器时:
environment的优先级高于env_file。两边都有LOG_LEVEL,容器里拿到的是environment写的值。 - 插值时:
${...}的取值来源优先级是 宿主机 shell 环境变量 >.env文件。也就是说,你临时想换个端口,不用改.env,直接:
NGINX_PORT=9090 docker compose up -d这一行 shell 里给的值会盖过 .env 里的 8080。这也是为什么 .env 里放默认值、命令行临时覆盖,是很常见的用法。
二、变量插值语法详解
插值是把 compose 文件写活的关键,语法不多,但每个符号差别很大,用错了会静默出错。以下假设变量名为 TAG:
| 语法 | 含义 |
|---|---|
${TAG} 或 $TAG | 直接替换;变量不存在时替换成空字符串,并打印一条警告 |
${TAG:-1.27} | 变量未设置或为空时用默认值 1.27 |
${TAG-1.27} | 只有变量未设置时才用默认值;设了空字符串就用空字符串 |
${TAG:?错误信息} | 变量未设置或为空时直接报错退出,并显示错误信息 |
${TAG?错误信息} | 只有变量未设置时才报错 |
${TAG:+latest} | 变量已设置且非空时替换成 latest,否则替换成空(进阶用法,偶尔用来拼可选片段) |
$$ | 转义,输出一个字面量的 $,不做插值 |
冒号的区别就一条:带冒号连「空值」也算没设,不带冒号只有「压根没设」才算。 拿不准就用带冒号的版本,符合大多数人的直觉。
一个把这些全用上的完整例子:
services: app: # DB_PASSWORD 没配就直接报错,不让它带着空密码启动 image: myapp:${APP_VERSION:-1.0.0} environment: - DB_PASSWORD=${DB_PASSWORD:?请先在 .env 里设置 DB_PASSWORD} # $$ 转义:容器里要用的字面量 $ 符号,比如提示符或正则 - PROMPT_SYMBOL=$$ ports: # 端口也允许外部覆盖,不写死 - "${APP_PORT:-8080}:8080"WARNING插值只发生在 compose 文件里,容器内部的 shell 不会再处理这些
${...}。另外$TAG这种不带花括号的写法在值紧挨着其他字符时容易粘连出错,正式文件里统一用${TAG}花括号写法。
三、敏感信息:密码到底放哪
先说必须养成的习惯:.env、app.env 这类装着密码的文件,绝对不要提交进 Git。 在项目里建 .gitignore 并写上:
.env*.env然后提交一份不含真实值的模板 .env.example 给自己(或协作者)参考:
# .env.example —— 复制为 .env 后填入真实值DB_PASSWORD=APP_PORT=8080如果密码已经不小心提交进 Git,光删文件没用,记录还在历史里,正确做法是直接改掉那个密码,把泄露的凭据作废。
再进一步,Compose 还提供了 secrets 机制:把敏感值放在单独文件里,Compose 在运行时把它们挂载到容器的 /run/secrets/<名字> 路径下,程序从文件读,而不是从环境变量读。好处是环境变量能被 docker inspect 直接看到,而 secrets 文件相对隐蔽一些:
services: app: image: myapp:latest secrets: - db_password
secrets: db_password: file: ./db_password.txtdb_password.txt 里只放密码本身一行。注意:这要求你的应用支持「从文件读密码」(不少官方镜像支持,比如 PostgreSQL 镜像的 POSTGRES_PASSWORD_FILE 环境变量就是干这个的)。日常个人项目里,把 .env 管好、不进 Git、不写进镜像,已经能挡住绝大多数问题;secrets 作为进阶选项了解即可。
四、healthcheck:让 Compose 知道服务「真的好了」
容器 Up 只代表进程起来了,不代表服务能用了——PostgreSQL 进程在跑,但它还在做初始化、还没开始接受连接,这时候应用连过去就是报错。healthcheck 就是让 Docker 周期性地「体检」容器,把状态细分成 starting(体检中)、healthy(可用)、unhealthy(体检连续失败)。
1. 五个字段逐个讲
healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 10s timeout: 5s retries: 5 start_period: 30s| 字段 | 含义 |
|---|---|
test | 体检命令。["CMD", ...] 是直接执行程序(推荐);["CMD-SHELL", "..."] 是走一层 shell,可以用管道和 &&;["NONE"] 表示禁用健康检查 |
interval | 两次体检的间隔 |
timeout | 单次体检的超时时间,超时算失败 |
retries | 连续失败几次才标记为 unhealthy |
start_period | 启动宽限期:这段时间内的失败不计数,给慢启动的服务留活路 |
还有个较新的字段 start_interval:在 start_period 期间用更密的间隔体检,好让服务一旦就绪就尽快转 healthy,日常可以不写。
体检命令的判定标准只有一个:退出码为 0 算通过,非 0 算失败。
2. 三个可直接抄的例子
PostgreSQL(镜像自带 pg_isready):
services: db: image: postgres:16 environment: - POSTGRES_PASSWORD=${DB_PASSWORD:?请先在 .env 里设置 DB_PASSWORD} healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] interval: 10s timeout: 5s retries: 5 start_period: 20sMySQL(镜像自带 mysqladmin;MySQL 8 的镜像也可以用 mysqladmin ping):
services: mysql: image: mysql:8.4 environment: - MYSQL_ROOT_PASSWORD=${MYSQL_PASSWORD:?请先设置 MYSQL_PASSWORD} healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p$$MYSQL_ROOT_PASSWORD"] interval: 10s timeout: 5s retries: 10 start_period: 30s注意这里的 $$MYSQL_ROOT_PASSWORD:双写 $ 是转义,意思是让容器内部的 shell/程序去读它自己的环境变量,而不是让 Compose 在宿主机上插值。mysqladmin ping 不带密码在开了密码认证时也能返回部分结果,为了严谨建议带上密码测。
HTTP 服务(用 curl 或 wget 探一个健康接口):
services: web: image: myapp:latest healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"] interval: 15s timeout: 5s retries: 3 start_period: 15scurl -f 在 HTTP 状态码是 4xx/5xx 时返回非 0 退出码,正好符合体检的判定方式。
WARNING体检命令依赖容器镜像里有这个工具。很多精简镜像(alpine、distroless)里既没有
curl也没有wget,这时 healthcheck 会一直失败,误报unhealthy。解决办法:优先用服务自带的探针(如pg_isready),实在没有就换带工具的镜像标签,或者在 Dockerfile 里把curl装进去。
五、depends_on:让启动有顺序,尤其是等数据库
光有体检还不够,要让体检结果参与编排,就靠 depends_on 的条件。它有三种:
| condition | 含义 | 典型用途 |
|---|---|---|
service_started | 依赖的容器进程启动就算数(默认行为) | 只要求对方先拉起来,不关心能不能用 |
service_healthy | 依赖的容器健康检查通过才启动自己 | 等数据库、缓存真正能连接了再启动应用 |
service_completed_successfully | 依赖的容器运行完并以 0 退出才启动自己 | 等一次性初始化/迁移任务跑完 |
注意:只要用到条件,就必须用长语法(把依赖写成映射),短语法 depends_on: [db] 只有 service_started 的效果。
经典问题:数据库没就绪,应用启动就报错
没有健康检查配合时,经典翻车现场是这样的:depends_on 只保证 db 容器先启动,app 紧跟着起来,第一件事连数据库——而 PostgreSQL 还在初始化数据目录——连接被拒,app 直接崩溃退出,日志里全是 connection refused。以前大家靠在应用里写重试循环或者塞个 sleep 硬扛,现在标准解法是 healthcheck + service_healthy,一份完整的例子:
services: db: image: postgres:16 environment: - POSTGRES_USER=app - POSTGRES_PASSWORD=${DB_PASSWORD:?请先在 .env 里设置 DB_PASSWORD} - POSTGRES_DB=appdb volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d appdb"] interval: 5s timeout: 3s retries: 10 start_period: 15s
migrate: image: myapp:latest command: ./run-migrations.sh depends_on: db: condition: service_healthy environment: - DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/appdb
app: image: myapp:latest ports: - "${APP_PORT:-8080}:8080" depends_on: db: condition: service_healthy migrate: condition: service_completed_successfully environment: - DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/appdb
volumes: db_data:这份配置的启动顺序是:db 先起 → 体检通过(真的能连了)→ migrate 迁移任务开跑 → 迁移以 0 退出 → app 才启动。三个条件一次全用上了。
两个必须知道的边界:
depends_on只管「启动这一刻」。运行中途 db 重启了,app 不会跟着等它健康,要靠应用自身的连接重试兜底——编排解决顺序,代码解决健壮性,两回事。- 短语法在 Compose 老版本(V1)里还有「等网络就绪」的错觉,V2 里短语法就是纯粹的先后拉起,别指望更多。
WARNING
docker compose down时的停止顺序正好和启动相反:先停 app,再停 db,这个不用你操心。但如果某个服务体检一直不过(比如探针命令写错),依赖它的服务会一直等着,up看起来像卡死——先去看被依赖服务的健康状态,别干等,排查方法在最后一节。
六、profiles:一份文件,按需点菜
有些服务不是每次都要:调试用的数据库管理面板(Adminer)、压测工具、一次性的数据导入脚本……把它们写进 compose 文件、又不想每次 up 都跟着启动,就用 profiles 给服务分组:
services: app: image: myapp:latest ports: - "8080:8080"
adminer: image: adminer:latest profiles: - debug ports: - "8081:8080"
importer: image: myapp:latest command: ./import-data.sh profiles: - tools行为规则很简单:
- 没写
profiles的服务(如app):永远是默认成员,普通docker compose up -d就启动。 - 写了
profiles的服务:默认不启动,只有显式点名才算数。
点名的方式有两种:
# 方式一:命令行参数,可以叠加多个docker compose --profile debug up -ddocker compose --profile debug --profile tools up -d
# 方式二:环境变量,适合写进当前 shell 或 CICOMPOSE_PROFILES=debug docker compose up -d还有一个省心细节:被依赖会自动激活。如果某个默认服务 depends_on 了带 profile 的服务,那个服务会被连带拉起来,不需要你再手动加 --profile。另外 docker compose --profile debug ps、down 等命令同理,操作带 profile 的服务时记得把 --profile 带上,不然会「找不到」它们。
适合 profiles 的场景再总结一下:
| 场景 | 典型成员 |
|---|---|
| 调试工具 | Adminer、日志查看器 |
| 一次性任务 | 迁移、导入、备份脚本 |
| 本地开发才需要的模拟服务 | 假邮件服务器、mock 接口 |
正式环境不用 profile、测试环境点开,一份文件两边跑。
七、多文件:base + override 拆开写
配置再大一点,就轮到多文件拆分了。约定是这样的:项目里同时存在 docker-compose.yml(或 compose.yaml)和 docker-compose.override.yml 时,执行 docker compose 命令会自动把 override 合并进来,不用任何参数。
典型分工:base 写公共部分,override 只写当前环境的差异:
# docker-compose.yml —— 公共配置,提交进 Gitservices: app: image: myapp:${APP_VERSION:-1.0.0} environment: - LOG_LEVEL=info# docker-compose.override.yml —— 本地开发专用,不提交services: app: ports: - "8080:8080" volumes: - ./src:/app/src environment: - LOG_LEVEL=debug本地 docker compose up -d 自动得到:镜像和版本来自 base,端口映射和源码挂载来自 override,LOG_LEVEL 被 override 的 debug 盖掉。
不用 override 约定、想完全自己指定文件时,用 -f(可多次,顺序很重要,后写的盖先写的):
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d合并规则记个大概就够日常用了:
- 单值选项(
image、command、单个environment键):后一个文件的值覆盖前面的。 - 映射类(
environment、labels):按 key 合并,同 key 后者胜。 - 列表类(
ports、volumes、expose):拼接而不是覆盖,所以两边都映射了端口会全部生效(端口冲突了会在启动时报错)。 - 完全不想合并、想让某个文件彻底接管,就别用
-f叠加,老实实只指定一份。
八、进阶命令:先看清楚,再动手
Compose 用熟了之后,下面这几个命令能帮你少踩很多坑:
校验并预览最终配置——排查问题的第一招,把插值、合并、多文件全部算完后的「最终版」打印出来:
docker compose config某个变量到底被替换成了什么、override 合并对不对,看这个输出一清二楚。只想确认语法没错不想看全文,可以加 -q(quiet,只报错不输出):
docker compose config -q只列服务名或卷名也有专门参数:docker compose config --services、docker compose config --volumes。
看日志:
# 跟随输出,只看最后 100 行,重启后接着排查最常用docker compose logs -f --tail=100
# 只看某个服务,并带时间戳docker compose logs -f -t app进容器执行命令:
# 进正在运行的容器开个 shell 调试docker compose exec app sh
# 不进交互,直接看容器里实际的环境变量(验证注入是否成功)docker compose exec app printenv看容器内的进程和整体状态:
docker compose topdocker compose ps
# 自定义输出格式,只看名字和状态,写脚本时很好用docker compose ps --format "table {{.Name}}\t{{.State}}\t{{.Status}}"
# 只看某个服务是否健康(输出里带 (healthy) / (unhealthy) 字样)docker compose ps app九、排障:两个高频问题
1. 变量没生效
按这个顺序查,基本都能定位:
- 先看最终配置:
docker compose config,找到对应服务,看变量被替换成了什么。如果这里就是错的,问题在插值层:检查.env在不在 compose 文件同目录、变量名有没有写错、是不是被 shell 里同名变量盖掉了(shell 优先级更高)。 - 再看容器内部:
docker compose exec <服务名> printenv | grep <变量名>。最终配置对、容器里没有,是注入层的问题:env_file路径写错、或者变量只写在.env里——再强调一遍,.env不进容器,要给容器用必须走env_file或environment。 - 改了没重启:改完
.env或 compose 文件后,已经跑着的容器不会自动更新,必须重新docker compose up -d让它重建,光restart不会重新读配置。
2. healthcheck 一直 unhealthy
docker compose ps 里状态一直 (health: starting) 或者 (unhealthy),这样查:
- 看健康检查的原始记录,Docker 把每次体检的退出码和输出都存了:
docker inspect --format='{{json .State.Health}}' <容器名>输出里的 Log 数组是最近几次体检记录,重点看 ExitCode 和 Output——命令不存在会报 executable file not found,连接失败会直接把错误打在 Output 里,一看便知。
- 手动在容器里跑一遍体检命令,复现它到底卡在哪:
docker compose exec db pg_isready -U app -d appdbecho $? # 0 是通,非 0 是不通- 对一下时间参数:服务本来就要启动 40 秒,你
start_period只给了 10 秒、retries又小,它会在还没就绪时就被判死刑。慢启动的服务(数据库首次初始化、Java 应用)把start_period给足、retries给大。 - 确认命令在镜像里存在:精简镜像没
curl是最常见的乌龙,按第四节警告里的方法换探针。
小结
- 变量三件套分工明确:
.env管插值、env_file批量注入、environment单个指定且优先级最高;shell 变量能临时盖过.env。 - 插值记住四件套:
${VAR:-默认值}给退路、${VAR:?报错}把必填项卡死、$$转义给容器留字面量。 - 密码不进 Git、不写死在 compose 里,进阶可用
secrets从文件读。 - healthcheck 回答「服务能不能用」,
depends_on的service_healthy把这个答案变成启动条件,数据库没就绪应用先等着,这是本篇最值得抄走的一招。 - profiles 管「要不要启动」,多文件管「不同环境怎么变」,再配合
docker compose config先预览再动手,Compose 的进阶用法就齐了。