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-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}"
这样就把第一段代码里的所有属性一次性"注入"到 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-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
真正去做业务探活。
充分利用环境变量
仓库里放两份文件:
-
.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。
一句话:先量胃再点菜,单机省钱,集群省"芯"。
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")
- 本地开发(用容器)
-
你把官方 PostgreSQL 镜像跑起来,它要求你设
POSTGRES_USER
、POSTGRES_PASSWORD
等变量。 -
代码里先拿这些变量拼成连接串,例如:
postgresql://hello:password@postgres:5432/hello
-
开发时啥也不用改,直接
docker compose up
就能跑。
- 生产环境(用云端托管库)
-
云厂商(如 AWS RDS、Heroku、阿里云)只给你一个整串 的连接地址,叫
DATABASE_URL
:postgresql://user:pass@aws.rds.amazonaws.com:5432/dbname
-
代码发现
DATABASE_URL
存在,就跳过拼接,直接用这串。 -
于是生产只要多写一行环境变量,不用改代码,也不用管前面那些
POSTGRES_*
。
- 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
只干两件事:
-
redis.ping()
------ 证明 Redis 活着; -
SELECT 1
------ 证明 PostgreSQL 活着、账号密码对、至少有读权限。
整个流程在大多数框架里 1 ms 内就完事,不会给服务器添负担。
我偏爱专用端点,因为可以"最小代价换最大情报"。要是去戳首页,得执行 8 条 SQL、渲染 50 k HTML,每分钟来一次,纯属浪费流量。
有了 /up
,Docker Compose、Kubernetes 还是外部监控(Uptime Robot)都能直接拿来用。
选 /up
当路径,纯粹因为短且达意------以前用 /healthy
,后来听 Rails 之父 DHH 提了一嘴 /up
,觉得妙,就换了。他起名确实有一手!
这段就是在说:
-
给应用加个"体检URL"
单独写个
/up
接口,只干两件事:-
对 Redis 喊一句"在吗?"(
redis.ping()
) -
对 PostgreSQL 喊一句"在吗?"(
SELECT 1
) 两声都答应,就返回 HTTP 200,表示"我活得好"。
-
-
为什么不用首页做体检?
首页可能查 8 次数据库、拼 50 k HTML,每分钟戳一次太浪费。
/up
只做最小必要检查,1 毫秒搞定,几乎零开销。 -
谁能用这个接口?
Docker Compose、Kubernetes、各种外部监控(Uptime Robot 等)都能定时来访问
/up
,一看不是 200 或响应太慢就发警报。 -
为什么叫
/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
,就是两个阶段 ,分别命名为 webpack
和 app
。
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,构建更快、推送更快、启动更快,还兼容本地开发实时编译的套路。