Ctrl + Shift + ESC

Go 언어로 나만의 컨테이너 환경 만들기 본문

Linux

Go 언어로 나만의 컨테이너 환경 만들기

단축키실행해보세요 2025. 11. 17. 23:40

* 해당 포스트는 실무 초밀착 리눅스: 클라우드 환경 운영부터 성능분석까지 강의를 듣고 정리한 것입니다.

 

몽총이 Go Gopher

 

Go 언어 설치

https://go.dev/doc/install

 

Download and install - The Go Programming Language

Documentation Download and install Download and install Download and install Go quickly with the steps described here. For other content on installing, you might be interested in: Download Go installation Select the tab for your computer's operating system

go.dev

해당 페이지에서 다운받고자 하는 Go언어 프로그램의 링크를 복사한다.

나의 경우 Linux에 wget 형식으로 설치할 예정이므로 go1.25.4.linux-amd64.tar.gz를 선택했다.

 

https://go.dev/dl/

 

All releases - The Go Programming Language

 

go.dev

해당 페이지에서 원하는 운영체제의 가이드를 따라 설치를 진행하면 된다.

 

wget https://go.dev/dl/go1.25.4.linux-amd64.tar.gz
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.25.4.linux-amd64.tar.gz

// 전체 경로가 아닌 현재 프로필 경로에만 추가
vi $HOME/.profile

// 마지막 줄에 추가
export PATH=$PATH:/usr/local/go/bin

source $HOME/.profile

 

 go version을 통해 설치 확인을 진행하면 설치는 끝난다.

 

Go 기본 문법 학습

Hello, World!

mkdir -p ~/projects/box 명령어로 실습 디렉토리를 생성 후 cd ~/projects/box 로 디렉토리에 들어간다.
go mod init example/box 명령어로 Go 모듈을 초기화한다.

projects/box/main.go 파일에 다음과 같이 작성한다.

package main

import "fmt"

func main() {
    fmt.Println("Hello World")
}

 

go run main.go 명령어로 main.go 파일을 실행하면 작성된 Hello World가 출력되는 것을 확인할 수 있다.

 

외부 패키지를 포함한 코드 실행

package main

import (
    "fmt"
    "rsc.io/quote"
)

func main() {
    fmt.Println(quote.Hello())
}

 

해당 코드를 실행하기 전에 go mod tidy 명령어로 패키지 관리(추가된 패키지 설치)를 해야 한다.

main.go 파일을 실행하면 작성된 quote 패키지에 작성된 Hello 문구가 출력되는 것을 확인할 수 있다.

 

동시성 프로그램 작성해보기

package main

import (
    "fmt"
    "time"
)

func readword(ch chan string) { // 사용자로부터 단어를 입력받아 채널로 전송
    fmt.Println("Type a word, then hit Enter.")
    var word string
    fmt.Scanf("%s", &word)
    ch <- word
}

func printchar() { // 무한 루프를 돌며 2초마다 점(.) 출력
    for {
        fmt.Printf(".")
        time.Sleep(2 * time.Second)
    }
}

func main() {
    defer fmt.Println("===== BYE..")
    go printchar()                   // goroutine(경량 스레드)으로 printchar 함수 실행

    ch := make(chan string)
    go readword(ch)         // goroutine으로 readword 함수 실행

    select { 
    case word := <-ch: // 받은 메시지를 기반으로 case 실행, 채널 ch로부터 단어 수신
        fmt.Println("\nReceived: ", word)
    }
}

 

 

  • defer: 코드를 지연해서 실행할 수 있는 예약어, main 함수가 종료되기 직전에 실행됨
  • make: 스레드 간 통신을 위한 채널 생성, string 타입 데이터를 주고받음
  • select: 여러 채널 연산 중에서 준비된 것 하나를 선택하여 실행, switch와 유사

Go 언어는 goroutine이라는 경량 스레드를 이용해 동시성과 관련된 코딩이 용이하다. 또한 goroutine 간 통신과 데이터 전달을 안전하게 처리하는 파이프로 channel을 사용한다.

위 코드는 2초에 한번씩 .이 찍히는 동안 readword로 단어를 수신받아 출력하고, defer로 실행된 print 문이 종료 직전에 실행된다.

 

Go의 주요 명령어는 다음과 같다.

  • go build : go 바이너리를 빌드
  • go run : 코드를 빌드하고 실행
  • go mod init : 새로운 모듈 생성
  • go mod tidy : 필요한 의존성 설치 및 필요 없는 의존성 삭제

 

나만의 컨테이너 환경 만들기

실습 시나리오

Go언어 기반으로 나만의 최소한의 컨테이너 환경을 구성하는 것을 목표로 실습을 진행한다.

Docker engine에는 CRI 런타임과 OCI 런타임이 있는데, 그 중 OCI 런타임인 runc의 일부 기능(독립된 환경에서 쉘 명령어 실행)을 구현한다.

 

요구사항으로는 호스트명(UTS namespace) 변경, 프로세스 ID(PID namespace) 변경, 프로세스 리스트 정보(Mount namespace) 변경이 주어지며, 다음과 같은 5단계로 실습을 진행한다.

 

1단계: "run" 명령어 전달 시 run 함수 실행

2단계: 새로운 프로세스에서 명령어 실행

3단계: 새로운 UTS 설정 추가 hostname 변경

4단계: 컨테이너 환경 시작 시 호스트명을 container로 변경

5단계: 컨테이너 환경에서 ps명령 실행 시 제한된 프로세스 정보만 조회. 루트 파일 시스템 변경

 

Step1: 명령어 종류에 따른 함수 실행

package main

import (
    "fmt"
    "os"
)

func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        os.Exit(1)
    }
}

func run() {
    fmt.Printf("Running: %v\n", os.Args[2:])
}

 

 

처음으로 들어온 인자 os.Args[1] 이 run 때 다음 인자들을 출력하고, 그 외에는 종료하는 코드이다.

 

Step2: 새로운 프로세스에서 명령어 실행

package main

import (
    "fmt"
    "os"
    "os/exec"
)

func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        os.Exit(1)
    }
}

func run() {
    fmt.Printf("Running: %v\n", os.Args[2:])
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stderr = os.Stderr
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout

    cmd.Run()
}

 

 

기존 코드와 동일하되, 이전에는 다음 인자들을 단순히 출력했다면 현재 코드는 새로운 프로세스 생성  입력된 명령어를 실행한다.

표준 에러, 입출력은 OS 것을 그대로 사용한다.

 

Step3: 새로운 UTS 설정 추가, hostname 변경

package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        os.Exit(1)
    }
}

func run() {
    fmt.Printf("Running: %v\n", os.Args[2:])
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stderr = os.Stderr
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout

    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS,
    }

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

 

 

프로세스 생성 UTS 네임스페이스 플래그를 넣어 새로운 UTS 네임스페이스를 생성한다.

프로세스가 기존 시스템의 hostname을 공유하지 않고, 새로운 격리된 hostname 공간을 갖는다는 의미이다. 또한 UTS 네임스페이스 플래그를 통해 새로 생성되는 프로세스가 새로운 UTS namespace 안에서 실행된다.

 

위의 실행 결과를 보면 명령어를 통해 bash 쉘이 실행되었고, 내부에서 hostname을 container로 변경하였으나 실행된 자식프로세스가 끝난 후 다시 hostname을 확인해보면 변경되지 않은 것을 확인할 수 있다.

이는 자식 프로세스가 격리된 hostname 공간을 갖는다는 것을 의미한다.

 

Step4: 컨테이너 환경 시작시 호스트명을 container 변경

package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        os.Exit(1)
    }
}

func run() {
    fmt.Printf("Running: %v\n", os.Args[2:])
    // cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...) // 자기 자신을 다시 실행 후 argument로 child와 기존 argument 전달
    cmd.Stderr = os.Stderr
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout

    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS,
    }

    must(cmd.Run())
}

func child() {
    fmt.Printf("Running child: %v\n", os.Args[2:])

    syscall.Sethostname([]byte("container"))

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

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

 

 

지난 코드와 달라진 점은 다음과 같다.

    • child 프로세스가 컨테이너 내부에서 실행됨
    • cmd := exec.Command에서 자기 자신을 다시 실행 argument child 기존 argument 전달함
    • child 프로세스는 hostname 설정 system call 호출하고 hostname을 container로 변경함

이 내용을 한 줄로 "run function 내에서 child 호출하고, hostname 변경하는 systemcapp 호출해 host name container 변경된 상태로 bash shell 실행한다" 라고 할 수 있다.

실행 결과에서도 child 프로세스가 실행된 후 이전에는 root 경로 뒤에 ip가 입력되어 있었지만, 수정된 코드를 통해 hostname이 container로 변경된 것을 확인할 수 있다.

 

Step5: 컨테이너 환경에서 ps 명령 실행 제한된 프로세스 정보만 조회

package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        os.Exit(1)
    }
}

func run() {
    fmt.Printf("Running: %v as %d\n", os.Args[2:], os.Getpid())
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...) // 자기 자신을 다시 실행하면서 "child" 인자 추가
    cmd.Stderr = os.Stderr
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout

    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS, // 새로운 UTS, PID, Mount 네임스페이스 생성
    }

    must(cmd.Run())
}

func child() {
    fmt.Printf("Running child: %v as %d\n", os.Args[2:], os.Getpid())

    syscall.Sethostname([]byte("container"))

    /* 루트 파일시스템 다운로드
    # https://github.com/tianon/docker-brew-ubuntu-core/raw/88ba31584652db8b96a29849ea2809d99ce3cc31/focal/ubuntu-focal-oci-amd64-root.tar.gz
    # mkdir /tmp/ubuntu
    # tar zxf ubuntu-focal-oci-amd64-root.tar.gz -C /tmp/ubuntu
    */

    // 루트 파일시스템 변경
    syscall.Chroot("/tmp/ubuntu")
    syscall.Chdir("/")
    syscall.Mount("proc", "proc", "proc", 0, "")
    defer syscall.Unmount("proc", 0)

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

    must(cmd.Run())

}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

 

 

Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,를 통해 새로운 UTS, PID, Mount 네임스페이스를 생성했다. 따라서 이론적으로는 부모 프로세스와 격리되어야 한다.

 

하지만 위의 실행 결과는 루트 파일시스템을 마운트하지 않았을 때의 경로이다.

PID Namespace를 새로 생성한 child 프로세스 안에 들어와 있지만 /proc은 여전히 호스트의 /proc을 바라보고 있다.

child 프로세스 안에서 ps 출력이 정상적으로 보이려면 /proc을 새로 마운트해야 한다.

 

 

/home/ubuntu 경로에 마운트할 루트 파일시스템을 wget으로 다운받는다. 그 뒤 /tmp/ubuntu 경로를 생성해 압축을 해제한다.

 

 

syscall.Chroot("/tmp/ubuntu")로 다운로드 받은 우분투를 루트 파일시스템으로 변경한다.

그 뒤 루트 디렉토리로 이동해 /proc 파일시스템을 마운트해 부모 프로세스와 자식 프로세스를 격리하고 함수 종료 /proc를 언마운트한다.

이번에는 ps 명령어로 현재 쉘에 포함된 프로세스만 출력되는 것을 확인하여 격리가 진행된 것을 확인할 수 있다.

 

Process 개수 제한을 통한 fork bomb 막기

https://ctrl-shit-esc.tistory.com/209 에서 실습했던 내용을 동일하게 진행한다.

 

cgroup으로 fork bomb 막아보기

* 해당 포스트는 실무 초밀착 리눅스: 클라우드 환경 운영부터 성능분석까지 강의를 듣고 정리한 것입니다.실습 시나리오포크 폭탄이란 프로세스가 지속적으로 자신을 복제함으로써 이용 가능

ctrl-shit-esc.tistory.com

 

package main

import (
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "strconv"
    "strings"
    "syscall"
)

func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        os.Exit(1)
    }
}

func run() {
    fmt.Printf("Running: %v as %d\n", os.Args[2:], os.Getpid())
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    cmd.Stderr = os.Stderr
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout

    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    }

    cmd.Run()
}

func child() {
    fmt.Printf("Running child: %v as %d\n", os.Args[2:], os.Getpid())

    cg() // cgroup 설정

    syscall.Sethostname([]byte("container"))

    syscall.Chroot("/tmp/ubuntu")
    syscall.Chdir("/")
    syscall.Mount("proc", "proc", "proc", 0, "")
    defer syscall.Unmount("proc", 0)

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

    must(cmd.Run())

}

func cg() {
    base := "/sys/fs/cgroup" // cgroup 마운트 위치
    //pids := filepath.Join(base, "pids")
    group := filepath.Join(base, "linux_campus") // cgroup 디렉토리 경로

    if err := os.Mkdir(group, 0755); err != nil && !os.IsExist(err) { // 디렉토리가 이미 있으면 패스
        panic(err)
    }

    //must(os.WriteFile(filepath.Join(base, "cgroup.subtree_control"), []byte("+pids"), 0644))
    subtree := filepath.Join(base, "cgroup.subtree_control") // cgroup v2 서브트리 컨트롤 파일 경로
    data, _ := os.ReadFile(subtree)
    if !strings.Contains(string(data), "pids") { // pids가 없으면 추가, 있으면 패스
        must(os.WriteFile(subtree, []byte("+pids"), 0644))
    }

    must(os.WriteFile(filepath.Join(group, "pids.max"), []byte("20"), 0644))
    //must(os.WriteFile(filepath.Join(group, "notify_on_release"), []byte("1"), 0644))
    must(os.WriteFile(filepath.Join(group, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0644))
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

 

강의 진행 시점에는 cgrou이 v1이지만, 현재 ubuntu 24.04 LTS는 cgroup v2를 사용한다. 이로 인해 cgroup 사용하여 컨테이너 process 갯수 제한을 하는 로직을 많이 수정했다.

 

cgroup 관련 코드만 상세히 비교해보면 다음과 같다.

func cg() {
	cgroups := "/sys/fs/cgroup/"
	pids := filepath.Join(cgroups, "pids")
	os.Mkdir(filepath.Join(pids, "linux_campus"), 0755)
	must(ioutil.WriteFile(filepath.Join(pids, "linux_campus/pids.max"), []byte("20"), 0700))
	must(ioutil.WriteFile(filepath.Join(pids, "linux_campus/notify_on_release"), []byte("1"), 0700))
	must(ioutil.WriteFile(filepath.Join(pids, "linux_campus/cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}

 

cgroup v1에서는 컨트롤러가 각각 독립된 디렉토리에 마운트된다. 따라서 pids cgroup을 만들려면 /sys/fs/cgroup/pids/linux_campus/ 경로(pids 하위 경로)에 리소스 제한 파일을 생성해야 한다.

 

또한 컨트롤러에 대해 별도로 설정할 필요가 없고 cgroup을 더 이상 사용하는 프로세스가 없을 때 자동으로 제거할 수 있도록 notify_on_release 플래그를 사용한다.

 

func cg() {
    base := "/sys/fs/cgroup"
    group := filepath.Join(base, "linux_campus")

    if err := os.Mkdir(group, 0755); err != nil && !os.IsExist(err) {
        panic(err)
    }

    subtree := filepath.Join(base, "cgroup.subtree_control")
    data, _ := os.ReadFile(subtree)
    if !strings.Contains(string(data), "pids") {
        must(os.WriteFile(subtree, []byte("+pids"), 0644))
    }

    must(os.WriteFile(filepath.Join(group, "pids.max"), []byte("20"), 0644))
    must(os.WriteFile(filepath.Join(group, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0644))
}

 

 

cgroup v2에서는 디렉토리가 단일 계층 구조이며, 컨트롤러별 서브디렉토리가 존재하지 않는다.

그리고 cgroup v2에서는 resource controller가 기본적으로 비활성화 상태이므로 컨트롤러를 활성화해야 사용이 가능하다.

또한 notify_on_release가 없다.

 

추가적으로 디렉토리나 pid가 이미 있으면 생성하는 과정을 지나치도록 예외 처리를 해두었다.

 

 

프로세스 실행 후 fork bomb을 실행했을 때, 여러 개의 프로세스들이 생성되기 시작한 것을 확인할 수 있다.

 

 

하지만 pid의 max 개수를 20개로 지정했기 때문에 재귀 함수로 증가하는 fork bomb에도 불구하고 20개의 프로세스만 생성되어 서버에 무리가 가지 않은 모습을 확인할 수 있었다.