k3s에 zitadel 올려보기
2026.03.20 - [프로젝트] - OpenCSP 프로젝트 내용 정리
OpenCSP 프로젝트 내용 정리
진행 중인 오픈소스 프로젝트 OpenCSP의 깃허브 문서가 영어로만 있어서 한글로도 정리해보고 싶어졌다. https://github.com/h001-lab/OpenCSP-corehttps://github.com/h001-lab/OpenCSP-moduleshttps://github.com/h001-lab/OpenCSP-
miiml.tistory.com
보통 웹 서비스에서는 사용자를 직접 관리하는 경우가 많다.
회원가입, 로그인 기능을 직접 만들기도 하고 Google, Kakao 같은 기업들에서 Oauth provider를 제공해줘서 해당 기능을 사용하기도 한다.
서비스가 커지고 많아지면 여러 서비스에서 공통으로 사용하기 위해 인증 시스템을 추상화/ 고도화시켜 구현하게 될텐데, 그러다 보면 만들어지는게 IAM(Identity and Access Management) 이다.
큰 규모의 서비스에선 MFA, 외부 IdP 연동 지원, Audit, 멀티 테넌트(여러 고객들별로 대응), 사용자 라이프 사이클과 RBAC 등등의 기능과 여러 서비스에서 SSO까지 지원해야하는데 매번 같은걸 새로 만들 필요가 없다. (Keycloack을 많이 사용하는거 같음)
Keycloack이 여러 환경을 잘 지원한다고 하지만 무겁기 때문에 좀 더 경량 + 최신화된 Zitadel을 선택했다.
설치는 기존이랑 동일하게 k3s에 했고 구성하면서 삽질을 정말 많이 한거 같다..
1. zitadel 과 postgres 연동 시 버전 이슈
2. Cilium -> Zitadel 구성에서 gRPC 프로토콜 처리 관련 이슈
크게 2가지 인데 2번 해결이 좀 오래 걸렸다.
Postgresql 연동 문제
zitadel이 구성되는 순서는
PostgreSQL pod 생성 -> zitadel-init Job -> zitadel-setup Job -> zitadel 서버 pod 생성 인데
이 중에 init job이 진행이 안되고 있었다.
Helm upgrade failed for release zitadel/zitadel with chart zitadel@9.26.0:
pre-upgrade hooks failed: timeout waiting for: [Job/zitadel/zitadel-init status: 'InProgress']
로그로 확인해보니 job이 DB에 스키마를 구성하는 중에 계속 에러가 나고 있었고,
원인은 그냥 최신 버전(18.4.1)으로 집어넣은 bitnami postgresql 차트가 내부적으로 postgresql 18을 사용하고 있었고, 해당 버전에서는 CREATE UNLOGGED TABLE ... PARTITION BY ... 구문이 금지되었다고 한다. 근데 Zitadel이 마이그레이션하면서 해당 구문을 사용해서 Job이 계속 실패 중이었던 것
https://github.com/zitadel/zitadel/issues/10712 에 해당 에러가 등록 되어 있고 아직 수정이 안됐다.
해결 방법은 이전 버전인 postgresql 16을 사용하면 되는데,
bitnami가 구 버전 이미지를 다른 저장소(bitnamilegacy)로 옮겼다고 한다.
난 zitadel-postgresql의 HelmRelease를 별도로 작성해뒀기 때문에 여기서 이미지를 bitnamilegacy/postgresql 로 덮어써줬다.
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: zitadel-postgresql
namespace: zitadel
spec:
...
chart:
spec:
chart: postgresql
version: "18.4.1"
sourceRef:
kind: HelmRepository
name: bitnami
namespace: flux-system
valuesFrom:
- kind: Secret
name: zitadel-secrets
valuesKey: postgresql-values.yaml
values:
image:
repository: bitnamilegacy/postgresql
metrics:
image:
repository: bitnamilegacy/postgres-exporter
volumePermissions:
enabled: true
image:
repository: bitnamilegacy/os-shell
...
Cilium -> Zitadel 구성에서 gRPC 프로토콜 처리 관련 이슈


DB 연동도 됐고, 도메인으로 login 페이지 접근 및 유저 등록도 잘된다.
근데 등록하고 나면 아래처럼 not found 가 나오고


원래 도메인으로 가면 콘솔 경로로는 이동되지만 (브라우저에 인증 정보는 남아있는 거 같다)
missing trailer 라는 에러 문구가 나오고 아래처럼 gRPC 관련 요청들이 404로 나온다.

http는 처리가 잘 돼고, grpc는 일부만 안되는 이상한 상황..
이런건 보통 리버스 프록시처럼 중간에 처리해주는 애가 문제였던 거 같다.
참고로 내 서버는 k3s 앞에 cloudflare tunnel과 haproxy가 있고, CNI로 Cilium을 사용하고 있음
AI, 구글에서 좀 찾아보고 테스트 해보면서 알게된 건 Cilium Envoy가 envoy.filters.http.grpc_web 필터를 기본으로 추가하고, 이 필터가 gRPC-web 요청을 native gRPC로 변환한다고 한다.
그리고 내 경우랑 같은 Cilium 이슈가 ArgoCD에서 발생했던 걸 정리해둔 글을 찾았다.
https://sofianedjerbi.com/en/blog/argocd-cli-cilium-gateway-grpc/
위에 링크에서 해결한 방법은 cilium gateway를 활용한 TLS passthrough인데
내 경우엔 ingress 기반이고 하나의 도메인에 2개의 서비스 (zitadel + zitadel-login)가 /ui/v2/login 같은 경로기반 라우팅하고 있어서 적용이 좀 어려울 거 같다. (그리고 이것도 테스트 해보긴 했는데 좀 별로인 듯)
근데 결국 cilium 필터에서 프로토콜을 강제로 변환하는게 문제인거 같으니까 해당 필터를 제거해보기로 함.
혹시 모르니까 백업 먼저해주고
kubectl get ciliumenvoyconfigs cilium-ingress -n kube-system -o yaml > /tmp/cec-backup.yaml
오퍼레이터가 계속 파일 수정하고 있으니까
kubectl scale deploy cilium-operator -n kube-system --replicas=0
로 잠깐 멈춰주고
이제 edit으로 파일 열어서 zitadel 도메인으로 먼저 찾고, grpc_web, grpc_stats 를 제거해줌
kubectl edit ciliumenvoyconfigs cilium-ingress -n kube-system
근데 transportProtocol: raw_buffer 말고 tls 체인에서 제거해줘야 된다.
- filterChainMatch:
serverNames:
- zitadel.domain.com
transportProtocol: tls
filters:
- name: envoy.filters.network.http_connection_manager
typedConfig:
'@type': type.googleapis.com/envoy.extensions.filters.network.http_con
commonHttpProtocolOptions:
maxStreamDuration: 0s
httpFilters:
- name: envoy.filters.http.grpc_web # 이거랑
typedConfig:
'@type': type.googleapis.com/envoy.extensions.filters.http.grpc_we
- name: envoy.filters.http.grpc_stats # 이거
typedConfig:
'@type': type.googleapis.com/envoy.extensions.filters.http.grpc_st
emitFilterState: true
enableUpstreamStats: true
- name: envoy.filters.http.router
typedConfig:
...
제거하고 확인해보면 잘 지워졌고, 도메인으로 v1 엔드포인트에 요청 보내봤더니 200 잘 나온다
kubectl get ciliumenvoyconfigs cilium-ingress -n kube-system -o yaml | grep -B2 -A10 "zitadel.domain.com" | grep -A5 "httpFilters"
httpFilters:
- name: envoy.filters.http.router
typedConfig:
--
httpsRedirect: true
- domains:
ubuntu@ops-k3s:~$ curl -s -o /dev/null -w "v1: %{http_code}\n" -X POST \
https://zitadel.domain.com/zitadel.auth.v1.AuthService/ListMyProjectOrgs \
-H "Content-Type: application/grpc-web+proto" \
--resolve zitadel.domain.com:443:10.46.63.51 \
--insecure
v1: 200
확인 끝났으니까 다시 실행해줘야됨.
kubectl scale deploy cilium-operator -n kube-system --replicas=1
이걸로 cilium filter가 계속 덮어씌워서 zitadel이 grpc-web을 인식할 수 없는 문제라는게 명확해졌다.
문제 원인 정리
Cilium의 grpc_web 필터 :
- Envoy의 grpc_web 필터는 원래 브라우저(HTTP/1.1)에서 오는 gRPC-web 요청을 native gRPC(HTTP/2)로 변환해서 백엔드에 전달하는 역할로 백엔드가 순수 gRPC 서버라면 유용하지만 문제는 Cilium이 이 필터를 모든 Ingress/Gateway에 무조건 넣는다는 점.
- Zitadel처럼 자체적으로 gRPC-web을 직접 처리하는 서버에서는 이중 변환이 일어나서 깨짐
- 이건 Cilium의 알려진 버그로 https://github.com/cilium/cilium/issues/31933에 이슈 등록되어 있고, 필터를 선택적으로 비활성화할 방법이 아직 없다..
Zitadel의 v1/v2 혼용 :
- Zitadel 콘솔 UI가 아직 v2로 완전히 마이그레이션되지 않았다.
- v2 API는 ConnectRPC 기반으로 새로 만들어지고 있는데, 모든 기능이 아직 v2에 없어서 콘솔이 일부는 v2(GetUserByID), 일부는 v1(ListMyProjectOrgs, GetMyUser 등)을 호출한다.
- v1은 전통적인 grpc-go 서버로 gRPC-web을 직접 처리하고, v2는 ConnectRPC라 HTTP/1.1도 가능한데 Cilium의 필터가 v1만 변환 시켰기 때문에 일부는 200, 일부는 404 나오는 현상 발생
- v5에서 v1이 deprecated되고 v6에서 제거될 예정이니 장기적으로는 해결될 듯
근데 이렇게 매번 직접 수정할 수도 없고(어차피 operator가 복구해버리고), 코드로 만들어진 클러스터를 직접 수정하면 관리측면에서도 문제가 있으니까 yaml로 작성해서 flux가 관리할 수 있게 해줘야된다.
이걸 만족하면서 문제를 해결해 줄 수 있는 방법이 대략 아래 3가지 인데
- Zitadel은 shared Ingress에서 빼고 별도의 (Custom) CiliumEnvoyConfig로 관리하기
- Cilium Envoy가 여러개 Config 가져올 수 있을 지 테스트 해야될거 같고, 오픈소스 커스텀하면 나중에 업데이트 때마다 수정해야 될수도
- Zitadel만 NodePort + Cloudflare Tunnel 직접 연결
- 이건 기존에 만들어둔 네트워크 경로가 아니라 다른 우회로를 만드는 거라 보안적으로도 별로일거 같고, 관리 포인트가 늘어나서 맘에 안든다
- Cilium Issue #31933 처럼 대응 - TLS Passthrough
- cilium gateway에 아직 몇가지 버그가 있고, 지금 구성에서 ingress와 같이 사용해야하는 게 별로인거 같다
3가지 다 마음엔 안들지만..
zitadel은 nodeport로 직접 노출(cilium 안거치고)하고, zitadel-login은 기존처럼 cilium ingress가 처리하는 방식으로 그나마 2번이랑 비슷하게 해결했다.
HelmRelease (release.yaml) 에 아래 내용을 추가해줬고
values:
zitadel:
...
service:
type: NodePort
protocol: grpc
appProtocol: kubernetes.io/h2c
기존 ingress.yaml에 / 에 대한 건 주석 처리해줬음
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: zitadel-ingress
namespace: zitadel
annotations:
cert-manager.io/cluster-issuer: "selfsigned-issuer"
spec:
ingressClassName: cilium
tls:
- hosts:
- zitadel.domain.com
secretName: zitadel-tls
rules:
- host: zitadel.domain.com
http:
paths:
- path: /ui/v2/login
pathType: Prefix
backend:
service:
name: zitadel-login
port:
number: 3000
# Since not all of zitadel's grpc endpoints are under /v2, we expose zitadel via NodePort and use HTTP for the ingress.
# cf. https://sofianedjerbi.com/en/blog/argocd-cli-cilium-gateway-grpc/
# - path: /
# pathType: Prefix
# backend:
# service:
# name: zitadel
# port:
# number: 8080
그리고 앞단에 Cloudflare tunnel 의 Config를 아래처럼 수정해줬다.
- hostname: 'zitadel.domain.com'
path: '/ui/v2/login'
service: https://localhost:443 # 얘가 haproxy임
originRequest:
noTLSVerify: true
http2Origin: true
originServerName: zitadel.domain.com
- hostname: 'zitadel.domain.com'
service: http://{K3S_NODE_IP}:{NODE_PORT}
originRequest:
http2Origin: true
originServerName: zitadel.domain.com
해결 완료

