홈랩 K3s 클러스터에 보안 자동화 파이프라인 구축하기

TL;DR

  • 홈랩 K3s 클러스터(x86 master 1대 + RPi 4 worker 4대)에 보안 자동화 파이프라인을 하루 만에 구축
  • unattended-upgrades + kured로 OS 패치 → 자동 재부팅, Trivy Operator로 전 워크로드 이미지 스캔
  • 첫 스캔 결과: 20개 워크로드 중 16개에서 CRITICAL 30건, HIGH 236건 발견
  • 취약점을 “직접 업그레이드 / 리빌드 / upstream 대기” 3단계로 분류해 대응

들어가며

Kubernetes를 운영하면 기능 구현에 집중하느라 보안은 뒷전이 되기 쉽습니다. “홈랩인데 뭐”라는 생각도 있었고요.

그런데 최근 Go stdlib 취약점(CVE-2025-68121), OpenSSL 취약점(CVE-2026-31789) 같은 굵직한 보안 이슈들이 연이어 터지면서 생각이 바뀌었습니다. 홈랩이라도 외부에 노출된 서비스가 있고, 개인 데이터가 올라가 있는 이상 보안은 선택이 아닙니다.

문제는 **“뭐가 위험한지 모른다”**는 것이었습니다. 클러스터에 어떤 이미지가 올라가 있고, 그 안에 어떤 취약점이 있는지 한 번도 체계적으로 확인한 적이 없었거든요. 그래서 하루를 투자해서 OS 패치부터 이미지 스캔, 알림, 실제 패치 적용까지 한 사이클을 돌려봤습니다.


환경

  • K3s 클러스터: x86_64 master 1대 + Raspberry Pi 4 worker 4대
  • 서비스: Airflow, 자체 Django 앱 2개, Cloudflare Tunnel, Traefik, cert-manager 등
  • 외부 접근: Cloudflare Tunnel + Zero Trust

Phase 1: OS 보안 패치 자동화

가장 기본적인 레이어부터 시작했습니다. 전체 6대 호스트(master, worker 4, DB 서버)에 unattended-upgrades를 설치했습니다.

# Ansible playbook 핵심 부분
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::Automatic-Reboot "false";

핵심 설정:

  • 보안 업데이트만 자동 적용 (-security 리포지토리만 허용)
  • 자동 재부팅 비활성화 — 커널 업데이트 후 재부팅은 kured에게 위임

재부팅은 kured가 담당합니다. DaemonSet으로 모든 노드에 배포되어, /var/run/reboot-required 파일을 감지하면 자동으로 drain → reboot → uncordon을 수행합니다.

helm install kured kubereboot/kured \
  --set configuration.startTime="02:00" \
  --set configuration.endTime="05:00" \
  --set configuration.timeZone="Asia/Seoul" \
  --set configuration.concurrency=1

설정 포인트:

  • 재부팅 윈도우: 매일 02:00~05:00 KST (서비스 영향 최소화)
  • 동시 재부팅 1대 — 워크로드가 다른 노드로 이동할 시간 확보
  • master 제외kured-reboot-blocked annotation으로 control plane 보호

Phase 2: 워크로드 이미지 취약점 스캔

여기가 오늘의 핵심이었습니다. Trivy Operator를 설치해서 클러스터의 모든 컨테이너 이미지를 자동 스캔합니다.

helm install trivy-operator aqua/trivy-operator \
  --namespace trivy-system --create-namespace \
  --set trivy.slow=true \
  --set operator.scanJobsConcurrentLimit=2 \
  --set trivy.resources.limits.memory=512Mi

Raspberry Pi 환경에서의 최적화가 필요했습니다:

  • trivy.slow=true: DB 다운로드 시 메모리 사용량 감소
  • scanJobsConcurrentLimit=2: 동시 스캔 제한 (RPi의 제한된 리소스 고려)
  • memory limit 512Mi: OOMKill 방지

첫 스캔 결과: 현실 직면

설치 후 첫 스캔 결과를 보고 놀랐습니다.

trivy-operator    — C:3  H:32
kured             — C:3  H:26
tusd              — C:3  H:24
metallb speaker   — C:2  H:19
coredns           — C:0  H:14
...

20개 워크로드 중 16개에서 CRITICAL 또는 HIGH 취약점이 발견됐습니다. CRITICAL 30건, HIGH 236건. “홈랩인데 뭐”라고 생각했던 클러스터에 이 정도 취약점이 있었습니다.


Phase 3: Telegram 알림

스캔 결과를 매번 수동으로 확인할 수는 없으니, Telegram 봇으로 알림을 보내는 CronJob을 만들었습니다.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: trivy-telegram-alert
  namespace: trivy-system
spec:
  schedule: "0 8,20 * * *"
  timeZone: "Asia/Seoul"

매일 오전 8시, 오후 8시에 CRITICAL/HIGH 취약점이 있는 워크로드를 요약해서 보내줍니다. 알림 스크립트는 kubectl get vulnerabilityreports로 리포트를 조회하고, jq로 필터링해서 Telegram Bot API로 전송합니다.

스크립트 작성 중 신경 쓴 부분:

  • ReplicaSet 해시 제거: web-759b7bf78b 같은 이름에서 해시를 잘라 web으로 표시
  • CVE 중복 제거: 같은 CVE가 여러 컨테이너에서 나오면 한 번만 표시
  • 워크로드 그룹핑: 같은 Deployment의 여러 리포트를 하나로 합침

Phase 4: 실제 패치 적용

알림을 받았으니 이제 고칠 차례입니다. 취약점을 3가지로 분류했습니다.

직접 업그레이드 가능

컴포넌트이전이후결과
MetalLBv0.15.3v0.16.0speaker C:3/H:33 해소
Traefikv3.6.14v3.7.1H:8 해소, 알림에서 제거
K3sv1.35.3v1.35.5coredns, containerd 업데이트
cloudflared2026.3.02026.5.0C:1 감소

별도 레포에서 리빌드 필요

자체 빌드 이미지(Django 앱 등)는 베이스 이미지의 OS 패키지가 원인입니다. Dockerfile에 apt-get upgrade -y를 추가하고 리빌드하면 해결되는 것들이죠.

upstream 대기

kured, trivy-operator, cert-manager 등은 이미 최신 버전인데도 취약점이 있습니다. Go stdlib이나 Alpine/Debian base 패키지의 문제로, upstream에서 새 릴리스를 내기 전까지는 할 수 있는 게 없습니다.

이 분류가 중요합니다. 처음에는 “CRITICAL이 30건이나 있다”며 당황했는데, 실제로 내가 조치할 수 있는 건 일부였습니다. 나머지는 “인지하고 모니터링한다”가 최선입니다.


삽질 기록

K3s 업그레이드 시 서비스 파일 덮어쓰기

오늘 가장 아찔했던 순간입니다. K3s 설치 스크립트(curl -sfL https://get.k3s.io | sh -)로 업그레이드하면 /etc/systemd/system/k3s.service 파일을 새로 생성합니다. 문제는 수동으로 추가했던 플래그들이 전부 날아간다는 것이었습니다.

# 유실된 플래그들
--secrets-encryption     # etcd Secret 암호화
--disable traefik        # 내장 Traefik 비활성화
--disable servicelb      # 내장 ServiceLB 비활성화
--tls-san 192.168.50.100 # API 서버 TLS SAN

--secrets-encryption이 사라지면서 API 서버가 암호화된 Secret을 읽지 못해 identity transformer tried to read encrypted data 에러가 쏟아졌습니다. --disable traefik이 빠지면서 K3s가 내장 Traefik을 다시 설치하려고 시도해 CrashLoopBackOff가 발생했고요.

sed로 플래그를 복원하고 systemctl daemon-reload && restart로 해결했지만, 프로덕션이었다면 장애였습니다.

교훈: K3s 업그레이드 전에 서비스 파일을 반드시 백업할 것. 장기적으로는 /etc/rancher/k3s/config.yaml에 설정을 옮기는 게 안전합니다.

Trivy DB 캐시 잠금 충돌

Raspberry Pi에서 여러 스캔 Job이 동시에 실행되면 DB 캐시 파일에 대한 잠금 충돌이 발생했습니다. scanJobsConcurrentLimit=2로 동시 스캔을 제한하고, 자동 재시도로 해소됐습니다.

bitnami/kubectl 이미지 부재

처음에 Telegram 알림 CronJob에 bitnami/kubectl:1.35 이미지를 쓰려 했는데, 해당 태그가 존재하지 않았습니다. alpine/k8s:1.35.5로 대체했습니다. kubectl + jq + curl이 모두 포함된 경량 이미지입니다.


최종 구성

[unattended-upgrades] → OS 보안 패치 자동 적용

[kured] → 커널 업데이트 후 자동 재부팅 (새벽 2~5시)

[Trivy Operator] → 모든 워크로드 이미지 취약점 스캔

[CronJob] → 매일 08:00/20:00 Telegram 알림

[수동 대응] → 업그레이드 가능한 컴포넌트 패치

전체 파이프라인을 구축하는 데 하루가 걸렸습니다. Ansible 플레이북 2개, K8s 매니페스트 1개, Helm 릴리스 2개, justfile 명령어 7개. 홈랩이라 이 정도지만, 핵심 원리는 프로덕션과 동일합니다.


느낀 점

  1. “최신 버전 = 안전”이 아닙니다. 모든 컴포넌트를 최신으로 올려도 CRITICAL 30건, HIGH 236건이 남았습니다. 대부분 Go stdlib이나 OS 패키지의 upstream 이슈입니다.

  2. 분류가 핵심입니다. 취약점을 발견했을 때 “내가 고칠 수 있는 것 / 리빌드로 해결되는 것 / 기다려야 하는 것”을 빠르게 구분하는 게 중요합니다. 전부 당장 고쳐야 한다는 압박감은 비생산적입니다.

  3. 자동화 없이는 지속 불가능합니다. 수동으로 trivy image scan을 돌려보는 건 한 번은 할 수 있습니다. 하지만 매일? 이미지가 바뀔 때마다? Operator + CronJob 조합이 이 문제를 해결해줍니다.

  4. 홈랩의 가치. K3s 서비스 파일 덮어쓰기 같은 실수를 프로덕션이 아닌 홈랩에서 겪은 건 다행입니다. 이런 경험이 쌓여야 프로덕션에서 같은 실수를 하지 않습니다.