1. 从多个 docker run 走向可维护的应用

前五篇已经分别处理了镜像、容器、Dockerfile、网络和持久化。单独看每一项并不复杂,但当一个真实应用同时包含 Web 服务、后台任务、数据库、缓存、反向代理、多个网络和数据卷时,继续依靠手工执行 docker run 会迅速失控。

假设要启动一个最小业务系统,至少要回答下面这些问题:

  • 每个容器使用什么镜像,是否需要本地构建?
  • 容器名称、重启策略、启动命令和环境变量是什么?
  • 哪些端口需要发布到宿主机,哪些服务只能在内部网络访问?
  • 数据库和缓存应该连接哪个网络?
  • 数据放在哪个命名卷,配置文件是否只读挂载?
  • 应用什么时候算真正可用,依赖未就绪时如何处理?
  • 变更镜像、配置或环境变量后,哪些容器应该重建?
  • 系统发生异常时,如何查看统一状态、日志和最终生效配置?

如果把这些信息散落在 Shell 历史、Wiki 片段和个人经验里,部署无法审查,也无法可靠复现。Docker Compose 的价值,就是把一个多容器应用声明为一个项目:服务、网络、卷、配置和依赖关系集中写入 compose.yaml,再由 docker compose 命令统一完成校验、创建、启动、观察、更新和清理。

本文采用“多服务实战 walkthrough + 运维排障 runbook”的形式。目标不是罗列 Compose 字段,而是建立一条可以在开发、测试和单机生产场景复用的编排链路。读完后,你应该能够理解 Compose 项目模型,编写符合当前 Compose Specification 的配置,使用健康检查和条件依赖降低启动风险,并根据最终配置、状态、日志、网络和卷证据定位常见故障。

本文以 Docker Engine 与 Compose 插件为基线,统一使用带空格的 docker compose 命令。旧式 docker-compose 独立命令以及为了兼容旧实现而常见的顶层 version: "3" 写法,不作为当前主线。具体能力仍应以本机 docker compose version 和官方文档为准。

2. Compose 的核心心智模型

Compose 不是另一套容器运行时。真正创建网络、卷和容器的仍然是 Docker Engine;Compose 是声明式配置和项目级生命周期管理层。

这张图把编排层、应用层、数据层和运行层分开。后文所有字段最终都在描述四件事:运行哪些服务、它们如何连接、状态如何保存,以及如何判断服务可用。

2.1 Project:一组资源的管理边界

一次 Compose 部署首先是一个项目。项目名会影响容器、默认网络和命名卷等资源的名称。默认情况下,项目名通常从 Compose 文件所在目录推导,也可以通过 -pCOMPOSE_PROJECT_NAME 或配置中的顶层 name 指定。

同一份配置可以启动两个相互隔离的实验环境:

bash
docker compose -p shop-dev up -d
docker compose -p shop-test up -d
docker compose -p shop-dev ps
docker compose -p shop-test ps

两组服务使用相同的服务名,但资源归属不同项目。生产环境应显式固定项目名,不要让目录改名意外改变资源名称,更不要在排障时把不同项目的同名容器混在一起。

2.2 Service:可复制的运行定义

services 下的每个条目是一种服务定义,例如 proxywebworkerdbredis。服务描述镜像、构建方式、命令、环境变量、挂载、网络、健康检查等信息。服务可以对应一个或多个容器实例。

服务名同时是同一 Compose 网络内的 DNS 名称。应用连接数据库时,主机名应该写 db,而不是容器临时 IP,也不是 127.0.0.1

text
postgresql://app:password@db:5432/app

容器里的 127.0.0.1 指向容器自身。把数据库地址写成 localhost 是多容器应用最常见的错误之一。

2.3 Network:服务可见性的边界

如果不声明网络,Compose 会为项目创建一个默认网络,并把服务接入其中。简单实验可以使用默认网络;更清晰的生产设计应显式划分网络:

  • frontend:反向代理与 Web 应用通信。
  • backend:Web、Worker、数据库和缓存通信。
  • 数据库与缓存不接入 frontend,也不发布宿主机端口。

网络分区不能替代主机防火墙和应用鉴权,但能减少不必要的服务互访,帮助读者从配置中直接看出流量边界。

2.4 Volume:数据生命周期的边界

Compose 可以声明命名卷,并把卷挂载到服务的数据目录。容器更新、重建或删除时,命名卷默认不会跟着丢失。数据库卷与容器生命周期解耦,是多容器应用可维护的基础。

需要特别区分:

bash
docker compose down

默认删除项目容器和网络,但保留命名卷。下面的命令会额外删除声明的命名卷,可能导致数据不可恢复:

bash
docker compose down -v

生产操作手册应把 -v 视为破坏性参数,不应出现在日常重启命令中。

2.5 配置与密钥边界

环境变量适合短小的运行参数。较长的配置文件可以使用只读 bind mount 或 Compose configs;敏感值应优先由外部密钥系统、受控文件或运行平台注入。

不要把真实密码直接提交到 compose.yaml,也不要因为 .env 不会写进镜像,就误以为它天然安全。.env 仍是本地明文文件,需要设置权限、加入 .gitignore,并纳入密钥轮换流程。

3. 实验场景与目录设计

本次实验使用一个简化的 Python Web 应用,配合 PostgreSQL、Redis、后台 Worker 和 Nginx。重点是 Compose 编排方法,不依赖特定业务代码。

项目 建议
操作系统 Ubuntu 22.04/24.04 LTS,或安装 Docker Desktop 的开发机
Docker 当前受支持的 Docker Engine 或 Docker Desktop
Compose Docker Compose v2 插件
文件名 compose.yaml
网络 frontendbackend 两个自定义 bridge
持久化 PostgreSQL 使用命名卷 pgdata
暴露面 只把 Nginx 的 8080 端口发布到宿主机

确认环境:

bash
docker version
docker compose version
docker info

如果 docker compose version 不可用,应按 Docker 官方安装文档安装 Compose 插件,不要为了临时通过而从未知来源下载旧二进制。

创建目录:

bash
mkdir -p ~/compose-lab/{app,nginx}
cd ~/compose-lab

目录结构:

text
compose-lab/
├── compose.yaml
├── .env.example
├── .gitignore
├── app/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app.py
└── nginx/
    └── default.conf

.gitignore 至少包含:

gitignore
.env
*.log
__pycache__/
.pytest_cache/

仓库中提交 .env.example,只保留变量名和安全示例;实际 .env 由部署流程生成或从密钥管理系统下发。

4. 准备应用镜像和反向代理

创建 app/requirements.txt

text
flask==3.1.1
gunicorn==23.0.0
psycopg[binary]==3.2.9
redis==6.2.0

版本号只是实验基线,正式使用前应根据包仓库、兼容性和安全扫描结果更新。

创建 app/app.py

python
import os
from flask import Flask, jsonify
import psycopg
import redis

app = Flask(__name__)
DATABASE_URL = os.environ["DATABASE_URL"]
REDIS_URL = os.environ["REDIS_URL"]

@app.get("/")
def index():
    return jsonify(service="compose-lab", status="ok")

@app.get("/health")
def health():
    checks = {}
    try:
        with psycopg.connect(DATABASE_URL, connect_timeout=2) as conn:
            with conn.cursor() as cur:
                cur.execute("SELECT 1")
                checks["database"] = cur.fetchone()[0] == 1
    except Exception:
        checks["database"] = False

    try:
        checks["redis"] = redis.from_url(
            REDIS_URL, socket_connect_timeout=2
        ).ping()
    except Exception:
        checks["redis"] = False

    return jsonify(checks=checks), 200 if all(checks.values()) else 503

创建 app/Dockerfile

dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app
RUN useradd --create-home --uid 10001 appuser

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .

USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]

创建 nginx/default.conf

nginx
server {
    listen 80;

    location / {
        proxy_pass http://web:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

反向代理使用服务名 web 访问应用,不需要知道应用容器 IP。即使 web 因重建而获得新 IP,只要服务名和网络不变,Docker 内置 DNS 会继续提供名称解析。

5. 编写当前规范的 compose.yaml

yaml
name: compose-lab

services:
  proxy:
    image: nginx:1.28-alpine
    ports:
      - "127.0.0.1:${APP_PORT:-8080}:80"
    volumes:
      - type: bind
        source: ./nginx/default.conf
        target: /etc/nginx/conf.d/default.conf
        read_only: true
    depends_on:
      web:
        condition: service_healthy
    networks: [frontend]
    restart: unless-stopped

  web:
    build:
      context: ./app
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://redis:6379/0
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - >-
          import urllib.request;
          urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)
      interval: 10s
      timeout: 4s
      retries: 6
      start_period: 20s
    networks: [frontend, backend]
    restart: unless-stopped

  worker:
    build:
      context: ./app
    command:
      - python
      - -c
      - >-
        import time;
        print('worker started', flush=True);
        time.sleep(86400)
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://redis:6379/0
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks: [backend]
    restart: unless-stopped

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test:
        - CMD-SHELL
        - pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s
    networks: [backend]
    restart: unless-stopped

  redis:
    image: redis:8-alpine
    command: ["redis-server", "--appendonly", "yes"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 10
    networks: [backend]
    restart: unless-stopped

networks:
  frontend:
  backend:
    internal: true

volumes:
  pgdata:

这份配置没有顶层 version 字段。当前 Compose Specification 由 Compose 实现解析,旧式版本号不再是表达新配置的必要入口。

5.1 只发布入口端口

只有 proxy 声明 portswebdbredis 只通过内部网络提供服务。示例绑定到 127.0.0.1,适合本机验证或由宿主机反向代理继续转发。

如需局域网访问,应按安全评审结果绑定明确的业务网卡地址,并同步检查主机防火墙、云安全组和上游 ACL。不要因为访问不通就直接开放所有地址。

5.2 depends_on 不等于业务永远可用

短语法依赖主要表达创建和启动顺序,不能自动证明数据库已经可以接受连接。使用 condition: service_healthy 时,Compose 可以等待依赖健康检查通过后再启动依赖方。

但这仍然不是业务容错的替代品。数据库可能在应用启动后重启,网络也可能瞬时中断。应用自身仍需具备连接超时、重试、退避、连接池回收和可观测性。

5.3 健康检查要接近真实可用性

只检查进程是否存在,往往会得到“容器健康但业务不可用”的假象。Web 服务的 /health 同时检查 PostgreSQL 与 Redis,因此更接近实际就绪状态。

健康检查也不能无限复杂。外部支付、短信或第三方 API 短暂异常时,如果健康检查把本服务判定为失败,可能触发不必要的重启风暴。应区分进程存活、流量就绪和外部依赖健康。

5.4 双美元符号避免主机提前插值

数据库健康检查中的 $${POSTGRES_USER} 使用双美元符号,是为了把变量表达式留给容器内 Shell,而不是让 Compose 在主机侧先替换。变量到底在哪一层展开,是 Compose 配置中非常容易出错的细节。

6. 环境变量与最终配置

创建 .env.example

dotenv
APP_PORT=8080
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=replace-me

生成实验用 .env

bash
cp .env.example .env
chmod 600 .env

生产环境不要沿用示例密码。应由部署流程注入随机高强度值,并记录轮换、回滚和灾难恢复方法。

Compose 的变量来源可能包括 Shell 环境、.env--env-file、配置中的 environmentenv_file。复杂项目最容易出现“以为用了 A,实际被 B 覆盖”的问题。因此每次部署前都应查看解析后的配置:

bash
docker compose config
docker compose config --services
docker compose config --images

docker compose config 会展开变量和合并配置,输出中可能出现敏感值。不要把完整输出直接贴到公开工单或聊天群。

如需指定独立变量文件:

bash
docker compose --env-file .env.test config
docker compose --env-file .env.test up -d

验证与启动必须使用同一变量文件,否则预检查和实际部署可能不是同一份配置。

7. 构建、启动与分阶段验证

先拉取基础镜像并构建本地服务:

bash
docker compose pull
docker compose build
docker compose images
docker compose config --quiet

启动项目:

bash
docker compose up -d

不要把命令成功返回等同于应用可用。按以下顺序验证。

7.1 项目级状态

bash
docker compose ps
docker compose ps --all

关注服务是否全部创建,状态是 runningexited 还是持续重启,健康状态是否达到 healthy,入口端口绑定是否符合预期。

7.2 日志与进程

bash
docker compose logs --tail=100
docker compose logs -f --tail=50 proxy web db redis
docker compose top

日志用于定位,不应成为唯一监控手段。生产环境还需要日志采集、指标、告警和资源使用监控。

7.3 网络解析

bash
docker compose exec web python -c \
  'import socket; print(socket.gethostbyname("db")); print(socket.gethostbyname("redis"))'

docker compose exec web cat /etc/resolv.conf
docker network ls
docker network inspect compose-lab_backend

如果服务名无法解析,先确认两个服务是否连接到同一网络,不要改成固定容器 IP。

7.4 应用访问

bash
curl -fsS http://127.0.0.1:8080/
curl -fsS http://127.0.0.1:8080/health

如果入口返回 502,分别检查:

bash
docker compose ps
docker compose logs --tail=100 proxy web
docker compose exec proxy wget -qO- http://web:8000/health

这能把问题缩小为入口端口、Nginx 配置、服务名解析、Web 监听地址或应用健康检查。

7.5 数据持久化

创建测试表:

bash
docker compose exec db psql -U app -d app -c \
  'CREATE TABLE IF NOT EXISTS compose_check(id integer primary key, note text);'

docker compose exec db psql -U app -d app -c \
  "INSERT INTO compose_check VALUES (1, 'persisted') ON CONFLICT (id) DO UPDATE SET note = EXCLUDED.note;"

重建数据库容器并复验:

bash
docker compose up -d --force-recreate db
docker compose exec db psql -U app -d app -c \
  'SELECT * FROM compose_check;'

如果数据仍在,说明命名卷在容器重建后继续挂载。这个验证比只看 docker volume ls 更有意义。

8. 日常更新、扩缩与可选服务

修改配置、环境变量、镜像标签或构建上下文后:

bash
docker compose config --quiet
docker compose up -d --build

Compose 会比较期望状态与现有资源,对需要变化的服务执行重建。数据库等未变化服务通常不需要先删除。

只重启进程:

bash
docker compose restart web

restart 不会应用新的 Compose 配置,也不会重新构建镜像。如果修改了环境变量、端口、卷或镜像,单纯重启不能生效。

拉取新镜像并更新:

bash
docker compose pull
docker compose up -d
docker compose ps

生产环境不要长期使用不可追踪的 latest。应使用明确版本或不可变摘要,并在测试环境完成兼容性验证。

临时扩展无状态 Worker:

bash
docker compose up -d --scale worker=3
docker compose ps worker

扩展前要确认服务没有固定 container_name、没有冲突的宿主机端口,并且任务消费逻辑支持并发。Compose 的单机扩展适合实验和小规模任务,不等同于跨主机集群编排。

调试工具可以使用 Profile:

yaml
services:
  adminer:
    image: adminer:5
    profiles: ["debug"]
    networks: [backend]

需要时显式启用:

bash
docker compose --profile debug up -d adminer

Profile 只是启动选择机制,不会自动提供鉴权或网络安全。

9. 故障排查:从最终配置开始

Docker Compose 生命周期与故障排查

这张图把运行生命周期与五类常见故障放在同一条证据链上。排障不要从反复 down/up 开始,而应先确认最终配置,再沿状态、日志、端口、网络和卷逐层定位。

9.1 配置无法解析或变量为空

症状包括 YAML 错误、变量未设置、端口或镜像名与预期不一致,以及多文件合并后字段被意外覆盖。

bash
docker compose config
docker compose config --quiet
docker compose config --environment

处理时先修复缩进、字段类型和变量来源,再核对工作目录、-f 参数与 --env-file。对必填变量可使用错误提示:

yaml
environment:
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}

9.2 服务持续重启或健康检查失败

bash
docker compose ps --all
docker compose logs --tail=200 web
docker compose top web
docker inspect "$(docker compose ps -q web)" --format '{{json .State}}'

常见原因包括启动命令错误、变量缺失、应用只监听 127.0.0.1、依赖尚未初始化、文件权限错误,或者健康检查命令在镜像中不存在。

可以临时进入一次性容器调试:

bash
docker compose run --rm --no-deps web sh

如果镜像没有 Shell,应使用镜像提供的诊断方式或构建专用调试镜像。

9.3 端口冲突或外部无法访问

bash
docker compose ps
docker compose port proxy 80
sudo ss -lntp | grep ':8080'
curl -v http://127.0.0.1:8080/

如果绑定到 127.0.0.1,局域网客户端无法直接访问是预期行为。如果需要外部访问,应调整为明确的业务网卡地址,并同步检查防火墙和上游策略。

9.4 服务名无法解析或网络不通

bash
docker compose exec web getent hosts db
docker compose exec web getent hosts redis
docker compose exec web cat /etc/resolv.conf
docker network inspect compose-lab_backend

常见原因是服务没有共同网络、应用使用 localhost、依赖固定容器 IP,或者 internal: true 与真实外联需求冲突。网络隔离必须根据流量设计,不能机械套用。

9.5 数据卷权限错误或数据看似消失

bash
docker compose exec db id
docker compose exec db ls -ld /var/lib/postgresql/data
docker volume ls
docker volume inspect compose-lab_pgdata
docker inspect "$(docker compose ps -q db)" --format '{{json .Mounts}}'

重点判断实际挂载的是不是预期卷,项目名变化是否创建了另一个卷,容器运行用户是否有权限,bind mount 路径是否存在,以及是否执行过 down -v

不要在不理解镜像用户模型时对数据目录递归执行 chmod 777。这会掩盖权限设计问题,并扩大安全风险。

10. 备份、恢复与清理边界

Compose 只负责声明卷,不自动提供数据库一致性备份。数据库应优先使用自身的逻辑备份、物理备份或快照集成。

PostgreSQL 逻辑备份:

bash
mkdir -p backups
docker compose exec -T db \
  pg_dump -U app -d app -Fc > backups/app-$(date +%F).dump

验证:

bash
test -s backups/app-$(date +%F).dump
file backups/app-$(date +%F).dump

恢复应在隔离环境演练:

bash
docker compose exec -T db createdb -U app app_restore
docker compose exec -T db \
  pg_restore -U app -d app_restore < backups/app-2026-06-14.dump

清理项目容器与网络:

bash
docker compose down
docker compose down --remove-orphans

删除命名卷前必须确认备份和数据归属:

bash
docker compose down -v

清理镜像、构建缓存或全局未使用资源属于主机级操作,影响范围超过当前项目。不要把 docker system prune -a --volumes 当作普通项目清理命令。

11. 多环境配置与生产化边界

一份基础配置可以叠加开发或生产差异:

bash
docker compose \
  -f compose.yaml \
  -f compose.dev.yaml \
  config

开发覆盖可以加入源码 bind mount、调试端口和热更新命令;生产覆盖可以固定镜像摘要、日志驱动和只读文件系统。无论使用多少文件,都应以 docker compose config 的最终输出作为审查对象。

需要警惕三类复杂化:

  1. 覆盖文件过多,没人能判断最终值来自哪里。
  2. 开发环境依赖宿主机目录,生产环境却误用同一 bind mount。
  3. 配置文件承担了密钥管理、发布审批和跨主机调度等不属于它的职责。

Compose 适合本地开发、自动化测试依赖、单机实验室、边缘节点和有明确备份监控流程的小型单机生产服务。

当需求进入跨主机调度、节点故障迁移、滚动升级、弹性伸缩、复杂密钥管理和多租户隔离时,应评估 Kubernetes、Nomad、Docker Swarm 或云平台托管编排能力。不要因为 Compose 文件已经很长,就误认为它自然升级成了集群控制平面。

12. 团队上线清单

配置审查

  • [ ] 使用 compose.yaml 和当前 docker compose 命令。
  • [ ] docker compose config --quiet 通过。
  • [ ] 项目名、镜像版本和变量来源明确。
  • [ ] 真实密钥没有进入 Git、镜像层和公开日志。
  • [ ] 只有入口服务发布宿主机端口。
  • [ ] 数据库和缓存不无必要地暴露到外部网络。

运行可靠性

  • [ ] 关键服务有有效健康检查。
  • [ ] 应用具备依赖连接重试与超时。
  • [ ] 重启策略与故障处理目标一致。
  • [ ] 无状态服务与有状态服务的更新方式分开。
  • [ ] 日志可采集,磁盘增长有上限和告警。

数据与恢复

  • [ ] 持久化数据使用明确命名卷或受控存储。
  • [ ] 已验证容器重建后数据仍存在。
  • [ ] 数据库备份不是简单复制活动数据目录。
  • [ ] 恢复流程在隔离环境演练过。
  • [ ] 运维手册明确标注 down -v 的破坏性。

安全基线

  • [ ] 应用镜像尽量使用非 root 用户。
  • [ ] 配置文件采用只读挂载。
  • [ ] 不需要互联网访问的网络评估 internal
  • [ ] 调试服务通过 Profile 或独立配置控制。
  • [ ] Docker socket 未直接挂载给普通业务容器。

13. 小结

Docker Compose 的关键不是把多条 docker run 改写成 YAML,而是形成可审查、可复现、可验证的应用运行契约。

可靠的 Compose 项目应具备清晰的服务边界、最小化的端口暴露、明确的网络分区、独立的数据生命周期、可解释的变量来源和有效健康检查。日常操作应围绕 configuppslogsdown 建立证据链:先确认期望状态,再观察实际状态,最后实施最小范围变更。

Compose 能显著降低单机多容器应用的部署复杂度,但不会自动解决应用容错、数据库备份、日志治理、密钥管理和跨主机高可用。把边界说清楚,才能既发挥 Compose 的效率,又避免把它用成不可维护的临时平台。

下一篇将进入容器安全,讨论镜像来源、非 root 运行、能力集、只读文件系统、密钥暴露和 Docker daemon 权限等风险。

14. 参考资料