## 结果 四容器编排从 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 是拼接语义)在宿主反代上 一样成立,只是换了文件。
232 lines
11 KiB
YAML
232 lines
11 KiB
YAML
# 墨观 云端部署编排(单机起步,可平滑扩多实例)
|
||
# 组件: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:
|