deploy(all-in-one): 单容器部署路径(四进程一个容器,应用代码零改动)
一个 pmon-app-1 容器跑 postgres + redis + gateway + business
(supervisord 各自守护、各自重启),外加跑完即退的 migrate。
合容器不合进程:两个 node 进程仍是独立进程,SIGTERM 直达原生优雅
关闭处理器,全部既有安全测试原样有效。
关键不变量(均有测试看守):
· compose project 仍是 pmon、数据卷仍是 pmon_pg_data/pmon_redis_data
→ 与四容器路径随时互切、数据不动(卷名锚点,改名=清库);
· 端口全部绑 127.0.0.1(云防火墙全端口放通,0.0.0.0=公网直露);
· init.sql「空数据目录才初始化」语义复用官方 entrypoint,不重写;
· 启动门 start-{gateway,business}.sh 复刻 depends_on service_healthy,
防全新卷首启时 node 抢在 PG 初始化前 FATAL 停摆;
· 安全开关两个进程同读一份环境,一处传全。
dockerfile-coverage-test.js 扩展:「all」镜像双入口逐断 require 图、
安全开关通路按 compose 文件分别断言(含服务名映射)、新增第 6 段
端口回环断言(IP 前缀可选——裸映射 "3000:3000" 恰是绑 0.0.0.0 的
最危险写法,第一版正则漏了它,审计加固)。
代价如实记入 runbook 7.3:进程隔离弱于四容器、重启四进程齐动、
business 启动参数比四容器多堆上限(D-1)。
This commit is contained in:
1 parent
21b1a561a3
commit
29c4a9c846
8 files changed
+644
-46
No files matched your search
@@ -0,0 +1,118 @@
|
||||
# 墨观 单容器镜像(all-in-one)—— 一个容器跑四个进程:
|
||||
# postgres(TimescaleDB)/ redis / gateway / business,由 supervisord 守护
|
||||
#
|
||||
# ── 与四容器路径(Dockerfile.gateway / Dockerfile.business + 官方 pg/redis 镜像)的关系 ──
|
||||
# · 应用代码零改动:本镜像只是把四个进程装进同一容器,各自仍是独立进程、
|
||||
# 独立重启(supervisord autorestart),**不合并成一个 Node 进程** ——
|
||||
# 因此全部既有测试(含安全测试)原样有效,
|
||||
# 覆盖性由 tests/dockerfile-coverage-test.js 的「all」镜像段按 require 图强制校验。
|
||||
# · 数据卷名不变(compose project 仍是 pmon → pmon_pg_data / pmon_redis_data),
|
||||
# 两条路径可随时互切,数据不动。切换与回退见 docs/deploy-tencent-runbook.md。
|
||||
# · init.sql 的「空数据目录才初始化」语义由官方 postgres entrypoint 原样承载
|
||||
# (program:postgres 直接跑 docker-entrypoint.sh)—— 这条首次部署路径
|
||||
# 本项目踩过坑(云上首装功能全废、健康检查却全绿),不重写、只复用。
|
||||
# · 基底镜像自带 ENTRYPOINT ["docker-entrypoint.sh"],对非 postgres 命令
|
||||
# 直接 exec 透传,故本镜像的 CMD 原样生效,无需覆盖 ENTRYPOINT。
|
||||
#
|
||||
# ── 基底说明(2026-10-14 实测)────────────────────────────────────
|
||||
# timescale/timescaledb:2.17.2-pg16 是 **Alpine 3.21(musl)**镜像,
|
||||
# 不是 Debian —— 包管理器是 apk,node 二进制必须取 musl 版
|
||||
# (node:22-alpine),glibc 版(node:22-bookworm)链不进 musl 系统。
|
||||
# 这与两个四容器 Dockerfile 用 node:22-alpine 的既有选择一致。
|
||||
#
|
||||
# ── 代价(拿「一个容器」的部署体验换的,如实记录)────────────────────
|
||||
# · 镜像 ~500MB 级(Alpine 基底小);应用升级 = 重建整镜像
|
||||
# · 容器重启 = 四进程齐重启(supervisord 各进程独立守护,单崩只拉那一个)
|
||||
# · 进程隔离弱于四容器:redis 与两个 node 进程共享容器边界
|
||||
# (postgres 仍由官方 entrypoint 降权到 postgres 用户;两个 node 进程跑 node 用户)
|
||||
# · redis 版本随 Alpine 源(3.21 为 7.2),不再锁定 7.4-alpine;本仓用法
|
||||
# (GET/SET/EX/PUBSUB/allkeys-lru)跨版本兼容
|
||||
# · 宿主机不发布 6379:redis 只绑容器内回环(四容器路径发布它是为了本机
|
||||
# 测试脚本直连;需要时 docker exec 进容器用 redis-cli)
|
||||
|
||||
# ---------- 阶段 1:jsbuilder —— 装 Node 依赖 ----------
|
||||
# musl 与运行基底同源(Alpine),原生模块(若有)不会出现 glibc/musl 混装。
|
||||
FROM node:22-alpine AS jsbuilder
|
||||
WORKDIR /app
|
||||
# npm 源可覆盖:构建机在中国时默认源常超时(本机与云上走国内镜像更快)。
|
||||
# 用法:docker build --build-arg NPM_REGISTRY=https://registry.npmmirror.com ...
|
||||
# 只换源、不换锁文件(lockfile 仍锁定版本),不影响可复现性。
|
||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
||||
COPY shared/package*.json ./shared/
|
||||
COPY server/package*.json ./server/
|
||||
COPY server/gateway/package*.json ./server/gateway/
|
||||
COPY server/business/package*.json ./server/business/
|
||||
RUN cd shared && npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" \
|
||||
&& cd ../server && npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" \
|
||||
&& cd gateway && npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" \
|
||||
&& cd ../business && npm ci --no-audit --no-fund --registry="$NPM_REGISTRY"
|
||||
|
||||
# ---------- 阶段 2:nodesrc —— 取 node 可执行文件(musl 版) ----------
|
||||
FROM node:22-alpine AS nodesrc
|
||||
|
||||
# ---------- 阶段 3:runtime ----------
|
||||
FROM timescale/timescaledb:2.17.2-pg16
|
||||
|
||||
# apk 源走国内镜像:构建机在中国时 dl-cdn.alpinelinux.org 常超时。
|
||||
# 上游若升级 Alpine 小版本号,repositories 里的路径段不变,替换规则不失配。
|
||||
RUN set -eux; \
|
||||
if [ -f /etc/apk/repositories ]; then \
|
||||
sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://mirrors.aliyun.com/alpine|g' /etc/apk/repositories; \
|
||||
fi
|
||||
|
||||
# redis(含 redis-server 与 redis-cli,同一个包)+ supervisord。
|
||||
# --no-cache:不落索引缓存,镜像不因 apk 缓存变大。
|
||||
RUN apk add --no-cache redis supervisor
|
||||
|
||||
# node 运行时(只取二进制;npm ci 已在 jsbuilder 阶段完成)
|
||||
COPY --from=nodesrc /usr/local/bin/node /usr/local/bin/node
|
||||
|
||||
# 两个 Node 进程以非 root 运行(安全基线,与四容器路径 Dockerfile 的 USER node 一致)
|
||||
# Alpine busybox adduser 语法:-D 免密、-H 不建家目录、-s 指定 shell
|
||||
RUN adduser -D -H -s /sbin/nologin node
|
||||
|
||||
# ── 依赖与源码:目录布局与仓库严格一致 ──
|
||||
# 只有路径一致,require('../../shared/protocol') 与 require('../shared/repository')
|
||||
# 才能正确解析。覆盖性由 tests/dockerfile-coverage-test.js 按 require 图强制校验。
|
||||
# --chown 在建 user 之后做:避免先 COPY 再 chown -R 触发 overlayfs 整层 copy-up。
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node --from=jsbuilder /app/shared/node_modules ./shared/node_modules
|
||||
COPY --chown=node:node shared/package*.json ./shared/
|
||||
COPY --chown=node:node --from=jsbuilder /app/server/node_modules ./server/node_modules
|
||||
COPY --chown=node:node server/package*.json ./server/
|
||||
COPY --chown=node:node --from=jsbuilder /app/server/gateway/node_modules ./server/gateway/node_modules
|
||||
COPY --chown=node:node server/gateway/package*.json ./server/gateway/
|
||||
COPY --chown=node:node --from=jsbuilder /app/server/business/node_modules ./server/business/node_modules
|
||||
COPY --chown=node:node server/business/package*.json ./server/business/
|
||||
COPY --chown=node:node shared/*.js ./shared/
|
||||
COPY --chown=node:node server/shared/*.js ./server/shared/
|
||||
COPY --chown=node:node server/gateway/*.js ./server/gateway/
|
||||
COPY --chown=node:node server/business/*.js ./server/business/
|
||||
|
||||
# ── 进程守护 / 启动门 / 健康检查 ──
|
||||
# start-*.sh 的等待逻辑复刻四容器路径 depends_on: service_healthy 的语义 ——
|
||||
# 单容器里没有 compose 依赖图,「等 postgres/redis 就绪再起进程」必须自己写,
|
||||
# 否则全新卷首次启动时 node 进程会抢在 PG 初始化前面,连不上就退出,
|
||||
# supervisord 重试耗尽后 FATAL 停摆(容器看起来起着、其实半边是死的)。
|
||||
COPY deploy/supervisord.conf /etc/supervisor/conf.d/inkwatch.conf
|
||||
COPY deploy/start-gateway.sh /usr/local/bin/start-gateway.sh
|
||||
COPY deploy/start-business.sh /usr/local/bin/start-business.sh
|
||||
COPY deploy/healthcheck-all.sh /usr/local/bin/healthcheck-all.sh
|
||||
RUN chmod +x /usr/local/bin/start-gateway.sh /usr/local/bin/start-business.sh \
|
||||
/usr/local/bin/healthcheck-all.sh
|
||||
|
||||
# 端口是结构性的:compose 发布 3000/4000。两个进程共用一个环境,
|
||||
# 必须让 business 读更具体的 BUSINESS_PORT,否则它会去抢 gateway 的 3000
|
||||
# (business/index.js 的读取顺序:BUSINESS_PORT → PORT → 4000)。
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV BUSINESS_PORT=4000
|
||||
|
||||
EXPOSE 3000 4000 5432
|
||||
|
||||
# 四合一健康检查:pg / redis / gateway / business 全部就绪才 healthy
|
||||
HEALTHCHECK --interval=15s --timeout=10s --start-period=40s --retries=5 \
|
||||
CMD ["/usr/local/bin/healthcheck-all.sh"]
|
||||
|
||||
# supervisord 前台运行,守护四个进程(见 deploy/supervisord.conf)
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/inkwatch.conf"]
|
||||
@@ -0,0 +1,126 @@
|
||||
# 墨观 单容器部署编排(all-in-one)—— 与 docker-compose.yml(四容器)二选一
|
||||
#
|
||||
# 用法:
|
||||
# docker compose -f docker-compose.all.yml up -d --build
|
||||
#
|
||||
# 形态:一个 app 容器 = postgres + redis + gateway + business(supervisord 守护,
|
||||
# 见 deploy/supervisord.conf),外加一次性 migrate 容器(跑完即退,与四容器
|
||||
# 路径同设计)。1Panel OpenResty 仍是接入层,反代规则不变
|
||||
# (/socket.io/ → 3000、/api/ → 4000)。
|
||||
#
|
||||
# ⚠️ 与四容器路径共用同一个 compose project(name: pmon)→ 数据卷名不变
|
||||
# (pmon_pg_data / pmon_redis_data)→ 两条路径可随时互切,数据不动。
|
||||
# 切换与回退步骤见 docs/deploy-tencent-runbook.md 第七节。
|
||||
#
|
||||
# ⚠️ 端口一律绑回环:该机云防火墙是「全部 TCP 1-65535 放通」,
|
||||
# 绑 0.0.0.0 等于把 gateway/business/PG 直接暴露公网(业务服务带
|
||||
# TRUST_PROXY_HOPS=1,直连可伪造 X-Forwarded-For 绕过配对码 IP 限流)。
|
||||
# 回环绑定由 tests/dockerfile-coverage-test.js 第 6 段断言看守。
|
||||
# (四容器路径用 prod 覆盖文件的 !override 做同一件事,踩过 Compose 的
|
||||
# ports 拼接语义坑;单容器路径只有一个文件,直接绑死。)
|
||||
#
|
||||
# 与方案 V4.0 第 11 章的关系:单容器是单机运维形态;将来若要水平扩容
|
||||
# (多 gateway 实例),回到四容器路径按第 11 章扩,数据卷直接复用。
|
||||
|
||||
name: pmon
|
||||
|
||||
services:
|
||||
# ---------- 单容器应用:四个进程一个容器 ----------
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.all
|
||||
image: pmon-all:latest
|
||||
environment:
|
||||
# ── 存储层(官方 postgres entrypoint 读;同时拼出 DATABASE_URL)──
|
||||
POSTGRES_USER: ${POSTGRES_USER:-pmon}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pmon_dev_only}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-pmon}
|
||||
# 单容器内直连回环,不再走 compose 服务名
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-pmon}:${POSTGRES_PASSWORD:-pmon_dev_only}@127.0.0.1:5432/${POSTGRES_DB:-pmon}
|
||||
REDIS_URL: redis://127.0.0.1:6379
|
||||
# ── 网关 ──
|
||||
REDIS_ADAPTER: "${REDIS_ADAPTER:-0}" # 多实例扩容时置 1(单容器形态用不到)
|
||||
CORS_ORIGIN: "${CORS_ORIGIN:-*}"
|
||||
# ── 业务 ──
|
||||
# 配对接口按来源 IP 限流,需要信任反代头。默认 1 层(1Panel OpenResty)。
|
||||
# 若改了拓扑(例如让业务服务直接对外),必须同步调整,否则
|
||||
# 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_DEVICE_TOKEN,见 runbook ②)。
|
||||
# 覆盖性仍由 tests/dockerfile-coverage-test.js 第 5 段按代码派生清单断言。
|
||||
# REQUIRE_AUTH=1 强制登录:无令牌一律 401;同时关闭自助注册
|
||||
# ENFORCE_DEVICE_QUOTA=1 按档位限制名下设备数(免费 3 / 专业 10 / 团队 30)
|
||||
REQUIRE_AUTH: "${REQUIRE_AUTH:-0}"
|
||||
REQUIRE_DEVICE_TOKEN: "${REQUIRE_DEVICE_TOKEN:-0}"
|
||||
ENFORCE_DEVICE_QUOTA: "${ENFORCE_DEVICE_QUOTA:-0}"
|
||||
ALLOW_OPEN_REGISTER: "${ALLOW_OPEN_REGISTER:-0}"
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
- redis_data:/data
|
||||
# init.sql 挂官方 entrypoint 的初始化目录:只在数据目录为空时执行(语义不变)
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
# migrate.sql 供 migrate 一次性服务使用(app 容器本身不读它)
|
||||
- ./migrate.sql:/migrate.sql:ro
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000" # gateway(OpenResty /socket.io/ 反代到此)
|
||||
- "127.0.0.1:4000:4000" # business(OpenResty /api/ 反代到此)
|
||||
- "127.0.0.1:5432:5432" # postgres(本机 psql 直连排障用)
|
||||
# 6379 有意不发布:redis 只绑容器内回环,需要时 docker exec 进去用
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
restart: unless-stopped
|
||||
# 网关优雅关闭要 3s drain + 落库,留足宽限(supervisord stopwaitsecs 最大 20s)
|
||||
stop_grace_period: 30s
|
||||
logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m" # 日志有界:防磁盘打满(见方案 ADR-001)
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
# 四合一:pg / redis / gateway / business 全就绪才 healthy
|
||||
test: ["CMD", "/usr/local/bin/healthcheck-all.sh"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
start_period: 40s
|
||||
retries: 5
|
||||
|
||||
# ---------- 幂等迁移:每次 up 都跑一次,跑完即退出 ----------
|
||||
#
|
||||
# 为什么必须有它(2026-09-15 运维坑):init.sql 挂在 docker-entrypoint-initdb.d/,
|
||||
# 只在数据目录为空时执行 —— 已有库上,写在里面的迁移语句永远不会跑。
|
||||
# 于是"更新代码但忘了手工迁移"会变成静默的链路中断。一次性服务把"迁移"
|
||||
# 变成 docker compose up -d 的必然副作用;migrate.sql 每条语句幂等。
|
||||
#
|
||||
# 单容器化后的两处变化:
|
||||
# ① 共享 app 的网络命名空间,故 -h 127.0.0.1 而非服务名 postgres;
|
||||
# ② 只等 postgres 就绪(不等 gateway/business 健康)—— 迁移必须是 up 的
|
||||
# 必然副作用,不能因为应用层不健康就被跳过(四容器路径里 migrate
|
||||
# 也只 depends_on postgres healthy)。
|
||||
migrate:
|
||||
image: pmon-all:latest
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_started
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:-pmon_dev_only}
|
||||
command: >
|
||||
sh -c 'i=0; until pg_isready -q -h 127.0.0.1 -U $${POSTGRES_USER:-pmon};
|
||||
do i=$$((i+1)); echo "[migrate] 等 postgres 就绪... ($${i}s)"; sleep 1; done;
|
||||
psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U $${POSTGRES_USER:-pmon}
|
||||
-d $${POSTGRES_DB:-pmon} -f /migrate.sql'
|
||||
volumes:
|
||||
- ./migrate.sql:/migrate.sql:ro
|
||||
restart: "no"
|
||||
logging: *logging
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
redis_data:
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# 墨观单容器四合一健康检查:postgres / redis / gateway / business
|
||||
# 全部就绪才算 healthy。任一项都不掉谎 —— 容器编排与上层探针
|
||||
# 都靠它判断实例可用性。
|
||||
#
|
||||
# 与四容器路径的对应关系:原 compose 里 postgres/redis/gateway/business
|
||||
# 各有一个 healthcheck,这里把四个判断合并进一个脚本,语义不变:
|
||||
# pg_isready(不鉴权,只判断接受连接)
|
||||
# redis-cli ping
|
||||
# node fetch /health(网关:含 Redis 连通性与 PG 授权层)
|
||||
# node fetch /health(业务服务)
|
||||
set -e
|
||||
|
||||
PG_USER="${POSTGRES_USER:-pmon}"
|
||||
PG_DB="${POSTGRES_DB:-pmon}"
|
||||
GW_PORT="${PORT:-3000}"
|
||||
BIZ_PORT="${BUSINESS_PORT:-4000}"
|
||||
|
||||
# 1. PostgreSQL
|
||||
pg_isready -q -h 127.0.0.1 -U "$PG_USER" -d "$PG_DB"
|
||||
|
||||
# 2. Redis
|
||||
redis-cli -h 127.0.0.1 ping | grep -q PONG
|
||||
|
||||
# 3. 网关
|
||||
node -e "fetch('http://127.0.0.1:'+${GW_PORT}+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
# 4. 业务服务
|
||||
node -e "fetch('http://127.0.0.1:'+${BIZ_PORT}+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# 墨观单容器 · 业务服务启动门
|
||||
#
|
||||
# 与 start-gateway.sh 同构:四容器路径里 business 靠 compose 的
|
||||
# depends_on: {redis/postgres: service_healthy} 保证依赖就绪后才起;
|
||||
# 单容器里这个等待必须自己写,理由见 start-gateway.sh 的注释。
|
||||
#
|
||||
# 业务服务对 redis 是失败关闭语义(配对接口无 redis 即 503),
|
||||
# 对 PG 同理(授权/账号查询)—— 所以两者都等,不等「部分就绪」。
|
||||
# pg_isready 必须显式 -U:本脚本以 node 用户运行,缺省用 OS 用户名(node)
|
||||
# 连 PG,服务端日志会刷 "role \"node\" does not exist" 的 FATAL 噪音
|
||||
# (pg_isready 对鉴权失败仍返回 0,门不受影响,但不该留误导排查的日志)。
|
||||
PG_USER="${POSTGRES_USER:-pmon}"
|
||||
|
||||
echo "[start-business] 等待依赖就绪(postgres + redis)..."
|
||||
until pg_isready -q -h 127.0.0.1 -U "$PG_USER" && redis-cli -h 127.0.0.1 ping >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
echo "[start-business] 依赖就绪,启动业务服务(exec node,PID 交给 supervisord)"
|
||||
|
||||
# exec:让 node 取代本脚本的 PID,supervisord 发 SIGTERM 时直达 node 的
|
||||
# 原生关闭处理器(server.close + 关 redis + 关 PG 池)。
|
||||
exec node --expose-gc --max-old-space-size=256 index.js
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# 墨观单容器 · 网关启动门
|
||||
#
|
||||
# 为什么需要它:四容器路径里 gateway 靠 compose 的
|
||||
# depends_on: {redis/postgres: service_healthy} 保证「依赖就绪后才起」。
|
||||
# 单容器里没有 compose 依赖图,这个等待必须自己写 —— 否则全新卷首次启动时
|
||||
# node 进程抢在 PG 初始化前面,连不上就退出,supervisord 重试耗尽后
|
||||
# FATAL 停摆:容器看着起着,其实半边是死的(这正是本项目反复强调的
|
||||
# 「部署看起来成功、功能全废」形态)。
|
||||
#
|
||||
# 就绪判据用 pg_isready / redis-cli ping(都不鉴权,只判断「在accept」),
|
||||
# 与 compose healthcheck condition 的口径一致。
|
||||
# ⚠️ pg_isready 必须显式 -U:本脚本以 node 用户运行,缺省会用 OS 用户名
|
||||
# (node)连 PG,服务端日志刷 "role \"node\" does not exist" 的 FATAL
|
||||
# (2026-10-14 冒烟实测)。pg_isready 对鉴权失败仍返回 0(门不受影响),
|
||||
# 但不该留这种误导排查的噪音。
|
||||
PG_USER="${POSTGRES_USER:-pmon}"
|
||||
|
||||
echo "[start-gateway] 等待依赖就绪(postgres + redis)..."
|
||||
until pg_isready -q -h 127.0.0.1 -U "$PG_USER" && redis-cli -h 127.0.0.1 ping >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
echo "[start-gateway] 依赖就绪,启动网关(exec node,PID 交给 supervisord)"
|
||||
|
||||
# exec:让 node 取代本脚本的 PID,supervisord 发 SIGTERM 时直达 node 的
|
||||
# 原生优雅关闭处理器(3s drain 刷净未落库指标)。
|
||||
exec node --expose-gc --max-old-space-size=256 index.js
|
||||
@@ -0,0 +1,111 @@
|
||||
; 墨观单容器进程守护 —— postgres / redis / gateway / business
|
||||
;
|
||||
; 设计原则(重要):四个进程彼此独立守护(autorestart=true),
|
||||
; **不合并成一个 Node 进程**。「合容器」不等于「合进程」——
|
||||
; · 单崩只拉那一个:business 崩不牵连 gateway 的设备长连接;
|
||||
; · 应用代码零改动:两个入口的 SIGTERM/drain 逻辑原样有效;
|
||||
; · 全部既有测试(含安全测试)原样有效。
|
||||
;
|
||||
; 启动顺序靠 priority(postgres 10 → redis 20 → 两个 node 30),
|
||||
; 但 supervisord 不保证「前者就绪后才起后者」—— 就绪等待写在
|
||||
; start-gateway.sh / start-business.sh 里(复刻 compose 的
|
||||
; depends_on: service_healthy 语义)。
|
||||
;
|
||||
; 日志统一走各进程的 stdout/stderr → docker logs 一把看全;
|
||||
; json-file 上限由 compose 的 logging 段控制(10m × 3)。
|
||||
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
; logfile 指到 /dev/null 而不是 /dev/stdout:nodaemon=true 时 supervisord 会把
|
||||
; 事件日志 echo 到 stdout,若 logfile 也指向 stdout,每一行会出现两遍
|
||||
; (2026-10-14 冒烟实测踩到,事件行成双、进程行正常)。
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
pidfile=/var/run/supervisord.pid
|
||||
; 子进程环境继承容器环境(compose 注入的 POSTGRES_* / DATABASE_URL /
|
||||
; REDIS_URL / 安全开关等),无需在此重复声明 —— 单一真相源只有一份。
|
||||
|
||||
; supervisorctl 的管理通道:docker exec pmon-app-1 supervisorctl status/restart
|
||||
; 靠它单进程重启(不必整容器重启)。缺了这三段,supervisorctl 会报
|
||||
; "unix:///run/supervisord.sock no such file"(2026-10-14 冒烟实测)。
|
||||
[unix_http_server]
|
||||
file=/run/supervisord.sock
|
||||
chmod=0700
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///run/supervisord.sock
|
||||
|
||||
; ── 存储层:直接复用官方 entrypoint ──
|
||||
; init.sql 由 compose 挂在 /docker-entrypoint-initdb.d/,「空数据目录才初始化」
|
||||
; 的语义由官方脚本承载,本项目不重写(首次部署路径踩过坑,见 Dockerfile.all 注释)。
|
||||
; entrypoint 内部 exec gosu 降权到 postgres 用户,PID 链不变,
|
||||
; supervisord 停进程时信号直达 postmaster(smart shutdown)。
|
||||
[program:postgres]
|
||||
command=/usr/local/bin/docker-entrypoint.sh postgres
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=3
|
||||
startretries=5
|
||||
priority=10
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopwaitsecs=20
|
||||
|
||||
; ── 实时层:无持久化(appendonly no / save ""),卷仅为结构占位 ──
|
||||
; 只绑容器内回环:不暴露给同桥接网络的其他容器(该机跑着 searxng 等
|
||||
; 别的项目的容器,都在默认桥接网络上);宿主机侧不发布 6379,
|
||||
; 需要时 docker exec 进容器用 redis-cli。参数与四容器路径逐项一致。
|
||||
[program:redis]
|
||||
command=/usr/bin/redis-server --bind 127.0.0.1 --port 6379 --appendonly no --maxmemory 256mb --maxmemory-policy allkeys-lru --save "" --dir /data
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=3
|
||||
startretries=5
|
||||
priority=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopwaitsecs=10
|
||||
|
||||
; ── 网关:Socket.io 连接面(端口来自镜像 ENV PORT=3000)──
|
||||
; start-gateway.sh 等 pg/redis 就绪后 exec node,故 SIGTERM 直达
|
||||
; node 原生处理器(3s drain 刷净未落库指标的那套)。
|
||||
[program:gateway]
|
||||
command=/usr/local/bin/start-gateway.sh
|
||||
directory=/app/server/gateway
|
||||
user=node
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=5
|
||||
startretries=5
|
||||
priority=30
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopwaitsecs=12
|
||||
|
||||
; ── 业务服务:REST 控制面(端口来自镜像 ENV BUSINESS_PORT=4000)──
|
||||
; business 读 BUSINESS_PORT 优先于 PORT —— 单容器里两个进程共用一个环境,
|
||||
; 少了这个键 business 会去抢 gateway 的 3000。
|
||||
[program:business]
|
||||
command=/usr/local/bin/start-business.sh
|
||||
directory=/app/server/business
|
||||
user=node
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=5
|
||||
startretries=5
|
||||
priority=30
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopwaitsecs=12
|
||||
@@ -242,12 +242,104 @@ $ docker exec pmon-postgres-1 psql -U pmon -d pmon -c "\dt"
|
||||
3. **云防火墙全端口放通**:真正的边界靠"只绑回环 + 只由 OpenResty 暴露 80/443"。
|
||||
若日后有人把 gateway/business 改回 `0.0.0.0`,等于开门 —— 已在
|
||||
`docker-compose.prod.yml` 注释里写明理由。
|
||||
4. **同机邻居**:`market-sentiment`(8088) / `searxng-core`(8080) / `meilisearch`(7700) /
|
||||
1Panel。部署与构建时注意别在它们抓取高峰做重活。
|
||||
4. **同机邻居**(2026-10-14 复核):`searxng-core`(8080) / `searxng-valkey` / `meilisearch`(7700) / 1Panel
|
||||
(`market-sentiment` 看板已停用,不在跑)。部署与构建时注意别在它们抓取高峰做重活。
|
||||
5. **`pmon-ip.conf` 不在 1Panel 管理内**:1Panel 的站点操作不会覆盖它,但也**不会帮你备份**。
|
||||
内容已同时留在仓库(`docs/deploy-tencent-assessment.md` 与本文件)。
|
||||
6. 该实例 **2027-04-22 到期且需手动续费**。
|
||||
|
||||
---
|
||||
|
||||
## 七、单容器部署(all-in-one,2026-10-14 新增,可选)
|
||||
|
||||
> 动机:一个容器跑全部四个进程(postgres / redis / gateway / business),
|
||||
> 常驻容器从四个变成一个(外加一个跑完即退的 migrate)。
|
||||
> **应用代码零改动** —— 合容器不合进程:四个进程仍是独立进程,由 supervisord
|
||||
> 各自守护、各自重启,因此全部安全测试原样有效(本地已用项目测试套件
|
||||
> 对单容器实跑过:配对安全 84 / 账号 65 / 安全开关 36 / 端到端 35 /
|
||||
> 设备归并 31 / 竞态残留 3,共 254 项全绿)。
|
||||
|
||||
### 7.1 与四容器路径的关系(关键:数据卷共用,可随时互切)
|
||||
|
||||
| | 四容器路径 | 单容器路径 |
|
||||
|---|---|---|
|
||||
| 编排文件 | `docker-compose.yml` + `docker-compose.prod.yml` | `docker-compose.all.yml` |
|
||||
| 镜像定义 | `Dockerfile.gateway` + `Dockerfile.business` + 官方 pg/redis 镜像 | `Dockerfile.all`(一个) |
|
||||
| 常驻容器 | gateway / business / postgres / redis 四个 | 一个 `pmon-app-1` |
|
||||
| 一次性容器 | migrate(跑完即退) | migrate(跑完即退,同一设计) |
|
||||
| 数据卷 | `pmon_pg_data` / `pmon_redis_data` | **同名同卷**(project name 都是 `pmon`) |
|
||||
| 接入层 | 1Panel OpenResty 反代不变 | 不变(`/socket.io/` → 3000、`/api/` → 4000) |
|
||||
|
||||
**两条路径共用同一个 compose project(`name: pmon`)→ 数据卷名不变 →
|
||||
可随时互切,数据不动。** 切换就是换 `-f` 参数(同一时刻只能跑一条路径,
|
||||
端口 3000/4000 绑定冲突;`down` 不删卷):
|
||||
|
||||
```bash
|
||||
cd ~/pmon/deploy
|
||||
# 四容器 → 单容器
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml down
|
||||
docker compose -f docker-compose.all.yml up -d --build
|
||||
|
||||
# 单容器 → 四容器(回退)
|
||||
docker compose -f docker-compose.all.yml down
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 7.2 构建与部署
|
||||
|
||||
`Dockerfile.all` 以 `timescale/timescaledb:2.17.2-pg16` 为基底(**Alpine,
|
||||
apk 装 redis/supervisor**,COPY node:22-alpine 的 node 二进制),
|
||||
四个进程由 `deploy/supervisord.conf` 守护:
|
||||
|
||||
```bash
|
||||
# 服务端(必须脱离 ssh 会话,否则前台构建会被本地 SIGKILL(137))
|
||||
cd ~/pmon/deploy && setsid nohup docker compose -f docker-compose.all.yml \
|
||||
up -d --build > /tmp/pmon-all-up.log 2>&1 &
|
||||
|
||||
docker compose -f docker-compose.all.yml ps # app healthy + migrate Exited(0)
|
||||
docker exec pmon-app-1 supervisorctl status # 四进程 RUNNING(可单进程重启)
|
||||
curl -s localhost:3000/health | head -c 300 # 网关(persistence 应为 postgresql)
|
||||
curl -s localhost:4000/health | head -c 300 # 业务
|
||||
docker logs pmon-app-1 # 四进程日志一把看全
|
||||
```
|
||||
|
||||
日常运维:
|
||||
|
||||
```bash
|
||||
docker exec pmon-app-1 supervisorctl restart business # 只重启业务进程,不动其他
|
||||
docker exec pmon-app-1 psql -U pmon -d pmon # 直连库(PG 发布了回环 5432)
|
||||
docker exec pmon-app-1 redis-cli # redis 未发布到宿主机,进容器用
|
||||
```
|
||||
|
||||
### 7.3 与四容器路径的差异(如实清单)
|
||||
|
||||
| 项 | 差异 | 说明 |
|
||||
|---|---|---|
|
||||
| 健康检查 | 四合一 | 一个 healthcheck 同时判 pg/redis/gateway/business(`deploy/healthcheck-all.sh`);任一不掉谎。实测:进程降级运行时检查拒绝转绿 |
|
||||
| 启动门 | 自己写 | 单容器没有 compose 依赖图,「等 pg/redis 就绪再起 node」由 `start-gateway.sh` / `start-business.sh` 承担(复刻 `depends_on: service_healthy`)。否则全新卷首启时 node 抢在 PG 初始化前,重试耗尽后 FATAL 停摆 |
|
||||
| redis 宿主机映射 | **取消** | redis 只绑容器内回环,不暴露给同桥接网络的其他容器;需要时 `docker exec` 进容器 |
|
||||
| PG 宿主机映射 | 保留(回环) | `127.0.0.1:5432`,本机 psql 排障照旧 |
|
||||
| 端口绑定 | 回环 | 与四容器路径同一纪律(云防火墙全端口放通);由 `tests/dockerfile-coverage-test.js` 第 6 段断言看守 |
|
||||
| 安全开关 | 一处传全 | 两个进程同读一份环境,不存在「传了一个漏一个」;覆盖性仍由测试第 5 段按代码派生清单断言 |
|
||||
| 升级成本 | 重建整镜像 | 含数据库引擎;Alpine 基底镜像约 500MB 级,构建约半分钟 |
|
||||
| 重启影响 | 四进程齐重启 | 容器重启才齐重启;单个进程崩只拉那个(supervisord 独立守护) |
|
||||
| redis 版本 | 7.2(Alpine 源) | 四容器路径锁 7.4-alpine;用法兼容(GET/SET/EX/PUBSUB/allkeys-lru) |
|
||||
| 进程隔离 | 弱于四容器 | redis/两个 node 进程共享容器边界;postgres 仍由官方 entrypoint 降权,两个 node 进程跑 `node` 用户 |
|
||||
| business 启动参数 | 单容器多给堆上限 | 四容器 business 是裸 `node index.js`(Dockerfile.business CMD,与 gateway 的 `--expose-gc --max-old-space-size=256` **不对称**);单容器 start-business.sh 传全套(2026-10-16 审计记录)。方向是增强:四进程共宿给每个 node 256MB 上限属资源纪律,且业务服务是 REST 控制面、负载远低于网关。代价是 business 堆超 256MB 会 FATAL —— 四容器路径下不会(用默认上限)。**两条路径行为差异仅此一处,记在这里备查** |
|
||||
|
||||
### 7.4 护栏(都在 tests/ 里,删掉即红,已做变异测试)
|
||||
|
||||
- `tests/dockerfile-coverage-test.js` 第 1/2 段:`Dockerfile.all` 的 require 图
|
||||
覆盖性(两个入口分别断言,漏拷即红 —— 复刻「镜像构建成功但容器启动
|
||||
Cannot find module」那类事故的防线);
|
||||
- 第 5 段:安全开关通路(代码派生清单 → .env.example → 单容器 compose 的 app 服务);
|
||||
- 第 6 段:单容器 compose 端口必须全部绑 127.0.0.1。
|
||||
|
||||
### 7.5 扩容口径
|
||||
|
||||
单容器是**单机运维形态**。将来若要水平扩容(多 gateway 实例 + Redis Adapter),
|
||||
回到四容器路径按方案 V4.0 第 11 章扩 —— 数据卷直接复用,无需迁移。
|
||||
|
||||
---
|
||||
|
||||
> 本文为技术运维说明,不构成投资建议或商业承诺。
|
||||
@@ -175,6 +175,10 @@ function collectRequires(entry) {
|
||||
const IMAGES = [
|
||||
{ name: 'gateway', dockerfile: 'deploy/Dockerfile.gateway', entry: 'server/gateway/index.js' },
|
||||
{ name: 'business', dockerfile: 'deploy/Dockerfile.business', entry: 'server/business/index.js' },
|
||||
// 单容器镜像(all-in-one,2026-10-14):同一镜像承载两个入口 —— gateway 与
|
||||
// business 仍是两个独立进程(supervisord 守护),应用代码零改动,
|
||||
// 故 require 图覆盖性必须对两个入口分别成立。
|
||||
{ name: 'all', dockerfile: 'deploy/Dockerfile.all', entries: ['server/gateway/index.js', 'server/business/index.js'] },
|
||||
];
|
||||
|
||||
for (const img of IMAGES) {
|
||||
@@ -192,28 +196,32 @@ for (const img of IMAGES) {
|
||||
const copied = new Set();
|
||||
for (const g of srcs) for (const f of expandGlob(g)) copied.add(f);
|
||||
|
||||
// 入口必须被覆盖
|
||||
ok(`入口模块被 COPY 覆盖: ${img.entry}`, copied.has(img.entry),
|
||||
`已覆盖 ${copied.size} 个文件`);
|
||||
|
||||
// require 图闭包必须全部被覆盖
|
||||
const { files, missing } = collectRequires(img.entry);
|
||||
ok('require 图无未解析的相对路径', missing.length === 0, missing.join(', '));
|
||||
|
||||
const notCopied = files.filter((f) => !copied.has(f));
|
||||
ok(
|
||||
`require 图全部 ${files.length} 个文件均被 COPY 覆盖`,
|
||||
notCopied.length === 0,
|
||||
notCopied.length ? `漏拷(容器启动即 Cannot find module): ${notCopied.join(', ')}` : ''
|
||||
);
|
||||
|
||||
// 反向:每条 COPY 规则必须匹配到真实文件,否则 docker build 直接失败
|
||||
// 反向:每条 COPY 规则必须匹配到真实文件,否则 docker build 直接失败(事故 B)
|
||||
const emptyGlobs = srcs.filter((g) => expandGlob(g).length === 0);
|
||||
ok(
|
||||
'每条 COPY 规则都能匹配到真实文件',
|
||||
emptyGlobs.length === 0,
|
||||
emptyGlobs.length ? `指向不存在的文件(构建硬失败): ${emptyGlobs.join(', ')}` : ''
|
||||
);
|
||||
|
||||
// 入口 + require 图闭包:all 镜像有两个入口(两个独立进程),逐入口断言
|
||||
const entries = img.entries || [img.entry];
|
||||
for (const entry of entries) {
|
||||
// 入口必须被覆盖
|
||||
ok(`入口模块被 COPY 覆盖: ${entry}`, copied.has(entry),
|
||||
`已覆盖 ${copied.size} 个文件`);
|
||||
|
||||
// require 图闭包必须全部被覆盖
|
||||
const { files, missing } = collectRequires(entry);
|
||||
ok('require 图无未解析的相对路径', missing.length === 0, missing.join(', '));
|
||||
|
||||
const notCopied = files.filter((f) => !copied.has(f));
|
||||
ok(
|
||||
`require 图全部 ${files.length} 个文件均被 COPY 覆盖`,
|
||||
notCopied.length === 0,
|
||||
notCopied.length ? `漏拷(容器启动即 Cannot find module): ${notCopied.join(', ')}` : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
@@ -233,10 +241,12 @@ console.log('\n=== .dockerignore 与必需文件 ===');
|
||||
.filter((l) => !l.startsWith('!'))
|
||||
.map((l) => globToRegex(l.replace(/^\//, '').replace(/\/$/, '')));
|
||||
|
||||
// 两张镜像实际需要的全部文件
|
||||
// 全部镜像(含单容器镜像的两个入口)实际需要的文件
|
||||
const needed = new Set();
|
||||
for (const img of IMAGES) {
|
||||
for (const f of collectRequires(img.entry).files) needed.add(f);
|
||||
for (const entry of (img.entries || [img.entry])) {
|
||||
for (const f of collectRequires(entry).files) needed.add(f);
|
||||
}
|
||||
}
|
||||
|
||||
const excluded = [...needed].filter((f) => patterns.some((re) => re.test(f) || re.test(f.split('/').pop())));
|
||||
@@ -360,18 +370,7 @@ console.log('\n=== 安全开关通路(代码 → .env.example → compose)==
|
||||
return out;
|
||||
}
|
||||
|
||||
const composeText = fs.readFileSync(path.join(ROOT, 'deploy/docker-compose.yml'), 'utf8');
|
||||
const envBySvc = composeEnvByService(composeText);
|
||||
const svcNames = Object.keys(envBySvc);
|
||||
|
||||
// 解析自检:解析失手会静默放行,故必须先证明解析是对的
|
||||
ok('compose 解析自检:认出了全部服务',
|
||||
['gateway', 'business', 'redis', 'postgres', 'nginx'].every((s) => svcNames.includes(s)),
|
||||
`实得 ${svcNames.join(', ')}`);
|
||||
ok('compose 解析自检:gateway 的 PORT 被认出',
|
||||
envBySvc.gateway && envBySvc.gateway.has('PORT'));
|
||||
|
||||
// ① 扫代码里的安全开关读取点(排除 node_modules)
|
||||
// ① 扫代码里的安全开关读取点(排除 node_modules)—— 与 compose 无关,只做一次
|
||||
const reads = []; // {key, svc}
|
||||
const SWITCH_RE = /process\.env\.((?:REQUIRE_|ENFORCE_|ALLOW_)[A-Z0-9_]*)/g;
|
||||
const collect = (dir, owner) => {
|
||||
@@ -414,21 +413,94 @@ console.log('\n=== 安全开关通路(代码 → .env.example → compose)==
|
||||
undocumented.length === 0,
|
||||
undocumented.length ? `未登记: ${undocumented.join(', ')}` : '');
|
||||
|
||||
// ③ 逐 (开关, 服务) 断言 compose 真的传了
|
||||
const gaps = [];
|
||||
const seen = new Set();
|
||||
for (const { key, svc } of reads) {
|
||||
const tag = `${svc}←${key}`;
|
||||
if (seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
if (!envBySvc[svc] || !envBySvc[svc].has(key)) gaps.push(tag);
|
||||
}
|
||||
ok('compose 把每个安全开关都传给了读它的服务',
|
||||
gaps.length === 0,
|
||||
gaps.length ? `漏传: ${gaps.join(', ')}` : `${seen.size} 条通路全部打通`);
|
||||
// ② 逐 compose 文件断言:每个开关都传给了「读它的服务」
|
||||
// 四容器路径:gateway/business 各是一个服务;
|
||||
// 单容器路径(2026-10-14 起):两个进程同在 app 服务里,映射到同一服务名。
|
||||
// 这条映射正是 2026-09-15 那起「漏传 REQUIRE_DEVICE_TOKEN 给网关」事故的
|
||||
// 守门段:当时 compose 传了一个服务、漏了另一个,.env 里写 1 完全无效。
|
||||
const COMPOSE_FILES = [
|
||||
{
|
||||
file: 'deploy/docker-compose.yml', label: '四容器编排',
|
||||
map: { gateway: 'gateway', business: 'business' },
|
||||
selfCheck: ['gateway', 'business', 'redis', 'postgres', 'nginx'],
|
||||
// 四容器路径里网关端口由 compose 注入
|
||||
portSelfCheck: (envBySvc) => ok('compose 解析自检:gateway 的 PORT 被认出',
|
||||
!!(envBySvc.gateway && envBySvc.gateway.has('PORT'))),
|
||||
},
|
||||
{
|
||||
file: 'deploy/docker-compose.all.yml', label: '单容器编排',
|
||||
map: { gateway: 'app', business: 'app' },
|
||||
selfCheck: ['app', 'migrate'],
|
||||
// 单容器路径里端口烤进镜像(ENV PORT/BUSINESS_PORT),compose 侧改验 DATABASE_URL
|
||||
portSelfCheck: (envBySvc) => ok('compose 解析自检:app 的 DATABASE_URL 被认出',
|
||||
!!(envBySvc.app && envBySvc.app.has('DATABASE_URL'))),
|
||||
},
|
||||
];
|
||||
|
||||
// 反向自检:断言集合不能是空的(否则本段形同虚设)
|
||||
ok('反向自检:至少有一条 (服务←开关) 通路被检查', seen.size >= 3, `实得 ${seen.size}`);
|
||||
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 svcNames = Object.keys(envBySvc);
|
||||
|
||||
// 解析自检:解析失手会静默放行,故必须先证明解析是对的
|
||||
ok('compose 解析自检:认出了全部服务',
|
||||
cf.selfCheck.every((s) => svcNames.includes(s)),
|
||||
`实得 ${svcNames.join(', ')}`);
|
||||
cf.portSelfCheck(envBySvc);
|
||||
|
||||
const gaps = [];
|
||||
const seen = new Set();
|
||||
for (const { key, svc } of reads) {
|
||||
const target = cf.map[svc];
|
||||
const tag = `${target}←${key}`;
|
||||
if (seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
if (!envBySvc[target] || !envBySvc[target].has(key)) gaps.push(tag);
|
||||
}
|
||||
ok(`${cf.label}:compose 把每个安全开关都传给了读它的服务`,
|
||||
gaps.length === 0,
|
||||
gaps.length ? `漏传: ${gaps.join(', ')}` : `${seen.size} 条通路全部打通`);
|
||||
|
||||
// 反向自检:断言集合不能是空的(否则本段形同虚设)
|
||||
ok('反向自检:至少有一条 (服务←开关) 通路被检查', seen.size >= 3, `实得 ${seen.size}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* 6. 单容器编排的端口必须全部绑回环
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 该机云防火墙是「全部 TCP 1-65535 放通」,绑 0.0.0.0 等于把
|
||||
* gateway/business/PG 直接暴露公网(业务服务带 TRUST_PROXY_HOPS=1,
|
||||
* 直连可伪造 X-Forwarded-For 绕过配对码 IP 限流)。
|
||||
*
|
||||
* 四容器路径用 prod 覆盖文件的 !override 绑回环(踩过 Compose 的 ports
|
||||
* 拼接语义坑);单容器路径把回环绑在唯一文件里,故直接断言:
|
||||
* 任何一条非 127.0.0.1 的端口映射都必须让本段变红。
|
||||
*/
|
||||
|
||||
console.log('\n=== 单容器编排端口绑定(云防火墙全端口放通 → 必须回环)===');
|
||||
{
|
||||
const p = path.join(ROOT, 'deploy/docker-compose.all.yml');
|
||||
ok('deploy/docker-compose.all.yml 存在', fs.existsSync(p));
|
||||
|
||||
if (fs.existsSync(p)) {
|
||||
const text = fs.readFileSync(p, 'utf8');
|
||||
// 只收 "IP:port:port" 形态的引号行(volumes 的路径挂载不会误入)
|
||||
// 收两种形态:带 IP 前缀的 "127.0.0.1:3000:3000" 与**裸** "3000:3000"。
|
||||
// ⚠️ 裸映射在 Docker 语义里就是绑 0.0.0.0(全部网卡)—— 正是本段要防的
|
||||
// 最危险写法。第一版正则只认带 IP 前缀的,裸映射会被静默漏检
|
||||
// (2026-10-16 审计补):把 IP 前缀设为可选,漏网之鱼全部进 notLoopback。
|
||||
const ports = [...text.matchAll(/^\s*-\s*"([^"]*)"/gm)].map((m) => m[1])
|
||||
.filter((s) => /^(\d{1,3}(\.\d{1,3}){3}:)?\d+:\d+$/.test(s));
|
||||
ok('解析到端口映射(≥3 条)', ports.length >= 3, `实得 ${ports.length}`);
|
||||
|
||||
const notLoopback = ports.filter((s) => !s.startsWith('127.0.0.1:'));
|
||||
ok('端口全部绑 127.0.0.1(不暴露公网)',
|
||||
notLoopback.length === 0,
|
||||
notLoopback.length ? `暴露公网的映射: ${notLoopback.join(', ')}` : `${ports.length} 条全部回环`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user