首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >如何部署一套生产可用的 Langfuse —— 自托管踩坑纪实

如何部署一套生产可用的 Langfuse —— 自托管踩坑纪实

作者头像
windealli
发布2026-06-25 19:48:04
发布2026-06-25 19:48:04
7260
举报
文章被收录于专栏:windealliwindealli

如何部署一套生产可用的 Langfuse —— 自托管踩坑纪实

Langfuse 官方给了一份 docker-compose.ymldocker compose up 一行就能跑起来。但那是 demo,不是生产。这篇讲清楚:为什么生产环境不能直接用它、web/worker 怎么拆开部署、把存储换成腾讯云托管服务会踩哪些坑(ClickHouse 的 cluster 名和时区是重灾区),以及怎么把它接进公司内部的账号系统。都是踩过的坑,建议收藏。

一、先说结论:docker-compose 是 demo,不是生产

Langfuse 是一套 LLM 可观测平台(trace / 评估 / prompt 管理)。它的官方 docker-compose应用所有有状态组件塞进了一台机器:

代码语言:javascript
复制
# 官方 compose 里同机起的容器(简化) langfuse-web      # 应用:UI + API langfuse-worker   # 应用:后台消费 postgres          # 元数据库 clickhouse        # trace 分析库(单机) redis             # 队列 / 缓存 minio             # 对象存储(S3 兼容)

一句话:官方 compose 的价值是「五分钟看到效果」,代价是「所有鸡蛋放一个篮子、且这个篮子你得自己看着」。

它不能直接上生产,核心是三件事:

  • 持久化与可靠性:四个有状态组件全靠单机的本地 volume。机器一挂、磁盘一坏,trace 数据、元数据、队列全没了,没有副本、没有自动备份、没有时间点恢复。
  • 没有高可用与弹性:web 扛流量、worker 扛写入,两者负载特征完全不同,却被绑在同一份编排里,没法各自水平扩缩;ClickHouse / Redis / PG 也都是单实例,单点故障。
  • 有状态组件的运维太重:真正难的不是把 Langfuse 跑起来,而是常年运维 ClickHouse 集群、Redis、PostgreSQL——副本、备份、监控、版本升级、慢查询、磁盘水位……这些你都不想自己背。

所以生产方案的总思路就一句话:应用层(web/worker)自己以镜像方式部署、可弹性伸缩;有状态组件全部交给云上托管服务

二、生产部署:web/worker 拆分 + 托管中间件

2.1 web 和 worker,分别按镜像部署

Langfuse 应用层是两个独立镜像,职责分明,必须分开部署:

组件

官方镜像

职责

伸缩依据

langfuse-web

langfuse/langfuse:3

提供 UI、tRPC、对外 REST 摄取接口

看用户请求 / SDK 上报 QPS

langfuse-worker

langfuse/langfuse-worker:3

消费队列、批量写 ClickHouse、跑评估等后台任务

看摄取量 / 队列积压

为什么必须拆:web 是「前台接客」,worker 是「后厨出餐」。把它们分成两个 Deployment,各自配 HPA,前台流量高就加 web、上报量大就加 worker,互不拖累。

它俩共享同一套环境变量(连同一个 PG / ClickHouse / Redis / 对象存储),但作为两个 workload 独立调度。我们的做法是在容器平台上各起一个 Deployment + Service,web 前面挂网关/Ingress 暴露 3000 端口,worker 不对外。

2.2 数据链路:理解了它,部署就不会瞎配

Langfuse v3 的摄取是异步的,这决定了组件怎么连:

  • SDK / 应用 把 trace 发到 web 的摄取接口;
  • web 把原始事件落到对象存储(S3/COS),同时把任务丢进 Redis 队列(BullMQ),快速返回;
  • worker 从队列取任务,批量写入 ClickHouse
  • PostgreSQL 存项目、用户、prompt、配置等元数据;
  • 对象存储还兼做媒体文件、批量导出的落地。

四个有状态组件,缺一不可。看清这条链路,就知道为什么后面 ClickHouse 的坑最致命——它是 trace 的最终归宿。

2.3 把存储换成腾讯云托管服务

我们把四个有状态组件全部换成腾讯云的托管产品:

组件

替换为

关键收益

PostgreSQL

云数据库 PostgreSQL

主备 / 备份 / 监控开箱即用

Redis

云数据库 Redis

高可用、免运维

ClickHouse

腾讯云数据仓库 TCHouse-C

集群化、副本、扩容托管

MinIO

对象存储 COS(S3 兼容)

海量、低成本、生命周期管理

应用侧只是改一组连接环境变量,把 DATABASE_URLCLICKHOUSE_URLREDIS_HOSTLANGFUSE_S3_* 指向云上实例即可——理论上。实际上,ClickHouse 这一项让我们折腾了好几天。

三、ClickHouse 的两个坑:cluster 名 与 时区

结论先行:托管 ClickHouse 和官方默认假设不一致的两个点——集群名不叫 default服务器时区不是 UTC——会分别让你「初始化建表直接失败」和「界面上所有时间戳偏移 8 小时」。

3.1 坑一:集群名默认不是 default,迁移脚本写死了

现象:跑 ClickHouse 初始化迁移(建表)直接报错,建不出表。

根因:Langfuse 的集群版迁移 SQL 里,建表语句把集群名硬编码成了 ON CLUSTER default

代码语言:javascript
复制
CREATE TABLE traces ON CLUSTER default ( ... ) ENGINE = ReplicatedMergeTree ...

而我们用的腾讯云数据仓库 TCHouse-C,默认集群名并不是 default,是 default_cluster。集群名对不上,ON CLUSTER 找不到目标,迁移全线失败。

怎么改:迁移脚本本来已经接受一个 CLICKHOUSE_CLUSTER_NAME 环境变量(用于连接串),但 SQL 文件里的 ON CLUSTER default 没跟着它走。我们的改造思路是——不动上游 SQL 源文件,而是在执行迁移前把它们「渲染」一遍:

  • clustered/*.sql 全部拷到一个临时目录;
  • sed 把里面的 ON CLUSTER default 替换成 ON CLUSTER ${CLICKHOUSE_CLUSTER_NAME}
  • 让迁移工具(golang-migrate)跑渲染后的临时目录;
  • 跑完用 trap 清理临时目录。
代码语言:javascript
复制
# up.sh / down.sh:执行迁移前渲染集群名(核心片段) RENDERED_DIR="$(mktemp -d)" trap 'rm -rf "$RENDERED_DIR"' EXIT for f in clickhouse/migrations/clustered/*.sql; do   sed "s|ON CLUSTER default|ON CLUSTER ${CLICKHOUSE_CLUSTER_NAME}|g" \     "$f" > "${RENDERED_DIR}/$(basename "$f")" done migrate -source "file://${RENDERED_DIR}" -database "$DATABASE_URL" up

为什么这么干而不是直接改 SQL:上游 SQL 是会随版本升级的,直接改源文件会和每次升级冲突。渲染方案做到了对上游零侵入——配置名还是 default 时整个替换是 no-op,行为完全不变;只有你显式设了别的集群名才生效。

3.2 坑二:时区对不上,trace 时间整体偏 8 小时

现象:trace 接进来后,界面上显示的时间统一比真实时间晚/早 8 小时,dashboard 的时间筛选、now() 比较全乱。

根因(这个最隐蔽):

一句话:Langfuse 全程假设「ClickHouse 说的是 UTC」,但 ClickHouse 的 DateTime不带时区,一旦服务器时区是 Asia/Shanghai,它就用本地时区去格式化这些「裸时间」,于是凭空多出 8 小时偏移。

拆开说:

  • Langfuse 写入用 Date.toISOString()(UTC),读出时给字符串补个 Z 当 UTC 解析——两端都按 UTC
  • 但 schema 里的 DateTime / DateTime64 列没有显式时区;
  • 托管 ClickHouse 的服务器时区是 Asia/Shanghai,于是它在读出这些裸时间时,按服务器本地时区格式化字符串;
  • 一个本该是 UTC 的时刻,被当成东八区时刻又补了个 Z,就偏了 8 小时。

踩坑过程(绕了一圈,最后回到配置)

一开始我们都在应用代码侧找补:试过在读取解析处按一个时区环境变量把裸时间反推回 UTC(用 Intl 做 DST 感知换算),也试过在 ClickHouse 客户端会话上加 session_timezone: "UTC"。这些都能修对,但本质上都是在迁就一个时区不对的服务器——逻辑绕、覆盖面零碎,还得当心边界。

最终发现:根因既然是「服务器时区不是 UTC」,那最干净的解法就是把服务器时区改成 UTC,而这跟 Langfuse 代码一点关系都没有。TCHouse-C 实例默认 <timezone>Asia/Shanghai</timezone>,在控制台的配置参数里把它改成 UTC 即可:

代码语言:javascript
复制
# ClickHouse 服务端配置(config.xml)—— 改这一行,重启生效 - <timezone>Asia/Shanghai</timezone> + <timezone>UTC</timezone>

改完 ClickHouse 自己就说 UTC 了,裸 DateTime 的格式化、now() 比较、分区全部回归 UTC,应用侧零改动、那些代码侧的找补也可以撤掉。

绕了一圈的教训:「整体偏 8 小时」这类问题,根因在 ClickHouse 服务端时区,就别在应用层到处补换算——直接把服务端 <timezone> 参数改成 UTC,一个配置项解决,还和代码彻底解耦。

四、对接公司内部账号系统:OIDC 是通用解

结论先行:不要去改 Langfuse 的登录代码。Langfuse 基于 NextAuth,原生支持「自定义 OIDC 身份提供方」。对接任何账号系统的正确姿势,是给它喂一个标准 OIDC Provider,而不是侵入它的认证逻辑。

4.1 Langfuse 留好的口子:自定义 OIDC Provider

Langfuse 内置了一个 CustomSSOProvider,只要配齐几个环境变量,它就会把你的身份系统当成一个标准 OIDC 登录入口:

代码语言:javascript
复制
AUTH_CUSTOM_NAME="公司账号"          # 登录按钮显示名 AUTH_CUSTOM_CLIENT_ID=...           # OIDC client AUTH_CUSTOM_CLIENT_SECRET=... AUTH_CUSTOM_ISSUER=https://your-idp/oidc   # 关键:指向你的 IdP AUTH_CUSTOM_SCOPE="openid email profile" AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=true     # 同 email 自动关联账号

AUTH_CUSTOM_ISSUER 指向哪儿,Langfuse 就会去拉那个地址的 /.well-known/openid-configuration,按标准 OIDC 流程走授权码、换 token、取 userinfo。所以问题就转化成:搭一个 IdP,对外说标准 OIDC。

4.2 难点不在 OIDC,在「内部网关说的不是 OIDC」

很多公司内网有统一的接入网关:用户在网关侧已经登录了,网关会把身份信息透传给后端——但用的是私有协议(比如一个加密的自定义 HTTP 头),而不是 OIDC。

于是我们做了一个 OIDC Bridge(桥接服务):它的唯一职责,就是把网关的私有身份协议,翻译成标准 OIDC。Langfuse 完全不知道网关的存在,它只看到一个规规矩矩的 OIDC IdP。

Bridge 对外暴露的就是一套标准 OIDC 端点:

端点

作用

桥接做的事

/.well-known/openid-configuration

OIDC 元数据发现

返回自己的端点清单

/jwks

验签公钥集

暴露 RS256 公钥

/authorize

授权

读网关透传的身份头 → 直接 302 回调,无登录页

/token

换 token

授权码 + PKCE 换发 id_token

/userinfo

取用户信息

反查并返回用户档案

整套对接的精髓是「无感登录」:用户在网关侧已经是登录态,Bridge 的 /authorize 直接拿网关透传的身份、跳过登录页 302 回去。用户点一下「公司账号登录」,唰地就进来了,不用再输一次密码。

实现上有几个要点值得说(不依赖任何具体账号系统):

  • 身份从哪来:网关把用户身份放在一个加密的请求头里透传给 Bridge,Bridge 解密拿到员工标识;
  • 怎么映射成 Langfuse 用户:把内部员工标识映射成 email(如 工号/登录名@公司域名),再靠 ALLOW_ACCOUNT_LINKING 让同 email 自动关联到 Langfuse 账号;
  • 签名与验签:Bridge 用 RS256 自签 id_token,并通过 /jwks 暴露公钥,Langfuse 按标准 OIDC 验签,全程不需要私有 SDK。

4.3 顺带一个权限坑:谁能创建组织

接进公司账号后,所有员工都能一键登录了,但你通常不希望人人都能创建顶级组织。Langfuse 把「创建组织」的能力归在企业版 entitlement 里,社区版不好单独控制。

我们的做法是加一个不依赖企业版的白名单开关:用环境变量 SSV_ALLOWED_ORG_CREATORS 配一组允许创建组织的 email,把判定收敛成单一数据源,API 层做 403 兜底、前端非白名单用户直接禁用入口。

代码语言:javascript
复制
# 留空 = 不限制;配了就只有名单内的人能创建组织 SSV_ALLOWED_ORG_CREATORS=alice@corp.com,bob@corp.com

一句话:账号系统负责「谁能进来」,白名单负责「进来后谁能开新组织」,两层分开,互不耦合。

五、踩坑速查:改了什么、为什么、怎么改

问题

现象

根因

解法

架构

compose 不可上生产

应用与存储同机、无 HA/备份

web/worker 拆镜像部署,存储全用云托管

集群名

迁移建表失败

SQL 写死 ON CLUSTER default

迁移前 sed 渲染成 ${CLICKHOUSE_CLUSTER_NAME}

时区

时间偏 8 小时

服务器非 UTC,裸 DateTime 被本地化

改 TCHouse-C 服务端 <timezone> 为 UTC

登录

要接内部账号

网关用私有协议非 OIDC

OIDC Bridge 翻译 + AUTH_CUSTOM_* 配置

权限

人人可建组织

能力绑在企业版

加 SSV_ALLOWED_ORG_CREATORS 白名单

结论:自托管的难点不在「跑起来」,在「扛得住」

把 Langfuse 跑起来只要一行 compose;把它做成生产可用,要做的是「应用与存储解耦、有状态组件托管化、对接好账号体系」,再把云厂商和上游默认假设之间的缝(集群名、时区)一个个填平。

三条经验留给后来者:

  • 应用层无状态、可弹性;存储层全托管、别自己扛——这是自托管能不能长期稳的分水岭。
  • 遇到「整体偏 8 小时」先查服务端时区,把 ClickHouse 服务端时区直接改成 UTC,别在应用层到处补换算——一个配置项的事。
  • 对接账号系统认准 OIDC——给它一个标准 IdP(必要时自建 Bridge 翻译私有协议),而不是去改它的登录代码。

延伸阅读

  • Langfuse 自托管文档:组件构成、环境变量、ClickHouse 集群配置
  • NextAuth 自定义 OIDC Provider(AUTH_CUSTOM_*)配置说明
  • ClickHouse 服务端 <timezone> 参数与 DateTime 列的时区语义

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-06-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 海天二路搬砖工 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 如何部署一套生产可用的 Langfuse —— 自托管踩坑纪实
    • 一、先说结论:docker-compose 是 demo,不是生产
    • 二、生产部署:web/worker 拆分 + 托管中间件
      • 2.1 web 和 worker,分别按镜像部署
      • 2.2 数据链路:理解了它,部署就不会瞎配
      • 2.3 把存储换成腾讯云托管服务
    • 三、ClickHouse 的两个坑:cluster 名 与 时区
      • 3.1 坑一:集群名默认不是 default,迁移脚本写死了
      • 3.2 坑二:时区对不上,trace 时间整体偏 8 小时
    • 四、对接公司内部账号系统:OIDC 是通用解
      • 4.1 Langfuse 留好的口子:自定义 OIDC Provider
      • 4.2 难点不在 OIDC,在「内部网关说的不是 OIDC」
      • 4.3 顺带一个权限坑:谁能创建组织
    • 五、踩坑速查:改了什么、为什么、怎么改
    • 结论:自托管的难点不在「跑起来」,在「扛得住」
    • 延伸阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档