1. 왜 인벤토리를 그룹으로 나누는가

Kubespray는 인벤토리의 그룹 이름을 그대로 인식한다. kube_control_plane에 속한 호스트는 마스터로 취급하고, kube_node 아래에 있는 호스트는 워커로 조인시킨다. 즉 인벤토리를 어떻게 그룹화하느냐가 곧 클러스터의 노드 역할을 결정한다.

이걸 모르면 IP만 주루룩 나열하는 인벤토리를 만들게 되는데, 그러면 모든 노드가 똑같이 취급된다. GPU 노드에 스토리지 워크로드가 올라가고, 스토리지 노드에 GPU 런타임이 깔리는 식으로 꼬이게 된다. 결국 역할별로 그룹을 나누고, 그룹별로 다른 변수를 먹이는 게 정석이다.

INFO

Ansible의 group_vars는 그룹 이름과 파일명이 같으면 자동으로 로드된다. gpu_node 그룹에만 변수를 주고 싶으면 group_vars/gpu_node.yml을 만들면 된다. 이게 그룹 분리가 인벤토리 설계의 핵심인 이유다.

2. 노드 역할 그룹화

클러스터는 크게 컨트롤플레인, 스토리지, GPU, 일반 워커 네 가지 역할로 노드를 나눴다. 인벤토리의 inventory.yml을 간추리면 이런 구조다. (호스트명·IP는 전부 가상값이다.)

# 컨트롤플레인: 마스터 노드 3대
kube_control_plane:
  hosts:
    master-01:
      ansible_host: 10.0.10.4
      ip: 10.0.10.4
    master-02:
      ansible_host: 10.0.10.18
      ip: 10.0.10.18
    master-03:
      ansible_host: 10.0.10.19
      ip: 10.0.10.19

컨트롤플레인은 3대로 묶었다. etcd도 이 노드들에 같이 올린다. 마스터 3대는 쿼럼 유지를 위한 최소 구성이니, 1대가 죽어도 클러스터는 살아있다.

# 스토리지 노드: NFS 1대 + Rook-Ceph 3대
storage_node:
  hosts:
    storage-01:
      ansible_host: 10.0.20.13
      ip: 10.0.20.13
      node_labels:
        node-role.kubernetes.io/storage: nfs
    storage-02:
      ansible_host: 10.0.20.14
      ip: 10.0.20.14
      node_labels:
        node-role.kubernetes.io/storage: rook-ceph
        node-role.kubernetes.io/disk: longhorn
      node_taints:
      - "dedicated=rook-ceph:NoSchedule"
    storage-03:
      ansible_host: 10.0.20.15
      ip: 10.0.20.15
      node_labels:
        node-role.kubernetes.io/storage: rook-ceph
        node-role.kubernetes.io/disk: longhorn
      node_taints:
      - "dedicated=rook-ceph:NoSchedule"
    storage-04:
      ansible_host: 10.0.20.16
      ip: 10.0.20.16
      node_labels:
        node-role.kubernetes.io/storage: rook-ceph
        node-role.kubernetes.io/disk: longhorn
      node_taints:
      - "dedicated=rook-ceph:NoSchedule"

스토리지 노드는 4대다. storage-01은 NFS 전용, storage-02~04는 Rook-Ceph과 Longhorn 디스크로 쓴다. (Longhorn은 disk 라벨로 구분.) Rook-Ceph 노드에는 dedicated=rook-ceph:NoSchedule 테인트를 걸어서, 일반 파드가 스케줄링되지 않게 막았다. 스토리지 전용 노드에 워크로드가 올라가면 I/O 경합이 생기기 때문이다.

# GPU 노드 2대
gpu_node:
  hosts:
    gpu-worker-01:
      ansible_host: 10.0.20.10
      ip: 10.0.20.10
      node_labels:
        gpu: nvidia
    gpu-worker-02:
      ansible_host: 10.0.10.22
      ip: 10.0.10.22
      node_labels:
        gpu: nvidia
 
# 일반 워커 2대
none_gpu_node:
  hosts:
    worker-01:
      ansible_host: 10.0.20.17
      ip: 10.0.20.17
    worker-02:
      ansible_host: 10.0.20.18
      ip: 10.0.20.18

GPU 노드는 gpu: nvidia 라벨을 붙였다. 나중에 GPU Operator가 이 라벨을 보고 노드를 찾는다. 일반 워커(none_gpu_node)는 GPU가 없는 평범한 노드로, 별도 라벨이나 테인트 없이 뒀다. (이름이 none_gpu인 건 GPU가 없다는 걸 명시적으로 표현하려는 의도다. worker라고 하면 GPU 노드도 워커라 헷갈리니까.)

마지막으로 부모-자식 그룹 관계를 묶는다.

# 워커 노드들을 하나의 부모 그룹으로
kube_node:
  children:
    storage_node:
    gpu_node:
    none_gpu_node:
 
# etcd는 컨트롤플레인과 동일하게
etcd:
  children:
    kube_control_plane:

kube_node 부모 그룹이 세 자식 그룹을 묶는다. Kubespray는 kube_node에 속한 호스트를 워커 노드로 취급해서 클러스터에 조인시킨다. etcdkube_control_plane을 자식으로 가지는데, 결국 마스터 노드에 etcd를 함께 올린다는 뜻이다. (스태키드 etcd 방식.)

3. node_labels와 node_taints

인벤토리에 node_labelsnode_taints를 적어두면, Kubespray가 노드 조인 시 자동으로 라벨과 테인트를 붙여준다. 나중에 kubectl label이나 kubectl taint를 수동으로 칠 필요가 없다.

# 노드에 라벨과 테인트를 인벤토리에서 선언
storage-02:
  node_labels:
    node-role.kubernetes.io/storage: rook-ceph
    node-role.kubernetes.io/disk: longhorn
  node_taints:
  - "dedicated=rook-ceph:NoSchedule"

이런 식으로 선언해두면, Kubespray가 노드를 클러스터에 조인시키는 과정에서 라벨과 테인트를 한 번에 적용한다. 노드 추가 후 별도 작업이 필요 없으니까, scale-out할 때마다 인벤토리 파일만 보면 노드의 역할이 한눈에 들어온다. (선언적이라는 게 이런 맛이다.)

테인트를 거는 이유는 단순하다. 스토리지 노드에 일반 파드가 스케줄링되면 디스크 I/O를 뺏긴다. NoSchedule 테인트로 막아두면, Rook-Ceph이나 Longhorn처럼 톨러레이션(toleration)을 가진 파드만 해당 노드에 올라간다.

4. group_vars 분리

인벤토리 디렉토리 안에 group_vars/를 두면, 그룹별로 변수를 분리할 수 있다. all.yml에 공통 변수를 두고, gpu_node.yml에 GPU 관련 변수만 따로 뺐다.

inventory/dev/
├── inventory.yml
└── group_vars/
    ├── all.yml          # 전체 노드 공통
    └── gpu_node.yml     # GPU 노드 전용

이렇게 나누는 이유는 GPU 변수가 일반 노드에까지 적용되는 걸 막기 위해서다. nvidia_driver_branch 같은 값은 GPU 노드에만 필요하고, 일반 노드에 이 값이 들어가면 의미도 없고 헷갈리기만 한다. (실제로 Kubespray가 GPU 런타임 설정을 일반 노드 containerd에 넣어버리는 사고를 한 번 겪었다. 변수를 그룹별로 분리한 뒤로는 깔끔해졌다.)

5. group_vars/all.yml 주요 변수

all.yml은 클러스터 전체에 적용되는 변수를 담는다. 자주 건드리는 값들만 정리했다.

# Ansible 접속 설정
ansible_user: deploy
ansible_become: true
 
# 바이너리 경로
bin_dir: /usr/local/bin
 
# 데이터 경로를 /data로 이동
containerd_storage_dir: /data/containerd
etcd_data_dir: /data/etcd
 
# 외부 로드밸런서 (API 서버용)
apiserver_loadbalancer_domain_name: "k8s-api.example.com"
loadbalancer_apiserver:
  address: 203.0.113.10
  port: 6000
 
# 쿠버네티스 버전
kube_version: 1.32.6

ansible_user는 SSH 접속 계정, ansible_become은 sudo 권한 사용 여부다. Kubespray는 root 권한이 필요한 작업이 많으니 true로 둔다.

# 서비스 CIDR (ClusterIP 대역)
kube_service_addresses: 10.20.0.0/16
 
# 파드 CIDR
kube_pods_subnet: 10.10.0.0/16
 
# 노드당 파드 서브넷 prefix
kube_network_node_prefix: 22

CIDR은 클러스터 설계에서 한 번 정하면 바꾸기 어렵다. 서비스 대역을 10.20.0.0/16, 파드 대역을 10.10.0.0/16으로 잡았다. kube_network_node_prefix: 22는 노드마다 /22 서브넷(1024개 IP)을 할당한다는 뜻이다. 노드당 파드가 1024개면 대부분의 규모에선 충분하다. (파드가 1024개 꽉 찰 일은 거의 없으니까.)

# CNI 제외 (별도 설치)
kube_network_plugin: none
 
# kube-proxy 제거 (Cilium으로 대체)
kube_proxy_remove: true
 
# 노드 로컬 DNS 비활성화
enable_nodelocaldns: false
 
# 인증서 자동 갱신
auto_renew_certificates: true
 
# etcd 배포 방식
etcd_deployment_type: kubeadm

CNI와 kube-proxy를 아예 빼는 건, Cilium을 별도로 설치하기 위해서다. Kubespray 기본 Cilium은 Envoy가 빠져 있어서 Service Mesh를 온전히 못 쓴다. 그래서 CNI 설치를 건너뛰고, 클러스터가 뜬 뒤 Cilium Helm 차트로 직접 올린다. (이건 클러스터링 글에서 다룬 내용이다.)

enable_nodelocaldns: false는 노드 로컬 DNS를 끈 거다. iptables에 의존하는 컴포넌트라, iptables-free 환경을 만들고 싶어서 껐다. auto_renew_certificates: true는 인증서 만료 걱정을 덜어준다. Kubespray가 알아서 갱신하니까, 1년마다 수동으로 인증서를 밀어야 하는 귀찮은 일이 사라진다.

# Entra ID(구 Azure AD) OIDC 인증
kube_oidc_auth: true

OIDC는 Entra ID를 IdP로 써서 API 서버 인증을 외부로 위임한 거다. kubectl을 OIDC 토큰으로 인증할 수 있어서, 인증서 기반 접속보다 관리가 편하다. (상세한 인증인가 구성은 별도 글에서 다룬다.)

6. group_vars/gpu_node.yml

GPU 노드 전용 변수다. 드라이버 브랜치와 CUDA 버전, 그리고 containerd에 NVIDIA 런타임을 등록하는 설정이 들어간다.

# NVIDIA 드라이버 브랜치
nvidia_driver_branch: "570"
cuda_version: "cuda-toolkit-12-8"
 
# containerd에 NVIDIA 런타임 추가
containerd_additional_runtimes:
  - name: nvidia
    type: "io.containerd.runc.v2"
    engine: ""
    root: ""
    options:
      systemdCgroup: "true"
      BinaryName: "/usr/bin/nvidia-container-runtime"

이 값들은 gpu_node 그룹에만 적용된다. all.yml이 아니라 gpu_node.yml에 있는 게 핵심이다. 일반 워커나 스토리지 노드에는 NVIDIA 런타임 설정이 들어가지 않는다. Kubespray가 이 변수를 읽어서 containerd 설정 파일에 nvidia 런타임을 추가해주니, GPU 파드가 nvidia-container-runtime을 통해 GPU를 인식할 수 있다.

NOTE

드라이버 설치 자체는 Kubespray가 아닌 별도 플레이북(install_cuda.yml)에서 처리한다. NVIDIA 공식 role을 오프라인 환경에 맞게 뜯어고친 건데, 이건 NVIDIA 드라이버 설치 글에서 다룬다.

7. 환경별 인벤토리 관리

인벤토리는 클러스터 환경마다 하나씩 둔다. 리포에는 현재 세 개가 있다.

inventory/
├── dev/              # 개발 클러스터
├── offline_test/      # 오프라인 테스트
└── prod/             # 프로덕션

각 디렉토리 안에 inventory.ymlgroup_vars/가 들어있다. 환경마다 노드 수, IP, CIDR이 다르니 인벤토리를 분리하는 게 자연스럽다. 변수를 환경별로 오버라이드할 때도 파일만 보면 되니까 관리가 편하다.

dev는 개발용, offline_test는 폐쇄망 설치 테스트용, prod가 실제 프로덕션이다. 플레이북 실행 시 -i 플래그로 인벤토리를 선택한다.

# dev 클러스터에 배포
ansible-playbook -i inventory/dev/inventory.yml playbooks/kubernetes/cluster.yml

인벤토리 경로만 바꾸면 같은 플레이북으로 모든 환경에 배포할 수 있다. 이게 인벤토리를 환경별로 나누는 가장 큰 이유다. 코드(플레이북)는 공통으로 두고, 데이터(인벤토리)만 환경별로 관리하는 구조다.

IMPORTANT

환경이 늘어나면 인벤토리 간 변수 중복이 생기기 쉽다. devoffline_test가 거의 같은데 노드만 다르다면, 공통 변수를 group_vars/all.yml로 빼고 환경별로 다른 값만 남기는 게 낫다. 인벤토리 전체를 복사해서 관리하면 어느 순간 동기화가 깨진다.