fix(devops): 修复容器化部署面缺口,docker compose 可干净构建

消除 DEPLOYMENT.md §1.5 列出的 6 个「照抄即部署」缺口(仅动部署面文件,不改应用源码):

1. apps/api/Dockerfile 补 `COPY alembic.ini ./`,容器内可直接 alembic upgrade head。
2. base compose api 注入 CREDENTIAL_ENC_KEY(${VAR:-dev 占位},自动读根 .env 覆盖)+
   APP_ENV/LOG_JSON/CORS_ORIGINS,无 .env 也能起,不再因缺 Fernet key 启动失败。
3. base compose web 设 API_BASE_INTERNAL=http://api:8000,RSC 直连 api 容器。
4. web Dockerfile 加 ARG/ENV NEXT_PUBLIC_API_BASE(默认 localhost:8000),compose
   web.build.args 传入,构建期可注入公网 API 基址。
5. api/web Dockerfile 内置 HEALTHCHECK(api 用 urllib 打 /health、web 用 node 打 /)。
6. 新增 docker-compose.prod.yml 覆盖层做生产加固:pg 强口令 ${POSTGRES_PASSWORD}、
   pg 仅绑 127.0.0.1、APP_ENV=prod、LOG_JSON=true、密钥/连接串/CORS/公网 API 全从
   env 且必填(:? 缺失拒起)、restart: unless-stopped。base 保持 dev 便利不破坏。

附带修复:web Dockerfile 在 install 前补 COPY pnpm-workspace.yaml,使容器内 install
看到 minimumReleaseAgeExclude/onlyBuiltDependencies,避免误拒 @xyflow 等近期发布依赖;
新增 .dockerignore(根 + apps/web)排除宿主 node_modules/.next/.venv 等,避免平台原生
二进制经 COPY . . 覆盖容器版导致 next build 失败,并加速构建上下文、保证可复现。

验证:docker compose config(base)与 prod 合并 config 均通过;docker compose build
两镜像均成功(api 378MB / web 296MB);自定义 NEXT_PUBLIC_API_BASE 已确认内联进前端
bundle。DEPLOYMENT.md §1.5 回写为「已修复」并补 §2.6 生产 env 清单与正确部署命令。
This commit is contained in:
Yaojia Wang
2026-07-07 05:01:44 +02:00
parent 6b08fbe123
commit 5464539a91
7 changed files with 191 additions and 84 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
# api 镜像构建上下文context=仓库根。api Dockerfile 仅选择性 COPY
# pyproject.toml/uv.lock/packages/apps/api/alembic.ini但整个仓库会作为构建上下文发给
# daemon——排除大体积/无关目录,加速上传并避免宿主产物泄漏进镜像。
.git
.venv
**/node_modules
**/.next
**/__pycache__
**/*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
htmlcov
.coverage
**/*.tsbuildinfo
.env
.env.local
.DS_Store
example_skills

View File

@@ -20,8 +20,8 @@
| 服务名 | 角色 | 镜像来源 | 容器端口 | compose 发布端口 | 健康检查 |
|---|---|---|---|---|---|
| `pg` | Postgres 16 数据库(唯一真源) | `postgres:16-alpine`(拉取) | 5432 | `5432:5432` | ✅ `pg_isready -U writer`interval 5s / timeout 3s / retries 10 |
| `api` | FastAPI 后端async + SSE + LangGraph 编排) | 由 `apps/api/Dockerfile` 构建,构建上下文 = **仓库根 `.`** | 8000 | `8000:8000` | ❌ 无(见 §1.5 缺口 |
| `web` | Next.js 前端standalone 产物) | 由 `apps/web/Dockerfile` 构建,构建上下文 = **`./apps/web`** | 3000 | `3000:3000` | ❌ 无(见 §1.5 缺口 |
| `api` | FastAPI 后端async + SSE + LangGraph 编排) | 由 `apps/api/Dockerfile` 构建,构建上下文 = **仓库根 `.`** | 8000 | `8000:8000` | ✅ Dockerfile `HEALTHCHECK``python urllib``/health`interval 30s / timeout 5s / start-period 20s / retries 3 |
| `web` | Next.js 前端standalone 产物) | 由 `apps/web/Dockerfile` 构建,构建上下文 = **`./apps/web`** | 3000 | `3000:3000` | ✅ Dockerfile `HEALTHCHECK``node http``/`interval 30s / timeout 5s / start-period 20s / retries 3 |
依赖关系compose `depends_on`
- `api` 依赖 `pg`,且**等 pg 健康**后才起(`condition: service_healthy`)。
@@ -57,8 +57,8 @@
| 变量 | 是否必填 | 默认值(`config.ts` 回退) | 说明 |
|---|---|---|---|
| `NEXT_PUBLIC_API_BASE` | 生产建议 | `http://localhost:8000` | **浏览器端**访问 API 的基址。⚠️ Next.js 的 `NEXT_PUBLIC_*` 在**构建期内联**进客户端 bundle——只在运行期(compose environment)设它**不会改**已构建产物(见 §1.5 缺口)。生产在**构建期**注入前端可达的公网 API 地址(通常反代后的 `https://writer.example.com/api` 之类)。 |
| `API_BASE_INTERNAL` | 容器编排建议 | 回退到 `NEXT_PUBLIC_API_BASE` | **服务端组件(RSC)**取数用的内网基址。容器内应设为 `http://api:8000`compose 服务名)。**当前 compose 未设它**(见 §1.5 缺口RSC 会回退到 `localhost:8000` 而打不到 api 容器。 |
| `NEXT_PUBLIC_API_BASE` | 生产建议 | `http://localhost:8000` | **浏览器端**访问 API 的基址。⚠️ Next.js 的 `NEXT_PUBLIC_*` 在**构建期内联**进客户端 bundle——只在运行期设它**不会改**已构建产物。web `Dockerfile` 现声明 `ARG NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8000`base/prod compose 的 `web.build.args` 均传入它;生产在**构建期**`docker compose build` 注入前端可达的公网 API 地址(通常反代后的 `https://writer.example.com` 之类)。 |
| `API_BASE_INTERNAL` | 容器编排建议 | 回退到 `NEXT_PUBLIC_API_BASE` | **服务端组件(RSC)**取数用的内网基址。base/prod compose 的 `web.environment`设为 `http://api:8000`compose 服务名)RSC 直连 api 容器。 |
### 1.4 LLM provider key 不进环境变量
@@ -69,17 +69,20 @@ Kimi Code 走 OAuth 订阅授权(`kimi_oauth` 路由),同样落库。
推论:`CREDENTIAL_ENC_KEY` 是解开所有已存凭据的唯一钥匙——**丢了它 = 库里所有 provider 凭据不可解密**,只能重新录入。务必备份保管(见 §2.3 / §6
### 1.5 已发现的部署面缺口(部署前需知
### 1.5 部署面缺口——已修复(现可照抄部署
以下是审阅 Dockerfile/compose 发现的、影响「照抄即部署」的点,本文各节给出规避办法
此前审阅 Dockerfile/compose 发现的 6 个「照抄即部署」缺口**均已在部署面文件消除**(不改应用源码)。现状如下
1. **api 镜像拷 `alembic.ini`**`apps/api/Dockerfile` `COPY pyproject.toml / packages / apps/api`**没有** `COPY alembic.ini`(它在仓库根)。因此 `docker compose run --rm api uv run alembic upgrade head` 会因找不到 `alembic.ini` 而失败。规避:从**宿主机 checkout** 跑迁移§2.4 方式一,当前即可用),或给 Dockerfile 补一行 `COPY alembic.ini ./`§2.4 方式二)。
2. **compose `api` 服务未提供 `CREDENTIAL_ENC_KEY`**(也没 `env_file`base compose 里 `api.environment` 只有 `DATABASE_URL/DATABASE_URL_SYNC/APP_ENV=dev`。由于该密钥启动即校验,**base compose 直接 `up` 会让 api 启动失败**。生产必须补齐(§2.2 覆盖文件)。
3. **compose `web` 服务未设 `API_BASE_INTERNAL`**RSC 服务端取数会回退到 `localhost:8000`,在容器里打不到 api。需在覆盖文件`API_BASE_INTERNAL=http://api:8000`§2.2
4. **`NEXT_PUBLIC_API_BASE` 构建期内联**web Dockerfile 未声明该 build ARG浏览器 bundle 里的 API 基址会固定为 `http://localhost:8000`。生产要改,需在**构建阶段**注入§3 反代方案里说明取舍)
5. **api/web HEALTHCHECK**:两个 `Dockerfile` 和 compose 的 `api`/`web` 都没有健康检查(只有 `pg` 有)。编排/反代的存活探测需自行加health 端点已就绪,见 §7.1
1. **api 镜像拷 `alembic.ini`**`apps/api/Dockerfile` `COPY apps/api` 之后新增 `COPY alembic.ini ./`(迁移目录 `packages/db/migrations``COPY packages` 进镜像)。因此可直接在 api 容器内跑 `docker compose run --rm api uv run alembic upgrade head`§2.4)。
2. **compose `api` 提供 `CREDENTIAL_ENC_KEY`**base `docker-compose.yml``api.environment` 现注入 `CREDENTIAL_ENC_KEY``${CREDENTIAL_ENC_KEY:-<dev 占位>}`compose 自动读仓库根 `.env` 覆盖该默认)、`APP_ENV`/`LOG_JSON`/`CORS_ORIGINS`**base compose 现可直接 `up` api**(无 `.env` 也走 dev 占位 key 起来;生产用 §2.2 覆盖文件强制真值)。
3. **compose `web` 设 `API_BASE_INTERNAL`**base 的 `web.environment` `API_BASE_INTERNAL=http://api:8000`RSC 服务端取数直连 api 容器
4. **`NEXT_PUBLIC_API_BASE` 支持构建期注入** ✅web `Dockerfile``pnpm build` 前声明 `ARG NEXT_PUBLIC_API_BASE=http://localhost:8000` + `ENV`base/prod compose 的 `web.build.args` 传入它。生产 `docker compose -f ... -f docker-compose.prod.yml build` 即把公网 API 基址内联进 bundle
5. **api/web 已内置 HEALTHCHECK** ✅:`apps/api/Dockerfile` 用 stdlib `urllib``/health``apps/web/Dockerfile``node http``/`(均 interval 30s / timeout 5s / start-period 20s / retries 3镜像无 curl 故用运行时自带解释器)。编排/反代可直接读容器健康态
6. **base 弱口令/裸暴露——生产用覆盖层加固**base `docker-compose.yml` 保持 dev 便利pg `writer/writer``5432:5432``APP_ENV=dev`);新增 **`docker-compose.prod.yml`** 覆盖层做生产加固——pg 强口令 `${POSTGRES_PASSWORD}``:?` 缺失即拒起、pg 仅绑 `127.0.0.1:5432``APP_ENV=prod``LOG_JSON=true``CREDENTIAL_ENC_KEY`/`DATABASE_URL*`/`CORS_ORIGINS`/`NEXT_PUBLIC_API_BASE` 全从 env 且必填、`restart: unless-stopped`
> 以上缺口不阻断部署,但决定了「不能照抄 base compose 一把梭」。生产用 §2.2 的覆盖文件补齐即可
> 构建面同时补了两个 `.dockerignore`(仓库根 + `apps/web`):排除宿主 `node_modules`/`.next`/`.venv`/`.git`/缓存,避免宿主平台原生二进制(如 macOS 的 next-swc经 `COPY . .` 覆盖容器内 Linux 版导致 `pnpm build` 失败,并加速构建上下文传输、保证可复现
>
> 校验:`docker compose config`base与 `docker compose -f docker-compose.yml -f docker-compose.prod.yml config`prod 合并)均通过;`docker compose build` 两镜像均成功构建。
---
@@ -102,46 +105,27 @@ git checkout main # 或你的发布分支
### 2.2 准备生产配置(覆盖 base compose 的 dev 缺省)
base `docker-compose.yml` 是**开发导向**api 用 `APP_ENV=dev`、缺 `CREDENTIAL_ENC_KEY`
web 缺 `API_BASE_INTERNAL`、pg 端口对外发布)。生产用一个**覆盖文件** `docker-compose.prod.yml`
补齐,不改 base 文件
base `docker-compose.yml` 是**开发导向**pg 弱口令 `writer/writer``5432` 对外发布、`APP_ENV=dev`
`CREDENTIAL_ENC_KEY` 用 dev 占位默认)。生产加固的**覆盖文件 `docker-compose.prod.yml` 已随仓库提供**(无需手写),
base 叠加使用即可。它把安全项全部改为**从宿主 env 强制注入**(缺失即拒绝启动)
```yaml
# docker-compose.prod.yml —— 与 base 叠加docker compose -f docker-compose.yml -f docker-compose.prod.yml ...
services:
pg:
# 不把 5432 暴露到公网:仅绑本机回环(供宿主机跑迁移用),或整段删掉只走 compose 内网
ports:
- "127.0.0.1:5432:5432"
- `pg``POSTGRES_PASSWORD=${POSTGRES_PASSWORD}` 强口令、端口仅绑 `127.0.0.1:5432``restart: unless-stopped`
- `api``APP_ENV=prod``LOG_JSON=true``CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`DATABASE_URL*`(用同一强口令)全从 env。
- `web``NEXT_PUBLIC_API_BASE`(构建期 build arg + 运行期 env指向公网 API、`API_BASE_INTERNAL=http://api:8000`
api:
environment:
DATABASE_URL: postgresql+asyncpg://writer:${PG_PASSWORD}@pg:5432/writer
DATABASE_URL_SYNC: postgresql+psycopg://writer:${PG_PASSWORD}@pg:5432/writer
APP_ENV: prod
LOG_JSON: "true"
CREDENTIAL_ENC_KEY: ${CREDENTIAL_ENC_KEY} # 见 §2.3 生成
CORS_ORIGINS: "https://writer.example.com" # 前端真实来源,逗号分隔;不得含 '*'
# 可选:补健康检查(镜像里已装 uv/python可用 python 探 /health
# 若加了反代做存活探测,这里可省
web:
environment:
NEXT_PUBLIC_API_BASE: https://writer.example.com # 浏览器可达的公网 API经反代
API_BASE_INTERNAL: http://api:8000 # RSC 服务端内网直连 api 容器
```
同时准备 pg 的强口令与密钥(**不要提交进仓库**),可放在同目录 `.env`compose 会自动读取用于 `${...}` 插值):
你只需在**仓库根**准备一个 `.env`compose 自动读取做 `${...}` 插值;**chmod 600、勿入库**,已被 `.gitignore` 忽略):
```bash
# .envcompose 变量插值用chmod 600勿入库
PG_PASSWORD=<强随机口令>
POSTGRES_PASSWORD=<强随机口令>
CREDENTIAL_ENC_KEY=<§2.3 生成的 Fernet key>
CORS_ORIGINS=https://writer.example.com # 前端真实来源,逗号分隔;禁通配 *
NEXT_PUBLIC_API_BASE=https://writer.example.com # 浏览器可达的公网 API经反代
```
> ⚠️ base compose 里 pg 的 `POSTGRES_PASSWORD: writer` 是硬编码弱口令。生产请在覆盖文件里
> 用 `POSTGRES_PASSWORD: ${PG_PASSWORD}` 覆盖 pg并让 `DATABASE_URL*` 用同一口令
> (上例已用 `${PG_PASSWORD}`)。若你保持 base 的 `writer` 口令,**务必**确保 pg 不可从公网访问§6
> prod 覆盖层对 `POSTGRES_PASSWORD`/`CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`NEXT_PUBLIC_API_BASE` 用
> `${VAR:?...}`(必填)语义——**任一缺失,`docker compose ... config`/`up` 直接报错拒起**,从设计上杜绝
> 「带 base 弱口令/占位 key 上生产」。完整清单见 [§2.6 生产 env 清单](#26-生产-env-清单)
### 2.3 生成并妥善保管 CREDENTIAL_ENC_KEY
@@ -182,31 +166,27 @@ $COMPOSE ps # 看 pg 是否 healthy
四张检查点表。**checkpointer 的建表只在迁移里发生,绝不在应用运行时**(符合 CLAUDE.md「LangGraph」纪律
所以**部署时必须先把迁移升到 head应用才能正常写章/审稿/续跑**。
api 镜像随包 `alembic.ini`§1.5 缺口 1有两种可用方式
api 镜像现已随包 `alembic.ini`§1.5 已修复 1**推荐直接在 api 容器里跑迁移**(无需宿主装 uv
**方式一(当前即可用,无需改 Dockerfile——从宿主机 checkout 跑迁移:**
```bash
# 宿主机需装 uvcurl -LsSf https://astral.sh/uv/install.sh | sh; export PATH="$HOME/.local/bin:$PATH"
cd writer-work-flow
uv sync
# 指向 compose 里 pg 暴露到 127.0.0.1:5432 的端口§2.2 覆盖文件已绑本机)
DATABASE_URL="postgresql+asyncpg://writer:${PG_PASSWORD}@127.0.0.1:5432/writer" \
DATABASE_URL_SYNC="postgresql+psycopg://writer:${PG_PASSWORD}@127.0.0.1:5432/writer" \
CREDENTIAL_ENC_KEY="${CREDENTIAL_ENC_KEY}" \
uv run alembic upgrade head
uv run alembic check # 可选:确认无漂移
```
**方式二(更干净,需给 Dockerfile 补一行)——在 api 容器里跑迁移:**
先在 `apps/api/Dockerfile``COPY alembic.ini ./`(放在 `COPY apps/api ./apps/api` 之后),
`$COMPOSE build api` 重建后:
**方式一(推荐)——在 api 容器里跑迁移:**
```bash
$COMPOSE run --rm api uv run alembic upgrade head
$COMPOSE run --rm api uv run alembic check # 可选:确认无漂移
```
> 这一步需要 parent/维护者决定是否接受改 Dockerfile。改前方式二不可用
> 该临时容器与 api 服务共享同一 env含 `DATABASE_URL*`/`CREDENTIAL_ENC_KEY`),直连 compose 内网的 `pg`,无需暴露端口
**方式二(备选)——从宿主机 checkout 跑迁移**(宿主已装 uv 时可用prod 覆盖层把 pg 绑到 `127.0.0.1:5432`
```bash
# 宿主机装 uvcurl -LsSf https://astral.sh/uv/install.sh | sh; export PATH="$HOME/.local/bin:$PATH"
cd writer-work-flow && uv sync
DATABASE_URL="postgresql+asyncpg://writer:${POSTGRES_PASSWORD}@127.0.0.1:5432/writer" \
DATABASE_URL_SYNC="postgresql+psycopg://writer:${POSTGRES_PASSWORD}@127.0.0.1:5432/writer" \
CREDENTIAL_ENC_KEY="${CREDENTIAL_ENC_KEY}" \
uv run alembic upgrade head
```
**4) 起全部服务:**
@@ -229,11 +209,40 @@ curl -fI http://127.0.0.1:3000/
$COMPOSE logs -f api
```
若 api 启动即退出,最常见原因是 `CREDENTIAL_ENC_KEY` 缺失/非法§1.5 缺口 2 / §2.3)——
日志会显示 CredentialKeyError 相关堆栈。
若 api 启动即退出,最常见原因是 `CREDENTIAL_ENC_KEY` 非法base compose 已注入 dev 默认;
生产覆盖层要求经 env 提供合法 Fernet key见 §2.3)——日志会显示 CredentialKeyError 相关堆栈。
部署完成后,**打开前端「设置」页录入 LLM provider 凭据**§1.4),产品才能真正调用模型。
### 2.6 生产 env 清单
生产用 `docker-compose.prod.yml` 覆盖层,以下变量必须在**仓库根 `.env`**compose 自动读取做 `${...}` 插值;
`chmod 600`、勿入库)或宿主环境变量提供。带 `:?` 者缺失即 `config`/`up` **报错拒起**
| 变量 | 必填 | 消费方 | 说明 |
|---|:---:|---|---|
| `POSTGRES_PASSWORD` | ✅ | pg + api(`DATABASE_URL*`) | pg 强随机口令,替换 base 弱口令 `writer/writer`。api 连接串复用它。 |
| `CREDENTIAL_ENC_KEY` | ✅ | api | Fernet key§2.3 新生成,**勿用 `.env.example` 占位值**。api 启动即校验;解开全部已存 provider 凭据的唯一钥匙,须离线备份。 |
| `CORS_ORIGINS` | ✅ | api | 浏览器跨源白名单,逗号分隔,**禁通配 `*`**(如 `https://writer.example.com`)。 |
| `NEXT_PUBLIC_API_BASE` | ✅ | web**构建期**内联 + 运行期) | 浏览器可达的公网 API 基址(经反代)。改它必须**重新 `docker compose ... build web`**(构建期内联进 bundle。 |
| `APP_ENV` | 覆盖层已固定 `prod` | api | 无需手设prod 层写死)。 |
| `LOG_JSON` | 覆盖层已固定 `true` | api | 无需手设prod 层写死)。 |
| `API_BASE_INTERNAL` | 覆盖层已固定 `http://api:8000` | web(RSC) | 无需手设prod 层写死)。 |
> LLM provider keyDeepSeek/Kimi/OpenAI…**永不进 env**——部署后在应用「设置」页录入、加密落库§1.4)。
生成 `.env` 骨架(示例):
```bash
cat > .env <<'EOF'
POSTGRES_PASSWORD=<强随机口令>
CREDENTIAL_ENC_KEY=<uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 的输出>
CORS_ORIGINS=https://writer.example.com
NEXT_PUBLIC_API_BASE=https://writer.example.com
EOF
chmod 600 .env
```
---
## 3. 反向代理 + HTTPS
@@ -241,9 +250,9 @@ $COMPOSE logs -f api
生产应在应用前放一层反代做 TLS 终止、单一入口、并(配合 §6做访问控制。
拓扑:浏览器 → 反代(443) → `web:3000`(页面/RSC`api:8000``/api` 或独立子域)。
> 注意 `NEXT_PUBLIC_API_BASE` 的构建期内联§1.5 缺口 4:浏览器直接打 API 的地址在**构建 web 镜像时**就定死了。
> 若走「同源 `/api` 前缀反代」,需让前端构建时 `NEXT_PUBLIC_API_BASE` 指向该前缀(当前 web Dockerfile 未暴露该 build ARG需按缺口 4 处理);
> 若走「api 独立子域」(如 `api.example.com`),同理需在构建期注入。
> 注意 `NEXT_PUBLIC_API_BASE` 的构建期内联:浏览器直接打 API 的地址在**构建 web 镜像时**就定死了。
> web `Dockerfile` 已暴露 `ARG NEXT_PUBLIC_API_BASE`、prod compose 的 `web.build.args` 会传入它——改这个基址后**必须重新 `docker compose ... build web`** 才生效(重启不改已构建 bundle
> 若走「同源 `/api` 前缀反代」,构建时把 `NEXT_PUBLIC_API_BASE` 设为该前缀;若走「api 独立子域」(如 `api.example.com`),同理构建期注入。
### 3.1 Caddy推荐自动证书
@@ -310,9 +319,9 @@ server {
- 两个 app`fly launch` 分别为 api`apps/api/Dockerfile`,构建上下文=仓库根)与 web`apps/web/Dockerfile`,上下文=`apps/web`)。
- Postgres 用 `fly postgres create`(或外部托管 PG`DATABASE_URL`/`DATABASE_URL_SYNC` 设成其连接串(注意驱动前缀 `+asyncpg`/`+psycopg`Fly 给的是原生 `postgres://`,需自行加前缀)。
- `fly secrets set CREDENTIAL_ENC_KEY=... CORS_ORIGINS=... PG_PASSWORD=...`
- 迁移:`fly ssh console` 进 api 机器跑 `alembic upgrade head`(同样受 §1.5 缺口 1 影响——需镜像含 `alembic.ini`,或用 `fly ssh` + release_command。可用 Fly `[deploy] release_command` 在每次发布前自动跑迁移(需 alembic.ini 在镜像里)
- web 的 `NEXT_PUBLIC_API_BASE` 在**构建期**注入Fly build args / Dockerfile ARG,缺口 4)。
- `fly secrets set CREDENTIAL_ENC_KEY=... CORS_ORIGINS=... POSTGRES_PASSWORD=...`
- 迁移:镜像`alembic.ini`§1.5 已修复 1可用 Fly `[deploy] release_command = "uv run alembic upgrade head"` 在每次发布前自动跑迁移,或 `fly ssh console` 手动跑
- web 的 `NEXT_PUBLIC_API_BASE` 在**构建期**注入Fly build args Dockerfile `ARG NEXT_PUBLIC_API_BASE`,已暴露)。
- api 内部端口 8000、web 内部端口 3000`fly.toml``internal_port` 对应。
### 4.2 Render
@@ -320,11 +329,11 @@ server {
- 两个 Web ServiceDocker 运行时),分别指向两个 Dockerfileweb 的 Docker build context 设 `apps/web`api 设仓库根。
- Postgres 用 Render Managed Postgres把内部连接串填进 api 的 `DATABASE_URL*`(加 `+asyncpg`/`+psycopg` 前缀)。
- 在 api service 的 Environment 填 `CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`APP_ENV=prod`/`LOG_JSON=true`
- 迁移:用 Render 的 **Pre-Deploy Command**`uv run alembic upgrade head`镜像含 `alembic.ini`缺口 1
- web 与 api 的互通用 Render 的内部地址;`API_BASE_INTERNAL` 指向 api 内部 URL`NEXT_PUBLIC_API_BASE`(构建期)指向 api 公网 URL。
- 迁移:用 Render 的 **Pre-Deploy Command**`uv run alembic upgrade head`(镜像`alembic.ini`§1.5 已修复 1
- web 与 api 的互通用 Render 的内部地址;`API_BASE_INTERNAL` 指向 api 内部 URL`NEXT_PUBLIC_API_BASE`(构建期 build arg)指向 api 公网 URL。
> 两个平台的共同注意点:**全部继承 §1.5 的缺口**(尤其 alembic.ini 未随镜像、NEXT_PUBLIC 构建期内联)
> 上线前按缺口处理,或接受「迁移只能从含源码的环境跑」
> 两个平台的共同注意点:`NEXT_PUBLIC_API_BASE` 仍是**构建期内联**(改它须重构 web 镜像);`alembic.ini` 已随镜像,迁移可在容器/release 命令里跑
> HEALTHCHECK 已内建于镜像,平台存活探测可直接复用或按平台惯例另配 `/health`api/`/`web
---
@@ -337,9 +346,8 @@ export COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
git pull # 拉新代码(含新迁移 / schema.d.ts
$COMPOSE build # 重建 api/web 镜像
# 先升迁移到 head含新增业务表 + 可能的 checkpointer 变更),再滚动重启
# —— 用 §2.4 的方式一或方式二
uv run alembic upgrade head # 方式一(宿主机 checkout指向 pg
# 先升迁移到 head含新增业务表 + 可能的 checkpointer 变更),再滚动重启(见 §2.4
$COMPOSE run --rm api uv run alembic upgrade head # 在 api 容器内跑迁移(镜像已含 alembic.ini
$COMPOSE up -d # 用新镜像重启 api/webpg 不动)
# 冒烟
@@ -358,7 +366,7 @@ git checkout <上一个可用 tag>
$COMPOSE build && $COMPOSE up -d
# 数据库回滚(谨慎):按需降级一版
uv run alembic downgrade -1
$COMPOSE run --rm api uv run alembic downgrade -1
```
> ⚠️ DB 降级可能丢列/丢表数据,且 `d3e4f5a6b7c8` 的 `downgrade()` 会 `DROP` 四张 checkpoint 表
@@ -399,7 +407,7 @@ uv run alembic downgrade -1
- `GET /health``{"status":"ok"}`
两者均无鉴权、返回 200可作反代/编排的存活探测目标。
(提醒:api/web 的 Dockerfile 与 compose **内置** HEALTHCHECK§1.5 缺口 5——存活探测靠反代/平台配。)
api/web 的 Dockerfile **内置** `HEALTHCHECK`§1.5 已修复 5api 用 stdlib `urllib``/health`、web 用 `node http``/`——`docker compose ps` 直接显示 `healthy`,反代/平台亦可复用。
### 7.2 结构化日志
@@ -444,5 +452,6 @@ $COMPOSE exec -T pg pg_restore -U writer -d writer --clean < backup_YYYY-MM-DD.d
- **必填 env**`CREDENTIAL_ENC_KEY`(启动即校验,缺失/非法则 api 起不来)。
- **迁移**`alembic upgrade head` 同时建业务表 + LangGraph checkpointer 表checkpointer 只在迁移建表,不在运行时。
- **provider key**:不进 env部署后在设置页录入、加密落库。
- **已知缺口**§1.5api 镜像 `alembic.ini`compose `api` `CREDENTIAL_ENC_KEY``web` `API_BASE_INTERNAL``NEXT_PUBLIC_API_BASE` 构建期内联api/web HEALTHCHECK。
- **部署面缺口已全部修复**§1.5api 镜像 `alembic.ini`base compose `api` 注入 `CREDENTIAL_ENC_KEY`/`APP_ENV`/`LOG_JSON`/`CORS_ORIGINS``web` `API_BASE_INTERNAL` + `NEXT_PUBLIC_API_BASE` build argapi/web 内置 HEALTHCHECK;生产加固走 `docker-compose.prod.yml` 覆盖层(强口令/回环 pg/prod env/`restart`
- **部署命令**`docker compose build`dev`docker compose -f docker-compose.yml -f docker-compose.prod.yml build`prod`... run --rm api uv run alembic upgrade head``... up -d`
- **红线**:单用户无认证 → 不裸暴露公网pg 不对外CORS 禁 `*``CREDENTIAL_ENC_KEY` 备份+轮换。

View File

@@ -4,6 +4,12 @@ WORKDIR /app
COPY pyproject.toml uv.lock* ./
COPY packages ./packages
COPY apps/api ./apps/api
# alembic.ini 在仓库根,随 build context 拷入镜像,供容器内 `alembic upgrade head`(迁移目录
# packages/db/migrations 已随 `COPY packages` 进镜像;缺 alembic.ini 迁移会找不到配置而失败)。
COPY alembic.ini ./
RUN uv sync --no-dev --frozen || uv sync --no-dev
EXPOSE 8000
# 存活探测:镜像无 curl用 stdlib urllib 打 /health非 200/连接失败会抛异常,退出非零)。
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
CMD ["uv", "run", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]

13
apps/web/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
# web 镜像构建上下文context=./apps/web。排除宿主产物避免 `COPY . .` 把宿主
# node_modules含平台原生 next-swc/esbuild 二进制)覆盖容器内 `pnpm install` 的 Linux 版,
# 导致 `pnpm build` 加载原生绑定失败。同时排除构建产物/日志加速上下文传输、保证可复现。
node_modules
.next
.turbo
npm-debug.log*
pnpm-debug.log*
.env
.env.local
.DS_Store
coverage
*.tsbuildinfo

View File

@@ -1,9 +1,16 @@
FROM node:22-slim AS build
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
# pnpm-workspace.yaml 必须在 install 前就位:它携带 minimumReleaseAgeExclude豁免新发布包的
# 供应链策略、onlyBuiltDependencies/allowBuilds构建脚本白名单。缺它则容器内 install 看不到
# 这些策略,会误拒近期发布的依赖(如 @xyflow/*)而失败——与宿主行为不一致。
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile || pnpm install
COPY . .
# NEXT_PUBLIC_* 在 Next 构建期内联进浏览器 bundle——必须在 `pnpm build` 前用 build ARG 注入。
# 默认 http://localhost:8000 供 dev生产经 compose build args 传公网 API 基址。
ARG NEXT_PUBLIC_API_BASE=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE=$NEXT_PUBLIC_API_BASE
RUN pnpm build
FROM node:22-slim AS run
@@ -12,4 +19,7 @@ COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
# 存活探测node 打首页run 阶段无 curl5xx/连接失败退出非零。
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["node", "-e", "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"]
CMD ["node", "server.js"]

40
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,40 @@
# 生产加固覆盖层 —— 与 base 叠加使用base 保持 dev 便利,本文件收紧安全):
# docker compose -f docker-compose.yml -f docker-compose.prod.yml build
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# 需在宿主机 .envchmod 600勿入库或环境变量提供以下必填项缺失即拒绝 up / config 报错):
# POSTGRES_PASSWORD —— pg 强随机口令(替换 base 的弱口令 writer/writer
# CREDENTIAL_ENC_KEY —— Fernet key生产新生成勿用 .env.example 占位值)
# CORS_ORIGINS —— 前端真实来源,逗号分隔,禁通配 *
# NEXT_PUBLIC_API_BASE—— 浏览器可达的公网 API 基址(构建期内联进 web bundle
services:
pg:
environment:
POSTGRES_USER: writer
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 必填(生产强口令,勿用 base 弱口令)}
POSTGRES_DB: writer
# 不对公网暴露:仅绑本机回环(供宿主机跑迁移)。彻底隔离可整段删掉只走 compose 内网。
ports:
- "127.0.0.1:5432:5432"
restart: unless-stopped
api:
environment:
DATABASE_URL: postgresql+asyncpg://writer:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 必填}@pg:5432/writer
DATABASE_URL_SYNC: postgresql+psycopg://writer:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 必填}@pg:5432/writer
APP_ENV: prod
LOG_JSON: "true"
CREDENTIAL_ENC_KEY: ${CREDENTIAL_ENC_KEY:?CREDENTIAL_ENC_KEY 必填(生产 Fernet key勿用占位值}
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS 必填(前端真实来源,逗号分隔,禁通配)}
restart: unless-stopped
web:
build:
context: ./apps/web
args:
# 构建期内联:生产须指向浏览器可达的公网 API通常反代后的 https 域名)。
NEXT_PUBLIC_API_BASE: ${NEXT_PUBLIC_API_BASE:?NEXT_PUBLIC_API_BASE 必填(浏览器可达的公网 API 基址)}
environment:
NEXT_PUBLIC_API_BASE: ${NEXT_PUBLIC_API_BASE:?NEXT_PUBLIC_API_BASE 必填}
API_BASE_INTERNAL: http://api:8000
restart: unless-stopped

View File

@@ -22,7 +22,12 @@ services:
environment:
DATABASE_URL: postgresql+asyncpg://writer:writer@pg:5432/writer
DATABASE_URL_SYNC: postgresql+psycopg://writer:writer@pg:5432/writer
APP_ENV: dev
APP_ENV: ${APP_ENV:-dev}
LOG_JSON: ${LOG_JSON:-false}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
# Fernet 加密 keyapi 启动即校验,缺失/非法则拒绝启动。compose 自动读根 .env 覆盖此默认;
# 默认值 = .env.example 的 dev 占位 key保证 `docker compose up` 无 .env 也能起(勿用于生产)。
CREDENTIAL_ENC_KEY: ${CREDENTIAL_ENC_KEY:-cnMpG6QQxJejuDLHTe_S-nq2snoKgXCqWFfsctEHB-4=}
ports:
- "8000:8000"
depends_on:
@@ -32,8 +37,13 @@ services:
web:
build:
context: ./apps/web
# NEXT_PUBLIC_API_BASE 为构建期内联,须经 build arg 传入(见 apps/web/Dockerfile
args:
NEXT_PUBLIC_API_BASE: ${NEXT_PUBLIC_API_BASE:-http://localhost:8000}
environment:
NEXT_PUBLIC_API_BASE: http://localhost:8000
NEXT_PUBLIC_API_BASE: ${NEXT_PUBLIC_API_BASE:-http://localhost:8000}
# RSC 服务端取数走容器内网直连 api否则回退 localhost:8000 打不到 api 容器)。
API_BASE_INTERNAL: http://api:8000
ports:
- "3000:3000"
depends_on: