이 가이드의 흐름

vCenter에 복제를 요청하고, VMware Tools로 VM 내부 설정을 마무리한다.

OpenTofu 실행 서버는 vCenter API에 연결한다. vCenter가 ESXi·datastore·네트워크를 사용해 템플릿을 복제하고, 게스트 커스터마이징이 Rocky의 호스트명과 IP를 설정한다. 접속 경로와 설정 경로를 구분해 VM 한 대부터 시작한다.

환경 확인 → 템플릿 준비 → 코드 작성 → 생성·접속 → 추가·삭제 순서로 따라간다.

OpenTofu 입문 가이드 - 1에서 익힌 개념을 VMware VM 생성 실습으로 연결한다. 이 글은 vCenter가 관리하는 독립 ESXi Host, Rocky Linux 9.6 일반 VM 템플릿, 고정 IPv4를 기준으로 한다. VMware Cluster는 사용하지 않는다. vSphere Client의 메뉴 이름은 버전·언어에 따라 조금 다르다.

1. 어떤 서버에 무엇을 요청하는가

OpenTofu 실행 서버에서 vCenter, ESXi, Rocky 게스트로 이어지는 요청 관계

구성 요소 담당하는 일 예제에서 넣는 값
OpenTofu 실행 서버 설정을 읽고 Provider를 실행 관리용 Linux 서버 또는 Runner
vCenter 인벤토리 조회, 복제·전원·커스터마이징 요청 처리 vcenter.example.com
ESXi VM의 CPU·메모리·가상 장치 실행 esxi-01.example.com을 직접 배치 대상으로 지정
Datastore VM 디스크와 설정 파일 저장 lab-datastore
Port group VM의 가상 NIC 연결 템플릿의 NIC 5개에 연결된 Port group
VMware Tools 게스트 정보 보고와 초기 설정 처리 Rocky의 vmtoolsd

접속 대상은 vCenter다. Provider의 vsphere_server에는 ESXi IP나 게스트 VM IP가 아니라 vCenter의 FQDN 또는 IP를 넣는다. 템플릿 clone은 vCenter가 필요하며, 직접 ESXi 연결에서는 지원하지 않는다. Provider 연결 설정, VM clone 조건

처리 순서는 다음과 같다.

  1. Provider가 HTTPS로 vCenter에 로그인한다.
  2. Datacenter·ESXi Host·Datastore·템플릿을 조회해 ID와 NIC의 Port group 연결을 얻는다.
  3. 템플릿 UUID와 배치·사양을 포함한 복제 요청을 보낸다.
  4. vCenter가 작업을 실행하고 VM을 부팅한다.
  5. 게스트 커스터마이징이 VMware Tools를 통해 호스트명·IP 등을 적용한다.
  6. Provider가 완료 상태와 게스트 네트워크 정보를 기다리고 State·output을 기록한다.

Provider가 가상화 관리 API를 호출하는 것과 Ansible이 SSH로 게스트에 접속하는 것은 다른 연결이다. vCenter에 접속할 수 있어도 새 VM의 네트워크에 경로가 없으면 SSH·Ansible은 실패한다.

2. vCenter에서 확인할 값

브라우저로 https://vcenter.example.com/ui/에 접속한다. 여기서 확인하는 이름을 뒤의 lab.tfvars에 넣는다. 예제 이름은 실제 환경의 이름으로 바꿔야 한다.

확인 대상 vSphere Client에서 볼 위치 입력값·주의
vCenter 주소·버전 로그인 주소, vCenter의 Summary·About FQDN 또는 IP. Provider에는 /ui/·/sdk를 넣지 않음
Datacenter Hosts and Clusters의 상위 객체 Lab-DC
ESXi Host Hosts and Clusters → Datacenter → 대상 Host vCenter에 등록된 이름, 예: esxi-01.example.com
Resource Pool 독립 Host의 기본 자원 풀 vsphere_host에서 Host의 기본 pool ID 조회
Datastore Storage → 대상 datastore lab-datastore, 여유 공간 확인
Port group Networking → 대상 네트워크 lab-management, VLAN·연결 가능한 ESXi 확인
원본 템플릿 VMs and Templates → Templates 폴더 Templates/rocky-9.6-base-v1
생성할 폴더 VMs and Templates → Lab 폴더 Datacenter의 VM 루트 기준 Lab/opentofu

VM 폴더와 datastore 폴더는 다르다. 여기서 Lab/opentofu는 vCenter 인벤토리의 VM 폴더다. datastore 브라우저의 파일 경로를 입력하는 곳이 아니다. 폴더는 미리 만들고 템플릿과 중복되지 않는 이름을 사용한다.

예제는 Cluster 없이 Datacenter 아래의 ESXi Host에 VM을 배치한다. resource_pool_id에는 Host의 기본 자원 풀 ID를, host_system_id에는 같은 Host의 ID를 넣는다. Provider 접속 대상은 계속 vCenter다. ESXi로 직접 API 연결을 바꾸는 것이 아니다. 선택한 Host가 템플릿·목적지 datastore와 NIC의 모든 Port group을 사용할 수 있어야 한다. Host data source

vCenter 연결과 VM 연결을 따로 확인하는 방법

OpenTofu 실행 서버에서 vCenter 이름 해석과 HTTPS 연결을 확인한다.

getent hosts vcenter.example.com
curl --connect-timeout 10 -fsS \
  https://vcenter.example.com/sdk/vimServiceVersions.xml

/sdk는 vSphere Web Services API 경로다. 위 조회는 API 버전 정보를 읽는 연결 확인이며, 로그인 권한이나 VM 생성 권한을 증명하지 않는다. 브라우저 UI가 열린다는 사실만으로 Provider 인증이 확인된 것도 아니다.

내부 CA를 사용한다면 실행 서버의 신뢰 저장소에 CA 인증서를 넣는다. Rocky 계열의 예:

sudo install -m 0644 lab-root-ca.crt \
  /etc/pki/ca-trust/source/anchors/lab-root-ca.crt
sudo update-ca-trust

CA 파일은 환경 관리자가 제공한 인증서를 사용한다. IP로 접속하면 인증서 SAN에 해당 IP가 포함돼야 이름 검증이 통과한다. 예제의 allow_unverified_ssl 기본값은 false다. 실습에서 검증을 끄는 경우에는 true로 명시하지만, 이는 신뢰 문제를 해결하는 설정이 아니라 검증을 생략하는 설정이다.

VM 생성 이후의 연결은 별도로 확인한다.

ssh root@192.0.2.101

HTTPS 443은 실행 서버→vCenter, SSH 22는 실행 서버→게스트 VM의 연결이다. 템플릿 다운로드·Provider 설치에는 별도로 인터넷 또는 패키지 미러 접근이 필요하다. vSphere API, Provider 인증·TLS 옵션

3. 계정과 권한 준비

vCenter API 계정과 Rocky SSH 계정을 구분한다.

계정 어디에 사용 필요한 작업
vCenter API 계정 vSphere Provider 객체 조회, VM 복제·설정 변경·전원·삭제
root Rocky 게스트 Runner의 공개키로 SSH 접속. 별도 sudo 정책 불필요

사용자를 만들고 Role을 정의하는 것만으로는 권한이 생기지 않는다. Role은 허용할 작업의 목록이다. 실제 권한은 인벤토리의 특정 대상에 사용자 + Role을 연결해야 생긴다. 예를 들어 VM 폴더에 연결한 권한은 그 폴더의 VM을 관리하는 권한이지, 다른 계층의 datastore·네트워크를 사용하는 권한은 아니다.

  1. 전용 사용자 생성
  2. 필요한 작업으로 Role 정의
  3. 대상 자원에 사용자·Role 연결
  4. 조회·복제·삭제 확인

아래는 기존 템플릿 복제, CPU·메모리 변경, 고정 IP 설정, 전원 조작, VM 삭제를 위한 구성이다. 탐색 경로는 한글 vSphere Client 기준으로 적고, 권한 체크박스는 영문 이름을 함께 사용한다. 메뉴에 인벤토리만 보이는 화면에서는 메뉴 → 인벤토리로 들어간 뒤 상단의 보기 아이콘을 바꾼다. 버전에 따라 보기 전환 항목이 메뉴에 직접 표시되기도 한다.

권한을 연결할 대상 연결하는 이유 사용할 Role 하위 항목으로 전파
원본 템플릿과 VM을 만들 폴더 원본 복제, 목적지 VM 생성·관리 OpenTofu 폴더는 켬. 원본 템플릿 하나에 직접 연결하면 끔
목적지 ESXi Host Host 조회, 기본 Resource Pool에 VM 배치 OpenTofu 기본 pool·하위 VM에 필요한 권한이 적용되도록 켬
목적지 Datastore 복제한 디스크를 저장 OpenTofu 대상 datastore에 직접 연결, 끔
목적지 Port group 가상 NIC를 네트워크에 연결 OpenTofu 대상 네트워크에 직접 연결, 끔
vCenter 최상위 객체 스토리지 정책 조회 OpenTofu 통합 Role의 VM 권한이 확산되지 않도록 끔
분산 스위치(VDS), 분산 Port group을 사용할 때 Port group이 참조하는 스위치 설정 조회 기본 제공 Read-only 스위치 자체에 연결, 끔

기본 연결 대상은 다섯 종류이며, 분산 Port group을 사용하면 해당 VDS의 조회 권한 연결이 추가된다. 템플릿과 목적지 폴더가 분리돼 있으면 첫 번째 종류에서 두 곳에 연결한다. 이 글은 Cluster 대신 목적지 ESXi Host와 그 기본 Resource Pool을 사용한다. Host의 시스템 설정을 변경하는 권한까지 추가하는 것은 아니다.

Role을 넓게 전파하지 않는다

아래 구성은 관리에 필요한 권한을 사용자 정의 Role 하나에 모은다. VDS 조회에는 기존의 기본 제공 Read-only Role을 사용한다. 관리 Role을 vCenter 최상위나 공유 운영 Host에 연결하고 전파하면 VM 생성·삭제 권한도 하위 객체에 적용될 수 있다. vCenter 최상위의 전파는 끄고, Host 전파는 해당 실습 범위에 VM 관리 권한까지 허용해도 되는지 확인한다. 공유 Host에서 배치 권한만 허용하려면 Role 분리가 필요하다.

3-1. 전용 API 사용자 만들기 — 관리 → Single Sign-On → 사용자 및 그룹

vSphere Client에 사용자·Role·권한을 관리할 수 있는 관리자 계정으로 로그인한다.

  1. 메뉴 → 관리(Administration) → Single Sign-On → 사용자 및 그룹(Users and Groups)으로 이동한다.
  2. 사용자(Users) 탭에서 도메인(Domain)을 선택한다. 기본 SSO 도메인은 vsphere.local이며 환경에서 바꿨다면 실제 도메인을 선택한다.
  3. 추가(ADD)를 누른다.
  4. 예제 사용자 이름 svc-opentofu와 비밀번호, 화면에서 요구하는 정보를 입력해 생성한다.
  5. 사용자 목록에서 생성 결과를 확인한다. 이후 Provider에 전달할 이름은 svc-opentofu@vsphere.local이다.

자동화를 위해 이 사용자를 Administrators 그룹에 넣지는 않는다. 다음 단계에서 필요한 대상에 권한을 직접 연결한다. AD 계정을 쓸 경우에는 SSO 로컬 사용자를 새로 만드는 대신 등록된 인증 소스의 계정을 선택한다. Broadcom: 사용자와 Role 생성 경로

3-2. Role 만들기 — 선택할 권한과 이유

메뉴 → 관리(Administration) → 액세스 제어(Access Control) → 역할(Roles) → 새로 만들기(NEW)로 이동한다. 화면이 축약돼 있으면 각 범주의 See more privileges를 펼친다. 범주 전체를 체크하지 않고 아래 항목을 개별 선택한다.

Role 이름은 예를 들어 OpenTofu로 정하고, VM·자원 배치·스토리지 정책 조회 권한을 이 Role 하나에 함께 넣는다. 이미 만든 Role이 있다면 새로 만들지 않고 같은 화면에서 해당 Role을 편집한다.

권한 범주 체크할 항목 필요한 이유
Virtual machine → Provisioning Deploy template 기존 템플릿으로 일반 VM을 복제
Virtual machine → Provisioning Customize guest Rocky의 호스트명·IP 등 게스트 설정
Virtual machine → Edit Inventory Create from existing 복제한 VM을 목적지 폴더에 생성
Virtual machine → Edit Inventory Remove tofu destroy로 생성 VM 삭제
Virtual machine → Change Configuration Change CPU count 예제의 num_cpus 적용
Virtual machine → Change Configuration Change Memory 예제의 memory 적용
Virtual machine → Change Configuration Change resource / 리소스 변경 VM의 CPU·메모리 예약량·제한값·shares 등 자원 할당 설정. 권한 ID는 VirtualMachine.Config.Resource
Virtual machine → Change Configuration Change Settings VM의 일반 구성 변경
Virtual machine → Change Configuration Modify device settings 템플릿에서 이어받은 가상 장치의 설정 변경
Virtual machine → Change Configuration Change Swapfile Placement Provider가 기본 swap 배치 정책 설정
Virtual machine → Change Configuration Extend virtual disk 디스크를 원본보다 확장할 때만 선택. 기본 예제는 템플릿 크기를 그대로 사용
Virtual machine → Interaction Power on 복제·설정 후 VM 부팅
Virtual machine → Interaction Power off 전원 종료가 필요한 변경·삭제
Resource Assign virtual machine to resource pool 목적지 Resource Pool에 VM 배치. Host 설정 변경 권한은 아님
Datastore Allocate space VM 디스크를 저장할 공간 할당
Network Assign network VM의 NIC를 선택한 Port group에 연결
VM storage policies View VM storage policies Provider의 스토리지 정책 조회. 권한 ID는 StorageProfile.View
Virtual machine → Change Configuration Add new disk 새 디스크를 추가할 때만 선택. 기존 OS 디스크만 복제하면 불필요
Virtual machine → Change Configuration Add existing disk 별도의 기존 VMDK를 연결할 때만 선택
Virtual machine → Change Configuration Add or remove device NIC·컨트롤러 등 장치를 추가·제거할 때만 선택
Virtual machine → Change Configuration Advanced configuration extra_config로 고급 구성 키를 변경할 때만 선택

Deploy template과 Clone template은 다르다. 이 실습은 템플릿 → 일반 VM이므로 Deploy template을 사용한다. 새 템플릿을 만드는 권한은 선택하지 않는다. vSphere API: CloneVM_Task의 권한 조건

Change resource는 CPU 개수·메모리 크기 변경 권한과 별개다. 한글 화면에서는 가상 시스템(가상 머신) → 구성 변경 → 리소스 변경에 해당한다. VirtualMachine.Config.Resource 오류가 나오면 새 Role을 만들지 않고 메뉴 → 관리 → 액세스 제어 → 역할 → 기존 OpenTofu Role 편집에서 이 항목을 추가한다. 템플릿과 같은 사양이어도 복제 후 재구성 요청에서 자원 할당 권한이 필요할 수 있다. Broadcom: Change resource 누락 오류와 설정 경로

뒤의 예제는 템플릿의 CPU·메모리·디스크 크기를 그대로 읽어 사용한다. 디스크 확장 권한은 기본 복제와 구분하고 실제 확장할 때만 추가한다. nested_hv_enabled와 extra_config는 서로 다른 설정이므로 같은 것으로 취급하지 않는다. vSphere API: ReconfigVM_Task의 항목별 권한

Change Swapfile Placement는 Rocky 내부의 swap 파티션을 만드는 권한이 아니다. VMware가 사용하는 VM swap 파일의 배치 정책이며, 이 Provider는 기본값도 설정하므로 직접 HCL에 쓰지 않아도 권한이 필요하다. 또한 태그와 커스터마이징·전원 이벤트를 조회하므로 읽기 접근도 유지해야 한다. 태그를 읽기 위해 태그 생성·수정 권한을 일괄 선택하지는 않는다. Provider 2.17.1의 추가 권한 안내

이 목록은 위 예제의 작업을 기준으로 한 구성이다. 템플릿의 추가 장치나 스토리지 정책에 따라 필요한 권한이 달라지면 오류에 나온 대상과 권한 ID를 보고 해당 항목만 보완한다.

3-3. 사용자와 Role 연결하기 — 자원별 화면 경로와 하위 전파

공통 연결 방법

대상 객체 선택 → 권한(Permissions) 탭 → 권한 추가(Add Permission, +) → 도메인과 사용자 svc-opentofu 선택 → Role 선택 → 하위 항목으로 전파(Propagate to children) 지정 → 확인 순서다. ①~⑤에는 같은 OpenTofu Role을 연결하고, ⑥의 VDS에는 기본 제공 Read-only를 사용한다. Role 정의 화면이 아니라 권한을 적용할 대상 객체의 화면에서 연결한다.

① 원본 템플릿과 목적지 VM 폴더

경로: 메뉴 → 인벤토리 → VM 및 템플릿(VMs and Templates) 보기 → vCenter → Datacenter → 대상 폴더 → 권한.

  • 목적지 Lab/opentofu 폴더에 OpenTofu를 연결하고 하위 전파를 켠다. 이후 그 안에 생성되는 VM이 권한을 상속한다.
  • 이 글의 원본은 Templates/rocky-9.6-base-v1이다. Templates 폴더 전체가 아니라 사용할 템플릿 하나를 선택해 같은 Role을 연결하고 하위 전파는 끈다.
  • 원본 템플릿과 새 VM을 같은 실습 폴더 안에 두면 그 폴더 한 곳에 연결해도 된다. 이 경우 뒤의 template_path도 실제 이동한 경로로 바꾼다. 예: Lab/opentofu/rocky-9.6-base-v1.

② 목적지 ESXi Host와 기본 Resource Pool

경로: 메뉴 → 인벤토리 → 호스트 및 클러스터(Hosts and Clusters) 보기 → vCenter → Datacenter → 목적지 ESXi Host → 권한.

  • 이 글은 esxi-01.example.com의 기본 Resource Pool을 쓴다. 목적지 Host에 OpenTofu를 연결하고 하위 전파를 켠다. 같은 Role의 VM 관리 권한도 그 범위에 전파될 수 있으므로 전용 실습 Host 또는 승인된 범위를 사용한다.
  • Resource → Assign virtual machine to resource pool이 실제 기본 Resource Pool에 적용되는지 확인한다. Resource.AssignVMToPool 오류가 나오면 Host의 조회 권한과 기본 pool의 유효 권한을 구분해 확인한다.
  • Cluster 권한 연결은 하지 않는다. Host의 네트워크·스토리지·시스템 설정 변경 권한을 범주 전체로 선택하지도 않는다. Broadcom의 연결·전파 절차

③ 목적지 Datastore

경로: 메뉴 → 인벤토리 → 스토리지(Storage) 보기 → vCenter → Datacenter → 목적지 datastore → 권한.

lab-datastore 자체에 OpenTofu를 연결하고 하위 전파를 끈다. datastore 내부의 파일 브라우저에서 폴더를 선택하는 것이 아니다. 이 대상에서 사용하는 권한은 Allocate space다.

④ 목적지 Port group

경로: 메뉴 → 인벤토리 → 네트워킹(Networking) 보기 → vCenter → Datacenter → 목적지 Port group → 권한.

템플릿의 NIC 5개가 사용하는 각 Port group에 OpenTofu를 연결하고 하위 전파를 끈다. 분산 Port group이라면 해당 Distributed Switch 아래에서 실제 Port group을 선택한다. 네트워크 연결을 허용하는 절차이지 Switch·VLAN 구성을 바꾸는 권한을 주는 절차는 아니다.

⑤ vCenter 최상위 객체

경로: 메뉴 → 인벤토리 → 호스트 및 클러스터 보기 → 인벤토리 트리의 vCenter 이름 → 권한.

Datacenter가 아니라 그 위의 vCenter 객체를 선택해 OpenTofu를 연결하고 하위 전파는 끈다. 통합 Role 전체를 하위 객체로 전파하지 않기 위한 설정이다.

Broadcom의 스토리지 정책 권한 해결 절차에는 하위 전파를 켜는 단계가 있지만, VM 생성·삭제까지 담긴 이 통합 Role을 그대로 전파하면 관리 범위가 넓어진다. 최상위에 StorageProfile.View가 연결됐는지 확인하고, 아래 실행 단계에서 실제 정책 조회 결과를 확인한다. 조회 오류가 남으면 전파를 무조건 켜지 말고 권한 적용 범위를 다시 확인한다. Broadcom: StorageProfile.View의 적용 위치와 전파

⑥ 분산 스위치 조회 — 분산 Port group을 사용할 때만

경로: 메뉴 → 인벤토리 → 네트워킹(Networking) 보기 → vCenter → Datacenter → 사용하는 분산 스위치(Distributed Switch) → 권한 → 권한 추가.

Port group이 아니라 그 Port group을 제공하는 스위치 자체를 선택한다. 같은 OpenTofu API 사용자를 선택하고 아래처럼 연결한다.

항목 설정
역할 기본 제공 읽기 전용(Read-only)
하위 항목으로 전파 끔

분산 Port group에 VM의 NIC를 연결하려면 Provider가 VDS의 UUID 등 설정을 조회해야 한다. System.Read privilege required for config.distributedVirtualSwitch 오류는 이 스위치를 읽을 권한이 없을 때 발생한다. 포트그룹에 권한을 줬다는 사실만으로 VDS의 조회 권한까지 생기지는 않는다. VMware govmomi 소스의 대상 객체 설명

System.Read는 사용자 정의 Role에 기본 포함되는 시스템 권한이다. 이번 오류는 Role 편집에서 체크박스를 찾는 문제가 아니라, 조회할 스위치에 사용자와 Role을 연결하는 문제다. 기본 Read-only Role이면 충분하며 새 Role 생성이나 Distributed switch 범주 전체 선택은 필요하지 않다. 공식 API의 기본 시스템 권한, VDS에 Read-only를 연결하고 전파를 끄는 사례

포트그룹의 기존 Role은 유지

④의 Port group에는 Network.Assign이 포함된 OpenTofu Role을 유지한다. 이를 Read-only로 바꾸면 네트워크 할당 권한이 사라질 수 있다. VDS의 조회 권한과 Port group의 연결 권한은 각각 다른 대상에 설정한다.

사용자 정의 OpenTofu Role 하나를 여러 자원에 재사용하고, VDS에는 기본 Read-only를 추가 연결하는 구조다. 하위 전파는 연결 대상마다 따로 지정한다.

3-4. 연결한 권한 확인과 OpenTofu 계정 입력

각 자원의 권한(Permissions) 탭에서 사용자·Role·상속 여부를 확인한다. 상위 객체에서 받은 항목은 직접 연결한 항목과 구분해서 보고, 상속받은 권한을 바꾸려면 연결한 상위 객체로 이동한다.

  1. 관리자 세션과 다른 브라우저 세션에서 svc-opentofu@vsphere.local로 로그인한다.
  2. 원본 템플릿·목적지 폴더·ESXi Host·Datastore·Port group을 조회할 수 있는지 확인한다. 분산 Port group을 사용하면 해당 VDS도 확인한다.
  3. 뒤의 실행 절차에서 VSPHERE_USER에 이 계정을 입력하고, 비밀번호는 VSPHERE_PASSWORD로 전달한다. Rocky의 root 로그인 정보와 혼동하지 않는다.
  4. tofu plan으로 조회와 계획을 확인한 뒤, 실습 VM 한 대를 tofu apply로 생성한다. 마지막에는 실습 VM을 tofu destroy로 삭제해 생성·전원·삭제 권한까지 확인한다.

plan이 성공했다고 복제·전원·삭제 권한까지 확인된 것은 아니다. NoPermission이 발생하면 오류의 대상 객체와 privilege ID를 먼저 읽는다.

부족한 권한의 예 확인할 연결 위치
VirtualMachine.Provisioning.DeployTemplate 원본 템플릿
VirtualMachine.Inventory.CreateFromExisting VM을 만들 목적지 폴더
Resource.AssignVMToPool 목적지 독립 Host의 기본 Resource Pool에 적용된 유효 권한
Datastore.AllocateSpace 선택한 datastore
Network.Assign 선택한 Port group
StorageProfile.View vCenter 최상위에 연결한 Role의 정책 조회 권한
VirtualMachine.Config.SwapPlacement 생성 VM에 상속되는 VM Role
VirtualMachine.Config.Resource 생성 VM에 적용된 Role의 Change resource / 리소스 변경
System.Read와 config.distributedVirtualSwitch Port group이 사용하는 VDS 자체의 Read-only 연결

필요한 자원이 보이지 않으면 해당 객체와 탐색 경로의 조회 권한도 확인한다. 권한 오류를 해결하려고 모든 범주를 체크하거나 API 사용자를 관리자 그룹에 넣지는 않는다.

4. Rocky Linux 9.6 템플릿 준비

템플릿은 복제에 사용할 원본 VM이다. Rocky Linux 9.6을 설치한 일반 VM에서 기본 준비를 끝내고, 전원을 끈 뒤 템플릿으로 전환하는 순서로 진행한다.

현재 준비 기준은 다음과 같다.

항목 준비 상태·방식
OS Rocky Linux 9.6 설치·기본 설정 완료
VMware Tools·SSH open-vm-tools, perl, openssh-server 설치 및 서비스 활성화
접속 계정 root 사용. SSH 방화벽 허용
가상 NIC ens33, ens34, ens35, ens36, ens38 연결 확인. IP 미할당·UP 상태
cloud-init 사용하지 않음
호스트명·고정 IP 원본에 최종 값을 넣지 않고 복제할 때 OpenTofu에서 지정
SSH 공개키 variables.tf의 기본값을 별도 초기화 스크립트로 전달해 등록
게스트 커스터마이징 별도 설정 호환성 파일이나 vCenter의 저장된 커스터마이징 규격은 아직 구성하지 않음

원본 VM에 IP가 없어도 템플릿으로 전환할 수 있다. 다만 VMware Tools 설치만으로 공개키가 자동 등록되지는 않는다. 이 예제는 별도 초기화 스크립트로 공개키 등록과 uplink 설정을 실행하므로 아래의 스크립트 실행 허용을 먼저 준비한다.

4-1. 원본 VM에서 준비한 패키지와 서비스

원본 VM에서 진행한 준비 명령이다.

cat /etc/rocky-release
sudo dnf install -y open-vm-tools perl openssh-server
sudo systemctl enable --now vmtoolsd sshd
rpm -q open-vm-tools perl
systemctl is-active vmtoolsd sshd

enable --now는 지금 서비스를 시작하고 다음 부팅에도 자동으로 시작하도록 설정한다. 마지막 명령에서 vmtoolsd, sshd가 각각 active인지 확인한다.

VMware Tools는 vCenter에 게스트 상태를 보고하고, 복제 시 요청된 호스트명·IP 등의 초기 설정을 처리한다. 별도의 cloud-init 구성 없이 VMware Tools 기반의 경로를 사용한다. open-vm-tools 역할

인터페이스 확인은 다음 조회로 충분하다. ip -br link에서 장치 이름과 UP 상태를, ip -br -4 address에서 고정 IPv4 미할당 상태를 확인한다. IPv6 link-local 주소가 보이는 것과 고정 IPv4를 설정한 것은 다르다.

ip -br link
ip -br -4 address

ens38처럼 번호가 건너뛰는 이름 자체는 문제가 아니다. 복제 코드의 NIC·Port group 선언 순서를 실제 가상 장치 순서에 맞춘다. 게스트 IP 설정도 같은 순서를 따른다. 빈 network_interface {}는 IP 미설정이 아니라 DHCP 요청이므로 IP 없이 사용할 인터페이스와 구분해야 한다. Provider의 NIC·IP 설정 순서

뒤의 5번은 템플릿의 NIC 5개를 순서대로 재사용하는 예제다. 앞 4개에는 고정 IP를 설정하고, 마지막 ens38은 커스터마이징 후 스크립트로 IPv4·IPv6를 끄고 UP 상태를 유지한다. CPU·메모리와 기본 SCSI 컨트롤러에서 조회한 디스크도 템플릿 값을 사용한다.

4-2. 공개키를 붙여넣지 않고 복제할 때 등록하기

OpenTofu의 vSphere Provider는 clone.customize.linux_options.script_text로 복제 VM 안에서 실행할 명령을 전달할 수 있다. variables.tf의 공개키 → guest-init.sh.tftpl → vCenter → VMware Tools → 복제 VM의 authorized_keys로 이어진다. 키 전달에 게스트 SSH 접속은 필요하지 않다. main.tf에는 스크립트를 읽는 호출만 둔다. Provider의 script_text

아래는 기존 준비에 더할 절차다. 원본 VM의 콘솔에서 스크립트 실행을 허용한다. 공개키를 콘솔에 입력할 필요는 없다.

원본 VM — 공개키 자동 등록을 사용할 때 추가 준비
sudo vmware-toolbox-cmd config set deployPkg enable-custom-scripts true
vmware-toolbox-cmd config get deployPkg enable-custom-scripts

이 옵션은 기본적으로 꺼져 있다. 활성화하면 게스트 커스터마이징에 전달된 스크립트를 실행할 수 있으므로 이 VM을 복제·커스터마이징할 권한도 함께 관리한다. Broadcom의 실행 허용·호출 시점 안내

저장된 커스터마이징 규격을 만드는 작업과, 복제할 때 customize로 값을 전달하는 작업은 다르다. 여기서는 별도의 규격을 미리 만들지 않고 OpenTofu 코드에서 호스트명·IP·공개키 등록 스크립트를 함께 전달한다.

VM 생성 자체에 공개키가 필수인 것은 아니다. 이 가이드와 3편은 Runner가 VM에 비밀번호 없이 SSH로 접속하는 구성이므로 공개키 등록을 사용한다. 템플릿에 이미 같은 키가 등록돼 있어도 되며, 비밀번호 인증 등 다른 방식은 별도 접속 설정이 필요하다. vCenter API 계정과 VM의 SSH 키는 서로 다른 인증이다.

Runner에서 실제로 SSH를 실행할 계정의 공개키를 확인한다. 경로는 예시이며 .pub 파일 내용 한 줄 전체를 복사한다.

Runner — 공개키 확인
cat ~/.ssh/id_ed25519.pub

5번 variables.tf의 ssh_public_key에는 사용할 Runner의 공개키를 기본값으로 설정한다. 같은 Runner라면 이 값을 다시 입력하지 않는다. 다른 환경에서는 위에서 확인한 공개키로 바꾼다. 개인키 파일은 코드에 넣거나 VM으로 전달하지 않는다. 공개키가 없다면 먼저 Runner의 SSH 키 쌍을 준비한다.

  • postcustomization 시점에 실행한다. 같은 키는 중복 등록하지 않고 권한·SELinux 컨텍스트를 맞춘다. root 로그인 허용 정책 자체는 바꾸지 않는다.
  • Runner가 고정이면 ssh_public_key 기본값을 그대로 사용한다. 공개키 파일 경로를 매번 지정하지 않는다.
  • Runner 키가 바뀌면 변수 기본값을 바꾸거나 lab.tfvars에서 덮어쓴다. 템플릿을 다시 만들 필요는 없지만, 커스터마이징 변경이 기존 VM 교체를 유발하는지는 새 plan에서 확인한다.

처음에는 VM 한 대로 IP·호스트명·uplink 상태·SSH 접속을 확인한 뒤 여러 대로 늘린다. [확인 필요] 이 다중 NIC 예제의 실제 게스트 초기화·SSH 접속 결과. 이미 복제에 성공한 기존 코드와는 구분한다.

4-3. vCenter에서 템플릿으로 전환

이 예제는 공개키 등록·uplink 초기화 스크립트를 사용한다. 원본 VM에서 4-2의 실행 허용까지 마친 뒤 다음 순서로 전환한다.

  1. 메뉴 → 인벤토리 → VM 및 템플릿(VMs and Templates) 보기에서 원본 VM을 선택한다.
  2. 요약(Summary)에서 VMware Tools 실행 상태를 확인한다. IP 미할당 상태라면 게스트 IP가 비어 있는 것은 준비 실패가 아니다.
  3. 설정 편집(Edit Settings) → CD/DVD 드라이브에서 설치 ISO 연결과 전원 켤 때 연결 옵션을 해제한다.
  4. 원본 VM 콘솔에서 아래 명령으로 정상 종료한다.

    sudo shutdown -h now
    
  5. vSphere Client에서 전원 꺼짐을 확인한다.

  6. VM 우클릭 → 템플릿(Template) → 템플릿으로 변환(Convert to Template)을 선택한다.
  7. 템플릿 이름과 폴더 경로를 확인한다. 뒤의 template_path에는 Datacenter의 VM 루트 기준 경로를 넣는다. 예: Templates/rocky-9.6-base-v1.

템플릿 전환 자체가 게스트 IP·호스트명·SSH 공개키를 설정하는 것은 아니다. 이 값은 뒤의 OpenTofu 복제 과정에서 적용한다. Rocky의 게스트 커스터마이징이 실패할 때는 해당 vCenter·Tools 버전의 지원 조건을 확인한다. 호환성 설정을 적용하지 않은 현재 준비와, 오류가 발생했을 때의 추가 조치를 구분한다. Rocky 호환 배포판 설정 안내

5. 프로젝트 파일 작성

OpenTofu 실행 서버에서 실습 디렉터리를 만든다.

mkdir -p ~/opentofu-learning/vmware-lab
cd ~/opentofu-learning/vmware-lab

아래 일곱 파일을 같은 디렉터리에 작성한다. .tf 파일 5개, lab.tfvars, guest-init.sh.tftpl이다. VM 구성은 main.tf, 공개키·uplink 초기화 명령은 별도 스크립트로 분리한다. 예제는 vSphere Provider 2.17.1로 고정한다. 이 버전의 공식 문서는 vSphere 8.x·9.x 지원을 안내한다. 기존 vCenter가 그보다 오래됐다면 해당 환경을 지원하는 Provider 문서를 기준으로 버전을 선택한다. Provider 릴리스

VM 사양·NIC 연결은 템플릿을 재사용하고, VM마다 이름·IP 4개만 지정한다. CPU·메모리·디스크 값을 각 VM에 반복해서 적지 않는다. 이름은 vms의 key이고, 고정 IP는 ipv4_addresses의 순서 있는 목록이다.

설정 값을 가져오는 곳
CPU 수·소켓당 코어 수 템플릿의 num_cpus, num_cores_per_socket
메모리 템플릿의 memory
디스크 크기·배치·프로비저닝 조회한 템플릿의 disks 목록
NIC 수·순서·Port group·adapter type 템플릿의 network_interfaces 목록
호스트명·고정 IP 4개 vms의 이름과 ipv4_addresses
provider uplink provider_uplink_interface 기본값 ens38, 별도 스크립트에서 IP 없이 UP
SSH 공개키 ssh_public_key 기본값, Runner 변경 시에만 교체
IPv4 prefix 공통 변수 ipv4_prefix, 기본값 24
중첩 가상화 공통 변수 nested_hv, 기본값 false

이 방식은 위의 사양을 조회한 값으로 명시하는 것이다. OpenTofu의 사양 설정을 생략하면 템플릿 값을 모두 자동 상속한다는 뜻은 아니다. 템플릿에서 읽을 수 있는 속성

템플릿의 NIC 순서 게스트 이름 예시 용도 복제 VM의 설정
1 ens33 관리·API ipv4_addresses[0], 기본 게이트웨이
2 ens34 모니터링 ipv4_addresses[1]
3 ens35 Ceph public ipv4_addresses[2]
4 ens36 Ceph cluster ipv4_addresses[3]
5 ens38 provider uplink 초기화 스크립트로 IPv4·IPv6 비활성화, UP

NIC 4개에 provider uplink까지 총 5개다. HCL의 매핑 기준은 ens33 같은 이름이 아니라 템플릿 장치 순서다. 복제 VM에서도 같은 이름으로 나타나는지 확인한다. 템플릿의 MAC 주소는 복사하지 않는다.

IP 없는 uplink도 VMware의 초기 커스터마이징은 DHCP로 처리

마지막 빈 network_interface {}는 DHCP 요청이다. 이 API에는 IP 없는 NIC 설정이 없어, postcustomization 스크립트로 최종 상태를 맞춘다. 스크립트 실행 전 DHCP를 시도할 수 있으므로 provider Port group에 DHCP가 있다면 임시 주소를 받을 수 있다. DHCP 시도 자체를 금지해야 하는 환경에서는 이 예제를 그대로 사용하지 않는다.

versions.tf — 실행 파일과 Provider의 버전

versions.tf
terraform {
  required_version = ">= 1.10.0, < 2.0.0"

  required_providers {
    vsphere = {
      source  = "vmware/vsphere"
      version = "= 2.17.1"
    }
  }
}

providers.tf — 접속할 vCenter

계정과 비밀번호는 뒤에서 VSPHERE_USER, VSPHERE_PASSWORD 환경변수로 전달한다. HCL 파일에 비밀번호를 넣지 않는다.

providers.tf
provider "vsphere" {
  vsphere_server       = var.vcenter_server
  allow_unverified_ssl = var.allow_unverified_ssl
}

variables.tf — 사용할 입력값

VM 목록은 이름을 key로 갖는 map이다. VM을 몇 대 만들지는 이 목록의 항목 수로 결정한다.

variables.tf
variable "vcenter_server" {
  type = string
}

variable "allow_unverified_ssl" {
  type    = bool
  default = false
}

variable "datacenter_name" {
  type = string
}

variable "host_name" {
  type = string
}

variable "datastore_name" {
  type = string
}

variable "template_path" {
  type = string
}

variable "vm_folder" {
  type = string
}

variable "domain" {
  type = string
}

variable "ipv4_gateway" {
  type = string
}

variable "dns_servers" {
  type = list(string)
}

variable "ipv4_prefix" {
  type    = number
  default = 24
}

variable "nested_hv" {
  type    = bool
  default = false
}

variable "provider_uplink_interface" {
  type    = string
  default = "ens38"
}

variable "ssh_public_key" {
  type    = string
  default = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPE7hzFYY5Mx8wRk2Xzxm6llfFHO2faY+FNH/zEfTpVD runner"
}

variable "vms" {
  type = map(object({
    ipv4_addresses = list(string)
  }))

  validation {
    condition = (
      length(var.vms) > 0 &&
      alltrue([for vm in values(var.vms) : length(vm.ipv4_addresses) == 4]) &&
      alltrue([
        for ip in flatten([for vm in values(var.vms) : vm.ipv4_addresses]) :
        can(cidrnetmask("${ip}/32"))
      ]) &&
      length(distinct(flatten([for vm in values(var.vms) : vm.ipv4_addresses]))) ==
      length(flatten([for vm in values(var.vms) : vm.ipv4_addresses]))
    )
    error_message = "VM마다 중복되지 않는 유효한 IPv4 주소 4개를 NIC 순서대로 지정한다."
  }
}

main.tf — 기존 인벤토리 조회와 VM 생성

data 블록은 기존 객체를 읽는다. resource 블록만 새 VM을 관리한다.

main.tf
data "vsphere_datacenter" "dc" {
  name = var.datacenter_name
}

data "vsphere_host" "host" {
  name          = var.host_name
  datacenter_id = data.vsphere_datacenter.dc.id
}

data "vsphere_datastore" "storage" {
  name          = var.datastore_name
  datacenter_id = data.vsphere_datacenter.dc.id
}

data "vsphere_virtual_machine" "template" {
  name          = var.template_path
  datacenter_id = data.vsphere_datacenter.dc.id
}

resource "vsphere_virtual_machine" "vm" {
  for_each = var.vms

  name             = each.key
  folder           = var.vm_folder
  resource_pool_id = data.vsphere_host.host.resource_pool_id
  host_system_id   = data.vsphere_host.host.id
  datastore_id     = data.vsphere_datastore.storage.id

  num_cpus             = data.vsphere_virtual_machine.template.num_cpus
  num_cores_per_socket = data.vsphere_virtual_machine.template.num_cores_per_socket
  memory               = data.vsphere_virtual_machine.template.memory
  guest_id             = data.vsphere_virtual_machine.template.guest_id
  firmware             = data.vsphere_virtual_machine.template.firmware
  scsi_type            = data.vsphere_virtual_machine.template.scsi_type
  nested_hv_enabled    = var.nested_hv

  dynamic "network_interface" {
    for_each = data.vsphere_virtual_machine.template.network_interfaces

    content {
      network_id   = network_interface.value.network_id
      adapter_type = network_interface.value.adapter_type
    }
  }

  dynamic "disk" {
    for_each = data.vsphere_virtual_machine.template.disks

    content {
      label            = "disk${disk.key}"
      unit_number      = disk.value.unit_number
      size             = disk.value.size
      thin_provisioned = disk.value.thin_provisioned
      eagerly_scrub    = disk.value.eagerly_scrub
    }
  }

  clone {
    template_uuid = data.vsphere_virtual_machine.template.id
    linked_clone  = false
    timeout       = 30

    customize {
      timeout = 15

      linux_options {
        host_name = each.key
        domain    = var.domain

        script_text = templatefile("${path.module}/guest-init.sh.tftpl", {
          ssh_public_key            = var.ssh_public_key
          provider_uplink_interface = var.provider_uplink_interface
        })
      }

      dynamic "network_interface" {
        for_each = each.value.ipv4_addresses

        content {
          ipv4_address = network_interface.value
          ipv4_netmask = var.ipv4_prefix
        }
      }

      # 5번째 NIC는 DHCP로 처리된다. 후처리로 IP 없는 UP 상태를 만든다.
      network_interface {}

      ipv4_gateway    = var.ipv4_gateway
      dns_server_list = var.dns_servers
    }
  }

  wait_for_guest_net_timeout = 10

  lifecycle {
    precondition {
      condition     = length(data.vsphere_virtual_machine.template.network_interfaces) == 5
      error_message = "고정 IP용 NIC 4개와 provider uplink 1개가 있는 템플릿을 사용한다."
    }
  }
}

학습 예제에서 중요한 연결은 다음과 같다.

  • template.id는 템플릿 UUID다. 이름으로 조회해 얻은 값을 clone에 전달한다.
  • num_cpus, num_cores_per_socket, memory는 조회한 템플릿 사양을 사용한다.
  • dynamic "disk"는 조회한 디스크마다 disk 블록을 만들어 크기·SCSI 장치 번호·프로비저닝을 맞춘다. OS 디스크 하나뿐이면 블록도 하나다.
  • host.resource_pool_id로 독립 ESXi Host의 기본 자원 풀을 선택하고, host_system_id로 같은 Host에 VM을 배치한다.
  • 바깥 dynamic "network_interface"는 템플릿의 NIC 5개를 같은 순서·Port group·adapter type으로 연결한다. network_name을 따로 입력하지 않는다.
  • customize 안의 dynamic "network_interface"는 앞 4개 NIC의 Rocky 내부 IP를 목록 순서대로 설정한다. 마지막 빈 블록은 uplink에 대응한다.
  • templatefile()은 별도 초기화 스크립트에 두 변수 값을 넣는다. main.tf에는 셸 명령을 작성하지 않는다.
  • firmware, guest_id, scsi_type는 템플릿 값을 따른다. EFI 템플릿을 BIOS로 만드는 실수를 피한다.
  • linked_clone = false는 Full Clone이다. 복제 후 디스크가 원본 snapshot에 의존하지 않도록 시작한다.
  • clone·customization·guest network timeout은 서로 다른 대기 단계이며 단위는 분이다.

디스크 예제는 템플릿의 SCSI 디스크가 기본 컨트롤러 하나에 있는 구성을 기준으로 한다. 컨트롤러가 여러 개면 data source의 조회 범위와 리소스의 컨트롤러 개수도 맞춰야 한다. NIC 예제는 5개로 제한한다. NIC 하나만 선언하면 템플릿에 있던 나머지 NIC가 그대로 보존된다고 생각하면 안 된다. 복제 후 NIC 수를 맞추는 Provider 코드

customize는 유지한다. 템플릿 사양을 재사용하는 것과, 복제 VM의 호스트명·IP를 새로 설정하는 것은 별개다. 관리 NIC에만 기본 게이트웨이를 두고 다른 내부망 NIC에는 추가 게이트웨이를 넣지 않는다.

조회할 템플릿 속성은 VM data source, 복제와 커스터마이징 옵션은 VM resource를 참고한다.

guest-init.sh.tftpl — 공개키 등록과 provider uplink 설정

main.tf와 같은 디렉터리에 아래 파일을 둔다. .tftpl은 OpenTofu가 변수 값을 넣어 읽는 템플릿 파일 확장자다. 결과는 복제 VM의 VMware Tools가 실행한다. Runner에서 이 파일을 직접 실행하지 않는다. templatefile

guest-init.sh.tftpl 전체 코드
guest-init.sh.tftpl
#!/bin/bash
set -eu
[ "$1" = "postcustomization" ] || exit 0
exec >> /var/log/opentofu-guest-init.log 2>&1

key='${ssh_public_key}'
uplink='${provider_uplink_interface}'

install -d -m 700 /root/.ssh
touch /root/.ssh/authorized_keys
grep -qxF -- "$key" /root/.ssh/authorized_keys ||
  printf '%s\n' "$key" >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
restorecon -R /root/.ssh

for profile in $(nmcli -g UUID connection show); do
  device="$(nmcli -g connection.interface-name connection show uuid "$profile")"
  if [ "$device" = "$uplink" ]; then
    nmcli connection modify uuid "$profile" \
      ipv4.method disabled ipv6.method disabled connection.autoconnect yes
    nmcli connection up uuid "$profile" ifname "$uplink"
    echo 'guest-init complete'
    exit 0
  fi
done

echo "No connection profile bound to $uplink" >&2
exit 1

공개키와 NIC 이름은 스크립트에 다시 쓰지 않는다. ssh_public_key, provider_uplink_interface의 기본값을 전달받는다. Runner와 템플릿이 같으면 lab.tfvars에서 두 값을 반복 지정할 필요가 없다.

프로필의 표시 이름을 System ens38 등으로 고정하지 않고, connection.interface-name이 대상 NIC인 프로필을 찾는다. NIC마다 하나의 연결 프로필을 사용하는 템플릿을 기준으로 한다. 일치하는 프로필이 없으면 새 프로필을 임의로 만들지 않고 오류로 중단한다. 여러 프로필이 같은 NIC에 연결돼 있으면 실제 사용할 프로필을 먼저 정리한다.

ipv4.method disabled와 ipv6.method disabled는 해당 프로필의 IPv4·IPv6를 비활성화한다. connection up으로 NIC 연결을 활성화한다. 관리 NIC를 provider uplink 변수에 지정하지 않는다. NetworkManager 설정

완료 시 /var/log/opentofu-guest-init.log에 guest-init complete를 남긴다. 3편은 이 표시와 SSH 접속을 확인한 뒤 설치를 시작한다. 로그만으로 앞 4개 NIC의 주소 설정까지 성공했다고 판단하지 않는다.

outputs.tf — 다음 단계에서 사용할 정보

outputs.tf
output "vm_info" {
  value = {
    for name, vm in vsphere_virtual_machine.vm : name => {
      id             = vm.id
      configured_ip  = var.vms[name].ipv4_addresses[0]
      configured_ips = var.vms[name].ipv4_addresses
      reported_ip    = vm.default_ip_address
      reported_ips   = vm.guest_ip_addresses
    }
  }
}

configured_ip는 입력한 관리 IP, configured_ips는 순서대로 넣은 4개 IP다. reported_ip·reported_ips는 VMware Tools에서 보고한 값이며 배열 순서가 NIC 순서라는 보장은 없다. 입력값이 출력됐다는 이유만으로 실제 네트워크 설정이 성공했다고 판단하지 않는다.

lab.tfvars — 먼저 VM 한 대

예제의 이름·문서용 IP를 실제 테스트 환경의 값으로 바꾼다. 공개 예제는 서로 겹치지 않는 네트워크 4개를 표현하려고 문서용 대역을 /25로 나눴다. 실제 환경이 /24라면 ipv4_prefix = 24와 실제 주소를 함께 넣는다. 255.255.255.0 문자열을 넣는 자리가 아니다.

기존 코드에서 바꿀 때는 VM 이름을 유지

variables.tf, main.tf, outputs.tf, lab.tfvars를 함께 맞추고 guest-init.sh.tftpl을 추가한다. 예전 Cluster 조회·입력은 Host 조회·host_name으로 바꾼다. 이미 다른 key로 만든 VM이 있으면 예제의 runner01로 바꾸지 말고 기존 key를 유지한다. 배치·NIC·커스터마이징 변경의 영향은 새 tofu plan으로 확인한다. 기존 계획 파일은 재사용하지 않는다.

lab.tfvars
vcenter_server = "vcenter.example.com"

datacenter_name = "Lab-DC"
host_name       = "esxi-01.example.com"
datastore_name  = "lab-datastore"
template_path   = "Templates/rocky-9.6-base-v1"
vm_folder       = "Lab/opentofu"

domain       = "lab.example.com"
ipv4_gateway = "192.0.2.1"
ipv4_prefix  = 25
dns_servers  = ["192.0.2.53"]

vms = {
  runner01 = {
    ipv4_addresses = ["192.0.2.101", "198.51.100.101", "203.0.113.101", "203.0.113.201"]
  }
}

CPU·메모리·디스크 입력은 필요 없다. 예를 들어 템플릿이 16 vCPU·32GiB 메모리·OS 디스크 100GiB라면 같은 사양으로 복제한다. 이 수치는 설명용이며 실제 값은 tofu plan에서 조회 결과를 확인한다. 원하는 검증 환경에 필요한 사양은 템플릿에서 먼저 준비한다.

ssh_public_key와 provider_uplink_interface는 variables.tf의 기본값을 사용하므로 이 예제의 lab.tfvars에는 쓰지 않는다. 다른 Runner·템플릿을 사용할 때만 여기서 덮어쓴다. 이미 같은 공개키가 들어 있으면 중복 추가하지 않는다. SSH 접속에는 그 공개키와 짝이 맞는 Runner의 개인키를 사용한다.

host_name은 VM 호스트명이 아니라 vCenter에 등록된 ESXi Host 이름이다. 복제 VM의 호스트명은 기존처럼 vms의 key에서 가져온다.

6. 인증과 조회부터 시작

실습 디렉터리에서 계정과 비밀번호를 입력받는다. 입력값을 명령줄에 직접 적어 셸 이력에 남기는 방식은 피한다.

read -r -p 'vCenter 계정: ' VSPHERE_USER
read -r -s -p 'vCenter 비밀번호: ' VSPHERE_PASSWORD
printf '\n'
export VSPHERE_USER VSPHERE_PASSWORD

Provider는 이 환경변수 이름을 지원한다. 새 터미널에서는 다시 전달해야 한다. VM에 접속할 SSH 계정은 여기 넣지 않는다.

tofu init
tofu fmt
tofu validate
tofu plan -var-file=lab.tfvars -out=create.tfplan
tofu show create.tfplan

init에서 vSphere Provider와 .terraform.lock.hcl이 준비된다. plan에서는 실제 vCenter 인증과 data source 조회가 진행된다. 템플릿을 찾지 못하거나 권한이 부족하면 이 단계에서 원인을 확인한다.

plan에서는 VM 1대의 생성, 이름, 폴더, datastore, CPU·메모리·디스크, IP를 확인한다. UUID처럼 실행 뒤 정해질 값은 known after apply로 표시될 수 있다.

저장한 계획을 적용하면 추가 질문 없이 실행

create.tfplan을 읽고 실습 VM만 생성되는지 확인한 뒤 다음 명령을 실행한다. 기존 VM 삭제·교체가 포함돼 있으면 대상과 원인을 먼저 확인한다.

tofu apply create.tfplan
tofu output vm_info
tofu state list

vSphere Client의 Recent Tasks에서 Clone virtual machine, Reconfigure, Power on, Customization 관련 작업을 확인한다. 게스트에 들어가 설정 결과를 확인한다.

ssh root@192.0.2.101
hostnamectl
ip -br address
ip route
systemctl is-active vmtoolsd
cat /var/log/opentofu-guest-init.log
ip -br link show dev ens38
ip -br address show dev ens38

앞 4개 NIC가 입력한 주소를 순서대로 받았는지, 기본 경로가 관리 NIC에만 있는지 확인한다. 마지막 ens38은 UP이면서 IPv4·IPv6 주소가 없어야 한다. guest-init complete 로그도 확인한다. VMware의 장치 순서와 게스트 인터페이스 이름이 달라졌으면 실제 이름을 확인한 뒤 코드를 맞춘다.

Provider의 완료 대기는 초기화 스크립트·SSH 서버·Nexus·OpenStack 서비스의 준비 완료까지 보장하지 않는다. 다음 설치 단계에서는 해당 서비스의 준비 여부를 따로 확인한다.

7. 같은 템플릿으로 VM 여러 대 생성

VM 한 대가 정상 복제된 뒤 lab.tfvars의 vms에 항목을 추가한다. 아래는 기존 runner01을 유지하고 runner02~runner04를 추가하는 예시다. 모두 같은 템플릿 사양을 사용한다.

lab.tfvars — vms 부분 교체
vms = {
  runner01 = {
    ipv4_addresses = ["192.0.2.101", "198.51.100.101", "203.0.113.101", "203.0.113.201"]
  }
  runner02 = {
    ipv4_addresses = ["192.0.2.102", "198.51.100.102", "203.0.113.102", "203.0.113.202"]
  }
  runner03 = {
    ipv4_addresses = ["192.0.2.103", "198.51.100.103", "203.0.113.103", "203.0.113.203"]
  }
  runner04 = {
    ipv4_addresses = ["192.0.2.104", "198.51.100.104", "203.0.113.104", "203.0.113.204"]
  }
}

VM마다 각 네트워크 주소의 마지막 자리만 바꾼다. 모든 VM에 NIC 5개가 붙고, 마지막 provider uplink에는 IP를 넣지 않는다. VM 이름만으로 역할이 자동 지정되지는 않는다. 첫 번째 Deploy Node 등 역할 구분은 이후 배포 단계에서 이름을 기준으로 연결한다. 이 예제는 모든 VM의 사양을 동일하게 유지한다.

tofu plan -var-file=lab.tfvars -out=expand.tfplan
tofu show expand.tfplan
tofu apply expand.tfplan

for_each는 이름을 식별 key로 쓴다. runner05를 추가하면 그 항목이 생성 대상이 된다. 기존 key를 삭제하면 해당 VM은 삭제 대상이고, key를 바꾸면 기존 객체 삭제와 새 객체 생성으로 해석될 수 있다. 단순 이름 변경과 코드 식별자 변경을 혼동하지 않는다. for_each

Compute VM 안에서 다시 KVM 인스턴스를 실행하려면 중첩 가상화가 필요하다. 네 VM이 모두 Compute 역할을 겸한다면 lab.tfvars에 다음 공통값을 추가한다.

lab.tfvars — 중첩 가상화가 필요한 경우 추가
nested_hv = true

nested_hv_enabled는 가상화 기능을 게스트에 노출하는 설정이며, 물리 CPU·ESXi·VM 구성도 이를 지원해야 한다. 이 값은 템플릿의 CPU·메모리 조회와 별도로 코드에서 명시한다. 게스트에서 확인한다.

lscpu | grep -E 'Virtualization|Hypervisor'
grep -Eo 'vmx|svm' /proc/cpuinfo | sort -u

CPU 플래그가 보이는 것과 실제 OpenStack 인스턴스 실행 성공은 다르다. KVM 모듈과 Nova 설정까지 이어서 확인한다.

동시 생성과 IP 할당

독립적인 리소스는 병렬 실행될 수 있다. 작은 실습 환경에서 복제 부하를 제한하려면 tofu apply -parallelism=2 expand.tfplan처럼 실행한다. Runner 동시 작업 1개와 OpenTofu 내부 병렬도는 별개다. apply 옵션

OpenTofu가 IP를 지정한다고 DHCP·IPAM에서 그 IP를 예약해 주지는 않는다. 실제 네트워크에서 예약한 미사용 IP를 넣어야 한다.

고정 IP 대신 DHCP를 쓰려면?

관리·내부망 NIC까지 DHCP로 바꾸려면 해당 clone.customize NIC 블록을 비우고 고정 IP 관련 설정을 재구성해야 한다. 다음은 NIC 한 개의 설정을 표현하는 조각이며 앞의 다중 NIC 전체 코드를 교체하는 예제가 아니다.

DHCP용 customize NIC 부분
network_interface {}

바깥의 Port group 연결 블록은 유지한다. IP가 정해지는 주체만 DHCP로 바뀌는 것이다. 변수에서 고정 IP를 제거하면 vms 타입·중복 IP 검증·output·초기화 스크립트의 고정 IP 보호 부분도 함께 변경해야 한다. 뒤의 Ansible에는 VMware Tools에서 보고한 주소나 별도 DHCP 조회 결과를 전달한다.

DHCP 서버가 없는 네트워크에서는 이 방식만으로 주소가 생기지 않는다. 커스터마이징 네트워크 옵션

8. 변경·실패·삭제

같은 설정을 다시 실행

tofu plan -var-file=lab.tfvars

변경이 없다면 계획에도 추가 작업이 없어야 한다. 이 예제는 매번 템플릿 사양을 조회하므로 같은 템플릿의 CPU·메모리·디스크를 바꾸면 기존 VM의 계획에도 영향을 줄 수 있다. IP·템플릿 경로 등 clone 설정 변경은 VM 교체를 유발할 수 있으므로 tofu show에서 대상을 확인한다. 복제 기준을 유지하려면 base-v1, base-v2처럼 템플릿을 구분한다.

VM별 CPU·메모리·디스크 입력을 없앴으므로 예전 cpus, memory_mb, disk_gib 값은 lab.tfvars에서 제거한다. NIC 연결은 템플릿에서 읽으므로 network_name도 제거한다. 예전의 단일 ipv4_address는 4개 주소 목록인 ipv4_addresses로 바꾼다. 이후 별도로 디스크를 확장하더라도 Rocky의 partition·LVM·파일시스템 확장은 따로 필요할 수 있다.

중간에 실패

실행 실패가 “VM이 하나도 만들어지지 않았다”는 뜻은 아니다. State와 vCenter Recent Tasks를 함께 확인한다.

tofu state list
tofu plan -var-file=lab.tfvars

State에 VM이 연결돼 있으면 오류를 해결하고 새 계획으로 진행한다. vCenter에만 남은 객체는 기존 State 복구·import·실습 VM 정리 중 적절한 방법을 선택한다. 이름이 겹친다고 무작정 State 파일을 지우고 다시 만들지 않는다.

권한 오류를 수정한 뒤에도 실패한 VM을 먼저 삭제하지 않는다. 원래 실행 디렉터리와 State를 유지한 채 새 계획을 만든다. 아래 lab.tfvars는 기존 실행에서 사용한 변수 파일로 맞춘다.

tofu state list
tofu plan -var-file=lab.tfvars -out=retry.tfplan
tofu show retry.tfplan

계획의 생성·수정·교체 대상을 확인한 뒤 tofu apply retry.tfplan으로 진행한다. 실패 전 저장한 create.tfplan을 무작정 재사용하거나 관리되지 않는 VM을 그대로 둔 채 같은 이름으로 다시 생성하지 않는다.

실습 VM 삭제

아래 계획은 현재 State가 관리하는 VM을 삭제한다. source 템플릿과 data source로 조회한 ESXi Host·Datastore·Port group은 삭제 대상이 아니다. 삭제된 게스트의 디스크와 데이터는 백업이 없으면 복구할 수 없다.

tofu state list
tofu plan -destroy -var-file=lab.tfvars -out=destroy.tfplan
tofu show destroy.tfplan

지워질 VM을 확인한 뒤 적용한다.

tofu apply destroy.tfplan
tofu state list
unset VSPHERE_USER VSPHERE_PASSWORD

계정·환경값·State는 삭제에도 필요하다. 생성 직후 작업 디렉터리와 State를 지우면 나중에 자동 정리가 어려워진다. OpenTofu destroy

9. 자주 막히는 지점

증상 먼저 구분할 것 확인 위치
x509 인증서 오류 CA 신뢰·SAN·접속 주소 실행 서버의 curl, Provider TLS 설정
로그인 실패 vCenter API 계정과 SSO 도메인 같은 계정의 vSphere Client 로그인
Datacenter·ESXi Host 조회 실패 실제 이름·조회 권한 Hosts and Clusters
템플릿을 찾지 못함 Datacenter 기준 상대 폴더·이름 VMs and Templates
Permission denied 원본·목적지·네트워크·스토리지 권한 오류 privilege, 각 객체 Permissions
재구성 중 VirtualMachine.Config.Resource 누락 VM 자원 할당 변경 권한 기존 Role의 가상 시스템 → 구성 변경 → 리소스 변경
System.Read privilege required for config.distributedVirtualSwitch 분산 스위치 조회 권한 VDS → 권한 → API 사용자 + Read-only, 전파 끔. Port group Role은 유지
복제는 됐지만 OS 부팅 실패 EFI/BIOS·컨트롤러·디스크 VM 콘솔, 템플릿 설정
Customization timeout Rocky 지원 조건·Tools·Perl·네트워크 초기화 충돌 vCenter Events, 게스트 Tools 로그
Guest network timeout 실제 IP·게이트웨이·Tools 보고 VM 콘솔, Summary, nmcli
복제 후 NIC가 하나만 남음 리소스 바깥 NIC 선언이 한 개뿐인지 main.tf의 dynamic NIC, 템플릿 NIC 목록
uplink에 주소가 남아 있음 초기화 스크립트 허용·실행·NIC 이름 opentofu-guest-init.log, ens38 주소·프로필
디스크 크기 오류 원본보다 작은 디스크 또는 다른 장치 구조 템플릿 Edit Settings
SSH 접속 실패 라우팅·방화벽·sshd·계정 공개키 VM 콘솔과 SSH 상세 로그

진행률이 높아도 권한 확인이 끝난 것은 아니다. 템플릿 복제 뒤에 VM 재구성이 이어지므로 90%대에서 실패했다는 사실만으로 부족한 권한을 특정할 수 없다. vSphere Client의 하단 최근 작업(Recent Tasks) → 실패한 작업 → 오류 상세를 확인하고, 필요하면 대상 VM → 모니터(Monitor) → 작업 및 이벤트(Tasks and Events)에서 권한 ID와 대상을 찾는다. 실습에서는 VirtualMachine.Config.Resource 누락과 VDS의 System.Read 오류를 확인했고, 리소스 변경 권한 추가와 VDS Read-only 연결 후 해당 권한 오류 해소를 확인했다. VM 내부 배포·기능 시험 결과와는 구분한다.

Rocky 콘솔에서 확인할 명령:

systemctl status vmtoolsd --no-pager
journalctl -u vmtoolsd -b --no-pager
sudo ls -l /var/log/vmware-imc/
nmcli device status
nmcli connection show
ip -br address
ip route
systemctl status sshd --no-pager

/var/log/vmware-imc/에는 게스트 커스터마이징 관련 로그가 생성될 수 있다. 해당 디렉터리나 로그가 없는 경우에는 커스터마이징이 시작됐는지부터 vCenter Events와 Tools 상태를 확인한다.

IP 대기 timeout만 늘려서 해결하려고 하지 않는다. NIC 연결 문제, 잘못된 VLAN, 미지원 커스터마이징은 기다리는 시간을 늘려도 바뀌지 않는다.

10. Ansible·릴리스 검증으로 연결

이어지는 OpenTofu 입문 가이드 - 3에서는 VM 4대의 SSH 준비 확인, 첫 VM의 TAR 다운로드·해제, deploy.sh 실행, Ansible 실행을 연결한다. inventory와 main.yml은 TAR에 미리 준비된 파일을 그대로 사용한다.

VM 생성 이후 결과를 JSON으로 내보내면 다음 단계가 읽기 쉽다.

tofu output -json vm_info > vm-info.json

TAR 안의 Ansible inventory에는 Deploy·Controller·Compute 등의 역할과 고정 IP를 준비한다. OpenTofu의 VM IP도 같은 값으로 지정하고, 실제 SSH 연결을 확인한 뒤 설치를 시작한다. Runner에서 inventory를 생성하거나 복사할 필요는 없다.

  1. OpenTofu VM 생성
  2. IP·SSH 준비 확인
  3. Ansible 설치·기능 시험
  4. 로그 수집·VM 삭제

릴리스 검증에서는 실행마다 VM 이름·폴더·State를 분리한다. 생성된 Deploy VM이 staging Bundle을 다운로드하고, Private Nexus와 Ansible 컨테이너를 준비한 뒤 나머지 노드를 설치하는 흐름으로 확장할 수 있다.

여기서 OpenTofu가 판단하는 VM 생성 성공과 Ansible의 설치 성공, OpenStack 기능 시험 성공은 각각 다른 결과다. Workflow는 단계별 결과를 받아 다음 단계로 진행하고, 실패해도 해당 실행에서 만든 VM을 정리할 수 있게 State를 유지한다.

11. 참고 자료