本文為從零開始寫 Docker 系列第九篇,實現類似 docker ps 的功能,使得我們能夠查詢到後臺執行中的所有容器。
完整程式碼見:https://github.com/lixd/mydocker
歡迎 Star
推薦閱讀以下文章對 docker 基本實現有一個大致認識:
- 核心原理:深入理解 Docker 核心原理:Namespace、Cgroups 和 Rootfs
- 基於 namespace 的檢視隔離:探索 Linux Namespace:Docker 隔離的神奇背後
- 基於 cgroups 的資源限制
- 初探 Linux Cgroups:資源控制的奇妙世界
- 深入剖析 Linux Cgroups 子系統:資源精細管理
- Docker 與 Linux Cgroups:資源隔離的魔法之旅
- 基於 overlayfs 的檔案系統:Docker 魔法解密:探索 UnionFS 與 OverlayFS
- 基於 veth pair、bridge、iptables 等等技術的 Docker 網路:揭秘 Docker 網路:手動實現 Docker 橋接網路
開發環境如下:
root@mydocker:~# lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 20.04.2 LTS
Release: 20.04
Codename: focal
root@mydocker:~# uname -r
5.4.0-74-generic
注意:需要使用 root 使用者
1. 概述
上一篇已經實現了mydocker run -d
命令,可以讓容器脫離父程序在後臺獨立執行。
那麼我們怎麼知道有哪些容器在執行,而且它們的資訊又是什麼呢?
這裡就需要實現 mydocker ps 命令了。其實 mydocker ps 命令比較簡單,主要是去約定好的位置查詢一下容器的資訊資料,然後顯示出來,因此資料準備就顯得尤為重要。
因此整個實現分為兩部分:
- 1)容器執行時記錄資料
- 2)mydocker ps 查詢資料
對於 docker 來說,他會把容器資訊儲存在var/lib/docker/containers
目錄下。
- 讀取
var/lib/docker/containers
目錄下的所有資料夾就能拿到當前系統中的容器 - 讀取
/var/lib/docker/containers/{containerID}/config.v2.json
檔案即可拿到對應容器的詳細資訊。
我們也參考著 Docker 實現即可。
2. 記錄容器資訊
在前面章節建立的容器中,所有關於容器的資訊,比如PID、容器建立時間、容器執行命令等,都沒有記錄,這導致容器執行完後就再也不知道它的資訊了,因此需要把這部分資訊保留下來。
具體實現則是建立容器時將相關資訊寫入/var/lib/mydocker/containers/{containerId}/config.json
檔案中。
具體流程如下圖所示:
提供 -name flag
首先,要在 runCommand flag 裡面增加一個 name 標籤,方便使用者啟動容器時指定容器的名字。
var runCommand = cli.Command{
Name: "run",
Usage: `Create a container with namespace and cgroups limit
mydocker run -it [command]`,
Flags: []cli.Flag{
// 省略其他內容
cli.StringFlag{
Name: "name",
Usage: "container name",
},
},
Action: func(context *cli.Context) error {
// 把namne傳遞給Run方法
containerName := context.String("name")
Run(tty, cmdArray, resConf, volume, containerName)
return nil
},
recordContainerInfo
然後,需要增加一個 record 方法記錄容器的相關資訊。在增加之前,需要一個 ID 生成器,用來唯一標識容器。
使用過 Docker 的都知道,每個容器都會有一個 ID,為了方便起見,mydocker 中就用 10 位數字來表示一個容器的 ID。
func randStringBytes(n int) string {
letterBytes := "1234567890"
rand.Seed(time.Now().UnixNano())
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}
另外就是記錄容器資訊這個重要的環節,我們先定義了一個容器的一些基本資訊,比如 PID 和建立時間等,然後預設把容器的資訊以 json 的形式儲存在宿主機的/var/run/mydocker/容器名/config.json
檔案裡面。
容器完整資訊的基本格式如下:
type Info struct {
Pid string `json:"pid"` // 容器的init程序在宿主機上的 PID
Id string `json:"id"` // 容器Id
Name string `json:"name"` // 容器名
Command string `json:"command"` // 容器內init執行命令
CreatedTime string `json:"createTime"` // 建立時間
Status string `json:"status"` // 容器的狀態
}
然後就開始記錄容器資訊:
func RecordContainerInfo(containerPID int, commandArray []string, containerName, containerId string) error {
// 如果未指定容器名,則使用隨機生成的containerID
if containerName == "" {
containerName = containerId
}
command := strings.Join(commandArray, "")
containerInfo := &Info{
Id: containerId,
Pid: strconv.Itoa(containerPID),
Command: command,
CreatedTime: time.Now().Format("2006-01-02 15:04:05"),
Status: RUNNING,
Name: containerName,
}
jsonBytes, err := json.Marshal(containerInfo)
if err != nil {
return errors.WithMessage(err, "container info marshal failed")
}
jsonStr := string(jsonBytes)
// 拼接出儲存容器資訊檔案的路徑,如果目錄不存在則級聯建立
dirPath := fmt.Sprintf(InfoLocFormat, containerId)
if err := os.MkdirAll(dirPath, constant.Perm0622); err != nil {
return errors.WithMessagef(err, "mkdir %s failed", dirPath)
}
// 將容器資訊寫入檔案
fileName := path.Join(dirPath, ConfigName)
file, err := os.Create(fileName)
defer file.Close()
if err != nil {
return errors.WithMessagef(err, "create file %s failed", fileName)
}
if _, err = file.WriteString(jsonStr); err != nil {
return errors.WithMessagef(err, "write container info to file %s failed", fileName)
}
return nil
}
實際就是把容器的資訊序列化之後持久化到磁碟的/var/run/{containerID}/config.json
檔案裡。
如果你對雲原生技術充滿好奇,想要深入瞭解更多相關的文章和資訊,歡迎關注微信公眾號。
搜尋公眾號【探索雲原生】即可訂閱
Run 方法修改
最後,在 Run 函式上加上對於這個函式的呼叫,程式碼如下:
func Run(tty bool, comArray []string, res *subsystems.ResourceConfig, volume, containerName string) {
containerId := container.GenerateContainerID() // 生成 10 位容器 id
parent, writePipe := container.NewParentProcess(tty, volume)
if parent == nil {
log.Errorf("New parent process error")
return
}
if err := parent.Start(); err != nil {
log.Errorf("Run parent.Start err:%v", err)
return
}
// record container info
err := container.RecordContainerInfo(parent.Process.Pid, comArray, containerName, containerId)
if err != nil {
log.Errorf("Record container info error %v", err)
return
}
// 建立cgroup manager, 並透過呼叫set和apply設定資源限制並使限制在容器上生效
cgroupManager := cgroups.NewCgroupManager("mydocker-cgroup")
defer cgroupManager.Destroy()
_ = cgroupManager.Set(res)
_ = cgroupManager.Apply(parent.Process.Pid, res)
// 在子程序建立後才能透過pipe來傳送引數
sendInitCommand(comArray, writePipe)
if tty { // 如果是tty,那麼父程序等待,就是前臺執行,否則就是跳過,實現後臺執行
_ = parent.Wait()
container.DeleteWorkSpace("/root/", volume)
container.DeleteContainerInfo(containerId)
}
}
另外再容器退出後,就需要刪除容器的相關資訊,實現也很簡單,把對應目錄的資訊都刪除就好了。
func DeleteContainerInfo(containerID string) {
dirPath := fmt.Sprintf(InfoLocFormat, containerID)
if err := os.RemoveAll(dirPath); err != nil {
log.Errorf("Remove dir %s error %v", dirPath, err)
}
}
到此為止,就完成了資訊的收集。容器建立後,所有需要的資訊都被儲存到/var/lib/mydocker/containers/{containerID}
下,下面就可以透過讀取並遍歷這個目錄下的容器去實現 mydocker ps 命令了。
3. 實現 mydocker ps
具體實現則是遍歷 /var/lib/mydocker/containers/
目錄,解析得到容器資訊並彙總後以表格形式列印出來。
具體流程如下圖所示:
listCommand
首先在 main_command.go 中增加 ps 命令:
var listCommand = cli.Command{
Name: "ps",
Usage: "list all the containers",
Action: func(context *cli.Context) error {
ListContainers()
return nil
},
}
在 main.go 中引用該命令:
func main {
// 省略其他內容
app.Commands = []cli.Command{
initCommand,
runCommand,
commitCommand,
listCommand,
}
}
具體實現見 ListContainers 方法。
ListContainers
整體實現也比較簡單:
- 首先遍歷存放容器資料的
/var/lib/mydocker/containers/
目錄,裡面每一個子目錄都是一個容器。 - 然後使用 getContainerInfo 方法解析子目錄中的
config.json
檔案拿到容器資訊 - 最後格式化成 table 形式列印出來即可
func ListContainers() {
// 讀取存放容器資訊目錄下的所有檔案
files, err := os.ReadDir(container.InfoLoc)
if err != nil {
log.Errorf("read dir %s error %v", container.InfoLoc, err)
return
}
containers := make([]*container.Info, 0, len(files))
for _, file := range files {
tmpContainer, err := getContainerInfo(file)
if err != nil {
log.Errorf("get container info error %v", err)
continue
}
containers = append(containers, tmpContainer)
}
// 使用tabwriter.NewWriter在控制檯列印出容器資訊
// tabwriter 是引用的text/tabwriter類庫,用於在控制檯列印對齊的表格
w := tabwriter.NewWriter(os.Stdout, 12, 1, 3, ' ', 0)
_, err = fmt.Fprint(w, "ID\tNAME\tPID\tSTATUS\tCOMMAND\tCREATED\n")
if err != nil {
log.Errorf("Fprint error %v", err)
}
for _, item := range containers {
_, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
item.Id,
item.Name,
item.Pid,
item.Status,
item.Command,
item.CreatedTime)
if err != nil {
log.Errorf("Fprint error %v", err)
}
}
if err = w.Flush(); err != nil {
log.Errorf("Flush error %v", err)
}
}
getContainerInfo
具體的解析方法則提取到了 getContainerInfo
。
讀取檔案內容,並反序列化得到容器資訊。
func getContainerInfo(file os.DirEntry) (*container.Info, error) {
// 根據檔名拼接出完整路徑
configFileDir := fmt.Sprintf(container.InfoLocFormat, file.Name())
configFileDir = path.Join(configFileDir, container.ConfigName)
// 讀取容器配置檔案
content, err := os.ReadFile(configFileDir)
if err != nil {
log.Errorf("read file %s error %v", configFileDir, err)
return nil, err
}
info := new(container.Info)
if err = json.Unmarshal(content, info); err != nil {
log.Errorf("json unmarshal error %v", err)
return nil, err
}
return info, nil
}
4. 測試
測試以下功能:
- 建立容器後能否記錄資訊到檔案
- mydocker ps 能否正常讀取並展示容器資訊
記錄容器資訊
分別測試指定容器名稱和不知道名稱兩種情況。
指定名稱
透過--name
指定容器名稱,並透過-d
指定後臺執行:
root@mydocker:~/feat-ps/mydocker# ./mydocker run -d -name runtop top
{"level":"info","msg":"createTty false","time":"2024-01-25T14:20:11+08:00"}
{"level":"info","msg":"resConf:\u0026{ 0 }","time":"2024-01-25T14:20:11+08:00"}
{"level":"info","msg":"busybox:/root/busybox busybox.tar:/root/busybox.tar","time":"2024-01-25T14:20:11+08:00"}
{"level":"error","msg":"mkdir dir /root/merged error. mkdir /root/merged: file exists","time":"2024-01-25T14:20:11+08:00"}
{"level":"error","msg":"mkdir dir /root/upper error. mkdir /root/upper: file exists","time":"2024-01-25T14:20:11+08:00"}
{"level":"error","msg":"mkdir dir /root/work error. mkdir /root/work: file exists","time":"2024-01-25T14:20:11+08:00"}
{"level":"info","msg":"mount overlayfs: [/usr/bin/mount -t overlay overlay -o lowerdir=/root/busybox,upperdir=/root/upper,workdir=/root/work /root/merged]","time":"2024-01-25T14:20:11+08:00"}
{"level":"info","msg":"command all is top","time":"2024-01-25T14:20:11+08:00"}
可以看到此時,命令已經退出了,查詢容器(top 命令)是否在後臺執行。
root@mydocker:~/feat-ps/mydocker# ps -ef|grep -e PPID -e top
UID PID PPID C STIME TTY TIME CMD
root 169514 1 0 14:20 pts/8 00:00:00 top
後臺確實有一個 top 命令在執行,PID 為 169514。
檢視 /var/lib/mydocker/containers
目錄,是否新增了容器資訊記錄檔案
root@mydocker:~/feat-ps/mydocker# ls /var/lib/mydocker/containers
5633481844
root@mydocker:~/feat-ps/mydocker# ls /var/lib/mydocker/containers/5633481844/
config.json
root@mydocker:~/feat-ps/mydocker# cat /var/lib/mydocker/containers/5633481844/config.json
{"pid":"169514","id":"5633481844","name":"runtop","command":"top","createTime":"2024-01-25 14:20:11","status":"running"}
可以看到,config.json
檔案記錄了容器名稱,id、pid、command 等資訊,基於這些資訊,我們執行 mydocker ps
時就可以列出當前正在執行的容器資訊了。
不指定名稱
在測試一下不指定名稱的容器,能否正常記錄。
root@mydocker:~/feat-ps/mydocker# ./mydocker run -d top
{"level":"info","msg":"createTty false","time":"2024-01-25T14:22:28+08:00"}
{"level":"info","msg":"resConf:\u0026{ 0 }","time":"2024-01-25T14:22:28+08:00"}
{"level":"info","msg":"busybox:/root/busybox busybox.tar:/root/busybox.tar","time":"2024-01-25T14:22:28+08:00"}
{"level":"error","msg":"mkdir dir /root/merged error. mkdir /root/merged: file exists","time":"2024-01-25T14:22:28+08:00"}
{"level":"error","msg":"mkdir dir /root/upper error. mkdir /root/upper: file exists","time":"2024-01-25T14:22:28+08:00"}
{"level":"error","msg":"mkdir dir /root/work error. mkdir /root/work: file exists","time":"2024-01-25T14:22:28+08:00"}
{"level":"info","msg":"mount overlayfs: [/usr/bin/mount -t overlay overlay -o lowerdir=/root/busybox,upperdir=/root/upper,workdir=/root/work /root/merged]","time":"2024-01-25T14:22:28+08:00"}
{"level":"info","msg":"command all is top","time":"2024-01-25T14:22:28+08:00"}
檢視 /var/lib/mydocker/containers
目錄是否新增記錄檔案
root@mydocker:~/feat-ps/mydocker# ls /var/lib/mydocker/containers
5633481844 8636128862
root@mydocker:~/feat-ps/mydocker# ls /var/lib/mydocker/containers/8636128862/
config.json
root@mydocker:~/feat-ps/mydocker# cat /var/lib/mydocker/containers/8636128862/config.json
{"pid":"169707","id":"8636128862","name":"8636128862","command":"top","createTime":"2024-01-25 14:22:28","status":"running"
可以看到,新增了 8636128862 目錄,其中 8636128862 就是容器 ID,對於未指定名稱的容器,會使用生成的 id 作為名稱。
接著檢視一下/var/lib/mydocker/containers
目錄結構:
root@mydocker:/var/lib/mydocker/containers# tree .
.
├── 5633481844
│ └── config.json
└── 8636128862
└── config.json
可以看到,mydocker 分別在該路徑下建立了兩個資料夾,分別以容器的ID命名。
子目錄裡面的config.json 儲存了容器的詳細資訊。
至此,說明我們的容器資訊記錄功能是正常的。
mydocker ps
最後測試 mydocker ps
命令能否正常展示,容器資訊。
root@mydocker:~/feat-ps/mydocker# ./mydocker ps
ID NAME PID STATUS COMMAND CREATED
5633481844 runtop 169514 running top 2024-01-25 14:20:11
8636128862 8636128862 169707 running top 2024-01-25 14:22:28
成功列印出了當前執行中的兩個容器,說明 mydocker ps
命令是 ok 的。
5. 總結
本篇實現的 mydocker ps
比較簡單,和 docker 實現基本類似:
-
容器啟動把資訊儲存在
var/lib/mydocker/containers
目錄下 -
讀取
var/lib/mydocker/containers
目錄下的所有資料夾就能拿到當前系統中的容器 -
讀取
/var/lib/mydocker/containers/{containerID}/config.json
檔案即可拿到對應容器的詳細資訊。
不過現在由於沒有隔離每個容器的 rootfs,因此啟動多個容器時會出現一些問題,不過不是本篇重點,暫時先不關注,等後續統一處理。
【從零開始寫 Docker 系列】持續更新中,搜尋公眾號【探索雲原生】訂閱,閱讀更多文章。
完整程式碼見:https://github.com/lixd/mydocker
歡迎關注~
相關程式碼見 feat-volume
分支,測試指令碼如下:
需要提前在 /root 目錄準備好 busybox.tar 檔案,具體見第四篇第二節。
# 克隆程式碼
git clone -b feat-ps https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依賴並編譯
go mod tidy
go build .
# 測試
./mydocker run -d -name c1 top