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 |
| inboxpilot | URL/메모 수집/분류 | Django + Celery + Redis + PostgreSQL |
| superset | BI 대시보드 | 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 | 용도 | 비고 |
|---|---|---|
| 0 | loopback | |
| 1 | 공유 Celery broker | inboxpilot + videokeeper 공유 |
| 2 | inboxpilot cache | Django CACHES backend |
| 3 | videokeeper cache | Django CACHES backend |
| 4 | superset | cache + 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",
)
이게 동작하려면:
- 양쪽
CELERY_BROKER_URL이 같은 Redis + 같은 DB(1번)를 가리켜야 한다 - videokeeper worker가
videokeeper큐를 consume해야 한다 - 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_HOST를 redis.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 앱을 추가할 때의 순서:
- PostgreSQL: Vault에 비밀번호 추가 →
just <app>-db실행 - Redis: 다음 빈 DB 번호 할당, 할당표 업데이트
- K8s namespace: 생성
- Secret: DB 비밀번호 + Redis URL(비밀번호 포함) + Django SECRET_KEY + API 키
- ConfigMap: DJANGO_SETTINGS_MODULE, ALLOWED_HOSTS, DB 접속 정보 (비밀 아닌 것만)
- GHCR pull secret: 기존 앱에서 복사 (같은 PAT 재사용)
- 매니페스트 apply: namespace → configmap → secret → deployment → service
- Cloudflare Tunnel: Public Hostname 추가
- dev-infra: 로컬 docker-compose에 db-init + external network 추가
정리
| 항목 | 설계 | 이유 |
|---|---|---|
| PostgreSQL | 클러스터 외부 전용 머신 | RPi 장애로부터 데이터 보호 |
| PostgreSQL 격리 | 앱별 DB + 유저 | 접근 권한 분리, 백업 단위 |
| Redis | 클러스터 내부 단일 인스턴스 | RPi 리소스 절약 |
| Redis 격리 | DB 번호로 분리 | broker 공유 + cache 격리 |
| Redis 비밀번호 | Secret에 전체 URL | ConfigMap 평문 노출 방지 |
| 비밀번호 관리 | Ansible Vault → K8s Secret | git 추적 가능 + Pod 주입 |
| 로컬 개발 | dev-infra + external network | 포트 충돌 방지, K8s 패턴 재현 |
RPi 4대 클러스터에서 PostgreSQL 1대 + Redis 1개로 4개 앱을 돌리는 구조가 됐다. Superset 하나를 추가하려다 인프라 전체를 다시 짠 셈인데, 결과적으로 리소스 효율이 좋고 새 앱 추가도 체크리스트대로 하면 30분이면 끝난다. 다만 공유 인프라라 장애 영향 범위가 넓어지는 트레이드오프는 있다 — 이건 홈랩 규모에서 감수할 수 있는 수준이라고 판단했다.