diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..891f4d5 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 206143c..804299c 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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:-}`,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 # .env(compose 变量插值用;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 -# 宿主机需装 uv(curl -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 +# 宿主机装 uv:curl -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 key(DeepSeek/Kimi/OpenAI…)**永不进 env**——部署后在应用「设置」页录入、加密落库(§1.4)。 + +生成 `.env` 骨架(示例): + +```bash +cat > .env <<'EOF' +POSTGRES_PASSWORD=<强随机口令> +CREDENTIAL_ENC_KEY= +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 Service(Docker 运行时),分别指向两个 Dockerfile;web 的 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/web(pg 不动) # 冒烟 @@ -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 已修复 5):api 用 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.5):api 镜像缺 `alembic.ini`;compose `api` 缺 `CREDENTIAL_ENC_KEY`、`web` 缺 `API_BASE_INTERNAL`;`NEXT_PUBLIC_API_BASE` 构建期内联;api/web 无 HEALTHCHECK。 +- **部署面缺口已全部修复**(§1.5):api 镜像含 `alembic.ini`;base compose `api` 注入 `CREDENTIAL_ENC_KEY`/`APP_ENV`/`LOG_JSON`/`CORS_ORIGINS`、`web` 设 `API_BASE_INTERNAL` + `NEXT_PUBLIC_API_BASE` build arg;api/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` 备份+轮换。 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 1da6cf1..02ccc3e 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -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"] diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..fb68a64 --- /dev/null +++ b/apps/web/.dockerignore @@ -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 diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index ef11dca..77b39e8 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -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 阶段无 curl);5xx/连接失败退出非零。 +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"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..1bab666 --- /dev/null +++ b/docker-compose.prod.yml @@ -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 +# +# 需在宿主机 .env(chmod 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 diff --git a/docker-compose.yml b/docker-compose.yml index c994326..3267631 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 加密 key:api 启动即校验,缺失/非法则拒绝启动。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: