Commit bda06ca4 by luoqi

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

parents 0cf8409e 5950b2fa
Pipeline #3534 failed in 0 seconds
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');
});
});
...@@ -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 的重建判定)"
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[@]}" "${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