
소셜 미디어 여러 채널에 글을 일일이 올리는 건 솔직히 귀찮다. LinkedIn, X, YouTube까지 관리하려면 손이 세 개라도 모자란다. Postiz는 이런 문제를 풀어주는 오픈소스 소셜 미디어 자동화 도구인데, 이번에 홈랩 K8s 클러스터에 올리면서 꽤 고생했다.
결론부터 말하면 커밋 13개가 필요했다. Helm subchart 서비스 이름이 틀리고, Temporal이 필수 의존성이 되었는데 문서에 안 나와 있고, ConfigMap 마운트 경로가 entrypoint를 덮어쓰고, Google OAuth가 내부적으로 YouTube 환경변수를 공유하고... 하나 고치면 다음 문제가 터지는 연쇄 삽질이었다.
먼저 완성된 구성을 보자. ArgoCD multi-source Application으로 OCI Helm 차트, Git values, Git manifests 세 소스를 조합했다.
핵심 구성 요소는 이렇다.
Postiz는 공식 Helm 차트를 OCI(ghcr.io)로 배포한다. 여기에 우리 환경에 맞는 values를 Git에서 override하고, Helm이 관리하지 않는 Temporal 매니페스트까지 같은 Application으로 묶어야 했다. ArgoCD의 multi-source 기능이 딱 맞는 상황이었다.
apiVersion: argoproj.io/v1alpha1
kind: Application
spec:
sources:
# Source 1: OCI Helm 차트
- repoURL: ghcr.io/gitroomhq/postiz-helmchart/charts/postiz-app
chart: postiz-app
targetRevision: 1.0.5
helm:
valueFiles:
- $values/k8s-company-internal/services/postiz/postiz-values.yaml
# Source 2: Git values 참조
- repoURL: https://gitea.xssh.org/homelab/homelab.git
targetRevision: master
ref: values
# Source 3: Temporal 등 추가 매니페스트
- repoURL: https://gitea.xssh.org/homelab/homelab.git
targetRevision: master
path: k8s-company-internal/services/postiz/manifests$values는 Source 2를 가리키는 참조 변수다. Source 1의 Helm 차트가 설치될 때 Source 2의 values 파일을 끌어다 쓴다. Source 3은 Helm 차트 밖의 매니페스트(Temporal Deployment, ConfigMap 등)를 배포한다.
첫 배포에서 바로 터진 문제다. Postiz의 DATABASE_URL과 REDIS_URL이 연결 실패를 뿜었다.
Helm subchart에서 Bitnami PostgreSQL/Redis를 띄우면 서비스 이름이 직관과 다르게 생성된다. fullnameOverride를 지정하지 않으면 릴리스 이름 기반으로 postiz-postgresql, postiz-redis-master가 된다. 처음에 postgresql이나 redis로 접근하려다 계속 실패했다.
# 잘못된 설정
DATABASE_URL: postgresql://user:pass@postgresql:5432/postiz
# 올바른 설정 (릴리스명 기반)
DATABASE_URL: postgresql://user:pass@postiz-postgresql:5432/postiz
REDIS_URL: redis://postiz-redis-master:6379거기에 Bitnami는 특정 이미지 태그를 주기적으로 삭제한다. 15.x.x 같은 버전을 values에 고정해두면 어느 날 갑자기 ImagePullBackOff가 뜬다. 결국 latest를 쓰는 게 가장 안정적이었다. 프로덕션에서는 좀 꺼림칙하지만 홈랩이니까 괜찮다.
Bitnami 문제를 잡고 나니 백엔드가 기동하다가 죽었다. 로그를 보니 ECONNREFUSED :7233이 찍혀 있었다. 포트 7233은 Temporal 서버 포트다.
Postiz v2.12.0부터 orchestrator가 Temporal 워크플로우 엔진을 사용하는데, 문서에는 "optional"이라고 적혀 있었다. 실제로는 백엔드 기동 시 Temporal 연결을 시도하고 실패하면 크래시한다. 소스 코드를 직접 까봐서야 확인했다.
교훈: 오픈소스 프로젝트의 공식 문서보다 소스 코드의 환경변수 사용법을 직접 확인하는 게 더 정확하다.
Temporal을 별도 Deployment로 배포했다. auto-setup 이미지를 쓰면 스키마 생성까지 자동으로 해준다. Temporal용 PostgreSQL은 emptyDir 볼륨으로 잡았는데, Postiz가 자체 DB에 스케줄 상태를 관리하므로 Temporal DB가 날아가도 괜찮기 때문이다.
Temporal이 뜨긴 하는데 search attribute 타입 제한에 걸렸다. PostgreSQL visibility store는 타입당 search attribute를 3개까지만 허용한다. dynamic config로 이 제한을 풀어야 했다.
apiVersion: v1
kind: ConfigMap
metadata:
name: temporal-dynamic-config
data:
dynamic_config.yaml: |
limit.searchAttributesNumberOfKeysPerRequest:
- value: 100
limit.searchAttributesTotalSizePerRequest:
- value: 262144이 ConfigMap을 처음에 /etc/temporal에 마운트했다. 컨테이너가 아예 시작을 안 했다. Temporal의 auto-setup 이미지는 /etc/temporal/entrypoint.sh를 실행하는데, ConfigMap 마운트가 이 디렉토리를 통째로 덮어써버린 거다.
마운트 경로를 /etc/temporal-dynamic/으로 바꾸고 환경변수에서 이 경로를 참조하게 수정하니 정상 동작했다. 사소한 경로 하나가 컨테이너를 완전히 먹통으로 만든 셈이다.
Temporal까지 잡고 나니 이번에는 orchestrator가 OOMKilled로 죽었다. 처음에 1Gi 제한을 줬는데, orchestrator는 시작할 때 플랫폼별 workflow bundle을 webpack으로 컴파일한다. 이 과정에서 피크 메모리가 치솟는다.
1Gi에서 OOM. 2Gi로 올려도 OOM. 결국 4Gi까지 올려서야 안정화됐다. 정상 운영 중에는 1Gi도 안 쓰는데 초기 빌드 때문에 4Gi가 필요한 구조다. 홈랩에서 메모리 32GB짜리 노드를 쓰고 있으니 감당 가능하지만 좀 아깝긴 하다.
기능이 다 돌아가니까 마지막으로 Google 계정 연동을 설정했다. 여기서 또 삽질 3라운드가 벌어졌다.
YOUTUBE_CLIENT_ID를 사용. YouTube 연동과 Google 인증이 같은 credential을 공유하는 구조YOUTUBE_CLIENT_ID와 YOUTUBE_CLIENT_SECRET을 설정하니 Google OAuth가 바로 동작했다. 환경변수 이름만 봐서는 절대 유추할 수 없는 구조였다.
원래 museck.com 도메인의 서브도메인으로 배포하려 했는데, 생각해보니 소셜 미디어 관리 도구는 외부에 노출할 이유가 없다. Cloudflare Tunnel을 타는 museck.com 대신 내부망에서만 접근 가능한 xssh.org 도메인으로 전환했다. Traefik IngressRoute만 수정하면 되니 간단했다.
커밋 13개에 걸친 삽질을 돌아보면 결국 문제는 전부 "문서에 안 써있는 것"이었다.
복잡한 오픈소스를 셀프 호스팅할 때는 README나 문서를 믿기보다 소스 코드를 직접 읽는 게 결국 가장 빠른 길이다. 특히 Helm subchart가 여러 겹 쌓여있고 외부 의존성(Temporal)까지 끼어들면 "사소한" 설정(서비스 이름, 마운트 경로, 환경변수 이름) 하나가 연쇄 장애를 만든다.
한 가지 위안이라면 커밋 하나하나가 각각 독립적인 교훈이라는 점이다. 이 글이 Postiz를 K8s에 올리려는 누군가에게 삽질 시간을 줄여줬으면 한다.