본문 바로가기
Lesson Learned

Langfuse v3에서 v4로 올리기: 셀프호스팅 무중단 마이그레이션

by J's Note 2026. 9. 23.

 

최근 저희 Langfuse를 v3.x에서 v4.x으로 올렸습니다. 환경은 EKS에 ArgoCD automated sync, ClickHouse Cloud, ClickHouse Managed PostgreSQL, 그리고 차트에 번들된 Redis(valkey)입니다. 공식 업그레이드 가이드 [1]가 안 다루는 것들, 특히 Helm 차트 메이저 전환이랑 ArgoCD, ClickHouse Cloud에서 실제로 걸렸던 지점 위주로 정리했습니다. 구성이 저희랑 달라도 체크리스트랑 검증 쿼리는 그대로 쓰실 수 있을 거예요. 결국에는 직접 해보고 걸린 것들을 적은 글입니다.

요약

  • 서버 업그레이드를 다섯 단계(최신 v3, 차트 v2, v4 legacy, dual, backfill)로 쪼개고 단계마다 PR과 sync를 분리했습니다. 전체 3시간쯤 걸렸고 서비스 중단은 없었습니다.
  • v4 스키마 마이그레이션 자체는 첫 Pod 기동 60초 안에 끝났습니다. 시간을 잡아먹은 건 기존 설정에 잠복해 있던 오류 세 개였어요.
  • 가이드가 요구하는 background migration 0건 조건의 본질은 v4가 DROP하는 소스 테이블의 row count입니다. 직접 세어보셔야 해요.
  • ClickHouse Cloud는 가이드의 GRANT 목록에서 하나를 못 주고(SYSTEM MERGES), 하나는 더 넓게 줘야 합니다(system.mutations 테이블 전체 SELECT).
  • 차트 v1에서 v2로 갈 때 번들 Redis가 StatefulSet에서 Deployment로 바뀝니다. Recreate 전략이랑 큐 드레인이 필요합니다.

1. v4에서 뭐가 바뀌었고 왜 올려야 하나

v3는 트레이스랑 옵저베이션을 traces, observations 두 테이블에 따로 저장합니다. v4는 OpenTelemetry 모델대로 트레이스 루트까지 span으로 취급하고 events_full 한 테이블에 저장합니다. 이 단일 모델 위에 ngram 인덱스가 올라가니까 입력이랑 출력 본문을 풀텍스트 서치할 수 있게 됐고, Observations API v2랑 Metrics API v2도 이 테이블만 읽어서 빨라졌습니다. 모니터, 알림, 필터 서치바 같은 것들도 v4 전용이죠 [1].

 

올려야 하는 실질적인 이유는 좀 단순합니다. v3 계열에는 더 이상 기능이 안 붙어요. 대가는 컷오버 이후에 생깁니다. Python SDK v2 이하랑 JS SDK v3 이하는 인제스천에서 거부되고, traces, observations, scores, metrics의 v1 읽기 API는 404를 냅니다. trace 단위 LLM-as-a-judge 평가자랑 구 익스포트도 멈춰요. 그래서 v4는 legacy, dual, events_only 세 가지 write mode를 주고 컷오버 시점을 운영자가 고르게 해둔 것들입니다.

2. 시작 전에 확인할 것들

가이드의 전제조건에 저희 환경에서 추가로 확인한 항목을 더했습니다.

항목 기준 확인 방법
ClickHouse 버전 25.12 이상, 26.4 권장 SELECT version()
PostgreSQL / Redis 15 이상 / 7.0 이상 SELECT version(), INFO server
CH 스키마 버전 최신 v3 기준 37 SELECT version, dirty FROM schema_migrations ORDER BY sequence DESC LIMIT 1
background migration finished_at IS NULL 0건 아래 SQL
v4가 DROP하는 소스 테이블 전부 0건이거나 CH로 복사 완료 아래 SQL
ClickHouse GRANT 가이드 목록 + Cloud 보정 4절 참고
백업 PG dump, CH 백업 pg_dump -Fc, Cloud 자동 백업 확인
producer 인벤토리 SDK 종류와 버전 최신 v3부터 CH에 ingestion_sdk 컬럼이 생김
consumer 인벤토리 구 테이블을 직접 읽는 곳 Grafana, 사내 SQL 도구, deprecated API 사용처

 

background migration이랑 소스 테이블은 같이 보셔야 합니다. 저희 배포에는 초기 부트스트랩 때 ClickHouse 테이블이 아직 없어서 실패한 채로 멈춘 항목이 두 건 있었거든요. 근데 이 둘이 옮기려던 소스가 정확히 v4 첫 부팅에서 DROP되는 테이블이더라고요. v4의 PG 마이그레이션은 이 행들을 삭제하고 소스를 지웁니다. 그러니까 행 상태랑 무관하게 소스에 데이터가 남았는지 직접 세어야 하는 것들입니다.

-- PostgreSQL
SELECT name, failed_at, failed_reason FROM background_migrations WHERE finished_at IS NULL;
SELECT count(*) FROM dataset_run_items;

-- ClickHouse
SELECT 'event_log', count() FROM event_log
UNION ALL SELECT 'dataset_run_items', count() FROM dataset_run_items
UNION ALL SELECT 'project_environments', count() FROM project_environments;

최신 v3(3.224.1 이상)로 올리면 20260701_v4_step_1부터 5까지 다섯 행이 finished_at IS NULL로 나타납니다. 이건 envGate로 잠겨 있는 v4 backfill 체인이라 정상이고, Retry 버튼은 누르시면 안 돼요. UI에서 실패 항목을 Retry하려면 ADMIN_API_KEY 환경변수가 있어야 하고, 워커는 기동할 때만 큐를 훑으니까 Retry 뒤에 워커 Pod 하나를 재생성해야 실행됩니다. 결국에는 여기서 세어본 숫자가 이 마이그레이션의 안전장치였습니다.

3. 단계별로 어떻게 갔나

다섯 단계

원칙은 되돌리기 싼 변경부터 한 번에 한 가지였습니다. 예전에 프로젝트 몇 개에서 빅뱅으로 올려봤는데 문제가 터지면 어느 변경이 원인인지를 못 가르더라고요. 그래서 단계마다 머지 전에 helm template으로 렌더해서 현재 main 렌더랑 diff를 떴습니다. 뭐가 바뀌는지를 미리 알고 들어가는 것들이 중요합니다.

1단계. 최신 v3로

이미지 3.225.8, 차트 1.5.41이에요. 가이드의 전제조건인 latest v3를 맞추는 단계입니다. ClickHouse 스키마가 34에서 37로 올라가고, 최신 v3부터는 observations에 ingestion_sdk_name, ingestion_sdk_version 컬럼이 생겨서 producer 인벤토리가 됩니다.

langfuse:
  image:
    tag: 3.225.8
  additionalEnv:
    - name: ADMIN_API_KEY
      valueFrom:
        secretKeyRef:
          name: langfuse-backing
          key: admin-api-key
          optional: true

2단계. 차트 v2로, 앱은 v3 고정

차트 2.x는 Bitnami 서브차트를 전부 버립니다. PostgreSQL, ClickHouse, S3가 외부면 실제 교체는 번들 Redis 하나예요. 공식 Helm 문서에도 차트를 옮기는 동안 이미지 태그를 현재 v3에 고정하라고 적혀 있습니다 [2]. 앱 메이저랑 차트 메이저를 같은 창에서 안 겪으려는 겁니다.

redis:
  auth:
    existingSecret: langfuse-backing
    existingSecretPasswordKey: redis-password
    usersExistingSecret: langfuse-redis-acl   # 키 이름 default, 값은 redis-password와 동일
  deploymentStrategy: Recreate                # RWO PVC라 RollingUpdate면 Multi-Attach로 멈춤
  valkeyConfig: |
    maxmemory-policy noeviction
  dataStorage:
    keepPvc: true
clickhouse:
  cluster:
    enabled: true                             # v1의 clusterEnabled에서 이름이 바뀜

여기서 좀 걸리는 것들이 세 개 있습니다. existingSecret을 쓰면 usersExistingSecret은 기본 이름(langfuse-redis-auth)이 아닌 별도 Secret이어야 렌더가 통과해요. 차트가 자동 생성하는 Secret은 helm lookup 기반이라 ArgoCD의 helm template에서는 sync마다 비밀번호가 바뀌니까 쓰면 안 되고요. v1 차트가 만들던 NetworkPolicy는 v2에 없어서 extraManifests로 복원해야 합니다.

 

배포는 큐를 비운 다음에 했습니다. web을 0으로 내려서 인제스천을 멈추고, 구 valkey에서 bull::wait랑 bull::active 합이 0인 걸 확인하고 머지했습니다. delayed에 남는 것들은 인테그레이션의 반복 스케줄러라 워커가 다시 뜨면 재등록되니까 무시해도 됩니다. upstream 마이그레이션 스크립트도 같은 기준이더라고요 [3]. valkey랑 워커가 Ready 되면 web을 복구하고 고아가 된 구 PVC를 지웠습니다. 결국에는 큐만 비어 있으면 in-place로 되게 조용히 넘어가요.

3단계. v4 이미지, legacy 모드

langfuse:
  image:
    tag: 4.41.0
  additionalEnv:
    - name: LANGFUSE_MIGRATION_V4_WRITE_MODE
      value: legacy
    - name: LANGFUSE_MIGRATION_V4_NATIVE_OTEL_BEHAVIOUR
      value: dual_write
    - name: LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN
      value: 'false'
    - name: LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL
      value: 'false'

legacy는 v4 이미지가 v3처럼 동작하는 모드입니다. 첫 Pod가 PG 마이그레이션 14개랑 CH 마이그레이션 38부터 49까지를 적용하는데 저희는 60초 안에 끝났습니다. 레거시 테이블 일곱 개가 사라지고 events_full, events_core, observations_batch_staging이 생깁니다. 스키마 변경만 따로 검증하는 단계라서 문제가 나면 원인이 스키마 쪽이라는 걸 바로 알 수 있는 게 되게 좋아요.

 

HISTORIC_BACKFILL을 명시적으로 false로 두는 게 중요합니다. v4 기본값이 true고 backfill은 정확히 한 번만 도니까, dual write 시작 전에 돌아버리면 그 사이 데이터가 새 테이블에서 영영 빠집니다. 결국에는 이 플래그 한 줄이 legacy 단계의 전부입니다.

4단계. dual 모드

WRITE_MODE만 dual로 바꿉니다. 구 테이블이랑 신 테이블에 동시에 기록하고, UI는 PREVIEW_OPT_IN이 false인 동안 v3 그대로예요. 확인은 두 가지입니다. 전환 이후 들어온 observations 수랑 events_full의 span 수가 같은지, 그리고 워커의 전파 헬스입니다.

GET http://<worker>:3030/api/health?failIfEventPropagationStuck=true

dual write와 backfill

 

Python SDK 4.7 이상, JS SDK 5.4 이상, x-langfuse-ingestion-version: 4 헤더를 붙인 OTel은 events_full에 직접 기록됩니다. 그 이하 SDK는 observations_batch_staging을 거쳐서 15분쯤 뒤에 전파되고요. 저희는 staging이 계속 0이라 전파가 안 되는 줄 알았거든요. 아니죠, 저희 에이전트가 쓰는 Python SDK가 4.15.1이라 애초에 직접 기록 경로였던 겁니다. producer 인벤토리는 이 시점부터 events_full에서 바로 뽑을 수 있는 것들이에요.

SELECT project_id, ingestion_sdk_name, ingestion_sdk_version, count()
FROM events_full WHERE start_time > now() - INTERVAL 7 DAY GROUP BY 1, 2, 3;

5단계. preview 허용, backfill, 컷오버

dual이 정상이면 ALLOW_PREVIEW_OPT_IN을 true로, HISTORIC_BACKFILL을 true로 바꿉니다. 워커가 재기동하면서 v4_step_1부터 4까지 순서대로 돕니다. 끝나면 정합성을 확인하고, 컷오버는 전환 플래그 네 개를 제거하면서(= events_only, direct) LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES를 true로 둬서 backfill 스크래치 테이블을 정리합니다. 컷오버는 구 테이블을 직접 읽는 consumer를 전부 events_full로 옮긴 다음에 해야 해요.

4. 저희가 밟은 함정과 해결

Node 24 힙 상한으로 web Pod 크래시루프

증상은 FATAL ERROR: Reached heap limit, exit 134입니다. Node 24는 cgroup 1Gi에서 V8 힙 상한을 560MB쯤으로 잡는데, 최신 v3 web은 기동만으로 RSS 544-614Mi를 써요. web 메모리를 1Gi/2Gi로 올리고 NODE_OPTIONS에 --max-old-space-size-percentage=75를 주니까 힙 상한이 1632MB가 됐습니다. Langfuse 이미지 빌드 단계도 같은 플래그를 씁니다 [4]. 올리기 전에 기존 Pod에서 heap_size_limit을 재보면 미리 잡을 수 있는 것들이에요.

소형 spot 노드에서 probe 사망, HPA 교착

인과 체인

사실 이게 제일 문제였습니다. web 한 대만 exit 137을 반복하면서 롤아웃이 안 끝나는 거죠. Karpenter가 띄운 2 vCPU 노드에서는 기동이 60초를 넘겨서 차트 기본 liveness(20초 지연, 5초 타임아웃, 3회)에 죽어요. 재시작 때마다 CPU가 request 대비 900% 넘게 튀고, HPA가 그 버스트로 추천을 갱신해서 max에 고정되고, 불량 Pod가 maxUnavailable 1을 점유해서 구 Pod 교체가 멈춥니다. liveness를 60초/10초/6회, readiness를 20초/10초/6회로 풀면 해결됩니다.

ArgoCD selfHeal 환경에서 큐 드레인

root-app이 자기 자신을 관리하는 구조면 클러스터에서 auto-sync를 꺼도 되돌려집니다. 대신 web Deployment에 replicas 필드가 없으면 kubectl scale이 diff에 안 잡히고, replicas가 0이면 HPA도 개입을 안 해요. 실패한 sync 오퍼레이션이 재시도 중이면 새 리비전이 시작을 안 하니까 UI에서 Terminate하셔야 합니다.

ClickHouse Cloud의 GRANT

가이드의 GRANT는 default 데이터베이스 기준이니까 실제 DB 이름으로 바꾸세요. SYSTEM MERGES는 Cloud의 admin 계정도 못 주는데, Langfuse가 SharedMergeTree를 감지하면 ALTER TABLE MODIFY SETTING으로 우회하니까 없어도 됩니다. 반대로 워커가 clusterAllReplicas로 system.mutations를 읽어서 가이드의 컬럼 단위 SELECT로는 부족하고 테이블 전체 SELECT랑 SHOW COLUMNS가 있어야 돌더라고요.

GRANT DROP VIEW ON langfuse.* TO langfuse;
GRANT ALTER ADD INDEX, ALTER DROP INDEX, ALTER MATERIALIZE INDEX ON langfuse.* TO langfuse;
GRANT SELECT(database, table, name, partition, partition_id, active, rows) ON system.parts TO langfuse;
GRANT SELECT, SHOW COLUMNS ON system.mutations TO langfuse;
GRANT SELECT(database, name, engine) ON system.tables TO langfuse;
GRANT SELECT ON system.processes TO langfuse;
GRANT SELECT ON system.query_log* TO langfuse;
GRANT ALTER SETTINGS ON langfuse.observations_pid_tid_sorting TO langfuse;
GRANT READ ON REMOTE TO langfuse;
GRANT CLUSTER ON *.* TO langfuse;

backfill이 오래 걸리는 이유

query_log를 보니까 백필 쿼리 58개의 실행 시간 합은 8.9분(평균 9초, 최대 47초)이고, 나머지는 워커가 청크 50개마다 쿼리를 던지고 query_log를 폴링하는 페이스더라고요. 청크당 1분쯤이라 작은 데이터일수록 오버헤드 비중이 큽니다. 진행 상황은 background_migrations의 state 컬럼이랑 워커 로그의 Completed chunk N/M으로 볼 수 있어요.

5. 정합성 검증

-- 트레이스: 구 테이블과 events_full의 distinct trace 수가 같아야 함
SELECT (SELECT countDistinct(id) FROM traces FINAL WHERE is_deleted = 0),
       (SELECT countDistinct(trace_id) FROM events_full);

-- 옵저베이션: events_full에 없는 것이 0건이어야 함
SELECT count() FROM observations FINAL WHERE is_deleted = 0
  AND id NOT IN (SELECT span_id FROM events_full);

-- 프로젝트별 비교 (부모 없는 최상위 observation은 parent_span_id가 비어 있으므로 non-root 필터에 주의)
SELECT project_id, countDistinct(id) FROM observations FINAL WHERE is_deleted = 0 GROUP BY project_id;

저희 결과는 트레이스는 같고, 누락 옵저베이션 0건이었습니다.

6. 롤백

컷오버 전이면 v4 web 컨테이너 안에서 ClickHouse 스키마를 37로 되감은 다음에 이미지를 v3로 내립니다. 이미지만 바꾸면 안 돼요. PostgreSQL은 최신 v3가 v4 스키마에서 동작하니까 손대지 않습니다 [1].

cd /app/packages/shared
migrate -source file://clickhouse/migrations/clustered \
  -database "${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=langfuse&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree" \
  goto 37

 

ArgoCD selfHeal이 v4 desired state를 계속 재적용하니까, 롤백 전에 auto-sync를 멈출 수 있는 경로를 미리 확보해 두세요. 저희는 git에서 끄는 수밖에 없었습니다.

7. 컷오버 전에 남는 것들

  • 구 테이블을 직접 SQL로 읽는 도구를 events_full 기준으로 전환
  • 외부 producer의 SDK 버전 확인 (dual 상태에서 events_full의 ingestion_sdk 컬럼으로)
  • 컷오버 후 안정 기간을 두고 구 테이블 TRUNCATE 여부 결정 (되돌릴 수 없음)

다시 한다면 두 가지를 먼저 하겠습니다. 이미지 올리기 전에 기존 Pod의 힙 상한이랑 기동 시간을 재는 것, 그리고 렌더 결과에서 중복 env를 자동으로 잡는 검사를 두는 것입니다. 스키마 마이그레이션은 가이드대로 하면 됩니다. 결국에는 시간을 잡아먹는 건 언제나 그 주변에 있는 것들이더라고요. 이상입니다.

주석

[1] Langfuse, Upgrade Langfuse v3 to v4 (self-hosting guide): https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4

 

[2] Langfuse, Kubernetes (Helm) deployment guide. 차트 v1에서 v2로 옮기는 동안 langfuse.image.tag를 현재 v3 이미지에 고정하라는 안내: https://langfuse.com/self-hosting/deployment/kubernetes-helm

 

[3] langfuse-k8s examples/upgrade-v1-to-v2. 번들 Redis는 데이터 복사 없이 비운 채 재배포하며 wait, active, delayed 큐를 드레인 기준으로 삼는다: https://github.com/langfuse/langfuse-k8s/tree/main/examples/upgrade-v1-to-v2

 

[4] Langfuse web/Dockerfile (v3.225.8). 빌드 단계에서 NODE_OPTIONS에 max-old-space-size-percentage=75를 사용: https://github.com/langfuse/langfuse/blob/v3.225.8/web/Dockerfile