文章很長,而且持續更新,建議收藏起來,慢慢讀!瘋狂創客圈總目錄 博客園版 為您奉上珍貴的學習資源 :
免費贈送 :《尼恩Java面試寶典》 持續更新+ 史上最全 + 面試必備 2000頁+ 面試必備 + 大廠必備 +漲薪必備
免費贈送 經典圖書:《Java高並發核心編程(卷1)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《Java高並發核心編程(卷2)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《Java高並發核心編程(卷3)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《尼恩Java面試寶典 最新版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 資源寶庫: Java 必備 百度網盤資源大合集 價值>10000元 加尼恩領取
背景:
下一個視頻版本,從架構師視角,尼恩為大家打造高可用、高並發中間件的原理與實操。
目標:通過視頻和博客的方式,為各位潛力架構師,徹底介紹清楚架構師必須掌握的高可用、高並發環境,包括但不限於:
-
高可用、高並發nginx架構的原理與實操
-
高可用、高並發mysql架構的原理與實操
-
高可用、高並發nacos架構的原理與實操
-
高可用、高並發rocketmq架構的原理與實操
-
高可用、高並發es架構的原理與實操
-
高可用、高並發minio架構的原理與實操
why 高可用、高並發中間件的原理與實操:
實際的開發過程中,很多小伙伴聚焦crud開發,環境出了問題,都不能啟動。
作為架構師,或者未來想走向高端開發,或者做架構,必須掌握高可用、高並發中間件的原理,掌握其實操。
由於以上的高可用環境的原理和大家,使用docker和 docker-compose完成,包含 高可用mysql , 高可用 rocketmq ,等一系列的中間件。
另外,在高級程序員和架構師的招聘中, docker的原理,也是必須掌握的基礎內容。
所以,給大家介紹一下docker原理。
本系列博客的具體內容,請參見 Java 高並發 發燒友社群:瘋狂創客圈
Docker基礎
作為大神或者准架構師/架構師,一定要了解一下docker的底層原理。
但是,首先還是簡單說明一下docker的簡介。
Docker 簡介
Docker 是一個開源的應用容器引擎,基於 Go 語言 並遵從 Apache2.0 協議開源。
Docker 可以讓開發者打包他們的應用以及依賴包到一個輕量級、可移植的容器中,然后發布到任何流行的 Linux 機器上,也可以實現虛擬化。
容器是完全使用沙箱機制,相互之間不會有任何接口(類似 iPhone 的 app),更重要的是容器性能開銷極低。
Docker 從 17.03 版本之后分為 CE(Community Edition: 社區版) 和 EE(Enterprise Edition: 企業版),我們用社區版就可以了
Docker的應用場景
- Web 應用的自動化打包和發布。
- 自動化測試和持續集成、發布。
- 在服務型環境中部署和調整數據庫或其他的后台應用。
- 從頭編譯或者擴展現有的 OpenShift 或 Cloud Foundry 平台來搭建自己的 PaaS 環境。
Docker 架構
Docker 包括三個基本概念:
- 鏡像(Image):Docker 鏡像(Image),就相當於是一個 root 文件系統。比如官方鏡像 ubuntu:16.04 就包含了完整的一套 Ubuntu16.04 最小系統的 root 文件系統。
- 容器(Container):鏡像(Image)和容器(Container)的關系,就像是面向對象程序設計中的類和實例一樣,鏡像是靜態的定義,容器是鏡像運行時的實體。容器可以被創建、啟動、停止、刪除、暫停等。
- 倉庫(Repository):倉庫可看成一個代碼控制中心,用來保存鏡像。
Docker 使用客戶端-服務器 (C/S) 架構模式,使用遠程API來管理和創建Docker容器。
Docker 容器通過 Docker 鏡像來創建。
概念 | 說明 |
---|---|
Docker 鏡像(Images) | Docker 鏡像是用於創建 Docker 容器的模板,比如 Ubuntu 系統。 |
Docker 容器(Container) | 容器是獨立運行的一個或一組應用,是鏡像運行時的實體。 |
Docker 客戶端(Client) | Docker 客戶端通過命令行或者其他工具使用 Docker SDK (https://docs.docker.com/develop/sdk/) 與 Docker 的守護進程通信。 |
Docker 主機(Host) | 一個物理或者虛擬的機器用於執行 Docker 守護進程和容器。 |
Docker Registry | Docker 倉庫用來保存鏡像,可以理解為代碼控制中的代碼倉庫。Docker Hub(https://hub.docker.com) 提供了龐大的鏡像集合供使用。一個 Docker Registry 中可以包含多個倉庫(Repository);每個倉庫可以包含多個標簽(Tag);每個標簽對應一個鏡像。通常,一個倉庫會包含同一個軟件不同版本的鏡像,而標簽就常用於對應該軟件的各個版本。我們可以通過 <倉庫名>:<標簽> 的格式來指定具體是這個軟件哪個版本的鏡像。如果不給出標簽,將以 latest 作為默認標簽。 |
最為常用的幾個命令
docker的守護進程查看
systemctl status docker
docker 鏡像查看
docker image ls
docker 容器查看
docker ps
Docker Registry配置和查看
cat /etc/docker/daemon.json
配置私有倉庫
cat>/etc/docker/daemon.json<<EOF
{
"registry-mirrors":["http://10.24.2.30:5000","https://tnxkcso1.mirrors.aliyuncs.com"],
"insecure-registries":["10.24.2.30:5000"]
}
EOF
Docker 的發展歷史
Docker 公司前身是 DotCloud,由 Solomon Hykes 在2010年成立,2013年更名 Docker。同年發布了 Docker-compose 組件提供容器的編排工具。
2014年 Docker 發布1.0版本,2015年Docker 提供 Docker-machine,支持 windows 平台。
在此期間,Docker 項目在開源社區大受追捧,同時也被業界詬病的是 Docker 公司對於 Docker 發展具有絕對的話語權,比如 Docker 公司推行了 libcontainer 難以被社區接受。
為了防止 Docker 這項開源技術被Docker 公司控制,在幾個核心貢獻的廠商,於是在一同貢獻 Docker 代碼的公司諸如 Redhat,谷歌的倡導下,成立了 OCI 開源社區。
OCI 開源社區旨在於將 Docker 的發展權利回歸社區,當然反過來講,Docker 公司也希望更多的廠商安心貢獻代碼到Docker 項目,促進 Docker 項目的發展。
於是通過OCI建立了 runc 項目,替代 libcontainer,這為開發者提供了除 Docker 之外的容器化實現的選擇。
OCI 社區提供了 runc 的維護,而 runc 是基於 OCI 規范的運行容器的工具。換句話說,你可以通過 runc,提供自己的容器實現,而不需要依賴 Docker。當然,Docker 的發行版底層也是用的 runc。在 Docker 宿主機上執行 runc,你會發現它的大多數命令和 Docker 命令類似,感興趣的讀者可以自己實踐如何用 runc 啟動容器。
至2017年,Docker 項目轉移到 Moby 項目,基於 Moby 項目,Docker 提供了兩種發行版,Docker CE 和 Docker EE, Docker CE 就是目前大家普遍使用的版本,Docker EE 成為付費版本,提供了容器的編排,Service 等概念。Docker 公司承諾 Docker 的發行版會基於 Moby 項目。這樣一來,通過 Moby 項目,你也可以自己打造一個定制化的容器引擎,而不會被 Docker 公司綁定。
Docker 與虛擬機有何區別
Docker 的誤解:Docker 是輕量級的虛擬機。
很多人將docker理解為, Docker 實現了類似於虛擬化的技術,能夠讓應用跑在一些輕量級的容器里。這么理解其實是錯誤的。
到底什么是docker:
Docker是一個Client-Server結構的系統,Docker守護進程運行在主機上, 然后通過Socket連接從客戶端訪問Docker守護進程。
Docker守護進程從客戶端接受命令,並按照命令,管理運行在主機上的容器。
一個docker 容器,是一個運行時環境,可以簡單理解為進程運行的集裝箱。
如圖下所示
docker和kvm都是虛擬化技術,它們的主要差別:
1、Docker有着比虛擬機更少的抽象層
2、docker利用的是宿主機的內核,VM需要的是Guest OS
二者的不同:
- VM(VMware)在宿主機器、宿主機器操作系統的基礎上創建虛擬層、虛擬化的操作系統、虛擬化的倉庫,然后再安裝應用;
- Container(Docker容器),在宿主機器、宿主機器操作系統上創建Docker引擎,在引擎的基礎上再安裝應用。
所以說,新建一個容器的時候,docker不需要像虛擬機一樣重新加載一個操作系統,避免引導。docker是利用宿主機的操作系統,省略了這個復雜的過程,秒級!
虛擬機是加載Guest OS ,這是分鍾級別的
與傳統VM特性對比:
作為一種輕量級的虛擬化方式,Docker在運行應用上跟傳統的虛擬機方式相比具有顯著優勢:
- Docker 容器很快,啟動和停止可以在秒級實現,這相比傳統的虛擬機方式要快得多。
- Docker 容器對系統資源需求很少,一台主機上可以同時運行數千個Docker容器。
- Docker 通過類似Git的操作來方便用戶獲取、分發和更新應用鏡像,指令簡明,學習成本較低。
- Docker 通過Dockerfile配置文件來支持靈活的自動化創建和部署機制,提高工作效率。
- Docker 容器除了運行其中的應用之外,基本不消耗額外的系統資源,保證應用性能的同時,盡量減小系統開銷。
- Docker 利用Linux系統上的多種防護機制實現了嚴格可靠的隔離。從1.3版本開始,Docker引入了安全選項和鏡像簽名機制,極大地提高了使用Docker的安全性。
特性 | 容器 | 虛擬機 |
---|---|---|
啟動速度 | 秒級 | 分鍾級 |
硬盤使用 | 一般為MB | 一般為GB |
性能 | 接近原生 | 弱於原生 |
系統支持量 | 單機支持上千個容器 | 一般幾十個 |
Docker的技術底座:
Linux 命名空間、控制組和 UnionFS 三大技術支撐了目前 Docker 的實現,也是 Docker 能夠出現的最重要原因。
- namespace,命名空間
命名空間,容器隔離的基礎,保證A容器看不到B容器.
6個命名空間:User,Mnt,Network,UTS,IPC,Pid
- cgroups,Cgroups 是 Control Group 的縮寫,控制組
cgroups 容器資源統計和隔離
主要用到的cgroups子系統:cpu,blkio,device,freezer,memory
實際上 Docker 是使用了很多 Linux 的隔離功能,讓容器看起來像一個輕量級虛擬機在獨立運行,容器的本質是被限制了的 Namespaces,cgroup,具有邏輯上獨立文件系統,網絡的一個進程。
- unionfs 聯合文件系統
典型:aufs/overlayfs,分層鏡像實現的基礎
UnionFS 聯合文件系統
什么是鏡像
那么問題來了,沒有操作系統,怎么運行程序?
可以在Docker中創建一個centos的鏡像文件,這樣就能將centos系統集成到Docker中,運行的應用就都是centos的應用。
Image 是 Docker 部署的基本單位,一個 Image 包含了我們的程序文件,以及這個程序依賴的資源的環境。Docker Image 對外是以一個文件的形式展示的(更准確的說是一個 mount 點)。
UnionFS 與AUFS
UnionFS 其實是一種為 Linux 操作系統設計的用於把多個文件系統『聯合』到同一個掛載點的文件系統服務。
AUFS 即 Advanced UnionFS 其實就是 UnionFS 的升級版,它能夠提供更優秀的性能和效率。
AUFS 作為先進聯合文件系統,它能夠將不同文件夾中的層聯合(Union)到了同一個文件夾中,這些文件夾在 AUFS 中稱作分支,整個『聯合』的過程被稱為聯合掛載(Union Mount)。
概念理解起來比較枯燥,最好是有一個真實的例子來幫助我們理解:
首先,我們建立 company 和 home 兩個目錄,並且分別為他們創造兩個文件
# tree .
.
|-- company
| |-- code
| `-- meeting
`-- home
|-- eat
`-- sleep
然后我們將通過 mount 命令把 company 和 home 兩個目錄「聯合」起來,建立一個 AUFS 的文件系統,並掛載到當前目錄下的 mnt 目錄下:
# mkdir mnt
# ll
total 20
drwxr-xr-x 5 root root 4096 Oct 25 16:10 ./
drwxr-xr-x 5 root root 4096 Oct 25 16:06 ../
drwxr-xr-x 4 root root 4096 Oct 25 16:06 company/
drwxr-xr-x 4 root root 4096 Oct 25 16:05 home/
drwxr-xr-x 2 root root 4096 Oct 25 16:10 mnt/
# mount -t aufs -o dirs=./home:./company none ./mnt
# ll
total 20
drwxr-xr-x 5 root root 4096 Oct 25 16:10 ./
drwxr-xr-x 5 root root 4096 Oct 25 16:06 ../
drwxr-xr-x 4 root root 4096 Oct 25 16:06 company/
drwxr-xr-x 6 root root 4096 Oct 25 16:10 home/
drwxr-xr-x 8 root root 4096 Oct 25 16:10 mnt/
root@rds-k8s-18-svr0:~/xuran/aufs# tree ./mnt/
./mnt/
|-- code
|-- eat
|-- meeting
`-- sleep
4 directories, 0 files
通過 ./mnt 目錄結構的輸出結果,可以看到原來兩個目錄下的內容都被合並到了一個 mnt 的目錄下。
默認情況下,如果我們不對「聯合」的目錄指定權限,內核將根據從左至右的順序將第一個目錄指定為可讀可寫的,其余的都為只讀。那么,當我們向只讀的目錄做一些寫入操作的話,會發生什么呢?
# echo apple > ./mnt/code
# cat company/code
# cat home/code
apple
通過對上面代碼段的觀察,我們可以看出,當寫入操作發生在 company/code 文件時, 對應的修改並沒有反映到原始的目錄中。
而是在 home 目錄下又創建了一個名為 code 的文件,並將 apple 寫入了進去。
看起來很奇怪的現象,其實這正是 Union File System 的厲害之處:
Union File System 聯合了多個不同的目錄,並且把他們掛載到一個統一的目錄上。
在這些「聯合」的子目錄中, 有一部分是可讀可寫的,但是有一部分只是可讀的。
當你對可讀的目錄內容做出修改的時候,其結果只會保存到可寫的目錄下,不會影響只讀的目錄。
比如,我們可以把我們的服務的源代碼目錄和一個存放代碼修改記錄的目錄「聯合」起來構成一個 AUFS。前者設置只讀權限,后者設置讀寫權限。
那么,一切對源代碼目錄下文件的修改都只會影響那個存放修改的目錄,不會污染原始的代碼。
在 AUFS 中還有一個特殊的概念需要提及一下:
branch – 就是各個要被union起來的目錄。
Stack 結構 - AUFS 它會根據branch 被 Union 的順序形成一個 Stack 的結構,從下至上,最上面的目錄是可讀寫的,其余都是可讀的。如果按照我們剛剛執行 aufs 掛載的命令來說,最左側的目錄就對應 Stack 最頂層的 branch。
所以:下面的命令中,最為左側的為 home,而不是 company
mount -t aufs -o dirs=./home:./company none ./mnt
什么是 Docker 鏡像分層機制?
首先,讓我們來看下 Docker Image 中的 Layer 的概念:
Docker Image 是有一個層級結構的,最底層的 Layer 為 BaseImage(一般為一個操作系統的 ISO 鏡像),然后順序執行每一條指令,生成的 Layer 按照入棧的順序逐漸累加,最終形成一個 Image。
直觀的角度來說,是這個圖所示:
每一次都是一個被聯合的目錄,從目錄的角度來說,大致如下圖所示:
Docker Image 如何而來呢?
簡單來說,一個 Image 是通過一個 DockerFile 定義的,然后使用 docker build 命令構建它。
DockerFile 中的每一條命令的執行結果都會成為 Image 中的一個 Layer。
這里,我們通過 Build 一個鏡像,來觀察 Image 的分層機制:
Dockerfile:
# Use an official Python runtime as a parent image
FROM python:2.7-slim
# Set the working directory to /app
WORKDIR /app
# Copy the current directory contents into the container at /app
COPY . /app
# Install any needed packages specified in requirements.txt
RUN pip install --trusted-host pypi.python.org -r requirements.txt
# Make port 80 available to the world outside this container
EXPOSE 80
# Define environment variable
ENV NAME World
# Run app.py when the container launches
CMD ["python", "app.py"]
構建結果:
root@rds-k8s-18-svr0:~/xuran/exampleimage# docker build -t hello ./
Sending build context to Docker daemon 5.12 kB
Step 1/7 : FROM python:2.7-slim
---> 804b0a01ea83
Step 2/7 : WORKDIR /app
---> Using cache
---> 6d93c5b91703
Step 3/7 : COPY . /app
---> Using cache
---> feddc82d321b
Step 4/7 : RUN pip install --trusted-host pypi.python.org -r requirements.txt
---> Using cache
---> 94695df5e14d
Step 5/7 : EXPOSE 81
---> Using cache
---> 43c392d51dff
Step 6/7 : ENV NAME World
---> Using cache
---> 78c9a60237c8
Step 7/7 : CMD python app.py
---> Using cache
---> a5ccd4e1b15d
Successfully built a5ccd4e1b15d
通過構建結果可以看出,構建的過程就是執行 Dockerfile 文件中我們寫入的命令。構建一共進行了7個步驟,每個步驟進行完都會生成一個隨機的 ID,來標識這一 layer 中的內容。 最后一行的 a5ccd4e1b15d 為鏡像的 ID。由於我貼上來的構建過程已經是構建了第二次的結果了,所以可以看出,對於沒有任何修改的內容,Docker 會復用之前的結果。
如果 DockerFile 中的內容沒有變動,那么相應的鏡像在 build 的時候會復用之前的 layer,以便提升構建效率。並且,即使文件內容有修改,那也只會重新 build 修改的 layer,其他未修改的也仍然會復用。
通過了解了 Docker Image 的分層機制,我們多多少少能夠感覺到,Layer 和 Image 的關系與 AUFS 中的聯合目錄和掛載點的關系比較相似。
而 Docker 也正是通過 AUFS 來管理 Images 的。
Namespaces
在Linux系統中,Namespace是在內核級別以一種抽象的形式來封裝系統資源,通過將系統資源放在不同的Namespace中,來實現資源隔離的目的。不同的Namespace程序,可以享有一份獨立的系統資源。
命名空間(namespaces)是 Linux 為我們提供的用於分離進程樹、網絡接口、掛載點以及進程間通信等資源的方法。在日常使用 Linux 或者 macOS 時,我們並沒有運行多個完全分離的服務器的需要,但是如果我們在服務器上啟動了多個服務,這些服務其實會相互影響的,每一個服務都能看到其他服務的進程,也可以訪問宿主機器上的任意文件,這是很多時候我們都不願意看到的,我們更希望運行在同一台機器上的不同服務能做到完全隔離,就像運行在多台不同的機器上一樣。
Linux 的命名空間機制提供了以下七種不同的命名空間,包括 :
- CLONE_NEWCGROUP、
- CLONE_NEWIPC、
- CLONE_NEWNET、
- CLONE_NEWNS、
- CLONE_NEWPID、
- CLONE_NEWUSER 和
- CLONE_NEWUTS,
通過這七個選項, 我們能在創建新的進程時, 設置新進程應該在哪些資源上與宿主機器進行隔離。具體如下:
Namespace | Flag | Page | Isolates |
---|---|---|---|
Cgroup | CLONE_NEWCGROUP | cgroup_namespaces | Cgroup root directory |
IPC | CLONE_NEWIPC | ipc_namespaces | System V IPC,POSIX message queues 隔離進程間通信 |
Network | CLONE_NEWNET | network_namespaces | Network devices,stacks, ports, etc. 隔離網絡資源 |
Mount | CLONE_NEWNS | mount_namespaces | Mount points 隔離文件系統掛載點 |
PID | CLONE_NEWPID | pid_namespaces | Process IDs 隔離進程的ID |
Time | CLONE_NEWTIME | time_namespaces | Boot and monotonic clocks |
User | CLONE_NEWUSER | user_namespaces | User and group IDs 隔離用戶和用戶組的ID |
UTS | CLONE_NEWUTS | uts_namespaces | Hostname and NIS domain name 隔離主機名和域名信息 |
對於docker來說,最為直接的,是PID隔離
PID隔離
如果需要了解 PID的命名空間隔離,我們從基礎的 linux 進程的 fork函數開始,盡管,系統調用函數fork()並不屬於namespace的API。
當程序調用fork()函數時,系統會創建新的進程,為其分配資源,例如存儲數據和代碼的空間。然后把原來的進程的所有值都復制到新的進程中,只有少量數值與原來的進程值不同,相當於克隆了一個自己。那么程序的后續代碼邏輯要如何區分自己是新進程還是父進程呢?
fork()的神奇之處在於它僅僅被調用一次,卻能夠返回兩次(父進程與子進程各返回一次),通過返回值的不同就可以進行區分父進程與子進程。它可能有三種不同的返回值:
-
在父進程中,fork返回新創建子進程的進程ID
-
在子進程中,fork返回0
-
如果出現錯誤,fork返回一個負值
下面給出一段實例代碼,命名為fork_example.c。
int main (){
pid_t fpid; //fpid表示fork函數返回的值
int count=0;
fpid=fork();
if (fpid < 0)printf("error in fork!");
else if (fpid == 0) {
printf("I am child. Process id is %d/n",getpid());
}
else {
printf("i am parent. Process id is %d/n",getpid());
}
return 0;
}
編譯並執行,結果如下。
root@local:~# gcc -Wall fork_example.c && ./a.out
I am parent. Process id is 28365
I am child. Process id is 28366
使用fork()后,父進程有義務監控子進程的運行狀態,並在子進程退出后自己才能正常退出,否則子進程就會成為“孤兒”進程。
這里提出一個問題,在宿主機上啟動兩個容器,在這兩個容器內都各有一個 PID=1的進程,眾所周知,Linux 里 PID 是唯一的,既然 Docker 不是跑在宿主機上的兩個虛擬機,那么它是如何實現在宿主機上運行兩個相同 PID 的進程呢?
這里就用到了 PID Namespaces,它其實是 Linux 創建新進程時的一個可選參數,在 Linux 系統中創建進程的系統調用是 clone()方法。
int clone(int (*fn) (void *),void *child stack,
int flags, void *arg, . . .
/* pid_ t *ptid, void *newtls, pid_ t *ctid */ ) ;
通過調用這個方法,這個進程會獲得一個獨立的進程空間,它的 pid 是1,並且看不到宿主機上的其他進程,這也就是在容器內執行 PS 命令的結果。
下面我們通過運行代碼來感受一下PID namespace的隔離效果。下面的代碼,使用clone創建子進程,並且加入PID namespace的標識位CLONE_NEWPID,並把程序命名為pid.c。
...
#define _GNU_SOURCE
#define STACK_SIZE (1024 * 1024)
static char child_stack[STACK_SIZE];
char* const child_args[] = {
"/bin/bash",
NULL
};
int child_main(void* args) {
printf("在子進程中!\n");
execv(child_args[0], child_args);
return 1;
}
int main() {
printf("程序開始: \n");
int child_pid = clone(child_main, child_stack+STACK_SIZE,
CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS
| SIGCHLD, NULL);
waitpid(child_pid, NULL, 0);
printf("已退出\n");
return 0;
}
編譯運行可以看到如下結果。
root@local:~# gcc -Wall pid.c -o pid.o && ./pid.o
程序開始:
在子進程中!
此時的控制台的 命名空間,已經變了,變成了NewNamespace,並且可以輸入命令。
來一個簡單的,輸入 echo $$ 查看 shell id
root@NewNamespace:~# echo $$
1 <<-- 注意此處看到 shell 的 PID 變成了 1
退出 子進程
root@NewNamespace:~# exit
exit
已退出 <<-- 父進程的輸出來了
退出之后,再一次打印 $$ 可以看到 shell 的 PID,退出后如果再次執行可以看到效果如下。
root@local:~# echo $$
17542 <<-- 已經回到了正常狀態
已經回到了正常狀態。
可能有的讀者在子進程的shell中執行了ps aux/top之類的命令,發現還是可以看到所有父進程的PID,那是因為我們還沒有對文件系統進行隔離,ps/top之類的命令調用的是真實系統下的/proc文件內容,看到的自然是所有的進程。
此外,與其他的namespace不同的是,為了實現一個穩定安全的容器,PID namespace還需要進行一些額外的工作才能確保其中的進程運行順利。
詳解進程隔離
進程是 Linux 以及現在操作系統中非常重要的概念,它表示一個正在執行的程序,也是在現代分時系統中的一個任務單元。
在每一個 *nix 的操作系統上,我們都能夠通過 ps 命令打印出當前操作系統中正在執行的進程,比如在 Ubuntu 上,使用該命令就能得到以下的結果:
|$ ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 Apr08 ? 00:00:09 /sbin/init
root 2 0 0 Apr08 ? 00:00:00 [kthreadd]
root 3 2 0 Apr08 ? 00:00:05 [ksoftirqd/0]
root 5 2 0 Apr08 ? 00:00:00 [kworker/0:0H]
root 7 2 0 Apr08 ? 00:07:10 [rcu_sched]
root 39 2 0 Apr08 ? 00:00:00 [migration/0]
root 40 2 0 Apr08 ? 00:01:54 [watchdog/0]
當前機器上有很多的進程正在執行,在上述進程中有兩個非常特殊,一個是 pid 為 1 的 /sbin/init 進程,另一個是 pid 為 2 的 kthreadd 進程,這兩個進程都是被 Linux 中的上帝進程 idle 創建出來的,其中前者負責執行內核的一部分初始化工作和系統配置,也會創建一些類似 getty 的注冊進程,而后者負責管理和調度其他的內核進程。
如果我們在當前的 Linux 操作系統下運行一個新的 Docker 容器,並通過 exec 進入其內部的 bash 並打印其中的全部進程,我們會得到以下的結果:
UID PID PPID C STIME TTY TIME CMD
root 29407 1 0 Nov16 ? 00:08:38 /usr/bin/dockerd --raw-logs
root 1554 29407 0 Nov19 ? 00:03:28 docker-containerd -l unix:///var/run/docker/libcontainerd/docker-containerd.sock --metrics-interval=0 --start-timeout 2m --state-dir /var/run/docker/libcontainerd/containerd --shim docker-containerd-shim --runtime docker-runc
root 5006 1554 0 08:38 ? 00:00:00 docker-containerd-shim b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79 /var/run/docker/libcontainerd/b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79 docker-runc
在新的容器內部執行 ps 命令打印出了非常干凈的進程列表,只有包含當前 ps -ef 在內的三個進程,在宿主機器上的幾十個進程都已經消失不見了。
當前的 Docker 容器成功將容器內的進程與宿主機器中的進程隔離,如果我們在宿主機器上打印當前的全部進程時,會得到下面三條與 Docker 相關的結果:
在當前的宿主機器上,可能就存在由上述的不同進程構成的進程樹:
實際上,docker容器的pid隔離,就是在使用 clone(2) 創建新進程時傳入 CLONE_NEWPID 實現的,也就是使用 Linux 的命名空間實現進程的隔離,Docker 容器內部的任意進程都對宿主機器的進程一無所知。
containerRouter.postContainersStart
└── daemon.ContainerStart
└── daemon.createSpec
└── setNamespaces
└── setNamespace
Docker 的容器就是使用上述技術實現與宿主機器的進程隔離,當我們每次運行 docker run 或者 docker start 時,都會在下面的方法中創建一個用於設置進程間隔離的 Spec:
func (daemon *Daemon) createSpec(c *container.Container) (*specs.Spec, error) {
s := oci.DefaultSpec()
// ...
if err := setNamespaces(daemon, &s, c); err != nil {
return nil, fmt.Errorf("linux spec namespaces: %v", err)
}
return &s, nil
}
在 setNamespaces 方法中不僅會設置進程相關的命名空間,還會設置與用戶、網絡、IPC 以及 UTS 相關的命名空間:
func setNamespaces(daemon *Daemon, s *specs.Spec, c *container.Container) error {
// user
// network
// ipc
// uts
// pid
if c.HostConfig.PidMode.IsContainer() {
ns := specs.LinuxNamespace{Type: "pid"}
pc, err := daemon.getPidContainer(c)
if err != nil {
return err
}
ns.Path = fmt.Sprintf("/proc/%d/ns/pid", pc.State.GetPID())
setNamespace(s, ns)
} else if c.HostConfig.PidMode.IsHost() {
oci.RemoveNamespace(s, specs.LinuxNamespaceType("pid"))
} else {
ns := specs.LinuxNamespace{Type: "pid"}
setNamespace(s, ns)
}
return nil
}
所有命名空間相關的設置 Spec 最后都會作為 Create 函數的入參在創建新的容器時進行設置:
daemon.containerd.Create(context.Background(), container.ID, spec, createOptions)
所有與命名空間的相關的設置都是在上述的兩個函數中完成的,Docker 通過命名空間成功完成了與宿主機進程和網絡的隔。
PID namespace隔離非常實用
PID namespace隔離非常實用,它對進程PID重新標號,即兩個不同namespace下的進程可以有同一個PID。
每個PID namespace都有自己的計數程序。內核為所有的PID namespace維護了一個樹狀結構,最頂層的是系統初始時創建的,我們稱之為root namespace。他創建的新PID namespace就稱之為child namespace(樹的子節點),而原先的PID namespace就是新創建的PID namespace的parent namespace(樹的父節點)。
通過這種方式,不同的PID namespaces會形成一個等級體系。所屬的父節點可以看到子節點中的進程,並可以通過信號等方式對子節點中的進程產生影響。反過來,子節點不能看到父節點PID namespace中的任何內容。由此產生如下結論
-
每個PID namespace中的第一個進程“PID 1“,都會像傳統Linux中的init進程一樣擁有特權,起特殊作用。
-
一個namespace中的進程,不可能通過kill或ptrace影響父節點或者兄弟節點中的進程,因為其他節點的PID在這個namespace中沒有任何意義。
-
如果你在新的PID namespace中重新掛載/proc文件系統,會發現其下只顯示同屬一個PID namespace中的其他進程。
-
在root namespace中可以看到所有的進程,並且遞歸包含所有子節點中的進程。
到這里,可能你已經聯想到一種在外部監控Docker中運行程序的方法了,就是監控Docker Daemon所在的PID namespace下的所有進程即其子進程,再進行刪選即可。
其他的操作系統基礎組件隔離
不僅僅是 PID,當你啟動啟動容器之后,Docker 會為這個容器創建一系列其他 namespaces。
這些 namespaces 提供了不同層面的隔離。容器的運行受到各個層面 namespace 的限制。
Docker Engine 使用了以下 Linux 的隔離技術:
The pid namespace: 管理 PID 命名空間 (PID: Process ID).
The net namespace: 管理網絡命名空間(NET: Networking).
The ipc namespace: 管理進程間通信命名空間(IPC: InterProcess Communication).
The mnt namespace: 管理文件系統掛載點命名空間 (MNT: Mount).
The uts namespace: Unix 時間系統隔離. (UTS: Unix Timesharing System).
通過這些技術,運行時的容器得以看到一個和宿主機上其他容器隔離的環境。
網絡隔離
如果 Docker 的容器通過 Linux 的命名空間完成了與宿主機進程的網絡隔離,但是卻有沒有辦法通過宿主機的網絡與整個互聯網相連,就會產生很多限制。
所以 Docker 雖然可以通過命名空間創建一個隔離的網絡環境,但是 Docker 中的服務仍然需要與外界相連才能發揮作用。
每一個使用 docker run 啟動的容器其實都具有單獨的網絡命名空間,Docker 為我們提供了四種不同的網絡模式,Host、Container、None 和 Bridge 模式。
在這一部分,我們將介紹 Docker 默認的網絡設置模式:網橋模式。
在這種模式下,除了分配隔離的網絡命名空間之外,Docker 還會為所有的容器設置 IP 地址。
當 Docker 服務器在主機上啟動之后會創建新的虛擬網橋 docker0,隨后在該主機上啟動的全部服務在默認情況下都與該網橋相連。
在默認情況下,每一個容器在創建時都會創建一對虛擬網卡,兩個虛擬網卡組成了數據的通道,其中一個會放在創建的容器中,會加入到名為 docker0 網橋中。
我們可以使用如下的命令來查看當前網橋的接口:
$ brctl show
bridge name bridge id STP enabled interfaces
docker0 8000.0242a6654980 no veth3e84d4f
veth9953b75
docker0會為每一個容器分配一個新的 IP 地址並將 docker0 的 IP 地址設置為默認的網關。
網橋 docker0 通過 iptables 中的配置與宿主機器上的網卡相連,所有符合條件的請求都會通過 iptables 轉發到 docker0 並由網橋分發給對應的機器。
$ iptables -t nat -L
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DOCKER all -- anywhere anywhere ADDRTYPE match dst-type LOCAL
Chain DOCKER (2 references)
target prot opt source destination
RETURN all -- anywhere anywhere
我們在當前的機器上使用 docker run -d -p 6379:6379 redis 命令啟動了一個新的 Redis 容器,在這之后我們再查看當前 iptables 的 NAT 配置就會看到在 DOCKER 的鏈中出現了一條新的規則:
DNAT tcp -- anywhere anywhere tcp dpt:6379 to:192.168.0.4:6379
上述規則會將從任意源發送到當前機器 6379 端口的 TCP 包轉發到 192.168.0.4:6379 所在的地址上。
這個地址其實也是 Docker 為 Redis 服務分配的 IP 地址,如果我們在當前機器上直接 ping 這個 IP 地址就會發現它是可以訪問到的:
$ ping 192.168.0.4
PING 192.168.0.4 (192.168.0.4) 56(84) bytes of data.
64 bytes from 192.168.0.4: icmp_seq=1 ttl=64 time=0.069 ms
64 bytes from 192.168.0.4: icmp_seq=2 ttl=64 time=0.043 ms
^C
--- 192.168.0.4 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 999ms
rtt min/avg/max/mdev = 0.043/0.056/0.069/0.013 ms
從上述的一系列現象,我們就可以推測出 Docker 是如何將容器的內部的端口暴露出來並對數據包進行轉發的了;當有 Docker 的容器需要將服務暴露給宿主機器,就會為容器分配一個 IP 地址,同時向 iptables 中追加一條新的規則。
當我們使用 redis-cli 在宿主機器的命令行中訪問 127.0.0.1:6379 的地址時,經過 iptables 的 NAT PREROUTING 將 ip 地址定向到了 192.168.0.4,重定向過的數據包就可以通過 iptables 中的 FILTER 配置,最終在 NAT POSTROUTING 階段將 ip 地址偽裝成 127.0.0.1,到這里雖然從外面看起來我們請求的是 127.0.0.1:6379,但是實際上請求的已經是 Docker 容器暴露出的端口了。
$ redis-cli -h 127.0.0.1 -p 6379 ping
PONG
Docker 通過 Linux 的命名空間實現了網絡的隔離,又通過 iptables 進行數據包轉發,讓 Docker 容器能夠優雅地為宿主機器或者其他容器提供服務。
Libnetwork
整個網絡部分的功能都是通過 Docker 拆分出來的 libnetwork 實現的,它提供了一個連接不同容器的實現,同時也能夠為應用給出一個能夠提供一致的編程接口和網絡層抽象的容器網絡模型。
The goal of libnetwork is to deliver a robust Container Network Model that provides a consistent programming interface and the required network abstractions for applications.
libnetwork 中最重要的概念,容器網絡模型由以下的幾個主要組件組成,分別是 Sandbox、Endpoint 和 Network:
在容器網絡模型中,每一個容器內部都包含一個 Sandbox,其中存儲着當前容器的網絡棧配置,包括容器的接口、路由表和 DNS 設置,Linux 使用網絡命名空間實現這個 Sandbox,每一個 Sandbox 中都可能會有一個或多個 Endpoint,在 Linux 上就是一個虛擬的網卡 veth,Sandbox 通過 Endpoint 加入到對應的網絡中,這里的網絡可能就是我們在上面提到的 Linux 網橋或者 VLAN。
掛載點
雖然我們已經通過 Linux 的命名空間解決了進程和網絡隔離的問題,在 Docker 進程中我們已經沒有辦法訪問宿主機器上的其他進程並且限制了網絡的訪問,但是 Docker 容器中的進程仍然能夠訪問或者修改宿主機器上的其他目錄,這是我們不希望看到的。
在新的進程中創建隔離的掛載點命名空間需要在 clone 函數中傳入 CLONE_NEWNS,這樣子進程就能得到父進程掛載點的拷貝,如果不傳入這個參數子進程對文件系統的讀寫都會同步回父進程以及整個主機的文件系統。
如果一個容器需要啟動,那么它一定需要提供一個根文件系統(rootfs),容器需要使用這個文件系統來創建一個新的進程,所有二進制的執行都必須在這個根文件系統中。
想要正常啟動一個容器就需要在 rootfs 中掛載以上的幾個特定的目錄,除了上述的幾個目錄需要掛載之外我們還需要建立一些符號鏈接保證系統 IO 不會出現問題。
為了保證當前的容器進程沒有辦法訪問宿主機器上其他目錄,我們在這里還需要通過 libcotainer 提供的 pivor_root 或者 chroot 函數改變進程能夠訪問個文件目錄的根節點。
// pivor_root
put_old = mkdir(...);
pivot_root(rootfs, put_old);
chdir("/");
unmount(put_old, MS_DETACH);
rmdir(put_old);
// chroot
mount(rootfs, "/", NULL, MS_MOVE, NULL);
chroot(".");
chdir("/");
到這里我們就將容器需要的目錄掛載到了容器中,同時也禁止當前的容器進程訪問宿主機器上的其他目錄,保證了不同文件系統的隔離。
Chroot
在這里不得不簡單介紹一下 chroot(change root),在 Linux 系統中,系統默認的目錄就都是以 / 也就是根目錄開頭的,chroot 的使用能夠改變當前的系統根目錄結構,通過改變當前系統的根目錄,我們能夠限制用戶的權利,在新的根目錄下並不能夠訪問舊系統根目錄的結構個文件,也就建立了一個與原系統完全隔離的目錄結構。
CGroups物理資源限制分組
我們通過 Linux 的命名空間為新創建的進程隔離了文件系統、網絡並與宿主機器之間的進程相互隔離,但是命名空間並不能夠為我們提供物理資源上的隔離,比如 CPU 或者內存,如果在同一台機器上運行了多個對彼此以及宿主機器一無所知的『容器』,這些容器卻共同占用了宿主機器的物理資源。
如果其中的某一個容器正在執行 CPU 密集型的任務,那么就會影響其他容器中任務的性能與執行效率,導致多個容器相互影響並且搶占資源。如何對多個容器的資源使用進行限制就成了解決進程虛擬資源隔離之后的主要問題,而 Control Groups(簡稱 CGroups)就是能夠隔離宿主機器上的物理資源,例如 CPU、內存、磁盤 I/O 和網絡帶寬。
每一個 CGroup 都是一組被相同的標准和參數限制的進程,不同的 CGroup 之間是有層級關系的,也就是說它們之間可以從父類繼承一些用於限制資源使用的標准和參數。
Linux 的 CGroup 能夠為一組進程分配資源,也就是我們在上面提到的 CPU、內存、網絡帶寬等資源,通過對資源的分配。
Linux 使用文件系統來實現 CGroup,我們可以直接使用下面的命令查看當前的 CGroup 中有哪些子系統:
$ lssubsys -m
cpuset /sys/fs/cgroup/cpuset
cpu /sys/fs/cgroup/cpu
cpuacct /sys/fs/cgroup/cpuacct
memory /sys/fs/cgroup/memory
devices /sys/fs/cgroup/devices
freezer /sys/fs/cgroup/freezer
blkio /sys/fs/cgroup/blkio
perf_event /sys/fs/cgroup/perf_event
hugetlb /sys/fs/cgroup/hugetlb
大多數 Linux 的發行版都有着非常相似的子系統,而之所以將上面的 cpuset、cpu 等東西稱作子系統,是因為它們能夠為對應的控制組分配資源並限制資源的使用。
如果我們想要創建一個新的 cgroup 只需要在想要分配或者限制資源的子系統下面創建一個新的文件夾,然后這個文件夾下就會自動出現很多的內容,如果你在 Linux 上安裝了 Docker,你就會發現所有子系統的目錄下都有一個名為 Docker 的文件夾:
$ ls cpu
cgroup.clone_children
...
cpu.stat
docker
notify_on_release
release_agent
tasks
$ ls cpu/docker/
9c3057f1291b53fd54a3d12023d2644efe6a7db6ddf330436ae73ac92d401cf1
cgroup.clone_children
...
cpu.stat
notify_on_release
release_agent
tasks
9c3057xxx 其實就是我們運行的一個 Docker 容器,啟動這個容器時,Docker 會為這個容器創建一個與容器標識符相同的 CGroup,在當前的主機上 CGroup 就會有以下的層級關系:
每一個 CGroup 下面都有一個 tasks 文件,其中存儲着屬於當前控制組的所有進程的 pid,作為負責 cpu 的子系統,cpu.cfs_quota_us 文件中的內容能夠對 CPU 的使用作出限制,如果當前文件的內容為 50000,那么當前控制組中的全部進程的 CPU 占用率不能超過 50%。
如果系統管理員想要控制 Docker 某個容器的資源使用率就可以在 docker 這個父控制組下面找到對應的子控制組並且改變它們對應文件的內容,當然我們也可以直接在程序運行時就使用參數,讓 Docker 進程去改變相應文件中的內容。
當我們使用 Docker 關閉掉正在運行的容器時,Docker 的子控制組對應的文件夾也會被 Docker 進程移除,Docker 在使用 CGroup 時其實也只是做了一些創建文件夾改變文件內容的文件操作,不過 CGroup 的使用也確實解決了我們限制子容器資源占用的問題,系統管理員能夠為多個容器合理的分配資源並且不會出現多個容器互相搶占資源的問題。
linux namespace的API
接下來,介紹一下如何使用namespace的API,本文所討論的namespace實現針對的均是Linux內核3.8及其以后的版本。
namespace的API包括clone()、setns()以及unshare(),還有/proc下的部分文件。為了確定隔離的到底是哪種namespace,在使用這些API時,通常需要指定以下六個常數的一個或多個,通過|(位或)操作來實現。你可能已經在上面的表格中注意到,這六個參數分別是CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEWUSER和CLONE_NEWUTS。
通過clone()創建新進程的同時創建namespace
使用clone()來創建一個獨立namespace的進程是最常見做法,它的調用方式如下。
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
clone()實際上是傳統UNIX系統調用fork()的一種更通用的實現方式,它可以通過flags來控制使用多少功能。一共有二十多種CLONE_*的flag(標志位)參數用來控制clone進程的方方面面(如是否與父進程共享虛擬內存等等),下面外面逐一講解clone函數傳入的參數。
-
參數child_func傳入子進程運行的程序主函數
-
參數child_stack傳入子進程使用的棧空間
-
參數flags表示使用哪些CLONE_*標志位
-
參數args則可用於傳入用戶參數
查看/proc/[pid]/ns文件
從3.8版本的內核開始,用戶就可以在/proc/[pid]/ns文件下看到指向不同namespace號的文件,效果如下所示,形如[4026531839]者即為namespace號。
$ ls -l /proc/$$/ns <<-- 0="" 1="" 8="" $$="" 表示應用的pid="" total="" lrwxrwxrwx.="" mtk="" jan="" 04:12="" ipc="" -=""> ipc:[4026531839]
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 mnt -> mnt:[4026531840]
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 net -> net:[4026531956]
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 pid -> pid:[4026531836]
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 user->user:[4026531837]
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 uts -> uts:[4026531838]
如果兩個進程指向的namespace編號相同,就說明他們在同一個namespace下,否則則在不同namespace里面。/proc/[pid]/ns的另外一個作用是,一旦文件被打開,只要打開的文件描述符(fd)存在,那么就算PID所屬的所有進程都已經結束,創建的namespace就會一直存在。那如何打開文件描述符呢?把/proc/[pid]/ns目錄掛載起來就可以達到這個效果,命令如下。
# touch ~/uts
# mount --bind /proc/27514/ns/uts ~/uts
如果你看到的內容與本文所描述的不符,那么說明你使用的內核在3.8版本以前。該目錄下存在的只有ipc、net和uts,並且以硬鏈接存在。
通過setns()加入一個已經存在的namespace
上文剛提到,在進程都結束的情況下,也可以通過掛載的形式把namespace保留下來,保留namespace的目的自然是為以后有進程加入做准備。通過setns()系統調用,你的進程從原先的namespace加入我們准備好的新namespace,使用方法如下。
int setns(int fd, int nstype);
-
參數fd表示我們要加入的namespace的文件描述符。上文已經提到,它是一個指向/proc/[pid]/ns目錄的文件描述符,可以通過直接打開該目錄下的鏈接或者打開一個掛載了該目錄下鏈接的文件得到。
-
參數nstype讓調用者可以去檢查fd指向的namespace類型是否符合我們實際的要求。如果填0表示不檢查。
為了把我們創建的namespace利用起來,我們需要引入execve()系列函數,這個函數可以執行用戶命令,最常用的就是調用/bin/bash並接受參數,運行起一個shell,用法如下。
fd = open(argv[1], O_RDONLY); /* 獲取namespace文件描述符 */
setns(fd, 0); /* 加入新的namespace */
execvp(argv[2], &argv[2]); /* 執行程序 */
假設編譯后的程序名稱為setns。
# ./setns ~/uts /bin/bash # ~/uts 是綁定的/proc/27514/ns/uts
至此,你就可以在新的命名空間中執行shell命令了,在下文中會多次使用這種方式來演示隔離的效果。
通過unshare()在原先進程上進行namespace隔離
最后要提的系統調用是unshare(),它跟clone()很像,不同的是,unshare()運行在原先的進程上,不需要啟動一個新進程,使用方法如下。
int unshare(int flags);
調用unshare()的主要作用就是不啟動一個新進程就可以起到隔離的效果,相當於跳出原先的namespace進行操作。這樣,你就可以在原進程進行一些需要隔離的操作。Linux中自帶的unshare命令,就是通過unshare()系統調用實現的,有興趣的讀者可以在網上搜索一下這個命令的作用。
總之:dockers=LXC+AUFS
docker為LXC+AUFS組合:
- LXC負責資源管理
- AUFS負責鏡像管理;
而LXC包括cgroup,namespace,chroot等組件,並通過cgroup資源管理,那么,從資源管理的角度來看,Docker,Lxc,Cgroup三者的關系是怎樣的呢?
cgroup是在底層落實資源管理,LXC在cgroup上面封裝了一層,隨后,docker有在LXC封裝了一層;
Cgroup其實就是linux提供的一種限制,記錄,隔離進程組所使用的物理資源管理機制;也就是說,Cgroup是LXC為實現虛擬化所使用資源管理手段,我們可以這樣說,底層沒有cgroup支持,也就沒有lxc,更別說docker的存在了,這是我們需要掌握和理解的,三者之間的關系概念
我們在把重心轉移到LXC這個相當於中間件上,上述我們提到LXC是建立在cgroup基礎上的,我們可以粗略的認為LXC=Cgroup+namespace+Chroot+veth+用戶控制腳本;LXC利用內核的新特性(cgroup)來提供用戶空間的對象,用來保證資源的隔離和對應用系統資源的限制;
Docker容器的文件系統最早是建立在Aufs基礎上的,Aufs是一種Union FS,簡單來說就是支持將不同的目錄掛載到同一個虛擬文件系統之下
並實現一種laver的概念,
由於Aufs未能加入到linux內核中,考慮到兼容性的問題,便加入了Devicemapper的支持,Docker目前默認是建立在Devicemapper基礎上,
devicemapper用戶控件相關部分主要負責配置具體的策略和控制邏輯,比如邏輯設備和哪些物理設備建立映射,怎么建立這些映射關系等,而具體過濾和重定向IO請求的工作有內核中相關代碼完成,因此整個device mapper機制由兩部分組成--內核空間的device mapper驅動,用戶控件的device mapper庫以及它提供的dmsetup工具;
參考資料
https://docs.docker.com/storage/storagedriver/aufs-driver/#how-the-aufs-storage-driver-works
https://github.com/opencontainers/runc
http://www.sel.zju.edu.cn/?p=840
https://draveness.me/docker/
https://blog.csdn.net/wangqingjiewa/article/details/85000393
https://zhuanlan.zhihu.com/p/47683490
https://www.cnblogs.com/sally-zhou/p/13398260.html
https://blog.csdn.net/weixin_37098404/article/details/102704159
https://blog.51cto.com/u_15127672/2805023