Docker Compose 配置环境变量的完整实践——以 SmartVoice 为例
一句话概述:以 SmartVoice 全栈项目(Serverpod 后端 + 三个 Python 微服务 + PostgreSQL/Redis/MinIO)的
infra/docker-compose.yaml为真实样本,系统梳理 Docker Compose 配置环境变量的五种注入方式、${VAR}插值语法与优先级、.env自动加载规则、YAML 锚点复用,以及踩过的三个坑。
一、背景与目标
SmartVoice 是一个「录音 → ASR 转写 → LLM 分析 → 生成报告」的语音纪要产品,技术栈为:
- 后端:Serverpod(Dart),唯一对外入口,监听 8080/8081/8082
- 微服务:ASR(Whisper,8001)、Report(报告渲染,8002)、LLM(DeepSeek/GPT 调度,8003),均为 Python/FastAPI
- 基础设施:PostgreSQL(pgvector)、Redis、MinIO(S3 兼容对象存储)
目标有三:
- 一条命令跑通本地全栈:
docker compose -f infra/docker-compose.yaml up -d - 敏感信息不进 Git:真实密钥/密码通过
.env注入,仓库只保留.env.example模板 - 真机/模拟器可用:MinIO 预签名 URL 必须对客户端可达,而不是只对容器内部可达
下面结合 infra/docker-compose.yaml、infra/.env.example、infra/scripts/start-local.sh 的真实内容,拆解 Compose 配置环境变量的完整知识。
二、Docker Compose 配置环境变量的五种方式
方式一:environment: map 语法(字面量)
最常见写法,KEY: value 形式,值写死或插值。SmartVoice 的 postgres 服务就是典型:
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: postgres
POSTGRES_DB: smartvoice
POSTGRES_PASSWORD: smartvoice_dev_pwd # 仅本地开发,生产必须替换
map 语法清晰易读,适合「值固定、无需动态变化」的场景。注意 YAML 里纯数字值要加引号,否则会被解析成数字类型(如 SERVERPOD_DATABASE_PORT: "5432")。
方式二:environment: list 语法(透传宿主环境)
list 语法用 - KEY=value,如果只写 KEY 不写 =value,则直接把宿主机上的同名环境变量透传进容器:
environment:
- REDIS_REPLICATION_MODE=master # 写死
- LLM_DEEPSEEK_API_KEY # 只写 KEY:透传宿主的同名变量
SmartVoice 的 backend/smartvoice_server/docker-compose.yaml(Serverpod 生成的 dev/test 编排)里就用了 list 语法。
方式三:${VAR} 插值语法(Compose 核心)
这是 Compose 最强大的能力,也是 SmartVoice 用得最多的模式。插值发生在 Compose 客户端(宿主机)解析 compose 文件时,而不是容器运行时。三种形态:
| 语法 | 含义 | SmartVoice 实例 |
|---|---|---|
${VAR} |
直接用 VAR,未定义则报错 | — |
${VAR:-default} |
未定义或为空时用默认值 | ${ASR_WHISPER_MODEL:-large-v3} |
${VAR:?err} |
未定义时报错并提示 err | 脚本里的 : "${HMAC_SECRET:?...}" |
真实例子(infra/docker-compose.yaml 的 llm 服务):
llm:
environment:
LLM_DEFAULT_PROVIDER: ${LLM_DEFAULT_PROVIDER:-deepseek}
LLM_DEEPSEEK_API_KEY: ${LLM_DEEPSEEK_API_KEY:-} # 空默认:不填就不注入
LLM_DEEPSEEK_BASE_URL: ${LLM_DEEPSEEK_BASE_URL:-https://api.deepseek.com}
LLM_DEEPSEEK_MODEL: ${LLM_DEEPSEEK_MODEL:-deepseek-v4-flash}
${LLM_DEEPSEEK_API_KEY:-} 这种「空默认值」写法很实用:.env 里不填 key,该环境变量就是空串,服务端按「无 key → 回退/报错」处理,不会把占位符当真值发出去。
方式四:.env 文件自动加载 + .env.example 模板
${VAR} 的值从哪来?按优先级:宿主机 shell 环境变量 > 项目目录下的 .env 文件 > compose 文件里的 :-default。
关键点:Compose v2 默认在项目目录找 .env,而项目目录默认是 compose 文件所在目录(不是执行命令时的工作目录)。所以 SmartVoice 的用法是:
cp infra/.env.example infra/.env # 模板复制成真实配置,.env 已被 .gitignore 忽略
docker compose -f infra/docker-compose.yaml up -d
infra/.env.example 内容结构(节选):
# 真机/模拟器访问宿主 docker 服务时使用的 LAN IP
SV_LAN_HOST=
# 内部服务 HMAC 共享密钥
# 生成:openssl rand -hex 32
SV_INTERNAL_HMAC_SECRET=
# LLM 提供方(真实 E2E 需要真实 key)
LLM_DEEPSEEK_API_KEY=
ASR_WHISPER_MODEL=large-v3
模板只含注释和空值/开发默认值,真实密钥只在开发者本地的 .env,绝不提交。
方式五:Dockerfile 里的 ENV(运行时默认值,可被覆盖)
ENV 写在镜像里,是运行时默认值,会被 Compose 的 environment: 覆盖。优先级:environment: > 镜像 ENV > Dockerfile 里更早的 ENV。
SmartVoice 的 services/llm/Dockerfile 设了进程级默认:
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
backend/smartvoice_server/Dockerfile 则用 ENV + shell 展开传给启动命令:
ENV runmode=production
ENV serverid=default
ENTRYPOINT ./server --mode=$runmode --server-id=$serverid --logging=$logging --role=$role
这里 --mode=$runmode 是在容器启动时由 shell 展开的,所以 Compose 里 environment: runmode: development 就能覆盖镜像默认的 production。
加分项:YAML 锚点复用(x-* 扩展字段)
多个服务要共享同一个值(如内部 HMAC 密钥),复制粘贴容易不一致。SmartVoice 用 YAML 锚点 + 顶层 x-* 扩展字段一次定义、多处引用:
name: smartvoice
x-hmac-secret: &hmac_secret "${SV_INTERNAL_HMAC_SECRET:-a046d7fce0ff9e3a7905687606d0644b8176e30acd51b7e42ef8a6ff7c547d15}"
services:
serverpod:
environment:
SV_INTERNAL_HMAC_SECRET: *hmac_secret
asr:
environment:
ASR_INTERNAL_HMAC_SECRET: *hmac_secret
report:
environment:
REPORT_INTERNAL_HMAC_SECRET: *hmac_secret
llm:
environment:
LLM_INTERNAL_HMAC_SECRET: *hmac_secret
&hmac_secret 定义锚点、*hmac_secret 引用,四个服务共享同一密钥且保证一致。
三、踩坑与修复(核心)
1. 预签名 URL 的内外部地址混用,真机打不开
现象:后端能正常上传音频,但客户端(真机/模拟器)拿到的 MinIO 预签名 URL 打不开,报连接超时或 host 解析失败。
根因:MinIO 预签名 URL 的主机名来自签名时的请求 Host。Serverpod 容器内连 MinIO 用的是内部地址(
host.docker.internal或minio:9000),如果把这个地址原样塞进预签名 URL 返回给客户端,客户端根本解析不到容器网络里的地址。修复:把「对内连接地址」和「对外公开地址」拆成两个变量。
infra/docker-compose.yaml里:serverpod: environment: # 内部地址:serverpod 容器连 MinIO 用 SV_STORAGE_ENDPOINT: ${SV_LAN_HOST:-host.docker.internal} # 公开地址:只写进预签名 URL,必须能被客户端访问 SV_STORAGE_PUBLIC_ENDPOINT: ${SV_PUBLIC_HOST:-localhost} minio: environment: # 让 MinIO 自报对外可达地址,避免与签名 host 不一致 MINIO_SERVER_URL: ${SV_MINIO_PUBLIC_URL:-http://localhost:9000}再配合
start-local.sh自动探测局域网 IP(ipconfig getifaddr en0),真机用SV_LAN_HOST覆盖即可。可复用经验:凡是「生成签名 URL / 重定向地址」的服务,必须区分内部服务地址和外部公开地址,二者不能混用一个变量;对象存储类服务记得配置
SERVER_URL自报公开地址。
2. .env 放错目录,${VAR} 没展开
现象:
.env明明写了值,但 compose 文件里的${VAR}没被替换,服务拿到的还是空或默认值。根因:Compose v2 的
.env查找目录是项目目录(默认等于-f指定的 compose 文件所在目录),不是执行docker compose时的 shell 工作目录。把.env放在仓库根目录、却在infra/下用-f infra/docker-compose.yaml启动,就加载不到。修复:把
.env放在infra/目录下,并在infra/README.md写清楚:cp infra/.env.example infra/.env docker compose -f infra/docker-compose.yaml up -d可复用经验:
.env的位置跟随 compose 文件所在目录(可用--env-file显式指定);文档里把「模板复制到哪」写死,避免协作时各人放不同位置。
3. 框架自带的密钥注入约定,别自造变量名
现象:compose 文件里出现
SERVERPOD_PASSWORD_serviceSecret、SERVERPOD_PASSWORD_jwtHmacSha512PrivateKey这种「看起来很奇怪」的变量名,不知道值最终落到哪。根因:这是 Serverpod 框架的约定——用
SERVERPOD_PASSWORD_<passwordName>形式的环境变量覆盖config/passwords.yaml里的对应密码条目,而不用改配置文件。修复(也是经验):框架有约定的命名规则时,先查框架文档/源码确认,再按约定注入,不要自己发明变量名;否则变量名对不上,配置不生效还极难排查。
SERVERPOD_PASSWORD_serviceSecret: ${SERVERPOD_SERVICE_SECRET:-smartvoice_dev_service_secret} SERVERPOD_PASSWORD_jwtHmacSha512PrivateKey: ${SERVERPOD_JWT_HMAC_SHA512_PRIVATE_KEY:-smartvoice_dev_jwt_hmac_sha512_private_key}
4. Compose 插值发生在宿主侧,容器内不再二次展开
- 现象:有人以为
${VAR}会在容器里被 shell 再次展开,结果在.env里写了A=${B}期望链式展开,实际拿到的就是字面量${B}。 - 根因:
${VAR}插值是 Compose 客户端(宿主机) 在解析 compose 文件时一次性完成的,值传进容器后就是最终字符串,容器进程的 shell 不会对environment里的值做第二次${}展开。 - 修复/可复用经验:需要组合值时,在宿主侧(shell 或
.env)先算好;不要在.env里写${A}期望链式展开。
四、结论
- 五种注入方式各有分工:固定值用 map
environment:,透传宿主变量用 list 语法只写 KEY,需要默认值/可覆盖用${VAR:-default},敏感值走.env(模板.env.example),镜像默认值用 DockerfileENV。 - 优先级记住一条链:宿主 shell 环境变量 > 项目目录
.env> compose 文件:-default> 镜像ENV;且${VAR}只在宿主机展开一次。 .env的位置跟 compose 文件走(-f所在目录,非 shell 工作目录),模板复制的路径要写进 README。- 签名 URL 类配置必须拆「对内 / 对外」两套地址,并给对象存储配
SERVER_URL自报公开地址。 - 多服务共享同一密钥用 YAML 锚点(
x-*扩展字段),一次定义多处引用,避免不一致;框架有变量命名约定时先查约定再注入。