用 Golang 手寫一個 Container

本文作者系 360 奇舞團前端開發工程師

前言

Docker 作爲一種流行的容器化技術,對於每一個程序開發者而言都具有重要性和必要性。因爲容器化相關技術的普及大大簡化了開發環境配置、更好的隔離性和更高的安全性,對於部署項目和團隊協作而言也更加方便。本文將嘗試使用 Go 語言編寫一個極簡版的容器,以此來了解容器的基本原理。

前置知識儲備:

Docker 是基於 Linux 容器技術構建的,因此瞭解 Linux 操作系統的基本原理、命令和文件系統等知識對於理解本文乃至於 Docker 源碼非常重要。

瞭解容器技術的基本概念、原理和實現方式對於理解 Docker 源碼非常有幫助。可以參考 Docker 官方文檔 [2] 中的容器概述部分,以及相關的教程和文章。

Docker 的源碼主要是用 Go 語言編寫的,具體可以參考 Go 語言官方文檔 [3]。

[圖片來源:Docker 架構概覽 [4]]

什麼是容器化

容器化是作爲一種虛擬化技術,允許應用程序和其依賴的資源(如庫、環境變量等)被封裝在一個獨立的運行環境中,稱爲容器。其核心概念主要包括:

容器使用操作系統級別的虛擬化技術,如 Linux 的命名空間和控制組(cgroup),實現隔離。每個容器都有自己的進程空間、文件系統、網絡和用戶空間,使得容器之間相互隔離,不會相互干擾。

相比傳統的虛擬機(VM),容器更加輕量級。容器共享主機操作系統的內核,因此啓動更快、佔用更少的資源。

容器可以在不同的環境中運行,包括開發、測試和生產環境。容器以相同的方式運行,不受底層基礎設施的影響,提供了更好的可移植性。

容器可以根據需求進行擴展和縮減。容器編排工具(如 Kubernetes)可以自動管理容器的部署、伸縮和負載均衡,提供彈性和可擴展性。

"如果創建一個容器就像系統調用 create_container 一樣簡單就好了"[5]

Guideline

這裏我們粗略的估算一下可能涉及到的步驟會有:導入必要的包、main 函數、子進程及其命名空間、掛載文件系統、運行子進程命令等。

我們知道真正的容器實現要複雜得多。它可能會涉及更多的命名空間設置、資源限制、文件系統掛載、網絡配置等方面的工作。

但是本文,“刪繁就簡”,主要是爲了瞭解容器的基本原理。

按照這種實現的思路,我們開始一步步用代碼實現:

package main

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

func main() {
 // 根據命令行參數選擇執行不同的操作
 switch os.Args[1] {
 case "run":
  parent() // 執行parent函數
 case "child":
  child() // 執行child函數
 default:
  panic("wat should I do") // 拋出異常,程序無法繼續執行
 }
}

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

 // 運行命令並檢查錯誤
 if err := cmd.Run(); err != nil {
  fmt.Println("ERROR", err)
  os.Exit(1)
 }
}

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

 // 運行命令並檢查錯誤
 if err := cmd.Run(); err != nil {
  fmt.Println("ERROR", err)
  os.Exit(1)
 }
}

func must(err error) {
 // 如果錯誤不爲空,拋出panic異常
 if err != nil {
  panic(err)
 }
}

我們從 main.go 開始,讀取第一個參數。如果是 "run",我們就運行 Parent 函數,如果是 "child",我們就運行子方法。父方法運行 "/proc/self/exe",這是一個包含當前可執行文件內存映像的特殊文件。

換句話說,我們重新運行自己,但將 child 作爲第一個參數傳遞。

我們可以藉此執行另外一個執行用戶請求的程序(在 os.Args[2:] 中提供)。有了這個簡單的腳手架,我們就可以創建一個容器了。

命名空間

在 Linux 中,命名空間(Namespace)[6] 是一種內核功能,用於隔離進程的資源視圖。它允許在同一系統上運行的進程具有獨立的資源副本,如進程 ID、網絡接口、文件系統掛載點等。這種隔離性可以提供更好的安全性和資源管理。以下是一些常見的 Linux 命名空間類型:

UTS 命名空間

Linux UTS Namespace[7]。在 UTS 命名空間中,每個命名空間都有自己的主機名和域名。UTS 命名空間的使用場景包括:容器化和網絡隔離等。

要在程序中添加命名空間,我們只需在 parent() 方法的第二行,添加下面的這幾行代碼,以便於在 Go 運行子進程時傳遞給其一些額外的標識。

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

如果現在運行程序,程序將在 UTS、PID 和 MNT 命名空間內運行。

在 Docker 中,根文件系統是由 Docker 鏡像提供的,並且在容器啓動時被掛載到容器的根目錄上。Docker 根文件系統一般具有分層結構、只讀性和寫時複製等特性。

現在,雖然我們的進程處於一組孤立的命名空間中,但文件系統看起來與主機相同。爲了解決這個問題,我們需要以下四行代碼來實現根文件系統:

must(syscall.Mount("rootfs""rootfs""", syscall.MS_BIND, ""))
 must(os.MkdirAll("rootfs/oldrootfs", 0700))
    
    // 將當前目錄 `/` 移到 `rootfs/oldrootfs` 並將新的 rootfs 目錄交換到 `/`
 must(syscall.PivotRoot("rootfs""rootfs/oldrootfs"))
 must(os.Chdir("/"))

所以完整代碼如下:

package main

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

func main() {
 // 根據命令行參數選擇執行不同的操作
 switch os.Args[1] {
 case "run":
  parent() // 執行parent函數
 case "child":
  child() // 執行child函數
 default:
  panic("wat should I do") // 拋出異常,程序無法繼續執行
 }
}

func parent() {
 cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
 
 // 設置子進程的命名空間
 cmd.SysProcAttr = &syscall.SysProcAttr{
  Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
 }
 
 cmd.Stdin = os.Stdin
 cmd.Stdout = os.Stdout
 cmd.Stderr = os.Stderr

 // 運行命令並檢查錯誤
 if err := cmd.Run(); err != nil {
  fmt.Println("ERROR", err)
  os.Exit(1)
 }
}

func child() {
 // 掛載文件系統
 must(syscall.Mount("rootfs""rootfs""", syscall.MS_BIND, ""))
 must(os.MkdirAll("rootfs/oldrootfs", 0700))
 must(syscall.PivotRoot("rootfs""rootfs/oldrootfs"))
 must(os.Chdir("/"))
    
 cmd := exec.Command(os.Args[2], os.Args[3:]...)
 cmd.Stdin = os.Stdin
 cmd.Stdout = os.Stdout
 cmd.Stderr = os.Stderr

 // 運行命令並檢查錯誤
 if err := cmd.Run(); err != nil {
  fmt.Println("ERROR", err)
  os.Exit(1)
 }
}

func must(err error) {
 // 如果錯誤不爲空,拋出panic異常
 if err != nil {
  panic(err)
 }
}

是的,至此,基於 golang 實現的極簡版的容器代碼已經有了基本骨架。

Cgroups

Linux Cgroups[8] 在 Docker 容器化中起着重要的作用,它提供了對容器的資源限制和隔離,使得容器可以在共享的宿主機上運行而不會相互干擾:

通過 Cgroups,Docker 可以對容器的資源使用進行限制,如 CPU、內存、磁盤和網絡等。這樣可以避免容器過度佔用宿主機資源,保證系統的穩定性和公平性。

Cgroups 提供了容器級別的資源隔離,每個容器都可以被分配和限制其使用的資源。這樣,容器之間的資源使用不會互相干擾,一個容器的問題也不會影響其他容器或宿主機。

Docker 使用 Cgroups 對容器進行管理和監控。通過讀取和設置 Cgroups 的屬性,Docker 可以實時瞭解容器的資源使用情況,並可以調整資源限制以滿足需求。

在 cgroup(控制組)這部分,需要注意 Cgroup 的掛載和層級結構等限制。

所以我們將 Cgrous 這一部分加入到代碼實現中來如下:

package main

import (
    "fmt"
    "io/ioutil"
    "os"
    "os/exec"
    "strconv"
    "syscall"
)

func main() {
    // 創建 cgroup
    err := createCgroup("mycontainer")
    if err != nil {
        fmt.Println("Failed to create cgroup:", err)
        return
    }

    defer func() {
        // 退出時刪除 cgroup
        err := deleteCgroup("mycontainer")
        if err != nil {
            fmt.Println("Failed to delete cgroup:", err)
        }
    }()

    // 限制 CPU 使用率爲 50%
    err = setCPULimit("mycontainer", 50)
    if err != nil {
        fmt.Println("Failed to set CPU limit:", err)
        return
    }

    // 在容器中運行命令
    cmd := exec.Command("/bin/bash")
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWNET,
        Cgroup:     "mycontainer",
    }

    err = cmd.Run()
    if err != nil {
        fmt.Println("Failed to run command in container:", err)
    }
}

func createCgroup(name string) error {
    cgroupPath := "/sys/fs/cgroup/cpu/" + name
    err := os.Mkdir(cgroupPath, 0755)
    if err != nil {
        return err
    }

    // 將當前進程加入到 cgroup 中
    err = ioutil.WriteFile(cgroupPath+"/tasks"[]byte(strconv.Itoa(os.Getpid())), 0644)
    if err != nil {
        return err
    }

    return nil
}

func deleteCgroup(name string) error {
    cgroupPath := "/sys/fs/cgroup/cpu/" + name
    err := os.Remove(cgroupPath)
    if err != nil {
        return err
    }

    return nil
}

func setCPULimit(name string, limit int) error {
    cgroupPath := "/sys/fs/cgroup/cpu/" + name
    err := ioutil.WriteFile(cgroupPath+"/cpu.cfs_quota_us"[]byte(strconv.Itoa(limit*1000)), 0644)
    if err != nil {
        return err
    }

    return nil
}

在上面,我們將當前進程加入到新創建的 "mycontainer" 的 cgroup,然後,設置該 cgroup 的 CPU 使用率限制爲 50%。繼而實現在容器中運行一個交互式的 shell。

結語

編寫一個容器(container)是一個相當複雜的任務,涉及到許多底層的概念和技術。回顧本文,使用 golang 一步步 “還原” 一個 mini 版的 container 所需步驟基本如下:

  1. 瞭解容器技術和相關概念:在開始編寫 mini 容器之前,強烈建議先了解一些容器技術的基本原理,如命名空間(namespaces)、控制組(cgroups)、文件系統隔離等。

  2. 選擇編程語言和庫:之所以選擇使用 Golang 進行容器的編寫,因爲它提供了強大的併發和系統編程能力。同時,還可以使用一些相關的庫,如os/execsyscall

  3. 創建容器的基本結構:首先創建出一個基本的容器結構,該結構將包含容器的信息,如 ID、進程 ID、文件系統等。

  4. 設置容器的命名空間:使用 Golang 的syscall包,設置容器的命名空間,如 PID 命名空間、網絡命名空間等。這樣可以將容器中的進程與主機系統的進程隔離開來。

  5. 設置容器的文件系統:創建一個文件系統,可以是一個文件夾或鏡像文件,用於存儲容器內的文件和目錄。這裏我們可以藉助於 Golang 的osio/ioutil包來操作文件系統。

  6. 啓動容器中的進程:使用os/exec包,在容器的命名空間中啓動一個新的進程, 並指定要運行的可執行文件和參數。

  7. 設置容器的網絡:如果想讓容器具有網絡連接能力,我們還需要設置容器的網絡命名空間,並進行相關網絡配置。這可能涉及到創建虛擬網絡設備、配置 IP 地址等。

  8. 處理容器的生命週期:需要考慮到容器的創建、啓動、停止和銷燬等生命週期事件。這可能涉及到信號處理、資源清理等操作。

除此之外,還需要考慮到安全性、權限管理、資源限制等多方面因素。

當然,實際的容器實現要更加複雜和完善。在實際項目應用中,我們可能還需要考慮到如文件系統隔離、網絡隔離等遠比這些複雜的場景。

參考資料

[1]

unsplash.com: https://unsplash.com/

[2]

Docker 官方文檔: https://docs.docker.com/

[3]

Go 語言官方文檔: https://golang.org/doc/

[4]

Docker 源碼分析: https://zhuanlan.zhihu.com/p/302786713

[5]

Build Your Own Container Using Less than 100 Lines of Go: https://www.infoq.com/articles/build-a-container-golang/

[6]

Linux 命名空間概述 - 官方文檔 : https://www.kernel.org/doc/html/latest/admin-guide/namespaces/index.html

[7]

UTS 命名空間 - Linuxize: https://lwn.net/Articles/531114/

[8]

Linux Cgroups: https://www.kernel.org/doc/Documentation/cgroup-v1/cgroups.txt

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Bh05LxVG5jeVQdsYY-ZSxA