mcpField
I
inngest-local

inngest-local

github.com/joelhooks云与 DevOps6541 次安装1 天前更新
代码仓库

关于 inngest-local

inngest-local 是在 macOS 上将 Inngest 部署为可自托管持久化工作流引擎的设置技能。内容涵盖 Docker 和 Kubernetes 部署、Worker 配置、步骤重试、状态持久化及常见运维问题。

主要功能

  1. 01Docker 与 Kubernetes 部署方案
  2. 02步骤级持久化重试
  3. 03Worker 脚手架与注册
  4. 04事件串联和定时函数
  5. 05持久化与重启恢复

适用场景

  1. 01本地试用 Inngest
  2. 02运行持久化后台工作流
  3. 03通过 Kubernetes 部署 Inngest
  4. 04Worker 中断后恢复任务

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/joelhooks/joelclaw --skill inngest-local

安装后在 Claude Code 中输入/inngest-local确认已加载。

附带文件agents/openai.yamlassets/small-logo.svg

详细介绍

在 macOS 上自托管 Inngest

此技能可在 Mac 上将 Inngest 配置为自托管的持久化工作流引擎。Inngest 提供事件驱动函数,每个步骤都可独立重试——如果 5 个步骤中的第 3 步失败,只会重试第 3 步。

开始之前

必需:

  • 安装了 Docker 的 macOS(Docker Desktop、OrbStack 或 Colima)
  • 用于工作进程的 Bun 或 Node.js

可选:

  • k8s 集群(Colima 上的 Talos 等),用于持久化部署
  • Redis(用于在函数之间共享状态,以及与网关集成)

意图对齐

询问用户以下问题,以确定配置范围。

问题 1:你要构建什么?
  1. 快速试验 — 我想试用 Inngest、运行一个函数并查看仪表板
  2. 持久化配置 — 我想让它始终运行、重启后仍能继续,并运行真正的工作流
  3. 完整基础设施 — 我想部署基于 k8s 的 Inngest,使用持久化存储,并与 Agent 网关集成
问题 2:工作进程使用什么运行时?
  1. Bun — 速度快,TypeScript 支持良好,也是 joelclaw 使用的运行时
  2. Node.js — 标准方案,兼容性最广
  3. 现有框架 — 我已经有 Next.js/Express/Hono 应用
问题 3:要处理哪类工作?
  1. AI Agent 任务 — 编码循环、内容处理、转录流水线
  2. 通用后台任务 — 定时任务、webhook、数据处理
  3. 两者都有 — 混合工作负载

配置层级

签名密钥(必需)

截至 2026 年 2 月,inngest/inngest:latest 需要签名密钥。缺少密钥时,容器会陷入崩溃重启循环,并报错 Error: signing-key is required。

# Generate once, reuse across tiers
INNGEST_SIGNING_KEY="signkey-dev-$(openssl rand -hex 16)"
INNGEST_EVENT_KEY="evtkey-dev-$(openssl rand -hex 16)"
echo "INNGEST_SIGNING_KEY=$INNGEST_SIGNING_KEY" >> .env.inngest
echo "INNGEST_EVENT_KEY=$INNGEST_EVENT_KEY" >> .env.inngest
层级 1:Docker 单行命令(试验)

在 30 秒内启动 Inngest:

docker run -d --name inngest \
  -p 8288:8288 \
  -e INNGEST_SIGNING_KEY="$INNGEST_SIGNING_KEY" \
  -e INNGEST_EVENT_KEY="$INNGEST_EVENT_KEY" \
  inngest/inngest:latest \
  inngest start --host 0.0.0.0

打开 http://localhost:8288 — 你应该会看到 Inngest 仪表板。

限制: 不会持久化状态。容器重启后,历史记录会丢失。适合试验。

层级 2:持久化 Docker(每日使用)

添加卷以保存 SQLite 状态:

docker run -d --name inngest \
  -p 8288:8288 \
  -v inngest-data:/var/lib/inngest \
  -e INNGEST_SIGNING_KEY="$INNGEST_SIGNING_KEY" \
  -e INNGEST_EVENT_KEY="$INNGEST_EVENT_KEY" \
  --restart unless-stopped \
  inngest/inngest:latest \
  inngest start --host 0.0.0.0

现在,Inngest 状态在容器重启后仍会保留。Docker 重启后,--restart unless-stopped 会让容器重新启动。

层级 3:Kubernetes(生产级)

实现完整持久化并配置适当的运行状况检查。需要 k8s 集群(Colima 上的 Talos 等)。

# inngest.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: inngest
  namespace: default
spec:
  serviceName: inngest-svc  # NOT "inngest" — avoids env var collision
  replicas: 1
  selector:
    matchLabels:
      app: inngest
  template:
    metadata:
      labels:
        app: inngest
    spec:
      containers:
      - name: inngest
        image: inngest/inngest:latest
        command: ["inngest", "start", "--host", "0.0.0.0"]
        ports:
        - containerPort: 8288
        volumeMounts:
        - name: data
          mountPath: /var/lib/inngest
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 5Gi
---
apiVersion: v1
kind: Service
metadata:
  name: inngest-svc  # CRITICAL: not "inngest" — k8s creates INNGEST_PORT env var that conflicts
  namespace: default
spec:
  type: NodePort
  selector:
    app: inngest
  ports:
  - port: 8288
    targetPort: 8288
    nodePort: 8288

应用配置:

kubectl apply -f inngest.yaml

⚠️ 注意: 切勿将 k8s Service 命名为与其运行的二进制文件相同的名称。名为 inngest 的 Service 会创建 INNGEST_PORT=tcp://10.43.x.x:8288。Inngest 二进制文件要求 INNGEST_PORT 是整数。请将其命名为 inngest-svc。

构建工作进程

步骤 1:初始化
mkdir my-worker && cd my-worker
bun init -y
bun add inngest @inngest/ai hono
步骤 2:创建 Inngest 客户端
// src/inngest.ts
import { Inngest } from "inngest";

// Type your events for full type safety
type Events = {
  "task/process": { data: { url: string; outputPath: string } };
  "task/completed": { data: { url: string; result: string } };
};

export const inngest = new Inngest({
  id: "my-worker",
  schemas: new EventSchemas().fromRecord<Events>(),
});
步骤 3:编写第一个函数
// src/functions/process-task.ts
import { inngest } from "../inngest";

export const processTask = inngest.createFunction(
  {
    id: "process-task",
    concurrency: { limit: 1 },  // one at a time
    retries: 3,
  },
  { event: "task/process" },
  async ({ event, step }) => {
    // Step 1: Download — retries independently on failure
    const localPath = await step.run("download", async () => {
      const response = await fetch(event.data.url);
      const buffer = await response.arrayBuffer();
      const path = `/tmp/downloads/${crypto.randomUUID()}.bin`;
      await Bun.write(path, buffer);
      return path;  // Only the path is stored in step state (claim-check pattern)
    });

    // Step 2: Process — if this fails, download doesn't re-run
    const result = await step.run("process", async () => {
      const data = await Bun.file(localPath).text();
      // ... your processing logic
      return { processed: true, size: data.length };
    });

    // Step 3: Emit completion event — chains to other functions
    await step.sendEvent("notify-complete", {
      name: "task/completed",
      data: { url: event.data.url, result: JSON.stringify(result) },
    });

    return { status: "done", result };
  }
);
步骤 4:提供函数
// src/serve.ts
import { Hono } from "hono";
import { serve as inngestServe } from "inngest/hono";
import { inngest } from "./inngest";
import { processTask } from "./functions/process-task";

const app = new Hono();

// Health check
app.get("/", (c) => c.json({ status: "running", functions: 1 }));

// Inngest endpoint — registers functions with the server
app.on(
  ["GET", "POST", "PUT"],
  "/api/inngest",
  inngestServe({ client: inngest, functions: [processTask] })
);

export default {
  port: 3111,
  fetch: app.fetch,
};
步骤 5:运行
INNGEST_DEV=1 bun run src/serve.ts

工作进程启动后,会在 localhost:8288 向 Inngest 注册,你的函数也会显示在仪表板中。

步骤 6:测试

通过仪表板(Events → Send Event)发送事件,或使用 curl:

curl -X POST http://localhost:8288/e/key \
  -H "Content-Type: application/json" \
  -d '{"name": "task/process", "data": {"url": "https://example.com/file.txt", "outputPath": "/tmp/out"}}'

在仪表板中观察它逐步执行。

模式

事件链

函数 A 发出一个事件,触发函数 B:

// In function A:
await step.sendEvent("chain", { name: "pipeline/step-two", data: { result } });

// Function B triggers on that event:
export const stepTwo = inngest.createFunction(
  { id: "step-two" },
  { event: "pipeline/step-two" },
  async ({ event, step }) => { /* ... */ }
);
并发键

每个项目运行一个实例,但允许不同项目并行运行:

concurrency: {
  key: "event.data.project",
  limit: 1,
}
Cron 函数
export const heartbeat = inngest.createFunction(
  { id: "heartbeat" },
  [{ cron: "*/15 * * * *" }],
  async ({ step }) => {
    await step.run("check-health", async () => {
      // ... system health checks
    });
  }
);
声明后取回模式

步骤间传递大型数据:写入文件,并传递路径。

// ❌ DON'T: return large data from a step
const transcript = await step.run("transcribe", async () => {
  return { text: hugeString }; // Step output has size limits!
});

// ✅ DO: write to file, return path
const transcriptPath = await step.run("transcribe", async () => {
  const result = await transcribe(audioPath);
  await Bun.write("/tmp/transcript.json", JSON.stringify(result));
  return "/tmp/transcript.json";
});

确保重启后仍能运行

通过 launchd 运行工作进程

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.you.inngest-worker</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/you/.bun/bin/bun</string>
    <string>run</string>
    <string>/path/to/your/worker/src/serve.ts</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>INNGEST_DEV</key><string>1</string>
    <key>HOME</key><string>/Users/you</string>
    <key>PATH</key><string>/usr/local/bin:/usr/bin:/bin:/Users/you/.bun/bin</string>
  </dict>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>StandardOutPath</key><string>/tmp/inngest-worker.log</string>
  <key>StandardErrorPath</key><string>/tmp/inngest-worker.log</string>
  <key>WorkingDirectory</key><string>/path/to/your/worker</string>
</dict>
</plist>

加载:

launchctl load ~/Library/LaunchAgents/com.you.inngest-worker.plist
重启时会发生什么
  1. Docker 启动 → Inngest 服务携带已持久化的状态(SQLite)启动
  2. launchd 启动 → 工作进程注册函数
  3. 任何未完成的函数都会从最后完成的步骤恢复运行

注意事项

  1. @inngest/ai 是必需的 peer dep。 仅运行 bun add inngest 并不足够——SDK 启动时会导入 @inngest/ai。Worker 会因 Cannot find module '@inngest/ai' 而崩溃。务必同时安装两者。

  2. Docker 到主机的网络连接。 如果 Inngest 在 Docker 中运行,而 Worker 在主机上运行,服务无法访问 localhost:3111。在 docker run 命令中传入 --sdk-url http://host.docker.internal:3111/api/inngest。这仅适用于 Docker Desktop/OrbStack;Linux Docker 需要使用 --add-host=host.docker.internal:host-gateway。

  3. k8s 中的服务命名: 切勿将 Service 命名为与二进制文件相同的名称。INNGEST_PORT 环境变量冲突会导致容器崩溃。

  4. 步骤输出大小: 保持步骤返回值较小。对于大型数据,请使用声明检查模式。

  5. Worker 重新注册: Inngest 服务重启后,Worker 需要重新注册。重启 Worker 或调用注册端点。

  6. 触发器漂移: 函数会在启动时注册其触发器。如果你在代码中更改了触发器,但服务仍保留旧状态,旧触发器就会继续生效。请构建审计器,或同时重启服务和 Worker。

  7. INNGEST_DEV=1: 本地开发必需。若未设置,Worker 会尝试向 Inngest Cloud 注册。

  8. GPU 工作的并发数设为 1: 转录、推理——任何会占满 GPU 的工作都需要设置 concurrency: { limit: 1 }。

验证

  • 可访问 Inngest 仪表板:http://localhost:8288
  • 仪表板中显示 Worker 已注册(Functions 选项卡)
  • 发送测试事件——函数在仪表板中执行
  • 在函数执行过程中终止 Worker——重启 Worker 后,函数会从上一个步骤继续
  • (Tier 2+)重启 Docker——Inngest 状态得到保留
  • (launchd)重启 Mac——Worker 和 Inngest 都会自动恢复运行

设置脚本(curl 优先)

如需自动设置,用户可以运行:

curl -sL joelclaw.com/scripts/inngest-setup.sh | bash

或指定层级:

curl -sL joelclaw.com/scripts/inngest-setup.sh | bash -s -- 2

该脚本具有幂等性,会检测现有状态,并搭建包含类型化事件的 Worker。

决策链(精简版 ADR)

此技能的架构由一系列架构决策记录(ADR)支撑。需要了解权衡背景时再展开查看。

ADR-0010 → ADR-0029 → 当前状态

决策选择主要权衡链接
工作流引擎Inngest(自托管)步骤级持久性与复杂度之间的权衡。Cron + 脚本不支持逐步骤重试。ADR-0010
容器运行时Colima(VZ 框架)替代 Docker Desktop。免费、无头运行、占用 RAM 更少。ADR-0029
3 个容器使用 k8s是(在 Colima 上运行 Talos)为协调循环及未来的多节点支持增加 380MB 开销。Docker Compose 不具备自我修复能力。joel-deploys-k8s
服务命名inngest-svc,而非 inngestk8s 会注入 INNGEST_PORT 环境变量。二进制文件需要整数,却收到 URL。代价高昂的调试经验
Worker 运行时Bun + Hono冷启动比 Node 更快。Hono 提供精简的 HTTP 服务。通过 launchd KeepAlive 保持运行。实用选择
步骤数据模式声明检查(文件路径)步骤输出有大小限制。将大型数据写入磁盘,并在步骤间传递路径。Inngest 文档
触发器审计心跳 Cron 审计器未被发现的触发器漂移曾导致 promote 函数数日无法运行。现在每 15 分钟审计一次。ADR-0037

致谢

评论

支持 Markdown