Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ ADMIN_TOKEN=change-me
# 设置为 false 可禁用自动迁移
AUTO_MIGRATE=true

# 网关多核心模式(生产启动入口 cluster.js)
# - auto(默认):仅在有效 vCPU >= 4 且内存/共享预算足够时启用。4 vCPU 默认 2 个
# 完整网关进程,8+ vCPU 默认最多 4 个;请求正文始终只由其中一个进程持有,不经 IPC 复制。
# - on:显式要求至少 2 个进程;off:保持单进程。
# - CCH_MULTICORE_WORKERS 可显式指定 1~32;1 等价于单进程。显式多进程仍会校验内存和预算。
# - 多进程依赖 Redis Pub/Sub 同步安全、路由、系统设置与 Provider group 计费倍率失效;未配置 Redis 或关闭
# ENABLE_RATE_LIMIT 时 auto 回退单进程,显式多进程配置会启动失败。
CCH_MULTICORE_MODE=auto
# CCH_MULTICORE_WORKERS=4
CCH_MULTICORE_MEMORY_PER_WORKER_MB=1024
CCH_MULTICORE_PRIMARY_MEMORY_RESERVE_MB=256
# CCH_MULTICORE_READY_TIMEOUT_MS=180000
# CCH_MULTICORE_SHUTDOWN_TIMEOUT_MS=33000 # 未设置时自动取 SHUTDOWN_HARD_EXIT_MS + 5 秒

# 数据库连接字符串(仅用于本地开发或非 Docker Compose 部署)
DSN="postgres://user:password@host:port/db_name"

Expand All @@ -20,10 +34,11 @@ ENABLE_API_KEY_VACUUM_FILTER="true"

# PostgreSQL 连接池配置(postgres.js)
# 说明:
# - DB_POOL_MAX 是每个应用进程内 data/control/writer 三类 pool 的连接总预算
# - 默认拆分:生产环境 20 = 15/4/1,开发与测试环境 10 = 7/2/1
# - DB_POOL_MAX 是一个应用容器内 data/control/writer 三类 pool 的连接总预算;启用内置
# 多进程时由 launcher 确定性分摊到各网关进程,单进程时保持原语义
# - 单进程默认拆分:生产环境 20 = 15/4/1,开发与测试环境 10 = 7/2/1
# - 每条 pool 另有有界 outstanding admission,满载时以 DB_POOL_ADMISSION_EXCEEDED 快速失败
# - k8s 多副本时仍需按副本数分摊这个总预算
# - k8s 多 Pod 时每个 Pod 都有一份该预算,仍需按 Pod 数核算数据库总连接数
DB_POOL_MAX=20
DB_POOL_IDLE_TIMEOUT=20 # 空闲连接回收(秒)
DB_POOL_CONNECT_TIMEOUT=10 # 建立连接超时(秒)
Expand All @@ -35,7 +50,7 @@ DB_LOCK_TIMEOUT_MS=5000 # SQL 等待数据库锁的最长时
# - sync:同步写入(兼容旧行为,但高并发下会增加请求尾部阻塞)
MESSAGE_REQUEST_WRITE_MODE=async

# message_request 异步批量参数(可选)
# message_request 异步批量参数(可选);MAX_PENDING 在内置多进程间分摊,避免队列内存倍增
MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS=250
MESSAGE_REQUEST_ASYNC_BATCH_SIZE=200
MESSAGE_REQUEST_ASYNC_MAX_PENDING=5000
Expand Down Expand Up @@ -114,6 +129,8 @@ SESSION_RESPONSE_BODY_DEDUP_ENABLED=false # response body 单 key 去重 writer
SESSION_RESPONSE_BODY_MAX_BYTES=5242880 # 会话响应体 Redis 存储上限(默认 5 MiB,范围 64 KiB-64 MiB)
# 去重关闭时限制每份正文;去重开启时限制所有唯一正文的 UTF-8 总字节数
# 超限正文不落 Redis;before/after snapshot 的 headers/meta 仍保留
SESSION_REQUEST_ARTIFACT_MAX_BYTES=5242880 # 单份请求调试正文 Redis 存储上限(默认 5 MiB,范围 64 KiB-64 MiB)
# 超限时跳过 requestBody/messages 和 before/after body,保留 headers/meta

# Dashboard 配置
DASHBOARD_LOGS_POLL_INTERVAL_MS=5000 # 日志页自动刷新轮询间隔(毫秒,默认 5000,范围 250-60000)
Expand Down Expand Up @@ -171,13 +188,19 @@ FETCH_HEADERS_TIMEOUT=600000
FETCH_BODY_TIMEOUT=600000
MAX_RETRY_ATTEMPTS_DEFAULT=2 # 单供应商最大尝试次数(含首次调用),范围 1-10,留空使用默认值 2

# 客户端断开后的 detached stream 共享带权进程级资源预算
# 客户端断开后的 detached stream 共享带权容器级资源预算(内置多进程间分摊)
# Replay owner 申请较重的 replay lease;预算不足时降级为轻量 metering,
# 两者都无法准入时才终止上游并按 499 结算。
DETACHED_STREAM_MAX_CONCURRENCY=64
DETACHED_STREAM_BUDGET_BYTES=67108864
DETACHED_STREAM_METERING_RESERVE_BYTES=16777216

# 流式内容门禁在容器内共享的 precommit 原始字节与解析文本预算(默认 256 MiB,
# 内置多进程间分摊)。
# 预算不足时请求等待现有前缀被消费,不会关闭门禁或误触发供应商切换。
# 至少应为 STREAM_GATE_PREBUFFER_BYTE_CAP 的 4 倍。
STREAM_GATE_GLOBAL_PREBUFFER_BYTE_CAP=268435456

# 入站压缩请求体(content-encoding: zstd/gzip/deflate/br)解压上限(字节)
# 功能说明:/v1、/v1beta 代理路径不受 proxyClientMaxBodySize 钳制,这两项是入站解压的内存/CPU 兜底。
# - MAX_DECOMPRESSED_REQUEST_BYTES:解压输出上限,防御解压炸弹,超过按 413 拒绝。默认 100MB。
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ RUN mkdir -p /app/reports
# --report-exclude-env:诊断报告不得持久化 ADMIN_TOKEN、DSN、Redis、Langfuse
# 与 Provider credentials 等运行时环境变量
# --report-directory:指向 /app/reports 以便挂卷持久化
CMD ["node", "--report-on-fatalerror", "--report-uncaught-exception", "--report-exclude-env", "--report-directory=/app/reports", "server.js"]
CMD ["node", "--report-on-fatalerror", "--report-uncaught-exception", "--report-exclude-env", "--report-directory=/app/reports", "cluster.js"]
108 changes: 108 additions & 0 deletions cluster.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// claude-code-hub 的资源感知型生产启动器。
//
// 每个 cluster worker 独占完整的请求生命周期。主进程只分发已接受的 socket
// 句柄,绝不传递请求正文或解析后的对象图,避免大请求跨越 IPC 序列化边界。

"use strict";

const cluster = require("node:cluster");
const path = require("node:path");
const { createClusterSupervisor } = require("./server-lib/cluster-supervisor");
const {
createMulticorePlan,
detectRuntimeResources,
hasCrossProcessInvalidation,
} = require("./server-lib/multicore");

function log(level, msg, extra) {
const line = { ts: new Date().toISOString(), level, msg, ...(extra || {}) };
try {
process.stdout.write(`${JSON.stringify(line)}\n`);
} catch {
// 日志写入失败不应影响启动监督。
}
}

function loadLauncherEnvironment() {
try {
// worker 数必须在 Next 启动前确定。复用 Next 的 .env 加载语义,确保
// `bun run start` 与容器注入环境变量时行为一致。
const { loadEnvConfig } = require("@next/env");
loadEnvConfig(__dirname, process.env.NODE_ENV !== "production");
} catch (error) {
log("warn", "multicore_env_load_failed", {
error: String(error?.message || error),
});
}
}

function normalizeStandaloneEnvironment(env = process.env) {
// `off` 是紧急回滚入口。即使部署残留了内部 worker 标记,单进程也必须恢复
// 完整 owner 角色,不能误跳过迁移、队列 consumer 或后台调度器。
env.CCH_MULTICORE_ACTIVE = "0";
env.CCH_MULTICORE_WORKER_INDEX = "0";
env.CCH_MULTICORE_WORKER_COUNT = "1";
env.CCH_MULTICORE_BACKGROUND_OWNER = "1";
env.CCH_PROCESS_ROLE = "gateway-control";
delete env.CCH_MULTICORE_EFFECTIVE_VCPUS;
delete env.CCH_MULTICORE_EFFECTIVE_MEMORY_BYTES;
return env;
}

async function main() {
if (!cluster.isPrimary) {
throw new Error("cluster.js must only run as the primary process");
}

loadLauncherEnvironment();
const resources = detectRuntimeResources();
const plan = createMulticorePlan({ env: process.env, resources });
log("info", "multicore_plan_resolved", {
enabled: plan.enabled,
mode: plan.mode,
reason: plan.reason,
workerCount: plan.workerCount,
effectiveVcpus: resources.effectiveCpu,
cpuQuota: resources.cpuQuota,
cpusetVcpus: resources.cpusetCpu,
crossProcessInvalidation: hasCrossProcessInvalidation(process.env),
effectiveMemoryMiB: Math.floor(resources.effectiveMemoryBytes / (1024 * 1024)),
memoryPerWorkerMiB: plan.memoryPerWorkerBytes
? Math.floor(plan.memoryPerWorkerBytes / (1024 * 1024))
: null,
});

if (!plan.enabled) {
normalizeStandaloneEnvironment(process.env);
const { main: startServer } = require("./server");
await startServer();
return;
}

// 轮询调度比任由少数 keep-alive listener 获得大部分连接更均衡。Windows
// 保留原生策略;生产容器运行于 Linux,因此使用 SCHED_RR。
cluster.schedulingPolicy = process.platform === "win32" ? cluster.SCHED_NONE : cluster.SCHED_RR;
cluster.setupPrimary({
exec: path.join(__dirname, "server.js"),
execArgv: process.execArgv,
});

createClusterSupervisor({
clusterModule: cluster,
plan,
processRef: process,
env: process.env,
log,
}).start();
}

module.exports = { loadLauncherEnvironment, main, normalizeStandaloneEnvironment };

if (require.main === module) {
main().catch((error) => {
log("error", "multicore_bootstrap_failed", {
error: String(error?.stack || error),
});
process.exit(1);
});
}
5 changes: 4 additions & 1 deletion deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ RUN bun run build
# 要求 Node >= 22.19(undici 8 和入站请求体 zstd 解压依赖,见 package.json engines)。
# node:trixie-slim 基于 Debian Trixie,提供 Node 24+,满足该要求。
FROM node:trixie-slim AS runner
ARG APP_VERSION=dev
ENV NODE_ENV=production
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
ENV PORT=3000
ENV HOST=0.0.0.0
ENV HOSTNAME=0.0.0.0
Expand All @@ -55,6 +57,7 @@ RUN apt-get update && \
COPY --from=build --chown=node:node /app/public ./public
COPY --from=build --chown=node:node /app/drizzle ./drizzle
COPY --from=build --chown=node:node /app/messages ./messages
COPY --from=build --chown=node:node /app/VERSION ./VERSION
COPY --from=build --chown=node:node /app/.next/standalone ./
# Server Actions live inside .next/server; copy it or Next.js cannot resolve action IDs.
COPY --from=build --chown=node:node /app/.next/server ./.next/server
Expand All @@ -65,4 +68,4 @@ RUN mkdir -p /app/reports && chown node:node /app/reports
USER node
EXPOSE 3000

CMD ["node", "--report-on-fatalerror", "--report-uncaught-exception", "--report-exclude-env", "--report-directory=/app/reports", "server.js"]
CMD ["node", "--report-on-fatalerror", "--report-uncaught-exception", "--report-exclude-env", "--report-directory=/app/reports", "cluster.js"]
2 changes: 1 addition & 1 deletion deploy/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@ COPY --from=build --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000

CMD ["node", "server.js"]
CMD ["node", "cluster.js"]
17 changes: 17 additions & 0 deletions docs/k8s-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,23 @@ bash scripts/deploy-k8s.sh --replicas 3 --hpa-min 3 --hpa-max 10 -y
默认模板保留 `replicas=2`,但 `AUTO_MIGRATE` 入口 `src/instrumentation.ts` 会先获取 PostgreSQL advisory lock,
因此首次多副本启动时迁移会串行执行。如果你更关心首启速度,也可以先用 `--replicas 1` 部署,确认健康后再扩容。

### 单 Pod 多核心模式

生产镜像通过 `cluster.js` 启动。默认 `CCH_MULTICORE_MODE=auto` 只在 cgroup 有效
vCPU 至少为 4 且内存/共享预算足够时启用;当前 app 模板的 `limits.cpu=4`、
`limits.memory=4Gi` 会自动运行 2 个完整网关 worker。每个请求始终由一个 worker
从 socket 处理到 usage/计费写回,正文和 JSON 对象不会通过进程 IPC 复制。

`DB_POOL_MAX`、message writer pending、detached stream、stream gate 和 replay 并发等
配置在同一 Pod 内作为聚合预算分摊。例如模板的 `DB_POOL_MAX=24` 在 2 worker 下为
12/12,不会因为多进程把单 Pod 的 PostgreSQL 连接预算翻倍;多个 Pod 之间仍需再按
`replicas`/HPA 最大值计算总连接数。

HPA 看到的是 Pod 内 primary 与所有 worker 的合计 CPU/内存。少量长 keep-alive 或
WebSocket 连接仍具有单 worker 亲和性,压测时应同时检查每个进程的负载分布。若希望
完全由 Pod 横向扩展控制,可在 Deployment 中设置 `CCH_MULTICORE_MODE=off`。详细的
内存模型、覆盖配置和故障语义见[网关多核心运行模式](./multicore-gateway.md)。

### Codex `/v1/responses` WebSocket 反代

`/v1/responses` 端点对 Codex 客户端会走 **WebSocket 升级**(其余路径仍是 HTTP)。
Expand Down
Loading
Loading