refactor(deploy): 移除 nginx 容器,接入层改由宿主承担(常驻 5 → 4)

## 结果

四容器编排从 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 是拼接语义)在宿主反代上
  一样成立,只是换了文件。
This commit is contained in:
jkooe committed 2026-11-08 20:29:43 +08:00
1 parent 7c9c2f0605
commit 088c9b34a0
7 files changed
+109 -56

No files matched your search

+20 -8
View File
@@ -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 硬引用
+6 -9
View File
@@ -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"
+11 -30
View File
@@ -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:
@@ -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;
}
+2 -2
View File
@@ -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;
}
+13 -2
View File
@@ -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"
+33 -3
View File
@@ -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);
// 解析自检:解析失手会静默放行,故必须先证明解析是对的