개발일기/Docker

2. PID 네임스페이스로 프로세스 격리하기 (심화)

ignuy 2025. 5. 29.

Go로 "컨테이너스러운" 프로세스를 만들어보자.

 

지난 포스팅에서 mydocker run bash 명령으로 bash를 PID 1번으로 실행할 수 있었다. 이번 편에서는 그 구조가 왜 그렇게 동작하는지, 그리고 PID 네임스페이스의 격리 효과가 정확히 무엇인지 코드와 몇 가지 실험으로 구조를 파악해보자.

 

PID 네임스페이스

PID 네임스페이스는 리눅스 커널의 네임스페이스 중 하나로, 각 프로세스가 고유한 PID 트리를 갖도록 격리시킨다. 이때 아래와 같은 특징을 볼 수 있다.

 

  • 부모 네임스페이스에서 보면 자식 네임스페이스의 PID가 일반적인 번호 (예: 12345)
  • 자식 네임스페이스 안에서는 해당 프로세스가 PID 1부터 시작
  • 이 PID 1 프로세스는 네임스페이스의 init 프로세스로, 모든 자식의 조상

🔍 왜 자식 프로세스가 PID 1이어야 할까?

리눅스에서 [PID 1]이 가진 의미는 특별하다. PID 1은 좀비 프로세스를 수거하는 책임을 가진 특별한 프로세스이다. 컨테이너처럼 격리된 환경에서 PID 1이 존재하지 않으면 프로세스가 수거되지 않아 문제가 생길 수 있다. 따라서 도커도 항상 init 역할을 하는 프로세스를 먼저 실행하고, 그 아래에서 명령어를 실행하는 구조를 가지고 있다.

containerM run bash
    └─> exec.Command("/proc/self/exe", "child", "bash")
        └─> exec.Command("bash")

🤔 fork와 exec

"containerM run"이 자식 프로세스(=container init)를 만들고 PID 1이 되며 자식은 bash를 실행한다. 전 포스팅에서 사용했던 run() 메서드와 child() 메서드가 바로 리눅스의 전통적인 시스템 콜인 fork() -> exec() 구조를 go API를 통해서 간접적으로 흉내 내고 있는 구조인 것이다.

🟨 전통적인 UNIX 방식

pid_t pid = fork();
if (pid == 0) {
    // child
    execve("/bin/bash", ...);
} else {
    // parent
    waitpid(pid, ...);
}

전통적인 UNIX 방식에서는 fork()로 부모 프로세스로부터 자식 프로세스를 만든다. 자식은 exec()로 다른 프로그램으로 자신을 덮어쓴다.

✅ Go에서의 run()과 child() 역할

func main() {
  if os.Args[1] == "run" {
    run()     // 여기서 "child"를 실행하는 자식 프로세스 생성 (fork와 유사)
  } else if os.Args[1] == "child" {
    child()   // 여기서 진짜 실행할 명령어를 exec (exec와 유사)
  }
}

Go에서는 run()을 통해 새로운 프로세스를 생성한다. 이때, run() 내부에서 cmd,SysProcAtr.Cloneflags로 새로운 네임스페이스를 생성하게 된다. 즉 run() 메서드는 fork + clone + 전달 파라미터 포함하는 exec 준비 역할을 수행한다. 

child()는 내부에서 다시 exec.Command(os.Args[2])로 진짜 명령어 실행하게 된다. exec의 대체 역할을 맡게 되는 것이다.

 

전통적인 fork()/exec() 모델을 Go에서는 2단계 명령어(run, child)로 모방하고 있는 셈이다. 의미적으로는 매핑되지만, 내부적으로 완전히 OS가 제공하는 fork()나 exec() 시스템 콜을 직접 사용하는 1대1 대응이 아님에 유의하자.

 

코드를 다시 자세히 확인하며 따라가 보자.

📁 코드 분석

run() 함수 

func run() {
	...
    
	cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWPID,
	}
    
	cmd.Run() // fork + exec("/proc/self/exe" ...) ← 이 시점에 네임스페이스 분리
}

위에서 Go 코드에서는 syscall.SysProcAttr.Cloneflags 로 네임스페이스를 지정한다고 언급했다. cmd.SysProcAttr에 새로운 값을 할당할 때 fork + clone(CLONE_NEWPID | ...)를 실행하는 것이고, 네임스페이스는 이 순간 "분리" 된다.

✅child() 함수

func child() {
	...

	cmd := exec.Command(os.Args[2], os.Args[3:]...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	cmd.Run() // exec("bash") ← 진짜 컨테이너처럼 동작 시작
}

하지만, 네임스페이스가 분리된 자식 프로세스는 여전히 Go 런타임 환경을 가지고 있다. 이제 exec() 시스템 콜을 통해 원하는 명령어(Ex. bash)를 실행시킨다. 이제부터 이 프로그램은 분리된 네임스페이스 안에서 동작하게 된다.

🔧 fork와 exec 없이 가능할까?

환경에 대한 격리는 fork와 exec 없이는 불가능하다. 네임스페이스는 프로세스 단위로 작동하기 때문에, 반드시 fork() 또는 clone() 시스템 콜을 통해 새로운 프로세스를 만들어야 한다. 그 안에서 원하는 프로그램을 exec()로 실행해야 완전히 분리된다.

🧠 결론

키워드 설명
fork / clone 네임스페이스를 적용한 새 프로세스를 생성
exec 네임스페이스가 적용된 컨테이너 내부의 진짜 실행 환경을 구성
Go에서의 의미 exec.Cmd 를 사용할 때 내부적으로 fork → exec 발생

🔜 다음 이야기

다음 편에서는 UTS 네임스페이스를 통해 컨테이너 안에서 호스트 이름을 변경하는 실험을 해보겠다.

댓글