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.yml
和 docker-compose.override.yml
合并成一份配置并启动。整个过程无需额外参数,完全自动。
接下来,你只要把 docker-compose.override.yml
写进 .gitignore(
告诉 Git 哪些文件或目录不需要纳入版本控制(即不被 git add
和 git commit
追踪)。)
,这样代码推到生产(比如你自建的 VPS)时,服务器上根本不存在这个文件——于是开发阶段才需要的服务就不会跑出来。至此,你只用一份主文件就实现了「开发跑、生产不跑」的需求,再也不用维护 docker-compose-dev.yml
和 docker-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-appbuild: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: truevolumes:- "${DOCKER_WEB_VOLUME:-./public:/app/public}"
And then in your Docker Compose services, you can use it like this:
web:<<: *default-appports:- "${DOCKER_WEB_PORT_FORWARD:-127.0.0.1:8000}:8000"worker:<<: *default-appcommand: celery -A "hello.app.celery_app" worker -l "${CELERY_LOG_LEVEL:-info}"
这样就把第一段代码里的所有属性一次性“注入”到 web
和 worker
两个服务里,省掉了大约 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-apphealthcheck: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
真正去做业务探活。
充分利用环境变量
仓库里放两份文件:
-
.env
– 真・变量库,含密钥和各环境差异值,一律写进 .gitignore,永不进版本库。 -
.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,给每个服务画条“资源红线”也有好处:
-
买云主机时心里先有谱,不会为了“保险”多花钱,也不会因为低估配置导致半夜扩容。
-
提前把“饭量”报出来,以后上 Kubernetes 就轻松:
告诉 K8s“这货只吃 75 MB”,调度器就能把 10 个副本稳稳塞进 1 GB 的节点;
如果啥都不填,K8s 只能瞎猜,最后节点资源碎片化,集群白白浪费一大块内存和 CPU。
一句话:先量胃再点菜,单机省钱,集群省“芯”。