AboutWritten 지나간 발자국 발자국
황현석 | 2003. 06. 23.에 작성되었습니다.

황현석 일지

클라우드 서버 구축 일지 03 - 선언적 성질과 수렴적 철학 본문

클라우드 서버 구축 일지 03 - 선언적 성질과 수렴적 철학

회고.

처음에는 클라우드 서버를 만들어보겠다는 생각으로 API 서버를 하나 띄우고, Helm으로 KubeVirt 관련 YAML에 값을 주입하면서 VM을 조정하고 있었습니다. 사용자 정보나 상태도 Kubernetes 안에 두기보다는 외부 데이터베이스를 참조하는 구조였습니다.

 

문제는 KubeVirt를 제어한다고 하면서도, 실제로는 Kubernetes의 동작 방식을 충분히 활용하지 못하고 있었다는 점입니다. KubeVirt가 이미 Kubernetes 위에서 CRD와 controller 패턴으로 동작하는데, 저는 그 위에 다시 별도의 추상 계층을 얹어 명령형으로 제어하려고 했습니다.

 

결국 이 방식은 제가 이해하고 있는 기술 스택 안에서 Kubernetes를 너무 멀리서, 너무 추상적으로 다루는 접근이었습니다. 그래서 방향을 바꾸어 KubeVirt가 어떤 방식으로 Kubernetes API를 확장하고, controller가 어떤 방식으로 desired state와 actual state를 맞추는지부터 다시 살펴보기 시작했습니다.

 

그 과정에서 KubeVirt의 VirtualMachine, VirtualMachineInstance, CDI DataVolume, PVC, Service가 서로 어떤 관계로 이어지는지 확인했고, VM을 만든다는 일이 단순히 가상 머신 프로세스를 실행하는 것이 아니라 Kubernetes 리소스들을 선언하고 수렴시키는 일이라는 것을 알게 되었습니다.

 

SSH 접속 방식도 다시 고민하게 되었습니다. 학교 네트워크 환경에서는 22번 포트와 추가 포트 개방이 제한적이었기 때문에, 처음에는 bastion 계정을 두고 내부 VM으로 프록시하는 방식을 생각했습니다. 하지만 VM마다 포트를 열기보다는 하나의 SSH 진입점을 두고, 로그인 계정명을 기준으로 사용자의 VM을 찾아 라우팅하는 방식이 더 자연스럽다는 결론에 도달했습니다.

 

 


 

개요 및 상황

 

Kite는 Kubernetes 위에서 사용자별 KubeVirt VM을 만들고, 웹 API와 프론트엔드, SSH 접속까지 하나의 플랫폼으로 묶어보려는 프로젝트입니다.

처음 목표는 단순했습니다. 사용자가 회원가입을 하면 사용자 정보를 Kubernetes CRD로 저장하고, 그 사용자가 VM을 요청하면 controller가 Namespace, KubeVirt VirtualMachine, CDI DataVolume, Service를 생성하는 구조였습니다.

 

하지만 실제로 단일 노드 K3s, KubeVirt, CDI, Longhorn, GHCR, SSH gateway를 모두 연결하다 보니, 단순히 YAML 몇 개를 적용하는 수준이 아니라 여러 계층의 문제를 계속 마주하게 되었습니다. 이 글은 그 과정에서 겪은 문제와, 어떤 방식으로 우회하고 설계를 바꾸었는지에 대한 기록입니다.


초기 설계: API와 Controller 사이의 gRPC 제거

초기에는 kite-apikite-controller에게 gRPC로 명령을 보내고, controller가 그 요청을 받아 VM을 만드는 구조를 생각했습니다. 하지만 Kubernetes 위에서 controller를 만든다면, API와 controller가 직접 통신하는 것보다 CRD를 중심으로 상태를 맞추는 방식이 더 자연스럽다고 판단했습니다.

 

따라서 gRPC는 폐기하고, kite-api는 Kubernetes API server에 KiteUser, KiteVirtualMachine CRD를 쓰는 역할만 맡도록 정리했습니다. kite-controller는 CRD를 watch하면서 실제 Kubernetes 리소스를 reconcile합니다.

사용자 요청
  -> kite-api
  -> KiteUser / KiteVirtualMachine CRD spec 기록
  -> kite-controller watch
  -> Namespace / VM / DataVolume / Service / Secret 생성
  -> CRD status 갱신

 

이 구조로 바꾸면서 API process가 죽었다가 다시 살아나도, 이미 etcd에 저장된 CRD spec을 기준으로 controller가 계속 상태를 수렴할 수 있게 되었습니다.


회원가입과 사용자 CRD

회원가입 로직에서는 사용자를 일반 DB에 저장하지 않고 KiteUser CRD로 저장하도록 구성했습니다. 사용자가 처음 가입하면 username, email, password hash, namespace, profile image, access level이 CRD spec에 기록됩니다.

 

처음 가입한 사용자는 관리자 권한을 주고, 그 이후 가입자는 기본 권한으로 시작하도록 했습니다. password는 평문이 아니라 단방향 hash로 저장하도록 정리했습니다.

apiVersion: hy3ons.github.io/v1
kind: KiteUser
metadata:
  name: ku-599eeabe-009a-4d49-926b-059bf1748a9f
spec:
  username: test
  email: test@gmail.com
  password: <hashed-password>
  namespace: kite-user-ku-599eeabe-009a-4d49-926b-059bf1748a9f
  profile_image: base64encodedimage
  access_level: 0

여기서 중요한 점은 CRD 이름을 username 같은 변경 가능한 값으로 두지 않는 것입니다. username은 나중에 바뀔 수 있고, 중복 정책도 바뀔 수 있습니다. 따라서 CRD 이름은 내부 primary key 역할을 하는 안정적인 ID로 두는 방향이 더 좋았습니다.


KubeVirt VM 생성과 CDI DataVolume 문제

VM 생성은 생각보다 단순하지 않았습니다. KiteVirtualMachine CRD가 생성되면 controller가 DataVolume, VirtualMachine, SSH Service를 만들어야 했습니다. 처음에는 VM마다 Ubuntu 이미지를 HTTP로 새로 다운로드하는 구조를 고민했지만, VM이 늘어날수록 같은 이미지를 계속 다운로드하는 것은 너무 무거웠습니다.

 

그래서 kite namespace에 golden image DataVolume을 하나 만들어두고, 사용자 namespace의 VM disk는 이 golden image PVC를 clone하는 방식으로 바꾸었습니다.

kite/ubuntu-22.04 DataVolume
  -> PVC ubuntu-22.04

사용자 VM DataVolume
  -> source pvc: kite/ubuntu-22.04
  -> 사용자 namespace에 VM disk PVC 생성

 

이 과정에서 CDI CRD가 설치되지 않은 상태에서는 아래와 같은 오류를 만났습니다.

no matches for kind "DataVolume" in version "cdi.kubevirt.io/v1beta1"
ensure CRDs are installed first

 

해결은 명확했습니다. KubeVirt만 설치해서는 부족하고, CDI도 별도로 설치하고 준비 상태를 기다려야 합니다. 따라서 설치 스크립트에 KubeVirt, CDI 설치 및 wait 단계를 분리해서 넣었습니다.


NetworkPolicy가 CDI clone을 막은 문제

사용자 namespace를 격리하기 위해 NetworkPolicy를 적용했는데, 이 정책이 CDI의 host-assisted clone 통신까지 막아버리는 문제가 있었습니다. local-path나 일부 환경에서는 스냅샷 기반 clone이 되지 않아서 CDI가 source pod와 target pod 사이에서 직접 데이터를 복사합니다.

 

하지만 사용자 namespace의 egress를 사설 대역으로 막아두면, target pod가 kite namespace 또는 cdi namespace와 통신하지 못합니다. 증상은 DataVolume이 계속 Pending에 머무는 형태로 나타났습니다.

Normal  Pending  datavolume-import-controller  PVC asdf-disk Pending


우회 방법은 전체 보안 정책을 풀어버리는 것이 아니라, CDI가 필요한 namespace만 명시적으로 허용하는 것이었습니다. 사용자 namespace 내부 통신은 허용하고, kite, cdi, kube-system DNS 정도만 필요한 만큼 열어주는 방향으로 정리했습니다.


Longhorn 단일 노드 환경 문제

VM disk를 안정적으로 관리하기 위해 Longhorn StorageClass를 사용했습니다. 하지만 단일 노드 환경에서는 Longhorn의 기본 replica 수가 문제가 되었습니다. Longhorn은 기본적으로 여러 replica를 다른 노드에 분산하려고 하는데, 노드가 하나뿐이면 볼륨이 Faulted 상태가 될 수 있습니다.

 

Longhorn UI에서 확인했을 때 새로 만든 PVC 볼륨들이 Faulted로 보였고, 실제 DataVolume importer도 제대로 진행되지 않았습니다. 해결은 Kite 전용 StorageClass를 만들고 replica 수를 단일 노드에 맞게 조정하는 것이었습니다.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: kite-vm-storage
parameters:
  numberOfReplicas: "1"
  diskSelector: "kite"

 

또한 기존 Longhorn 사용자가 있을 수 있으므로, clear.sh가 Longhorn 전체를 무조건 지우는 것은 위험했습니다. 따라서 Kite가 할당한 디스크 데이터와 Longhorn 리소스 삭제는 opt-in으로 분리했습니다.

CLEAR_LONGHORN=true KITE_CLUSTER=k3s ./clear.sh
CLEAR_LONGHORN_DATA=true CLEAR_LONGHORN_DATA_CONFIRM=true KITE_CLUSTER=k3s ./clear.sh

NodePort 기반 SSH 접속을 버린 이유

처음에는 VM마다 SSH 접속용 NodePort Service를 만들려고 했습니다. Kubernetes가 nodePort를 자동 할당하고, controller가 그 값을 CRD status에 기록하면 된다고 생각했습니다.

 

하지만 이 방식은 VM이 늘어날수록 포트를 계속 소모하고, Service가 삭제되지 않으면 고아 포트가 남을 수 있습니다. 또한 사용자가 접속할 때마다 VM별 포트를 기억해야 해서 UX도 좋지 않았습니다.

 

그래서 NodePort를 제거하고, VM별 Service는 ClusterIP로 유지했습니다. 외부 SSH 진입점은 하나의 kite-gateway가 맡도록 설계를 바꾸었습니다.

ssh <sshId>@<server-ip> -p 22
  -> kite-gateway
  -> KiteVirtualMachine.spec.sshId lookup
  -> vps-access-<vmName>.<namespace>.svc.cluster.local:22
  -> VM sshd

 

이렇게 하면 사용자 입장에서는 VM마다 포트를 알 필요가 없습니다. username 자체가 라우팅 키가 됩니다.


host Linux 계정 방식에서 SSH Gateway 방식으로

중간에는 host OS에 Linux 계정을 만들고, 그 계정의 shell을 custom proxy shell로 바꾸는 방식도 고민했습니다. 사용자가 ssh asdf@host로 들어오면 host 계정의 shell이 실행되고, 그 shell이 내부 VM Service로 다시 SSH를 연결하는 구조였습니다.

#!/bin/bash
PRIVATE_KEY="/home/asdf/.ssh/id_rsa"
VM_TARGET="asdf@vps-access-asdf.kite-user-xxx.svc.cluster.local"
exec ssh -i "$PRIVATE_KEY" -o StrictHostKeyChecking=no "$VM_TARGET" "$@"

 

하지만 host OS 계정을 직접 만들기 시작하면 정리 문제가 생깁니다. CRD가 삭제되었는데 host 계정이 남아 있거나, agent가 죽은 동안 finalizer가 끝나면 고아 계정이 생길 수 있습니다. 또한 host의 DNS가 *.svc.cluster.local을 해석하지 못하는 문제도 있었습니다.

ssh: Could not resolve hostname vps-access-asdf...svc.cluster.local:
Temporary failure in name resolution

 

결국 host OS를 최대한 건드리지 않는 방향이 맞다고 판단했습니다. 그래서 자체 SSH server인 kite-gateway를 Kubernetes 내부에 띄우고, 이 gateway가 Kubernetes 내부 DNS를 사용해 VM Service로 연결하도록 했습니다.


kite-gateway에서 SSH를 한 번 terminate해야 했던 이유

처음에는 raw TCP L4 proxy처럼 단순히 바이트를 넘기면 가장 가볍지 않을까 생각했습니다. 하지만 사용자를 라우팅하려면 SSH login username을 알아야 합니다. 즉, 어떤 VM으로 보낼지 결정하려면 SSH handshake를 한 번 받아야 합니다.

 

그래서 kite-gateway는 Go의 SSH server를 사용해 클라이언트 SSH 연결을 terminate하고, username/password를 검증합니다. 그 후 backend VM에는 controller가 만든 private key로 SSH client 연결을 열고, 두 SSH channel을 proxy합니다.

client SSH
  -> kite-gateway SSH server
  -> username = sshId
  -> KiteVirtualMachine route lookup
  -> password hash 검증
  -> VM private key Secret 조회
  -> backend VM SSH 연결
  -> channel proxy

 

이 구조는 완전한 L4 proxy보다는 복잡하지만, VM 라우팅과 인증을 플랫폼이 제어할 수 있다는 장점이 있었습니다. 또한 VM 내부에는 public key만 cloud-init으로 넣고, private key는 Kubernetes Secret에 보관하도록 했습니다.


기존 host SSHD와 22번 포트 충돌

gateway를 22번 포트에 노출하려면 기존 host sshd와 충돌합니다. 처음부터 host sshd를 없애는 것은 위험하므로, 설치 스크립트에서 사용자의 확인을 받고 기존 sshd를 2222로 옮기는 방식으로 정리했습니다.

기존 host sshd: 22
설치 후 host sshd: 2222
kite-gateway: 22

 

또한 gateway가 기존 host 계정 접속도 어느 정도 유지할 수 있도록 fallback을 넣었습니다. KiteVirtualMachine.spec.sshId와 매칭되는 VM route가 있으면 VM이 우선이고, route가 없으면 gateway가 host의 node-ip:2222로 SSH 연결을 넘깁니다.

ssh hhs2003@host -p 22
  -> kite-gateway
  -> Kite VM route 없음
  -> host sshd node-ip:2222 fallback

이 과정에서 gateway host key도 중요했습니다. 사용자는 기존에 host 22번 fingerprint를 신뢰하고 있었기 때문에, gateway가 완전히 다른 host key를 쓰면 경고가 뜹니다. 그래서 gateway Secret을 만들 때 기존 /etc/ssh/ssh_host_* key를 우선 재사용하도록 했습니다.


cloud-init과 VM 내부 계정 문제

VM 내부에도 사용자가 로그인할 Linux 계정이 필요했습니다. 처음에는 password 기반 접속도 고민했지만, gateway가 이미 password 인증을 맡고 있으므로 VM 내부는 key 기반 접속으로 가는 편이 더 안전했습니다.

 

controller는 VM을 만들 때 keypair Secret을 만들고, public key를 cloud-init user-data에 넣습니다. gateway는 Secret의 private key를 읽어서 VM 내부 sshd에 접속합니다.

users:
  - name: asdf
    groups: sudo
    shell: /bin/bash
    ssh_authorized_keys:
      - ssh-rsa AAAA...

 

이 구조에서는 사용자가 외부에서 입력하는 password와 VM 내부 SSH key가 분리됩니다. 외부 password는 route 인증용이고, VM 내부 접속은 플랫폼이 만든 keypair로 처리됩니다.


SSH 접속 타이밍 문제

VM CRD가 생성되었다고 해서 바로 SSH가 되는 것은 아닙니다. DataVolume clone, VM scheduling, virt-launcher pod 생성, cloud-init 실행, VM 내부 sshd 기동까지 시간이 걸립니다. 이 사이에 gateway가 VM Service로 연결하면 아래와 같은 오류가 나타날 수 있었습니다.

ssh: connect to host vps-access-asdf... port 22: No route to host
ssh: connect to host vps-access-asdf... port 22: Connection refused

 

이 메시지를 사용자에게 그대로 보여주면 내부 구현이 노출되고 UX도 좋지 않습니다. 그래서 gateway는 backend 연결을 일정 시간 재시도하고, 아직 준비되지 않았다면 사용자가 이해할 수 있는 메시지를 보여주는 방향으로 정리했습니다.

VirtualMachine is starting sshd server. Please retry shortly.

 

또한 SSH 세션 종료 시 goroutine이나 channel이 제대로 닫히지 않으면 사용자가 exit를 입력한 뒤 터미널이 멈춘 것처럼 보이는 문제가 있었습니다. 이를 막기 위해 backend channel, request stream, client channel을 명확하게 닫고, exit-status 전달 후 대기 시간을 제한했습니다.


GHCR 이미지 배포와 ImagePullBackOff

배포 이미지는 GitHub Container Registry(GHCR)에 올리고, 설치 스크립트가 ghcr.io/hy3ons/kite-* 이미지를 pull하도록 구성했습니다. main 브랜치에 push하면 GitHub Actions가 production 이미지를 build/push합니다.

ghcr.io/hy3ons/kite-api:latest
ghcr.io/hy3ons/kite-controller:latest
ghcr.io/hy3ons/kite-gateway:latest
ghcr.io/hy3ons/kite-frontend:latest

 

하지만 실제 설치 시 Pod가 ImagePullBackOff에 빠졌습니다. 처음에는 DNS나 k3s containerd 문제를 의심했지만, event를 확인해보니 원인은 GHCR 인증이었습니다.

failed to authorize:
failed to fetch anonymous token:
unexpected status from GET request to https://ghcr.io/token... 401 Unauthorized

 

즉, 클러스터가 GHCR까지 접근은 했지만 package가 public pull 가능한 상태가 아니었습니다. 이 문제는 Kubernetes 내부 문제가 아니라 GitHub Packages visibility 문제였습니다. Dockerfile에는 OCI 표준 label을 추가해 GitHub Packages가 source repository와 description을 표시할 수 있게 했습니다.

LABEL org.opencontainers.image.source="https://github.com/Hy3ons/KiteVirtualMachines" \
      org.opencontainers.image.description="Kite SSH gateway for routing user SSH sessions to KubeVirt VM services" \
      org.opencontainers.image.licenses="MIT"

 

익명 설치를 목표로 한다면 GHCR package를 public으로 열어야 하고, private package를 유지하려면 imagePullSecret을 배포 과정에서 만들어야 합니다. 이 구분을 명확히 알게 된 것이 중요한 포인트였습니다.


설치 스크립트와 클린 스크립트

처음에는 사용자가 repository를 clone한 뒤 ./dev.sh./install.sh를 실행하는 구조였습니다. 하지만 실제 사용자는 git이 없을 수도 있고, 단순히 한 줄 명령으로 설치하고 싶을 수 있습니다.

 

그래서 루트의 install.shclean.sh는 GitHub archive를 다운로드해서 내부 스크립트를 실행하는 bootstrap 구조로 바꾸었습니다.

curl -fsSL https://raw.githubusercontent.com/Hy3ons/KiteVirtualMachines/main/install.sh | bash

curl -fsSL https://raw.githubusercontent.com/Hy3ons/KiteVirtualMachines/main/clean.sh | bash

 

이 방식은 git clone이 필요 없습니다. bootstrap script가 선택된 ref의 tar archive를 임시 디렉토리에 풀고, build/deploy/scripts/install-all.sh 또는 build/dev/clear.sh를 실행합니다.

 

이 과정에서도 작은 쉘 버그를 만났습니다. set -u가 켜진 상태에서 cleanup trap이 아직 초기화되지 않은 임시 디렉토리 변수를 참조하면 unbound variable로 실패했습니다.

secret/kite-gateway-host-key created
ensure-gateway-host-key-secret.sh: line 1: tmpdir: unbound variable

해결은 cleanup에서 변수를 직접 참조하지 않고, Bash parameter expansion의 기본값을 사용하는 것이었습니다.

cleanup() {
  if [[ -n "${KITE_INSTALL_TMPDIR:-}" ]]; then
    rm -rf "${KITE_INSTALL_TMPDIR}"
  fi
}

결론

이번 작업을 하면서 가장 크게 느낀 점은, Kubernetes 기반 구현은 단순히 controller 코드만 잘 짜면 끝나는 것이 아니라는 점이었습니다. KubeVirt, CDI, StorageClass, NetworkPolicy, GHCR, SSH, host systemd까지 여러 계층이 서로 영향을 줍니다.

 

초기에는 NodePort, host 계정, gRPC, 직접 명령형 API 같은 방향도 고민했지만, 작업을 진행하면서 점점 Kubernetes다운 선언적 구조로 정리되었습니다. API는 CRD spec을 쓰고, controller는 status를 맞추고, gateway는 SSH 진입점 하나만 책임지는 구조가 되었습니다.

 

특히 문제를 해결할 때마다 단순히 에러 하나를 지우는 것이 아니라, 왜 이 계층에서 문제가 생겼는지 확인하는 과정이 중요했습니다. ImagePullBackOff는 DNS 문제가 아니라 GHCR visibility 문제였고, DataVolume Pending은 권한 문제가 아니라 NetworkPolicy와 CDI clone 통신 문제였고, SSH 접속 실패는 Service가 없는 것이 아니라 VM 내부 sshd 준비 타이밍 문제였습니다.

 

결국 Kite는 단일 노드 K3s 환경에서도 VM을 생성하고, 디스크를 준비하고, 사용자가 하나의 SSH 포트로 자기 VM에 들어갈 수 있는 방향으로 정리되었습니다. 아직 고도화할 부분은 남아 있지만, 이 과정 자체가 Kubernetes 위에서 작은 클라우드 플랫폼을 설계할 때 어떤 문제를 만나게 되는지 잘 보여주는 경험이었습니다.

 

무엇보다 이번 작업은 Kubernetes가 계속 이야기하는 선언 상태와 수렴 상태의 의미를 실제로 이해하게 된 경험이었습니다. 사용자가 원하는 상태는 CRD의 spec에 남기고, controller는 현재 클러스터의 상태를 관찰하면서 그 차이를 줄여나갑니다. 문제가 생기면 다시 관찰하고, 필요한 리소스를 만들거나 지우고, status에 결과를 남깁니다. 결국 중요한 것은 한 번 명령을 성공시키는 것이 아니라, 시스템이 원하는 상태로 계속 돌아오게 만드는 것이었습니다.

 

현재 구현은 https://github.com/Hy3ons/KiteVirtualMachines 에서 진행하고 있습니다. host 서버 쪽 SSH 제어, gateway, CRD 기반 VM 생성 흐름은 큰 방향이 잡혔고, 앞으로는 프론트엔드를 서서히 고도화하면서 사용자 관리, VM 관리, 관리자 기능을 점진적으로 추가해 나갈 예정입니다.