Language:Chinese VersionEnglish Version

传统观念认为 Docker Compose 适用于开发环境,而 Kubernetes 适用于生产环境。与基础设施领域的大多数传统观念一样,这部分正确,部分是盲目跟风,而大部分是过度简化,给小团队带来了不必要的真实成本和运营复杂性。在生产环境中运行 Compose 并不是捷径或妥协——对于合适的工作负载和团队规模,它是正确的选择。问题在于了解哪些模式能使其可靠,哪些习惯会在凌晨2点给你带来麻烦。Docker Compose 生产模式已经足够成熟,以至于”我们只使用 Compose”对于广泛的实际部署来说是一个有说服力的答案。理解这些部署的稳定性所在,是将自信运行 Compose 的团队与草率部署并持续焦虑的团队区分开来的关键。

何时在生产环境中使用 Compose

在讨论如何在生产环境中运行 Compose 之前,明确其适用场景是值得的。Compose 是单主机编排器。这句话既包含了它的优势,也指出了它的局限性。在单台服务器上——即使是配置良好的服务器——Compose 提供了简单性、可预测性,几乎零运营开销。通过阅读一个文件就能理解整个部署。没有分布式状态,没有控制平面,没有需要备份和恢复的 etcd。

Compose 真正擅长的工作负载:中小流量的 Web 应用程序、内部工具、镜像生产环境的暂存环境、自托管服务(Gitea、Nextcloud、监控堆栈)以及处于早期增长阶段的 SaaS 产品,其中一台强大的服务器能舒适地处理所有流量。没有专职基础设施工程师的一到五人团队,精通 Compose 往往比部分实施 Kubernetes 更能受益。在配备适当备份和监控的每月40美元的 VPS 上良好运行的 Compose 部署,将优于成本十倍但管理不善的 Kubernetes 集群。

你已经超出 Compose 适用范围的信号是明确的:你需要将工作负载分布在多个节点上以增加容量或可用性;你需要响应流量峰值的自动水平扩展;你的团队规模足够大,使得跨服务的部署协调成为一个独立问题;或者你在受监管的环境中运行,Kubernetes 抽象提供了有意义的合规优势。如果这些情况都不适用于你,采用 Kubernetes 的压力是文化层面的,而非技术层面的。

基础:健康检查和重启策略

开发环境 Compose 文件与生产就绪 Compose 文件之间最 impactful 的两个变化是健康检查和重启策略。大多数教程完全跳过了这两点。

重启策略决定了容器退出时 Docker 的行为。在开发环境中,这几乎无关紧要——你可以手动重启服务。在生产环境中,容器崩溃是不可避免的:内存溢出、应用程序错误、依赖超时。如果没有重启策略,你的容器就会消失,直到有人注意到。

对生产环境而言,有两个重要的策略:unless-stoppedalways。它们的区别虽小但意义重大:always 会在你手动使用 docker compose stop 停止容器后仍然重启它,这使得有意的维护操作变得令人沮丧。unless-stopped 尊重手动停止,同时仍然会在崩溃和主机重启后自动重启。将 unless-stopped 作为应用程序服务的默认选择。

健康检查是 Docker 判断容器是否真正正常工作的机制,而不仅仅是运行中。从 Docker 的角度看,容器可能处于”运行”状态——进程是存活的——但容器内的应用程序可能已经死锁、配置错误或处于崩溃循环中。没有健康检查,Docker Compose 无法区分健康容器和恰好仍在运行进程的损坏容器。

一个生产环境的 Web 服务的健康检查在 compose.yml 中看起来是这样的:

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 40s

start_period 字段经常被忽视。它告诉 Docker 不要在初始启动窗口期间计算失败,这对于需要时间初始化的服务很重要——数据库迁移、缓存预热、JVM 启动。没有它,一个需要 30 秒初始化时间的服务会在有机会正确启动之前就被标记为不健康。

资源限制:大多数团队直到为时已晚才会配置的设置

在共享主机上运行没有资源限制的容器是一种操作风险。一个服务中的内存泄漏、失控的查询、流量激增——任何这些情况都可能消耗主机上所有可用的 RAM 并触发内核 OOM 杀手,它会无差别地终止进程,不管哪些进程最重要。没有限制,错误的进程会在最糟糕的时刻被终止。

Compose 中的资源限制位于 deploy.resources 键下。这是一个常见的困惑点:许多开发者在顶层设置 mem_limit,这在 Compose v2 语法中有效,但除非你特别以 Swarm 兼容模式运行,否则会被当前的 Compose 实现忽略。使用 deploy.resources

deploy:
  resources:
    limits:
      memory: 512M
      cpus: "0.5"
    reservations:
      memory: 256M
      cpus: "0.25"

limits 值是硬性上限 — 容器不能超过这些限制。reservations 值向 Docker 传达此容器所需的最小资源量,如果您之后切换到 Swarm 模式,这对调度决策很重要。只有没有限制的预留是建议性的;没有预留的限制是更常见且更有用的配置。

设置内存限制会迫使您仔细思考每个服务实际需要什么,这本身就是有用的。运行日志传送器的 sidecar 容器可能需要 64MB。您的数据库可能确实需要 2GB。对一切应用相同的慷慨限制会浪费资源并违背目的。首先进行监控,然后将限制设置为观察到的峰值使用量的约 150%,为合法的峰值波动留出空间,同时防止失控进程消耗主机资源。

持久数据:命名卷优于绑定挂载

绑定挂载 — 将主机目录映射到容器中,路径如 ./data:/var/lib/postgresql/data — 在开发中很方便,因为您可以直接查看和操作文件。在生产环境中,它们会产生几个问题。主机路径依赖使您的 Compose 配置不可移植。主机用户 ID 和容器用户 ID 之间的文件所有权和权限不匹配会导致难以调试的访问错误。Docker 本身对绑定挂载数据的生命周期可见性较低,使得卷管理和清理更加脆弱。

命名卷解决了所有这些问题。Docker 管理存储位置,在容器生命周期内正确处理所有权,并从主机文件系统提供适当的隔离:

volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local

这些在 compose.yml 底部的卷定义,在服务配置中引用为 - postgres_data:/var/lib/postgresql/data,为您提供在容器替换后仍然持久化的卷,按项目正确命名空间化,并且可以使用 docker run --rm -v postgres_data:/data -v $(pwd):/backup alpine tar czf /backup/postgres_data.tar.gz /data 进行备份。

在生产环境中,绑定挂载的一个合法用例是需要主机编辑并立即在容器中反映的配置文件 — 例如 Nginx 配置,您希望在不重建镜像的情况下重新加载配置。即使如此,也应尽可能优先使用 Docker 配置或环境变量驱动的配置。

网络隔离:仅暴露需要暴露的内容

默认的 Docker 网络模型将所有容器放在一个共享的桥接网络中,每个容器都可以到达其他所有容器。对于单个应用程序,这可能没问题。一旦您在同一主机上运行多个不相关的服务,或具有不同安全要求的服务,这种默认设置就会造成不必要的暴露。

正确的生产模式使用显式命名的网络,内部服务不连接到任何面向外部的网络。典型的堆栈可能如下所示:

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

后端网络上的 internal: true 标志意味着该网络上的容器没有出站互联网访问权限 — 它们只能与同一网络上的其他容器通信。您的 Postgres 数据库、Redis 缓存和内部 API 服务应该属于后端网络。只有您的反向代理属于前端网络,并具有到后端的显式连接以代理请求。

这种模式限制了容器被攻破的影响范围。如果您的应用程序被攻破,攻击者只能访问该容器所连接的网络,而不是您的整个主机网络。它还使您的架构在 Compose 文件中变得明确,这是一种与实际情况保持同步的文档,因为它是创建现实情况的配置。

环境变量管理

在您的 compose.yml 中存储密钥相当于将密码提交到公共仓库。这种情况确实会发生,但这是个坏主意,而且替代方案并不复杂。

.env 文件模式是摩擦最小的起点:Docker Compose 会自动读取与您的 compose.yml 同目录下的 .env 文件,并使这些变量可用于插值。您将带有虚拟值的 .env.example 提交到您的仓库,并将 .env 添加到您的 .gitignore 中。在服务器上,真实的 .env 文件位于版本控制之外,只有部署用户可以读取。

# .env.example — 提交此文件
POSTGRES_PASSWORD=change_me
POSTGRES_USER=appuser
SECRET_KEY=change_me_to_a_long_random_string
REDIS_PASSWORD=change_me

在 Swarm 模式下运行时可用的 Docker Secrets 提供了一种更复杂的机制,其中密钥值作为文件挂载在容器内部,并且永远不会出现在环境变量或进程列表中。这对于通过 docker inspect 检查环境变量是安全顾虑的高安全性部署是合适的。对于大多数小型团队的 Compose 部署,具有适当权限和严格文件权限(chmod 600 .env)的 .env 文件是一个合理且实用的选择。

什么是不合理的选择:在 compose.yml 中硬编码值,在命令行上传递密钥(它们会出现在 shell 历史记录中),或者对您的密钥文件使用过于宽松的文件权限。这些错误会将小规模泄露转变为完全的攻破。

不会填满您磁盘的日志配置

默认情况下,Docker 将容器输出以 JSON 文件的形式记录到主机上,且没有大小限制。对于繁忙的应用程序,这会填满您的磁盘。这是那些几周或几个月都不成问题,但在关键时刻导致根分区达到 100% 并造成灾难性后果的问题之一。

生产环境的解决方案很简单 — 在您的 daemon.json 或 compose.yml 中为每个服务配置日志轮转:

logging:
  driver: json-file
  options:
    max-size: "50m"
    max-file: "5"

这会将每个服务的日志总量限制在 250MB(五个 50MB 的文件),并自动轮转。根据您的日志量和可用磁盘空间调整这些值。低流量的内部服务可能需要较小的值;而高流量的 API 可能需要较大的值。

对于需要集中日志聚合的生产环境部署,请将 json-file 驱动程序替换为传输驱动程序。对于已经运行 Grafana 的团队,使用 Docker 插件的 Loki 是一个流行的选择。Fluentd 驱动程序与 ELK 堆栈配合良好。关键在于,做出这个决定并进行明确配置,远比在需要诊断事件时才发现您的日志策略是”填满磁盘”要好得多。

生产就绪的堆栈:完整模式

抽象原则配合具体示例会更加实用。下面是一个标准 Web 应用栈的代表性生产 compose.yml 文件 — 包括 Nginx 反向代理、Node.js 应用、Postgres 和 Redis — 并在整个文件中应用了上述讨论的模式:

services:
  nginx:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - certbot_data:/etc/letsencrypt
    networks:
      - frontend
      - backend
    depends_on:
      app:
        condition: service_healthy
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "3"
    deploy:
      resources:
        limits:
          memory: 128M
          cpus: "0.25"

  app:
    image: registry.example.com/myapp:${APP_VERSION}
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
      SECRET_KEY: ${SECRET_KEY}
    networks:
      - backend
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "5"
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "0.75"

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: "1.0"

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "--pass", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 384M
          cpus: "0.5"

volumes:
  postgres_data:
  redis_data:
  certbot_data:

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

此配置中有几个决定值得特别说明。镜像标签中的 APP_VERSION 变量意味着您永远不会部署 :latest — 每次部署都固定到特定版本。depends_on 条件使用 service_healthy 而不仅仅是 service_started,因此启动顺序尊重实际就绪状态而不仅仅是进程存在。后端网络是内部的,这意味着 Postgres 和 Redis 没有出站互联网访问。Redis 配置了内存限制和驱逐策略,防止其无限增长。

部署工作流

在单台服务器上使用 Compose 的可靠部署序列很简单,但需要一致执行的纪律。最小化停机时间并最大程度提高置信度的序列如下:

  • 在接触运行中的堆栈之前拉取新镜像:docker compose pull
  • 应用更新:docker compose up -d
  • 验证健康状态:docker compose ps 并检查所有服务是否显示为健康
  • 短暂查看日志以捕获即时错误:docker compose logs -f --tail=50
  • 清理旧镜像:docker image prune -f

docker compose up -d 执行滚动替换 — 它只重新创建配置已更改的容器,而保持其他容器不变。对于大多数 Compose 部署,这在实际应用中已足够接近零停机,特别是当前有反向代理可以吸收短暂重连时。如果您的应用程序需要严格的零停机(新容器完全健康后旧容器才停止),这是 Swarm 模式的更新配置或适当编排器比普通 Compose 提供有意义改进的合法情况之一。

一个经常被忽视的改进:通过脚本而非手动运行部署。即使是十行按顺序运行这些命令、检查退出代码并在失败时发送通知的 shell 脚本,也比手动部署好得多。手动部署会逐渐偏离;脚本部署保持一致。

Compose 卷的备份策略

生产环境中的命名卷需要备份策略。”数据在服务器上”不是备份策略。

对于 Postgres,最可靠的方法结合使用 pg_dump 进行逻辑备份和卷快照进行时间点恢复。每天运行一个 cron 作业,在容器内运行 pg_dump,压缩输出并将其发送到对象存储(S3、Backblaze B2 或等效服务)来处理数据库层:

docker exec postgres pg_dump -U $POSTGRES_USER $POSTGRES_DB | gzip > backup_$(date +%Y%m%d).sql.gz

对于卷本身,停止数据库容器、对卷目录进行快照,然后重新启动,可以提供文件系统级别的备份,这种备份能够恢复逻辑转储无法捕获的损坏情况。同时进行这两种操作并非冗余——它们针对的是不同的故障模式。

测试你的备份。未经测试的备份可能在需要时无法工作。每月在测试环境中进行一次恢复演练并不过分。这是了解你的备份策略是否真正有效的唯一方法。

监控:Portainer、cAdvisor 和 Watchtower 问题

在讨论 Compose 生产环境监控时,有三个工具经常被提及,它们满足不同的需求。

Portainer 提供了一个用于管理 Docker 容器、堆栈和卷的 Web UI。它的主要价值在于可见性——一目了然地查看正在运行的容器、它们的资源使用情况和最近的日志输出,而无需 SSH 访问权限。对于需要多人偶尔检查部署状态的团队来说,Portainer 值得为其运行带来的运维开销。对于习惯在命令行上操作的单人运维人员来说,它增加了复杂性但没有带来相应的收益。

cAdvisor(容器顾问)以兼容 Prometheus 的格式导出每个容器的指标,涵盖 CPU 使用率、内存消耗、网络 I/O 和磁盘 I/O 随时间的变化。如果你正在运行 Prometheus/Grafana 堆栈,添加 cAdvisor 可以让你获得容器资源行为的时间序列可见性,这是 docker stats 无法提供的。它轻量级,无需配置,是构建容量规划基线的正确工具。

Watchtower 自动检查更新的容器镜像并使用新版本重启容器。其吸引力显而易见——无需任何部署工作流程即可自动更新。风险也同样明显:有问题的上游镜像将自动部署到生产环境而无需人工审查。在生产应用容器上完全自动模式运行 Watchtower 并不是一个好主意。适当的使用范围有限:用于低风险基础设施容器(Watchtower 容器本身,可能是一个简单的代理)的自动更新,并启用监控通知,这样你就能看到它的操作。对于频繁变化或有真实流量的应用容器,应使用有意的部署工作流程。

将让你付出代价的常见错误

Compose 生产部署中的故障模式集中在少数几个特定错误上,这些错误在不同团队中反复出现。

在容器中以 root 身份运行是最需要覆盖的默认设置。除非您另有指定,大多数基础镜像都会以 root 身份运行。如果您的应用程序中存在漏洞导致容器逃逸或代码执行,以 root 身份运行的容器比最小权限用户提供的访问权限要多得多。在您的 Dockerfile 中添加一个非 root 用户:RUN addgroup -S app && adduser -S app -G appUSER app。对于不提供非 root 用户的官方镜像,请在您的 compose.yml 中使用 user 键指定一个。

在生产中使用 latest 标签会造成不可预测的部署表面。latest 是镜像发布者最后决定标记的内容。常规的 docker compose pull 可能会拉取与之前运行版本完全不同的软件版本。固定特定版本——不仅仅是主版本,还要完整的补丁版本,如 postgres:16.2-alpine——并有意识地升级,而不是意外升级。

不设置内存限制已经被讨论过,但潜在的习惯值得明确指出:跳过资源限制是一种乐观行为,而非配置。它假设一切都不会出现问题。生产环境恰恰是问题可能出现的地方,因为它们在真实条件下运行真实流量,并处理真实的数据边缘情况。设置限制。

在 compose.yml 中存储密钥同时造成了版本控制和访问控制问题。每个拥有仓库访问权限的人都能获取您的生产凭据。每次部署历史都会显示凭据值。使用来自 .env 文件的环境变量插值,或者对于更高安全要求,使用 Docker Secrets。

诚实的评估

Docker Compose 生产部署并非二等基础设施。它们是针对特定且常见工作负载的适当基础设施,成功运行它们的团队并非在妥协——他们正在做出一个深思熟虑的选择,用 Kubernetes 的运营复杂性换取 Compose 的简单性,用团队的带宽去解决更重要的问题。

使 Compose 值得在生产中使用的模式并不罕见。健康检查、重启策略、资源限制、网络隔离、适当的密钥管理、日志轮换和有纪律的部署工作流程——这些都不难实现。从为开发临时拼凑的 Compose 文件到能够在生产环境中可靠运行数年的文件,差距只是几个小时有针对性的配置工作。

完成那些工作。固定你的镜像版本。设置你的内存限制。配置你的健康检查。测试你的备份。结果是一个你可以完全理解、快速调试、自信操作的部署堆栈——对于没有专职基础设施工程师的团队来说,这比上千行 Kubernetes YAML 更有价值。

By Michael Sun

Founder and Editor-in-Chief of NovVista. Software engineer with hands-on experience in cloud infrastructure, full-stack development, and DevOps. Writes about AI tools, developer workflows, server architecture, and the practical side of technology. Based in China.

Leave a Reply

Your email address will not be published. Required fields are marked *

You missed