跳转至

09 - 部署方案(学校内网 · Docker Compose 单机)

本文是 Web SaaS 版 Clip票夹 的部署与运维契约,落点是 03-architecture.md §13 的「学校内网单机 Docker Compose」。文中「当前实现」以 docker/compose.yml、docker/*、packages/db/prisma/migrations 为准;标注为待办的条目是文档要求但尚未实现的部分,部署前必须补齐。

1. 目标与范围

  • 形态:单机多容器,web(Next.js)+ api(NestJS)+ worker(BullMQ)+ postgres:16 + redis:7;入口反向代理 Caddy 运行在宿主机(非 compose 服务)。
  • 用户模型:单组织、多用户(非公网多租户)。PostgreSQL 是唯一事实源,Redis 承担队列与事件总线,发票文件落在宿主机数据卷。
  • 网络:默认只在内网可达;出站仅百度 OCR 与用户邮箱 IMAP(443/993),可选 HTTP_PROXY。
  • 不在范围:多机高可用、Kubernetes、公网 TLS 证书自动化。内网规模用单机 Compose,重启窗口可接受。

2. 部署拓扑

   内网用户 ──HTTP/HTTPS──▶ Caddy(宿主机,80/443 反向代理)
                              │
              ┌───────────────┴───────────────┐
              ▼                               ▼
        127.0.0.1:3000 (web)           127.0.0.1:4000 (api)
        页面 / 静态资源                 REST / SSE / 鉴权
                                        (/api/*、/healthz)
                                              │
                        ┌─────────────────────┼─────────────────────┐
                        ▼                     ▼                     ▼
                   worker(常驻)         postgres:5432          redis:6379
                   OCR / 归档 / 导出      (postgres-data)      (redis-data,AOF)

  文件卷 app-data:/data → api(受控下载/上传)与 worker(OCR、归档、导出)
  • 启动依赖:postgres healthy → migrate(一次性,退出码 0)→ api / worker;redis healthy 后 api / worker 才启动;web 依赖 api。Caddy 在宿主机单独启动。
  • 端口约定:宿主机唯一入口是 Caddy 的 80/443;compose 把 web:3000 / api:4000 映射到宿主机 127.0.0.1:${WEB_PORT|API_PORT}(仅回环,供 Caddy 反代),postgres:5432 / redis:6379 只在容器网络内使用。

3. 前置条件

项 要求
主机 Linux x86_64/arm64;建议 ≥4 vCPU / 8 GB RAM / 100 GB 系统盘
运行时 Docker Engine + Compose v2;pnpm(仅构建或本地调试需要,版本见 package.json engines)
出站 aip.baidubce.com(443)、IMAP 服务器(993);浙大计财处门户 cwcx.zju.edu.cn(443,见下);可经 HTTP_PROXY
时间同步 主机 NTP 正常(JWT/session 有效期、任务退避依赖时钟)
域名/证书 内网域名(写入 BETTER_AUTH_URL)+ 内部 CA 或自签证书
磁盘 /data 与 postgres-data 所在盘需有监控与告警,发票原件不可丢

计财处门户 DNS(校内外分离解析):cwcx.zju.edu.cn 对内网客户端解析到内网地址、对公网客户端解析到被防火墙拦截的公网 IP。若宿主机/容器用公网 DNS,从服务器发起登录会连接超时(ZjuNetworkError → FINANCE_PORTAL_UNAVAILABLE)。因此 docker/compose.yml 给 api/worker 固定了浙大内网 DNS 10.10.0.21、10.10.2.21;换部署环境若无法访问内网 DNS,需去掉/调整该 dns 配置。

4. 环境变量与密钥

首次部署复制模板并逐项填写,.env 权限设为 600、严禁入库:

cp .env.example .env
chmod 600 .env

生成密钥(一次性,之后只读保管):

openssl rand -base64 32   # BETTER_AUTH_SECRET
openssl rand -base64 32   # ENVELOPE_MASTER_KEY(必须 32 字节 base64)
openssl rand -hex 16      # POSTGRES_PASSWORD / POSTGRES_APP_PASSWORD

字段清单与用途:

变量 用途 / 约束
POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD 库名与 owner 角色(建表、授权、迁移)
POSTGRES_APP_PASSWORD 受限角色 poco_app 密码;首次初始化写入,RLS 对它真实生效
DATABASE_URL api/worker 运行时连接,主机名用容器名 postgres,角色 poco_app
MIGRATION_DATABASE_URL 手动/CI 迁移连接,owner 角色
REDIS_URL redis://redis:6379(队列 + 事件总线)
WEB_PORT / API_PORT compose 映射到宿主机回环的端口(Caddy 反代目标,一般不改)
BETTER_AUTH_URL 必须 = 用户访问的最终源站,如 https://ream.example.internal
CLIP_PUBLIC_URL 飞书发票材料卡片「查看详情」的 HTTPS 源站,默认 https://clip.philfan.cn;部署在其他域名时改为实际源站
NEXT_PUBLIC_DOCS_URL 文档站源站(Zensical 构建的静态站点);Web 帮助入口 /help/webhooks 跳到这里,需与 mkdocs.yaml 的 site_url 一致;构建期编入静态页,改值需重建 web 镜像
BETTER_AUTH_SECRET session 加密密钥,≥32 字节随机
ENVELOPE_MASTER_KEY 用户级凭据信封加密主密钥;丢失 = 所有邮箱/Webhook 凭据不可解
ADMIN_INITIAL_PASSWORD(可配 ADMIN_INITIAL_EMAIL / ADMIN_INITIAL_USERNAME) 首启种子 admin;首次登录后立即移除
BAIDU_OCR_API_KEY / BAIDU_OCR_SECRET_KEY OCR Provider;不写库、不进日志
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET 可选的 GitHub OAuth,内网可留空
CC98_CLIENT_ID / CC98_CLIENT_SECRET 可选的 CC98 OIDC 登录(03 §11);回调 BETTER_AUTH_URL/api/v1/auth/callback/cc98,留空即关闭
POSTHOG_KEY / POSTHOG_HOST 可选的 PostHog 项目 key 与实例地址(默认 https://us.i.posthog.com,自建填实例地址);同上构建期编入静态页
POSTHOG_REPLAY / POSTHOG_HEATMAP 可选,置 1 开启会话回放 / 热图(默认关闭);回放强制全遮蔽,同上构建期编入静态页
FILE_STORAGE_ROOT 文件存储根,容器内默认 /data/invoice(对应 app-data 卷)
HTTP_PROXY 可选出站代理(当前代码未接入,见 §12)

密钥交接:BETTER_AUTH_SECRET、ENVELOPE_MASTER_KEY、数据库密码、百度 key 必须进入受控交接清单(密码管理器/加密封存),不得只存在部署机 .env 一处(03 §15)。

5. 首次部署步骤

# 1) 获取代码(固定 tag/commit,生产不跟浮动分支)
git clone <repo> && cd better-call-poco
git checkout <release-tag>

# 2) 准备 .env(见 §4),确认 BETTER_AUTH_URL / 密码 / 密钥已替换

# 3) 构建镜像(web/api/worker;migrate 复用 api 镜像)
docker compose -f docker/compose.yml build

# 4) 起数据库与 Redis,等待 healthy
docker compose -f docker/compose.yml up -d postgres redis

# 5) 执行迁移(一次性服务,退出码 0 才继续)
docker compose -f docker/compose.yml up migrate

# 6) 起应用(web/api/worker 映射到宿主机回环端口)
docker compose -f docker/compose.yml up -d api worker web

# 7) 启动宿主机 Caddy 反代
caddy run --config docker/Caddyfile   # 或用 systemd 托管

# 8) 健康检查
curl -fsS https://ream.example.internal/healthz   # {"status":"ok"}
docker compose -f docker/compose.yml ps            # 全部 running/healthy

首启 admin 由 apps/api 的 BootstrapService 在 api 启动时创建:仅当库中还没有 role=admin 的用户、且设置了 ADMIN_INITIAL_PASSWORD 时才执行;创建后写入一条 organization 与个人工作区。完成首次登录后,从 .env 删除 ADMIN_INITIAL_PASSWORD 并重启 api。

6. 数据库角色与迁移

  • 两个角色:owner(POSTGRES_USER,建表/授权/迁移)与受限角色 poco_app(api/worker 运行时,NOSUPERUSER、无建库建角色、仅业务表 DML)。运行时必须用 poco_app,否则超级用户会绕过 RLS。
  • 角色与授权由迁移 20260927000000_app_role_and_grants 提供;密码由 docker/postgres-init-app-role.sh 在数据卷首次初始化时写入。
  • 迁移只前滚:prisma migrate deploy。生产迁移经 compose migrate 服务(用 owner 连接)或 MIGRATION_DATABASE_URL=... pnpm db:migrate 执行。
  • 空库验收:任何 schema 变更都要验证能在空库从零部署全部 migration,并跑通 RLS 隔离用例(04 §9)。
  • 升级已有卷的坑:postgres-init-app-role.sh 只在空数据卷执行;若复用旧卷或手工建库,需手动 ALTER ROLE poco_app WITH PASSWORD '<POSTGRES_APP_PASSWORD>' 并确保 grants 迁移已应用。

7. 反向代理与 HTTPS(Caddy)

docker/Caddyfile 已把 /api/*、/healthz 反代到 127.0.0.1:4000,其余到 127.0.0.1:3000,并对 GET /api/v1/events 设置 flush_interval -1。Caddy 默认即时 flush,SSE 不会被攒住;Host / X-Forwarded-* 由 Caddy 自动补齐。

  1. 域名与证书:把 ream.example.internal 换成内网域名;tls internal 用 Caddy 自签的内部 CA,若学校有内部 CA 则改成 tls /path/cert.pem /path/key.pem。
  2. 同步 BETTER_AUTH_URL:必须等于 Caddy 暴露的源站(https://内网域名),否则 better-auth 回跳与 trustedOrigins 不匹配。
  3. 启动:caddy run --config docker/Caddyfile,或用 systemd 托管;80→443 跳转由 Caddy 自动处理。
  4. 如需把 Caddy 放进 compose:上游改成 api:4000 / web:3000 并加入同一网络(此时可去掉 api/web 的 ports)。

Caddy 自签 CA 的 root 证书(发给客户端信任):/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt。

8. 文件存储与备份

  • 文件根:app-data 卷挂到 /data;发票与归档落在 /data/invoice/{workspace_id}/,file_blob 用内容寻址 blobs/<2位>/<sha256>。
  • 访问方式:文件不经反向代理静态直出,下载一律走 api 受控端点(校验工作区成员资格,08 §3.5 / 03 §10)。因此 api 与 worker 都必须挂载 app-data 卷。
  • 备份(每日,保留 30 份):
# 数据库
docker compose -f docker/compose.yml exec -T postgres \
  pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB" > "backup/poco-$(date +%F).dump"
# 文件卷
docker run --rm -v better-call-poco_app-data:/data \
  -v "$PWD/backup":/backup alpine \
  tar czf /backup/app-data-$(date +%F).tgz -C /data .
  • 恢复演练进验收:从 dump + 文件快照 + ENVELOPE_MASTER_KEY 恢复出可登录、可解密凭据、可下载原件的一套环境。

9. 升级与回滚

git fetch && git checkout <new-tag>
docker compose -f docker/compose.yml build
docker compose -f docker/compose.yml up migrate        # 先迁移,只前滚
docker compose -f docker/compose.yml up -d api worker web
  • worker 重启会触发崩溃自愈补偿(03 §5.3),任务以库为准收敛。
  • 迁移必须向后兼容(先加可空列/新表,再切读写;不删列、不收紧约束)。若某次迁移不兼容,回滚只能恢复 §8 的数据库备份,因此每次迁移前先做一次即时备份。
  • 镜像按 release tag 固定,compose.yml 不用浮动 latest。

10. 日常运维

事项 做法
健康检查 外部探活 GET /healthz(恒 200,不探测下游)
日志 容器 stdout → json-file driver 轮转;api/worker 结构化日志、凭据脱敏
Redis --appendonly yes,队列可恢复;监控内存与持久化文件大小
磁盘 监控 postgres-data / app-data;发票原件与 OCR 原始数据不可丢
备份校验 每日任务成功告警 + 每月一次恢复演练
审计 管理员排障、成员移除、受控下载均落 access_audit,定期抽查

11. 安全与红线检查(上线前逐项确认)

  • [ ] .env 权限 600、不在 git、不在镜像构建上下文外泄。
  • [ ] BETTER_AUTH_SECRET、ENVELOPE_MASTER_KEY、数据库密码、百度 key 均为真实随机值(非 change-me)。
  • [ ] 主密钥已进交接清单;已知「丢失即不可解密」。
  • [ ] api/worker 以 poco_app 连接,RLS 未被超级用户绕过。
  • [ ] 迁移由 owner 角色执行,与运行时角色分离。
  • [ ] 文件无静态直出,下载走 api 受控端点并产生审计。
  • [ ] 首次登录后已移除 ADMIN_INITIAL_PASSWORD。
  • [ ] 无凭据/OCR 明文出现在日志或异常栈。

12. 当前实现差距与部署待办

以下是文档要求、但当前仓库实现尚未覆盖的部分,上线前必须完成(对应 06-roadmap「运维收尾」)。

# 待办 位置 / 说明 优先级
1 compose 的 api 注入 ADMIN_INITIAL_PASSWORD(及可选 EMAIL/USERNAME) 当前 api.environment 未传,首次部署不会种子 admin,BootstrapService 只打 warn 跳过 P0
2 给 api 服务挂载 app-data:/data 当前卷只挂 worker;api 的受控下载/上传读不到文件 P0
3 确认 docker/Caddyfile 的内网域名与证书 模板已含 tls internal 与 SSE flush_interval -1;上线前替换域名、决定用 Caddy 自签还是学校 CA P0
4 BETTER_AUTH_URL 指向 Caddy 源站 .env.example 默认 https://ream.example.internal,必须与真实域名一致 P0
5 统一替换 .env 默认密码/密钥为随机值 compose 默认 change-me,ENVELOPE_MASTER_KEY=change-me 会被当成全零主密钥 P0
6 为常驻服务加 restart: unless-stopped compose 默认 no,主机重启后不会自恢复 P1
7 备份自动化(pg_dump + /data 快照 + 保留 30 份)与恢复演练 见 §8;当前无 cron/脚本 P1
8 已存在数据卷的 poco_app 密码/授权补齐流程 init 脚本只在空卷执行;升级旧卷需手动 ALTER ROLE 并确认 grants 迁移 P1
9 容器日志轮转与资源限制 配置 json-file max-size/max-file,必要时限制 cpu/mem P2
10 HTTP_PROXY 出站代理接入 03 §12 列为可选;代码当前未读取该变量 P2

13. 部署验收清单

对应 06-roadmap M0 验收标准,部署完成后逐项验证:

  • [ ] docker compose ps 全部 running/healthy,/healthz 返回 {"status":"ok"}。
  • [ ] 迁移在空库可完整部署(含 app_role_and_grants、rls_member_policy_recursion_fix)。
  • [ ] 管理员与两个普通用户可登录,各自只看到自己的个人工作区。
  • [ ] 伪造 X-Workspace-Id、跨工作区资源 ID、raw SQL 查询均无法越权。
  • [ ] 管理员排障访问产生 access_audit 记录。
  • [ ] 受控下载只对活动成员放行,非成员 404/403。
  • [ ] SSE 经 Caddy 实时到达,心跳正常,断线自动重连并补拉。
  • [ ] 重启 api/worker 与主机后,session、数据库与 /data 数据保持可用。
  • [ ] 从备份完成一次恢复演练:可登录、可解密凭据、可下载原件。