Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
cd84233a
Commit
cd84233a
authored
Aug 06, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge remote-tracking branch 'origin/test' into docs/plan-assignment-doctrine
parents
6e79b48d
ca6ccc4b
Pipeline
#3544
failed in 0 seconds
Changes
14
Pipelines
2
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
264 additions
and
10 deletions
+264
-10
apps/pac-docs/content/docs/deployment.mdx
+1
-1
apps/pac-docs/content/docs/integration/friday-push-payload.mdx
+1
-1
apps/pac-docs/content/docs/monitoring.mdx
+9
-1
apps/pac-docs/openapi/pac.json
+0
-0
apps/pac-service/src/app.module.ts
+3
-1
apps/pac-service/src/queues/bull-board.module.ts
+14
-2
apps/pac-service/src/queues/scheduler-switch.ts
+25
-0
apps/pac-service/src/queues/stale-scan.service.ts
+7
-0
apps/pac-service/src/queues/sync-incremental.scheduler.ts
+20
-0
apps/pac-service/tests/compose-logging.spec.ts
+58
-0
apps/pac-service/tests/scheduler-kill-switch.spec.ts
+60
-0
deploy/README.md
+10
-1
deploy/deploy-prod.sh
+27
-1
docker-compose.prod.yml
+29
-2
No files found.
apps/pac-docs/content/docs/deployment.mdx
View file @
cd84233a
...
@@ -155,7 +155,7 @@ cd apps/pac-web && pnpm start &
...
@@ -155,7 +155,7 @@ cd apps/pac-web && pnpm start &
curl -s http://localhost:3101/health # → {"status":"ok","timestamp":"..."}
curl -s http://localhost:3101/health # → {"status":"ok","timestamp":"..."}
```
```
`GET /health` 免鉴权、且**不走** `pac/v1` 全局前缀,适合做负载均衡探针 / 容器 healthcheck / 外部拨测,返回 `{ "status": "ok", "timestamp": "<ISO>" }`。(同样豁免前缀的还有 Swagger `/api/docs`、Bull Board `/admin/queues`。)
`GET /health` 免鉴权、且**不走** `pac/v1` 全局前缀,适合做负载均衡探针 / 容器 healthcheck / 外部拨测,返回 `{ "status": "ok", "timestamp": "<ISO>" }`。(同样豁免前缀的还有 Swagger `/api/docs`、Bull Board `/admin/queues`
—— 后者**默认不挂载**,需 `PAC_BULL_BOARD=1`,见[监控](/docs/monitoring)
。)
随后即可首次导入数据 —— 见 [数据摄入](/docs/ingestion)。
随后即可首次导入数据 —— 见 [数据摄入](/docs/ingestion)。
...
...
apps/pac-docs/content/docs/integration/friday-push-payload.mdx
View file @
cd84233a
...
@@ -342,7 +342,7 @@ GET /pac/v1/admin/host/self/push-logs?limit=50
...
@@ -342,7 +342,7 @@ GET /pac/v1/admin/host/self/push-logs?limit=50
| 交互式 API 文档(可试调) | `/api/docs` |
| 交互式 API 文档(可试调) | `/api/docs` |
| 本契约文档 | `/docs/integration/friday-push-payload` |
| 本契约文档 | `/docs/integration/friday-push-payload` |
| 宿主自助页(患者数 / 24h 交易 / 上次 push / 失败率) | `/admin/host` |
| 宿主自助页(患者数 / 24h 交易 / 上次 push / 失败率) | `/admin/host` |
| 队列面板(看 push 触发的画像重算) | `/admin/queues`
|
| 队列面板(看 push 触发的画像重算) | `/admin/queues`
(默认不挂载,需 `PAC_BULL_BOARD=1`)
|
> **常见错误码**:`10106` 缺 header / 签名不匹配(或时间戳偏差 >5min)· `10001` source 拼错
> **常见错误码**:`10106` 缺 header / 签名不匹配(或时间戳偏差 >5min)· `10001` source 拼错
> (响应会列出可选源表名)· `10002` body 校验失败 · `10003` 并发超限(退避重发即可,幂等安全)
> (响应会列出可选源表名)· `10002` body 校验失败 · `10003` 并发超限(退避重发即可,幂等安全)
...
...
apps/pac-docs/content/docs/monitoring.mdx
View file @
cd84233a
...
@@ -46,7 +46,15 @@ monitoring:
...
@@ -46,7 +46,15 @@ monitoring:
## 三、队列健康(Bull Board)
## 三、队列健康(Bull Board)
`/admin/queues`(需登录 admin)实时查看三条队列 —— `persona-recompute` / `plan-recompute` / `plan-asset-generate` 的处理速度、失败重试与死信。
`/admin/queues` 实时查看三条队列 —— `persona-recompute` / `plan-recompute` / `plan-asset-generate` 的处理速度、失败重试与死信。
<Callout type="warn">
**默认不挂载**,要用得启动时设 `PAC_BULL_BOARD=1`。
此前文档写的「需登录 admin」**不成立** —— `@bull-board/nestjs` 自己挂独立 Express handler,不经过 Nest 守卫,面板与其数据接口都是免鉴权的,且 `readOnlyMode:false`(能重试 / 清理任务)。2026-08-05 实测生产上经网关可从公网直接打开,故改为默认关闭。
关掉的只是**面板**;队列本身、定时任务、job 消费全部照常 —— 它只是个查看器。排障时临时开:`PAC_BULL_BOARD=1`,用完记得关。
</Callout>
---
---
...
...
apps/pac-docs/openapi/pac.json
View file @
cd84233a
This source diff could not be displayed because it is too large. You can
view the blob
instead.
apps/pac-service/src/app.module.ts
View file @
cd84233a
...
@@ -44,7 +44,9 @@ import { HealthController } from './health.controller';
...
@@ -44,7 +44,9 @@ import { HealthController } from './health.controller';
PrismaModule
,
PrismaModule
,
RedisModule
,
RedisModule
,
QueuesModule
,
QueuesModule
,
QueuesBullBoardModule
,
// Bull Board 队列面板 —— **默认不挂载**,设 PAC_BULL_BOARD=1 才开(见 bull-board.module.ts)。
// 2026-08-05:实测它在生产上是公网无鉴权可读可写的入口,而平时没人用 → 默认关掉最省事。
...(
process
.
env
.
PAC_BULL_BOARD
===
'1'
?
[
QueuesBullBoardModule
]
:
[]),
AlertingModule
,
AlertingModule
,
AuthModule
,
AuthModule
,
FactsModule
,
FactsModule
,
...
...
apps/pac-service/src/queues/bull-board.module.ts
View file @
cd84233a
...
@@ -8,8 +8,20 @@ import { QueueName } from './queue-names';
...
@@ -8,8 +8,20 @@ import { QueueName } from './queue-names';
/**
/**
* Bull Board UI — 挂到 /admin/queues 路径,可视化队列状态(active/waiting/failed)。
* Bull Board UI — 挂到 /admin/queues 路径,可视化队列状态(active/waiting/failed)。
*
*
* 安全:本路由由 NestJS 路由系统接管;需要走全局 JwtAuthGuard。
* ⚠️ **默认不挂载**;要用得显式设 `PAC_BULL_BOARD=1`(见 app.module.ts 的条件 import)。
* 后续可加 @RequirePermission(Permission.ADMIN_PLATFORM_*) 收口管理员可见。
*
* 【为什么默认关】原注释写的是「本路由由 NestJS 路由系统接管;需要走全局 JwtAuthGuard」——
* **这条不成立**。`@bull-board/nestjs` 用 ExpressAdapter 自己挂了一个独立的 Express handler,
* 压根不经过 Nest 的路由与守卫。2026-08-05 从公网实测旧生产(网关把 /admin/queues 转到了 3101):
* GET /admin/queues → 200,面板 HTML
* GET /admin/queues/api/queues → 200,队列数据,且 `readOnlyMode:false` `allowRetries:true`
* 即:未鉴权就能翻 job payload(含 patientId / hostId / tenantId)并重试、清理任务。
*
* 加守卫要在 Express 中间件层做,而这个面板平时没人用(前端没有任何入口链接,只在
* docs/monitoring 里作为排障手段提过),所以选了成本最低的解法:**默认不挂载**。
* 本地 / 测试排查队列时 `PAC_BULL_BOARD=1 pnpm dev` 即可。
*
* ⚠️ 注意:关掉的只是这个**面板**。队列本身、定时任务、job 消费全部照常 —— 它只是个查看器。
*/
*/
@
Module
({
@
Module
({
imports
:
[
imports
:
[
...
...
apps/pac-service/src/queues/scheduler-switch.ts
0 → 100644
View file @
cd84233a
/**
* 定时任务写侧总闸 —— `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'
;
}
apps/pac-service/src/queues/stale-scan.service.ts
View file @
cd84233a
...
@@ -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'
);
...
...
apps/pac-service/src/queues/sync-incremental.scheduler.ts
View file @
cd84233a
...
@@ -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 滞后告警)。
...
...
apps/pac-service/tests/compose-logging.spec.ts
0 → 100644
View file @
cd84233a
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'
);
});
});
describe
(
'docs OpenAPI server 地址'
,
()
=>
{
test
(
'⭐ 不硬编码域名 —— 这份 compose 测试服与生产共用,写死会让生产 docs 打到测试服'
,
()
=>
{
const
args
=
(
compose
.
services
[
'pac-docs'
]
as
{
build
?:
{
args
?:
Record
<
string
,
string
>
}
}).
build
?.
args
??
{};
const
v
=
args
[
'DOCS_API_URL'
]
??
''
;
expect
(
v
).
toMatch
(
/^
\$\{
/
);
// 必须是变量插值
expect
(
v
).
not
.
toMatch
(
/jarvismedical|friday
\.
tech/
);
// 不得出现任何具体域名
// 复用 NEXT_PUBLIC_API_BASE_URL:它就是同一个东西,各环境 .env 已有,不会再漂移
expect
(
v
).
toContain
(
'NEXT_PUBLIC_API_BASE_URL'
);
});
});
apps/pac-service/tests/scheduler-kill-switch.spec.ts
0 → 100644
View file @
cd84233a
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'
);
});
});
deploy/README.md
View file @
cd84233a
...
@@ -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` 即可。
...
...
deploy/deploy-prod.sh
View file @
cd84233a
...
@@ -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:容器跑的镜像 == 刚构建的镜像"
...
...
docker-compose.prod.yml
View file @
cd84233a
...
@@ -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,13 +157,19 @@ services:
...
@@ -136,13 +157,19 @@ 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
target
:
prod
target
:
prod
args
:
args
:
# OpenAPI 参考页 Scalar 的 server 地址(cURL/Send base);本地默认 localhost:3101,线上注入生产域名
# OpenAPI 参考页 Scalar 的 server 地址(cURL/Send base)。
DOCS_API_URL
:
https://pac.jarvismedical.asia
# ⚠️ 曾硬编码成 https://pac.jarvismedical.asia(测试服域名)—— 而这份 compose 是
# **测试服与生产共用**的,于是生产 docs 页上点"发送请求"会打到测试服去。
# 改为复用 NEXT_PUBLIC_API_BASE_URL:它就是同一个东西(API 的对外地址),
# 各环境 apps/pac-web/.env 里本来就有,零额外配置、且不会再随环境漂移。
# 要单独指定时才设 DOCS_API_URL 覆盖;都没有则退回本地。
DOCS_API_URL
:
${DOCS_API_URL:-${NEXT_PUBLIC_API_BASE_URL:-http://localhost:3101}}
restart
:
always
restart
:
always
environment
:
environment
:
NODE_ENV
:
production
NODE_ENV
:
production
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment