컨테이너 가상화
2023년 4월 05일소프트웨어는 가상화의 세계에서 살고 있습니다. 에뮬레이터를 돌리고 가상 머신을 만들고 컨테이너를 띄우면서 여러 환경을 구축하고 프로그램을 돌립니다. 예전에는 Hypervisor(이하 하이퍼바이저)를 통해서 하나의 환경에서 여러 환경을 만들었습니다. 하지만 컨테이너 가상화 기술을 활용한 docker 프로그램이 나오면서, 컨테이너 가상화 기술은 인프라 구축에 없어서는 안 될 존재가 되었습니다. 컨테이너 가상화가 기존의 가상화 기술들을 밀어내고 가상화의 대세로 떠올랐습니다. 컨테이너 가상화가 어떤 기술이길래 이토록 열광하는 걸까요?
컨테이너 가상화를 알아보기 전에 가상화라는 개념에 대해 알아봅시다. 가상화(Virtualization)는 물리적인 형태가 아닌 가상 컴퓨터 환경을 만드는 기술(혹은 프로세스)입니다. 가상화를 사용해서 하나의 컴퓨터에서 여러 개의 컴퓨터 ‘환경’을 만들 수 있습니다. 가상화를 사용하는 이유 중 하나는 하드웨어 자원을 효율적으로 사용하기 위해서입니다.
❓ In computing, virtualization or virtualisation is the act of creating a virtual (rather than actual) version of something at the same abstraction level - Wikipedia
예를 들어 서비스를 운영하기 위해 Ubuntu, Fedora, Windows 환경이 필요하다고 생각해봅시다. 가상화가 없다면 이 환경들을 만들기 위해 3대의 컴퓨터가 필요합니다. 서로 다른 운영체제를 설치하기 위해 하드웨어 비용도 들고 시간도 들고 관리 포인트도 늘어납니다. 하지만 가상화를 이용한다면 한 컴퓨터에서 여러 환경을 만들 수 있어서 이러한 부담을 줄여줍니다.

가상화 종류에는 크게 하이퍼바이저의 Type1, Type2 방식과 컨테이너 가상화가 있습니다. 이번 시간에는 컨테이너 가상화에 대해 집중적으로 알아볼 겁니다. Type1과 Type2 가상화는 이전의 글을 참고하시면 좋을 것 같습니다.
컨테이너 가상화란 무엇일까요? 단어 그대로 생각해보면 컨테이너를 이용해서 가상화한다는 의미입니다. 다시 말하면 컨테이너를 이용해서 한 컴퓨터에 여러 환경을 만들 수 있는 기술이라고 보면 되겠습니다. 그러면 컨테이너는 무엇일까요? 컨테이너는 어떤 프로그램(어플리케이션)을 실행하기 위해 필요한 것(코드, 라이브러리)들을 모아둔 가상 환경입니다. 이 컨테이너는 프로세스 형태로 컨테이너 간 격리되며 자원 제한을 설정할 수 있습니다.
❓ A container is a standard unit of software that packages up code and all its dependencies so the application runs quickly and reliably from one computing environment to another - Docker
얼핏 보기에 기존의 가상화와 컨테이너 가상화는 차이가 없어 보입니다. 둘 다 한 컴퓨터에서 여러 환경을 띄우는 것이 목표이고 이것을 위해 하이퍼바이저를 이용하냐, 컨테이너를 이용하냐의 차이인 거 같은데 왜 컨테이너 가상화 방식이 하이퍼바이저보다 가볍고 빠르다고 하는 걸까요?
하이퍼바이저를 통한 가상화는 프로그램을 돌리기 위해 프로세스, 램, 스토리지, 네트워크 등의 하드웨어 자체를 가상화하여 새로운 환경을 만듭니다. 반면 컨테이너는 하드웨어를 가상화하지 않고 기존 하드웨어 그대로 써서 새로운 환경을 만듭니다. 또한 컨테이너는 프로그램을 실행하는데 필요한 코드와 의존성 도구만 있으면 되므로 용량이 가볍습니다. 정리하면 하이퍼바이저는 하드웨어 레벨의 가상화이고 컨테이너는 OS 레벨의 가상화라는 차이가 있습니다.
컨테이너는 runtime engine(이하 런타임 엔진) 위에서 관리됩니다. 런타임 엔진은 컨테이너와 하드웨어의 중간에 있으며, 서로 간의 통신을 하여 컨테이너가 필요한 연산을 할 수 있도록 합니다. 대표적인 컨테이너 런타임 엔진으로 containerd, runC, CRI-O가 있습니다. Docker Engine 또한 컨테이너 런타임 엔진이며 containerd와 runC를 포함하고 있습니다. 초기 Docker Engine에는 사용자의 편의성을 위해서 한 패키지에 컨테이너 관리 도구들을 모두 담았습니다. 이후에 Kubernetes(이하 k8s)와 같은 컨테이너 오케스트레이션 도구가 컨테이너 관리 도구로 docker를 채택하였는데, docker가 업데이트할 때마다 의존성 문제가 생기기 시작합니다. 이를 해결하기 위해 컨테이너 기술 표준인 OCI(Open Container Initiative) 만들고 OCI에 맞춘 프로그램들이 만들어졌습니다. containerd와 runC는 OCI를 충족하는 프로그램으로 Docker Engine은 하나의 패키지가 아닌 containerd와 runC로 나뉘어 컨테이너를 관리합니다. OCI 덕분에 컨테이너를 돌리기 위해 docker뿐만 아니라 OCI 사양에 맞는 cotainerd 같은 프로그램만 업데이트하거나 교체하여 컨테이너를 만들 수 있습니다. 비슷한 맥락으로 k8s는 1.20 버전 이후로 docker 사용 중단한다는 내용을 발표해서 의존성 문제를 해결하였습니다.
컨테이너 런타임 엔진은 고수준 컨테이너 런타임(High-level Container Runtime), 저수준 컨테이너 런타임(Low-level Container Runtime)으로 나뉘어집니다. containerd와 CRI-O와 같은 고수준 런타임은 다음과 같은 일을 합니다.
- 컨테이너 생명주기 관리
- 컨테이너 이미지 관리, 전송, 압축 풀기
- 컨테이너 네트워크 연결
- 컨테이너 모니터링 등등
containerd 같은 경우 초기에는 컨테이너 생명주기만 관리하였는데 컨테이너 이미지, 볼륨, 네트워크 관리 기능까지 추가되었습니다.
💡 containerd is available as a daemon for Linux and Windows. It manages the complete container lifecycle of its host system, from image transfer and storage to container execution and supervision to low-level storage to network attachments and beyond - containerd.io
runC와 같은 저수준 컨테이너 런타임은 컨테이너 생성과 같은 실질적인 컨테이너 동작 관리를 담당합니다.
- 컨테이너 생성, 시작, 중지, 삭제
💡 runc is a CLI tool for spawning and running containers on Linux according to the OCI specification - runC Github
저수준 컨테이너 런타임은 고수준 컨테이너 런타임에 의해 실행됩니다. docker에서 사용자가 명령어를 통해 컨테이너를 만든다고 생각해봅시다. docker 명령어는 API 통신하듯이 dockerd로 전해지게 됩니다. dockerd는 컨테이너 생성을 하기위해 내부적으로 gPRC 통신으로 containerd로 보냅니다. containerd는 runC가 컨테이너를 생성할 수 있도록 OCI 이미지를 shim으로 보냅니다. shim은 runC를 이용하여 컨테이너를 실행 시킵니다. 컨테이너 생성을 한 뒤 runC는 exit 하고 shim이 컨테이너의 부모 프로세스가 되면서 컨테이너 생성이 완료됩니다.

runC는 컨테이너 생성만 하고 바로 종료됩니다. 이렇게 되면 컨테이너는 실행 상태를 유지할 수 없게 되는데 이 때 필요한 것이 shim입니다. shim은 컨테이너 생성에 직접적인 관여는 하지 않지만 컨테이너 생성 후 종료되는 runC를 대신하여 컨테이너 상태를 관리하는 프로세스입니다. 컨테이너에서 일어나는 IO, exit code의 정보를 받고 containerd와 통신하여 생명주기 상태를 알립니다.
runC 컨테이너 런타임 엔진을 통해 컨테이너끼리 격리되고 자원 제한이 걸려있는 컨테이너가 생성이 됩니다. 격리된 환경을 만들기 위해 namespace
를 사용하고 자원 제한 환경을 만들기 위해 cgroups
리눅스 커널 기능을 사용합니다. 간단한 예시를 통해 namespace
를 사용하여 격리된 컨테이너 공간을 만들어봅시다.
코드를 실행한 환경과 참고한 코드 예제는 다음과 같습니다.
- Debian 11(arm64)
- Golang 1.20
- Code: https://youtu.be/HPuvDm8IC-4
아래와 같이 코드를 작성하면 명령어 인자로 보낸 문자들이 exec.Command()
함수를 통해 터미널에 실행되고 그 결과값을 반환합니다.
GOpackage main
import (
"fmt"
"os"
"os/exec"
)
func main() {
switch os.Args[1] {
case "run":
run()
default:
panic("what?")
}
}
func run() {
fmt.Printf("running %v\n", os.Args[2:])
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
must(cmd.Run())
}
func must(err error) {
if err != nil {
panic(err)
}
}
BASH$ go run main.go run echo Hello World Hello world
작성한 코드를 bash
인자를 넘겨서 실행하면(go run main.go run bash
) 새로운 쉘을 얻을 수 있습니다. 하지만 새롭게 띄운 쉘에 hostname을 변경하고 나오면 부모 hostname도 바뀌고 ps -ef
명령어를 통해 확인하면 PID 7을 공유하고 있습니다. 또한 PID 417의 CMD가 go run main.go run bash
인것을 보아 새로운 쉘은 격리된 공간이 아님을 알 수 있습니다.
BASH$ hostname && ps -ef original UID PID PPID C STIME TTY TIME CMD root 1 0 0 12:46 pts/0 00:00:00 bash root 7 0 0 12:46 pts/1 00:00:00 bash root 483 7 0 13:05 pts/1 00:00:00 ps -ef $ go run main.go run bash $ hostname new && hostname && ps -ef new UID PID PPID C STIME TTY TIME CMD root 1 0 0 12:46 pts/0 00:00:00 bash root 7 0 0 12:46 pts/1 00:00:00 bash root 417 7 9 13:05 pts/1 00:00:00 go run main.go run bash root 475 417 0 13:05 pts/1 00:00:00 /tmp/go-build2676865621/b001/exe/main run bash root 480 475 0 13:05 pts/1 00:00:00 bash root 481 480 0 13:05 pts/1 00:00:00 ps -ef $ exit $ hostname new
격리된 공간을 만들기 위해 syscall.SysProcAttr
의 구조체 내용에 새로운 UTS, PID, NS를 설정하겠다는 내용의 Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
속성을 설정합니다. 이때 주의할 점은 Cloneflags
속성은 리눅스 환경에서만 적용 할 수 있습니다.
- UTS: Unix Time Sharing
- PID: ProcessID
- NS: Namespace
격리된 볼륨 공간을 위해 /home/rootfs
디렉터리를 만들어주시고, 안에 /bin
/lib
/usr
/proc
디렉터리를 복사해주세요.
BASHmkdir /home/rootfs cp -a /bin /home/rootfs cp -a /lib /home/rootfs cp -a /usr /home/rootfs cp -a /proc /home/rootfs
GOpackage main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
switch os.Args[1] {
case "run":
run()
case "child":
child()
default:
panic("what?")
}
}
func run() {
fmt.Printf("running %v\n", os.Args[2:])
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
}
must(cmd.Run())
}
func child() {
fmt.Printf("running %v as pid %d\n", os.Args[2:], os.Getpid())
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
must(syscall.Chroot("/home/rootfs"))
must(os.Chdir("/"))
must(syscall.Mount("proc", "proc", "proc", 0, ""))
must(cmd.Run())
}
func must(err error) {
if err != nil {
panic(err)
}
}
BASH$ hostname && ps --ef original UID PID PPID C STIME TTY TIME CMD root 1 0 0 12:46 pts/0 00:00:00 bash root 7 0 0 12:46 pts/1 00:00:00 bash root 143 7 0 13:06 pts/1 00:00:00 ps -ef $ go run main.go run bash $ hostname new && hostname && ps -ef new UID PID PPID C STIME TTY TIME CMD root 1 0 0 13:06 ? 00:00:00 /proc/self/exe child bash root 6 1 0 13:06 ? 00:00:00 bash root 9 6 1 13:06 ? 00:00:00 ps -ef $ exit $ hostname original
새로운 쉘에서 hostname을 변경해도 hostname이 공유되지 않고, 프로세스 목록을 참고해도 공유하고 있는 프로세스가 없는것을 보니 격리된 공간을 알 수 있습니다. 추가로 볼륨 공간 또한 격리되어있으니 한 번 확인해보면 좋을 것 같습니다.
오늘날 인프라에서 컨테이너가 차지하는 중요도는 높습니다. 저는 모든 서비스를 dockerize하는 병에 걸리고 말았습니다. 컨테이너를 많이 쓰고 있지만, 컨테이너 내부가 어떻게 구성되어있고 돌아가는지 모르고 썼습니다. 이번 글을 정리하면서 가상화와 컨테이너 가상화에 대해 더욱 알 수 있는 시간이 되었습니다. 컨테이너 가상화를 넘어서 다음에는 어떤 가상화가 대세가 될지 기대가 됩니다.
참고자료