在使用 Docker Compose 部署多服务应用时,敏感信息管理、YAML 语法理解以及顶层配置项的正确使用,是开发者绕不开的三大核心问题。很多初学者困惑于:为什么密码文件在 Dockerfile 中找不到,容器却能访问?YAML 的三种数据结构到底只作用于“值”,还是所有节点?Compose 的顶层键究竟有几个?本文将以一个真实的 PHP + MariaDB + phpMyAdmin 项目为例,系统梳理这些知识点,纠正常见误解,并给出生产级实践建议。

一、Docker Compose Secrets 机制:运行时注入,与构建无关

1.1 为什么 Dockerfile 里找不到密码文件?

在项目 compose.yaml 中,顶层定义了 secret:

secrets:
  db-password:
    file: db/password.txt

服务中引用:

services:
  db:
    secrets:
      - db-password
    environment:
      - MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-password

Dockerfile 中没有任何 COPY db/password.txt 的操作,但容器内却能读取 /run/secrets/db-password。这是因为 Docker Compose 在运行时(docker compose up)将宿主机文件注入容器,完全绕过了 docker build 阶段。

1.2 核心机制:构建时 vs 运行时

阶段

命令

涉及文件

是否写入镜像

构建时

docker build

Dockerfile 上下文中的文件

是,成为镜像层

运行时

docker compose up

宿主机 secret 文件

否,仅注入容器

Secret 注入流程:

  1. Compose 解析顶层 secrets 定义,找到宿主机文件 db/password.txt。

  2. 创建容器时,向 Docker Engine 传递 secret 配置。

  3. 容器内出现 /run/secrets/db-password,内容即宿主机文件内容。

  4. 应用通过环境变量(如 MARIADB_ROOT_PASSWORD_FILE)得知文件路径,读取密码。

1.3 安全设计
  • 不写入镜像:密码不会出现在镜像层,推送 Registry 时不泄露。

  • 运行时挂载:单机 Compose 通常以只读 bind mount 方式挂载;Swarm 模式则使用 tmpfs 内存文件系统。

  • 路径解析:file: db/password.txt 相对于 compose.yaml 所在目录。

1.4 密码修改与轮换

修改宿主机密码文件后,运行中的容器不会自动更新。 必须重建容器:

docker compose up -d --force-recreate

更关键的问题:数据库首次初始化时,MARIADB_ROOT_PASSWORD_FILE 才生效。一旦 db-data volume 存在,密码已写入 MySQL 系统表,修改 secret 不会自动更改数据库密码。

开发环境:删除 volume 重新初始化:

docker compose down -v
docker compose up -d

生产环境方案:

  • 方案 A:轮换脚本——启动临时容器执行 ALTER USER。

  • 方案 B:版本化文件——每次使用新文件名,实现秒级回滚。

  • 方案 C:外部 Secret Manager——如 Vault、AWS Secrets Manager,需 Swarm 或 Kubernetes 支持。

示例:版本化命名

不覆盖旧密码文件,每次轮换创建新的版本,完整流程:

# ===== 第一步:创建新版本密码文件 =====
echo "newpass" > db/password.v2.txt

# ===== 第二步:在数据库内修改密码 =====
# 这一步是必须的,无法跳过
docker compose exec db mariadb -u root -pdemo -e \
  "ALTER USER 'root'@'localhost' IDENTIFIED BY 'newpass'; \
   ALTER USER 'root'@'%' IDENTIFIED BY 'newpass'; \
   FLUSH PRIVILEGES;"

# ===== 第三步:修改 compose.yaml =====
# 将所有 db-password-v1 → db-password-v2
# 将 file: db/password.v1.txt → file: db/password.v2.txt

# ===== 第四步:重建所有相关容器 =====
docker compose up -d --force-recreate

回滚流程:

# 出问题了!需要回滚

# 1. 在数据库内改回旧密码
docker compose exec db mariadb -u root -pnewpass -e \
  "ALTER USER 'root'@'localhost' IDENTIFIED BY 'demo'; \
   ALTER USER 'root'@'%' IDENTIFIED BY 'demo'; \
   FLUSH PRIVILEGES;"

# 2. 修改 compose.yaml,改回 db-password-v1
#    旧文件 db/password.v1.txt 还在!不需要重新创建

# 3. 重建容器
docker compose up -d --force-recreate

优势:旧版本文件保留,出问题可以立即回滚。

最小权限原则:避免使用 root,创建应用专用账号。

db:
  environment:
    - MARIADB_USER=appuser  # 应用专用账号
    - MARIADB_PASSWORD_FILE=/run/secrets/db-password
    - MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-root-password # root 仅管理用

二、YAML 语法:节点树的三种基本结构

2.1 YAML 是一棵节点树

YAML 文档本质上是一棵节点树。每个节点只能是三种结构之一:

结构

说明

写法示例

标量 (Scalar)

单个值(字符串、数字、布尔)

image: mariadb

映射 (Mapping)

键值对集合(字典/对象)

db: { condition: service_healthy }

序列 (Sequence)

有序列表(数组)

- db 或 [db]

在 YAML 中,字符串不是必须加引号的,引号只在需要消除歧义或明确类型时才必须使用。

示例:下面这种写法都合法

流式映射(Flow Mapping)

depends_on:
  db: { condition: service_healthy }

块式写法

depends_on:
  db:
    condition: service_healthy
2.2 三种结构作用于所有节点,不限于“值”

Compose 的键一般是标量字符串,平时只需留意值的类型即可。不过从规范上讲,根节点、嵌套映射和序列元素都可以是任意结构。

services:          # 根映射的键,其值是映射
  server:          # services 映射的键,其值是映射
    ports:         # server 映射的键,其值是序列
      - "9000:80"  # 序列元素是标量

对应节点树:

根节点(映射)
├── services(映射)
│   ├── server(映射)
│   │   ├── ports(序列)
│   │   │   └── "9000:80"(标量)
2.3 块序列 vs 流序列
  • 块序列:用 - 开头,适合长列表。

  • 流序列:用 [],适合短列表。

两者功能等价,仅是风格差异。示例:

块序列

healthcheck:
  test:
    [
      "CMD",
      "/usr/local/bin/healthcheck.sh",
      "--su-mysql",
      "--connect",
      "--innodb_initialized",
    ]

流序列

healthcheck:
  test:
    - CMD
    - /usr/local/bin/healthcheck.sh
    - --su-mysql
    - --connect
    - --innodb_initialized
2.4 depends_on 的语义差异
# 映射形式:指定条件
depends_on:
  db:
    condition: service_healthy   # 附加条件:等 db 健康后才启动

# 序列形式:仅声明依赖
depends_on:
  - db                           # 仅声明依赖,无条件(默认 service_started)

三、Docker Compose 顶层键

当前 Compose 规范定义的顶层键共9个:

顶层键

是否必填

说明

version

否(已废弃)

仅向后兼容,Compose 会警告,不再用于 schema 选择

name

否

自定义项目名称

include

否

引入其他 Compose 文件,实现模块化

services

是

定义容器服务,唯一必填项

models

否

新增,声明 AI 模型依赖(需 Compose 2.38.0+)

networks

否

定义命名网络

volumes

否

定义持久化数据卷

secrets

否

定义敏感信息

configs

否

定义只读配置文件

注意:服务内的 secrets 是列表(引用顶层定义),而顶层 secrets 是映射(定义 secret 本身)。

四、实战:示例解析

示例项目结构
├── docker-php-sample/
│ ├──  db/
│ │ └──password.txt
│ ├── src/
│ │ └──database.php
│ │ └──hello.php
│ ├──  tests/
│ ├──  .dockerignore
│ ├──  .gitignore
│ ├──  compose.yaml
│ ├──  composer.json
│ ├──  composer.lock
│ ├──  Dockerfile
│ ├──  README.md

compose.yaml

# ============================================================
# Docker Compose 配置文件 - PHP + MariaDB + phpMyAdmin 示例
# ============================================================
# 本文件演示了 Docker Compose 的核心功能:
#   - 多服务编排(PHP 应用、MariaDB 数据库、phpMyAdmin 管理界面)
#   - 服务依赖与健康检查
#   - Docker Secrets 安全管理敏感数据
#   - 持久化卷(Volumes)
# ============================================================

# ------------------------------------------------------------
# services: 定义所有容器服务
# ------------------------------------------------------------
services:

  # ----------------------------------------------------------
  # server 服务:自定义 PHP 应用容器
  # ----------------------------------------------------------
  server:
    build:
      context: .                              # 构建上下文为当前目录,使用当前目录下的 Dockerfile 构建镜像
    ports:
      - "9000:80"                             # 端口映射:宿主机 9000 端口 → 容器内 80 端口(访问 localhost:9000 即可到达应用)
    depends_on:
      db:
        condition: service_healthy            # 依赖 db 服务,且必须等 db 的健康检查通过后才启动 server
    secrets:
      - db-password                           # 引用顶层 secrets 中定义的 db-password,该 secret 会以文件形式挂载到容器的 /run/secrets/db-password
    environment:
      - PASSWORD_FILE_PATH=/run/secrets/db-password  # 告诉应用从 /run/secrets/db-password 文件中读取数据库密码
      - DB_HOST=db                            # 数据库主机名,对应 db 服务的服务名(Docker 内部 DNS 自动解析)
      - DB_NAME=example                       # 数据库名称
      - DB_USER=root                          # 数据库用户名

  # ----------------------------------------------------------
  # db 服务:MariaDB 数据库容器
  # ----------------------------------------------------------
  db:
    image: mariadb                            # 使用官方 mariadb 镜像
    restart: always                           # 容器退出时始终自动重启(适用于生产环境的数据库服务)
    user: root                                # 以 root 用户身份运行容器内的主进程
    secrets:
      - db-password                           # 引用 db-password secret,注入到 db 容器的 /run/secrets/db-password
    volumes:
      - db-data:/var/lib/mysql                # 持久化卷映射:将命名卷 db-data 挂载到容器内 MySQL 数据目录,防止容器重建后数据丢失
    environment:
      - MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-password  # MariaDB 官方镜像支持 _FILE 后缀,从文件读取 root 密码,避免在环境变量中明文暴露密码
      - MARIADB_DATABASE=example              # 容器首次启动时自动创建名为 example 的数据库
    expose:
      - 3306                                  # 仅在 Docker 内部网络暴露 3306 端口,不映射到宿主机(仅其他容器可访问)
    healthcheck:
      test:
        [
          "CMD",                              # 健康检查方式:在容器内执行命令
          "/usr/local/bin/healthcheck.sh",    # MariaDB 官方镜像自带的健康检查脚本
          "--su-mysql",                       # 以 mysql 用户身份执行检查
          "--connect",                        # 检查是否能成功连接到数据库
          "--innodb_initialized",             # 检查 InnoDB 存储引擎是否已初始化完成
        ]
      interval: 10s                           # 每隔 10 秒执行一次健康检查
      timeout: 5s                             # 单次健康检查的超时时间为 5 秒
      retries: 5                              # 连续 5 次检查失败后,才将容器标记为 unhealthy

  # ----------------------------------------------------------
  # phpmyadmin 服务:数据库可视化管理界面
  # ----------------------------------------------------------
  phpmyadmin:
    image: phpmyadmin                         # 使用官方 phpmyadmin 镜像
    ports:
      - "9090:80"                             # 端口映射:宿主机 9090 端口 → 容器内 80 端口(访问 localhost:9090 打开 phpMyAdmin 界面)
    depends_on:
      - db                                    # 依赖 db 服务(仅确保启动顺序,不等待健康检查)
    environment:
      - PMA_HOST=db                           # 指定 phpMyAdmin 连接的数据库主机名为 db(对应 db 服务的服务名)

# ------------------------------------------------------------
# volumes: 定义命名卷,用于数据持久化
# ------------------------------------------------------------
volumes:
  db-data:                                    # 命名卷 db-data,由 Docker 自动管理存储位置,用于保存 MariaDB 的数据文件

# ------------------------------------------------------------
# secrets: 顶层 secrets 定义,从宿主机文件读取敏感数据
# ------------------------------------------------------------
# Docker Secrets 是管理敏感信息(如密码、证书、API Key)的安全机制:
#   - Secret 以只读文件形式挂载到容器的 /run/secrets/<secret名> 目录
#   - 避免在 compose 文件或环境变量中明文写入密码
#   - 与环境变量相比,secrets 不会被 docker inspect 轻易暴露
# ------------------------------------------------------------
secrets:
  db-password:                                # 定义名为 db-password 的 secret
    file: db/password.txt                     # 从宿主机 compose.yaml 同目录下的 db/password.txt 文件读取 secret 的内容

Dockerfile

FROM composer:lts as deps

WORKDIR /app

RUN --mount=type=bind,source=composer.json,target=composer.json \
    --mount=type=bind,source=composer.lock,target=composer.lock \
    --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-interaction

FROM php:8.2-apache as final

RUN docker-php-ext-install pdo pdo_mysql
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

COPY --from=deps app/vendor /var/www/html/vendor
COPY ./src /var/www/html/

USER www-data

更多源码内容请见:https://github.com/docker/docker-php-sample/

五、常见误区与验证方法

常见误区
  1. 误以为 secrets 会写入镜像:不会,运行时注入。

  2. 误以为修改密码文件后数据库密码自动更新:不会,需手动轮换或重建 volume。

  3. 误以为 YAML 三种结构只针对值:它们作用于所有节点。

验证工具

总结

本文系统梳理了 Docker Compose 的三大核心知识:

  1. Secrets 机制:运行时从宿主机注入容器,不经过 Dockerfile 构建,不写入镜像;修改密码后需重建容器,数据库密码需通过轮换脚本或删除 volume 更新。

  2. YAML 节点树:文档是一棵节点树,每个节点可为标量、映射或序列,三种结构递归嵌套,不限于“值”。

  3. 顶层键规范:当前共 9 个顶层键(version 废弃、name、include、services 必填、models、networks、volumes、secrets、configs)。