컨테이너 기술의 핵심인 docker run과 유사한 기능을 Go 언어로 직접 구현해 봅니다. Linux Namespace를 활용하여 프로세스를 격리하고, 자체적인 init 프로세스를 통해 컨테이너 환경을 초기화하여 사용자 지정 프로세스를 PID 1로 실행하는 과정을 다룹니다.
CLI 프레임워크 구성
커맨드 라인 인터페이스를 구축하기 위해 urfave/cli 라이브러리를 사용합니다. 이 라이브러리는 경량이며 직관적인 API를 제공하여 간단한 CLI 도구를 빠르게 구현하는 데 적합합니다.
프로젝트 구조 및 엔트리포인트
프로젝트는 크게 진입점, 명령어 정의, 그리고 컨테이너 프로세스 관리 로직으로 나뉩니다.
.
├── main.go
├── commands.go
├── container.go
└── init_process.go
main.go는 애플리케이션의 진입점이며, 로거를 초기화하고 정의된 명령어들을 등록합니다.
package main
import (
"os"
log "github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
func main() {
app := cli.NewApp()
app.Name = "mydocker"
app.Usage = "A minimal container runtime built from scratch"
app.Commands = []cli.Command{
initCmd,
runCmd,
}
app.Before = func(ctx *cli.Context) error {
log.SetFormatter(&log.JSONFormatter{})
log.SetOutput(os.Stdout)
return nil
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
명령어 정의: run과 init
commands.go에서는 호스트에서 실행될 run 명령어와 격리된 환경 내부에서 실행될 init 명령어를 정의합니다.
package main
import (
"fmt"
"github.com/urfave/cli"
)
var runCmd = cli.Command{
Name: "run",
Usage: "Create and start a container",
Flags: []cli.Flag{
cli.BoolFlag{Name: "it", Usage: "Allocate a pseudo-TTY and keep stdin open"},
},
Action: func(ctx *cli.Context) error {
if ctx.NArg() < 1 {
return fmt.Errorf("container command is required")
}
targetCmd := ctx.Args().First()
allocateTty := ctx.Bool("it")
startContainer(allocateTty, targetCmd)
return nil
},
}
var initCmd = cli.Command{
Name: "init",
Usage: "Internal command to initialize container environment",
Action: func(ctx *cli.Context) error {
targetCmd := ctx.Args().First()
return initializeContainer(targetCmd)
},
}
부모 프로세스 생성 및 Namespace 적용
run 명령어가 실행되면 startContainer 함수를 통해 새로운 프로세스를 생성합니다. 여기서 핵심은 /proc/self/exe를 호출하여 현재 실행 파일(즉, mydocker 자체)을 다시 실행하되, 인자로 init을 전달하는 것입니다. 또한 SysProcAttr를 통해 Linux Namespace 플래그를 설정하여 프로세스를 격리합니다.
package main
import (
"os"
"os/exec"
"syscall"
log "github.com/sirupsen/logrus"
)
func startContainer(allocateTty bool, userCmd string) {
cmdArgs := []string{"init", userCmd}
execCmd := exec.Command("/proc/self/exe", cmdArgs...)
execCmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS | syscall.CLONE_NEWNET |
syscall.CLONE_NEWIPC,
}
if allocateTty {
execCmd.Stdin = os.Stdin
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
}
if err := execCmd.Start(); err != nil {
log.Fatalf("Failed to start container process: %v", err)
}
execCmd.Wait()
}
Cloneflags에 지정된 옵션들은 각각 UTS(호스트명), PID(프로세스 ID), Mount(파일 시스템), Network(네트워크), IPC(프로세스 간 통신)에 대한 격리를 의미합니다. TTY 옵션이 활성화되면 표준 입출력을 호스트의 터미널에 연결하여 상호작용이 가능해집니다.
컨테이너 초기화 및 execve 시스템 호출
새롭게 포크된 자식 프로세스는 init 명령어를 실행하게 되며, 이는 initializeContainer 함수로 연결됩니다. 이 함수는 컨테이너 내부의 첫 번째 프로세스(PID 1)로서 환경을 설정하고 최종적으로 사용자가 지정한 명령어로 프로세스를 대체합니다.
package main
import (
"fmt"
"os"
"syscall"
log "github.com/sirupsen/logrus"
)
func initializeContainer(userCmd string) error {
log.Infof("Initializing container with command: %s", userCmd)
// 마운트 전파(Mount propagation) 문제 해결을 위해 Private 설정
if err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""); err != nil {
log.Warnf("Failed to set mount private: %v", err)
}
// proc 파일 시스템 마운트
mountFlags := uintptr(syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV)
if err := syscall.Mount("proc", "/proc", "proc", mountFlags, ""); err != nil {
return fmt.Errorf("failed to mount proc: %v", err)
}
// 현재 프로세스를 사용자 지정 명령어로 대체
argv := []string{userCmd}
if err := syscall.Exec(userCmd, argv, os.Environ()); err != nil {
return fmt.Errorf("failed to exec command: %v", err)
}
return nil
}
execve와 PID 1 할당
컨테이너가 생성된 직후 실행되는 첫 번째 프로세스는 mydocker init입니다. 하지만 사용자가 컨테이너 내부에서 ps 명령어를 실행했을 때 PID 1이 mydocker가 아닌 자신이 실행한 명령어(예: /bin/sh)로 보여야 합니다.
이를 위해 syscall.Exec (내부적으로 execve 시스템 호출)을 사용합니다. execve는 현재 프로세스의 메모리 이미지, 데이터, 스택을 새로운 프로그램으로 완전히 덮어씁니다. PID는 유지된 채 프로세스 내용만 교체되므로, 결과적으로 사용자가 지정한 프로세스가 컨테이너의 PID 1이 됩니다.
/proc 파일 시스템 격리
Linux의 /proc 디렉토리는 커널과 실행 중인 프로세스들의 정보를 담고 있는 가상 파일 시스템입니다. 호스트의 /proc을 그대로 사용하면 컨테이너 내부에서 호스트의 모든 프로세스가 visibility하게 노출됩니다. 따라서 새로운 Mount Namespace 내에서 syscall.Mount를 통해 /proc을 다시 마운트하면, 현재 Namespace에 속한 프로세스 정보만 보이도록 격리할 수 있습니다.
실행 및 검증
코드를 컴파일하고 실행하여 컨테이너 내부의 프로세스 상태를 확인해 봅니다.
$ go build -o mydocker .
$ sudo ./mydocker run -it /bin/sh
{"level":"info","msg":"Initializing container with command: /bin/sh","time":"..."}
# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 09:47 pts/1 00:00:00 /bin/sh
root 5 1 0 09:47 pts/1 00:00:00 ps -ef
출력 결과를 보면 /bin/sh가 PID 1을 할당받아 실행되고 있으며, ps 명령어 역시 해당 셸의 자식 프로세스로 정상적으로 동작함을 알 수 있습니다. 이는 실제 Docker 컨테이너의 동작 방식과 동일합니다.
트러블슈팅: Mount Namespace 전파 문제
구현 과정에서 mydocker를 연속으로 실행할 때 다음과 같은 오류가 발생할 수 있습니다.
fork/exec /proc/self/exe: no such file or directory
원인 분석
Systemd가 도입된 최신 Linux 환경에서는 기본적으로 Mount Namespace가 shared 상태로 설정됩니다. 이로 인해 컨테이너 내부(자식 Namespace)에서 /proc을 마운트한 이벤트가 호스트(부모 Namespace)로 전파(propagation)됩니다. 컨테이너 프로세스가 종료되어 Namespace가 소멸하면, 호스트의 /proc 마운트 정보도 손상되어 subsequent 실행 시 /proc/self/exe를 찾지 못하게 됩니다.
해결 방안
마운트 이벤트를 격리하기 위해 /proc을 마운트하기 전에 루트 파일 시스템의 마운트 전파 타입을 private로 명시적으로 변경해야 합니다. initializeContainer 함수의 시작 부분에 다음 코드를 추가하여 이 문제를 해결할 수 있습니다.
// 마운트 전파(Mount propagation) 문제 해결을 위해 Private 설정
if err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""); err != nil {
log.Warnf("Failed to set mount private: %v", err)
}
MS_PRIVATE와 MS_REC 플래그를 사용함으로써 현재 Namespace 내부의 마운트 변경 사항이 외부 호스트로 전파되는 것을 차단하여 안정적인 컨테이너 실행 환경을 보장합니다.