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、归档、导出)
- 启动依赖:
postgreshealthy →migrate(一次性,退出码 0)→api/worker;redishealthy 后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固定了浙大内网 DNS10.10.0.21、10.10.2.21;换部署环境若无法访问内网 DNS,需去掉/调整该dns配置。
4. 环境变量与密钥¶
首次部署复制模板并逐项填写,.env 权限设为 600、严禁入库:
生成密钥(一次性,之后只读保管):
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。生产迁移经 composemigrate服务(用 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 自动补齐。
- 域名与证书:把
ream.example.internal换成内网域名;tls internal用 Caddy 自签的内部 CA,若学校有内部 CA 则改成tls /path/cert.pem /path/key.pem。 - 同步
BETTER_AUTH_URL:必须等于 Caddy 暴露的源站(https://内网域名),否则 better-auth 回跳与trustedOrigins不匹配。 - 启动:
caddy run --config docker/Caddyfile,或用 systemd 托管;80→443 跳转由 Caddy 自动处理。 - 如需把 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数据保持可用。 - [ ] 从备份完成一次恢复演练:可登录、可解密凭据、可下载原件。