刚开始用 Docker 那阵子,我特别喜欢在命令行里敲 docker run。参数一行能拉出去两百多个字符,-v、-p、-e 混在一起,看着挺有成就感。
直到有一次我为了调一个环境变量,把容器删了重建,结果忘了当初挂载的路径是哪个。数据卷还在,但容器起不来了,日志里全是找不到配置文件的报错。我在 docker inspect 的输出里翻了十几分钟,才从一个早已忘记的临时目录里把东西找回来。
那次之后我就老老实实用 Compose 了。现在服务器上所有的服务,都是一个个 compose.yaml 管着。
为什么是 Compose 而不是别的
有人会说,你都上 Docker 了,干嘛不上 Kubernetes。
我的答案很朴素,我就一台 2 核 4G 的机器,跑着七八个容器。K8s 的控制面自己就要吃掉一两个 G,剩下的空间还不够我塞服务。而且我一个人维护,出问题的时候需要的是能快速看懂的东西,不是一整套抽象。
Compose 的好处是,所有配置都在一个文件里,看得见摸得着。改完 docker compose up -d,它自己算差异,只重建变化的部分。要回滚就把文件换回去再 up 一次。
还有个被低估的点,就是文件本身就是文档。半年后我忘了某个服务是怎么配的,打开文件一看全明白了。
目录怎么摆
我现在的习惯是,所有服务放在 /srv 下面,一个服务一个目录:
|
|
数据统一放在服务目录下的 data 子目录,然后挂载进去。这样备份的时候,把 /srv 整个打包就够了,不用去 /var/lib/docker 里翻那些名字是哈希的目录。
这一点很关键。Docker 的具名卷虽然方便,但它在文件系统里藏在 /var/lib/docker/volumes/ 下面,备份脚本写起来很难看。用 bind mount 直接映射到自己的目录,什么都清楚。
一个完整的例子
拿博客举例。我的博客是 Hugo 生成的静态文件,用一个 nginx 容器托管:
|
|
几个地方值得说。
ports 我只绑了 127.0.0.1,不写 0.0.0.0。意思是这个端口只能从本机访问,外面进不来。真正对外的入口交给 nginx 反向代理,这样容器端口就不会直接暴露在公网上。这一点在我被扫过几次之后变得特别重要。
restart: unless-stopped 而不是 always。区别在于,我手动 docker compose stop 之后,unless-stopped 的容器重启机器也不会自己起来,always 会。调试的时候这个区别能省不少事。
logging 那段一定要加。Docker 默认的 json-file 日志没有大小限制,一个话多的服务跑上几个月,能把磁盘写满。我第一次被磁盘告警吵醒就是因为这个,日志文件堆到了 20 多个 G。
healthcheck 是给后面的 depends_on 用的,下面会讲。
数据库和依赖顺序
数据库的 compose 我一般这么写:
|
|
密码用 secrets 而不是直接写 environment。这样 docker inspect 出来的环境变量里看不到明文,compose.yaml 也可以放心提交到私有仓库。文件权限设成 600 就行。
start_period 这个参数新手容易忽略。它是给容器一个"还在启动中,别急着判定失败"的宽限期。MariaDB 第一次初始化数据目录要花几十秒,不设这个,健康检查会一直失败,把容器标记成 unhealthy。
healthcheck.sh 是 MariaDB 官方镜像自带的脚本,比自己拼 mysqladmin ping 可靠,因为它会真的等到 InnoDB 初始化完。
应用那边要等数据库好了再起,就这么写:
|
|
注意是 service_healthy,不是默认的 service_started。默认那个只保证容器进程起来了,不保证里面服务能连。我第一次用默认值的时候,应用每次重启都要重试好几次才连上数据库,日志里一堆 connection refused。
网络怎么连
Compose 默认会给项目建一个 bridge 网络,同一个文件里的服务互相用服务名就能访问。这个挺好用,但跨文件就不行了。
我有两个选择。要么把相关服务塞进同一个 compose 文件,要么建一个外部网络让它们共享。
我选了后者,因为数据库和博客的更新节奏不一样,混在一个文件里,改博客的配置会把数据库也带着重建一次,太危险。
建网络:
|
|
然后每个文件里都声明:
|
|
服务上再加一句:
|
|
这样博客容器里直接 ping mariadb 就能通。
不过这里有个坑,container_name 和自定义网络里的服务名是两套东西。用 container_name 的时候,容器在网络里的别名是那个名字,不是 services 下的键。我一开始没注意,配置里写服务名连不上,改成 container_name 才对上。
我的建议是干脆别用 container_name,让 Compose 自己按 项目名-服务名-序号 生成。虽然名字长一点,但不会和别的项目冲突,扩容的时候也不会撞名。
反代那一层
nginx 做反向代理的容器,配置文件单独放:
|
|
proxy_pass 里写的是容器名,因为它在同一个 shared 网络里,Docker 的内置 DNS 会解析。
X-Forwarded-For 这几行别省。省了之后,nginx 的访问日志里全是反代容器的内网 IP,你想看真实访客来源就看不到了。Cloudflare 那边传过来的真实 IP 也会在这一层层里丢掉。
更新镜像的流程
我不用 watchtower 那种自动更新的东西。自动更新很爽,但它会在你睡觉的时候把一个能跑的服务换成一个跑不起来的版本。
我的流程是手动但固定:
|
|
拉完先看 ps,确认状态是 running 并且 healthy,再看日志有没有报错。有问题就 docker compose down 然后换回上一个 tag。
所以镜像 tag 我尽量不用 latest,写具体版本号。mariadb:11.2 比 mariadb:latest 可控太多了。
备份怎么配合
因为数据都在 /srv/*/data 下面,备份脚本写起来很直接:
|
|
不过数据库不能这么直接打包,热备份出来的文件可能是不一致的。数据库我单独用 mariadb-dump 导出:
|
|
--single-transaction 保证导出的是一个一致的时间点,不会因为导出过程中有人写入而错乱。
写在最后
Compose 这个工具本身不复杂,难的是把使用习惯固定下来。
我现在新加一个服务,基本就是复制一个已有的目录,改改镜像名和端口,十分钟能跑起来。出问题的时候也知道去哪儿看,日志在哪、数据在哪、配置在哪,都是固定的位置。
这套东西没什么高深的技术,但它让我在面对一台跑了两年、装着七八个服务的机器时,心里是有底的。这大概就是运维这件事最实际的价值。