自从按LXC 和虚拟机那篇的思路把 Docker 都挪进一台专门的虚拟机(192.168.10.21)以后,上面的服务越加越多,到现在已经有十来个了。早期有的是一条长长的 docker run 命令,有的写在 compose 文件里;有的数据放在命名卷里,有的挂在家目录下……上个月想把其中一个服务迁走,翻了半天才找齐它的数据。趁着五一假期,把整套东西按统一的规矩重新整理了一遍。

目录约定

所有服务都放在 /srv/compose 下面,一个服务一个目录:

/srv/compose/
├── .gitignore
├── update-all.sh
├── miniflux/
│   ├── compose.yaml
│   ├── .env          # 密码等敏感信息,不进 git
│   └── data/         # 所有持久化数据,不进 git
├── vaultwarden/
│   ├── compose.yaml
│   ├── .env
│   └── data/
└── homepage/
    ├── compose.yaml
    └── data/

我给自己定的几条规矩:

  1. 一个服务一个目录,它依赖的数据库、缓存也放在同一个 compose 文件里。Compose 默认用目录名做项目名,所以目录名也决定了容器名的前缀。
  2. 文件统一叫 compose.yaml,这是现在 Compose 首选的文件名。
  3. 数据一律 bind mount 到 ./data/ 下,不用命名卷。备份和迁移时整个目录打包带走就行,docker compose down -v 手滑也不会把数据删掉。
  4. 密码等敏感信息放 .env,compose.yaml 里用 ${变量名} 引用。
  5. 镜像固定版本号,不用 latest。
  6. 整个 /srv/compose 用 git 管理,.gitignore 里排除 */.env 和 */data/。每次改了什么、什么时候升的级,都有据可查。

一个完整的例子

以 RSS 阅读器 Miniflux 为例,它需要一个 PostgreSQL:

services:
  miniflux:
    image: miniflux/miniflux:2.2.6
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "192.168.10.21:8080:8080"
    environment:
      DATABASE_URL: postgres://miniflux:${DB_PASSWORD}@db/miniflux?sslmode=disable
      RUN_MIGRATIONS: 1
      CREATE_ADMIN: 1
      ADMIN_USERNAME: admin
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: miniflux
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: miniflux
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "miniflux"]
      interval: 10s
      start_period: 30s

几个细节:文件开头不再写 version:,新版 Compose 会提示这个字段已经过时;depends_on 配合 healthcheck,保证数据库真正可用后应用才启动;端口绑定在虚拟机自己的局域网 IP 上,而不是默认的所有地址。postgres:16 我只固定了大版本,16.x 的小版本更新可以放心跟。

更新流程

我不用自动更新工具,而是每周末手动过一遍:

  1. 平时用 Miniflux 订阅各个项目发布页的 Atom 源,有新版本时能看到更新说明,重点找 breaking changes。
  2. 升级前先给虚拟机打个快照:在 PVE 宿主机上执行 qm snapshot 200 pre-update,出了问题可以整机回滚。
  3. 改 compose.yaml 里的版本号,git commit 记一笔。
  4. 进对应目录执行 docker compose pull && docker compose up -d,然后 docker compose logs -f --tail=100 盯一会儿日志。
  5. 跑一两天没问题,把快照删掉。

对于只固定了大版本的镜像,我写了个小脚本批量拉一遍:

#!/usr/bin/env bash
# /srv/compose/update-all.sh:逐个目录拉取镜像,并重建有变化的容器
set -euo pipefail

for dir in /srv/compose/*/; do
  [ -f "${dir}compose.yaml" ] || continue
  echo "==> ${dir}"
  (cd "$dir" && docker compose pull --quiet && docker compose up -d --remove-orphans)
done

# 清理不再被使用的旧镜像
docker image prune -f

up -d 只会重建镜像或配置发生了变化的容器,其他的不受影响,所以全部跑一遍也没关系。

踩过的坑

1. 日志把磁盘写满。 Docker 默认的 json-file 日志不做轮转,有个服务一直在刷错误日志,半年攒了好几个 G。在 /etc/docker/daemon.json 里给日志设上限:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

重启 Docker 之后,这个设置只对新创建的容器生效,已有的容器要 docker compose up -d --force-recreate 一遍。

2. 端口映射绕过了防火墙。 我在虚拟机上开了 ufw,以为没放行的端口外面就访问不到,结果发现 Docker 发布的端口照样能连。原因是 Docker 直接往 iptables 里插自己的规则,流量根本不经过 ufw 管的那几条链。所以才有了上面“端口绑定到具体 IP”那条规矩,只给本机用的服务干脆绑到 127.0.0.1。

3. 数据库大版本不能直接改 tag。 有一次手痒把 postgres:15 改成了 16,结果数据库起不来,日志说数据目录是旧版本初始化的,不兼容。PostgreSQL 跨大版本要先 pg_dump 导出、换新版本后再导入。好在有快照,回滚了事。现在数据库的大版本,我只在专门腾出时间时才动。

4. 目录权限。 bind mount 的目录如果事先不存在,Docker 会以 root 身份创建它,而有些镜像里的程序是以普通用户运行的,启动后写不进去。遇到这种情况,先看镜像文档里程序用的 UID,再把 data 下对应的目录 chown 过去。

小结

这套约定说不上多高明,核心就是“一个目录装下一个服务的全部”,再加上 git 记录变更。整理完之后最直接的感受是心里有底了:哪个服务在哪、数据在哪、上次什么时候升的级,一眼就能看到。下一步要解决的,是这些 data 目录的异地备份。