요약

SUMMARY

고객사 환경 대부분이 air-gapped 온프레미스였고, 그 위에 쿠버네티스를 반복해서 설치해야 했다. 직접 작성한 셸 스크립트에서 kubespray로, 다시 Deck이라는 자체 워크플로우 도구로 옮겨온 기록이다. 온라인에서 prepare로 아티팩트를 모아 bundle로 묶고, 폐쇄망에서 apply로 실행한다. 선언형 YAML 워크플로우와 멱등한 typed step으로, 셸보다 검증·재실행·리뷰가 쉬운 구조를 지향했다.

1. 도입 배경

도구를 새로 만드는 것은 대개 나쁜 선택이라 최대한 피하고 싶었다. 그러나 이번에는 셸 → kubespray로 두 번 전환하고도 계속 불편함이 남아서, 결국 직접 만들게 되었다.

내가 주로 다루던 현장은 다음과 같았다.

  • 인터넷 접속이 완전히 차단되어 있다.
  • 노드에 SSH로 접속하는 것조차 차단되거나 곤란한 경우가 있다.
  • PXE 부팅, BMC 원격 관리 같은 기능은 애초에 제공받지 못했다.

이런 환경에 쿠버네티스를 설치하는 업무가 자주 발생했다.

1세대: kubeadm + 직접 짠 셸 스크립트

처음에는 kubeadm을 단독으로 사용하면서, OS 설정부터 k8s 부트스트랩, 차트 설치까지의 과정을 셸 스크립트로 직접 엮었다. 실행은 되었지만, 문제는 k8s 버전이나 OS 버전이 바뀔 때였다. 버전 간 차이를 스크립트에 반영하고 분기를 추가하다 보니 관리 복잡도가 급격히 증가했고, 검증과 재실행과 리뷰가 어려운 셸의 한계를 그대로 확인했다.

2세대: kubespray + kubespray-offline

그래서 kubespray와 kubespray-offline으로 전환했다. 다양한 버전·환경에 대응하기가 한결 수월해졌지만, 이 방식도 장점만 있는 것은 아니었다.

  • kubespray 릴리스 하나가 대응하는 쿠버네티스 마이너 버전 범위가 3개 남짓(N-2)으로 좁다. 그 밖의 버전을 원하면 kubespray 버전 자체를 옮겨야 한다.
  • kubespray 버전에 따라 요구하는 Ansible 버전이 달라지거나, 인벤토리 필드 값이 바뀌는 경우가 생긴다.
  • CNI 컴포넌트 버전이 고정되어 있거나, CRI 설치 과정에서 호스트의 기존 컨테이너 런타임이 재구성되는 등 커스터마이징에 제약이 많다.
  • kubespray-offline은 결국 셸 스크립트 기반이다. 실행하려면 오프라인 미러를 컨테이너로 띄워야 하므로, containerd(혹은 podman)를 먼저 반입·설치하고 그 위에 nginx 웹서버와 도커 레지스트리를 올려야 한다. 셸에서 벗어나지 못한 데다, 설치 환경에서 요구하는 사전 작업도 많았다.

그리고 무엇보다 결정적이었던 것은, 고객사 보안 요구가 강화되면서 서버 간 SSH 접근에 제약이 걸리는 경우가 발생했다는 점이다. kubespray를 포함한 Ansible 계열은 SSH를 전제로 동작하므로, 이 지점에서 전체 흐름이 막혔다.

그래서 Deck

가급적 Ansible이나 Terraform을 사용하고 싶었다. 하지만 Ansible은 SSH 친화적이라 SSH가 차단된 환경에서 곤란했고(localhost 모드로 우회하면 결국 노드마다 접속하여 실행해야 한다), Terraform은 대상에 맞는 provider가 없으면 애초에 사용할 수 없었다. 남는 선택지는 다시 셸인데, 그것은 1세대에서 이미 한계를 확인했다.

결국 이 불편함들을 하나씩 해소할 도구를 직접 만들기로 했다. kubeadm은 그대로 사용하되(Deck 안의 한 step으로 다룬다), 그 위에서 OS 설정부터 차트 설치까지를 엮던 오케스트레이션 계층, 즉 예전의 셸과 그다음의 kubespray를 대체하는 워크플로우 도구다.

IMPORTANT

그래서 처음부터 범위를 명확히 나누었다. Deck은 범용 IaC 도구가 아니다. 연결된 일반 환경이라면 Ansible·Terraform·Pulumi를 사용하는 것이 맞다. Deck은 SSH/PXE/BMC가 없고 번들 반입과 현장 검증이 중요한, 바로 그 극단적인 경우에만 집중한다. 정의하자면 IaC라기보다 “셸 스크립트보다 구조화된 워크플로우 러너”에 가깝다.

2. 방향과 언어

방향은 좁게 잡았다. 번들 하나로 반입이 끝나야 하고(자체 완결), 명령과 step의 표면적은 최소로 유지한다. “이것도 있으면 좋겠다”는 요구를 계속 거부하지 않으면 또 하나의 무거운 범용 도구가 되기 때문이다.

언어는 Go를 선택했다. 정적으로 링크된 단일 바이너리, 쉬운 크로스 컴파일, 그리고 HTTP 서버와 도커 레지스트리 프로토콜까지 한 바이너리에 담을 수 있다는 점이 결정적이었다. 폐쇄망에 반입할 도구가 런타임이나 인터프리터를 요구하면 그 자체로 실격이다.

3. 네트워크 경계로 갈리는 흐름

Deck의 실행은 시간축이 아니라 네트워크 경계로 구분된다. 인터넷이 되는 쪽에서 필요한 것을 모두 수집하여 번들로 묶고, 폐쇄망에서는 그 번들만으로 실행한다.

온라인 단계. 인터넷이 되는 빌드 머신에서 워크플로우가 참조하는 리눅스 패키지·쿠버네티스 바이너리·컨테이너 이미지·임의 파일을 prepare로 모으고, bundle buildbundle.tar 하나에 묶는다. 이 번들이 폐쇄망으로 넘어가는 유일한 산출물이다.

# 워크스페이스 생성 → 워크플로우 검증 → 아티팩트 수집 → 번들 패키징
deck init --out ./site
cd ./site
deck lint
deck prepare
deck bundle build --out ./bundle.tar

오프라인 단계. 번들을 반입하면 먼저 무결성을 확인하고, 폐쇄망 노드에서 apply로 워크플로우를 실행한다. 이 단계부터는 인터넷이 필요 없다.

# 반입 직후 무결성 확인 (전송 중 손상은 실행 도중이 아니라 실행 전에 걸러야 한다)
deck bundle verify --file ./bundle.tar
 
# 폐쇄망 노드에서 워크플로우 실행. 실패했다가 다시 돌려도 완료된 step은 건너뛴다.
deck apply

prepare·bundle·apply로 단계를 나눈 이유는, 각 단계가 실행되는 위치(온라인 빌드 머신 vs 폐쇄망 노드)가 다르기 때문이다. 경계를 명령으로 드러내야 “지금 어디서 무엇을 하는 중인지”가 혼동되지 않는다.

4. 내장 서버

멀티 노드 지원이 대단한 난관은 아니었다. 워크플로우 구성에 따라 노드마다 번들을 반입하여 개별로 apply해도 된다. 다만 같은 데이터를 노드마다 USB로 운반하는 것이 번거로웠기 때문에, 한 노드에 반입한 데이터를 나머지 노드가 가져다 쓰면 편리하겠다고 판단하여 서버 기능을 추가했다.

마침 환경도 이 방향과 맞았다. SSH는 차단되었더라도 서버 간 TCP 통신에는 제약이 없거나, 있더라도 방화벽 허용 신청이 SSH보다 수월한 경우가 많았다. 그래서 서버가 노드에 밀어넣는(push) 방식 대신 각 노드가 HTTP로 가져가는(pull) 구조로 잡았다. 파일 서버와 이미지 미러를 한 바이너리에 담아 한 노드에서 deck server up으로 띄우면, 나머지 노드는 그 주소에서 바이너리·워크플로우·패키지·이미지를 pull한다. nginx와 레지스트리를 따로 세우던 kubespray-offline 방식의 사전 작업이 없어진 셈이다.

  • 정적 파일 서버: 패키지·바이너리·워크플로우 YAML을 HTTP로 배포한다.
  • pull 전용 도커 레지스트리: 표준 /v2 API로 이미지를 제공한다. 다른 노드의 containerd는 이 주소를 일반 레지스트리로 인식하고 이미지를 pull한다.

여기서 구현상 하나 짚고 넘어갈 부분이 있다. 이 레지스트리는 표준 OCI push를 처리하는 완전한 레지스트리가 아니다. google/go-containerregistrytarball을 소스로 삼아 read-only /v2 pull만 제공하는 구조다. 폐쇄망 노드는 이미지를 내려받기만 하면 되지 push할 일이 없으므로, blob 저장과 layer push까지 구현할 이유가 없었다. skopeo 같은 외부 바이너리를 반입하는 대신 이 라이브러리를 사용한 덕분에 “단일 바이너리” 원칙도 지켰다.

서버는 콘텐츠를 제공할 뿐, 노드에 명령을 밀어넣지 않는다. 실제 실행은 각 노드의 로컬 엔진이 수행하므로, 단일 노드에서 직접 실행하든 서버에서 받아 실행하든 노드에서 실행되는 방식은 동일하다.

5. YAML 워크플로우와 typed step

워크플로우 문법은 helm·k8s에 익숙한 사람이 처음 봐도 읽히도록 만들고 싶었다. 그래서 version·vars·steps 정도의 얕은 구조를 선택했고, 각 step은 kind로 어떤 작업인지 선언한다.

version: v1alpha1
vars:
  kubernetesVersion: v1.30.1
 
steps:
  - id: disable-swap
    kind: Swap
    spec:
      disable: true
      persist: true
 
  - id: load-kernel-modules
    kind: KernelModule
    spec:
      names: [overlay, br_netfilter]
      load: true
      persist: true
 
  - id: kubeadm-init
    kind: InitKubeadm
    spec:
      configFile: /tmp/deck/kubeadm-init.yaml
      configTemplate: |          # 표준 kubeadm 설정을 그대로 임베드
        apiVersion: kubeadm.k8s.io/v1beta4
        kind: ClusterConfiguration
        kubernetesVersion: "{{ .vars.kubernetesVersion }}"
        networking:
          podSubnet: 10.244.0.0/16

typed stepkind마다 스키마가 정해진 선언형 작업 단위다. swapoff -a 명령을 셸로 실행하는 대신 kind: Swap을 쓰고, 커널 모듈은 kind: KernelModule, 파일 배포는 kind: CopyFile/WriteFile, 이미지는 kind: DownloadImage/LoadImage 방식으로 다룬다. 쿠버네티스 관련 작업도 InitKubeadm·JoinKubeadm·ResetKubeadm·UpgradeKubeadm으로 나누어 두었다.

임의 셸을 실행하는 kind: Command도 있지만, 되도록 typed step을 사용하는 것을 전제로 했다. 서비스·명령·파일·포트의 준비를 기다리는 처리도 셸 루프 대신 WaitForService·WaitForCommand·WaitForFile·WaitForTCPPort 같은 전용 kind로 수행한다.

kind 이름은 대체로 동사+목적어형(DownloadImage·WriteFile·InitKubeadm)으로 맞췄다. 다만 이것이 명칭만 길어진 결과가 아닌지는 지금도 가끔 의심스럽다.

6. 멱등성

셸 스크립트가 위험한 이유는 두 번 실행하면 두 번 모두 다르게 동작할 수 있기 때문이다. 파일이 이미 있고, 설정이 이미 반영되어 있고, 패키지가 이미 설치된 상태에서 다시 실행했을 때도 결과가 같아야 한다. 그래서 각 typed step이 멱등성(idempotency)을 스스로 책임지도록 만들었다.

특히 신경 쓴 부분은 설정 파일 편집이다. containerd의 config.toml에 pause 이미지나 insecure 레지스트리 설정을 중복 없이 추가하거나 제거해야 하는데, 이 작업을 매번 sed로 수행하면 두 번 실행했을 때 같은 블록이 두 번 삽입된다. 그래서 containerd 설정은 아예 전용 kind(WriteContainerdConfig, 레지스트리 호스트는 WriteContainerdRegistryHosts)로 두어, 필요한 키만 조정하고 이미 원하는 상태라면 아무것도 하지 않도록 했다.

# containerd 설정을 선언적으로 조정한다. 이미 값이 맞으면 파일을 건드리지 않는다.
- id: configure-containerd
  kind: WriteContainerdConfig
  spec:
    path: /etc/containerd/config.toml
    systemdCgroup: true
    sandboxImage: registry.k8s.io/pause:3.9

범용 파일도 같은 발상을 적용하여, 임의 TOML/YAML/JSON은 EditTOML·EditYAML·EditJSON이 경로 단위로 값을 조정한다.

7. 상태 관리와 스키마 관리

상태 관리. apply가 중간에 실패하면 처음부터 다시 실행하고 싶지 않다. 그래서 완료된 단계(phase)를 상태로 기록하여, 재실행하면 완료된 항목은 건너뛰고 이어서 진행한다.

스키마 관리. typed step이 늘어나면서 문서화가 골칫거리가 되었다. 손으로 작성한 문서는 코드와 금방 어긋난다. 그래서 Go struct를 단일 진실 공급원(source of truth)으로 삼고, 거기서 각 kind의 스키마 문서를 생성하는 파이프라인을 도입했다. 스키마 문서를 고치려면 문서가 아니라 struct를 수정해야 한다. 번거로워 보이지만, 이렇게 해야 “문서는 그렇다고 하는데 실제로는 안 된다” 같은 사고를 막을 수 있다. 폐쇄망에서는 그 사소한 어긋남 하나가 현장의 몇 시간을 소모시킨다.

8. 하면서 배운 것

절반은 시행착오였다. 처음에는 이것저것 모두 가능한 범용 도구를 지향했다가, “그것은 Ansible이 이미 더 잘한다”는 한계에 계속 부딪혀 기능을 제거하는 방향으로 되돌아왔다. 미출시 프로젝트라 하위 호환을 고려하지 않은 것이 이때 크게 도움이 되었다. 어제 만든 것을 오늘 미련 없이 삭제할 수 있었다.

명령어 표면, DSL 문법, kind 네이밍 같은 부분은 지금도 “이것이 최선인가” 하는 의문이 남아 있다. 요구 정의부터 DSL 문법, 멱등성 추상화, 상태 관리, 다운로드용 샌드박스까지 밑바닥을 직접 설계해 보니, 평소 잘 사용하던 도구들이 그 자리에 오기까지 얼마나 많은 결정을 거쳤을지 조금은 짐작하게 되었다.

최종적으로 이 도구로 고객사 설치도 수행하고 내부 테스트 환경도 구축하면서, 실제로 쓸 만하다는 것을 확인했다. 세상을 바꿀 도구는 아니고 앞으로 갈 길도 멀지만, SSH도 PXE도 프록시도 없는 공간에 USB 하나만 들고 들어가 apply 한 번으로 클러스터를 구축하는 과정은 전보다 확실히 간결해졌다.

참고