Commit 59573e86 by luoqi

merge: fix/compose-logging-driver → test(podman 日志驱动 + rootless 部署回退 + 停机写实)

parents 59e9f53b 5950b2fa
Pipeline #3535 failed in 0 seconds
/**
* 定时任务写侧总闸 —— `PAC_SCHEDULER_DISABLED=1` 时本实例不跑任何**会改数据**的定时任务。
*
* 【为什么需要】服务器迁移 / 蓝绿发布 / 备用实例这类「新旧实例并存」的窗口里,新实例要能
* 起来接流量、验证配置,但绝不能同时对 DW 做增量摄入 + persona/plan 重算:
* - 各连各库:两边各拉各的,待迁移的数据快照持续偏移,dump 出来就是旧的
* - 同连一库:互抢 sync_logs 的 partial UNIQUE(host_id) WHERE status='running' 锁,
* 还会把对方**正在跑**的锁当"僵尸"回收掉(回收判据只看 startedAt 早于本进程启动)
*
* 【为什么必须是代码开关,不能用 env 现有机制】
* - jvs-dw 的 manifest 写死 `auto_sync: true`,scheduler 启动即自动发现并注册
* - cron 表达式也写在 manifest(`incremental_cron`),优先级高于全局 env
* - `PAC_INCREMENTAL_HOSTS=` 留空只会 fallback 到自动发现,关不掉
* - 改 manifest 能关,但 `deploy-prod.sh` 会 `git pull`,改动被覆盖
*
* 【边界:只关"写",不关"读"】本闸刻意**不覆盖** dw-lag-monitor / daily-health-report ——
* 那两个只发告警和报表、不碰业务数据,备用实例照常跑反而能多一双眼睛。真要静音它们,
* 各自的 `PAC_*_CRON` 不设即为「永不执行」(代码默认 `0 0 31 12 *`)。
*
* 【别忘了关掉】迁移完成、旧实例下线后,**务必移除该 env 并重启** —— 否则新生产静默不摄入,
* 表现只有"数据越来越旧",没有任何报错。DW 滞后监控能兜住(它不受本闸影响),但那是 24h 后的事。
*/
export function schedulerDisabled(): boolean {
return process.env.PAC_SCHEDULER_DISABLED === '1';
}
...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QueueProducer } from './queue-producer.service'; import { QueueProducer } from './queue-producer.service';
import { schedulerDisabled } from './scheduler-switch';
/** /**
* 凌晨兜底扫描 — 每日 02:00 跑。 * 凌晨兜底扫描 — 每日 02:00 跑。
...@@ -50,6 +51,12 @@ export class StaleScanService { ...@@ -50,6 +51,12 @@ export class StaleScanService {
timeZone: 'Asia/Shanghai', timeZone: 'Asia/Shanghai',
}) })
async scanAndEnqueueStale(): Promise<void> { async scanAndEnqueueStale(): Promise<void> {
// 写侧总闸(见 scheduler-switch):迁移/备用实例不重算 persona —— 它会 enqueue 重算任务、
// 改业务数据,与旧实例并跑会让待迁移的快照持续偏移。
if (schedulerDisabled()) {
this.logger.warn('stale-scan: ⏸ PAC_SCHEDULER_DISABLED=1,跳过本轮(迁移/备用实例)');
return;
}
const startedAt = Date.now(); const startedAt = Date.now();
this.logger.log('stale-scan: START'); this.logger.log('stale-scan: START');
......
...@@ -18,6 +18,7 @@ const PROCESS_STARTED_AT = new Date(); ...@@ -18,6 +18,7 @@ const PROCESS_STARTED_AT = new Date();
import { OrgTreeService } from '../modules/auth/org-tree'; import { OrgTreeService } from '../modules/auth/org-tree';
import { PersonaService } from '../modules/persona/persona.service'; import { PersonaService } from '../modules/persona/persona.service';
import { PlanEngineService } from '../modules/plan/engine/plan-engine.service'; import { PlanEngineService } from '../modules/plan/engine/plan-engine.service';
import { schedulerDisabled } from './scheduler-switch';
/** /**
* SyncIncrementalSchedulerService — DW 直连增量自动跑(**每宿主独立 cron**) * SyncIncrementalSchedulerService — DW 直连增量自动跑(**每宿主独立 cron**)
...@@ -50,6 +51,25 @@ export class SyncIncrementalSchedulerService implements OnModuleInit { ...@@ -50,6 +51,25 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
// ⭐ 写侧总闸:PAC_SCHEDULER_DISABLED=1 → 本实例不跑任何**会改数据**的定时任务。
// 用于「新旧实例并存」的窗口(服务器迁移 / 蓝绿 / 备用实例):新实例要能起来接流量、
// 验证配置,但**绝不能**同时对 DW 做增量摄入 + persona/plan 重算 —— 两边各拉各的,
// 会让待迁移的数据快照持续偏移;若两边连同一个库,还会互抢 sync_logs 的
// partial UNIQUE(host_id) WHERE status='running' 锁、互相回收对方的"僵尸锁"。
//
// ⚠️ 为什么必须是代码开关:jvs-dw 的 manifest 写死 auto_sync=true,cron 也写在
// manifest(优先级高于 env),`PAC_INCREMENTAL_HOSTS=` 留空只会 fallback 到自动发现 ——
// **靠 env 关不掉**。改 manifest 又会被 deploy 的 git pull 覆盖。
//
// ⚠️ 连僵尸锁回收也一并跳过:回收判据是"startedAt 早于本进程启动",共库场景下
// 新实例会把老实例**正在跑**的锁误当僵尸回收掉。禁用态就该完全不碰 sync_logs。
if (schedulerDisabled()) {
this.logger.warn(
'sync-incremental: ⏸ PAC_SCHEDULER_DISABLED=1 —— 不注册 cron、不回收僵尸锁(迁移/备用实例)',
);
return;
}
// 启动即回收上一个进程留下的僵尸锁(部署/崩溃重启时,running 行来不及标终态, // 启动即回收上一个进程留下的僵尸锁(部署/崩溃重启时,running 行来不及标终态,
// 会永久占住 sync_logs 的 partial UNIQUE(host_id) WHERE status='running', // 会永久占住 sync_logs 的 partial UNIQUE(host_id) WHERE status='running',
// 导致之后每次 cron 增量都被并发锁 skip → 游标不动 → DW 滞后告警)。 // 导致之后每次 cron 增量都被并发锁 skip → 游标不动 → DW 滞后告警)。
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
/**
* compose 日志驱动契约。
*
* 【为什么显式声明 json-file】docker 默认就是 json-file(写不写行为一样),但
* **podman rootless 默认是 journald**。2026-08-04 新服务器实测:
* - `docker logs <c>` 只回一行兼容层提示
* - `podman logs <c>` 返回 0 行
* - 日志进了用户 journal,而普通用户不在 systemd-journal 组 → 读不到
* 等于线上出问题看不到日志。显式声明让两种运行时行为一致。
*
* 【顺带治既有隐患】此前无任何 rotate —— 当前生产 /var/lib/docker/containers 已 1.4 GB
* 且只增不减。2026-08-01 测试服正是被写满后 Postgres 崩溃重启(pg_wal 写不下)。
*/
const compose = yaml.load(
readFileSync(join(__dirname, '../../../docker-compose.prod.yml'), 'utf-8'),
) as { services: Record<string, { logging?: { driver?: string; options?: Record<string, string> }; profiles?: string[] }> };
describe('docker-compose.prod.yml 日志驱动', () => {
const names = Object.keys(compose.services);
test('⭐ 每个服务都声明了 logging —— 漏一个,那个容器在 podman 上就看不到日志', () => {
const missing = names.filter((n) => !compose.services[n]!.logging);
expect(missing).toEqual([]);
});
test('驱动是 json-file(不是 podman 默认的 journald)', () => {
for (const n of names) {
expect(compose.services[n]!.logging!.driver).toBe('json-file');
}
});
test('⭐ 带 rotate 上限 —— 否则日志只增不减,最终吃满磁盘', () => {
for (const n of names) {
const opts = compose.services[n]!.logging!.options ?? {};
expect(opts['max-size']).toBeTruthy();
expect(opts['max-file']).toBeTruthy();
}
});
test('pac-asr 仍是 profile 可选服务(默认不启,迁移时无需额外处理)', () => {
expect(compose.services['pac-asr']!.profiles).toContain('asr');
});
});
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { schedulerDisabled } from '../src/queues/scheduler-switch';
/**
* 定时任务写侧总闸(PAC_SCHEDULER_DISABLED)。
*
* 【为什么必须是代码开关】迁移窗口里新旧实例并存,新实例绝不能同时跑增量摄入 + 重算。
* 但 jvs-dw 的 manifest 写死 `auto_sync: true`、cron 也写在 manifest(优先级高于全局 env),
* `PAC_INCREMENTAL_HOSTS=` 留空只 fallback 到自动发现 —— **靠 env 关不掉**;
* 改 manifest 又会被 deploy 的 `git pull` 覆盖。
*
* 【边界】只关"写",不关"读":dw-lag-monitor / daily-health-report 只发告警和报表,
* 备用实例照常跑反而多一双眼睛。这条边界如果被后来人"顺手统一"掉,迁移期就会静默失去监控 ——
* 故用测试钉住。
*/
const read = (f: string) => readFileSync(join(__dirname, '../src/queues', f), 'utf-8');
describe('PAC_SCHEDULER_DISABLED', () => {
const ORIG = process.env.PAC_SCHEDULER_DISABLED;
afterEach(() => {
if (ORIG === undefined) delete process.env.PAC_SCHEDULER_DISABLED;
else process.env.PAC_SCHEDULER_DISABLED = ORIG;
});
test('未设置 → false(默认跑,既有部署行为不变)', () => {
delete process.env.PAC_SCHEDULER_DISABLED;
expect(schedulerDisabled()).toBe(false);
});
test('=1 → true', () => {
process.env.PAC_SCHEDULER_DISABLED = '1';
expect(schedulerDisabled()).toBe(true);
});
test('⭐ 只认严格的 "1" —— "true"/"yes"/"0" 都不算,避免似是而非地半开', () => {
for (const v of ['true', 'yes', 'on', '0', '', ' 1 ']) {
process.env.PAC_SCHEDULER_DISABLED = v;
expect(schedulerDisabled()).toBe(false);
}
});
});
describe('闸门覆盖范围 — 只关写侧', () => {
test('⭐ 增量摄入(会改数据)受闸控,且连僵尸锁回收一并跳过', () => {
const s = read('sync-incremental.scheduler.ts');
expect(s).toContain('schedulerDisabled()');
// 闸判定必须在 reapStaleRunningLocks 之前 —— 共库场景下回收会误清老实例正在跑的锁
expect(s.indexOf('schedulerDisabled()')).toBeLessThan(s.indexOf('await this.reapStaleRunningLocks()'));
});
test('⭐ persona stale-scan(会 enqueue 重算)受闸控', () => {
expect(read('stale-scan.service.ts')).toContain('schedulerDisabled()');
});
test('⭐ 只读任务不受闸控 —— 迁移期仍要有监控和日报', () => {
expect(read('dw-lag-monitor.service.ts')).not.toContain('schedulerDisabled');
expect(read('daily-health-report.service.ts')).not.toContain('schedulerDisabled');
});
});
...@@ -21,7 +21,16 @@ ssh <server> && cd /opt/pac ...@@ -21,7 +21,16 @@ ssh <server> && cd /opt/pac
bash deploy/deploy-prod.sh bash deploy/deploy-prod.sh
``` ```
`git pull` → build → force-recreate → 验证(镜像 ID 一致 / 迁移无 pending / health 200 / web 200),任一不过即失败。只动代码,**不碰数据**。期间 service 秒级重启。 `git pull` → build → force-recreate → 验证(镜像 ID 一致 / 迁移无 pending / health 200 / web 200),任一不过即失败。只动代码,**不碰数据**
⚠️ **期间有约 16 秒停机**(2026-08-04 实测三次:15.97 / 16.86 / ~16s,0.5s 间隔探 `/health`)。
`force-recreate` **不是**滚动更新 —— 它就是停旧容器、起新容器,中间必然有真空期。
build 阶段不影响服务(老容器一直跑),停机只发生在重建那一刻,所以**部署总耗时长短与停机无关**
影响:那 16 秒内 API 报错、工作台加载失败,刷新即恢复;登录态不掉(JWT 在浏览器),无数据风险
(迁移由 pac-migrate 单独跑完才起 service,写操作要么已完成、要么失败可重试)。建议挑非高峰时段。
要真正零停机得引入网关层蓝绿切换(两套容器 + 上游切换),不在本脚本范围内。
> 改了 `NEXT_PUBLIC_*`(前端 build-time 变量)必须走本脚本重 build;只改后端 `.env` 用 `docker compose -f docker-compose.prod.yml restart pac-service` 即可。 > 改了 `NEXT_PUBLIC_*`(前端 build-time 变量)必须走本脚本重 build;只改后端 `.env` 用 `docker compose -f docker-compose.prod.yml restart pac-service` 即可。
......
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
set -euo pipefail set -euo pipefail
log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
warn() { printf '\033[1;33mWARN: %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; } die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; }
main() { main() {
...@@ -82,8 +83,33 @@ main() { ...@@ -82,8 +83,33 @@ main() {
log "build 镜像(显式,不和 up 混)" log "build 镜像(显式,不和 up 混)"
"${COMPOSE[@]}" build "${SERVICES[@]}" "${COMPOSE[@]}" build "${SERVICES[@]}"
# ⭐ 主路径仍是直接 force-recreate —— **它在 docker 上是零停机的**。
# 2026-08-04 三组实测(0.5s 间隔探 /health,部署全程采样):
# docker + 直接 force-recreate → 停机 0.00s(535 样本全 200)
# docker + 先 stop 再 recreate → 停机 15.97s ❌
# podman + 先 stop 再 recreate → 停机 5.44s
# compose 的 force-recreate 是逐服务滚动(新容器起来再切旧的),而"先全 stop"会造出
# 真空期 —— 把零停机做成了十几秒停机。所以**不能**为了兼容 rootless 就无条件加 stop。
#
# rootless podman(运维刻意选的形态:非 root 账号 + netavark/pasta,攻击面更小)下,
# force-recreate 要删**运行中**的容器,而 rootless 杀不掉它的网络进程:
# rootless netns: kill network process: permission denied
# → 仅在此时回退:清掉失败留下的 `<hash>_<proj>-<svc>-1` 半成品容器,stop 后重建。
# 代价是那次部署有几秒停机,但只发生在 rootless 环境,docker 侧行为完全不变。
# ⚠️ 永远不退回裸 `up -d`:那正是本脚本要绕开的 compose diff 缺陷(见文件头)。
log "force-recreate(不信 compose 的重建判定)" log "force-recreate(不信 compose 的重建判定)"
"${COMPOSE[@]}" up -d --force-recreate "${SERVICES[@]}" if ! "${COMPOSE[@]}" up -d --force-recreate "${SERVICES[@]}"; then
warn "force-recreate 失败(疑似 rootless 删不掉运行中容器的网络进程),回退:清残留 → stop → 重建"
local proj_r svc_r leftover
proj_r=$(basename "$PWD")
for svc_r in "${SERVICES[@]}"; do
# 失败会留下带 hash 前缀的半成品容器(如 b69b5bec_pac-pac-service-1),不清则重建报名字冲突
leftover=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E "_${proj_r}-${svc_r}-1$" || true)
[[ -n "$leftover" ]] && docker rm -f $leftover >/dev/null 2>&1 && log " 清理残留容器 $leftover"
done
"${COMPOSE[@]}" stop "${SERVICES[@]}" || true
"${COMPOSE[@]}" up -d --force-recreate "${SERVICES[@]}"
fi
# ── 部署后硬验证:任一不过 = 部署失败 ───────────────────────────── # ── 部署后硬验证:任一不过 = 部署失败 ─────────────────────────────
log "验证 1/3:容器跑的镜像 == 刚构建的镜像" log "验证 1/3:容器跑的镜像 == 刚构建的镜像"
......
...@@ -17,8 +17,24 @@ ...@@ -17,8 +17,24 @@
# #
# 内部网络:容器之间用服务名访问(postgres:5432 / redis:6379),容器内 port 用 image 默认 # 内部网络:容器之间用服务名访问(postgres:5432 / redis:6379),容器内 port 用 image 默认
# ─── 日志驱动(所有服务共用)────────────────────────────────────────────
# 【为什么显式声明 json-file】docker 的默认就是 json-file,写不写行为一样;但 **podman
# rootless 默认是 journald** —— 2026-08-04 新服务器实测:`docker logs` 只回一行兼容层提示,
# `podman logs` 返回 0 行,而容器日志进了用户 journal、普通用户不在 systemd-journal 组读不到,
# 等于**线上出问题看不到日志**。显式声明让两种运行时行为一致。
#
# 【顺带治一个既有隐患】此前没有任何 rotate —— 当前生产 /var/lib/docker/containers 已 1.4 GB,
# 且只会一直涨。50m × 5 = 单容器上限 250MB,够排查近期问题,又不会把盘吃满
# (2026-08-01 测试服就是被写满后 Postgres 崩溃重启的)。
x-logging: &default-logging
driver: json-file
options:
max-size: "50m"
max-file: "5"
services: services:
postgres: postgres:
logging: *default-logging
image: postgres:16-alpine image: postgres:16-alpine
restart: always restart: always
env_file: ./apps/pac-service/.env # 读 POSTGRES_USER/PASSWORD/DB env_file: ./apps/pac-service/.env # 读 POSTGRES_USER/PASSWORD/DB
...@@ -39,6 +55,7 @@ services: ...@@ -39,6 +55,7 @@ services:
retries: 5 retries: 5
redis: redis:
logging: *default-logging
image: redis:7-alpine image: redis:7-alpine
restart: always restart: always
command: ["redis-server", "--appendonly", "yes"] command: ["redis-server", "--appendonly", "yes"]
...@@ -51,6 +68,7 @@ services: ...@@ -51,6 +68,7 @@ services:
# Runs before pac-service so the schema is current before any traffic # Runs before pac-service so the schema is current before any traffic
# hits the API. Safe to re-run — `migrate deploy` is idempotent. # hits the API. Safe to re-run — `migrate deploy` is idempotent.
pac-migrate: pac-migrate:
logging: *default-logging
build: build:
context: . context: .
dockerfile: apps/pac-service/Dockerfile dockerfile: apps/pac-service/Dockerfile
...@@ -67,6 +85,7 @@ services: ...@@ -67,6 +85,7 @@ services:
command: ["npx", "prisma", "migrate", "deploy"] command: ["npx", "prisma", "migrate", "deploy"]
pac-service: pac-service:
logging: *default-logging
build: build:
context: . context: .
dockerfile: apps/pac-service/Dockerfile dockerfile: apps/pac-service/Dockerfile
...@@ -97,6 +116,7 @@ services: ...@@ -97,6 +116,7 @@ services:
# 启用 → docker compose --profile asr -f docker-compose.prod.yml up -d # 启用 → docker compose --profile asr -f docker-compose.prod.yml up -d
# 不启用时 pac-service 的 PAC_ASR_URL 指向的服务不在,transcribe 端点会失败(不影响其它功能/启动)。 # 不启用时 pac-service 的 PAC_ASR_URL 指向的服务不在,transcribe 端点会失败(不影响其它功能/启动)。
pac-asr: pac-asr:
logging: *default-logging
profiles: ["asr"] profiles: ["asr"]
build: build:
context: . context: .
...@@ -113,6 +133,7 @@ services: ...@@ -113,6 +133,7 @@ services:
mem_limit: 2g mem_limit: 2g
pac-web: pac-web:
logging: *default-logging
build: build:
context: . context: .
dockerfile: apps/pac-web/Dockerfile dockerfile: apps/pac-web/Dockerfile
...@@ -136,6 +157,7 @@ services: ...@@ -136,6 +157,7 @@ services:
- "127.0.0.1:3100:3100" - "127.0.0.1:3100:3100"
pac-docs: pac-docs:
logging: *default-logging
build: build:
context: . context: .
dockerfile: apps/pac-docs/Dockerfile dockerfile: apps/pac-docs/Dockerfile
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment