单机部署这件事,Docker Compose 几乎是个人站长的默认选择:一个 yml 文件描述完所有服务,一条命令全部拉起来。但用了两三年以后回头看,绝大多数折腾出来的故障,根因都不在镜像或者代码上,而在那份 yml 文件的写法上。
这篇文章整理我在自己几台机器上反复遇到、也帮别人排过的 Compose 用法问题。每一条都给出「为什么错」和「改成什么」,不讨论多机编排,只谈单机。
一、反模式一:用 latest 标签,然后奇怪为什么昨天还好今天挂了
最常见的一条,也是后果最严重的一条。yml 里写 image: postgres:latest,某天机器重启、容器被重建,拉下来一个新版本,主版本号跨了一级,数据目录格式不兼容,数据库直接起不来。更麻烦的是这时候旧镜像已经被覆盖或者被 prune 清掉了,回滚都无从下手。
正确做法是把标签钉死到主版本,最好到次版本:
services:
db:
image: postgres:15.4
cache:
image: redis:7.2-alpine钉死以后不代表不能升级,而是升级变成一个「明确的动作」:改标签、备份数据、拉新镜像、起容器、验数据、出问题改回去。这个动作和你某次重启时被动发生的升级,性质完全不同。
还有一个细节值得注意:docker compose pull 之后旧镜像会变成 dangling,docker image prune 默认会清掉它们。如果你希望保留上一版用于回滚,升级前先打个标签:
docker tag postgres:15.4 postgres:15.4-rollback-20261106二、反模式二:数据用匿名卷,删容器等于删数据
Compose 对数据持久化有三种写法,重要性差异极大:
- 匿名卷:
volumes: - /var/lib/postgresql/data,只有一个容器内路径。Compose 会自动建一个随机名字的卷,一旦docker compose down -v或者同名卷被清,数据就没了,而且你根本不知道那个随机名字是什么。 - 命名卷:
- pgdata:/var/lib/postgresql/data,配合顶层volumes: pgdata:。名字可控,能备份能迁移,是绝大多数场景的正确选择。 - 绑定挂载:
- ./data:/var/lib/postgresql/data,直接映射到宿主机目录。适合需要人直接看、直接改的配置和数据(比如站点文件、证书目录)。
数据库这类「格式由程序管理」的数据用命名卷;配置文件、上传目录这类「你需要经常动」的用绑定挂载。用绑定挂载挂数据库还有个隐性坑:文件属主和权限会跟着宿主机走,容器里的 postgres 用户(uid 999)在宿主机上可能没有对应,结果数据目录权限不对,起不来,报错还很不直观。
三、反模式三:不写 restart 策略,一次 OOM 之后站点静默下线
Compose 起来的容器默认 restart 策略是 no,进程一退就永远停在那里。半夜内存被吃光,内核 OOM killer 挑中一个容器杀掉,站点就静默下线了,直到第二天早上你发现打不开。
services:
web:
restart: unless-stopped为什么用 unless-stopped 而不是 always:两者都会在异常退出时重启,但 always 会在你手动 docker compose stop 之后、下次 docker 服务重启时把容器又拉起来,行为不符合直觉。unless-stopped 尊重你的手动停止。
restart 策略不是万能药。如果容器是因为配置错误进入「启动即退出」循环,restart 会让它疯狂重启,日志被刷爆,CPU 空转。所以重启次数必须有监控:
docker inspect --format '{{.RestartCount}} {{.Name}}' $(docker ps -aq)这个数字在一天内涨到几十的容器,一定要看日志。
四、反模式四:healthcheck 只写不读,depends_on 形同虚设
很多人加了 depends_on 就以为服务启动顺序有保证了。实际上不加条件的 depends_on 只保证「容器已启动」,不保证「服务已就绪」。MySQL 容器启动了,但它还在初始化数据目录,端口还没监听,PHP 容器已经开始连它,连接失败。
services:
db:
image: mysql:8.0
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 10s
timeout: 5s
retries: 6
start_period: 60s
app:
depends_on:
db:
condition: service_healthystart_period 这一项特别重要,它表示「这段时间内的失败不计入 retries」。MySQL 首次初始化要几十秒,没有 start_period 的话,健康检查在前 30 秒就耗尽重试次数,容器被判定 unhealthy。
healthcheck 的能量也不只是在启动阶段。它把「服务是否可用」变成一个可被外部读取的状态,监控、告警、自动化脚本都能用它做判断条件,比 ping 端口可靠得多 —— 端口监听着但数据库连不上是常态。
五、反模式五:日志不设上限,几个月后根分区满了
Docker 默认的 json-file 日志驱动不做任何限制。一个话多的应用(访问日志打到 stdout、每次请求打几行)跑三个月,日志文件能到几个 G。小机器根分区一共 20G,满了以后数据库写不进去,整站挂。
services:
web:
logging:
driver: "json-file"
options:
max-size: "20m"
max-file: "3"这一组配置表示单个文件最大 20M,最多保留 3 个,也就是单个容器最多 60M 日志。对排查够用了(最近几小时到几天的日志),长期留存应该走应用层写文件 + logrotate,而不是靠 docker 的日志驱动。
已经跑了一段时间的机器,可以先用这条命令看谁在吃磁盘:
du -sh /var/lib/docker/containers/*/*-json.log | sort -rh | head清理的时候千万不要直接 rm 日志文件 —— 文件被 docker 进程持有,删掉之后磁盘空间不会释放(inode 还在),得用 truncate -s 0 文件路径,或者干脆重建容器让它用新配置。
六、反模式六:端口全映射到 0.0.0.0
yml 里写 ports: - "3306:3306",等于把 MySQL 暴露在所有网卡上。有些不配密码或者用弱密码的镜像(开发默认值),直接就变成了公网上的一个洞。站上被扫到、爆破、拖库,很多都是这么来的。
原则很简单:只有真正需要对外提供服务的容器才映射端口,且只映射需要的那个。数据库、缓存、内部 API 一律不写 ports,同网络里的其他容器用服务名互相访问(Compose 默认会给项目建一个 network,服务名就是主机名)。
services:
db:
image: mysql:8.0
# 不写 ports,只在内部网络可达
web:
ports:
- "127.0.0.1:8080:80" # 只给本机 Nginx 反代用写成 127.0.0.1:8080:80 而不是 8080:80,是让这个端口只监听回环地址,公网直接访问不到,必须经过前面那层 Nginx。这一条几乎零成本,能挡掉大量扫描器。
七、反模式七:所有东西塞进一个 compose 文件
从一开始两个服务,慢慢变成十个:网站、数据库、缓存、监控、备份、图床、搜索、定时任务……全在一个 compose.yml 里。结果是想重启一下博客,得小心别碰坏别的;想单独升级一个服务,还得顾及依赖关系。
合理的切法是按「变更频率和生命周期」分组:
- 核心站点服务一组(web + db + cache),一起备份、一起升级
- 辅助服务各自一组或者几个一组(监控、备份、图床),独立启停
- 一次性的、实验性的服务单独放,随时可以
down -v删掉
Compose 支持 -p 项目名 指定项目,也支持多个 -f 叠加文件。分开以后,每个目录都有自己的 yml,启动命令 docker compose -p blog up -d,出了问题定位范围一下缩小。
八、反模式八:把密码写在 yml 里,然后提交到 Git
这个不用多解释,但发生率高得惊人。即使仓库是私有的,也迟早会有人拷贝、备份、迁移的时候带出去。
Compose 原生支持 env 文件,最简单的做法是把敏感项抽到 .env,并确保它在 .gitignore 里:
# compose.yml
services:
db:
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
# .env(不进版本库)
DB_ROOT_PASSWORD=一个足够长的随机串更进一步用 secrets:,把密码放在单独的文件里挂载进容器。对个人站来说 .env 已经够用,关键是这个习惯要从第一版 yml 就建立,等到有十个服务再来改,成本高得多。
九、一份可以直接抄的模板
把上面所有要点合起来,一个「站点 + 数据库 + 缓存」的最小可用模板大概长这样:
services:
web:
image: nginx:1.25-alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro
depends_on:
db:
condition: service_healthy
logging:
driver: json-file
options: {max-size: "20m", max-file: "3"}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
interval: 30s
timeout: 5s
retries: 3
db:
image: mysql:8.0.35
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- dbdata:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h127.0.0.1", "-p${DB_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 6
start_period: 60s
logging:
driver: json-file
options: {max-size: "20m", max-file: "3"}
volumes:
dbdata:这个模板里没有一处是「高级技巧」,全是默认值没设对的地方。真实环境里还会加上 CPU/内存限额、时区、ulimit,但这几项属于按需调优,不是必须。
十、小结
回头看这八条,会发现一个共性:Compose 的默认值是为「跑个 demo」设计的,而小站要的是「跑三年不出事」。两者的差距就体现在这些默认值上 —— 镜像标签、重启策略、日志上限、端口绑定、数据卷类型。
建议做一件事:把现在正在用的 compose.yml 打开,逐条对照上面八条过一遍,把缺的补上。整个过程大概二十分钟,但能避免的故障数量,比接下来半年做的任何优化都多。改完先 docker compose config 校验一下语法,再找个人少的时段 up -d 让它重建一次,确认服务都能正常起来再走。