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:
1 parent
7c9c2f0605
commit
088c9b34a0
7 files changed
+109
-56
No files matched your search
@@ -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 硬引用
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 解析自检:解析失手会静默放行,故必须先证明解析是对的
|
||||
|
||||
Reference in new issue
Block a user