來實作一個container吧

延續上篇的動手用Golang實作一個container - 概念篇,了解container的底層技術是如何實踐之後,我們就可以開始使用Golang來做出屬於我們自己的container了。

以Docker為例,當我們要啟動一個container的時候,會使用這個指令:

docker container run <image-name> [cmd] 

以此為發想點,當我想要使用我的程式碼啟動container時,他長得會像這樣子:

go run main.go run [cmd]

在這裡,我們會分別定義兩種function:

  • run() : parent process要執行的function。負責創建child process及並配置其運行的環境(如namespace)。
  • child() : child process要執行的function。負責管理在container環境中,要如何運行用戶端所指定的命令。

must()function則會作為error handler使用。

 func main() {
    switch os.Args[1] {
        case "run":
            run()
        case "child":
            child();
        default:
            panic("what??")
    }
 }

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

這邊還蠻想提一下os.Args這個指令。os.Args 是Golang裡面用來儲存命令行參數的一個變數。如果我們只單印出os.Args,他會長的像這樣:

sophie@Sophie-Desktop:~/go-container$ go run test.go run echo 'hi'
[/tmp/go-build3547203082/b001/exe/test run echo hi]

第一行是我執行Go程式碼的指令,第二行是os.Args印出來的結果。到這裡會發現為什麼go run test.go 不但沒有被儲存在os.Args中,反而還多了一串類似像檔案路徑的東西?

這是因為當我們使用go run來運行程式時,他會先編譯我們指定的程式碼檔案,創建一個臨時的執行檔,它實際運行的其實是這個臨時創建出來的執行檔。

因此,這也能說明為什麼go run test.go直接被這個臨時執行檔的路徑給取代了。基於這個架構,我們使用os.Args[1]的元素就能判斷是否為執行container的命令行。


Parent Process

run()這個function定義了parent process的行為。在這裡,我們會需要做到以下這幾件事:

  • 創建一個child process - 這個child process將會是這個container裡面的PID 1 process。
  • 使用這個child process執行命令行中,run後面的指令。
  • 為這個child process創建一個新的PID namespace、UTS namespace (實際上大家使用的container技術如Docker,可能會有更多的namespace來做更加嚴實的隔離,在這裡我們先使用兩個namespace作為實驗)
func run() {
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    //create a namespace and new pid
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
    }

    must(cmd.Run())
}

這邊可以注意到我使用exec.Command呼叫了一個child process,這個child process會使用我們剛剛編譯過的臨時執行檔,而且會創建一個新的命令行指令,且以"child"作為開頭,而後面的arguments則會與parent process的指令相同。

講的可能有些饒口,意思是,當parent process執行的命令行指令為go run main.go run /bin/bash,那麼現在這個child process則會執行/tmp/go-build3547203082/b001/exe/test child /bin/bash

到這邊,我們就可以開始定義child process接下來的行為了。

Child Process (container中的PID 1 Process)

我們可以先在這個function埋一個log,來看這個child process的PID是否真的為1。

fmt.Printf("running %v as PID %d\n", os.Args[2:], os.Getpid())

child()function裡面,可以實際來執行剛剛所輸入的命令行指令/bin/bash

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

must(cmd.Run())

完成後,到這裡我們就可以先試跑看看程式碼了。 golang container

可以觀察到,/bin/bash不僅被執行,且PID真的是1!接著,我們可以來試試看現在是否處在container的隔離環境之中:

我先在這個container裡面,把我的hostname從Sophie-Desktop替換成test-sophie: change hostname

退出container之後,在host觀察hostname,會發現hostname並沒有被更動!UTS namespace的確有發揮作用。 hostname

到這裡,一個簡易版的container(看似)就完成了。

編按:這邊要提醒讀者,這裡的hostname之所以能做到隔離,是因為在前面我們加上了UTS namespace syscall.CLONE_NEWUTS,在這次實驗中,我們只有加了PID和UTS這兩個比較好觀察到的namespace,因此只能在Hostname以及PID的部分觀察到隔離的效果!

檔案路徑與Process的可見性

為什麼上一個章節會說「看似」完成呢?實際上,我們再回到container中,會發現我們看得到host的檔案以及正在運行的process: hostname

這並不符合我們對於container的理解,對吧?

我們先來釐清一下為什麼會觀察到這樣的狀況。我們剛剛使用namespace創建了獨立的環境,來讓process運行,但實際上以目前的程式碼來看,container所使用的檔案系統和host會是同一個。為了要避免container裡面的process直接去訪問、操作位在host的檔案,我們可以使用先建造一個具有完整Linux系統的子目錄,再使用上一篇所提到chroot來改變container的根目錄位址。

在Linux環境中,可以使用debootstrap指令來創建這個子目錄,以下指令是以AMD64的晶片架構、Ubuntu 22.04為例。

sudo apt-get install debootstrap
sudo debootstrap --arch=amd64 jammy /home/rootfs http://archive.ubuntu.com/ubuntu/

接著就能看到在home/rootfs底下,就有一個基本的Linux檔案架構了。回到程式,便可以指定這個路徑,讓它成為container的根目錄。 rootfs

must(syscall.Chroot("/home/rootfs"))
must(os.Chdir("/"))

編按:如果使用這樣的方式,container對文件的修改會直接影響到host的檔案。最好的方法,還是要回歸上一篇有稍微提及的聯合檔案系統,將文件分為唯讀層、可寫層,所有的修改都應只在可寫層上進行。

再回頭來說說Process。在Linux中,/proc負責存取系統中每一個process和thread的狀態。回想前一個章節,當我們還在和host共用檔案目錄時,自然也讀取到了host的/proc檔案,連帶讀取到host的所有process了。

我們剛剛製作的小型Linux檔案系統並沒有包含/proc檔案系統,因此需要手動另外掛載:

must(syscall.Mount("proc", "proc", "proc", 0, ""))

完成後,再回頭執行ps aux,就會出現預期中的效果了。 ps aux

結語

此次透過實作一個container,來更加了解container的底層技術。不過,礙於篇幅及時間有限,實做出來的東西僅做實驗使用,並沒有做到非常完整的隔離與資源限制,或許都能成為下次努力精進的方向。

最後,不免俗地於文末附上完整的程式碼。(當然,僅供學習使用,其嚴謹度當然是不能夠使用在production環境!)

package main

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

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

func run() {
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

//create a namespace and new pid
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
    }

    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.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    //create file system for container
    must(syscall.Chroot("/home/rootfs"))
    must(os.Chdir("/"))

    //mount /proc
    must(syscall.Mount("proc", "proc", "proc", 0, ""))
    must(cmd.Run())
}

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

Reference

此篇文章受這部影片所啟發:Building a container from scratch in Go - Liz Rice (Microscaling Systems)