위키백과에서는 '가상화'를 다음과 같이 설명한다.
가상화(假像化, virtualization)는 컴퓨터에서 컴퓨터 리소스의 추상화를 일컫는 광범위한 용어이다. "물리적인 컴퓨터 리소스의 특징을 다른 시스템, 응용 프로그램, 최종 사용자들이 리소스와 상호 작용하는 방식으로부터 감추는 기술"로 정의할 수 있다.
이 설명을 보고 가상화가 뭔지 곧바로 이해할 수 있는 사람은 많지 않을 것이다. 다른 예를 통해 살펴보자.
컴퓨터에 '가상'이라는 말이 쓰이는 곳이 하나 더 있다. 바로 디스크를 메모리처럼 사용하는 가상 메모리이다. 메모리가 부족해지면 운영체제는 메모리에 저장할 데이터를 디스크에 저장한다. 그러나 응용 프로그램은 이를 인지하지 못하고 계속 메모리에서 데이터를 읽고 있다고 생각한다. 메모리가 부족할 때 메모리 대신 디스크에서 데이터를 읽는 코드를 작성할 필요가 없다는 뜻이다. 운영체제는 응용 프로그램에게 제공되는 공간이 물리적으로 실제 메모리인지 아닌지를 의도적으로 숨겨서 개발자의 부담을 낮춘다.
디스크가 메모리인척 하는 가상메모리처럼, 하드웨어나 운영체제 같은 리소스의 실체를 감추고 진짜인 척 하는 것이 가상화이다. (넓은 의미로 보면 가상 메모리도 가상화의 일종이다.) 가상화된 리소스를 사용하는 개체(이하 게스트로 표기)는 진짜 리소스가 있는 외부 세계로부터 격리되어 자신 만의 공간에 갇혀 있다. 응용 프로그램이 자신이 사용하는 공간이 메모리인지 디스크인지 몰랐던 것 처럼, 게스트는 자신이 사용하는 리소스가 가상화 되었다는 사실을 알 수 없다.
나이지리아 속담 중에 '아이 하나를 키우기 위해 마을 전체가 필요하다.'라는 말이 있다고 한다. 사람을 키우는 것에 비할 바는 아니지만, 컴퓨터 프로그램도 정상적으로 가동하기 위해선 많은 도움이 필요하다.
Hello, world를 출력하는 C 프로그램을 작성하고 실행 과정을 추적해보자.
#include <stdio.h>
int main() {
printf("Hello, World!");
return 0;
}
이 코드는 컴파일을 거치면 기계어 코드로 변환된다. 하지만 컴파일이 되었다고 해서 혼자만의 힘으로 실행될 수 있는 것은 아니다. 'printf' 함수는 이 소스 파일에 포함되지 않았다. 누군가 미리 작성해둔 함수를 호출하는 것이다. ldd 명령어를 통해 확인해보면 libc 공유 라이브러리에 의존하고 있음을 알 수 있다.
$ ldd hello
linux-vdso.so.1 => (0x00007ffd545a3000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5ee596f000)
/lib64/ld-linux-x86-64.so.2 (0x00007f5ee5d39000)
만약 공유 라이브러리가 업데이트 되어, printf 함수의 동작이 전혀 다른 방식으로 바뀌어 버린다면 개발자가 처음 의도한대로 동작하지 않게 된다. 아예 printf라는 함수가 없어지기라도 한다면 실행이 불가능해질 것이다. 의존하던 라이브러리의 내용이 바뀌는 것은 종종 일어날 수 있는 일이다. 물론 라이브러리의 개발자들이 하위 호환을 최대한 보장하기 위해 노력하긴 하겠지만 어쩔 수 없는 경우가 생긴다.
라이브러리 의존성을 정확하게 맞추었다고 끝이 아니다. 프로세스는 컴퓨팅 자원을 사용하기 위해 운영체제의 허락을 구해야 한다. 그런데 자원을 요청하기 위한 방법이 운영체제마다 다르다. 따라서 리눅스에서 가동할 목적으로 컴파일한 바이너리를 윈도우나 맥에서 가동할 수 없다.
마지막으로 하드웨어 역시 호환성이 맞아야 한다. 프로그램을 컴파일한 컴퓨터와 CPU 아키텍처가 다르다면 프로그램을 실행할 수 없다. 예를 들어 인텔 프로세서를 사용하는 맥에서 컴파일한 바이너리를 M1 맥에서 실행할 수 없다. CPU가 처리할 수 있는 명령어가 판이하게 다르기 때문이다.
Hello World 하나를 실행하기 위해 수많은 준비물들을 정확히 준비해야 한다는 것을 알았다. 우리가 개발할 프로젝트는 Hello World보다 훨씬 복잡하기 때문에 더 많은 준비물들이 필요하다. 그런데 매번 처음 개발할 때와 동일한 환경을 구축하는 것은 매우 번거로운 일이고, 때로는 불가능 할 수도 있다.
서비스에 또 하나의 Hello, World를 추가해야 한다고 해보자. 이 Hello World는 윈도우 환경에서 C#으로 작성한 것으로, 기존의 서비스와 동시에 돌아가야 한다. 가상화가 없다면 한 컴퓨터에서 두 개의 운영체제를 동시에 가동하는 방법은 없으므로 (듀얼 부트는 하나씩 번갈아 켜는 것이지 동시에 켜는 것이 아니다.) 컴퓨터를 한 대 더 사는 수 밖에 없다.
리눅스와 윈도우를 동시에 가동하지 못하는 이유는, 운영체제가 하드웨어에 전권을 행사하는 것을 전제하고 있기 때문이다. 윈도우와 리눅스가 정말로 동시에 가동된다면 두 개의 전지전능한 운영체제가 하나의 하드웨어 자원을 놓고 경쟁하게 된다. 만약 하드웨어에 전권을 행사하는 진짜 주인(이하 호스트로 지칭)은 따로 있고 OS는 호스트가 제공하는 하드웨어에만 접근할 수 있다면 경쟁이 일어나지 않을 것이다. 따라서 가상화를 하면 한 대의 컴퓨터에서 여러 대의 OS를 가동할 수 있게 된다. 고작 헬로 월드를 출력하기 위해 컴퓨터를 한 대 더 사는 사태를 방지한 것이다.
헬로 월드 개발팀은 이제 윈도우 버전과 리눅스 버전을 개발하기 위해 두 컴퓨터 사이를 왔다갔다 할 필요가 없다. 개발 환경을 설정하는 부담이 훨씬 줄어든 것이다. 또한 헬로 월드 인프라팀은 기존 서버 컴퓨터의 한계가 올 때까지 얼마든지 헬로 월드 프로그램을 늘릴 수 있다. 가상화를 통해 컴퓨팅 자원의 낭비를 최소화한 것이다.
(주의: 에뮬레이션을 가상화와 별개의 개념이라고 하는 자료와, 전가상화(Full virtualization)의 일종으로 설명하는 자료가 모두 존재한다. 이 포스팅에선 에뮬레이션을 VM과 구분되는 가상화의 일종으로 설명한다.)
에뮬레이션은 하드웨어 리소스의 동작을 소프트웨어로 대신하는 가상화 방식을 말한다. 에뮬레이터 안의 소프트웨어는 자신이 하드웨어를 통해 실행되는 것으로 알고 있지만, 사실은 다른 개발자가 만든 소프트웨어에 의해 실행된다.
개발자가 아니더라도 게임을 좋아하는 사람이라면 '에뮬레이터'라는 용어를 들어보았을 것이다. IT와 무관한 곳에서 에뮬레이터라는 말을 사용할 때는, 주로 게임기 전용으로 출시된 게임을 컴퓨터나 스마트폰 환경에서 구동할 수 있게 해주는 소프트웨어를 의미한다.
게임기와 같이 전혀 다른 하드웨어를 위해 작성된 바이너리 코드가 어떻게 PC에서 구동될 수 있을까? CPU와 메모리의 동작을 대신할 코드를 작성하면 된다. 우선 메모리는 배열로 흉내낼 수 있다. 구현하려는 하드웨어의 메모리와 동일한 크기의 배열을 선언하면 된다. 여기에 에뮬레이터를 통해 실행할 바이너리 명령어를 저장해놓는다.
우리의 CPU는 명령어를 직접 실행할 수 없으므로, 명령어가 어떤 동작을 원하는지 해석해 코드로 대신 실행해야 한다. 파이썬으로 동작을 표현하면 다음과 같다.
opcode, eax, ebx = decode(memory[program_counter])
if opcode == ADD:
#...
elif opcode == PUSH:
#...
elif opcode == MOV
#...
#...
당연히 CPU가 명령어를 직접 실행할 때 보다는 훨씬 비효율적이다. 현대의 컴퓨터는 고전 게임기보다 훨씬 뛰어난 컴퓨팅 파워를 가지고 있기 때문에 이 정도 핸디캡은 가볍게 극복한다. 그러나 성능을 신경써야 하는 환경이라면 쓰기 어렵다.
에뮬레이션의 가장 큰 장점은 구동 환경과 전혀 다른 하드웨어도 가상화할 수 있다는 것이다. 다음 파트에서 소개할 하이퍼바이저는 슈퍼패미콤이나 닌텐도 DS를 위해 작성된 프로그램은 구동할 수 없다. CPU 아키텍처의 차이가 너무 크기 때문이다. 그러나 에뮬레이션에서는 아키텍처의 차이가 큰 문제가 되지 않는다.
심지어는 현실에는 존재하지 않는 아키텍처도 에뮬레이션 할 수 있다. 자바 가상 머신(JVM)은 흔히 쓰이는 '가상머신'의 의미보다는 에뮬레이터에 훨씬 가깝다. 자신만의 메모리 구조와 명령어(바이트 코드)를 사용하는, 현실에 존재하지 않는 컴퓨터의 동작을 소프트웨어로 구현한 것이다.
가상머신은 프로그램에게 제공되는 가상화된, 즉 실체가 감추어진 하드웨어를 말한다. 앞서 말한 '프로그램'은 곧 운영체제라고 봐도 된다. 하드웨어를 직접 다루는 프로그램은 운영체제나 펌웨어 정도 밖에 없기 때문이다.
에뮬레이터 역시 프로그램에게 가상화된 하드웨어를 제공했다. 그러나 에뮬레이터가 제공한 것은 진짜 하드웨어가 아니라, CPU 명령어를 대신 실행하는 인터프리터 프로그램이었다. 반면 가상머신을 이용하는 응용프로그램은 하드웨어를 직접 사용한다. 그러나 Host OS나 Type 1 Hypervisor 같은 하드웨어의 진짜 주인들(호스트)과는 엄연한 차이가 있다.
호스트는 컴퓨터에 장착된 모든 하드웨어에 대해 사용 권한이 있다. 그러나 Guest OS는 호스트가 허락하는 만큼만 사용할 수 있다. 우선 리소스의 사용량이 제한된다. 16GiB 메모리중 2GiB만 내어주고 CPU 코어도 하나로 제한하는 식이다. 물론 '가상화' 되었기 때문에 Guest OS는 자신이 전권을 행사하고 있으며 이 컴퓨터에는 원래 메모리가 2GiB 밖에 없다고 생각하고 있다.
또한 일부 특권 명령어(Privileged Instruction)의 사용이 제한된다. 특권 명령은 호스트의 커널만이 실행 권한을 갖는 보안이 중요한 명령어다. 버추얼박스에 있는 OS에서 시스템 종료를 했는데 실제 컴퓨터 전원이 꺼진다면 참 황당할 것이다. 이런 명령어는 하이퍼바이저가 가로채서 다른 명령어로 바꾸어 버린다. 물론 Guest OS는 자신이 특권이 필요한 대단한 명령어를 실행했다고 착각하고 있다.
Guest OS가 자신이 가상화되어 있다는 사실을 전혀 모르는 것은 고전적인 전가상화(Full virtualization) 한정이다. 특권 명령어를 모두 가로 채는 것이 성능에 좋지 않기 때문에, 매트릭스의 네오처럼 자신이 가상머신에 있다는 것을 알고 호스트와 직접 소통하는 Guest OS도 있다. 이를 반만 가상화 되었다고 해서 반가상화(Para virtualization)라고 한다. 보통 운영체제는 호스트가 되는 것을 전제로 만들기 때문에, 반가상화를 위해 수정된 전용 커널을 사용해야 한다.
하드웨어를 직접 사용하기 때문에 아키텍처의 차이가 있다면 사용할 수 없다. 예를 들어 m1 칩을 사용하는 맥에서 x86용 윈도우를 사용할 수는 없다. (m1 맥에서 윈도우를 사용하는 영상은 x86이 아닌 ARM용 윈도우를 이용한 것이다. 윈도우 7같은 구형 OS는 ARM 버전이 없어 에뮬레이터를 이용해야 한다.)
앞서 운영체제마다 자원을 요청하는 방식이 다르므로 다른 OS의 프로그램을 구동할 수 없다고 했다. 예를 들어 리눅스에서는 파일을 읽기 위해 'read' 시스템 콜을, 윈도우에서는 win32 api의 'ReadFile'을 사용한다.
만약 윈도우 프로그램이 'ReadFile'을 호출할 때마다 누군가가 'read'로 바꾸어준다면 리눅스도 윈도우 프로그램을 이해할 수 있을 것이다. 이런 원리를 이용하는 것이 Wine 같은 프로그램이다.
가상머신은 OS를 격리시켜 하드웨어에 대해 알 수 없게 만들었다. 컨테이너는 1개 이상의 프로세스를 격리시켜 OS 환경에 대해 알 수 없게 만든다. 예를 들어 컨테이너는 전용의 루트 디렉토리를 부여받으며, 이 안에선 root 계정 행세를 하지만 host의 파일에는 절대 접근할 수 없다. 프로세스 ID도 자신만의 것을 가지고 있어 host에서 부여한 PID는 알 수 없다.
루트 디렉토리를 격리한다는 것이 무엇인지 간단지 체험해보려면 유닉스 운영체제에서 chroot
명령어를 사용해보면 된다. 사실 컨테이너 기술 자체가 chroot
에서 유래하였다고 볼 수 있다. chroot
를 자세히 설명하기엔 지면이 모자라므로, 잘 설명한 글을 첨부한다.
컨테이너 기초 - chroot를 사용한 프로세스의 루트 디렉터리 격리(44bits.io)
격리된 루트 디렉토리에는 런타임 환경(Runtime environment)을 넣는다. 런타임 환경이란 프로그램이 정상적으로 실행되기 위한 환경을 말한다. 넓은 의미에선 운영체제나 하드웨어도 포함되겠지만 여기선 대상을 좁히겠다. 앞서 소개한 Hello, World의 예에서는 libc 공유 라이브러리가 런타임 환경이다. 다른 예로는 자바 프로그램을 실행할 때 필요한 JRE가 있다. 보통 런타임 환경은 /bin
, /usr/local
등 모든 계정이 공유하는 공간에 설치된다. chroot
를 사용하면 이런 디렉토리에 접근이 불가능해지기 때문에 호스트의 런타임 환경을 사용할 수 없고 새로 만들어줘야 한다.
호스트와 구분된 런타임 환경이 있으면 개발이나 배포가 훨씬 쉬워진다. OpenJDK Java 1.17로 개발한 프로그램과, Oracle Java 1.8 버전으로 개발된 프로그램을 동시에 실행해야 한다고 가정하자. 컨테이너 없이 같은 호스트에서 실행하려면, 두 가지 자바를 모두 설치해두고 각자 알맞은 버전으로 빌드해야 한다. 그러나 컨테이너로 만든다면 각자 하나의 자바만 설치할 수 있다. 호스트의 환경은 다른 용도로 사용하다가 변해버리는 경우도 있지만, 컨테이너는 특정 상태를 스냅샷해서 이용하므로 애써 설정한 환경이 뒤바뀔 염려도 없다.
컨테이너는 격리되었다는 점을 제외하면 호스트의 다른 프로세스와 크게 다를 바 없다. 중간에 OS 하나가 더 낀 것이 아니다. ps
로 조회할 수도 있고 kill
로 종료할 수도 있다. 중간에 게스트 OS가 끼지 않고 호스트의 커널을 그대로 사용하므로, 가상머신보다 훨씬 실행 속도가 빠르고 관리하기도 편하다. 하지만 커널을 공유하는만큼 맥이나 윈도우 전용 프로그램을 리눅스에서 실행하는 등의 유연성은 없다. 벌써 질문이 들려오는 것 같아 예상 질문에 미리 답하도록 하겠다.
Q. 저는 윈도우용 도커로 리눅스 프로그램을 실행했는데요?
A. 리눅스를 제외한 OS에서 도커를 사용했다면, 당신이 인식하지 못한 사이에 가상머신을 사용한 것이다.
Q. 저는 Ubuntu에서 Centos 컨테이너를 실행했는데요?
A. 같은 리눅스라서 커널은 대동소이하기 때문에 가능하다. chroot로 격리된 공간 안에 centos에서 사용하는 파일들(yum 등)을 통째로 복사해넣으면 거의 차이를 못 느낀다.
가상머신에서는 게스트가 리소스를 정해진만큼만 사용하도록 강제할 수 있었다. 프로세스의 리소스 사용량을 제약하는 cgroup
이라는 기술을 사용하면 컨테이너의 리소스를 제약할 수 있다. cgroup에 대한 자세한 설명은 레드햇의 관련 문서를 링크하는 것으로 갈음하도록 하겠다. 프로그램이 의존하는 커널의 차이가 크지 않다면, 컨테이너는 가상머신의 장점을 대부분 취하면서도 훨씬 뛰어난 성능을 얻을 수 있는 가상화 방안이다.
앞서 가상메모리라는 말을 소개한 부분에서 알 수 있듯이, '가상화'라는 말은 굉장히 넓은 의미를 지니고 있어 하나의 뜻을 콕 집어 말하기가 어렵다. 특히 가상머신과 컨테이너는 잘 공부하지 않으면 정말 헷갈리는 개념이다. 그렇다보니 가상화, 가상머신 등의 개념을 파편화된 상태로 접하게 되면서 오개념을 가지게 되는 경우를 많이 보았고 한 번에 정리된 자료를 만들어야겠다는 생각을 하게 되었다. 나도 친구에게 도커를 "버추얼박스 같은 건데 훨씬 빠르대" 정도로 설명했던 적이 있다. 이 글로 인해 과거의 나 같은 사람이 한 명이라도 줄어든다면 이 글의 소임은 다한 것이다.
과제하는데 정말 도움이 많이 되었습니다. 감사합니다.