From 088c9b34a070d1eba4253517c8a88da0bd300bba Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:29:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor(deploy):=20=E7=A7=BB=E9=99=A4=20nginx?= =?UTF-8?q?=20=E5=AE=B9=E5=99=A8=EF=BC=8C=E6=8E=A5=E5=85=A5=E5=B1=82?= =?UTF-8?q?=E6=94=B9=E7=94=B1=E5=AE=BF=E4=B8=BB=E6=89=BF=E6=8B=85=EF=BC=88?= =?UTF-8?q?=E5=B8=B8=E9=A9=BB=205=20=E2=86=92=204=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 结果 四容器编排从 6 个 service 降到 5 个:`postgres` / `redis` / `business` / `gateway` 常驻 + `migrate` 一次性。**常驻容器 5 → 4**,实测直连 `127.0.0.1:4000|3000/health` 均 200。 ## 为什么现在做 `docker-compose.prod.yml` 里早就写着"生产不启动自带 nginx 容器,接入层交给 1Panel OpenResty",只是用 `profiles: ["never"]` 停用的。也就是说**生产本来就没这个容器**, 默认文件里留着它反而让两条路径分叉:本地开发跑一套、生产跑另一套,`nginx.conf` 只服务前者。现在两套文件对齐,反代只在宿主一处。 ## 三个必须处理的连带问题(都不是删几行就完事) **① `&logging` 锚点原本挂在 nginx service 里**,被另外 5 个 service 用 `*logging` 引用。直接删块会让 Compose 报 YAML 锚点未定义,而**报错点在引用的那几行、不在 被删的地方**。已把它提到顶层 `x-logging:` 扩展字段(Compose 支持),并把这个坑 写进注释。 **② prod 覆盖里那段 `nginx: profiles: ["never"]` 会变成悬空引用**(服务已不存在), 一并删除并改写注释。 **③ `watchdog.sh` 探的 `/healthz` 原本是 nginx 容器提供的虚拟端点。** 删掉后它会 恒报 `entry:404` —— 一个"服务其实好好的但监控一直叫"的假故障。已把探针语义改成 "接入层活着",并在 404 时把最常见成因写进告警文本(宿主反代没配 /healthz)。 ## 宿主反代模板 `deploy/nginx.host.conf.example`(替代原来的 `deploy/nginx.conf`),关键差异: upstream 从 `gateway:3000` / `business:4000` 改为 `127.0.0.1:3000` / `127.0.0.1:4000` —— 容器服务名**在宿主上根本不解析**,照抄旧配置会 `host not found in upstream`。 四条承载性指令原样保留,且写清了缺每一条的**症状**(都不是 502): - `proxy_http_version 1.1` + `Upgrade`/`Connection` → 缺了 WebSocket 握手失败 - `proxy_read_timeout 600s` → 缺了长连接 60 秒被无声掐断 - `proxy_buffering off` → 缺了画面不实时(数据被攒住) - `/relay/` 的 `proxy_request_buffering off` → 缺了"文件不落盘"变成假话 配置已用真 `nginx -t` 校验(`nginx:1.27-alpine`,`syntax is ok / test is successful`), 不是靠肉眼或 grep 判断。 ## 护栏:把"搬到宿主"这件事本身变成可验证的 `tests/dockerfile-coverage-test.js` 新增 7 条:模板存在、四条承载性指令各一条、 upstream 必须是回环而非 Docker 服务名、以及一条**负向**断言 —— 四容器编排里不许 再冒出 `nginx:` 服务(防"顺手加回来"造成两处真相)。 不加这段的话,宿主模板被删或被简化**不会有任何测试变红**,而症状是"页面能开但画面 不刷新",从现象很难倒推回配置。 测试:`dockerfile-coverage` **60/0**、`claim-proof` **57/0**。 ## README 现状描述(目录树 / 端口表 / 起栈步骤)已改。**历史段落不改写**: - "5 容器 healthy、经 nginx :80 入口"那句加"时代注记",说明那是当时形态; - "坑 B:nginx 强依赖证书"整段加抬头说明容器已移除,但**过程与教训原样保留** —— 那两条坑(硬引用可选文件导致 clone 必崩、Compose ports 是拼接语义)在宿主反代上 一样成立,只是换了文件。 --- README.md | 28 +++++++++---- deploy/docker-compose.prod.yml | 15 +++---- deploy/docker-compose.yml | 41 +++++-------------- .../{nginx.conf => nginx.host.conf.example} | 26 +++++++++++- deploy/nginx.ssl.conf.example | 4 +- deploy/watchdog.sh | 15 ++++++- tests/dockerfile-coverage-test.js | 36 ++++++++++++++-- 7 files changed, 109 insertions(+), 56 deletions(-) rename deploy/{nginx.conf => nginx.host.conf.example} (73%) diff --git a/README.md b/README.md index d2df9a2..b8e5635 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,10 @@ inkwatch/ │ ├── lib/ui/ # 界面层(样式只许来自 ui/theme.dart,有护栏测试) │ └── test/ # Dart 测试(含视觉令牌护栏与跨语言角色目录校验) ├── deploy/ -│ ├── docker-compose.yml # 编排:nginx + gateway + business + redis + pg +│ ├── docker-compose.yml # 编排:gateway + business + redis + pg │ ├── Dockerfile.gateway # 多阶段构建(ADR-001) │ ├── Dockerfile.business -│ ├── nginx.conf # WSS 反代 / 长连接调优 / /admin/ 路由 +│ ├── nginx.host.conf.example # **宿主**反代模板(WSS / 长连接 / /admin/ 路由) │ ├── nginx-all.conf # 单容器统一入口(3500) │ └── init.sql # TimescaleDB 超表 / 保留策略 / 降采样(含增量迁移段) └── tests/ @@ -106,11 +106,16 @@ inkwatch/ cd deploy docker compose up -d --build -docker compose ps # 5 个容器应全部 healthy -curl http://localhost/healthz # ok +docker compose ps # 4 个容器应全部 healthy +curl http://127.0.0.1:4000/health # ok ``` -默认仅 HTTP,开箱可用。启用 HTTPS 见 `deploy/nginx.ssl.conf.example`。 +> **接入层不在容器里**(2026-11-08 起)。原先的 nginx 容器已移除:那台机的 80/443 +> 本来就被 1Panel OpenResty 占着,自带 nginx 只是多一跳。现在反代由宿主承担, +> 模板见 `deploy/nginx.host.conf.example`(裸 nginx 整份照抄;1Panel 只抄 location 段)。 +> 四条承载性指令必须抄全(WebSocket 升级头 / `proxy_read_timeout 600s` / +> `proxy_buffering off` / `/relay/` 的 `proxy_request_buffering off`)—— +> 少一条的表现不是 502,而是"能开页面但画面不刷新"或"文件其实落了盘"。 **无 TimescaleDB 镜像时**(国内拉取 1GB 级镜像可能极慢):`init.sql` 已做成 **扩展可缺省**,把 PG 换成普通的即可,表结构与指标列完全一致: @@ -131,10 +136,10 @@ POSTGRES_IMAGE=postgres:15-alpine docker compose up -d --build | 服务 | 端口 | 用途 | |---|---|---| -| nginx | 80 | 接入层(`/socket.io/` → 网关,`/api/` → 业务) | -| gateway | 3000 | Socket.io 网关(调试直连用) | -| business | 4000 | 业务 REST(调试直连用) | +| gateway | 3000 | Socket.io 网关(宿主反代的上游) | +| business | 4000 | 业务 REST(宿主反代的上游) | | postgres / redis | 5432 / 6379 | 仅 `127.0.0.1` 绑定 | +| ~~nginx~~ | ~~80~~ | **已移除**(2026-11-08):反代归宿主,见上面的模板 | > 凭据经 `.env` 注入,默认值为本地开发用。生产务必覆盖 `POSTGRES_PASSWORD`。 @@ -318,6 +323,8 @@ node perf-bench.js # ★ 性能基准(动网关热路径前后 > 该文件头部早就写了这条教训("标签写 416 而实跑已 598"),仍在复发, > 故这次连头部注释一起校正。 上表的服务端套件已在 **Docker 全栈**(5 容器 healthy、经 nginx `:80` 入口)上复跑通过, +(**时代注记**:那一轮跑的是 5 容器形态。2026-11-08 移除 nginx 容器后为 4 容器 + +宿主反代;上述数字本身未因此变化,但"经 nginx `:80` 入口"这句只对当时成立。) 非仅本机进程直跑。 **真实帧实测值**(`rust-frame-pipeline-test.js` 打印,本机 M3 Max): @@ -458,6 +465,11 @@ disk_free_gb=3251.79 net_rx_mbps=4.55 net_tx_mbps=1.71 uptime_s=106001 `nginx.conf` 的 upstream 也从 `127.0.0.1:3000` 改为 `gateway:3000`。 (host 网络确实能省一层 NAT,但与 compose 服务发现互斥,单机也要二选一。) +> ⚠️ **本节的 nginx 容器已于 2026-11-08 移除**(接入层改由宿主承担,见 +> `deploy/nginx.host.conf.example`)。下面两条坑仍值得读 —— 它们的教训是 +> "配置硬引用可选文件会让 clone 下来必崩"与"ports 是拼接而非替换", +> 这两条在宿主反代上一样成立,只是换了文件。 + ### 坑 B:nginx 强依赖证书导致「clone 下来必崩」 `certs/` 被 `.gitignore` 排除(私钥不能入库),但 nginx.conf 硬引用 diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 982cf2d..63891dd 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -7,10 +7,12 @@ # # ① 该机 80/443 已被 1Panel OpenResty 占用(且 OpenResty 跑在 host 网络, # 反代的是 127.0.0.1:端口 —— 既有 searxng 就是这么接的)。 -# → 生产**不启动** 墨观 自己的 nginx 容器,接入层交给 1Panel 站点。 -# → 代价:deploy/nginx.conf 里那几条承载性指令必须搬进 1Panel 站点配置 -# (WebSocket 升级头 / proxy_read_timeout 600s / proxy_buffering off / -# proxy_http_version 1.1)—— 按上述四条搬进 1Panel 站点配置。 +# → **两套 compose 文件里都不再有 nginx 容器**(2026-11-08 移除,本文件原先 +# 只是用 profiles 停用它)。接入层一律交给宿主:1Panel 站点,或裸 nginx。 +# → 宿主配置模板见 deploy/nginx.host.conf.example。必须照抄的四条承载性指令: +# WebSocket 升级头 / proxy_read_timeout 600s / proxy_buffering off / +# proxy_http_version 1.1 —— 少任何一条的表现都不是 502,而是 +# "能打开页面但画面不刷新"或"WebSocket 60 秒后被无声掐断"。 # # ② 该实例云防火墙是「全部 TCP 1-65535 放通」。 # → gateway(3000) / business(4000) **必须**绑回环:业务服务带 @@ -24,11 +26,6 @@ name: inkwatch services: - # ---------- 接入层:由 1Panel OpenResty 承担,生产不启动本容器 ---------- - # 用 profile 停用(而不是删掉本服务)—— 本地开发仍按默认文件起它。 - nginx: - profiles: ["never"] - gateway: ports: !override - "127.0.0.1:3000:3000" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 659c6cc..3455ebd 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -9,36 +9,18 @@ # 永不随显示名改。 name: inkwatch -services: - # ---------- 接入层:反向代理 + WSS 升级 ---------- - # 默认仅 HTTP(开箱可用)。启用 HTTPS 见 deploy/nginx.ssl.conf.example - nginx: - image: nginx:1.27-alpine - ports: - - "80:80" - # 启用 HTTPS 时取消下行注释,并把 nginx.conf 换成 ssl 模板 - # - "443:443" - volumes: - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - # 证书目录可选:目录为空时默认 HTTP 配置不读取证书 - - ./certs:/etc/nginx/certs:ro - - nginx_logs:/var/log/nginx - depends_on: - gateway: - condition: service_healthy - restart: unless-stopped - logging: &logging - driver: json-file - options: - max-size: "10m" # 日志有界:防磁盘打满(见方案 ADR-001) - max-file: "3" - healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"] - interval: 20s - timeout: 5s - retries: 3 +# ── 日志有界(防磁盘打满,见方案 ADR-001)─────────────────────────── +# 用顶层 `x-` 扩展字段挂锚点,而不是挂在某个 service 上: +# 挂在 service 上的锚点会随那个 service 一起被删 —— 2026-11-08 去掉 nginx +# service 时差点踩到:删块后 5 处 `*logging` 会全部解析失败,而报错点在 +# 引用的那几行,不在被删的地方。 +x-logging: &logging + driver: json-file + options: + max-size: "10m" + max-file: "3" - # ---------- 网关:无状态 Socket.io 转发 ---------- +services: gateway: build: # 上下文取仓库根:网关需引用 server/shared 与 shared/(协议+编解码) @@ -247,4 +229,3 @@ services: volumes: redis_data: pg_data: - nginx_logs: diff --git a/deploy/nginx.conf b/deploy/nginx.host.conf.example similarity index 73% rename from deploy/nginx.conf rename to deploy/nginx.host.conf.example index 7140f52..75cd9c6 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.host.conf.example @@ -1,3 +1,25 @@ +# ───────────────────────────────────────────────────────────────────── +# 墨观 · **宿主**反向代理配置模板(2026-11-08 起) +# +# ⚠️ 这不是容器内配置。2026-11-08 已从四容器编排里**移除 nginx 容器**, +# 接入层一律由宿主承担。所以本文件里的 upstream 是 `127.0.0.1:3000/4000` +# (compose 用 `!override` 把两个端口绑回环,见 docker-compose.prod.yml), +# **不是** 原来的 `gateway:3000` / `business:4000` —— 那两个名字只在 +# Docker 网络内部可解析,宿主 nginx 用它必然 `host not found in upstream`。 +# +# 两种用法: +# ① 裸 nginx 做接入层:整份照抄(改掉 server_name 与证书路径即可)。 +# ② 1Panel / 已有 OpenResty 站点:**只抄 location 段**,把它贴进站点的 +# 反向代理配置里,`listen` / `server_name` 用面板自己的。 +# +# 四条**承载性**指令(缺一条不是 502,而是更难看出来的症状): +# · `proxy_http_version 1.1` + `Upgrade`/`Connection` 头 → 缺了 WebSocket 握手失败 +# · `proxy_read_timeout 600s` → 缺了长连接 60 秒被无声掐断 +# · `proxy_buffering off` → 缺了画面不实时(数据被攒住) +# · `/relay/` 的 `proxy_request_buffering off` → 缺了"文件不落盘"变成假话 +# (请求体被 nginx 缓冲到临时文件) +# ───────────────────────────────────────────────────────────────────── + # 墨观 接入层配置(见方案 V4.0 第 9 章 / 第 11 章) # # 两种模式: @@ -10,12 +32,12 @@ upstream inkwatch_gateway { # 多实例扩容时在此增加后端(见方案 11.3) - server gateway:3000 max_fails=2 fail_timeout=10s; + server 127.0.0.1:3000 max_fails=2 fail_timeout=10s; keepalive 64; } upstream inkwatch_business { - server business:4000 max_fails=2 fail_timeout=10s; + server 127.0.0.1:4000 max_fails=2 fail_timeout=10s; keepalive 16; } diff --git a/deploy/nginx.ssl.conf.example b/deploy/nginx.ssl.conf.example index b8274d1..333fe90 100644 --- a/deploy/nginx.ssl.conf.example +++ b/deploy/nginx.ssl.conf.example @@ -14,12 +14,12 @@ # 证书目录已被 .gitignore 排除,切勿把私钥提交入库。 upstream inkwatch_gateway { - server gateway:3000 max_fails=2 fail_timeout=10s; + server 127.0.0.1:3000 max_fails=2 fail_timeout=10s; keepalive 64; } upstream inkwatch_business { - server business:4000 max_fails=2 fail_timeout=10s; + server 127.0.0.1:4000 max_fails=2 fail_timeout=10s; keepalive 16; } diff --git a/deploy/watchdog.sh b/deploy/watchdog.sh index 7bc2349..7a79683 100644 --- a/deploy/watchdog.sh +++ b/deploy/watchdog.sh @@ -7,7 +7,12 @@ # 并且只在**状态变化时**写(见 STATE 文件),不刷屏。 # # ── 检查什么 ──────────────────────────────────────────────────────── -# 1. 统一入口 /healthz —— nginx 进程活着(最轻的判断) +# 1. 统一入口 /healthz —— **接入层**活着(最轻的判断) +# 2026-11-08 起接入层不在容器里(nginx 容器已移除),/healthz 由**宿主反代** +# 提供(见 deploy/nginx.host.conf.example 的 `location = /healthz`)。 +# 语义因此弱了一层:它证明的是"人能访问到的第一跳在",而不是"容器内的 nginx +# 进程在"。好处是它更接近用户真实路径;坏处是**宿主反代没配时这条必红** —— +# 那正是它该报的,因为此时服务对外确实不可达。 # 2. 网关 /health —— gateway 能应答(含 pg/redis 连通性字段) # 业务 /api/ 不单独探:它没有 health 路由(404 也说明 business 活着), # 用 404 当"活"会掩盖真故障,故改由网关 /health 的 checks 字段覆盖。 @@ -35,7 +40,13 @@ code_of() { now() { date '+%Y-%m-%d %H:%M:%S%z'; } fail="" -c=$(code_of "$BASE/healthz"); [ "$c" = "200" ] || fail="$fail entry:$c" +c=$(code_of "$BASE/healthz") +# 404 的常见成因不是服务挂了,而是**宿主反代没配 /healthz**(删掉 nginx 容器后 +# 它就是宿主的事了)。把这一句写进告警文本,省掉下次排查的第一步。 +if [ "$c" != "200" ]; then + fail="$fail entry:$c" + [ "$c" = "404" ] && fail="$fail(宿主反代缺 /healthz?见 deploy/nginx.host.conf.example)" +fi c=$(code_of "$BASE/health"); [ "$c" = "200" ] || fail="$fail gateway:$c" prev="up" diff --git a/tests/dockerfile-coverage-test.js b/tests/dockerfile-coverage-test.js index d20b029..ca816d6 100644 --- a/tests/dockerfile-coverage-test.js +++ b/tests/dockerfile-coverage-test.js @@ -568,7 +568,7 @@ console.log('\n=== 安全开关通路(代码 → .env.example → compose)== { file: 'deploy/docker-compose.yml', label: '四容器编排', map: { gateway: 'gateway', business: 'business' }, - selfCheck: ['gateway', 'business', 'redis', 'postgres', 'nginx'], + selfCheck: ['gateway', 'business', 'redis', 'postgres'], // 四容器路径里网关端口由 compose 注入 portSelfCheck: (envBySvc) => ok('compose 解析自检:gateway 的 PORT 被认出', !!(envBySvc.gateway && envBySvc.gateway.has('PORT'))), @@ -583,11 +583,41 @@ console.log('\n=== 安全开关通路(代码 → .env.example → compose)== }, ]; + // ── 接入层已移到宿主(2026-11-08 移除 nginx 容器)───────────────────── + // 为什么必须有这一段:nginx 容器删掉后,"反代该做什么"就只存在于**宿主**配置里, + // 而宿主配置不在本仓的运行路径上 —— 一旦那个模板被删或被简化,没有任何测试会红, + // 而症状也不是 502,是"页面能打开但画面不刷新""长连接 60 秒后被无声掐断" + // "文件其实落到了 nginx 的 temp 上"。这类退化只能靠对模板本身断言来防。 + console.log('\n--- 接入层:宿主反向代理(容器已移除)---'); + const hostConf = path.join(ROOT, 'deploy/nginx.host.conf.example'); + ok('deploy/nginx.host.conf.example 存在', fs.existsSync(hostConf)); + if (fs.existsSync(hostConf)) { + const nc = fs.readFileSync(hostConf, 'utf8'); + ok('宿主模板含 WebSocket 升级(http_version 1.1 + Upgrade 头)', + /proxy_http_version\s+1\.1/.test(nc) && /Upgrade\s+\$http_upgrade/.test(nc)); + ok('宿主模板含 proxy_read_timeout 600s(长连接不被 60 秒掐断)', + /proxy_read_timeout\s+600s/.test(nc)); + ok('宿主模板含 proxy_buffering off(画面才实时)', + /proxy_buffering\s+off/.test(nc)); + ok('宿主模板含 proxy_request_buffering off(文件中继不落盘)', + /proxy_request_buffering\s+off/.test(nc)); + // upstream 必须是宿主能解析的地址。容器服务名(gateway:3000)只在 Docker + // 网络内可解析,宿主 nginx 用它必然 "host not found in upstream"。 + ok('宿主模板 upstream 用回环地址而非 Docker 服务名', + /server\s+127\.0\.0\.1:3000/.test(nc) && /server\s+127\.0\.0\.1:4000/.test(nc) + && !/server\s+gateway:3000/.test(nc) && !/server\s+business:4000/.test(nc)); + } + // 负向断言:四容器编排里不该再出现 nginx 服务。 + // 防的是"顺手加回来"——那会让接入层变成两处真相,且默认文件与 prod 覆盖 + // 再次分叉(prod 原先正是用 profiles 停用它)。 + const fourTxt = fs.readFileSync(path.join(ROOT, 'deploy/docker-compose.yml'), 'utf8'); + ok('四容器编排不含 nginx 服务(接入层只在宿主)', + !/^\s{2}nginx:\s*$/m.test(fourTxt)); + for (const cf of COMPOSE_FILES) { console.log(`\n--- ${cf.label}(${cf.file})---`); ok(`${cf.file} 存在`, fs.existsSync(path.join(ROOT, cf.file))); - const composeText = fs.readFileSync(path.join(ROOT, cf.file), 'utf8'); - const envBySvc = composeEnvByService(composeText); + const composeText = fs.readFileSync(path.join(ROOT, cf.file), 'utf8'); const envBySvc = composeEnvByService(composeText); const svcNames = Object.keys(envBySvc); // 解析自检:解析失手会静默放行,故必须先证明解析是对的