[사례연구] 사내 개인용 개발환경 이미지 실험기 2부: 만들면서 마주친 것들과 풀어간 방법

Tech Story/DevOps & Container

[사례연구] 사내 개인용 개발환경 이미지 실험기 2부: 만들면서 마주친 것들과 풀어간 방법

 

kt cloud Platform엔지니어링팀 이초환

 

요약 SUMMARY

이 글에서는 OpenStack 샌드박스 이미지 구축 과정에서 발생한
IP 복구, GitOps 구성, 자격증명 관리 이슈와 해결 방안을 다룹니다.
보안성과 운영 재현성을 높이기 위한 실무적 판단 기준을 정리합니다.

#OpenStack #GitOps #ArgoCD #FluxCD #Vault

1. 부팅마다 바뀌는 IP 자동 복구

샌드박스 이미지는 누가 어디서 부팅하느냐에 따라 IP가 매번 다릅니다. K8s 다뤄보신 분들은 익숙하시겠지만 kubeadm으로 만든 클러스터는 빌드 시점 IP가 여러 곳에 박힙니다. 그대로 부팅하면 인증서 검증부터 어긋납니다.

 

처음엔 kube-apiserver 관련된 IP만 몇 개 건드리면 되겠지 생각했었습니다. 그런데 막상 첫 빌드를 부팅시켜보니 IP가 박혀 있던 자리들이 생각보다 줄줄이 나왔습니다.

  • apiserver 인증서의 SAN — 새 IP가 SAN에 없어서 TLS 검증부터 실패
  • kubeconfig 3종(admin·scheduler·controller-manager) — server: 필드에 옛 IP
  • apiserver static pod manifest의 --advertise-address
  • etcd static pod manifest의 --listen-client-urls / --initial-advertise-peer-urls
  • kube-proxy ConfigMap에 박힌 master IP — 인증서·apiserver는 살아났는데 워크로드 트래픽이 안 흐름

막힐 때마다 한 자리씩 추가하다 보니 결국 systemd oneshot으로 묶게 됐습니다. 부팅 시점에 한 번 실행되고, 동작 순서는 이렇습니다.

  1. 현재 IP 감지 (DHCP 대기 포함)
  2. apiserver 인증서를 새 IP SAN으로 재발급
  3. static pod manifest(kube-apiserver.yaml, etcd.yaml) IP 치환
  4. kubeconfig 3종(admin·scheduler·controller-manager) 재생성
  5. kubelet 재시작 → apiserver/etcd 재기동
  6. CNI·CoreDNS Ready 대기
  7. kube-proxy ConfigMap에 남은 옛 IP 치환 + DaemonSet rollout restart

각 단계에 retry/timeout만 넉넉히 잡아두는 정도로 마무리했습니다.


2. GitOps 적용 — helm install에서 HelmRelease + ArgoCD까지

1부 3번 섹션에서 이미지에 ArgoCD·Flux를 선탑재하고 values는 Git 레포의 ConfigMap으로 둔 구조를 결과만 적어뒀는데, 거기까지 한 번에 그렇게 잡힌 건 아닙니다. 시간순으로는 helm install → Flux HelmRelease → ArgoCD 추가 순서로 갈아탔고, 단계마다 막힌 게 있어서 다음으로 옮긴 흐름입니다.

1단계 — 단일 VM 환경 제약에 values 맞추기

처음엔 OpenStack 코어 6종과 인프라 의존성 3종을 helm install로 한 컴포넌트씩 직접 깔았습니다. 시작점은 차트의 default values였지만, default values는 멀티 노드·외부 스토리지를 전제하고 있고, 한편으로 1부 요건에 적은 대로 운영 환경 values와의 구조 정합도 잃지 않으려 했기 때문에, 그 두 사이에서 단일 VM 환경에 맞게 다듬는 게 1단계에서 한 일이었습니다.

  • cinder backend — 외부 스토리지 없이 동작해야 해서 단일 VM용 backend 정의로 교체
  • replicas·HA 전제(예: mariadb galera 다중 노드)를 단일 VM에 맞게 축소
  • 컴포넌트 시작 순서 — mariadb·rabbitmq·memcached가 먼저 Ready여야 keystone이 올라가고, keystone이 떠야 다른 코어 컴포넌트들이 자기 endpoint를 등록하는 흐름
  • 그 외 컴포넌트별 자잘한 보정

이 단계 결과물은 "단일 VM에서 OpenStack을 뜨게 할 수 있는 values 한 벌"이었습니다. 다만 install·upgrade를 매번 손으로 돌려야 했고, git 변경이 클러스터에 자동으로 반영되지 않았습니다.

2단계 — Flux HelmRelease로 git 변경 자동 반영

values가 어느 정도 굳고 나서 Flux HelmRelease로 옮겼습니다. Harbor OCI에 올린 차트를 가리키는 HelmRepository를 source로 두고, 컴포넌트별 HelmRelease가 그 차트를 참조해 install/upgrade를 수행하는 구조입니다. 여기서부터 git push 한 번으로 클러스터가 새 values를 따라가게 됐습니다.

 

이때 values를 HelmRelease manifest 안에 직접 박지 않기로 했습니다. HelmRelease의 spec.values에 값을 그대로 적는 방식 대신, kustomization의 configMapGeneratorvalues/{arm64,amd64}/keystone.yaml 같은 외부 파일에서 ConfigMap을 만들고 HelmRelease는 spec.valuesFrom으로 그 ConfigMap을 참조하게 했습니다.

# kustomization.yaml
configMapGenerator:
  - name: keystone-values
    files:
      - values/arm64/keystone.yaml

이렇게 분리한 이유는 디버깅·검증 융통성입니다. values 파일이 평범한 yaml로 외부에 있으면 같은 파일로 helm template을 그대로 돌려 렌더링 결과를 미리 보거나 --dry-run을 떠보는 게 손쉽습니다. HelmRelease 안에 박혀 있으면 그 자리만 잘라내서 다시 yaml로 만들어야 같은 일이 됩니다. 평소 쓰던 손버릇이 막히지 않게 그냥 분리해뒀습니다.

3단계 — ArgoCD 추가

마지막으로 ArgoCD를 같이 올렸습니다. 사내 운영 환경이 ArgoCD 기반이라 Application 단위 가시성(Sync 상태, OutOfSync, diff)을 운영 결과 정합 측면에서 그대로 가져가야 했습니다. Flux는 차트 install/upgrade 자체를 책임지는 자리에 두고, ArgoCD는 그 위에서 ExternalSecret·HelmRelease 등을 Application으로 묶어 상태를 보여주는 역할입니다.


3. Git 레포와 이미지에 자격증명을 평문으로 두지 않게

1번과 2번 섹션이 만들면서 걸린 것이라면, 이건 처음부터 챙겨야 했던 부분입니다. 일반 운영 클러스터에서 시크릿을 다루는 것과 다른 점은, 이 이미지는 그 파일 자체가 개발자에게 전달되는 매체라는 점입니다. 이미지에 평문이 박혀 있으면 이미지를 받는 모든 사람에게 같은 평문이 함께 배포됩니다. Git 매니페스트에 base64 인코딩된 값을 두는 것도 평문과 다를 바 없고, 빌드 시점에 만든 K8s Secret이 etcd에 저장되면 그 etcd가 이미지에 포함되니 이미지 안에 평문이 남는 셈이었습니다.

 

Vault를 후보로 둔 이유

 

사내에 Vault가 이미 운영되고 있어서 첫 후보였습니다. 시크릿을 한 곳에 모아두고 빌드 타임이 아닌 각 사용자 로컬 부팅 시점에 가져오게 만들면, 이미지 자체에는 Vault 경로 참조만 남고 실제 값은 어디에도 평문으로 박히지 않게 됩니다.

 

Vault·ESO·CSI의 일반적 비교나 활용 패턴은 테크블로그 Vault 도입 전략 편[26-1차 링크 삽입 요청 - https://ktcloud.atlassian.net/wiki/x/6gHEaQ]과 Vault + ESO/CSI 아키텍처 편에서 이미 다루고 있어, 이 글에서는 이번 샌드박스 이미지 맥락에서 어떤 도구를 어떤 이유로 골랐는지만 짚겠습니다.

 

AVP에서 ESO로 옮긴 이유

ArgoCD를 이미 쓰고 있어서 첫 후보는 자연스럽게 AVP(Argo CD Vault Plugin)였습니다. Helm values에 plaintext placeholder(<path:secret/...#key>)를 두는 방식이 GitOps 운영 패턴에 잘 맞아서, 처음엔 AVP로 결정하고 진행하려 했습니다.

 

그런데 AVP 공식 호환 매트릭스를 보니 다음까지만 명시되어 있었습니다.

Argo CD Version AVP Version
1.8.x 1.x
2.0.x 1.x
2.1.x 1.x

 

위 매트릭스는 2026-04 시점 AVP 공식 README 기준입니다.

 

저희 환경은 v3.x.x입니다. ArgoCD의 CMP(ConfigManagementPlugin) API가 하위 호환을 유지해서 기술적 동작 가능성은 높지만, 공식 테스트 범위 밖이라는 점이 부담이었습니다. Vault 도입 전략 편[26-1차 링크 삽입 요청 - https://ktcloud.atlassian.net/wiki/x/6gHEaQ]에서도 AVP는 "예외 수단"으로 포지셔닝되어 있었던 것과 결이 같았고요.

 

또 한 가지 — 1부에서 적었듯 이 샌드박스는 ArgoCD와 Flux HelmRelease를 둘 다 사용합니다. AVP는 ArgoCD CMP로 동작해서 시크릿 주입이 ArgoCD 렌더링 파이프라인을 거쳐야 합니다. Flux HelmRepository가 Harbor OCI에 붙을 때 쓸 Secret도 ArgoCD가 관리하는 Secret manifest에 placeholder를 박아 치환하는 식으로 우회는 가능하지만, 시크릿 생애주기가 ArgoCD sync에 묶이는 게 마음에 걸렸습니다. ESO는 ArgoCD와 무관하게 Vault에서 직접 K8s Secret으로 sync하는 별도 operator라, Flux·ArgoCD 양쪽이 같은 Secret을 그대로 참조할 수 있어 진입점을 하나로 둘 수 있다는 점이 컸습니다.

 

공식 호환 공백과 진입점 일원화, 두 이유로 ESO로 정했습니다.

결과 — 평문이 사라진 경로들

전반 흐름은 다음과 같이 정리됐습니다. 이미지에는 ExternalSecret 참조와 Vault AppRole secret-id만 박히고, 실제 값은 각 사용자 부팅 시점에 ESO가 Vault에서 가져와 K8s Secret으로 만들면 ArgoCD·Flux 양쪽이 같은 Secret을 참조하는 구조입니다.

[사례연구] 사내 개인용 개발환경 이미지 실험기 2부: 만들면서 마주친 것들과 풀어간 방법

경로별로 끊어낸 것을 정리하면 다음과 같습니다.

경로 변경 전 변경 후
containerd config.toml registry auth 평문 블록 제거 → K8s imagePullSecret으로 옮김. 빌드 타임 pre-pull은 ctr pull --user 환경변수로만
Git 레포 매니페스트 base64 Secret 직접 커밋 ExternalSecret 참조 선언만 두고 실제 값은 Vault에
이미지 etcd K8s Secret이 빌드 타임에 박힘 부팅 시 ESO가 Vault에서 fetch → 각 사용자 로컬에서 런타임에 생성

 

그래도 남는 자격증명 하나

경로를 끊어도 마지막 하나는 남습니다. Vault에 처음 접근하기 위한 부트스트랩 자격증명 — 저희 경우 Vault AppRole의 secret-id입니다. 이걸 다시 Vault에 넣으면 재귀가 되니 어딘가에는 평문으로 있어야 합니다.

 

이번 1차에서는 이 자격증명을 정책으로 좁혀두는 쪽으로 정리했습니다.

  • 스코프 제한: sandbox-read 정책으로 secret/data/sandbox/* 경로 읽기만 허용. 쓰기 작업도 다른 경로 읽기도 불가능
  • TTL 경계: secret-id에 720시간(30일) TTL. 이미지가 오래된 상태로 유통되는 걸 정책 자체에서 차단
  • Audit 가능성: Vault Audit Log 활성화 → AppRole의 모든 fetch 요청이 기록되어, 유출 의심 시 secret-id accessor를 destroy하면 즉시 무력화

완전 제거는 아닙니다. 다만 남는 하나가 읽기 전용 + 단일 경로 스코프 + 30일 시간 경계 + Audit 가능 상태라면 이미지가 배포되는 상황에서 감수할 만한 리스크라고 봤습니다. 부트스트랩 경로 자체는 사내 클라우드 개인용 환경 제공 트랙으로 확장하면서 다른 방식(K8s Auth Method 등)으로 바꾸는 걸 검토하고 있습니다.


4. 마치며

 

이 글에서 짚은 것들을 정리하면 다음과 같습니다.

  • 부팅마다 바뀌는 IP를 systemd oneshot으로 자동 복구
  • helm install로 values 안정화 후 HelmRelease + ArgoCD로 갈아탄 GitOps 구조 (values는 ConfigMap으로 분리해 helm template 등 검증 융통성 확보)
  • Git 레포와 이미지 어느 쪽에도 자격증명이 평문으로 박히지 않도록 Vault·ESO 조합으로 정리
  • AVP 대신 ESO를 택한 근거 (ArgoCD v3 공식 호환 공백 + Flux/ArgoCD 공통 진입점)

남은 과제는 다음과 같습니다.

  • 부트스트랩 자격증명을 더 좁히는 방향 — 사내 클라우드 트랙에서 K8s Auth Method 등 검토
  • openstack-helm 외부 차트 대신 운영 노하우가 반영된 사내 차트로 전환
  • 이미지에 nested virtualization을 활성화해 샌드박스 안에서 실제 OpenStack 인스턴스를 띄우는 검증 (Apple Silicon + Lima)

이 글도 1부와 마찬가지로 진행 중인 1차 실험의 한 시점을 기록한 것입니다.

위 항목들이 진행되면 후속 기록으로 또 공유드리겠습니다!

 

 

kt cloud 클라우드 플랫폼 서비스 바로가기 배너

자주 묻는 질문 FAQ

Q AVP가 가장 자연스러운 후보일 텐데 왜 ESO를 택하셨나요? 
A 저희도 처음엔 AVP로 가려고 했습니다. 다만 AVP 공식 호환 매트릭스가 ArgoCD v1.8~v2.1까지만 명시되어 있고, 저희 환경은 v3.x.x이었습니다. CMP API가 하위 호환을 유지해서 동작 가능성은 높지만 공식 테스트 범위 밖이라는 점이 부담이었고, Flux와 ArgoCD 양쪽이 공통 Secret을 참조해야 한다는 점까지 더해져 ESO 단일 경로로 갔습니다. 
Q 이미지에 결국 평문 자격증명 하나는 남는 거 아닌가요? 
A 맞습니다. Vault 자체에 처음 접근할 부트스트랩 자격증명(AppRole secret-id) 하나는 이미지에 남습니다. 이걸 다시 Vault에 넣으면 재귀가 되니 어딘가에는 평문으로 있어야 합니다. 대신 (1) sandbox-read 정책으로 읽기 전용, (2) secret/data/sandbox/* 단일 경로 스코프, (3) secret-id TTL 720시간으로 정책 레벨에서 영향 범위와 노출 시간을 묶어두는 방향으로 정리했습니다.