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

황현석 일지

클라우드 서버 구축 일지 05 - Host OS 제어 포기 본문

클라우드 서버 구축 일지 05 - Host OS 제어 포기

회고.

 

저번 글에서는 Kite의 uninstall.sh를 꽤 자세히 다뤘습니다. 설치된 리소스를 지우는 것뿐 아니라, Longhorn webhook, namespace finalizer, host sshd 복원 worker처럼 서버에 남은 흔적을 어디까지 책임져야 하는지에 가까운 이야기였습니다.

 

그 글을 쓰면서 "내가 설치한 것들은 모두 깔끔하게 정리하자."는 쪽으로 생각이 많이 갔습니다. 그런데 이번에는 조금 반대 방향의 질문이 생겼습니다. 다녀간 자리를 잘 치우는 것도 중요하지만, 애초에 어디까지 들어가도 되는가를 먼저 정해야 하는 것 아닌가 싶었습니다.

 

처음에는 Kite를 설치하는 경험을 굉장히 좋게 하고 싶었습니다. 사용자가 README에 있는 명령을 복사해서 install.sh | bash로 실행하면, 필요한 것들이 전부 설치되고, 80번 포트로 웹 UI에 들어가고, VM도 만들고, SSH 접속도 자연스럽게 되는 구조를 생각했습니다.

 

그 생각 자체는 좋아보였습니다. 운영자가 뭔가를 따로 신경쓰지 않아도 되고, 사용자는 ssh vm-id@domain처럼 익숙한 명령으로 VM에 들어갑니다. host OS의 SSH와 Kite VM SSH gateway를 한 주소 안에서 통합하면, 제품 경험이 깔끔해질 거라고 봤습니다.

 

그래서 처음 방향은 22번 포트를 Kite gateway가 맡는 쪽이었습니다. 기존 host sshd는 스크립트 단에서, 다른 포트로 옮기고, gateway가 22번으로 들어오는 SSH를 먼저 받습니다. VM 계정이면 VM으로 보내고, host 계정이면 host sshd로 fallback합니다. 그림으로 보면 꽤 그럴듯했습니다.

 

사용자:
  ssh vm-id@domain

Kite gateway:
  VM 계정이면 VM으로 proxy
  host 계정이면 host sshd로 fallback

host sshd:
  22번에서 다른 관리 포트로 이동

 

그런데 막상 실제 서버에서 생각해보니, 이 구조는 운영자의 편의성을 극대화하는 것처럼 보이면서 동시에 운영자의 제어권을 잃게 만들 수 있었습니다. SSH 22번은 그냥 포트 하나가 아니었습니다. 원격 서버에 다시 들어갈 수 있는 가장 중요한 진입점이었습니다.

이번 고민의 중심. Kite gateway를 host OS의 SSH 진입점과 어디까지 통합할 것인가였습니다. 처음에는 운영 UX를 위해 22번까지 자동으로 가져가고 싶었지만, 실제로는 그 통합이 host OS 접근성을 위험하게 만들 수 있었습니다.

 

운영 UX를 좋게 만들려던 선택이, 오히려 운영 UX를 최악으로 만들 수 있었습니다.

 


 

1. 처음 목표는 운영 UX를 극대화 하는 쪽이었습니다.

 

처음에는 설치 과정을 하나의 명령으로 끝내고 싶었습니다. Kubernetes, KubeVirt, CDI, Longhorn, API, controller, frontend, gateway가 전부 올라오고, 사용자는 웹 UI만 열면 됩니다. 운영자는 복잡한 포트 설정이나 gateway 설정을 직접 보지 않아도 됩니다.

 

SSH도 마찬가지였습니다. VM 접속 명령이 ssh -p 1241 vm-id@domain처럼 보이면 뭔가 제품이 덜 완성된 느낌이 들 수 있다고 생각했습니다. 일반적인 사용자는 포트 번호가 붙는 순간 "이건 뭔가 따로 설정해야 하는 개발용 도구인가"라고 느낄 수도 있습니다. 그래서 22번을 gateway가 맡는 게 사용자 경험상 좋아보였습니다.

처음 생각

integrated

install.sh | bash 한 번으로 설치, 웹 UI, SSH gateway까지 전부 준비합니다. 사용자는 80번 웹과 22번 SSH만 기억하면 됩니다.

숨어 있던 전제

host control

이 UX를 만들려면 Kite installer가 host sshd, 22번 포트, systemd socket, gateway Service까지 건드려야 합니다. 즉 애플리케이션 설치가 host OS 진입점 변경까지 포함하게 됩니다.

그때는 이 전제가 그렇게 위험하게 느껴지지 않았습니다. gateway가 host fallback을 제공하면 되는 것 아닌가 싶었습니다. 하지만 host fallback은 gateway가 제대로 떠 있고, host sshd가 옮겨진 포트에서 살아 있고, gateway가 그 주소로 잘 프록시할 때만 의미가 있습니다. 그중 하나라도 틀어지면 운영자는 서버에 못 들어갈 수 있습니다.


2. 22번 포트 점유는 socket 충돌보다 더 애매했습니다

 

처음에는 22번 충돌을 단순하게 생각했습니다. host sshd가 22번을 잡고 있으면 gateway가 22번을 못 열고, 그러면 실패가 보일 거라고 봤습니다. 그런데 k3s 같은 환경에서는 그렇게 단순하지 않았습니다.

 

이건 특별히 이상한 재현 케이스라기보다, Kubernetes Service 노출 모델에 가까웠습니다. Kubernetes Service 문서를 보면 NodePort는 각 Node IP의 정적 포트로 Service를 노출하고, LoadBalancer는 그 위에 외부 load balancer를 붙이는 타입입니다. 그리고 Virtual IPs and Service Proxies 문서에서는 kube-proxy가 Service와 EndpointSlice를 보고 node의 packet forwarding rule을 구성한다고 설명합니다. Linux에서는 그 구현이 iptables, IPVS, nftables 같은 계층으로 내려갑니다.

 

k3s에서는 이 부분이 더 직접적입니다. k3s ServiceLB 문서에 따르면, ServiceLB는 LoadBalancer Service를 보고 kube-system namespace에 DaemonSet을 만들고, 그 pod가 Service port를 hostPort로 사용합니다. NAT 환경이면 트래픽이 hostPort를 통해 ServiceLB pod로 들어가고, 그 pod가 iptables로 Service의 ClusterIP와 port로 넘깁니다. NAT가 아니더라도 LoadBalancer 주소로 들어온 트래픽은 kube-proxy의 iptables chain이나 IPVS에 의해 Service 경로로 들어갈 수 있습니다.

 

그래서 port: 22를 선언한다는 건 단순히 namespace 안의 앱 포트 하나를 고르는 문제가 아니었습니다. Service 객체는 namespace 안에 있지만, 외부에서 node로 들어오는 22번 트래픽을 어떻게 받을지는 hostPort, NAT, kube-proxy 규칙처럼 node 전체의 네트워크 계층으로 내려갑니다. host sshd가 이미 22번을 잡고 있으면 ServiceLB pod가 pending이 될 수도 있습니다. 반대로 해당 계층이 성공적으로 잡히면, 운영자가 생각한 host sshd의 22번과 Kubernetes가 노출하려는 gateway 22번이 같은 외부 진입점에서 충돌합니다.

 
1

host sshd는 살아 있음

운영자 입장에서는 22번이 host sshd의 진입점입니다. 하지만 Kubernetes Service 노출은 프로세스의 socket bind만으로 설명되지 않습니다.

 
2

Service가 node 계층으로 내려옴

k3s ServiceLB는 LoadBalancer Service port를 hostPort와 iptables 경로로 연결합니다. 즉 Service 선언이 node 외부 트래픽 처리에 영향을 줍니다.

 
3

host 접근 리스크가 됨

ServiceLB가 pending이 되든, 외부 22번이 gateway 경로로 들어가든, installer가 host SSH 진입점과 Service 노출 정책을 동시에 건드린다는 점은 그대로 남습니다.

4

운영 UX가 오히려 나빠짐

그래서 이 문제는 앱 포트 충돌이 아니라, host OS 진입점과 VM gateway 책임 경계의 문제가 됩니다.

이 지점에서 "installer가 해줘야 한다"는 전제를 다시 봤습니다. 22번은 namespace 안에서만 격리되는 값처럼 다루기 어려웠습니다. Service 객체는 namespace에 있어도, 외부에서 node로 들어오는 포트는 결국 hostPort와 netfilter 규칙, load balancer 구현으로 이어집니다. 설치 스크립트가 이 경로를 바꾸는 순간, 설치 실패는 단순한 앱 설치 실패가 아닙니다. 원격 서버 운영 실패가 됩니다.


3. shell로 sshd를 조작하는 건 너무 얇아보였습니다

 

그 다음에는 "그래도 22번 takeover를 안전하게 만들 수는 없을까"를 봤습니다. 단순히 22번을 내리고 다른 포트를 여는 방식은 위험합니다. 그래서 더 안전한 흐름을 생각했습니다.

 

1. 22번은 유지한 채 새 host sshd 포트를 먼저 추가
2. 새 포트로 실제 SSH 접속이 되는지 확인
3. 확인되면 gateway를 22번에 노출
4. gateway fallback이 host sshd 새 포트로 가는지 확인
5. 실패하거나 확인이 없으면 rollback

 

이 정도는 되어야 맞아보였습니다. 그런데 이걸 shell script 하나로 처리하는 건 너무 불안했습니다. 사용자의 현재 SSH 세션에서 실행되는 shell이 중간에 끊기면 어떻게 할 것인지, systemd socket 환경이면 어떻게 볼 것인지, 새 포트가 외부 방화벽이나 NAT에서 정말 들어오는지, 실패했을 때 어느 상태까지 되돌릴 것인지가 전부 남습니다.

 

특히 찜찜했던 건 systemd의 sshd.socket이나 ssh.service를 직접 만져야 할 수도 있다는 점이었습니다. 어떤 환경에서는 sshd 포트를 바꾸려면 socket activation 쪽을 끄거나, service를 재시작하거나, 둘 중 무엇이 실제로 22번을 잡고 있는지 확인해야 합니다. 그런데 나는 바로 그 SSH로 원격 개발을 하고 있는 상태였습니다. 내가 타고 들어온 줄을 내가 끊는 식이 될 수 있었습니다.

 

물론 기존 세션이 바로 끊기지 않을 수도 있고, 설정을 잘 적용하면 문제 없이 넘어갈 수도 있습니다. 하지만 그런 식의 "아마 괜찮을 것"을 installer 기본 동작에 넣는 건 이상했습니다. 특히 원격 서버에서는 작은 실수가 그냥 앱 오류가 아니라, 서버에 다시 못 들어가는 상황으로 이어질 수 있습니다. 여기서 이 방향은 아니라고 꽤 강하게 생각했습니다.

 

그래서 더 큰 방향도 생각했습니다. host-side daemon이나 작은 control plane을 두고, 상태가 충분히 확인될 때까지 단계적으로 조정합니다. 안 되면 전체를 rollback합니다. UI에는 현재 phase를 보여줍니다. 예를 들면 PreparingHostPort, WaitingForConfirmation, SwitchingGateway, Ready, RolledBack 같은 식입니다.

shell script 방식

thin

구현은 빠르지만, 현재 SSH 세션과 host sshd를 동시에 건드립니다. 중간 실패와 재시도, rollback 상태를 믿고 맡기기에는 얇아보였습니다.

host daemon/control plane 방식

heavy

상태를 관찰하고, 확인을 기다리고, 실패하면 되돌릴 수 있습니다. 하지만 privileged host agent, systemd 조작, 상태 머신까지 들어가서 지금 단계의 기본 설치에는 너무 크게 느껴졌습니다.

여기서 다시 생각했습니다. 22번 takeover를 정말 제품의 핵심으로 가져갈 거라면 이 정도 시스템이 필요할 수 있습니다. 하지만 지금 Kite에서 당장 필요한 건 "host OS의 SSH까지 안전하게 재구성하는 플랫폼"이 아니라, VM SSH gateway를 안정적으로 제공하는 것이었습니다. 둘은 비슷해 보이지만 책임 범위가 달랐습니다.


4. host sshd의 모든 조작은 운영주체로 넘기는 것으로 하였습니다.

 

결국 방향을 바꿨습니다. Kite는 host sshd를 옮기지 않습니다. 삭제하지도 않습니다. 22번을 자동으로 가져가지도 않습니다. host OS의 SSH 진입점은 운영자가 책임지는 영역으로 남기는 것이 맞아보였습니다.

 

처음에는 이게 UX를 포기하는 것처럼 보였습니다. 그런데 다시 생각해보니 꼭 그렇지는 않았습니다. 포트는 여러 계층에서 위장하거나 매핑할 수 있습니다. 공유기, NAT, 라우터, cloud load balancer, 방화벽 규칙에서 외부 포트와 내부 포트를 다르게 둘 수 있습니다.

 

예시 1:
  host sshd      -> server:22
  Kite VM gateway -> server:40022

예시 2:
  외부 22     -> 내부 gateway:40022
  외부 12311  -> 내부 host sshd:22

 

즉 Kite가 반드시 host sshd를 직접 옮겨야만 좋은 사용자 경험을 만들 수 있는 건 아니었습니다. 운영자가 자기 환경에 맞게 포트를 정하고, 필요하면 외부 라우팅에서 사용자에게 보이는 포트를 바꾸면 됩니다. Kite는 그 결정을 존중하고, 사용자가 복사할 SSH 명령어를 정확히 보여주면 됩니다.

현재 판단. 사용자가 편하게 느끼는 것은 "항상 22번을 쓰는 것"만은 아니었습니다. 더 중요한 건 UI가 현재 운영자가 정한 접속 방법을 정확히 안내하고, host OS의 진입점을 망가뜨리지 않는 것이었습니다.


5. VM gateway와 host sshd를 분리하니 UI 책임이 더 중요해졌습니다

 

host sshd를 건드리지 않는다고 해서 끝은 아니었습니다. 그러면 이제 Kite는 사용자에게 정확한 VM 접속 방법을 알려줘야 합니다. 운영자가 gateway 외부 포트를 40022로 열었다면, UI는 ssh -p 40022 vm-id@domain을 보여줘야 합니다. 포트가 22번이면 -p를 빼도 됩니다.

 

그리고 이 설정은 설치 시점에 박아두는 값이면 안 될 것 같았습니다. 운영자는 나중에 포트를 바꾸고 싶을 수 있습니다. NAT나 방화벽 정책이 바뀔 수도 있고, 특정 서버에서는 1241번을 쓰고 싶을 수도 있습니다. 그래서 admin config에서 SSH gateway 외부 노출과 포트를 다루는 것이 맞아보였습니다.

 
1

설치 직후

gateway pod는 뜨지만 외부 22번을 자동으로 가져가지 않습니다. host sshd는 그대로 둡니다.

 
2

운영자 설정

admin UI에서 VM SSH gateway를 외부에 열지, 몇 번 포트를 쓸지 정합니다.

 
3

controller reconcile

ConfigMap에 적힌 desired state를 보고 gateway external Service를 만들거나 내립니다.

4

사용자 안내

VM dashboard와 connection drawer는 현재 포트를 읽어서 정확한 SSH 명령을 보여줍니다.

이렇게 되면 VM gateway와 host sshd는 분리됩니다. host sshd는 운영자가 관리합니다. Kite는 VM gateway를 관리합니다. 둘을 한 UX로 자연스럽게 이어주는 역할은 UI와 설정이 맡습니다. 처음에 생각했던 통합보다 덜 자동화되어 있지만, 실패했을 때의 피해가 훨씬 작습니다.


6. ConfigMap과 status는 그래서 필요했습니다

 

설정을 UI에서 바꿀 수 있게 하려면, 단순히 manifest에 port를 박아두면 안 됩니다. 운영자가 dashboard에서 "VM SSH gateway를 1241번으로 열고 싶다"라고 설정할 수 있어야 했습니다. 여기까지는 자연스러웠습니다. 문제는 그 설정을 backend가 어떻게 다뤄야 하느냐였습니다.

 

처음에는 그냥 적용형으로 갈 수도 있다고 봤습니다. admin API가 요청을 받으면 바로 Service를 patch하고, 성공하면 성공 응답을 주고, 실패하면 오류를 돌려주는 방식입니다. 일반적인 웹 설정 API처럼 생각하면 이쪽이 더 단순해보입니다.

 

그런데 Kite는 전체적으로 Kubernetes 위에서 도는 KubeVirt를 한 번 더 간단한 추상화로 묶고, 그 위에 UI/UX를 제공하는 프로젝트입니다. Kubernetes의 기본 감각은 "요청을 한 번 적용하고 끝"이라기보다, 원하는 상태를 선언해두고 controller가 계속 현재 상태를 맞추는 쪽에 가깝습니다. 여기서도 그 철학을 적용하는 게 더 맞아보였습니다.

 

kite-runtime-config
  sshGatewayExternalEnabled=false
  sshGatewayExternalPort=
  sshGatewayHostFallbackEnabled=true
  sshGatewayHostSshdPort=22

kite-gateway-status
  phase=Disabled
  reason=ExternalDisabled
  observedExternalPort=
  message=SSH gateway external service is disabled

 

그래서 runtime config는 명령이 아니라 desired state로 보는 쪽이 맞았습니다. 운영자가 sshGatewayExternalPort=1241을 저장했다는 건 "지금 당장 반드시 성공시켜라"가 아니라 "Kite gateway를 1241번으로 열고 싶은 상태"를 선언한 것입니다. controller는 그 상태를 맞추려고 계속 reconcile하고, 실제로 맞췄는지 못 맞췄는지는 status에 남깁니다.

desired

운영자가 선언한 목표 상태입니다. gateway를 열지, 몇 번 포트로 열지, host fallback을 어디로 둘지를 담습니다.

observed

controller가 관찰한 현재 상태입니다. Service가 만들어졌는지, 막혔는지, 실패했다면 왜 실패했는지를 UI가 읽을 수 있게 둡니다.

이 차이는 포트 충돌 같은 상황에서 더 중요해집니다. 만약 1241번 포트가 아직 어떤 이유로 열리지 못한다면, 적용형 API에서는 backend가 실패를 반환하고 끝납니다. 운영자가 host 쪽 포트 충돌을 풀어도 다시 버튼을 누르거나 API를 다시 호출해야 합니다. 반대로 선언형으로 두면 desired state는 그대로 남아 있고, 충돌이 풀린 뒤 다음 reconcile에서 controller가 다시 맞추려고 합니다.

 

그래서 지금 구조에서는 새 CRD를 바로 만들지는 않더라도, 최소한 ConfigMap을 desired state처럼 사용하고 status ConfigMap을 따로 두는 방향이 좋아보였습니다. 나중에 이 설정이 커지면 KiteGatewayConfig 같은 CRD로 옮길 수 있습니다. 하지만 지금 판단의 핵심은 CRD냐 ConfigMap이냐보다, 이 설정을 일회성 적용 명령으로 보지 않고 계속 reconcile되는 상태로 본다는 점이었습니다.


7. 결론

 

이번 고민은 테스트 스크립트를 표준화하다가 시작됐지만, 실제 중심은 SSH gateway와 host OS의 경계였습니다. 어디까지 Kite가 책임지고, 어디부터 운영자에게 남겨야 하는가의 문제였습니다.

 

처음에는 통합 운영 UX를 극대화하고 싶었습니다. 설치 한 번으로 80번 웹 UI와 22번 SSH gateway가 전부 열리고, 사용자는 포트 생각 없이 쓰는 구조입니다. 그런데 그 통합이 host sshd의 진입점을 가져가는 순간, 실패했을 때 운영자는 원격 제어권을 잃을 수 있었습니다. 이건 UX가 좋아지는 게 아니라, UX가 최악으로 가는 길이었습니다.

 

그래서 지금 결론은 단순합니다. host sshd는 Kite가 조작하지 않습니다. VM SSH gateway는 별도 포트와 별도 Service로 관리합니다. 운영자는 자기 환경에 맞게 포트를 정하고, 필요하면 NAT나 라우터에서 외부 포트를 원하는 대로 매핑합니다. Kite는 그 설정을 UI에 정확히 반영해서 일반 사용자가 헷갈리지 않게 안내합니다.

 

언젠가 22번 takeover를 다시 제대로 하고 싶다면, 그때는 shell script가 아니라 host-side daemon, 확인 토큰, rollback 가능한 상태 머신까지 들고 가야 할 것 같습니다. 하지만 지금 단계에서는 오버엔지니어링이라고 생각했습니다. kite-sshd와 host sshd의 통제를 쉽게 갈 수 있게 하는것이 최선이라고 생각했습니다.