ENGINEERING NOTES · 工程笔记

单机 Docker Compose 跑生产:一份 Spring Boot + PostgreSQL 的部署清单

先说结论:小团队起步,一台云主机 + Docker Compose 足够撑过很长一段爬坡期,前提是把"单机能兜住的事"做满、把"单机兜不住的事"想清楚。下面是我们多租户微信小程序 SaaS(Java 21 + Spring Boot + PostgreSQL)单机部署的完整清单,每条按“为什么 + 怎么做”展开,配置均为脱敏示意。

一、背景:为什么不是 K8s

我们不是没评估过 K8s。结论很直白:K8s 的收益在多节点调度和高可用冗余,而当前阶段最大的风险不是"节点挂了",是"没人会修"。单机 Compose 的适用边界先说诚实:

  • 可用性上限 = 单机上限。 宿主机重启、磁盘故障、机房断电,服务就是会停。你的 SLA 能接受分钟级单点中断,就上;不能接受,别勉强。
  • 扩容上限 = 垂直扩容。 换大机型可以,水平拆分做不了。
  • 换来的东西:运维面只有一台机器 + 一个 YAML,出问题 ssh 上去十分钟内能定位。

还有一点常被忽略:镜像是通用的。Compose 与 K8s 的编排文件不互通,但只要应用打成标准镜像、配置全部外置,将来迁 K8s 重写的只是编排层,应用一行不用动。单机阶段把这两件事做干净,就没有 lock-in。

对日活不大、商户数还在爬坡的 SaaS,这笔账是划算的。这份清单,就是让这台单机"稳"的全部动作。

二、部署清单:八条

1. 容器内存限额:MaxRAMPercentage 必须配 mem_limit

先把翻车过程推演一遍。-XX:MaxRAMPercentage=75.0 的语义是"按容器内存上限的百分比算最大堆",坑在后半句的"上限":容器不设 mem_limit 时,JVM 从 cgroup 读到的内存上限就是宿主机总内存。16G 的机器,每个 JVM 容器都按 16G 的 75% 规划出 12G 的堆上限,再叠加元空间、线程栈、CodeCache 与堆外缓冲区。跑两个这样的容器,承诺的内存已超物理内存两倍。

它还不会立刻炸:日常流量下堆远用不到 12G,监控一片绿;某晚批量任务把堆顶上去,物理内存耗尽,内核 OOM killer 出手,按 oom_score 优先砍 RSS 最大的进程——多半是数据库。现场很典型:应用日志干干净净,没有任何异常栈,输出在某个时间点戛然而止(SIGKILL 不给 JVM 留遗言的机会);docker inspect <容器>State.OOMKilledtrue、退出码 137(128 + 9);dmesg 里翻得到 Out of memory: Killed process 的内核记录。排查路径:先看 OOMKilled 确认死因,再用 docker stats 对各容器用量与宿主 free -h 确认超卖,最后回到配置补 mem_limit

修法:MaxRAMPercentage 的分母是 mem_limit 给的。限额 2g、百分比 75%,堆上限才是确定的 1.5G,剩下约 0.5G 留给元空间、线程栈与直接内存。两者必须成对出现,缺一个等于没配。

2. PostgreSQL 不映射宿主端口

为什么:ports 把 5432 挂上公网后,扫描器通常几分钟内到访,之后是无限期的弱口令爆破与版本漏洞探测。算这笔攻击面:数据库的调用方只有同机的 app 容器,这段访问走 Compose 内置网络、按服务名直达,不经过宿主端口——映射端口换不来任何收益,只换来一条二十四小时被人推的门。

怎么做:db 服务不写 ports;要临时查数据,docker compose exec db psql -U <用户> 进容器操作。确有外部工具要连库,开 ssh 隧道转发,用完即关。

3. 数据卷与数据盘规划

为什么:Docker 默认把镜像、容器可写层、日志与卷全放系统盘 /var/lib/docker。镜像一多日志一涨,系统盘先满;/var 写不进东西时连 SSH 都半残,再扩盘就被动了。

怎么做:挂独立数据盘,把 data-root 迁过去,五步——systemctl stop docker docker.socket 停服务;rsync -aHAX /var/lib/docker/ /data/docker/ 拷全量;在 /etc/docker/daemon.json{"data-root": "/data/docker"}systemctl start docker 后用 docker info | grep "Docker Root Dir" 确认生效;稳定跑一两天再删旧目录。PG 数据用命名卷(自然落在数据盘上),不用 bind mount 挂宿主目录——权限与 SELinux 的坑更多,收益为零。

4. 健康检查与依赖顺序

为什么:depends_on 默认只保证"启动顺序",不保证"就绪"。PG 进程起来了但还没接受连接,Spring Boot 第一波初始化直接吃连接拒绝;靠 restart 重试能自愈,但冷启动日志里每次都躺着吓人的异常。

怎么做:pg_isready 配 healthcheck,应用侧声明 condition: service_healthy,叠加 restart: unless-stopped,冷启动才干净。

5. 日志轮转

为什么:json-file 驱动默认不限大小,磁盘被日志撑爆是单机最常见事故之一,形态是日志量翻几倍、三天写满盘、所有服务一起躺倒。

怎么做:每个服务都配 max-size + max-file,三行配置,最便宜的保险。

6. 备份:每日 pg_dump + 异地副本 + Persistent=true

单机部署,备份是全部底线——没有“另一副本在别处”这层兜底。怎么做,先给脚本(脱敏伪代码):

#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F)
docker compose exec -T db pg_dump -U "$PG_USER" -Fc "$PG_DB" \
  > "/backup/db_${STAMP}.dump"
find /backup -name 'db_*.dump' -mtime +7 -delete  # 本地留一周
# 随后由同步任务推到对象存储,异地保留 30 天

定时器两个要点:用 systemd timer 而非 cron;OnCalendar=--* 03:30:00 之外务必加 Persistent=true——机器重启错过的那次,开机后自动补跑,这条 cron 给不了。最后是恢复演练:每季度取一份异地文件在新机器上 pg_restore 走全流程,没恢复过的备份等于没有。

7. 安全组只开必要端口

为什么:安全组在流量到达主机之前就完成拦截,零成本、零维护。公网只开 80/443,SSH 限源 IP 或走堡垒机,数据库与内网组件一概不暴露。反例是"先全开 0.0.0.0/0,回头再收紧"——那个"回头"通常永远不会来。

8. HTTPS:反向代理 + 证书自动续期

为什么:应用端口直接对外,等于把 TLS、限流、证书续期全塞进业务进程,一项出事就是全站事故。

怎么做:入口放一个反向代理容器(Nginx/Caddy 均可),应用端口不直接对外。证书走 ACME 自动签发与续期(HTTP-01/TLS-ALPN 挑战挂在代理上),续期后 reload 生效。要点就一个:续期必须全自动,靠人肉记日期的证书迟早过期。

三、Compose 脱敏示例片段

services:
  app:
    image: registry.example.com/ns/saas-app:vX.Y.Z      # 占位
    environment:
      TZ: Asia/Shanghai
      JAVA_TOOL_OPTIONS: >-
        -XX:MaxRAMPercentage=75.0
        -Duser.timezone=Asia/Shanghai
    mem_limit: 2g                # 与 MaxRAMPercentage 成对出现
    depends_on:
      db:
        condition: service_healthy
    logging:
      driver: json-file
      options: { max-size: "50m", max-file: "5" }
    restart: unless-stopped

  db:
    image: postgres:16
    environment:
      TZ: Asia/Shanghai
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password  # 密钥文件注入,占位
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
      interval: 10s
      timeout: 3s
      retries: 5
    logging:
      driver: json-file
      options: { max-size: "20m", max-file: "5" }
    restart: unless-stopped
    # 注意:没有 ports,数据库只在内部网络可达

volumes:
  pgdata:

密钥不写在 YAML 里:用 secrets 文件或独立 env 文件注入,且不进 Git 仓库。

四、踩坑复盘:三起事故

事故一:down -v 十秒删库

现场:周五晚上想"清干净环境重拉一版",在错误目录执行了 docker compose down -v,十秒后命令跑完,PG 数据卷没了。复盘:-v 会把 Compose 文件声明的命名卷一并删除,PG 数据卷正是命名卷,且删除不进回收站。恢复只有一条路——从第 6 条的异地备份 pg_restore 到新卷,损失等于最近一次备份之后的增量。此后定死两条规矩:日常只用 down(不带 -v)和 up -d;确要删卷,用 docker volume rm 显式点名,永远不把这个决定交给一个"顺手"的参数。

事故二:差八小时的"每日"备份

现场:商户反馈订单时间整体差八小时。排查三步:先查数据库,timestamptz 的存储与换算都对,排除;再看应用日志,时间戳全是 UTC,确认容器没设 TZ;最后看定时任务——本该 03:30 跑的备份实际跑在 11:30,因为 cron 与 systemd timer 走的“本地时区”其实是 UTC。修法是三处统一:容器 TZ=Asia/Shanghai、JVM -Duser.timezone=Asia/Shanghai、PG 的 timezone 参数。别信"存的是 timestamptz 就没事"——存储是对的,调度和日志走的是另一条时区链路。

事故三:无 swap,OOM 先砍数据库

现场:云镜像默认无 swap,某晚内存瞬时打满,OOM killer 砍掉 RSS 最大的数据库。复盘出两个盲区:一是只看宿主 free -h 不看容器维度,docker stats 才反映各容器相对 limit 的水位;二是无 swap 时内存曲线“到顶即死”,没有缓冲带。修法:留一小块 swap(物理内存的一半到一倍)当缓冲,vm.swappiness 调低到 10 以下,让 swap 只在极端时刻兜底、不做日常;再加一条告警——任一容器内存超 limit 八成且持续五分钟就通知,把"到顶即死"换成"八成就有人知道"。

三句话总结

  1. 单机 Compose 不是妥协,是把有限运维人力押在一台看得懂的机器上——但可用性上限就是单机,SLA 想清楚再上。
  2. 内存限额成对配、数据库不开端口、数据在数据盘、备份异地且可恢复——这四条做到,单机的下限就有了。
  3. 迁 K8s 的时机:当你开始为"第二台机器"写脚本时再迁不迟;迁移的底气,来自 Compose 阶段就把无状态服务与有状态数据分离干净。

运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。

本文为位元跃迁原创内容,仅代表编辑观点,不构成经营或投资建议;文中涉及的品牌与案例仅作公开信息分析示例。

LET'S TALK

把方法用进你的生意

如果你正在为门店做数字化规划,欢迎和我们聊聊。位元跃迁是微信支付合作伙伴、微信开放平台第三方平台,提供小程序定制、开发咨询与企业数字化服务。

18601279913

业务咨询 · 项目合作 · 代理商合作

了解位元跃迁的服务 →
位元跃迁业务联系微信二维码
微信咨询