关于 inngest-local
inngest-local 是在 macOS 上将 Inngest 部署为可自托管持久化工作流引擎的设置技能。内容涵盖 Docker 和 Kubernetes 部署、Worker 配置、步骤重试、状态持久化及常见运维问题。
主要功能
- 01Docker 与 Kubernetes 部署方案
- 02步骤级持久化重试
- 03Worker 脚手架与注册
- 04事件串联和定时函数
- 05持久化与重启恢复
适用场景
- 01本地试用 Inngest
- 02运行持久化后台工作流
- 03通过 Kubernetes 部署 Inngest
- 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:你要构建什么?
- 快速试验 — 我想试用 Inngest、运行一个函数并查看仪表板
- 持久化配置 — 我想让它始终运行、重启后仍能继续,并运行真正的工作流
- 完整基础设施 — 我想部署基于 k8s 的 Inngest,使用持久化存储,并与 Agent 网关集成
问题 2:工作进程使用什么运行时?
- Bun — 速度快,TypeScript 支持良好,也是 joelclaw 使用的运行时
- Node.js — 标准方案,兼容性最广
- 现有框架 — 我已经有 Next.js/Express/Hono 应用
问题 3:要处理哪类工作?
- AI Agent 任务 — 编码循环、内容处理、转录流水线
- 通用后台任务 — 定时任务、webhook、数据处理
- 两者都有 — 混合工作负载
配置层级
签名密钥(必需)
截至 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
重启时会发生什么
- Docker 启动 → Inngest 服务携带已持久化的状态(SQLite)启动
- launchd 启动 → 工作进程注册函数
- 任何未完成的函数都会从最后完成的步骤恢复运行
注意事项
-
@inngest/ai是必需的 peer dep。 仅运行bun add inngest并不足够——SDK 启动时会导入@inngest/ai。Worker 会因Cannot find module '@inngest/ai'而崩溃。务必同时安装两者。 -
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。 -
k8s 中的服务命名: 切勿将 Service 命名为与二进制文件相同的名称。
INNGEST_PORT环境变量冲突会导致容器崩溃。 -
步骤输出大小: 保持步骤返回值较小。对于大型数据,请使用声明检查模式。
-
Worker 重新注册: Inngest 服务重启后,Worker 需要重新注册。重启 Worker 或调用注册端点。
-
触发器漂移: 函数会在启动时注册其触发器。如果你在代码中更改了触发器,但服务仍保留旧状态,旧触发器就会继续生效。请构建审计器,或同时重启服务和 Worker。
-
INNGEST_DEV=1: 本地开发必需。若未设置,Worker 会尝试向 Inngest Cloud 注册。 -
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,而非 inngest | k8s 会注入 INNGEST_PORT 环境变量。二进制文件需要整数,却收到 URL。 | 代价高昂的调试经验 |
| Worker 运行时 | Bun + Hono | 冷启动比 Node 更快。Hono 提供精简的 HTTP 服务。通过 launchd KeepAlive 保持运行。 | 实用选择 |
| 步骤数据模式 | 声明检查(文件路径) | 步骤输出有大小限制。将大型数据写入磁盘,并在步骤间传递路径。 | Inngest 文档 |
| 触发器审计 | 心跳 Cron 审计器 | 未被发现的触发器漂移曾导致 promote 函数数日无法运行。现在每 15 分钟审计一次。 | ADR-0037 |
致谢
- Inngest — 工作流引擎
- joelclaw.com/inngest-is-the-nervous-system — 架构叙述
- joelclaw.com/self-hosting-inngest-background-tasks — 面向用户的摘要