지난 글에서는 토이 프로젝트를 직접 운영하며 Kubernetes의 배포와 운영을 익힐 수 있는 홈랩을 만들기로 했습니다. 이번 글에서는 그 첫 단계로, NUC 한 대에 Proxmox를 설치하고 Terraform으로 Bastion과 Kubernetes 노드를 구성한 과정을 다룹니다.
처음 구성하려는 서버 구성은 Bastion과 Kubernetes Control Plane, 개발/운영 워커까지 네 대의 노드입니다. VM마다 OS를 설치하고 계정과 네트워크를 설정할 수도 있지만, 추후에 노드를 증설하는 동일한 환경을 구성하는 과정을 자동화할 수 있으면 좋을것 같다는 생각을 했습니다.
이번 글에서는 Proxmox 위에 Rocky Linux 템플릿을 준비하고, Terraform으로 복제하면서 Cloud-Init으로 초기 설정을 전달하는 IaC 방식으로 각 노드를 프로비저닝하기 위해 고민했던 과정을 기록합니다.
한 대의 PC에서 여러 서버를 관리하려면
이번 홈랩에는 관리 작업을 맡을 Bastion과 Kubernetes Control Plane, 개발/운영 워커 노드가 필요했습니다. 이 서버들을 NUC 한 대 안에서 각각의 VM으로 실행하기 위해 선택한 것이 Proxmox VE입니다.
Proxmox VE는 KVM 기반 가상 머신과 LXC 컨테이너를 관리하는 가상화 플랫폼으로, 웹 화면에서 VM을 만들고 CPU와 메모리, 디스크, 네트워크를 설정할 수 있습니다. 이번에는 각 서버를 Rocky Linux VM으로 구성하고, 노드마다 필요한 자원을 할당했습니다.
VM의 사양과 설정은 Terraform 코드로 관리하기로 했습니다. 앞으로 노드 구성이 바뀌더라도 변경 내용을 확인할 수 있고, 환경을 다시 만들 때도 같은 코드를 기준으로 삼을 수 있기 때문입니다. 코드에 필요한 VM을 정의하면 Terraform이 Proxmox API를 호출해 적용하고, 생성된 VM은 Proxmox 위에서 실행되는 구조입니다.
이번 구성에서 정의한 노드 별 프로비저닝 사양은 아래와 같습니다.
| VM | vCPU | RAM | Disk | 역할 |
| infra-bastion | 2 | 2GB | 20GB | 관리 접속, Terraform·Ansible 실행 환경 |
| k8s-master | 2 | 4GB | 40GB | Kubernetes Control Plane |
| k8s-worker-prod | 4 | 24GB | 200GB | 운영 워크로드 |
| k8s-worker-dev | 4 | 24GB | 150GB | 개발 워크로드 |
이렇게 역할별로 VM을 나눠도 현재는 단일 서버 환경이기 때문에, NUC가 멈추면 모든 서버가 함께 영향을 받습니다. 따라서 이번 구성에서는 개발과 운영의 실행 공간을 논리적으로 나누는 데까지 범위를 잡았습니다.
Proxmox 설치와 관리 네트워크 설정
먼저 Proxmox VE ISO를 내려받아 Rufus로 설치 USB를 만들었습니다. NUC에 USB와 유선 네트워크를 연결해 설치를 진행한 뒤, 브라우저에서 다음 주소로 관리 화면에 접속했습니다.
https://<Proxmox 관리 IP>:8006

패키지 저장소 설정
관리 화면에 접속한 뒤에는 패키지를 받을 저장소부터 설정했습니다. 구독 없이 사용하는 홈랩이므로 Enterprise 저장소를 비활성화하고 No-Subscription 저장소를 추가한 뒤 업데이트를 진행했습니다.
- Datacenter > pve > Updates > Repositories 접근
- enterprise 항목 모두 Disable
- Add > No-Subscription

저장소를 추가한 다음에는 Updates → Upgrade에서 업데이트를 실행할 수 있습니다.
관리 IP 설정
이어서 공유기에 유선으로 연결한 환경에 맞춰 Proxmox의 관리 IP를 설정했습니다. 먼저 Mac에서 기본 게이트웨이를 확인했습니다.
# Mac에서 실행
route -n get default | grep gateway
gateway: 192.168.0.1
게이트웨이는 192.168.0.1이었고, Proxmox의 관리 주소는 192.168.0.24/24로 지정했습니다.
Proxmox 호스트 콘솔에서 /etc/network/interfaces를 열어 vmbr0 부분을 수정했습니다.
# Proxmox 호스트에서 실행
nano /etc/network/interfaces
auto vmbr0
iface vmbr0 inet static
address 192.168.0.24/24
gateway 192.168.0.1
bridge-ports nic0
bridge-stp off
bridge-fd 0
vmbr0는 VM의 가상 네트워크 인터페이스를 연결할 브리지입니다. 여기서는 물리 NIC인 nic0와 연결해 VM이 LAN과 통신할 수 있도록 구성했습니다. 인터페이스 이름은 장비마다 다르므로 실제 호스트의 이름을 확인해 지정해야 합니다.
설정을 저장한 뒤에는 다음 명령으로 네트워크를 다시 적용하고 IP를 확인했습니다.
systemctl restart networking
ip addr
관리 IP가 바뀌면 기존 웹 화면이나 SSH 연결이 끊길 수 있습니다. 따라서 로컬 콘솔로 접근할 수 있는 상태에서 작업하고, 적용 후에는 변경한 주소의 8006 포트로 다시 접속합니다.
Terraform이 Proxmox에 접근할 수 있도록 준비하기
Mac : 테라폼 설치하기
Proxmox의 기본 설정을 마친 뒤에는 VM 생성을 자동화할 준비를 했습니다. Bastion을 포함한 최초 VM 생성은 Mac에서 진행했으며, Terraform은 Homebrew로 설치했습니다.
# HashiCorp 공식 저장소 추가 (Tap)
brew tap hashicorp/tap
# 테라폼 설치
brew install hashicorp/tap/terraform
# 설치 확인
terraform -v
제 환경에서는 Command Line Tools가 오래됐다는 오류로 설치가 막혔습니다. 기존 Command Line Tools를 제거한 뒤 xcode-select --install로 다시 설치하고 Terraform 설치를 재시도했습니다. 같은 오류가 없다면 이 과정은 건너뛰어도 됩니다.
Promox : API 토큰 발급 및 권한 부여
Terraform이 Proxmox API를 호출할 수 있도록 Proxmox 관리 화면에서 root@pam 사용자의 terraform 토큰을 생성했습니다.
- Datacenter > Permissions > API Token > Add

작업 중에는 VM.Monitor 권한이 없다는 오류가 발생했고, 다음 명령으로 토큰에 Administrator 역할을 부여했습니다.
# Proxmox 호스트에서 실행한 당시의 권한 설정
pveum acl modify -token 'root@pam!terraform' -role Administrator
Administrator는 전체 경로에 관리 권한을 부여하므로, 운영 시에는 필요한 권한으로 범위를 제한하는 것이 좋습니다.
다음으로 Terraform 작업 디렉터리에 provider.tf를 작성했습니다. Provider는 Terraform이 Proxmox API를 통해 리소스를 관리할 수 있도록 연결해주는 구성 요소입니다. 접속 정보는 var.* 변수를 통해 전달하도록 정리했습니다.
terraform {
required_providers {
proxmox = {
source = "telmate/proxmox"
version = "3.0.2-rc04"
}
}
}
provider "proxmox" {
pm_api_url = var.pm_api_url
pm_api_token_id = var.pm_api_token_id
pm_api_token_secret = var.pm_api_token_secret
pm_tls_insecure = var.pm_tls_insecure
}
실제 접속 정보와 공개 키는 terraform.tfvars에서 전달할 수 있습니다. 다음은 입력 형식을 보여주는 예시이며, 실제 토큰이 들어 있는 파일은 Git 추적 대상에서 제외해야 합니다.
# terraform.tfvars 작성 예시
pm_api_url = "<https://192.168.0.24:8006/api2/json>"
pm_api_token_id = "root@pam!terraform"
pm_api_token_secret = "<발급받은 토큰 값>"
ssh_public_key = "<Mac의 SSH 공개 키 전체 내용>"
pm_tls_insecure = true는 API 연결 시 TLS 인증서 검증을 생략하는 설정입니다. 인증서를 신뢰할 수 있도록 구성한 환경에서는 false로 설정해 검증을 활성화할 수 있습니다.
# Mac의 Terraform 작업 디렉터리에서 실행
terraform init
Provider를 초기화한 뒤에는 VM을 복제할 기준이 되는 Rocky Linux 템플릿을 준비했습니다.
Rocky Linux를 매번 설치하지 않도록 템플릿 만들기
네 대의 VM은 모두 Rocky Linux를 사용하므로, 공통 운영체제를 템플릿으로 만들어 재사용하도록 구성했습니다. 이 과정에서는 클라우드 이미지와 VM 템플릿, Cloud-Init이 각각 다른 역할을 맡습니다.
Rocky Linux 클라우드 이미지는 운영체제가 미리 설치된 가상 디스크 이미지입니다. 이 이미지를 Proxmox VM에 연결하고 가상 하드웨어를 설정한 뒤 템플릿으로 전환하면, 새 VM을 복제할 원본으로 사용할 수 있습니다.
복제한 VM마다 달라지는 사용자와 SSH 공개 키, 네트워크 설정은 Cloud-Init이 처리합니다. Terraform에서 지정한 값을 Proxmox가 Cloud-Init 드라이브로 전달하면, VM 내부의 Cloud-Init이 부팅 과정에서 이를 읽어 초기 설정을 적용합니다. 덕분에 운영체제는 같은 템플릿을 사용하면서도 노드마다 필요한 설정을 전달할 수 있습니다.
Rocky Linux를 템플릿으로 준비하기
Proxmox 콘솔에서 다음 명령을 실행했습니다. 먼저 클라우드 이미지를 내려받고, 템플릿으로 사용할 VM 9000을 생성했습니다.
- Promox UI > Datacenter > pve > Shell 접속
# Rocky Linux 9 클라우드 이미지 다운로드
wget <https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2>
# 템플릿으로 사용할 VM 생성
qm create 9000 --name Rocky-9-Template --memory 2048 --net0 virtio,bridge=vmbr0
# 이미지를 Proxmox 스토리지로 가져오기
qm importdisk 9000 Rocky-9-GenericCloud-Base.latest.x86_64.qcow2 local-lvm
가져온 이미지는 scsi0에 운영체제 디스크로 연결하고, ide2에는 초기 설정을 전달할 Cloud-Init 드라이브를 추가했습니다.
qm set 9000 --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-9000-disk-0
qm set 9000 --ide2 local-lvm:cloudinit
이어서 부팅 디스크와 콘솔을 설정하고 VM을 템플릿으로 전환했습니다.
qm set 9000 --boot c --bootdisk scsi0
qm set 9000 --serial0 socket --vga serial0
qm template 9000
여기서 지정한 메모리와 디스크 구성은 템플릿의 기본 설정입니다. 실제 노드에 할당할 CPU, 메모리, 디스크 크기는 이후 Terraform 코드에서 지정합니다.
Cloud-Init 템플릿으로 VM의 디스크 공간을 할당하는 과정
템플릿을 준비하면서 궁금했던 부분은 이미지 파일의 크기와 실제 VM에 할당하는 디스크 용량의 관계였습니다. 다운로드한 qcow2 파일의 크기와 이미지가 나타내는 가상 디스크 용량은 같지 않을 수 있습니다. 따라서 파일이 작다고 해서 템플릿의 디스크도 같은 크기라고 볼 수는 없습니다.
Terraform에 디스크 크기를 지정하면 Proxmox에서 복제한 VM의 가상 디스크 용량을 설정합니다. 기존 이미지보다 큰 디스크로 확장한 경우에는 게스트 운영체제 안의 파티션과 파일시스템도 늘어나야 추가 공간을 사용할 수 있습니다.
Cloud-Init의 growpart와 resizefs는 이 과정을 처리하는 모듈입니다. 다만 자동 확장 여부는 이미지의 설정과 파티션 구조에 따라 달라지므로, VM 생성 후에는 lsblk와 df -h로 디스크와 파일시스템 크기를 각각 확인할 수 있습니다.
Terraform으로 노드별 값을 넣어 복제하기
템플릿이 준비된 뒤 각 노드를 생성하기 위한 Terraform 구조를 구성했습니다.
Mac : SSH Key 확인
템플릿을 준비한 뒤에는 노드에 접속할 SSH 공개 키를 확인했습니다. 이번 구성에서는 rocky 계정에 공개 키를 전달하는 방식으로 접속하도록 설정했습니다.
cat ~/.ssh/id_rsa.pub
확인한 공개 키는 terraform.tfvars의 ssh_public_key에 입력합니다.
Terraform 파일 구성
노드별 사양은 vms 맵에서 관리하고, for_each로 각 VM을 생성하도록 구성했습니다. 공통 설정은 공유하면서 노드 이름, 자원, IP만 다르게 전달하는 방식입니다.
| 파일 | 역할 |
| provider.tf | Proxmox Provider와 API 연결 설정 |
| variables.tf | 노드별 사양 등 입력 변수 정의 |
| main.tf | for_each를 사용하는 VM 리소스 정의 |
| outputs.tf | Ansible 인벤토리 등 결과 출력 |
| terraform.tfvars | 실제 환경의 입력값 |
Terraform : main.tf 작성
main.tf에서는 vms의 각 항목에 대해 VM을 생성하도록 정의했습니다. 운영체제 디스크와 함께 초기 설정을 전달할 Cloud-Init 드라이브도 연결했습니다.
resource "proxmox_vm_qemu" "node" {
for_each = var.vms
name = each.key
vmid = each.value.vmid
target_node = var.target_node
clone = var.template_name
full_clone = true
os_type = "cloud-init"
ipconfig0 = each.value.ip == "dhcp" ? "ip=dhcp" : "ip=${each.value.ip},gw=${var.gateway}"
ciuser = var.ci_user
sshkeys = var.ssh_public_key
memory = each.value.memory
scsihw = "virtio-scsi-pci"
bootdisk = "scsi0"
cpu {
cores = each.value.cores
}
serial {
id = 0
type = "socket"
}
vga {
type = "std"
memory = 16
}
network {
id = 0
model = "virtio"
bridge = var.network_bridge
}
disk {
slot = "scsi0"
type = "disk"
storage = var.storage
size = each.value.disk
}
disk {
slot = "ide2"
type = "cloudinit"
storage = var.storage
}
}
clone에는 앞서 만든 템플릿을 지정하고, full_clone = true로 전체 복제를 요청했습니다. ciuser와 sshkeys는 초기 계정과 SSH 공개 키를 전달하며, CPU와 메모리, 디스크 크기는 각 노드의 값에서 가져옵니다.
네트워크는 ipconfig0의 조건식으로 처리했습니다. ip 값이 dhcp이면 DHCP를 사용하고, 주소가 지정되어 있으면 해당 IP와 var.gateway를 전달합니다. 아래 노드 정의에는 고정 IP를 지정했으므로 이 구성에서는 고정 IP가 적용됩니다.
Terraform : variables.tf
variables.tf에는 API 연결 정보, 템플릿과 스토리지 이름, 노드별 사양을 정의했습니다. 각 노드에는 고정 IP를 할당하고, 공통 게이트웨이는 192.168.0.1로 지정했습니다.
# ---------------------------------------------------------------------------
# Connection / provider
# ---------------------------------------------------------------------------
variable "pm_api_url" {
type = string
description = "Proxmox API URL, e.g. <https://192.168.1.5:8006/api2/json>"
}
variable "pm_api_token_id" {
type = string
description = "Proxmox API token id, format: user@realm!tokenname (e.g. terraform@pve!tf)"
}
variable "pm_api_token_secret" {
type = string
sensitive = true
description = "Proxmox API token secret (UUID shown once at creation). Set via terraform.tfvars or TF_VAR_pm_api_token_secret."
}
variable "pm_tls_insecure" {
type = bool
default = true
description = "Skip TLS verification (self-signed Proxmox cert)"
}
# ---------------------------------------------------------------------------
# Cluster-wide defaults
# ---------------------------------------------------------------------------
variable "target_node" {
type = string
default = "pve"
description = "Proxmox node name to create the VMs on"
}
variable "template_name" {
type = string
default = "Rocky-9-Template"
description = "Name of the Rocky Linux cloud-init template to clone"
}
variable "storage" {
type = string
default = "local-lvm"
description = "Storage pool for VM disks"
}
variable "network_bridge" {
type = string
default = "vmbr0"
description = "Proxmox network bridge"
}
variable "gateway" {
type = string
default = "192.168.1.1"
description = "Default gateway for static IPs"
}
variable "nameserver" {
type = string
default = "192.168.1.1"
description = "DNS server pushed via cloud-init"
}
variable "ci_user" {
type = string
default = "rocky"
description = "cloud-init default user"
}
variable "ssh_public_key" {
type = string
description = "SSH public key injected into the cloud-init user"
}
variable "qemu_agent" {
type = number
default = 0
description = <<-EOT
Enable the QEMU guest agent (1) or not (0).
Set to 1 ONLY if the template has qemu-guest-agent installed and enabled,
otherwise Terraform will hang waiting for the agent. Enabling it lets
Terraform discover VM IPs.
EOT
}
# ---------------------------------------------------------------------------
# VM definitions (spec table)
# ---------------------------------------------------------------------------
variable "vms" {
description = "Per-VM definitions"
type = map(object({
vmid = number
cores = number
memory = number # MB
disk = string # e.g. "40G"
ip = string # CIDR e.g. "192.168.1.11/24", or "dhcp"
tags = string # semicolon-separated
}))
default = {
"infra-bastion" = {
vmid = 100
cores = 2
memory = 6144
disk = "40G"
ip = "192.168.0.10/24"
tags = "bastion"
}
"k8s-master" = {
vmid = 101
cores = 2
memory = 8192
disk = "80G"
ip = "192.168.0.11/24"
tags = "k8s;control-plane"
}
"k8s-worker-prod" = {
vmid = 102
cores = 4
memory = 24576
disk = "400G"
ip = "192.168.0.12/24"
tags = "k8s;worker;pool-prod"
}
"k8s-worker-dev" = {
vmid = 103
cores = 4
memory = 20480
disk = "300G"
ip = "192.168.0.13/24"
tags = "k8s;worker;pool-dev"
}
}
}
Bastion은 관리 작업을 맡고, 나머지 노드는 Kubernetes Control Plane과 운영·개발 워커로 사용하도록 구분했습니다. 여기서는 각 역할에 필요한 VM까지만 준비하며, 실제 워크로드 배치와 접근 제어는 Kubernetes를 구성할 때 이어서 적용할 부분입니다.
구성을 준비한 뒤에는 Provider를 초기화하고, 변경 계획을 확인한 후 적용했습니다.
terraform plan
terraform apply
terraform plan에서는 작성한 설정에 따라 어떤 리소스가 생성,변경,삭제되는지 확인할 수 있습니다. 특히 사양을 바꾸는 과정에서 기존 VM을 다시 생성하는 작업이 포함되는지 살펴본 뒤 적용해야 합니다.
적용을 마친 뒤에는 infra-bastion, k8s-master, k8s-worker-prod, k8s-worker-dev 네 대의 VM이 생성된 것을 확인했습니다.
다음 단계에서 사용할 Ansible 인벤토리도 출력하도록 구성했습니다.
# Terraform 프로비저닝 완료 화면
Apply complete! Resources: 4 added, 0 changed, 1 destroyed.
Outputs:
ansible_inventory = <<EOT
[bastion]
infra-bastion ansible_host=192.168.0.10 ansible_user=rocky
[k8s_master]
k8s-master ansible_host=192.168.0.11 ansible_user=rocky
[k8s_workers]
k8s-worker-prod ansible_host=192.168.0.12 ansible_user=rocky
k8s-worker-dev ansible_host=192.168.0.13 ansible_user=rocky
특정 노드만 대상으로 적용할 때는 다음과 같이 -target을 지정할 수 있습니다. 이 옵션은 일부 리소스만 다뤄야 하는 예외적인 상황에서 사용하고, 일반적인 변경은 전체 계획을 기준으로 적용하는 편이 좋습니다.
terraform apply -target='proxmox_vm_qemu.node["변경할_노드_이름"]'
적용 후에는 Proxmox 대시보드에서 생성된 VM과 할당된 자원을 확인했습니다.

(Trouble Shooting) VM은 실행됐는데 SSH로 접속되지 않는 문제
VM은 실행됐지만 SSH로 접속하는 단계에서 문제가 생겼습니다. 초기 설정이 제대로 전달됐는지 확인하기 위해 템플릿과 Terraform의 VM 설정을 다시 비교했습니다. 먼저 Proxmox 호스트에서 템플릿 구성을 조회했습니다.
qm config 9000
템플릿에는 Cloud-Init 드라이브가 연결되어 있었습니다. 반면 Terraform의 VM 리소스에는 운영체제 디스크만 선언되어 있어, 기존 scsi0 설정에 ide2의 Cloud-Init 디스크를 추가했습니다.
# 네 대를 관리하는 VM 리소스 내부의 디스크 설정
disk {
slot = "scsi0"
type = "disk"
storage = var.storage
size = each.value.disk
}
disk {
slot = "ide2"
type = "cloudinit"
storage = var.storage
}
이 수정은 네 대를 관리하도록 바꾼 VM 리소스에 적용했습니다. 따라서 디스크 크기는 each.value.disk, 스토리지는 var.storage를 사용합니다. 수정 사항을 반영한 뒤에는 다음 명령으로 Bastion에 접속할 수 있었습니다.
ssh rocky@192.168.0.10
이번 문제는 초기 설정값만 작성해서는 충분하지 않다는 점을 보여줬습니다. ciuser와 sshkeys를 지정했더라도 VM 안의 Cloud-Init이 그 정보를 읽을 수 있어야 하므로, 접속이 되지 않을 때는 전달할 값과 Cloud-Init 드라이브 구성을 함께 확인해야 합니다.
마무리
여기까지 Proxmox에 Rocky Linux 템플릿을 준비하고, Terraform으로 관리용 서버와 Kubernetes에 사용할 노드를 생성했습니다. VM 생성 이후 사양을 조정하고 SSH 접속 문제를 해결하면서, 코드에 적은 설정이 실제 VM에 어떻게 반영되는지도 확인했습니다.
이제 다음 단계는 준비한 VM들을 Kubernetes 클러스터로 구성하는 일입니다. 다음 글에서는 Ansible로 서버 설정을 적용하고 Kubernetes 구성을 이어가겠습니다.
참고 자료
'프로젝트 > 홈랩 구성기' 카테고리의 다른 글
| Ansible 작업을 Role로 나누고 Makefile로 실행 순서 보장하기 (0) | 2026.09.24 |
|---|---|
| 토이 프로젝트가 돌아갈 공간을 만들어보자 (0) | 2026.09.14 |