Go로 "컨테이너스러운" 프로세스를 만들어보자.
순조롭게 PID, UTS, Mount, Network 네임스페이스를 격리한 여러분이 자랑스럽다. 이번 챕터는 직접 만든 containerM 프로젝트를 확장 가능한 구조로 리팩토링하면서 한숨 돌리고 가려고 준비했다. main.go의 코드를 모듈화하여 분리하고 필요에 따라 Linux, go의 개념도 정리해보자.
✅ 목표
지금까지는 기능 구현에 집중했기 때문에 하나의 main.go 파일에 모든 로직이 들어있었다. 하지만 이제 1. cgroup, 이미지 시스템, 런타임 관리 등이 추가될 예정이고, 2. 스크립트가 늘어나고, 컴포넌트 간 의존성도 커져서 더 이상 모놀리식 구조로는 유지보수가 힘들어질 것이다.
따라서 컴포넌트 단위로 코드를 구조화하고, 기능별로 디렉토리를 정리해 볼 것이다.
📁 새로운 디렉터리 구조
containerM/
├── main.go
├── go.mod
├── cmd/
│ └── run.go // run, child 명령 처리
├── namespace/
│ ├── uts.go
│ ├── pid.go
│ ├── mount.go
│ └── net.go
├── fs/
│ └── pivot.go // pivot_root, 마운트 처리
├── container/
│ └── process.go // 컨테이너 프로세스 실행
├── scripts/
│ ├── cleanup_host_net.sh // 호스트 측 veth/bridge 초기화
│ ├── host_setup.sh // 호스트 측 veth/bridge 설정
│ └── container_setup.sh // 컨테이너 측 eth0 설정
├── utils/
│ └── must.go // 에러 핸들러
└── README.md
1. main.go와 cmd/run.go
package main
import (
"containerM/cmd"
)
func main() {
//todo print containerM introduction
cmd.Execute()
}
main.go에 실질적인 실행 코드는 이제 존재하지 않는다. 추후 containerM에 대한 소개용 터미널 출력 기능 등 오로지 정적으로 필요한 기능이 추가될 것이다. main.go에서는 cmd.Excute()를 실행하여 본격적인 컨테이너 실행을 시작한다.
package cmd
import (
"fmt"
"os"
"containerM/container"
)
func Execute() {
if len(os.Args) < 2 {
fmt.Println("Usage: containerM run <command>")
os.Exit(1)
}
switch os.Args[1] {
case "run":
container.Run(os.Args[2:])
case "child":
container.Child(os.Args[2:])
default:
panic("unknown command")
}
}
cmd/run.go 는 컨테이너의 진입점을 담당한다. 이 컴포넌트에서 명령을 라우팅하는 기능을 추가할 것이다.
2. utils/must.go
package utils
import (
"fmt"
"os"
)
func Must(err error, context ...string) {
if err != nil {
if len(context) > 0 {
fmt.Fprintf(os.Stderr, "Error (%s): %v\n", context[0], err)
} else {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(1)
}
}
디버깅과 에러 처리를 위해서 must라는 유틸함수를 만들었었다.
3. container/process.go
package container
import (
"containerM/fs"
"containerM/namespace"
"containerM/utils"
"fmt"
"os"
"os/exec"
"syscall"
)
func Run(command []string) {
fmt.Printf("Parent: Running %v as PID %d\n", command, os.Getpid())
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, command...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// isolation namespace
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET,
}
// setting Environment variable
cmd.Env = append(os.Environ(), "SETUP_NET=1")
utils.Must(cmd.Start())
namespace.SetupHostNet(cmd)
// wait for child process to finish
utils.Must(cmd.Wait())
defer namespace.CleanupHostNet(cmd)
}
func Child(command []string) {
fmt.Printf("Running child process PID %d\n", syscall.Getpid())
// set hostname
namespace.SetHostname("containerM")
// set file system pivot root
fs.SetupPivotRoot("/tmp/ubuntufs")
// mount & cleanup /dev, /proc, /sys
namespace.MountDev()
namespace.MountProc()
namespace.MountSys()
defer namespace.UnmountDev()
defer namespace.UnmountProc()
defer namespace.UnmountSys()
// network configuration
if os.Getenv("SETUP_NET") == "1" {
pid := fmt.Sprintf("%d", syscall.Getpid())
cmd := exec.Command("/setup_container_net.sh", pid)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
utils.Must(cmd.Run(), "run setup_net.sh")
}
cmd := exec.Command(command[0], command[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
utils.Must(cmd.Run())
}
process.go 에서 컨테이너의 격리 기능이 전부 모여있다. 그동안 학습했던 내용을 돌아보며 다시 정리해보자.
⚙️ 왜 네임스페이스가 중요한가?
네임스페이스 | 분리 대상 | 설명 |
PID | 프로세스 트리 | 컨테이너 안에서 PID 1부터 시작 |
UTS | hostname/domainname | 호스트 이름 분리 |
Mount | 파일 시스템 | 루트 디렉터리 및 mount 독립성 |
Network | 인터페이스, 라우팅 | 고유한 IP, eth0 등 구성 |
이 네임스페이스들을 새롭게 조합하면 “작은 리눅스 시스템”을 만드는 게 가능해지는 것이다.
물론 이뿐만 아니라 실제 도커는 User, IPC, Cgroup, Time, Sysctl 네임스페이스 등 더 강력한 격리를 구현하고 있지만 현재 간단한 형태의 격리 컨테이너에는 이 정도만 해도 충분하다.
⚙️ exec와 fork(Go로 활용하기)
exec.Command()는 새 프로세스를 포크하고, SysProcAttr로 네임스페이스를 지정할 수 있다. 실제로 cmd.Run()은 내부적으로 fork + execve() 호출로 작동하게 된다. /proc/self/exe를 이용하면 현재 실행 중인 Go 바이너리 자체를 자식 프로세스로 재귀 호출할 수 있다.
⚙️ defer(in Go)
defer는 Go에서 함수가 끝날 때(리턴 직전) 자동으로 실행되는 지연 호출을 위한 용도로 사용된다. 주로 자원 해제, 파일 닫기, 락 해제, 정리 작업에 사용된다. 이에 아래와 같은 특징을 가지고 있으니 기억하면 좋을 것 같다.
- 여러 개의 defer는 스택 구조 (나중에 쓴 게 먼저 실행)
- defer는 반드시 현재 함수 스코프에서만 유효
4. fs/pivot.go
package fs
import (
"containerM/utils"
"os"
"os/exec"
"syscall"
)
func SetupPivotRoot(newRoot string) {
putOld := newRoot + "/.pivot_root"
// Mount point isolation
utils.Must(syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""), "make mount private")
// bind mount new root to itself (required by pivot_root)
utils.Must(syscall.Mount(newRoot, newRoot, "", syscall.MS_BIND|syscall.MS_REC, ""), "bind mount newRoot")
// make .pivot_root directory
utils.Must(os.MkdirAll(putOld, 0700), "create putOld")
// copy script file
utils.Must(exec.Command("cp", "./scripts/setup_container_net.sh", newRoot+"/setup_container_net.sh").Run(), "copy setup_net.sh")
// pivot_root(new_root, put_old)
utils.Must(syscall.PivotRoot(newRoot, putOld), "pivot_root")
// change working directory to new root
utils.Must(os.Chdir("/"), "chdir to /")
// unmount old root
utils.Must(syscall.Unmount("/.pivot_root", syscall.MNT_DETACH), "unmount old root")
utils.Must(os.RemoveAll("/.pivot_root"), "remove old root")
}
pivot_root(newRoot, putOld)는 반드시 newRoot가 마운트 포인트여야 하고 putOld가 newRoot의 하위 디렉터리여야 한다. 따라서 bind mount로 루트 디렉터리를 자기 자신에 mount하고 .pivot_root라는 하위 디렉터리를 만든 다음 루트 교체를 수행한다.
5. namespace/mount.go
package namespace
import (
"containerM/utils"
"fmt"
"syscall"
)
func MountProc() {
utils.Must(syscall.Mount("proc", "/proc", "proc", 0, ""), "mount /proc")
}
func MountSys() {
utils.Must(syscall.Mount("sysfs", "/sys", "sysfs", 0, ""), "mount /sys")
}
func MountDev() {
utils.Must(syscall.Mount("tmpfs", "/dev", "tmpfs", 0, ""), "mount /dev")
// 디바이스 생성 함수
createDev := func(path string, mode uint32, major, minor int) {
dev := (major << 8) | minor
err := syscall.Mknod(path, mode, dev)
if err != nil {
fmt.Printf("Failed to create %s: %v\n", path, err)
}
syscall.Chmod(path, 0666)
}
createDev("/dev/null", syscall.S_IFCHR|0666, 1, 3)
createDev("/dev/zero", syscall.S_IFCHR|0666, 1, 5)
createDev("/dev/random", syscall.S_IFCHR|0666, 1, 8)
createDev("/dev/urandom", syscall.S_IFCHR|0666, 1, 9)
createDev("/dev/tty", syscall.S_IFCHR|0666, 5, 0)
createDev("/dev/console", syscall.S_IFCHR|0600, 5, 1)
}
func UnmountProc() {
utils.Must(syscall.Unmount("/proc", 0))
}
func UnmountSys() {
utils.Must(syscall.Unmount("/sys", 0))
}
func UnmountDev() {
utils.Must(syscall.Unmount("/dev", 0))
}
앞서 다룬 5, 6장에서 /dev 경로를 마운트하며 /dev 디렉토리의 역할에 대해서 설명했었다. 이를 더 자세히 알아보자.
⚙️ /dev 디렉토리의 역할(in linux)
/dev는 장치 파일(device file)들이 위치한 디렉토리이다. 여기서 장치란, 사용자가 파일처럼 접근할 수 있는 하드웨어 또는 커널 인터페이스들을 의미한다. 예를 들어, /dev/random 디바이스는 "랜덤 바이트 읽기", echo hello > /dev/null 명령어에서 /dev/null은 출력을 버려주는 디바이스이다.
이런 디바이스들을 넣어놓을 /dev 디렉토리를 왜 tmpfs(RAM에 위치한 가상의 파일 시스템. 빠르고 휘발성)에 마운트할까?
컨테이너 안의 /dev는 호스트와 격리되어야 하며 보안상 안전해야 한다. 따라서 RAM에 위치한 빈 메모리 공간인 tmpfs를 마운트하고 필요한 장치 파일만 수동으로 만들어주면 보안상 안전하고 컨테이너 크기도 줄어들게 된다.
⚙️ 주요 디바이스 파일(in linux)
디바이스 파일 | 메이저:마이너 | 역할 및 중요성 |
/dev/null | 1:3 | 출력이 버려짐 (Black Hole). 로그 무시할 때 사용. |
/dev/zero | 1:5 | 무한한 0 바이트 제공. 메모리 초기화 등에 사용. |
/dev/random | 1:8 | 느리지만 암호학적으로 안전한 랜덤 바이트 |
/dev/urandom | 1:9 | 빠르지만 약간 덜 안전한 랜덤 바이트. 대부분 사용 |
/dev/tty | 5:0 | 현재 연결된 터미널과의 인터페이스 |
/dev/console | 5:1 | 시스템 메시지나 커널 메시지의 대상. 부팅 시 중요 |
디바이스는 리눅스 커널 내부에서 고유한 번호(장치 ID)로 관리된다. 이 ID는 아래 두 숫자로 나뉜다.
- 메이저 번호 (major number)
→ 어떤 드라이버(device driver)가 요청을 처리할지 알려줌. - 마이너 번호 (minor number)
→ 그 드라이버 아래의 특정 장치를 식별함. (예: 여러 개의 tty)
예시 : /dev/null
dev := (major << 8) | minor
syscall.Mknod("/dev/null", syscall.S_IFCHR|0666, dev)
1:3 → 1번 메이저 드라이버의 3번 장치
6. namespace/net.go & pid.go & uts.go
package namespace
import (
"containerM/utils"
"fmt"
"os/exec"
)
func SetupHostNet(cmd *exec.Cmd) {
// network setup: run host-side network setup script with child PID
utils.Must(exec.Command("bash", "./scripts/setup_host_net.sh", fmt.Sprintf("%d", cmd.Process.Pid)).Run(), "setup host network configure")
}
func CleanupHostNet(cmd *exec.Cmd) {
utils.Must(exec.Command("bash", "./scripts/cleanup_host_net.sh", fmt.Sprintf("%d", cmd.Process.Pid)).Run(), "cleanup host network configure")
}
package namespace
// PID namespace is already used as CLONE_NEWPID.
// If additional logic is needed, write it here.
package namespace
import (
"containerM/utils"
"syscall"
)
func SetHostname(name string) {
utils.Must(syscall.Sethostname([]byte(name)), "Set hostname")
}
🔜 다음 이야기
다음 파트에서는 진짜 리눅스 컨테이너처럼 CPU, 메모리 등 리소스 제한을 구현해보자. cgroups를 통해 컨테이너가 시스템을 독점하지 못하도록 제어하는 법을 공부할 것이다.
Release v1.5.0 · Yg-Hong/containerM
Refactor code
github.com
'개발일기 > Docker' 카테고리의 다른 글
6. Network 네임스페이스로 독립 네트워크 구성하기 (4) | 2025.06.26 |
---|---|
5. pivot_root로 루트 파일 시스템 바꾸기 (0) | 2025.06.16 |
4. Mount 네임스페이스로 파일시스템 분리하기 (0) | 2025.05.30 |
3. UTS 네임스페이스로 호스트명 분리하기 (0) | 2025.05.29 |
2. PID 네임스페이스로 프로세스 격리하기 (심화) (1) | 2025.05.29 |
댓글