在我们的 VPS 套餐上,大多数小型生产应用栈都是用 Docker Compose 运行的,这是一种很不错的做法。真正让人后悔的,往往是默认配置:重启后起不来的容器、到第三个月就把磁盘写满的日志、一个失控的进程拖垮整台机器,以及只备份了容器、却没备份数据的备份。每一个问题,都只需两行配置就能修好。下面把它们全部列出,按您会用到的先后顺序排列。
经得起时间考验的目录布局
每个应用栈在 /srv 下占一个目录,里面放 Compose 文件、存放机密信息的 .env,以及一个 data/ 目录,用来存放您想直接查看的 bind mount 数据。其余一切都用命名卷(named volume)。别搞花样:
/srv/app/
compose.yaml
.env # chmod 600, never in git
data/caddy/ # bind mounts you want to inspect
/var/lib/docker/volumes/ # named volumes, managed by Docker
Docker 一键应用会安装 Engine 27 和 Compose v2,并把守护进程的数据放在 NVMe 根盘上。如果您以后加挂了一块单独的存储卷,请把 /var/lib/docker 迁移过去,并通过 data-root(写在 daemon.json 里)指定新位置,而不要使用符号链接。
重启后自动恢复
内核更新会在夜间重启服务器。每个服务都必须声明,重启之后自己希望发生什么:
services:
app:
image: ghcr.io/example/app:1.8.2
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
db:
image: postgres:16.4
restart: unless-stopped
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 5
volumes:
pgdata:
这段配置里有两个习惯,比看上去重要得多。把镜像标签固定到某个具体版本,绝不要用 latest:凌晨 4 点的一次重启拉取了 PostgreSQL 的新主版本,是最让人无从排查的那种故障。另外,带有 depends_on 条件的健康检查,可以避免数据库还在启动时,应用不断崩溃重启。
别让日志写满磁盘
默认情况下,Docker 会把每个容器写过的每一行日志,以 JSON 格式永久保存。一个日志量很大的应用,几个月就能写满 40 GB。只需对整个守护进程做一次配置,就能解决:
cat > /etc/docker/daemon.json <<'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "20m", "max-file": "5" },
"default-address-pools": [{ "base": "172.30.0.0/16", "size": 24 }]
}
EOF
systemctl restart docker
已有的容器在重新创建之前仍沿用旧设置;执行 docker compose up -d --force-recreate 即可使其生效。其中地址池(address-pool)那一行是额外的好处,能避免日后 Compose 网络与 WireGuard 或办公网络的网段冲突。
别让一个失控进程拖垮整台机器
如果不设限制,某个容器的内存泄漏会让内核的 OOM killer 去挑一个“受害者”,而它挑中的往往是数据库。请给所有容器都设置内存上限,宽松一点,但必须有上限:
deploy:
resources:
limits:
memory: 1g
cpus: "2.0"
Compose v2 在不使用 Swarm 的情况下也会遵循 deploy.resources.limits。设置各项限制时,要让它们的总和低于 VPS 内存减去留给宿主机的约 1 GB;在 8 GB 的 VPS-4 上,大约有 6.5 GB 可以分配。当某个容器触及上限时,只有它自己会重启,事件日志会告诉您是哪一个。
带自动 TLS 的反向代理
应用端口只绑定到 localhost,把 80 和 443 端口交给唯一的一个代理。容器中的 Caddy 会自行获取证书,并在配置变更时自动重新加载:
services:
caddy:
image: caddy:2.8
restart: unless-stopped
ports: ["80:80", "443:443", "443:443/udp"]
volumes:
- ./data/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
app:
expose: ["3000"] # reachable by caddy on the compose network, not published
volumes:
caddy_data:
Caddyfile 每个站点只需一行:app.example.com { reverse_proxy app:3000 }。只发布代理的端口,还能绕开 Docker 在 ufw 上“打洞”这个众所周知的问题:除了 80 和 443,不会发布任何其他端口。
机密信息
把它们放进 .env 并执行 chmod 600,在 Compose 文件中用 ${DB_PASSWORD} 引用,并且不要把 .env 提交到 git,旁边放一份 .env.example。如果机密不止寥寥几项,那么以文件形式挂载的 Compose secrets: 要比环境变量更干净,因为环境变量会泄露到 docker inspect 的输出和崩溃报告中。
更新不出意外
cd /srv/app
docker compose pull # fetches the new pinned tags you edited
docker compose up -d # recreates only what changed
docker image prune -f # frees the old layers
改标签,pull,up。在单台 VPS 上,类似 Watchtower 的自动更新工具很诱人,但一次破坏性变更也正是这样在午夜到来的;如果您非要用,请把版本固定到次版本号,这样它就只能应用补丁版本。
备份卷,而不是容器
容器是可丢弃的;卷才是数据。对于数据库,请用 dump 导出,而不是直接复制其底层的文件:
docker compose exec -T db pg_dump -U postgres -Fc app > /srv/backups/app-$(date +%F).dump
对于其他所有数据,可以用一个一次性容器给卷打 tar 包:docker run --rm -v app_uploads:/v -v /srv/backups:/b alpine tar czf /b/uploads-$(date +%F).tgz -C /v .。用 restic 把 /srv/backups 传出服务器,具体做法见《真正能恢复的备份》。大型更新之前先在控制面板里做一个快照,几秒钟就能满足“撤销”的需求。
第一时间发现故障
docker compose ps 可以查看健康状态;docker stats --no-stream 可以看到谁在吃内存。如果想要一个常驻的视图,可以使用 netdata 附加服务,它会自动发现容器,并为每个容器绘制 CPU、内存和重启次数的曲线。请针对重启设置告警:一个小时内重启了三次的容器,会赶在用户之前告诉您出了问题。
检查清单
- 固定的镜像标签、
restart: unless-stopped、健康检查,以及depends_on条件。 - 作用于整个守护进程的日志轮转。
- 每个服务都设置内存上限,且总和低于 VPS 的内存。
- 由一个反向代理发布 80 和 443 端口;其余一切都留在内部网络中。
- 机密信息放在权限为 600 的
.env中,不进 git。 - 数据库导出和卷归档每晚传送到异地,并且每月在别处做一次实际恢复。
按这种方式配置的 VPS-4,可以运行一套由五到八个服务组成的典型应用栈,一连数年都不需要任何人登录,这是对一台服务器的最高赞美。
