自从按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/
我给自己定的几条规矩:
- 一个服务一个目录,它依赖的数据库、缓存也放在同一个 compose 文件里。Compose 默认用目录名做项目名,所以目录名也决定了容器名的前缀。
- 文件统一叫 compose.yaml,这是现在 Compose 首选的文件名。
- 数据一律 bind mount 到 ./data/ 下,不用命名卷。备份和迁移时整个目录打包带走就行,
docker compose down -v手滑也不会把数据删掉。 - 密码等敏感信息放 .env,compose.yaml 里用
${变量名}引用。 - 镜像固定版本号,不用 latest。
- 整个 /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 的小版本更新可以放心跟。
更新流程
我不用自动更新工具,而是每周末手动过一遍:
- 平时用 Miniflux 订阅各个项目发布页的 Atom 源,有新版本时能看到更新说明,重点找 breaking changes。
- 升级前先给虚拟机打个快照:在 PVE 宿主机上执行
qm snapshot 200 pre-update,出了问题可以整机回滚。 - 改 compose.yaml 里的版本号,
git commit记一笔。 - 进对应目录执行
docker compose pull && docker compose up -d,然后docker compose logs -f --tail=100盯一会儿日志。 - 跑一两天没问题,把快照删掉。
对于只固定了大版本的镜像,我写了个小脚本批量拉一遍:
#!/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 目录的异地备份。