用了两年 Docker Compose,我把这些套路固定下来了
刚开始用 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 下面,一个服务一个目录: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 /srv ├── blog │ ├── compose.yaml │ └── data ├── db │ ├── compose.yaml │ ├── conf │ └── data ├── proxy │ ├── compose.yaml │ ├── conf.d │ └── certs └── monitor ├── compose.yaml └── data 数据统一放在服务目录下的 data 子目录,然后挂载进去。这样备份的时候,把 /srv 整个打包就够了,不用去 /var/lib/docker 里翻那些名字是哈希的目录。 这一点很关键。Docker 的具名卷虽然方便,但它在文件系统里藏在 /var/lib/docker/volumes/ 下面,备份脚本写起来很难看。用 bind mount 直接映射到自己的目录,什么都清楚。 一个完整的例子 拿博客举例。我的博客是 Hugo 生成的静态文件,用一个 nginx 容器托管: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 services: blog: image: nginx:1.25-alpine container_name: blog restart: unless-stopped ports: - "127.0.0.1:8080:80" volumes: - ./public:/usr/share/nginx/html:ro - ./conf.d:/etc/nginx/conf.d:ro healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"] interval: 30s timeout: 5s retries: 3 logging: driver: json-file options: max-size: "10m" max-file: "3" 几个地方值得说。 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 我一般这么写: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 services: mariadb: image: mariadb:11.2 container_name: mariadb restart: unless-stopped environment: MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root MARIADB_DATABASE: app MARIADB_USER: app MARIADB_PASSWORD_FILE: /run/secrets/db_app TZ: Asia/Shanghai volumes: - ./data:/var/lib/mysql - ./conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro ports: - "127.0.0.1:3306:3306" healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s timeout: 5s retries: 10 start_period: 30s secrets: db_root: file: ./secrets/db_root.txt db_app: file: ./secrets/db_app.txt 密码用 secrets 而不是直接写 environment。这样 docker inspect 出来的环境变量里看不到明文,compose.yaml 也可以放心提交到私有仓库。文件权限设成 600 就行。 start_period 这个参数新手容易忽略。它是给容器一个"还在启动中,别急着判定失败"的宽限期。MariaDB 第一次初始化数据目录要花几十秒,不设这个,健康检查会一直失败,把容器标记成 unhealthy。 healthcheck.sh 是 MariaDB 官方镜像自带的脚本,比自己拼 mysqladmin ping 可靠,因为它会真的等到 InnoDB 初始化完。 应用那边要等数据库好了再起,就这么写: 1 2 3 4 5 app: image: myapp:latest depends_on: mariadb: condition: service_healthy 注意是 service_healthy,不是默认的 service_started。默认那个只保证容器进程起来了,不保证里面服务能连。我第一次用默认值的时候,应用每次重启都要重试好几次才连上数据库,日志里一堆 connection refused。 网络怎么连 Compose 默认会给项目建一个 bridge 网络,同一个文件里的服务互相用服务名就能访问。这个挺好用,但跨文件就不行了。 我有两个选择。要么把相关服务塞进同一个 compose 文件,要么建一个外部网络让它们共享。 我选了后者,因为数据库和博客的更新节奏不一样,混在一个文件里,改博客的配置会把数据库也带着重建一次,太危险。 建网络: 1 docker network create --driver bridge shared 然后每个文件里都声明: 1 2 3 networks: shared: external: true 服务上再加一句: 1 2 networks: - shared 这样博客容器里直接 ping mariadb 就能通。 不过这里有个坑,container_name 和自定义网络里的服务名是两套东西。用 container_name 的时候,容器在网络里的别名是那个名字,不是 services 下的键。我一开始没注意,配置里写服务名连不上,改成 container_name 才对上。 我的建议是干脆别用 container_name,让 Compose 自己按 项目名-服务名-序号 生成。虽然名字长一点,但不会和别的项目冲突,扩容的时候也不会撞名。 反代那一层 nginx 做反向代理的容器,配置文件单独放: 1 2 3 4 5 6 7 8 9 10 11 12 server { listen 80; server_name blog.mibase.top; location / { proxy_pass http://blog:80; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } proxy_pass 里写的是容器名,因为它在同一个 shared 网络里,Docker 的内置 DNS 会解析。 X-Forwarded-For 这几行别省。省了之后,nginx 的访问日志里全是反代容器的内网 IP,你想看真实访客来源就看不到了。Cloudflare 那边传过来的真实 IP 也会在这一层层里丢掉。 更新镜像的流程 我不用 watchtower 那种自动更新的东西。自动更新很爽,但它会在你睡觉的时候把一个能跑的服务换成一个跑不起来的版本。 我的流程是手动但固定: 1 2 3 4 5 cd /srv/db docker compose pull docker compose up -d docker compose ps docker compose logs --tail=50 拉完先看 ps,确认状态是 running 并且 healthy,再看日志有没有报错。有问题就 docker compose down 然后换回上一个 tag。 所以镜像 tag 我尽量不用 latest,写具体版本号。mariadb:11.2 比 mariadb:latest 可控太多了。 备份怎么配合 因为数据都在 /srv/*/data 下面,备份脚本写起来很直接: 1 2 3 4 #!/bin/bash set -euo pipefail ts=$(date +%F) tar -czf /backup/srv-$ts.tar.gz -C / srv --exclude='*/cache/*' 不过数据库不能这么直接打包,热备份出来的文件可能是不一致的。数据库我单独用 mariadb-dump 导出: 1 docker compose exec -T mariadb mariadb-dump --all-databases --single-transaction > /backup/db-$ts.sql --single-transaction 保证导出的是一个一致的时间点,不会因为导出过程中有人写入而错乱。 写在最后 Compose 这个工具本身不复杂,难的是把使用习惯固定下来。 我现在新加一个服务,基本就是复制一个已有的目录,改改镜像名和端口,十分钟能跑起来。出问题的时候也知道去哪儿看,日志在哪、数据在哪、配置在哪,都是固定的位置。 这套东西没什么高深的技术,但它让我在面对一台跑了两年、装着七八个服务的机器时,心里是有底的。这大概就是运维这件事最实际的价值。
以上内容来自博客订阅源,版权归原作者。关注作者后可以在 关注列表 第一时间查看作者文章。

登录后,加入这场讨论。
登录参与