4535 字
23 分钟
Docker Compose 进阶:环境变量、健康检查与多环境
2026-11-15

第一篇 《如何在Linux部署Docker&Compose》 里,我们用一份最基础的 docker-compose.yml 把 Nginx 和 Redis 跑了起来。真到了日常用的时候,问题很快就来了:数据库密码写死在 compose 文件里,换台机器就要改一遍;应用比数据库先启动,数据库还没初始化完,应用连不上直接报错退出;同一份配置想在测试机和正式机上跑,端口和镜像标签又不一样……这篇就专门解决这三个问题:环境变量(配置和文件分离)、健康检查 + 启动顺序(让服务按正确的节奏起来)、profiles 与多文件(一份配置多处用)。全篇的例子都可以直接抄。

一、环境变量三件套:先分清谁是谁#

Compose 里跟「变量」有关的东西有三个,新手最容易混:environment、env_file、项目根目录的 .env 文件。它们的作用完全不同,先看对比表:

方式写在哪作用会不会进容器
.env 文件项目目录(和 compose 文件同级)给 compose 文件本身做变量插值,替换 ${...} 占位符不会,它的值不会自动变成容器环境变量
env_filecompose 里服务的属性,指向一个或多个 env 文件把文件里的键值对批量注入容器内部的环境变量会
environmentcompose 里服务的属性在 compose 文件里直接写键值对,注入容器内部的环境变量会

一句话记忆:.env 是给 compose 文件看的,env_file 和 environment 是给容器看的。

1. .env 文件:只负责插值#

在 compose 文件同目录建一个 .env:

.env
NGINX_PORT=8080
IMAGE_TAG=1.27
DB_PASSWORD=example_password_123

compose 文件里用 ${...} 引用它:

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:批量把变量塞进容器#

应用真正要读的配置(数据库地址、密码、开关等)一般这样给:

app.env
DATABASE_URL=postgres://app:secret@db:5432/appdb
LOG_LEVEL=info
FEATURE_X=on
services:
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: debug

4. 三者优先级#

当同一个变量在多处出现时,谁说了算:

  • 注入容器时:environment 的优先级高于 env_file。两边都有 LOG_LEVEL,容器里拿到的是 environment 写的值。
  • 插值时:${...} 的取值来源优先级是 宿主机 shell 环境变量 > .env 文件。也就是说,你临时想换个端口,不用改 .env,直接:
Terminal window
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.txt

db_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: 20s

MySQL(镜像自带 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: 15s

curl -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 才启动。三个条件一次全用上了。

两个必须知道的边界:

  1. depends_on 只管「启动这一刻」。运行中途 db 重启了,app 不会跟着等它健康,要靠应用自身的连接重试兜底——编排解决顺序,代码解决健壮性,两回事。
  2. 短语法在 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 的服务:默认不启动,只有显式点名才算数。

点名的方式有两种:

Terminal window
# 方式一:命令行参数,可以叠加多个
docker compose --profile debug up -d
docker compose --profile debug --profile tools up -d
# 方式二:环境变量,适合写进当前 shell 或 CI
COMPOSE_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 —— 公共配置,提交进 Git
services:
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(可多次,顺序很重要,后写的盖先写的):

Terminal window
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 用熟了之后,下面这几个命令能帮你少踩很多坑:

校验并预览最终配置——排查问题的第一招,把插值、合并、多文件全部算完后的「最终版」打印出来:

Terminal window
docker compose config

某个变量到底被替换成了什么、override 合并对不对,看这个输出一清二楚。只想确认语法没错不想看全文,可以加 -q(quiet,只报错不输出):

Terminal window
docker compose config -q

只列服务名或卷名也有专门参数:docker compose config --services、docker compose config --volumes。

看日志:

Terminal window
# 跟随输出,只看最后 100 行,重启后接着排查最常用
docker compose logs -f --tail=100
# 只看某个服务,并带时间戳
docker compose logs -f -t app

进容器执行命令:

Terminal window
# 进正在运行的容器开个 shell 调试
docker compose exec app sh
# 不进交互,直接看容器里实际的环境变量(验证注入是否成功)
docker compose exec app printenv

看容器内的进程和整体状态:

Terminal window
docker compose top
docker compose ps
# 自定义输出格式,只看名字和状态,写脚本时很好用
docker compose ps --format "table {{.Name}}\t{{.State}}\t{{.Status}}"
# 只看某个服务是否健康(输出里带 (healthy) / (unhealthy) 字样)
docker compose ps app

九、排障:两个高频问题#

1. 变量没生效#

按这个顺序查,基本都能定位:

  1. 先看最终配置:docker compose config,找到对应服务,看变量被替换成了什么。如果这里就是错的,问题在插值层:检查 .env 在不在 compose 文件同目录、变量名有没有写错、是不是被 shell 里同名变量盖掉了(shell 优先级更高)。
  2. 再看容器内部:docker compose exec <服务名> printenv | grep <变量名>。最终配置对、容器里没有,是注入层的问题:env_file 路径写错、或者变量只写在 .env 里——再强调一遍,.env 不进容器,要给容器用必须走 env_file 或 environment。
  3. 改了没重启:改完 .env 或 compose 文件后,已经跑着的容器不会自动更新,必须重新 docker compose up -d 让它重建,光 restart 不会重新读配置。

2. healthcheck 一直 unhealthy#

docker compose ps 里状态一直 (health: starting) 或者 (unhealthy),这样查:

  1. 看健康检查的原始记录,Docker 把每次体检的退出码和输出都存了:
Terminal window
docker inspect --format='{{json .State.Health}}' <容器名>

输出里的 Log 数组是最近几次体检记录,重点看 ExitCode 和 Output——命令不存在会报 executable file not found,连接失败会直接把错误打在 Output 里,一看便知。

  1. 手动在容器里跑一遍体检命令,复现它到底卡在哪:
Terminal window
docker compose exec db pg_isready -U app -d appdb
echo $? # 0 是通,非 0 是不通
  1. 对一下时间参数:服务本来就要启动 40 秒,你 start_period 只给了 10 秒、retries 又小,它会在还没就绪时就被判死刑。慢启动的服务(数据库首次初始化、Java 应用)把 start_period 给足、 retries 给大。
  2. 确认命令在镜像里存在:精简镜像没 curl 是最常见的乌龙,按第四节警告里的方法换探针。

小结#

  • 变量三件套分工明确:.env 管插值、env_file 批量注入、environment 单个指定且优先级最高;shell 变量能临时盖过 .env。
  • 插值记住四件套:${VAR:-默认值} 给退路、${VAR:?报错} 把必填项卡死、$$ 转义给容器留字面量。
  • 密码不进 Git、不写死在 compose 里,进阶可用 secrets 从文件读。
  • healthcheck 回答「服务能不能用」,depends_on 的 service_healthy 把这个答案变成启动条件,数据库没就绪应用先等着,这是本篇最值得抄走的一招。
  • profiles 管「要不要启动」,多文件管「不同环境怎么变」,再配合 docker compose config 先预览再动手,Compose 的进阶用法就齐了。
Docker Compose 进阶:环境变量、健康检查与多环境
https://blog.anexns.com/posts/docker-compose-advanced/
作者
Anexns
发布于
2026-11-15
许可协议
CC BY-NC-SA 4.0