Superset을 올리려다 인프라부터 다시 짰다 — RPi K3s에서 PostgreSQL·Redis 공유 설계

TL;DR

  • Superset을 K3s에 올리려다 RPi 리소스 한계로 앱별 인프라 분리 전략이 불가능해짐
  • PostgreSQL은 앱별 DB/유저, Redis는 DB 번호로 논리적 격리하는 공유 구조로 전환
  • 로컬 개발도 dev-infra Docker Compose + external network 패턴으로 통일
  • cross-app Celery send_task를 위해 broker는 공유, cache는 분리
  • 삽질: 고아 Redis Pod, Redis 비밀번호가 ConfigMap에 노출될 뻔한 사고, Trivy arm64 스캔 실패

들어가며

홈랩 K3s 클러스터(RPi 4대 + x86 master)에서 Django + Celery 기반 앱을 여러 개 운영하고 있다. Airflow로 ETL 파이프라인을 돌리고, videokeeper로 영상을 아카이브하고, inboxpilot으로 URL/메모를 수집하는 중이었다.

여기에 수집한 데이터를 시각화할 BI 도구가 필요해서 Apache Superset을 같은 클러스터에 올리기로 했다. 그런데 Superset도 PostgreSQL + Redis + Celery가 필요하다. 앱마다 따로 띄우면 RPi 4대의 메모리와 CPU가 금방 바닥난다.

역할필요 인프라
videokeeper영상 다운로드/관리Django + Celery + Redis + PostgreSQL
inboxpilotURL/메모 수집/분류Django + Celery + Redis + PostgreSQL
supersetBI 대시보드Superset + Celery + Redis + PostgreSQL

세 앱이 Redis를 각각 띄우면 그것만 메모리 384Mi(128Mi × 3). RPi 4 (4GB)에서는 적지 않은 양이다. Superset 추가를 계기로, 인프라 리소스는 공유하되 앱 간 격리는 유지하는 구조를 설계하게 됐다.


설계 원칙: 하나의 인프라, 논리적 격리

클라우드였으면 RDS나 ElastiCache를 앱마다 따로 만들겠지만, RPi 4대로는 그런 여유가 없다. 대신 논리적 격리를 확실히 해서 앱 간 간섭을 막는다.

  • PostgreSQL: 1대 서버, 앱별 DB + 유저
  • Redis: 1개 인스턴스, 용도별 DB 번호

PostgreSQL: 클러스터 밖, 앱별 DB/유저

PostgreSQL 서버는 클러스터 외부의 전용 머신(192.168.50.99)에서 돌린다. K8s 안에 StatefulSet으로 넣지 않은 이유는 단순하다 — 데이터베이스는 노드 장애에 취약한 RPi 위에 두고 싶지 않았다.

PostgreSQL 16 (192.168.50.99)
├── DB: airflow     / User: airflow
├── DB: videokeeper / User: videokeeper
├── DB: inboxpilot  / User: inboxpilot
└── DB: superset    / User: superset

각 앱은 자기 DB에만 접근할 수 있다. 비밀번호는 Ansible Vault에 저장하고, K8s Secret으로 Pod에 주입한다.

자동화

DB/유저 생성은 Ansible 플레이북으로 관리한다.

just inboxpilot-db   # ansible-playbook playbooks/postgresql/create-inboxpilot-db.yml

플레이북은 Vault에서 비밀번호를 읽어 PostgreSQL에 DB/유저를 생성한다. 멱등성이 보장되어 이미 존재하면 skip된다.

백업

pg_dump로 NAS에 자동 백업한다.

  • 일별: 7일 보관
  • 주별: 4주 보관
  • 경로: NAS /volume1/backup/postgresql/

모든 앱의 DB가 한 서버에 있으니 백업도 한 곳에서 관리할 수 있다.


Redis: DB 번호로 용도 분리

Redis는 기본 16개의 논리적 DB(0~15)를 제공한다. 처음에는 앱마다 DB 하나씩 배정하는 단순한 구조였다. 그런데 두 앱(inboxpilot과 videokeeper) 사이에 Celery send_task로 cross-app 메시징이 필요해지면서 설계를 다시 했다.

최종 DB 할당표

DB용도비고
0loopback
1공유 Celery brokerinboxpilot + videokeeper 공유
2inboxpilot cacheDjango CACHES backend
3videokeeper cacheDjango CACHES backend
4supersetcache + celery

핵심 결정: broker는 공유(DB 1), cache는 앱별 분리(DB 2, 3).

broker를 공유하는 이유는 cross-app send_task가 동작하려면 양쪽 worker가 같은 broker를 바라봐야 하기 때문이다. cache는 앱 간 키 충돌 가능성이 있으므로 분리한다. Superset은 자체 Celery worker(비동기 쿼리, 리포트 스케줄)를 돌리지만 다른 앱과 cross-app 메시징이 필요 없어서 별도 DB(4번)에 broker와 cache를 함께 넣었다.

cross-app Celery 메시징

inboxpilot에서 videokeeper의 task를 호출하는 구조:

from celery import current_app
current_app.send_task(
    "videos.tasks.download_video",
    args=[url],
    queue="videokeeper",
)

이게 동작하려면:

  1. 양쪽 CELERY_BROKER_URL이 같은 Redis + 같은 DB(1번)를 가리켜야 한다
  2. videokeeper worker가 videokeeper 큐를 consume해야 한다
  3. inboxpilot worker는 기본 큐만 consume (videokeeper task를 실행하면 안 된다)
# videokeeper worker-deployment.yml
command: ["celery", "-A", "config", "worker", "-Q", "videokeeper", "-l", "info"]

로컬 개발: dev-infra Docker Compose

K8s 환경의 공유 구조를 로컬에서도 재현해야 한다. 여러 프로젝트의 docker-compose.yaml이 각자 PostgreSQL/Redis를 띄우면 포트 충돌(5432, 6379)이 발생한다.

해결: dev-infra라는 공유 인프라 Compose를 먼저 띄우고, 각 프로젝트는 external network로 연결한다.

# dev-infra/docker-compose.yaml (공유 인프라)
services:
  db:
    image: postgres:16
    ports: ["5432:5432"]
  redis:
    image: redis:7
    ports: ["6379:6379"]
# 각 프로젝트의 docker-compose.yaml
services:
  db-init:
    image: postgres:16
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        until pg_isready -h db -U postgres; do sleep 1; done
        psql -h db -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'superset'" \
          | grep -q 1 || psql -h db -U postgres -c "CREATE DATABASE superset"
    networks:
      - dev-infra

  superset-server:
    # ...
    networks:
      - dev-infra

networks:
  dev-infra:
    external: true
    name: dev-infra_default

dev-infra를 먼저 docker compose up -d로 띄우면, 이후 어떤 프로젝트를 올리든 같은 PostgreSQL/Redis에 연결된다. K8s 환경과 동일한 “하나의 인프라, 논리적 격리” 패턴이 로컬에서도 유지된다.


Superset on RPi: arm64 빌드와 리소스 제한

Superset을 RPi 클러스터에 올리면서 몇 가지 추가 고려사항이 있었다.

arm64 단일 빌드

master에 NoSchedule taint이 걸려 있어서 앱 Pod는 전부 arm64 worker에 스케줄된다. 멀티아키(amd64+arm64) 빌드는 QEMU 에뮬레이션 때문에 느리고 불필요해서 arm64만 빌드한다.

RPi에서의 리소스 제한

RPi 4 (4~8GB RAM)에서 Superset을 돌리므로 리소스 제한을 보수적으로 설정한다.

supersetNode:
  resources:
    requests: { cpu: "250m", memory: "512Mi" }
    limits:   { cpu: "1000m", memory: "2Gi" }
  startupProbe:
    failureThreshold: 60  # ARM에서 기동 느림, 최대 10분 대기

Helm configOverrides로 설정 주입

Superset Helm chart는 configOverrides로 Python 코드를 superset_config.py에 직접 주입할 수 있다. 민감 정보는 extraSecretEnv + os.environ.get() 패턴으로 분리.

extraSecretEnv:
  SUPERSET_SECRET_KEY: "OVERRIDDEN_BY_DEPLOY_COMMAND"

configOverrides:
  secret: |
    import os
    SECRET_KEY = os.environ.get("SUPERSET_SECRET_KEY")

Django settings에서 Redis 연결

앱마다 조금 다른 방식을 썼다. 앱이 만들어진 순서에 따른 차이이지, 의도된 설계는 아니다.

videokeeper — 컴포넌트 방식 (기존)

_redis_base = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}"
CELERY_BROKER_URL = f"{_redis_base}/1"
CACHES = {"default": {"LOCATION": f"{_redis_base}/3"}}

환경변수로 HOST, PORT, PASSWORD를 받고, DB 번호는 settings에 하드코딩한다.

inboxpilot — URL 방식 (신규)

CELERY_BROKER_URL = os.environ["CELERY_BROKER_URL"]
CELERY_RESULT_BACKEND = os.environ["CELERY_RESULT_BACKEND"]
CACHES = {"default": {"LOCATION": os.environ["REDIS_CACHE_URL"]}}

전체 URL을 환경변수로 받는다. Celery/django-redis 표준 패턴이고, DB 번호 변경이 앱 코드 수정 없이 가능하다.


삽질 기록

고아 Redis Pod — 매니페스트만 바꾸고 정리를 안 한 경우

videokeeper는 원래 자체 namespace에 전용 Redis를 가지고 있었다. 공유 Redis로 전환할 때 ConfigMap의 REDIS_HOSTredis.redis.svc.cluster.local로 바꿨는데, 전용 Redis의 Deployment와 Service를 삭제하지 않았다.

videokeeper namespace
├── web       → redis.redis.svc (공유 Redis)
├── worker    → redis.redis.svc (공유 Redis)
└── redis     ← 아무도 안 쓰는데 계속 돌고 있음 (고아)

RPi에서 Redis Pod 하나가 메모리 128Mi + CPU 200m을 먹고 있었다. 작은 양이지만, 홈랩에서는 이런 고아 리소스가 쌓이면 체감된다.

교훈: 설정 변경과 리소스 정리는 한 커밋에. ConfigMap 수정 + 레거시 매니페스트 삭제 + 클러스터 리소스 삭제를 세트로 처리해야 한다.

Redis 비밀번호가 ConfigMap에 노출될 뻔한 경우

inboxpilot의 Redis 설정을 URL 방식으로 전환하면서, 처음에는 CELERY_BROKER_URL을 ConfigMap에 넣으려 했다.

# configmap.yml — 이렇게 하면 안 된다
data:
  CELERY_BROKER_URL: "redis://:[email protected]:6379/1"

공유 Redis에 requirepass가 설정되어 있어서 URL에 비밀번호가 들어간다. ConfigMap은 kubectl get configmap -o yaml로 누구나 볼 수 있으므로, 비밀번호가 평문으로 노출된다.

해결: URL 전체를 Secret에 저장.

# secret.yml
stringData:
  CELERY_BROKER_URL: "redis://:[email protected]:6379/1"
  CELERY_RESULT_BACKEND: "redis://:[email protected]:6379/1"
  REDIS_CACHE_URL: "redis://:[email protected]:6379/2"

교훈: 비밀번호가 포함된 값은 무조건 Secret. URL 안에 숨어 있는 비밀번호를 놓치기 쉽다.

Trivy arm64 스캔 실패

arm64 단일 빌드 이미지를 Trivy로 스캔하면 실패한다.

no child with platform linux/amd64

Trivy가 기본적으로 linux/amd64 이미지를 찾으려 하기 때문이다. TRIVY_PLATFORM: linux/arm64 환경변수로 해결.

Helm pending-install 상태

Superset 첫 배포가 실패한 뒤 재시도하면 another operation (install/upgrade/rollback) is in progress 에러가 뜬다. 이전 실패한 Helm 작업이 pending-install 상태로 남아 있기 때문이다. helm uninstall 후 재배포로 해결.

Django settings 모듈 불일치

inboxpilot 매니페스트를 videokeeper에서 복사하면서 DJANGO_SETTINGS_MODULE: "config.settings.production"을 그대로 가져왔는데, inboxpilot은 config/settings.py 단일 파일 구조였다.

ModuleNotFoundError: No module named 'config.settings.production';
'config.settings' is not a package

교훈: 매니페스트 복사 시 앱별로 다른 부분을 반드시 확인. 특히 DJANGO_SETTINGS_MODULE, 포트 번호, healthcheck 경로.


최종 구조

┌─────────────────────────────────────────────────┐
│  K3s 클러스터                                    │
│                                                 │
│  master (amd64, NoSchedule)                     │
│    └─ 컨트롤 플레인 + self-hosted runner         │
│                                                 │
│  node-1~4 (arm64, RPi)                          │
│    ├─ Airflow (scheduler, api-server, triggerer) │
│    ├─ Superset (server, worker, beat)            │
│    ├─ videokeeper (web, worker)                  │
│    ├─ inboxpilot (web, worker)                   │
│    └─ Redis (공유, DB별 격리)                     │
│                                                 │
│  외부                                            │
│    ├─ PostgreSQL 16 (앱별 DB/유저 분리)            │
│    └─ Cloudflare Tunnel (*.jaypy.dev)            │
└─────────────────────────────────────────────────┘

새 앱 추가 시 체크리스트

이 구조에서 새 Django 앱을 추가할 때의 순서:

  1. PostgreSQL: Vault에 비밀번호 추가 → just <app>-db 실행
  2. Redis: 다음 빈 DB 번호 할당, 할당표 업데이트
  3. K8s namespace: 생성
  4. Secret: DB 비밀번호 + Redis URL(비밀번호 포함) + Django SECRET_KEY + API 키
  5. ConfigMap: DJANGO_SETTINGS_MODULE, ALLOWED_HOSTS, DB 접속 정보 (비밀 아닌 것만)
  6. GHCR pull secret: 기존 앱에서 복사 (같은 PAT 재사용)
  7. 매니페스트 apply: namespace → configmap → secret → deployment → service
  8. Cloudflare Tunnel: Public Hostname 추가
  9. dev-infra: 로컬 docker-compose에 db-init + external network 추가

정리

항목설계이유
PostgreSQL클러스터 외부 전용 머신RPi 장애로부터 데이터 보호
PostgreSQL 격리앱별 DB + 유저접근 권한 분리, 백업 단위
Redis클러스터 내부 단일 인스턴스RPi 리소스 절약
Redis 격리DB 번호로 분리broker 공유 + cache 격리
Redis 비밀번호Secret에 전체 URLConfigMap 평문 노출 방지
비밀번호 관리Ansible Vault → K8s Secretgit 추적 가능 + Pod 주입
로컬 개발dev-infra + external network포트 충돌 방지, K8s 패턴 재현

RPi 4대 클러스터에서 PostgreSQL 1대 + Redis 1개로 4개 앱을 돌리는 구조가 됐다. Superset 하나를 추가하려다 인프라 전체를 다시 짠 셈인데, 결과적으로 리소스 효율이 좋고 새 앱 추가도 체크리스트대로 하면 30분이면 끝난다. 다만 공유 인프라라 장애 영향 범위가 넓어지는 트레이드오프는 있다 — 이건 홈랩 규모에서 감수할 수 있는 수준이라고 판단했다.