Docker Compose学习

Best Practices Around Production Ready Web Apps with Docker Compose --- Nick Janetakis

Docker Compose

我们先从几条模式、小贴士和最佳实践谈起,这些内容适用于在开发和生产环境中使用 Docker Compose。

把文件顶部的 version 字段删掉

Docker Compose 规范已明确 version 属性「已弃用」。

在此之前,大家通常会写 version: "3.8" 或其他版本号,用来锁定可用的 API 属性集。自 Docker Compose v1.27 起,这一行可以彻底删掉

用一份覆盖文件,避免为「开发」和「生产」各写一套 Compose

Docker Tip #94: Docker Compose v2 and Profiles Are the Best Thing Ever --- Nick Janetakis

说到「开发 / 生产一致性」,我主张所有环境共用同一套 docker-compose.yml。但现实中常会遇到「某些容器只想在开发跑,而生产不跑」的场景。

例如:

  • 开发时需要 Webpack 监听并实时打包,而生产环境只负责把已打好的静态文件吐出去;

  • 生产用托管版 PostgreSQL,开发却想在本地起个容器。

这类需求可以用 docker-compose.override.yml 解决。

思路很简单:新建该文件,往里塞一段类似下面的内容即可:

复制代码
services:
  webpack:  #定义一个webpack的服务
    build:
      context: "." #使用当前目录作为构建上下文(即 Dockerfile 所在目录)。
      target: "webpack" #如果 Dockerfile 是多阶段构建(multi-stage),这个指定只构建到名为 webpack 的阶段。
      args:
        - "NODE_ENV=${NODE_ENV:-production}" #传递构建参数 NODE_ENV,默认值为 production,但可以通过环境变量覆盖。
    command: "yarn run watch" #容器启动后执行的命令是 yarn run watch,通常用于开发模式下监听文件变化并重新构建。
    env_file:
      - ".env" #从 .env 文件中加载环境变量到容器内。
    volumes:
      - ".:/app" #将当前主机目录挂载到容器的 /app 目录,方便开发时实时同步代码变化。

它就是一个普通的 Docker Compose 文件。默认情况下,当你执行 docker-compose up 时,Docker Compose 会自动把 docker-compose.ymldocker-compose.override.yml 合并成一份配置并启动。整个过程无需额外参数,完全自动。

接下来,你只要把 docker-compose.override.yml 写进 .gitignore(告诉 Git 哪些文件或目录不需要纳入版本控制 (即不被 git addgit commit 追踪)。 ,这样代码推到生产(比如你自建的 VPS)时,服务器上根本不存在这个文件------于是开发阶段才需要的服务就不会跑出来。至此,你只用一份主文件就实现了「开发跑、生产不跑」的需求,再也不用维护 docker-compose-dev.ymldocker-compose-prod.yml 两份几乎重复的配置。

为了进一步方便开发者,你可以在仓库里再留一个 docker-compose.override.yml.example,让它受版本控制。新人克隆项目后,只需执行:

复制代码
cp docker-compose.override.yml.example docker-compose.override.yml

就能立刻拿到一份现成的本地覆盖配置------这招在开发和 CI 环境里都特别省事。

用 YAML 的别名(aliases)与锚点(anchors)削减重复配置

Docker Tip #82: Using YAML Anchors and X Properties in Docker Compose --- Nick Janetakis

用 YAML 的别名(aliases)与锚点(anchors)再结合 Docker Compose 的「扩展字段」(extension fields),就能大幅削减重复配置。

这里先给个最小可用示例,放在 docker-compose.yml 最顶部即可:

复制代码
x-app: &default-app
  build:
    context: "."
    target: "app"
    args:
      - "FLASK_ENV=${FLASK_ENV:-production}"
      - "NODE_ENV=${NODE_ENV:-production}"
  depends_on:
    - "postgres"
    - "redis"
  env_file:
    - ".env"
  restart: "${DOCKER_RESTART_POLICY:-unless-stopped}"
  stop_grace_period: "3s"
  tty: true
  volumes:
    - "${DOCKER_WEB_VOLUME:-./public:/app/public}"

And then in your Docker Compose services, you can use it like this:

复制代码
  web:
    <<: *default-app
    ports:
      - "${DOCKER_WEB_PORT_FORWARD:-127.0.0.1:8000}:8000"

  worker:
    <<: *default-app
    command: celery -A "hello.app.celery_app" worker -l "${CELERY_LOG_LEVEL:-info}"

这样就把第一段代码里的所有属性一次性"注入"到 webworker 两个服务里,省掉了大约 15 行重复配置。

1.web服务

  • 额外把容器的 8000 端口映射到宿主机的 8000(默认只监听 127.0.0.1,安全)。

    负责处理 HTTP 请求,是 Flask 主应用。

2.worker服务

  • 启动命令被覆盖 :不运行 Flask,而是运行 Celery 后台任务。

    负责异步任务/队列消费

如果某个服务需要微调,只需在该服务里重新写同名字段即可覆盖别名里的值 。例如,只想让 worker 的优雅停止时间变成 10 秒,就在它下面加一行 stop_grace_period: "10s",它会优先于锚点里的设定。

这种模式特别适合**"多个服务共用同一 Dockerfile 和代码库,仅运行参数略有差异"**的场景。

在 Docker Compose 里声明 HEALTHCHECK,而不是写死在 Dockerfile

我通常不对"最终把应用扔到哪"做预设------可能是单机 VPS 配 Docker Compose,也可能是 Kubernetes 集群,甚至直接丢上 Heroku。

虽然这三者都跑容器,但运行方式天差地别。

Kubernetes 一旦在镜像里发现 HEALTHCHECK,会自动禁用它,因为它有自己的 readiness / liveness 机制;可我们不该依赖"别人帮忙关掉"这种隐含行为,能提前规避就规避。

因此,我把健康检查写在 docker-compose.yml 里,让"编排层"自己决定要不要用、怎么用。示例:

复制代码
  web:
    <<: *default-app
    healthcheck:
      test: "${DOCKER_WEB_HEALTHCHECK_TEST:-curl localhost:8000/up}"
      interval: "60s"
      timeout: "3s"
      start_period: "5s"
      retries: 3

healthcheck 节点

  • test:真正执行的检查命令。

    写法 ${DOCKER_WEB_HEALTHCHECK_TEST:-curl localhost:8000/up} 意思是:

    • 如果环境变量 DOCKER_WEB_HEALTHCHECK_TEST 存在,就用它的值作为检查命令;

    • 如果不存在,就默认执行 curl localhost:8000/up

      这条命令会在容器内部访问自己的 8000 端口 /up 路径,只要能返回 200 OK,就认为"健康"。

  • interval: "60s":每 60 秒做一次检查。

  • timeout: "3s":如果命令 3 秒内没跑完,就当作这次检查失败。

  • start_period: "5s":容器启动后的前 5 秒内即使检查失败也不计数,给应用一点初始化时间。

  • retries: 3:连续失败 3 次后才最终判定容器"不健康"。

这样:

  • 本地 docker compose up 会按上述策略自检;

  • 推到 K8s 时,只要把这段配置扔掉即可,互不污染;

  • 镜像保持"编排无关",同一 artifact 任意平台复用。

这个做法的另一个妙处是:health-check 在「运行时」才最终确定,所以我们可以给「开发」和「生产」配完全不同的检查逻辑,而不用重新打包镜像。

实现手段就是待会儿会讲到的环境变量。

  • 开发环境

    HEALTHCHECK_TEST 设成 /bin/true

    几乎不占资源、不写日志,每分钟触发也毫无感觉。

  • 生产环境

    同一字段换成 curl -f http://localhost:8000/up

    真正去做业务探活。

充分利用环境变量

仓库里放两份文件:

  1. .env -- 真・变量库,含密钥和各环境差异值,一律写进 .gitignore,永不进版本库。

  2. .env.example -- 示范文件,只留非敏感键值,提交到 Git 。新人 / CI 一落地就能 cp .env.example .env 立刻跑起来。

Here's a snippet from an example env file:

复制代码
# Which environment is running? These should be "development" or "production".
#export FLASK_ENV=production
#export NODE_ENV=production
export FLASK_ENV=development
export NODE_ENV=development

关于文档,我喜欢把「默认值」直接写在注释里。这样一来,一旦变量被覆盖,一眼就能看出来它被改成了啥,而不用去翻源码。

说到默认值,我坚持「以生产为准 」:

Compose 文件里能写死的,就写成线上想要的值。到了线上,真正需要手动注入的只剩密钥和极个别差异化变量,极大降低「忘改参数把库冲了」的人为事故。

开发阶段则随便折腾------所有 override 值都提前写进 .env.example,新人 cp 完就能跑,零成本。

把「环境变量 + Docker Compose + Dockerfile 里的 build-arg」这三板斧组合好,就能用同一套镜像、同一套代码横扫所有环境,只改几个变量即可。

回到「开发/生产一致性」主题,在 docker-compose.yml 里可以这样用变量:

复制代码
environment:
  - FLASK_ENV=${FLASK_ENV:-production}

Compose 会自动读取与 yml 同级的 .env 文件;如果找不到,就回落到 production。语法兼容 shell 的 ${VAR:-default},只是不能嵌套变量当默认值,算个小遗憾。

下面是几招利用环境变量的用法

Controlling which health check to use:
复制代码
  web:
    healthcheck:
      test: "${DOCKER_WEB_HEALTHCHECK_TEST:-curl localhost:8000/up}"
      interval: "60s"
      timeout: "3s"
      start_period: "5s"
      retries: 3

默认情况下,健康检查走的是 curl 那条路;可只要在 .env 里写一行

复制代码
export DOCKER_WEB_HEALTHCHECK_TEST=/bin/true

开发环境立刻「静音模式」

至于为啥我所有 .env 都带 export

------为的就是以后能在自定义脚本里直接 source .env,变量瞬间全员上线,省事到飞起。Compose 从 1.26 起就认 export 写法,放心用,不踩坑。

Publishing ports more securely in production:
复制代码
  web:
    restart: "${DOCKER_RESTART_POLICY:-unless-stopped}"

以前我写过,我喜欢把 Nginx 直接装在宿主机上、不放进容器。配合这个套路,能让 Web 容器的端口只监听 127.0.0.1,默认拒绝任何公网 IP 连进来。

这样一来,即使 Docker 自动写进了 iptables 规则,外人也无法直接访问 example.com:8000,省得再去云平台单独配防火墙(当然你配了更好,安全就是叠 Buff)。

开发阶段则把限制放开:在 .env 里加一行

复制代码
export DOCKER_WEB_PORT_FORWARD=8000
  • 线上不填变量 → 默认 127.0.0.1:8000,只能本机 Nginx 反代。

  • 开发填了 8000 → 变成 0.0.0.0:8000,同一局域网的笔记本、iPad、手机都能顺手打开 http://<dev机IP>:8000 调试,方便得一批。

一层小配置,公网威胁挡外头,内网调试畅通行。

Taking advantage of Docker's restart policies:
复制代码
  web:
    restart: "${DOCKER_RESTART_POLICY:-unless-stopped}"

生产环境把 restart: unless-stopped 写进 Compose,宿主机重启或容器可恢复性崩溃后,服务能自己爬回来,省得凌晨三点起床救火。

可开发机要是也这么干就热闹了------

一重启,你这辈子攒下的十几个老项目全会嗷嗷待哺地一起启动(带 restart 策略unless-stopped / always / on-failure)的容器会在宿主机重启后被 Docker 守护进程自动复活。),风扇直接变直升机。

所以 .env 里顺手加一行:

复制代码
export DOCKER_RESTART_POLICY=no
Switching up your bind mounts depending on your environment:
复制代码
  web:
    volumes:
      - "${DOCKER_WEB_VOLUME:-./public:/app/public}"

如果你打算让宿主机上的 Nginx (非容器)直接托管静态文件(css、js、images 等),用 bind mount 是最省心的办法。

咱们只把 public/ 目录挂进容器,静态资源就放在那。具体路径随框架而异,我在示例项目里一律惯用 public/ 这个约定目录。

至于挂载权限:

  • 只做静态托管 → 只读 (ro) 就够;

  • 如果业务支持用户直接上传文件到磁盘 → 就得给读写 (rw)。

开发阶段图方便,可以在 .env 里写:

复制代码
export DOCKER_WEB_VOLUME=.:/app

把整个源码目录挂进去,改完代码即时生效,无需重新 build 镜像,刷新浏览器就能看到新效果,爽到飞起。

Limiting CPU and memory resources of your containers:
复制代码
  web:
    deploy:
      resources:
        limits:
          cpus: "${DOCKER_WEB_CPUS:-0}"
          memory: "${DOCKER_WEB_MEMORY:-0}"

把值写成 0(或干脆不写)= 无限量供应,容器能吃多少就吃多少。单机部署时看起来"省事",但某些技术栈会趁机"暴饮暴食"------比如 Elixir 的 BEAM(Erlang VM),上来就把能抢到的内存和 CPU 全占满,结果 MySQL / Redis 连汤都喝不上,大家一起翻车。

就算不用 Elixir,给每个服务画条"资源红线"也有好处:

  1. 买云主机时心里先有谱,不会为了"保险"多花钱,也不会因为低估配置导致半夜扩容。

  2. 提前把"饭量"报出来,以后上 Kubernetes 就轻松:

    告诉 K8s"这货只吃 75 MB",调度器就能把 10 个副本稳稳塞进 1 GB 的节点;

    如果啥都不填,K8s 只能瞎猜,最后节点资源碎片化,集群白白浪费一大块内存和 CPU。

一句话:先量胃再点菜,单机省钱,集群省"芯"。

Your Web App

Docker Compose 的套路咱们已经聊得差不多,现在换档,来搞应用本身的配置

Web 服务器配置文件

Flask 或其他 Python 框架通常用 Gunicorn / uWSGI 当应用服务器;Rails 则常选 Puma。不管啥技术栈,下面这几项你大概率都得调:

接下来我用 Flask 示例项目里的 gunicorn.py 做演示,但思路基本通用,换到其他语言/服务器也能照猫画虎。

绑定地址和端口:
python 复制代码
bind = f"0.0.0.0:{os.getenv('PORT', '8000')}"
  • 0.0.0.0 写死:我们将绑定到 0.0.0.0,这样你就能从容器外部连接到容器。许多应用服务器默认使用 localhost,这在使用 Docker 时是个坑,因为它会阻止你从开发机的浏览器连接。因此这个值被硬编码了,不会更改。

  • 端口PORT 环境变量:Heroku 也认这个名字,一键云部署不折腾。本地没传 PORT 就回落到 8000,开发、上线两相宜。

Workers和线程数:
python 复制代码
workers = int(os.getenv("WEB_CONCURRENCY", multiprocessing.cpu_count() * 2))
threads = int(os.getenv("PYTHON_MAX_THREADS", 1))

在 Python、Ruby 及其他一些语言里,worker 和 thread 的数量决定了你的应用服务器每秒能处理多少请求。数量越多,并发能力越强,但也会消耗更多内存和 CPU。

与 PORT 一样,这两个环境变量的命名都沿用了 Heroku 的规范。

worker 数默认取宿主机 vCPU 数量的两倍,这样以后升级服务器时就无需改动任何配置,连环境变量都不用调。当然,如果你需要,也可以通过环境变量手动覆盖。

开发环境下,我会在 .env.example 里把这两个值都设为 1,因为调试时应用不 fork 会更轻松。所有支持这些选项的示例项目里都把它们默认写成 1。

Code reloading or no?:
python 复制代码
from distutils.util import strtobool

reload = bool(strtobool(os.getenv("WEB_RELOAD", "false")))

不同的 Web 框架和应用服务器对代码重载的处理方式各异。对于 Gunicorn,你必须显式地配置它是否开启代码重载。

这正是环境变量的用武之地。我们可以把默认值设为 false,用于生产环境;而在开发环境的 .env 文件里,将其设置为 true。

"代码重载"简单说就是:

一改源码,服务器立刻自动用新版本响应请求 ,不用你手动 docker build + 重启容器。

  • 开发时:保存文件 → 浏览器刷新 → 瞬间看到改后效果,调试用。

  • 生产时:关掉,防止文件误动就热替换,求稳。

对应 Gunicorn 的 --reload 开关,开则热重载,关则冷启动。

Log to standard out (stdout):
python 复制代码
accesslog = "-" 
#告诉 Gunicorn "把访问日志直接输出到标准输出(stdout)"。
  • 在 Gunicorn 里,字符串 "-" 被当作特殊值,表示"不要用文件,用 stdout"。

  • 日志走 stdout 后,Docker 就能统一收集,你可以用 docker logs、journald、CloudWatch 等方式查看或持久化,而不会因为容器被删除就把日志弄丢。

把访问日志打到标准输出(stdout)而不是磁盘文件,是更"Docker 原生"的做法。一旦容器被 stop & rm,存在文件里的日志就永远消失;而 stdout 的日志可以被 Docker 守护进程接管,你爱怎么持久化都行------单机可走 journald,用 journalctl 随便翻;上云也能直接送进 AWS CloudWatch 或其他第三方日志平台。要点是:所有应用统一往 stdout 打,日志收集在 Docker 层一次搞定。

Configuring your database:

python 复制代码
pg_user = os.getenv("POSTGRES_USER", "hello")
pg_pass = os.getenv("POSTGRES_PASSWORD", "password")
pg_host = os.getenv("POSTGRES_HOST", "postgres")
pg_port = os.getenv("POSTGRES_PORT", "5432")
pg_db = os.getenv("POSTGRES_DB", pg_user)

db = f"postgresql://{pg_user}:{pg_pass}@{pg_host}:{pg_port}/{pg_db}"
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", db)

上面是 SQLAlchemy 的写法,但思路同样适用于 Rails、Django、Phoenix 等任何框架。

要点是:先用 POSTGRES_* 这一系列变量,与官方 PostgreSQL 镜像的启动参数保持一致;最后再给一条 DATABASE_URL,如果存在就整体替代前面的拼接。

这样本地开发可以继续用容器里的 Postgres,而生产只要贴一个托管数据库的连接串即可,兼顾了 override 文件套路,两边都省心。名字选 DATABASE_URL 也是顺着多数云厂商的惯例。

python 复制代码
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
  1. 本地开发(用容器)
  • 你把官方 PostgreSQL 镜像跑起来,它要求你设 POSTGRES_USERPOSTGRES_PASSWORD 等变量。

  • 代码里先拿这些变量拼成连接串,例如:

    postgresql://hello:password@postgres:5432/hello

  • 开发时啥也不用改,直接 docker compose up 就能跑。

  1. 生产环境(用云端托管库)
  • 云厂商(如 AWS RDS、Heroku、阿里云)只给你一个整串 的连接地址,叫 DATABASE_URL

    postgresql://user:pass@aws.rds.amazonaws.com:5432/dbname

  • 代码发现 DATABASE_URL 存在,就跳过拼接,直接用这串。

  • 于是生产只要多写一行环境变量,不用改代码,也不用管前面那些 POSTGRES_*

  1. Redis 同理
  • 本地默认连 redis://redis:6379/0

  • 云上如果给 REDIS_URL,就优先用云的。

一句话

"变量拼本地,整串切云端",开发、生产各写一行 .env,代码零改动。

Setting up a health check URL endpoint

python 复制代码
@page.get("/up")
def up():
    redis.ping()
    db.engine.execute("SELECT 1")
    return ""

健康的应用才是快乐的应用------说真的,给应用留一个 /up 端点绝对划算。

它让自动化工具定时来"敲门":一旦发现没返回 HTTP 200,或者响应慢得离谱,就立即报警。

上面的 /up 只干两件事:

  1. redis.ping() ------ 证明 Redis 活着;

  2. SELECT 1 ------ 证明 PostgreSQL 活着、账号密码对、至少有读权限。

整个流程在大多数框架里 1 ms 内就完事,不会给服务器添负担。

我偏爱专用端点,因为可以"最小代价换最大情报"。要是去戳首页,得执行 8 条 SQL、渲染 50 k HTML,每分钟来一次,纯属浪费流量。

有了 /up,Docker Compose、Kubernetes 还是外部监控(Uptime Robot)都能直接拿来用。

/up 当路径,纯粹因为短且达意------以前用 /healthy,后来听 Rails 之父 DHH 提了一嘴 /up,觉得妙,就换了。他起名确实有一手!

这段就是在说:

  1. 给应用加个"体检URL"

    单独写个 /up 接口,只干两件事:

    • 对 Redis 喊一句"在吗?"(redis.ping())

    • 对 PostgreSQL 喊一句"在吗?"(SELECT 1) 两声都答应,就返回 HTTP 200,表示"我活得好"。

  2. 为什么不用首页做体检?

    首页可能查 8 次数据库、拼 50 k HTML,每分钟戳一次太浪费。/up 只做最小必要检查,1 毫秒搞定,几乎零开销。

  3. 谁能用这个接口?

    Docker Compose、Kubernetes、各种外部监控(Uptime Robot 等)都能定时来访问 /up,一看不是 200 或响应太慢就发警报。

  4. 为什么叫 /up

    简短、直观------"服务起来没?"→"up!"

    灵感来自 Rails 作者 DHH,他觉得 /up/healthy 更妙,作者听完后也跟着用了。

Dockerfile

现在咱们换个档位,聊聊你的 Dockerfile。接下来要讲的这些概念,放到哪个 Web 框架下几乎都通用。有趣的是,各种示例项目的 Dockerfile 配置居然长得差不多------套路高度一致。

使用多阶段构建来精简镜像体积

python 复制代码
FROM node:14.15.5-buster-slim AS webpack
...
FROM python:3.9.2-slim-buster AS app

Dockerfile 里出现两条 FROM,就是两个阶段 ,分别命名为 webpackapp

python 复制代码
COPY --chown=python:python --from=webpack /app/public /public

在 Node 阶段,我们把打包后的静态资源输出到 /app/public;上面这行在 Python 阶段把这些文件复制到 /public

这样最终 Python 镜像里只多了几份 CSS、JS 和图片,无需再安装 Node、Webpack 及其 500 MB+ 的依赖。之后还能用卷挂载给宿主机 Nginx 读取。

这与前面提到的 override 文件模式形成闭环:线上镜像自带已构建的静态资源,生产环境无需再运行 Webpack。

这只是多阶段构建的一个示例。

Play 和 Phoenix 的示例项目也展示了类似优化:它们都能生成仅包含运行所需最小文件的 jar 或 release。

最终我们得到的是体积更小、拉取更快、运行更轻的生产镜像。

这段一句话总结:

用 Docker 的「多阶段构建」把「编译工具链」和「最终运行环境」彻底分开------

  • 只在第一阶段(Node)里装 Webpack、打包静态文件;

  • 第二阶段(Python)只拷走打包好的 CSS/JS,不把 Node 生态带进去;

结果:生产镜像瘦掉几百 MB,构建更快、推送更快、启动更快,还兼容本地开发实时编译的套路。

相关推荐
忧郁的橙子.3 小时前
十二、kubernetes 1.29 之 存储 Volume、pv/pvc
云原生·容器·kubernetes
灰灰老师3 小时前
在Ubuntu22.04和24.04中安装Docker并安装和配置Java、Mysql、Tomcat
java·mysql·docker·tomcat
im_AMBER3 小时前
数据结构 04 栈和队列
数据结构·笔记·学习
霍小毛4 小时前
Kubernetes云平台管理实战:滚动升级与秒级回滚
java·容器·kubernetes
尘似鹤4 小时前
微信小程序学习(六)--多媒体操作
学习·微信小程序·小程序
IT东4 小时前
用 Docker + Squoosh 打造图片压缩 API 服务
运维·docker·容器
UpYoung!4 小时前
无广技术贴!【PDF编辑器】Solid Converter PDF保姆级图文下载安装指南——实用推荐之PDF编辑软件
学习·数学建模·pdf·编辑器·运维开发·个人开发
达瓦里氏1235 小时前
重排反应是什么?从分子变化到四大关键特征解析
数据库·学习·化学
神秘人X7075 小时前
Docker监控:cAdvisor+Prometheus+Grafana实战指南
docker·grafana·prometheus