فهرست منبع

增加k8s支持

lkatzey 6 روز پیش
والد
کامیت
0a07ac6498

+ 5 - 0
.env.example

@@ -33,6 +33,11 @@ ELEVENLABS_API_KEY=
 # OUTPUT_DIR=/app/output
 # Auto-clean cached outputs older than N days (0 disables). Default 30.
 # OUTPUT_RETENTION_DAYS=30
+# Where the WebUI stores job metadata (jobs.json). Defaults to the OS temp dir
+# (/tmp/pipeline-jobs). In Kubernetes point this at a PVC-backed path (e.g.
+# /app/jobs) so job history survives pod restarts; otherwise it lives on
+# ephemeral container storage and is lost on restart.
+# PIPELINE_JOBS_DIR=/app/jobs
 # Timezone for the github-trending cover date (首屏 + 开场口播) and the output
 # date subfolder, so the two never disagree and never roll over a day early in
 # a UTC container. Default Asia/Shanghai. Docker also sets TZ to this value.

+ 14 - 0
Dockerfile

@@ -93,7 +93,21 @@ RUN mkdir -p /usr/share/fonts/truetype/noto && \
     cp assets/fonts/NotoSansSC-Regular.ttf assets/fonts/NotoSansSC-Bold.ttf /usr/share/fonts/truetype/noto/ && \
     fc-cache -f
 
+# Run as the non-root `node` user (uid/gid 1000, shipped in the base image) so
+# the image is Pod Security "restricted"-friendly. Only the writable paths are
+# chowned to keep the layer small: the PVC-backed output, Next.js' .next cache,
+# and a HOME for Remotion's browser / faster-whisper model download cache.
+ENV HOME=/home/node
+RUN mkdir -p /app/output /home/node && \
+    chown -R node:node /app/output /home/node /app/apps/web/.next
+USER node
+
 EXPOSE 3000
 
+# Docker Compose / `docker run` health; Kubernetes uses its own probes against
+# the same endpoints. global fetch is available on Node 22.
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+  CMD node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+
 WORKDIR /app/apps/web
 CMD ["/app/node_modules/.bin/next", "start"]

+ 9 - 0
apps/web/src/app/api/health/live/route.ts

@@ -0,0 +1,9 @@
+// Liveness probe — succeeds as long as the Node/Next.js process can respond.
+// Intentionally cheap (no filesystem checks) so a transient PVC hiccup never
+// trips a liveness failure → restart loop. Use /api/health/ready for readiness.
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function GET() {
+  return Response.json({ status: "ok" }, { status: 200 });
+}

+ 35 - 0
apps/web/src/app/api/health/ready/route.ts

@@ -0,0 +1,35 @@
+// Readiness probe — the pod only receives traffic when its writable dirs are
+// reachable. The output dir is a PVC; if it isn't mounted (or the node user
+// can't write to it) readiness returns 503 and the Service stops routing here.
+import { accessSync, constants, mkdirSync } from "node:fs";
+import { resolve } from "node:path";
+import { tmpdir } from "node:os";
+import { loadConfig } from "@/lib/config";
+import { resolveOutputDir } from "@pipeline/shared/node";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+function writable(dir: string): boolean {
+  try {
+    mkdirSync(dir, { recursive: true });
+    accessSync(dir, constants.W_OK);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+export async function GET() {
+  const config = loadConfig();
+  const outputDir = resolveOutputDir(config?.output?.dir);
+  const jobsDir = process.env.PIPELINE_JOBS_DIR
+    ? resolve(process.env.PIPELINE_JOBS_DIR)
+    : resolve(tmpdir(), "pipeline-jobs");
+  const checks = { output: writable(outputDir), jobs: writable(jobsDir) };
+  const ok = checks.output && checks.jobs;
+  return Response.json(
+    { status: ok ? "ready" : "degraded", checks },
+    { status: ok ? 200 : 503 }
+  );
+}

+ 34 - 4
apps/web/src/lib/job-store.ts

@@ -1,5 +1,5 @@
 import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
-import { join } from "node:path";
+import { join, resolve } from "node:path";
 import { tmpdir } from "node:os";
 
 export interface JobData {
@@ -31,9 +31,33 @@ export interface JobData {
   updatedAt: number;
 }
 
-const STORE_DIR = join(tmpdir(), "pipeline-jobs");
+// Mountable job store. In Docker Compose this is /tmp/pipeline-jobs (a named
+// volume); in Kubernetes set PIPELINE_JOBS_DIR to a PVC-backed path so jobs
+// survive pod restarts instead of living on ephemeral container storage.
+const STORE_DIR = process.env.PIPELINE_JOBS_DIR
+  ? resolve(process.env.PIPELINE_JOBS_DIR)
+  : join(tmpdir(), "pipeline-jobs");
 const STORE_FILE = join(STORE_DIR, "jobs.json");
 
+// Rendering runs as a spawned child process; if the pod is restarted (rolling
+// update, OOM, node drain) mid-job, the job is left "running" forever. On the
+// first store access after a fresh start, sweep any interrupted jobs to failed
+// so the UI reflects reality instead of freezing on a ghost task.
+let recovered = false;
+function sweepInterrupted(jobs: Map<string, JobData>): boolean {
+  let dirty = false;
+  const now = Date.now();
+  for (const job of jobs.values()) {
+    if (job.status === "running" || job.status === "pending") {
+      job.status = "failed";
+      job.error = job.error ?? "任务因进程重启被中断(interrupted by pod restart)";
+      job.updatedAt = now;
+      dirty = true;
+    }
+  }
+  return dirty;
+}
+
 function ensureStore(): Map<string, JobData> {
   if (!existsSync(STORE_DIR)) {
     mkdirSync(STORE_DIR, { recursive: true });
@@ -41,13 +65,19 @@ function ensureStore(): Map<string, JobData> {
   if (!existsSync(STORE_FILE)) {
     writeFileSync(STORE_FILE, "{}", "utf-8");
   }
+  let jobs: Map<string, JobData>;
   try {
     const raw = readFileSync(STORE_FILE, "utf-8");
     const obj = JSON.parse(raw) as Record<string, JobData>;
-    return new Map(Object.entries(obj));
+    jobs = new Map(Object.entries(obj));
   } catch {
-    return new Map();
+    jobs = new Map();
+  }
+  if (!recovered) {
+    recovered = true;
+    if (sweepInterrupted(jobs)) flushStore(jobs);
   }
+  return jobs;
 }
 
 function flushStore(jobs: Map<string, JobData>): void {

+ 6 - 0
deploy/k8s/00-namespace.yaml

@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: pipeline
+  labels:
+    app.kubernetes.io/name: pipeline

+ 16 - 0
deploy/k8s/01-configmap.yaml

@@ -0,0 +1,16 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: pipeline-env
+  namespace: pipeline
+data:
+  # Non-sensitive runtime config. Secrets (API keys, OSS keys, Feishu webhook)
+  # live in the `pipeline-env-secret` Secret, generated from your local .env —
+  # see README.md. Anything here can also be overridden by that Secret.
+  OUTPUT_DIR: /app/output
+  PIPELINE_JOBS_DIR: /app/jobs
+  OUTPUT_RETENTION_DAYS: "30"
+  TIMEZONE: Asia/Shanghai
+  TZ: Asia/Shanghai
+  LOG_LEVEL: info
+  NODE_ENV: production

+ 24 - 0
deploy/k8s/02-pvc.yaml

@@ -0,0 +1,24 @@
+# Two PVCs so generated videos and job metadata survive pod restarts.
+# ReadWriteOnce + replicas: 1 — the app is single-replica (file-based job store
+# + spawned-CLI rendering; no distributed locking). See README for scaling notes.
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: pipeline-output
+  namespace: pipeline
+spec:
+  accessModes: ["ReadWriteOnce"]
+  resources:
+    requests:
+      storage: 50Gi
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: pipeline-jobs
+  namespace: pipeline
+spec:
+  accessModes: ["ReadWriteOnce"]
+  resources:
+    requests:
+      storage: 1Gi

+ 89 - 0
deploy/k8s/03-deployment.yaml

@@ -0,0 +1,89 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: pipeline
+  namespace: pipeline
+  labels:
+    app.kubernetes.io/name: pipeline
+spec:
+  # Keep at 1. The WebUI stores jobs in a local file and renders by spawning the
+  # CLI child process; there is no shared state or distributed locking, so extra
+  # replicas would each see a private jobs.json and could double-run jobs.
+  replicas: 1
+  strategy:
+    type: Recreate            # RWO PVCs can't move while the old pod owns them
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: pipeline
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/name: pipeline
+    spec:
+      securityContext:
+        runAsNonRoot: true
+        runAsUser: 1000        # `node` user baked into the image
+        runAsGroup: 1000
+        fsGroup: 1000          # chowns mounted PVCs so uid 1000 can write them
+      # Uncomment once you've created the Harbor pull secret (see README).
+      # imagePullSecrets:
+      #   - name: harbor-creds
+      containers:
+        - name: pipeline
+          # ⚠️ Replace with your Harbor registry + project. See README.
+          image: harbor.corp.shuidi.tech/library/pipeline:latest
+          imagePullPolicy: IfNotPresent
+          ports:
+            - name: http
+              containerPort: 3000
+          envFrom:
+            - configMapRef:
+                name: pipeline-env
+            - secretRef:
+                name: pipeline-env-secret
+          # Let in-flight renders finish on rolling update / drain. A long
+          # render still exceeds this; interrupted jobs are auto-marked failed
+          # on the next pod start (startup sweep in job-store).
+          terminationGracePeriodSeconds: 60
+          startupProbe:
+            httpGet:
+              path: /api/health/live
+              port: http
+            failureThreshold: 30
+            periodSeconds: 10
+          livenessProbe:
+            httpGet:
+              path: /api/health/live
+              port: http
+            initialDelaySeconds: 10
+            periodSeconds: 30
+            timeoutSeconds: 5
+            failureThreshold: 3
+          readinessProbe:
+            httpGet:
+              path: /api/health/ready
+              port: http
+            initialDelaySeconds: 5
+            periodSeconds: 10
+            timeoutSeconds: 5
+          # Video rendering (Remotion/Chrome + ffmpeg) is CPU/memory heavy.
+          # Tune to your cluster.
+          resources:
+            requests:
+              cpu: "1000m"
+              memory: 2Gi
+            limits:
+              cpu: "4000m"
+              memory: 6Gi
+          volumeMounts:
+            - name: output
+              mountPath: /app/output
+            - name: jobs
+              mountPath: /app/jobs
+      volumes:
+        - name: output
+          persistentVolumeClaim:
+            claimName: pipeline-output
+        - name: jobs
+          persistentVolumeClaim:
+            claimName: pipeline-jobs

+ 15 - 0
deploy/k8s/04-service.yaml

@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: pipeline
+  namespace: pipeline
+  labels:
+    app.kubernetes.io/name: pipeline
+spec:
+  type: ClusterIP
+  selector:
+    app.kubernetes.io/name: pipeline
+  ports:
+    - name: http
+      port: 80
+      targetPort: http

+ 22 - 0
deploy/k8s/05-ingress.yaml

@@ -0,0 +1,22 @@
+# Optional. Apply only if your cluster has an Ingress controller. Replace the
+# host and ingressClassName to match your environment.
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: pipeline
+  namespace: pipeline
+  labels:
+    app.kubernetes.io/name: pipeline
+spec:
+  ingressClassName: nginx
+  rules:
+    - host: pipeline.example.com
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: pipeline
+                port:
+                  name: http

+ 100 - 0
deploy/k8s/README.md

@@ -0,0 +1,100 @@
+# Kubernetes 部署
+
+本项目镜像已做 k8s 兼容(健康探针、非 root 运行、可挂载 PVC 的输出与任务存储、重启后的孤儿任务恢复)。本目录提供单副本部署清单。
+
+> 构建并推送镜像到 Harbor 的完整步骤见仓库根目录 [`docs/DEPLOYMENT.md`](../../docs/DEPLOYMENT.md) 的「Kubernetes 部署」章节。
+
+---
+
+## 前置要求
+
+- 一个可用 k8s 集群(1.24+),已配置默认 StorageClass(PVC 需要动态供给)。
+- `kubectl` 已指向目标集群。
+- 已把镜像推送到 Harbor(见上文链接),并知道镜像地址,例如:
+  `harbor.example.com/library/pipeline:latest`
+
+## 1. 替换镜像地址
+
+编辑 [`03-deployment.yaml`](03-deployment.yaml),把
+
+```yaml
+image: harbor.example.com/library/pipeline:latest
+```
+
+改成你 Harbor 上的实际镜像。
+
+## 2. 生成密钥(Secret)
+
+应用本身从 `.env` 读取密钥。把仓库根目录的 `.env`(含 `OPENAI_API_KEY`、`OPENAI_TTS_API_KEY`、`OSS_*`、`FEISHU_*` 等)一次性灌进同名 Secret:
+
+```bash
+kubectl create secret generic pipeline-env-secret \
+  --from-env-file=.env \
+  -n pipeline
+```
+
+> `--from-env-file` 会把 `.env` 中每一行 `KEY=VALUE` 转成 Secret 的一个键,并通过 Deployment 的 `envFrom: secretRef` 全部注入容器环境变量。
+> 之后修改密钥:`kubectl create secret generic pipeline-env-secret --from-env-file=.env -n pipeline --dry-run=client -o yaml | kubectl apply -f -`,再 `kubectl rollout restart deployment/pipeline -n pipeline`。
+
+## 3. Harbor 私有仓库的镜像拉取凭据(可选)
+
+若 Harbor 项目是私有的,创建 imagePullSecret,并取消 `03-deployment.yaml` 里 `imagePullSecrets` 的注释:
+
+```bash
+kubectl create secret docker-registry harbor-creds \
+  --docker-server=harbor.example.com \
+  --docker-username=<你的用户名> \
+  --docker-password=<密码或 Harbor CLI secret> \
+  -n pipeline
+```
+
+## 4. 应用清单
+
+```bash
+kubectl apply -f deploy/k8s/
+```
+
+包含:Namespace、ConfigMap、两个 PVC、Deployment(单副本)、Service、Ingress(可选)。
+
+## 5. 验证
+
+```bash
+kubectl -n pipeline get pods -w          # 等 READY 1/1(startup 探针通过)
+kubectl -n pipeline describe pod -l app.kubernetes.io/name=pipeline   # 看探针/事件
+kubectl -n pipeline logs -f deploy/pipeline
+```
+
+集群内访问 Service `pipeline:80`;经 Ingress 用你配置的域名访问。
+
+---
+
+## 注意事项
+
+### 为什么是单副本(replicas: 1)
+
+WebUI 把任务元数据存在**本地文件** `jobs.json`,渲染是 Web 进程 **spawn CLI 子进程**完成的,没有共享状态、没有分布式锁。多副本会各自维护一份 `jobs.json`,任务视图不一致,且可能重复执行同一任务。要水平扩展需先把任务存储外置(Redis/DB)并加任务调度——目前不在范围内。PVC 用 `ReadWriteOnce` + `strategy: Recreate` 与之匹配。
+
+### 持久化
+
+| PVC | 挂载点 | 用途 |
+| --- | --- | --- |
+| `pipeline-output` | `/app/output` | 渲染产物 + 临时目录(`OUTPUT_DIR`) |
+| `pipeline-jobs` | `/app/jobs` | 任务元数据 `jobs.json`(`PIPELINE_JOBS_DIR`) |
+
+两个 PVC 都通过 `fsGroup: 1000` 让非 root 的 `node` 用户可写。删除 PVC 会丢失对应数据。
+
+### 重启与中断
+
+Pod 重启(滚动更新、OOM、节点驱逐)会打断进行中的渲染。Web 进程下次启动时会扫描 `jobs.json`,把仍处于 `running`/`pending` 的任务标记为 `failed`(`interrupted by pod restart`),避免 UI 卡在幽灵任务上。被中断的渲染需要手动重跑。`terminationGracePeriodSeconds: 60` 给在途请求留出收尾窗口,但长渲染通常仍会超时。
+
+### 时区
+
+ConfigMap 同时设了 `TZ` 与 `TIMEZONE=Asia/Shanghai`,保证首屏日期、输出日期子目录、日志时间一致。如需改时区编辑 `01-configmap.yaml` 后重新 apply。
+
+### DNS
+
+不再使用 docker-compose 的自定义 DNS(`DNS_SERVER_*` 仅 compose 生效)。k8s 下走集群 CoreDNS;若飞书/OSS 域名需走内部解析,配置集群级别的 DNS 或 `dnsConfig`。
+
+### 资源
+
+渲染(Remotion headless Chrome + ffmpeg)较重,清单里给的 `requests/limits` 是起点,按集群实际调。OOM 会被 k8s 杀掉并重启——任务会被下次启动的孤儿扫描标为 failed。

+ 4 - 0
docker-compose.yml

@@ -1,6 +1,10 @@
 services:
   pipeline:
     build: .
+    # The image runs as the non-root `node` user for Kubernetes. For local dev
+    # we override back to root so the ./output bind mount keeps working
+    # regardless of the host user's uid; Kubernetes sets fsGroup instead.
+    user: "0:0"
     ports:
       - "${PORT:-13000}:3000"
     environment:

+ 53 - 2
docs/DEPLOYMENT.md

@@ -274,6 +274,57 @@ packages/templates/  Remotion 场景组件
 packages/collect/    数据采集器(github-trending 等)
 config/default.yaml  业务配置(provider / 模板配色 / 采集源 / OSS·飞书默认值)
 .env(.example)       密钥与端点
-docker-compose.yml   容器编排
-Dockerfile           多阶段构建
+docker-compose.yml   容器编排(Docker Compose 单机)
+Dockerfile           多阶段构建(非 root、内置 HEALTHCHECK)
+deploy/k8s/          Kubernetes 清单 + 部署说明
 ```
+
+---
+
+## 14. Kubernetes 部署(Harbor 推送)
+
+镜像本身已为 k8s 做好兼容:内置 `/api/health/live`(liveness)与 `/api/health/ready`(readiness)探针端点、以非 root(`node`,uid 1000)运行、输出与任务目录可挂载 PVC、进程重启后自动清理中断的孤儿任务。清单见 [`deploy/k8s/`](../deploy/k8s/)(含单副本 Deployment、Service、Ingress、ConfigMap、PVC 及部署 README)。
+
+### 14.1 构建并推送镜像到 Harbor
+
+```bash
+# 1. 设定你的 Harbor 地址与项目(替换为实际值)
+HARBOR=harbor.example.com          # Harbor 注册地址
+PROJECT=library                     # Harbor 上已创建的项目名
+IMAGE=$HARBOR/$PROJECT/pipeline
+TAG=$(git rev-parse --short HEAD)   # 也可用日期或 latest
+
+# 2. 登录 Harbor
+docker login $HARBOR -u <用户名>     # 回车后输入密码 / Harbor CLI secret
+
+# 3. 用 Harbor 地址构建并打 tag(Dockerfile 在仓库根目录,多阶段构建)
+docker build -t $IMAGE:$TAG -t $IMAGE:latest .
+
+# 4. 推送
+docker push $IMAGE:$TAG
+docker push $IMAGE:latest
+
+# 5. 把 deploy/k8s/03-deployment.yaml 里的 image: 改成 $IMAGE:$TAG(或 :latest)
+```
+
+要点:
+- **Harbor 走 HTTP(非 HTTPS)** 时,需在 docker daemon 的 `/etc/docker/daemon.json` 加入 `"insecure-registries": ["harbor.example.com"]` 后重启 dockerd;或在 k8s 各节点同理配置 containerd。
+- **私有项目**:k8s 拉镜像需要 imagePullSecret,创建方法见 `deploy/k8s/README.md`。
+- 镜像约 1.5–2GB(含 ffmpeg、Python/faster-whisper、Chrome 依赖、字体)。首次推送较慢。
+
+### 14.2 部署到集群
+
+完整步骤(生成 Secret、应用清单、验证、扩缩容与持久化说明)见 [`deploy/k8s/README.md`](../deploy/k8s/README.md)。最简流程:
+
+```bash
+# 用根目录 .env 生成密钥 Secret
+kubectl create secret generic pipeline-env-secret --from-env-file=.env -n pipeline || \
+  kubectl create namespace pipeline && \
+  kubectl create secret generic pipeline-env-secret --from-env-file=.env -n pipeline
+
+# 替换 03-deployment.yaml 中的镜像地址后:
+kubectl apply -f deploy/k8s/
+kubectl -n pipeline get pods -w
+```
+
+> **单副本**:应用以本地 `jobs.json` + spawn CLI 渲染,无共享状态/分布式锁,因此 Deployment 固定 `replicas: 1`(PVC `ReadWriteOnce` + `strategy: Recreate`)。水平扩展需先外置任务存储,详见 k8s README。