來實作一個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())
完成後,到這裡我們就可以先試跑看看程式碼了。
可以觀察到,/bin/bash
不僅被執行,且PID真的是1!接著,我們可以來試試看現在是否處在container的隔離環境之中:
我先在這個container裡面,把我的hostname從Sophie-Desktop替換成test-sophie:
退出container之後,在host觀察hostname,會發現hostname並沒有被更動!UTS namespace的確有發揮作用。
到這裡,一個簡易版的container(看似)就完成了。
編按:這邊要提醒讀者,這裡的hostname之所以能做到隔離,是因為在前面我們加上了UTS namespace
syscall.CLONE_NEWUTS
,在這次實驗中,我們只有加了PID和UTS這兩個比較好觀察到的namespace,因此只能在Hostname以及PID的部分觀察到隔離的效果!
檔案路徑與Process的可見性
為什麼上一個章節會說「看似」完成呢?實際上,我們再回到container中,會發現我們看得到host的檔案以及正在運行的process:
這並不符合我們對於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的根目錄。
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
,就會出現預期中的效果了。
結語
此次透過實作一個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)