🚀 요약
SUMMARY
GPU가 여러 장 꽂힌 서버에 KVM/libvirt로 VM을 여러 대 올릴 일이 생겼는데,
virt-install로 한 대씩 만드는 건 “같은 스펙 여러 대 + GPU 패스스루 + OS별 초기 설정”이 붙으니 손으로는 재현이 안 됐다. 그래서 Terraform + libvirt 프로바이더로 옮겼다. 스토리지·베이스와 VM을 두 단계로 나눠 remote_state로 잇고, VM 정의를 맵 하나에 적으면for_each가 GPU PCI 패스스루·cloud-init까지 얹어 그대로 찍어낸다.
손으로 만들 때 힘든 건 “한 번 만들기”가 아니라 “똑같이 다시 만들기”였다.
1. 왜 IaC로 옮겼나
libvirt를 손으로 다룰 때 걸리던 건 이런 것들이다.
- VM마다 vCPU·메모리·디스크·네트워크를 인자로 넣는데, 여러 대를 같은 스펙으로 맞추려면 그 인자를 매번 정확히 복붙해야 한다.
- GPU 패스스루가 특히 까다롭다. 어느 GPU(PCI 주소)를 어느 VM에 붙일지를 도메인 XML에 손으로 박아야 하고, 서버마다 PCI 토폴로지가 달라 재현이 어렵다.
- 초기 설정(사용자·SSH 키·패키지)을 VM 뜬 뒤에 또 손으로 한다. OS(Ubuntu/Rocky)마다 방식이 달라 실수가 난다.
Terraform으로 옮기면 이 전부가 선언이 된다. “이런 VM들이 있어야 한다”를 적어두면 apply가 현재 상태와 비교해 맞춰준다. 한 대가 날아가도 다시 apply면 같은 게 선다.
2. 두 단계로 쪼갠 구조
libvirt 리소스를 한 파일에 다 넣으니 관심사가 섞였다. 그래서 두 단계로 나눴다.
01-infra/ # 호스트 공용 인프라 — 자주 안 바뀜
· libvirt storage pool (VM 디스크가 놓일 디렉토리 풀)
· base OS 이미지 볼륨 (cloud 이미지)
→ outputs: pool_name, base_images, libvirt_uri ...
02-vms/ # VM 본체 — 자주 바뀜 (대수·스펙·GPU 매핑)
· terraform_remote_state 로 01-infra 의 출력을 읽어옴
· vms 맵을 돌며 디스크·cloudinit·도메인 생성나눈 이유는 변경 주기가 다르기 때문이다. 스토리지 풀과 베이스 이미지는 거의 안 바뀌는데, VM은 대수를 늘리거나 스펙을 조정하며 자주 건드린다. 자주 바뀌는 걸 따로 두면 apply 범위가 좁아져 안전하다. 둘은 terraform_remote_state로 잇는다. 02-vms가 01-infra의 출력(풀 이름·베이스 이미지·libvirt URI)을 읽어 쓴다.
# 02-vms: 앞 단계의 출력을 읽어온다
data "terraform_remote_state" "infra" {
backend = "local"
config = { path = "../01-infra/terraform.tfstate" }
}3. VM 목록을 맵 하나로
vms 변수가 중심이다. VM 한 대가 맵의 항목 하나고, for_each로 이 맵을 돌며 리소스를 찍는다.
variable "vms" {
type = map(object({
ip = string
os = string # ubuntu22 / ubuntu24 / rocky9
gpus = list(object({ # 이 VM에 붙일 GPU들의 PCI 주소
domain = number
bus = number
slot = number
function = number
}))
memory = optional(number) # 없으면 vm_defaults 사용
vcpu = optional(number)
disk_size = optional(number)
}))
}값은 대략 이런 모양이다(호스트명·IP·PCI는 예시).
vms = {
"gpu-node-01" = {
ip = "10.0.0.11"
os = "rocky9"
gpus = [
{ domain = 0, bus = 0x27, slot = 0, function = 0 },
{ domain = 0, bus = 0x2a, slot = 0, function = 0 },
]
}
"gpu-node-02" = { ip = "10.0.0.12", os = "rocky9", gpus = [ ... ] }
}VM을 늘리려면 맵에 항목 하나 추가하면 끝이다. for_each = var.vms가 그 항목에 대해 디스크·cloud-init·도메인을 알아서 만든다. 스펙은 optional로 두고 coalesce(each.value.memory, var.vm_defaults.memory)처럼 개별 값이 없으면 기본값을 쓴다. 대부분 같고 몇 대만 다른 경우에 딱 맞는다.
4. GPU 패스스루를 코드로
가장 크게 편해진 부분이다. 도메인 리소스의 hostdevs에 PCI 주소를 넣으면 그 물리 GPU가 VM 안으로 그대로 들어간다. VM별 gpus 목록을 돌며 hostdev를 만든다.
resource "libvirt_domain" "gpu_vm" {
for_each = var.vms
memory = coalesce(each.value.memory, var.vm_defaults.memory)
vcpu = coalesce(each.value.vcpu, var.vm_defaults.vcpu)
# gpu_passthrough 스위치가 켜져 있을 때만 GPU를 붙인다
hostdevs = var.vm_defaults.gpu_passthrough ? [
for gpu in each.value.gpus : {
subsys_pci = { addr = {
domain = gpu.domain, bus = gpu.bus, slot = gpu.slot, function = gpu.function
}}
}
] : []
# ... disk / cloudinit / network / graphics ...
}손으로 하면 VM XML을 열어 <hostdev> 블록을 GPU 개수만큼 박아야 하는데, 이제 맵에 PCI 주소만 적으면 된다. 서버가 바뀌어 PCI 토폴로지가 달라져도 고칠 곳이 맵 한 군데다. (물론 host 쪽 IOMMU·vfio 바인딩은 Terraform 밖에서 선행돼야 한다. 그건 별도다.)
5. OS별 초기 설정은 cloud-init + locals로
VM이 뜨자마자 사용자·SSH 키·패키지가 준비돼 있어야 한다. 이건 cloud-init으로 한다. libvirt에선 cloud-init ISO를 만들어 VM에 물린다(libvirt_cloudinit_disk).
문제는 OS마다 세부가 다르다는 것이다. 관리자 그룹(sudo vs wheel), SSH 서비스명(ssh vs sshd), 패키지 이름(apt vs dnf), 그리고 Rocky는 SELinux 컨텍스트 복구(restorecon)가 추가로 필요하다. 이걸 locals에 OS별 표로 정리해 cloud-init 템플릿에 주입한다.
locals {
os_config = {
"ubuntu24" = { admin_groups = "sudo", ssh_service = "ssh", packages = [...] }
"rocky9" = { admin_groups = "wheel", ssh_service = "sshd", packages = [...],
cmd_prepend = ["restorecon -Rv ...", "grubby --update-kernel ..."] }
}
}VM의 os 값으로 이 표에서 골라 쓰니, Ubuntu든 Rocky든 같은 맵에 섞어 적어도 각자 맞는 초기화가 들어간다.
자잘한 뒤처리도 코드에 담았다. UEFI 부팅 VM은 파괴 후 nvram 파일이 남는 문제가 있어서, null_resource로 VM 삭제 시 nvram을 청소하게 뒀다. 손으로 할 땐 매번 까먹던 건데 코드에 한 번 박으니 다신 신경 안 쓴다.
옮기고 나서 제일 좋았던 건 “똑같이 다시”가 공짜가 된 점이다. VM을 늘리는 게 맵에 줄 추가, GPU 재배치가 PCI 주소 수정, OS 추가가 locals에 표 한 칸. 클라우드가 아니라 온프레미스 GPU 서버에서도 Terraform의 선언적 모델이 그대로 통했다. (클라우드 리소스로 Terraform을 처음 익힐 땐 LocalStack으로 연습했었다.)