1. Docker底層技術支撐
Linux 命令空間、控制組和UnionFS三大技術支撐了目前Docker的實現:
- namespace命名空間:容器隔離的基礎,保證A容器看不到B容器
- cgroups控制組:容器資源統計和隔離
- UnionFS聯合文件系統:分層鏡像實現的基礎
實際上Docker使用了很多Linux的隔離功能,讓容器看起來是一個輕量級的虛擬機在獨立運行,容器的本質就是被限制了namespace和cgroup,具有邏輯上獨立的獨立文件系統的一個進程
2. namespce
在Linux系統中,namespace是在內核級別以一種抽象的形式來封裝系統資源,通過將系統資源放在不同的namespace中,來實現資源隔離的目的
不同的namespace程序,都可以擁有一份獨立的系統資源
namespace是linux為我們提供的用於分離進程樹、網絡接口、掛載點以及進程間通信等資源的方法
Linux的namespace機制提供了以下七種不同的命名空間,包括:
- CLONE_NEWCGROUP
- CLONE_NEWIPC:隔離進程間通信
- CLONE_NEWNET:隔離網絡資源
- CLONE_NEWNS:隔離文件系統掛載點
- CLONE_NEWPID:隔離進程PID
- CLONE_NEWUSER
- CLONE_NEWUTS:隔離主機名和域名信息
docker使用的是PID隔離
2.1 PID隔離
如果現在在宿主機上啟動兩個容器,在這兩個容器內各自都有一個PID=1的進程,但是眾所周知,PID在linux中是唯一的,那么兩個容器是怎么做到同時擁有PID=1的不同進程的?
本來,每當我們在宿主機上運行一個/bin/sh程序,操作系統就會分配給他一個PID,這個PID是進程的唯一標識,而PID=1的進程是屬於 /sbin/init 的
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 Mar21 ? 00:00:03 /sbin/init noibrs splash
root 2 0 0 Mar21 ? 00:00:00 [kthreadd]
root 4 2 0 Mar21 ? 00:00:00 [kworker /0 :0H]
root 6 2 0 Mar21 ? 00:00:00 [mm_percpu_wq]
root 7 2 0 Mar21 ? 00:00:11 [ksoftirqd /0 ]
什么是/sbin/init?這個進程是被linux中的上帝進程 idle 創建出來的,主要負責執行內核的一部分初始化工作和系統配置,也會創建一些類似於 getty 的注冊進程
現在我們通過docker在容器運行 /bin/sh 就會發現PID=1的進程其實就是我們創建的這個進程,而不再是宿主機上那個 /sbin/init
UID PID PPID C STIME TTY TIME CMD
mysql 1 0 0 Mar21 ? 00:10:24 mysqld
root 86 0 0 09:14 pts /0 00:00:00 /bin/bash
root 429 86 0 10:15 pts /0 00:00:00 ps -ef
這種技術就是linux的 PID namespace隔離
namespace的使用就是linux在創建進程的一個可選參數
我們知道,在linux中創建進程的系統調用是clone()方法:
int pid = clone(main_function, stack_size, SIGCHLD, NULL)
這個系統調用會為我們創建個新的進程,並返回它的PID
當我們使用clone()系統調用創建一個新進程時,就可以在參數中指定 CLONE_NEWPID 參數
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL)
此時,新創建的這個進程就是一個隔離的進程,它看不到宿主機上的任何進程
實際上,docker容器的pid隔離,就是在使用clone()創建新進程時傳入CLONE_NEWPID來實現的,也就是使用linux的命名空間來實現進程的隔離,docker容器內部的任意進程都對宿主機的進程一無所知
每次當我們運行 docker run 時,都會在下面的方法中創建一個用於設置進程間隔離的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)
PID namespace隔離非常實用,它對進程PID重新標號,即兩個不同namespace下的進程可以有同一個PID
每個PID namespace都有自己的計數程序。內核為所有的PID namespace維護了一個樹狀結構,最頂層的是系統初始時創建的,我們稱之為root namespace
他創建的新PID namespace就稱之為child namespace(樹的子節點),而原先的PID namespace就是新創建的PID namespace的parent namespace(樹的父節點)
通過這種方式,不同的PID namespace會形成一個等級體系,所屬的父節點可以看到子節點中的進程,並可以通過信號燈等方式對子節點中的進程產生影響
但是子節點不能看到父節點PID namespace 中的任何內容
- 每個PID namespace 中的第一個進程 PID=1,就會像傳統linux進程中的init一樣,起特殊作用
- 一個namespace中的進程,不可能通過 kill 或者 ptrace影響父節點或者兄弟節點中的進程
- 如果在新的PID namespace中重新掛載/proc文件系統,會發現其下只顯示同屬一個PID namespace中的其他進程
- 在root namespace中可以看到所有的進程,並且遞歸包含所有子節點中的進程
2.2 其它的操作系統基礎組件隔離
不僅僅是PID,當啟動容器之后,docker會為這個容器創建一系列其他namespaces
這些 namespaces 提供了不同層面的隔離,容器運行會受到各個層面 namesapce 的限制
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)
通過這些技術,運行時的容器得以看到一個和宿主機上其他容器隔離的環境
3. cgroups
cgroups是linux內核中用來為進城設置資源閑置的一個重要功能
cgroups最主要的功能就是限制一個進程組能夠使用的資源上限,包括CPU、內存、磁盤、網絡帶寬等
此外,cgroups還能對進程進行優先級設置、審計,以及將進程掛起和恢復等操作
linux使用文件系統來實現cgroups,我們可以直接使用命令來查看當前的cgroup有哪些子系統:
root@root:~ $ mount -t cgroup
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,name=systemd)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/net_cls ,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/cpu ,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
可以看到,在/sys/fs/cgroup下面有很多諸如cpuset、cpu、memory這樣的子目錄,這些就是可以被cgroups限制的資源種類
而在子目錄對應的資源種類下,可以看到這類資源具體可以被限制的方法,例如CPU:
root@root:~ $ ls /sys/fs/cgroup/cpu
aegis cgroup.sane_behavior cpuacct.usage_percpu cpuacct.usage_user cpu.stat system.slice
assist cpuacct.stat cpuacct.usage_percpu_sys cpu.cfs_period_us docker tasks
cgroup.clone_children cpuacct.usage cpuacct.usage_percpu_user cpu.cfs_quota_us notify_on_release user.slice
cgroup.procs cpuacct.usage_all cpuacct.usage_sys cpu.shares release_agent
我們可以看到其中有一個docker文件夾,cd到docker文件下
其中四個帶有序號的文件夾其實就是我們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 的使用也確實解決了我們限制子容器資源占用的問題,系統管理員能夠為多個容器合理的分配資源並且不會出現多個容器互相搶占資源的問題
除了CPU子系統外,cgroups的每一項子系統都有其獨有的資源限制能力:
- blkio:為塊設備設定I/O限制,一般用於磁盤等設備
- cpuset:為進程分配單獨的CPU核和對應的內存節點
- memory:為進程設定內存使用限制
linux cgroups的設計簡單而言,就是一個子系統目錄上加上一組資源限制文件的組合。而對於docker等linux容器項目來說,它們只需要在每個子系統下面為每個容器創建一個控制組,然后在啟動容器進程之后,把這個進程的PID填寫到對應控制組的tasks文件中即可。
至於在這些控制組下面的資源文件里填什么值,就是用戶執行docker run 時指定的參數,例如這樣一條命令:
$ docker run -it --cpu-period=100000 --cpu- quota =20000 ubuntu /bin/bash
在啟動這個容器后,就可以通過查看其資源文件的內容來確認具體的資源限制,這意味着這個docker容器只能使用20%的cpu帶寬
4. UnionFS
UnionFS其實是一種為linux操作系統設計的用於把多個文件系統聯合到同一個掛載點的文件系統服務
首先,我們建立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聯合了多個不同的目錄,並且把他們掛載到一個統一的目錄上
- 在這些聯合的子目錄中,有一些是讀寫的,但有一部分是只讀的
- 當對只讀的目錄內容做出修改時,其結果只會保存在可寫的目錄下,不會影響只讀目錄
這就是docker鏡像分層技術的基礎
4.1 docker鏡像分層
docker image有一個層級結構,最底層的layer為 baseimage(一般為一個操作系統的ISO鏡像),然后順序執行每一條指令,生成的layer按照入棧的順序逐漸累加,形成一個image
每一層都是一個被聯合的目錄,大致如下圖所示:
4.2 Dockerfile
簡單來說,一個image是通過一個dockerfile來定義的,然后使用docker build命令構建它
dockerfile中的每一條指令的執行結果都會成為image中的一個layer
簡單看一個dockerfile的內容,觀察image分層機制:
# 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 Image 的分層機制,可以看出Layer 和 Image 的關系與 AUFS 中的聯合目錄和掛載點的關系比較相似
參考:
Docker底層原理(圖解+秒懂+史上最全) - 瘋狂創客圈 - 博客園 (cnblogs.com)
https://blog.csdn.net/wangqingjiewa/article/details/85000393
https://zhuanlan.zhihu.com/p/47683490
https://blog.csdn.net/weixin_37098404/article/details/102704159
《深入剖析Kubernetes》 張磊