Files
inkwatch/deploy/docker-compose.yml
T
jkooe 088c9b34a0 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 是拼接语义)在宿主反代上
  一样成立,只是换了文件。
2026-11-08 20:29:43 +08:00

232 lines
11 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 墨观 云端部署编排(单机起步,可平滑扩多实例)
# 组件:Nginx + gateway + business + redis + postgres(timescaledb)
# 见方案 V4.0 第 11 章
# ⚠️⚠️ 这个 name 是 **Docker 卷名的锚点**(`inkwatch_pg_data` 等),
# **绝对不要**因为"项目改名叫墨观了"就把这里改成 inkwatch ——
# 改了之后 compose 会去找一套**新的空卷**,数据库数据全丢(等于把生产清库)。
# 项目显示名(界面/APK)与这里的内部标识是两件事 —— 内部标识(含卷名)
# 永不随显示名改。
name: inkwatch
# ── 日志有界(防磁盘打满,见方案 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"
services:
gateway:
build:
# 上下文取仓库根:网关需引用 server/shared 与 shared/(协议+编解码)
context: ..
dockerfile: deploy/Dockerfile.gateway
environment:
PORT: 3000
REDIS_URL: redis://redis:6379
DATABASE_URL: postgres://${POSTGRES_USER:-inkwatch}:${POSTGRES_PASSWORD:-inkwatch_dev_only}@postgres:5432/${POSTGRES_DB:-inkwatch}
REDIS_ADAPTER: "${REDIS_ADAPTER:-0}" # 多实例扩容时置 1
CORS_ORIGIN: "${CORS_ORIGIN:-*}"
# ── 第二步安全开关(与 business 段同名同义,默认全关 = 兼容模式)──────
# ⚠️ 这两个必须传:**设备令牌校验发生在网关的 onHello 里**,不传则
# REQUIRE_DEVICE_TOKEN 形同虚设(.env 里设了也没人读)。
# 真实事故:2026-09-15 部署评估时发现 business 段传了、gateway 段漏传,
# 而 tests/auth-mode-test.js 是直接 spawn 进程传 env,测不到 compose 这条通路。
# 现由 tests/dockerfile-coverage-test.js「开关通路」段锁定。
REQUIRE_AUTH: "${REQUIRE_AUTH:-0}"
REQUIRE_DEVICE_TOKEN: "${REQUIRE_DEVICE_TOKEN:-0}"
# 注意:--expose-gc / --max-old-space-size 已在 Dockerfile 的 CMD 中传入。
# 不要在此重复配置 NODE_OPTIONS,否则 Node 会报 "bad option"。
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
restart: unless-stopped
# 长连接场景关键调优:fd 限制必须显式放宽(见方案 ADR-001)
ulimits:
nofile:
soft: 65536
hard: 65536
# 端口映射:网关在默认桥接网络内,通过服务名 redis/postgres 访问依赖组件。
# 注意:不要用 network_mode: host —— 那会脱离 compose 网络,导致
# getaddrinfo ENOTFOUND redis 之类的服务名解析失败。
ports:
- "3000:3000"
logging: *logging
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 15s
timeout: 5s
start_period: 10s
retries: 3
# 部署时先摘流量再停实例(滚动更新,见方案 11.3)
stop_grace_period: 15s
# ---------- 业务服务:账号 / 配对 / 授权 ----------
business:
build:
# 上下文取仓库根(同 gateway,需引用 server/shared)
context: ..
dockerfile: deploy/Dockerfile.business
environment:
PORT: 4000
# 凭据从环境注入(默认值为本地开发用,生产务必覆盖)
DATABASE_URL: postgres://${POSTGRES_USER:-inkwatch}:${POSTGRES_PASSWORD:-inkwatch_dev_only}@postgres:5432/${POSTGRES_DB:-inkwatch}
REDIS_URL: redis://redis:6379
# 配对接口按来源 IP 限流,需要信任反代头。默认 1 层(本栈的 nginx)。
# 若改了拓扑(例如让业务服务直接对外),必须同步调整,否则
# X-Forwarded-For 可被伪造而绕过 IP 限流。直连语义填 0。
TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-1}"
# 单 IP 配对验证次数上限(默认 30 次/小时,见 shared/pairing.js)。
# NAT 出口后合法用户很多时按需调大。
PAIRING_IP_LIMIT: "${PAIRING_IP_LIMIT:-30}"
# ── 第二步安全开关(默认全关 = 兼容模式,旧客户端不受影响)──────────
# REQUIRE_AUTH=1 强制登录:无令牌一律 401;同时关闭自助注册
# (需要开自助注册再另设 ALLOW_OPEN_REGISTER=1)
# ENFORCE_DEVICE_QUOTA=1 按档位限制名下设备数(免费 3 / 专业 10 / 团队 30)
# 切换时机由运维掌握:改环境变量重启即可,不必改代码重新评审。
REQUIRE_AUTH: "${REQUIRE_AUTH:-0}"
# 默认值与代码同向(代码是 `!== '0'` 即生效)。这里若写 -0,
# 照着 .env.example 部署会把配额静默关掉。
ENFORCE_DEVICE_QUOTA: "${ENFORCE_DEVICE_QUOTA:-1}"
# 强制登录时是否仍允许自助注册(默认关)。**只有业务服务读**,
# 但同样必须传:不传则 .env 里设了也没人读(与网关那两个同一类事故)。
ALLOW_OPEN_REGISTER: "${ALLOW_OPEN_REGISTER:-0}"
# ── 云端管理台(/admin/ 与 /api/admin/*)──────────────────────────
# 不设 ADMIN_TOKEN = 管理端整体 404(不对外宣布它存在)。
# 三个都必须传:管理端代码读它们,不传则 .env 设了也没人读 ——
# 与上面 REQUIRE_DEVICE_TOKEN 漏传是同一类事故,由
# tests/dockerfile-coverage-test.js「开关通路」段锁定。
ADMIN_TOKEN: "${ADMIN_TOKEN:-}"
ADMIN_NAME: "${ADMIN_NAME:-admin}"
ADMIN_ALLOW_IPS: "${ADMIN_ALLOW_IPS:-}"
# 安装向导的引导令牌。**不设 = /setup 404**(同 ADMIN_TOKEN 的失败关闭口径)。
# 初始化完成后建议从 .env 里删掉它,或留着也无妨 —— 向导此时已永久 404。
INSTANCE_SETUP_TOKEN: "${INSTANCE_SETUP_TOKEN:-}"
# ── 实例配置的"可留空"覆盖项 ──────────────────────────────────
# 这几项在库里有值、管理端能改;这里传的是**可选的覆盖层**。
# 不传的后果与上面那几个开关不同:不是"没人读",而是"在 .env 里设了
# 却进不了容器",运维会以为自己改了、实际没改。故一律透传。
# 免费档设备上限与中继上限:**这两个以前两份编排都没传**(单容器的 RELAY
# 传了、四容器没有),于是在 .env 里设了不生效 —— 与开关漏传同一类问题。
FREE_DEVICE_LIMIT: "${FREE_DEVICE_LIMIT:-3}"
RELAY_MAX_BYTES: "${RELAY_MAX_BYTES:-1048576}"
INSTANCE_NAME: "${INSTANCE_NAME:-}"
PUBLIC_URL: "${PUBLIC_URL:-}"
MAIL_MODE: "${MAIL_MODE:-}"
MAIL_FROM: "${MAIL_FROM:-}"
MAIL_OUTBOX_PATH: "${MAIL_OUTBOX_PATH:-}"
# SMTP 中继(mail_mode=smtp 时才用)。口令只走环境变量,**不入库**。
SMTP_HOST: "${SMTP_HOST:-}"
SMTP_PORT: "${SMTP_PORT:-}"
SMTP_SECURE: "${SMTP_SECURE:-}"
SMTP_STARTTLS: "${SMTP_STARTTLS:-}"
SMTP_USER: "${SMTP_USER:-}"
SMTP_PASS: "${SMTP_PASS:-}"
SMTP_HELO: "${SMTP_HELO:-}"
SMTP_TLS_CA: "${SMTP_TLS_CA:-}"
AUDIT_RETENTION_DAYS: "${AUDIT_RETENTION_DAYS:-}"
# 业务服务同时依赖 PG(授权/账号)与 Redis(配对码)
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
ports:
- "4000:4000"
logging: *logging
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 20s
timeout: 5s
retries: 3
# ---------- 实时层:环形缓冲 / 在线态 / 订阅关系 / 配对码 ----------
redis:
image: redis:7.4-alpine
command: >
redis-server
--appendonly no
--maxmemory 256mb
--maxmemory-policy allkeys-lru
--save ""
volumes:
- redis_data:/data
# 仅绑定 127.0.0.1:本机测试脚本可直连,不暴露到公网
ports:
- "127.0.0.1:6379:6379"
restart: unless-stopped
logging: *logging
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# ---------- 存储层:元数据 + 时序(TimescaleDB 扩展) ----------
postgres:
# 生产用 TimescaleDB;本地/CI 无该镜像时可切换为普通 PG:
# POSTGRES_IMAGE=postgres:15-alpine docker compose up -d
# init.sql 已做成"扩展可缺省":无 TimescaleDB 时只跳过超表/自动保留/连续聚合策略,
# 表结构与指标列完全一致,功能测试与开发不受影响(见 init.sql 顶部说明)。
image: ${POSTGRES_IMAGE:-timescale/timescaledb:2.17.2-pg16}
environment:
POSTGRES_USER: ${POSTGRES_USER:-inkwatch}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-inkwatch_dev_only}
POSTGRES_DB: ${POSTGRES_DB:-inkwatch}
volumes:
- pg_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
# /migrate.sql 放在 initdb 目录**之外**:init.sql 用 \i 引用它,
# 而下面的 migrate 服务也跑它 —— 两条路径共用同一份定义。
- ./migrate.sql:/migrate.sql:ro
# 仅绑定 127.0.0.1:本机测试脚本可直连,不暴露到公网
ports:
- "127.0.0.1:5432:5432"
restart: unless-stopped
logging: *logging
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-inkwatch} -d ${POSTGRES_DB:-inkwatch}"]
interval: 10s
timeout: 5s
retries: 5
# ---------- 幂等迁移:每次 up 都跑一次,跑完即退出 ----------
#
# 为什么必须有它(2026-09-15 运维坑):`init.sql` 挂在 /docker-entrypoint-initdb.d/,
# **只在数据目录为空时执行** —— 已有库上,写在里面的迁移语句永远不会跑。
# 于是"更新代码但忘了手工迁移"会变成**静默的链路中断**:
# 例如新增 devices.hardware_id 后,registerDevice 写一个不存在的列 → SQL 报错 →
# 网关 catch 掉只记日志 → 设备不再落库,而 /health 全绿。
#
# 一次性服务(跑完退出、不 restart)比"让人记得敲 SQL"可靠:
# 它把"迁移"变成 `docker compose up -d` 的必然副作用。
# migrate.sql 里每条语句都幂等,故反复执行无副作用。
migrate:
image: ${POSTGRES_IMAGE:-timescale/timescaledb:2.17.2-pg16}
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:-inkwatch_dev_only}
# ON_ERROR_STOP=1:迁移失败必须**非零退出**,不能"打个警告继续跑",
# 否则又是"看起来部署成功、功能全废"
command: >
psql -v ON_ERROR_STOP=1 -h postgres -U ${POSTGRES_USER:-inkwatch}
-d ${POSTGRES_DB:-inkwatch} -f /migrate.sql
volumes:
- ./migrate.sql:/migrate.sql:ro
restart: "no"
logging: *logging
volumes:
redis_data:
pg_data: