Vagrant를 떠나 KVM, libvirt, virsh로 가상 머신을 직접 만들고 관리하게 된 이유와, preseeding, 네트워크, 마운트, SSH 에이전트 포워딩까지 정리한 개요.
안녕, 오랜 친구여. 우리는 2010년부터 함께해 왔고, 너는 내 모든 가상 머신 요구를 충실히 잘 처리해 주었다. 처음에는 VirtualBox와 함께, 그리고 나중에는 libvirt와 KVM과 함께 말이다. 하지만, 사람들이 1 말하듯이, 좋은 것은 모두 끝나기 마련이다.
무슨 일이 있었을까? 무엇이 바뀌었을까?
libvirt and virsh그래서, 우리는 여기서 무엇을 하고 있을까? 왜 헤어지는 걸까? 솔직히 말해서, 나는 Vagrant가 그저 나 같은 소박한 사람에게는 너무 많은 소프트웨어라는 것을 알게 되었다. 이 짧은 삶을 살아가며 계속 배워 나가고 점점 더 많은 것들을 접하게 되면서, 예전에는 지금만큼 알지 못했을 때 내렸던 선택들을 의심하고 다시 평가하게 된다. 나는 항상 지난 프로젝트와 결정들을 다시 돌아보곤 했고, 그것은 내게 큰 도움이 되었다. 이번 경우에는 내 작업 흐름을 너무 복잡하게 만들고 있다는 것을 보게 되었다.
2010년에 처음 Vagrant를 쓰기 시작했을 때는, 그저 VM의 생명주기를 관리해 준다는 사실만으로도 만족했다. Vagrant boxes는 멋졌고 시간을 절약해 주었으며, 나중에 Ansible을 쓰기 시작하면서는 Vagrant의 내장 Ansible 지원으로 내 머신들을 프로비저닝하기 시작했다.
하지만 Linux에 대해 점점 더 많이 배우면서, 왜 그냥 Vagrant 대신 KVM을 쓰지 않는지 궁금해지기 시작했다. 결국 이것은 Linux 커널 2.6.20 버전부터 병합되어 있었으니, 가상 머신을 만들고 관리하는 데 필요한 도구들은 이미 가지고 있는 셈이었다. Linux가 네이티브로 할 수 있는 일을 만들기 위해 왜 또 다른 소프트웨어 계층, 또 다른 추상화를 둬야 할까?
나는 점점 내가 겁쟁이 같다고 느끼기 시작했다. 냄새나는 작은 똥덩이처럼 느껴지기 시작했다. 무엇보다도, 내가 게으르게 굴고 있다는 사실을 깨달았다. 그래서 몇 년 전 절충안으로 VirtualBox provider에서 libvirt provider로 전환한 뒤 그냥 삶을 계속했다. 결국 내게는 훨씬 더 중요한 일들이 있었기 때문이다. 예를 들면 당시 고용주와 그들의 고객들을 위해 내가 할 수 있는 한 아주 열심히 일하는 것 같은 일들 말이다. 그들이 그것이 중요하다고, 가장 중요한 일이라고, 너무 중요해서 Agile 때문이고 Scrum master 때문이고 고객에게 가치를 제공하지 않는다는 이유로 평일 근무 시간에는 고칠 시간을 받지 못했던 성급한 인프라를 연속된 밤 새벽 3시에 일어나 고쳐야 한다고 말했기 때문이다.
어쨌든, 이런 부끄러운 행동을 몇 년 하고 난 뒤, 마침내 시간을 따로 내어 KVM과 libvirt를 깊이 파고들고 제대로 해 보기로 했다. 그래, 얘들아, 나는 Vagrant 2를 삭제했고 아내는 다시 나를 사랑하게 되었다.
그래서 내가 무엇을 했을까? 자, 모닥불가로 와서 함께 배워 보자.
libvirt and virshKVM은 Linux 커널이 커널 가상화 모듈을 통해 하이퍼바이저처럼 동작하여 가상 머신(VM)을 생성하고 실행할 수 있게 해 준다. 가상화를 통해 하드웨어는 소프트웨어로 에뮬레이션되므로, VM을 만든다는 것은 호스트 운영체제 안에 하나의 완전한 운영체제를 두는 것과 같다. 멋지지 않은가? 물론 엄청 멋지다!
커널 모듈은 아마도 활성화되어 있을 것이다. 확인하려면:
$ lsmod | ag kvm
kvm_intel 380928 0
kvm 1146880 1 kvm_intel
irqbypass 16384 1 kvm
또는:
$ ls /dev/kvm
/dev/kvm
반면 libvirt는 KVM과 다른 가상화 플랫폼들, 예를 들어 Xen, LXC, QEMU를 관리하는 라이브러리이자 네트워크 데몬이다. 이것은 VM을 생성, 시작, 중지, 일시정지, 삭제할 수 있게 해 주며, 그 밖에도 스토리지와 네트워크 관리 같은 기능을 제공한다. libvirt의 정말 좋은 점은, 지원하는 여러 플랫폼과 상호작용할 때 통일되고 공통된 라이브러리를 제공한다는 것이다. 그래서 다른 것을 쓸 필요도 없고, 서로 다른 하이퍼바이저마다 각기 다른 명령과 동작을 배울 필요도 없다. 다시 말해, 가상화 백엔드를 바꾸더라도 같은 명령으로 계속 libvirt를 사용할 수 있다.
설치하려면:
$ sudo apt-get install libvirt-daemon-system
virsh는 libvirt용 명령줄 프런트엔드다(libvirt는 virt-manager 같은 다른 프런트엔드도 지원하지만, 그건 GUI이고, CLI 도구가 있는데 GUI를 쓰는 사람은 없다는 건 누구나 안다). 이것은 libvirt 데몬과 상호작용하기 위한 깔끔한 추상화 계층을 제공하고, 그 데몬은 다시 KVM과 상호작용한다.
설치하려면:
$ sudo apt-get install libvirt-clients virtinst
이것은
virt-install도 함께 설치한다
가상 머신(즉, 도메인)에 대한 정보를 얻는 데 유용한 virsh 명령 몇 가지는 다음과 같다:
domblkinfodomblkstatdomiddomiflistdomifstatdominfodommemstatdomnamedomstatedomuuid그리고 호스트와 노드에 대한 정보를 얻으려면:
capabilitieshostnamenodeinfo그리고 유용한 관리 명령은 다음과 같다:
connectdestroydumpxmleditlistrebootshutdownstartundefine여기에 나열하기에는 정말 너무 많다. virsh 문서를 보라.
다시 말하지만, 개념적 모델은 user ->
virsh->libvirt-> KVM 이다.
이 각각의 주제에 대해 더 이해해야 할 것이 많지만, 이것만으로도 시작하기에는 충분하다. 이 주제와 그 주변에서 읽어볼 만한 좋은 글들은 다음과 같다:
나는 libvirt와 KVM으로 즐겁게 가상 머신을 만들고 있었다. 세상 걱정 하나 없었다. 그런데 갑자기 내 세상은 완전히 무너져 내렸다. bookworm에서 trixie로 업그레이드했더니, 이제 VM을 시작할 때 시리얼 출력이 전혀 나오지 않았다.
호스트와 VM이 시리얼 포트를 통해 통신할 수 있도록 하는 커널 부트 파라미터를 예전처럼 virt-install 명령에 전달하고 있었지만(이 부분은 뒤에서 더 이야기한다), 이제는 그 파라미터들이 실제로 VM에 기록되지 않는 것처럼 보였다(즉, 내 부트로더 설정에).
어떤 파라미터가 실제로
grub(또는 사용하는 다른 부트로더)에 기록되었는지 확인하려면, 가상 머신에 로그인해서grub설정을 열어 보라:
/etc/default/grub중요한 줄은 다음과 비슷할 것이다:
GRUB_CMDLINE_LINUX_DEFAULT="quiet" GRUB_CMDLINE_LINUX="console=ttyS0,115200"무언가를 변경했다면, 다음 명령을 실행하라:
$ sudo grub-mkconfig -o /boot/grub/grub.cfg $ sudo reboot
생각해 본 끝에, 나는 다른 방식으로 접근하기로 했다. 무엇이 바뀌었는지 디버깅하는 데 (어쩌면 긴) 시간을 쓰는 대신, 설치 명령에 설정 파일을 전달해서 이른바 preseed된 가상 머신을 만들기로 했다. 이것은 전반적으로 더 나은 해결책인데, 빌드를 결정론적으로 만들고 버전 관리할 수 있게 해 주기 때문이다(또는 빌드 머신들이 접근할 수 있는 어딘가에 둘 수도 있다). 그리고 파일에 지정한 어떤 값이든 가상 머신에 기록된다는 것을 알 수 있다.
게다가, 모든 머신이 공통으로 가져야 할 소프트웨어를 미리 설치해 둘 수 있고, 나중에는 cloud-init 같은 것을 사용해 VM 생성 생명주기에 훅을 걸어 각 개별 머신에 맞는 추가 소프트웨어를 넣을 수도 있다. 멋지다!
이제 그것을 살펴보자.
그렇다면 preseeding이란 무엇일까? 앞서 한 말에서 짐작했겠지만, preseeding은 설치 과정에서 묻는 질문들, 예를 들어 지역화, 사용자 이름과 비밀번호, 설치 패키지 등등에 대해 미리 정해 둔 답을 제공함으로써 가상 머신 생성을 자동화하는 방법이다. 익숙한 그 과정이다. 천 번쯤 지나쳐 온 바로 그 설치 절차 말이다.
나는
preseeding이라는 단어를 주로 Debian 빌드 맥락에서 보았지만, 다른 운영체제들도 비슷한 방법을 가지고 있다.
Debian trixie는 친절하게도 여러분이 자신만의 파일을 만드는 기초로 사용할 수 있는 예제 사전 설정 파일을 제공한다. Automating the installation using preseeding에서 더 훌륭한 정보를 볼 수 있는데, 아주 좋은 글이며 강력히 추천한다.
이 모든 것이 멋지게 들리고 아마도 여러분은 완전히 놀랐을 것이며, 이제 이것을 어떻게 시작할지 알고 싶을 것이다. 여기서 앞서 언급한 우리의 작은 친구 virt-install의 등장이다:
$ virt-install \
--connect qemu:///system \
--name kilgore-trout \
--memory 8192 \
--extra-args="preseed/file=/preseed.cfg console=ttyS0,115200n8" \
--initrd-inject ./preseed.cfg \
--install debian13 \
--disk size=40 \
--filesystem type=mount,source=/home/btoll/libvirt/kilgore-trout/mnt,target=shared,accessmode=mapped,driver.type=path,driver.wrpolicy=immediate \
--network network=default \
--graphics none
파라미터와 그 값들을 살펴보자(대부분의 값 설명은 virt-install 매뉴얼 페이지에서 거의 그대로 가져왔다):
| Parameter | Value |
|---|---|
--connect | 기본이 아닌 하이퍼바이저에 연결한다. 이것이 지정되지 않으면 libvirt는 가장 적절한 기본값을 선택하려고 시도한다. 시스템 libvirtd 인스턴스가 실행할 KVM 및 QEMU 게스트를 생성할 때 사용한다. 이것은 virt-manager가 사용하는 기본 모드이며, 대부분의 KVM 사용자가 원하는 방식이다. |
--name | 새 게스트 가상 머신 인스턴스의 이름이다. 현재 활성 상태가 아닌 것을 포함해, 해당 연결에서 하이퍼바이저가 알고 있는 모든 게스트 사이에서 고유해야 한다. |
--memory | 게스트에 할당할 메모리 크기이며, 단위는 MiB다. |
--extra-args | 게스트 설치를 수행할 때 설치 프로그램에 전달할 추가 커널 명령줄 인수다. |
--initrd-inject | --extra-args에서 참조할 preseed 파일이 HOST 상에 있는 위치다. |
--install | virt-install은 libosinfo에서 --location URL을 가져와 그곳의 기본값들을 채운다. |
--disk | 새 40G 디스크 이미지를 만들고 관련 디스크 장치를 생성한다. virt-install이 경로 이름을 생성하고 하이퍼바이저의 기본 이미지 위치에 배치한다. |
--filesystem | 호스트의 디렉터리를 게스트에 내보내도록 지정한다. |
--network | 게스트를 호스트 네트워크에 연결한다. 이 네트워크는 호스트 네트워크와 분리되며 가상 브리지로 연결된다. |
--graphics | 헤드리스로 설치한다. 게스트는 첫 번째 시리얼 포트에 텍스트 콘솔이 설정되어 있어야 할 가능성이 높다(--extra-args 옵션으로 설정 가능). |
--extra-args에console=ttyS0,115200n8이 없으면 가상 머신(VM)을 시작할 때 멈춘 것처럼 보이지만 실제로는 그렇지 않다. 문제는 터미널과 가상 머신 사이에 연결이 없어서, 시리얼 포트에서 화면으로 기록되는 로그 출력이 보이지 않는다는 것이다. 연결이 없기 때문이다.
위 명령은 qemu:///session 데몬을 대상으로 실행하면 실패한다. 네트워크는 시스템 전체에서 사용 가능해야 한다(uri 문자열에 주목하라):
$ virsh --connect qemu:///system net-list --all
Name State Autostart Persistent
----------------------------------------------------
default active yes yes
vagrant-libvirt active no yes
참고로 vagrant-libvirt 네트워크가 목록에 보이는 이유는 내가 이전에 libvirt provider와 함께 Vagrant를 사용하고 있었기 때문이다.
위와 같은 virt-install 명령이 성공적으로 실행되면 로그인 프롬프트로 들어가게 된다:
$ virsh -c qemu:///system list --all
Id Name State
--------------------------------
- kilgore-trout shut off
$ virsh -c qemu:///system start kilgore-trout
Domain 'kilgore-trout' started
$ virsh -c qemu:///system console kilgore-trout
Connected to domain 'kilgore-trout'
Escape character is ^] (Ctrl + ])
The highlighted entry will be executed automatically in 0s.
Booting `Debian GNU/Linux'
Loading Linux 6.12.94+deb13-amd64 ...
Loading initial ramdisk ...
/dev/mapper/kilgore--trout--vg-root: clean, 51705/2428272 files, 650933/9700352 blocks
[ 2.375045] systemd-ssh-generator[287]: Failed to query local AF_VSOCK CID: Cannot assign requested address
[ 2.376760] (sd-exec-[279]: /usr/lib/systemd/system-generators/systemd-ssh-generator failed with exit status 1.
Debian GNU/Linux 13 kilgore-trout ttyS0
kilgore-trout login: btoll
Password:
Linux kilgore-trout 6.12.94+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.94-1 (2026-06-20) x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
로그인한 뒤, 네트워크 연결을 확인해 보자.
btoll@kilgore-trout:~$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host noprefixroute
valid_lft forever preferred_lft forever
2: enp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 52:54:00:52:b4:2d brd ff:ff:ff:ff:ff:ff
altname enx52540052b42d
inet 192.168.122.112/24 brd 192.168.122.255 scope global dynamic noprefixroute enp2s0
valid_lft 3420sec preferred_lft 2970sec
inet6 fe80::c7e7:221f:751d:773c/64 scope link
valid_lft forever preferred_lft forever
btoll@kilgore-trout:~$ ip route
default via 192.168.122.1 dev enp2s0 proto dhcp src 192.168.122.112 metric 1002
192.168.122.0/24 dev enp2s0 proto dhcp scope link src 192.168.122.112 metric 1002
btoll@kilgore-trout:~$ ping benjamintoll.com
PING benjamintoll.com (167.114.97.28) 56(84) bytes of data.
64 bytes from dinesh (167.114.97.28): icmp_seq=1 ttl=44 time=44.0 ms
64 bytes from dinesh (167.114.97.28): icmp_seq=2 ttl=44 time=43.5 ms
64 bytes from dinesh (167.114.97.28): icmp_seq=3 ttl=44 time=40.8 ms
--- benjamintoll.com ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 40.781/42.771/43.993/1.419 ms
그리고 호스트에서는 ip을 사용한다:
$ ip link show type bridge
4: virbr0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 52:54:00:9d:81:89 brd ff:ff:ff:ff:ff:ff
$ ip addr show dev virbr0
4: virbr0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 52:54:00:9d:81:89 brd ff:ff:ff:ff:ff:ff
inet 192.168.122.1/24 brd 192.168.122.255 scope global virbr0
valid_lft forever preferred_lft forever
$ ip a show dev vnet46
77: vnet46: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master virbr0 state UNKNOWN group default qlen 1000
link/ether fe:54:00:c7:5e:c3 brd ff:ff:ff:ff:ff:ff
inet6 fe80::fc54:ff:fec7:5ec3/64 scope link proto kernel_ll
valid_lft forever preferred_lft forever
또는:
$ ip link show dev vnet46
77: vnet46: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master virbr0 state UNKNOWN mode DEFAULT group default qlen 1000
link/ether fe:54:00:c7:5e:c3 brd ff:ff:ff:ff:ff:ff
즉, libvirt가 virbr0 가상 브리지를 만들고 192.168.122.0/24 네트워크를 분리했다는 뜻이다. 좋다.
설치되어 있다면 brctl 유틸리티를 사용할 수도 있다:
$ brctl show virbr0
bridge name bridge id STP enabled interfaces
virbr0 8000.5254009d8189 yes vnet46
가상 네트워크 인터페이스 이름이 vnet46이니, virsh를 사용해 그것에 대한 정보를 좀 더 얻어 보자:
$ virsh --connect qemu:///system domifaddr kilgore-trout
Name MAC address Protocol Address
-------------------------------------------------------------------------------
vnet46 52:54:00:c7:5e:c3 ipv4 192.168.122.113/24
VM 안에서는:
$ ip a show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host noprefixroute
valid_lft forever preferred_lft forever
2: enp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 52:54:00:c7:5e:c3 brd ff:ff:ff:ff:ff:ff
altname enx525400c75ec3
inet 192.168.122.113/24 brd 192.168.122.255 scope global dynamic noprefixroute enp2s0
valid_lft 2537sec preferred_lft 1880sec
inet6 fe80::e708:62a0:9e79:ddcc/64 scope link
valid_lft forever preferred_lft forever
이 dynamic 키워드는 IP 주소가 DHCP를 통해 임대되었음을 알려 준다(libvirt는 dnsmasq를 사용한다). 임대 수명은 다음 줄에 지정되어 있다(valid_lft 2537sec preferred_lft 1880sec):
valid_lft - (유효 수명) = 주소가 만료될 때까지 2819초(~47분)preferred_lft (선호 수명) = 시스템이 갱신해야 할 때까지 2162초(~36분)MAC address의 마지막 세 옥텟(c7:5e:c3)이 호스트의 장치와 같다는 점에 주목하라. 이것은 이것이 TAP 장치의 한쪽 끝점이며, 다른 쪽은 호스트의 vnet46 장치임을 알려 준다. 이 TAP 장치는 서로 다른 두 네트워크 네임스페이스를 연결하는 가상 이더넷 케이블처럼 동작한다.
이제 마운트 장치를 확인해 보자.
호스트에서 공유하는 디렉터리가 반드시 존재해야 한다는 점은 굳이 말할 필요도 없다. 그렇지 않으면
virt-install은 실패한다.
VM 안에서:
$ mount --type 9p
shared on /mnt/shared type 9p (rw,relatime,cache=0xf,access=client,trans=virtio)
9P란 무엇일까? 이것은 Plan 9 from Bell Labs 분산 운영체제를 위해 개발된 네트워크 프로토콜로, 처음에는 Ken Thompson과 Rob Pike가 이끌었고, Unix와 C를 개발한 바로 그 그룹의 일부였다. 와.
좋다. 이제 마운트된 shared 디렉터리로 이동해 보자.
btoll@kilgore-trout:~$ cd /mnt/shared/
btoll@kilgore-trout:/mnt/shared$ touch grass
touch: cannot touch 'grass': Permission denied
이 문제는 호스트에서 권한을 열어 줌으로써 해결할 생각이지만, 언젠가 더 자세히 들여다볼 필요는 있다.
$ chmod 777 mnt
VM으로 돌아와서:
btoll@kilgore-trout:/mnt/shared$ touch grass
btoll@kilgore-trout:/mnt/shared$ ls
grass
해결책은 아니지만, 임시변통이다. 인생에는 이보다 더 나쁜 일들도 있다.
SSH 에이전트 포워딩을 활성화하라:
$ eval $(ssh-agent) && ssh-add ~/.ssh/your_private_key
$ ssh -A 192.168.122.114
VM에 로그인했을 때 어떤 작업을 수행할 권한이 없다는 것을 알게 된다면(ssh로 저장소를 클론하는 것처럼), 포워딩이 성공했는지 확인하라:
$ ssh-add -l
256 SHA256:SYtbHfjUelfldW4+nK7YVT/O9mMZRPKSnaU4kgN9LG4 ben@benjamintoll.com (ED25519)
물론 언제든 console 명령으로 VM에 로그인할 수 있다:
$ virsh --connect qemu:///system console kilgore-trout
좋다, 이것이면 내가 필요한 것에 충분히 가까워졌으니, 앞으로는 Vagrant 대신 virsh를 사용해 가상 머신을 만들 것이다.
$ virsh -c qemu:///system dumpxml kilgore-trout
뻔한 이야기부터 치우고 가자. 이것은 놀라운 개요다. 짧긴 하지만, 그렇다고 해서 Marky Mark와 Calvin Klein이 멈춘 것은 아니었다.
libvirtvirt-install