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 兼容对象存储)

目标有三:

  1. 一条命令跑通本地全栈:docker compose -f infra/docker-compose.yaml up -d
  2. 敏感信息不进 Git:真实密钥/密码通过 .env 注入,仓库只保留 .env.example 模板
  3. 真机/模拟器可用: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} 期望链式展开。

四、结论

  1. 五种注入方式各有分工:固定值用 map environment:,透传宿主变量用 list 语法只写 KEY,需要默认值/可覆盖用 ${VAR:-default},敏感值走 .env(模板 .env.example),镜像默认值用 Dockerfile ENV。
  2. 优先级记住一条链:宿主 shell 环境变量 > 项目目录 .env > compose 文件 :-default > 镜像 ENV;且 ${VAR} 只在宿主机展开一次。
  3. .env 的位置跟 compose 文件走(-f 所在目录,非 shell 工作目录),模板复制的路径要写进 README。
  4. 签名 URL 类配置必须拆「对内 / 对外」两套地址,并给对象存储配 SERVER_URL 自报公开地址。
  5. 多服务共享同一密钥用 YAML 锚点(x-* 扩展字段),一次定义多处引用,避免不一致;框架有变量命名约定时先查约定再注入。