100 行 shell 寫個 Docker

作者:vivo 互聯網運維團隊 - Hou Dengfeng

本文主要介紹使用 shell 實現一個簡易的 Docker。

一、目的

在初接觸 Docker 的時候,我們必須要了解的幾個概念就是 Cgroup、Namespace、RootFs,如果本身對虛擬化的發展沒有深入的瞭解,那麼很難對這幾個概念有深入的理解,本文的目的就是通過在操作系統中以交互式的方式去理解,Cgroup/Namespace/Rootfs 到底實現了什麼,能做到哪些事情,然後通過 shell 這種直觀的命令行方式把我們的理解組合起來,去模仿 Docker 實現一個縮減的版本。

二、技術拆解

2.1 Namespace

2.1.1 簡介

Linux Namespace 是 Linux 提供的一種內核級別環境隔離的方法。學習過 Linux 的同學應該對 chroot 命令比較熟悉(通過修改根目錄把用戶限制在一個特定目錄下),chroot 提供了一種簡單的隔離模式:chroot 內部的文件系統無法訪問外部的內容。Linux Namespace 在此基礎上,提供了對 UTS、IPC、mount、PID、network、User 等的隔離機制。Namespace 是對全局系統資源的一種封裝隔離,使得處於不同 namespace 的進程擁有獨立的全局系統資源,改變一個 namespace 中的系統資源只會影響當前 namespace 裏的進程,對其他 namespace 中的進程沒有影響。

Linux Namespace 有如下種類:

圖片

2.1.2 Namespace 相關係統調用

amespace 相關的系統調用有 3 個,分別是 clone(),setns(),unshare()。

2.1.3 查看進程所屬 Namespace

上面的概念都比較抽象,我們來看看在 Linux 系統中怎麼樣去 get namespace。

系統中的每個進程都有 / proc/[pid]/ns / 這樣一個目錄,裏面包含了這個進程所屬 namespace 的信息,裏面每個文件的描述符都可以用來作爲 setns 函數 (2.1.2) 的 fd 參數。

#查看當前bash進程關聯的Namespace
# ls -l /proc/$$/ns
total 0
lrwxrwxrwx 1 root root 0 Jan 17 21:43 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 Jan 17 21:43 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 Jan 17 21:43 net -> net:[4026531956]
lrwxrwxrwx 1 root root 0 Jan 17 21:43 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Jan 17 21:43 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Jan 17 21:43 uts -> uts:[4026531838]
#這些 namespace 文件都是鏈接文件。鏈接文件的內容的格式爲 xxx:[inode number]。
    其中的 xxx 爲 namespace 的類型,inode number 則用來標識一個 namespace,我們也可以把它理解爲 namespace 的 ID。
    如果兩個進程的某個 namespace 文件指向同一個鏈接文件,說明其相關資源在同一個 namespace 中。以ipc:[4026531839]例,
    ipc是namespace的類型,4026531839是inode number,如果兩個進程的ipc namespace的inode number一樣,說明他們屬於同一個namespace。
    這條規則對其他類型的namespace也同樣適用。
#從上面的輸出可以看出,對於每種類型的namespace,進程都會與一個namespace ID關聯。
#當一個namespace中的所有進程都退出時,該namespace將會被銷燬。在 /proc/[pid]/ns 裏放置這些鏈接文件的作用就是,一旦這些鏈接文件被打開,
    只要打開的文件描述符(fd)存在,那麼就算該 namespace 下的所有進程都結束了,但這個 namespace 也會一直存在,後續的進程還可以再加入進來。

2.1.4 相關命令及操作示例

本節會用 UTS/IPC/NET 3 個 Namespace 作爲示例演示如何在 linux 系統中創建 Namespace,並介紹相關命令。

2.1.4.1 IPC Namespace

IPC namespace 用來隔離 System V IPC objects 和 POSIX message queues。其中 System V IPC objects 包含消息列表 Message queues、信號量 Semaphore sets 和共享內存 Shared memory segments。爲了展現區分 IPC Namespace 我們這裏會使用到 ipc 相關命令:

#    nsenter: 加入指定進程的指定類型的namespace中,然後執行參數中指定的命令。
#       命令格式:nsenter [options] [program [arguments]]
#       示例:nsenter –t 27668 –u –I /bin/bash
#
#    unshare: 離開當前指定類型的namespace,創建且加入新的namesapce,然後執行參數中執行的命令。
#       命令格式:unshare [options] program [arguments]
#       示例:unshare --fork --pid --mount-proc readlink /proc/self
#
#    ipcmk:創建shared memory segments, message queues, 和semaphore arrays
#       參數-Q:創建message queues
#    ipcs:查看shared memory segments, message queues, 和semaphore arrays的相關信息
#      參數-a:顯示全部可顯示的信息
#      參數-q:顯示活動的消息隊列信息

下面將以消息隊列爲例,演示一下隔離效果,爲了使演示更直觀,我們在創建新的 ipc namespace 的時候,同時也創建新的 uts namespace,然後爲新的 uts namespace 設置新 hostname,這樣就能通過 shell 提示符一眼看出這是屬於新的 namespace 的 bash。示例中我們用兩個 shell 來展示:

shell A

#查看當前shell的uts / ipc namespace number
# readlink /proc/$$/ns/uts /proc/$$/ns/ipc
uts:[4026531838]
ipc:[4026531839]
#查看當前主機名
# hostname
myCentos
#查看ipc message queues,默認情況下沒有message queue
# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages   
#創建一個message queue
# ipcmk -Q
Message queue id: 131072
# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages   
0x82a1d963 131072     root       644        0            0 
-----> 切換至shell B執行
------------------------------------------------------------------
#回到shell A之後我們可以看下hostname、ipc等有沒有收到影響
# hostname
myCentos
# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages   
0x82a1d963 131072     root       644        0            0          
#接下來我們嘗試加入shell B中新的Namespace
# nsenter -t 30372 -u -i /bin/bash
[root@shell-B:/root]
# hostname
shell-B
# readlink /proc/$$/ns/uts /proc/$$/ns/ipc
uts:[4026532382]
ipc:[4026532383]
# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages   
#可以看到我們已經成功的加入到了新的Namespace中

shell B

#確認當前shell和shell A屬於相同Namespace
# readlink /proc/$$/ns/uts /proc/$$/ns/ipc
uts:[4026531838]
ipc:[4026531839]
# ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
0x82a1d963 131072 root 644 0 0
#使用unshare創建新的uts和ipc Namespace,並在新的Namespace中啓動bash
# unshare -iu /bin/bash
#確認新的bash uts/ipc Namespace Number
# readlink /proc/$$/ns/uts /proc/$$/ns/ipc
uts:[4026532382]
ipc:[4026532383]
#設置新的hostname與shell A做區分
# hostname shell-B
# hostname
shell-B
#查看之前的ipc message queue
# ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
#查看當前bash進程的PID
# echo $$
30372
切換回shell A <-----

2.1.4.2 Net Namespace

Network namespace 用來隔離網絡設備, IP 地址, 端口等. 每個 namespace 將會有自己獨立的網絡棧,路由表,防火牆規則,socket 等。每個新的 network namespace 默認有一個本地環回接口,除了 lo 接口外,所有的其他網絡設備(物理 / 虛擬網絡接口,網橋等)只能屬於一個 network namespace。每個 socket 也只能屬於一個 network namespace。當新的 network namespace 被創建時,lo 接口默認是關閉的,需要自己手動啓動起。標記爲 "local devices" 的設備不能從一個 namespace 移動到另一個 namespace,比如 loopback, bridge, ppp 等,我們可以通過 ethtool -k 命令來查看設備的 netns-local 屬性。

我們使用以下命令來創建 net namespace。

相關命令:
    ip netns: 管理網絡namespace
    用法:
       ip netns list
       ip netns add NAME
       ip netns set NAME NETNSID
       ip [-all] netns delete [NAME]

下面使用 ip netns 來演示創建 net Namespace。

shell A

#創建一對網卡,分別命名爲veth0_11/veth1_11
# ip link add veth0_11 type veth peer name veth1_11
#查看已經創建的網卡
#ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000
    link/ether 5e:75:97:0d:54:17 brd ff:ff:ff:ff:ff:ff
    inet 192.168.1.1/24 brd 192.168.1.255 scope global eth0
       valid_lft forever preferred_lft forever
3: br1: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN qlen 1000
    link/ether 00:00:00:00:00:00 brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.1/24 scope global br1
       valid_lft forever preferred_lft forever
96: veth1_11@veth0_11: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN qlen 1000
    link/ether 5e:75:97:0d:54:0e brd ff:ff:ff:ff:ff:ff
97: veth0_11@veth1_11: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN qlen 1000
    link/ether a6:c7:1f:79:a6:a6 brd ff:ff:ff:ff:ff:ff
#使用ip netns創建兩個net namespace
# ip netns add r1
# ip netns add r2
# ip netns list
r2
r1 (id: 0)
#將兩個網卡分別加入到對應的netns中
# ip link set veth0_11 netns r1
# ip link set veth1_11 netns r2
#再次查看網卡,在bash當前的namespace中已經看不到veth0_11和veth1_11了
# ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000
    link/ether 5e:75:97:0d:54:17 brd ff:ff:ff:ff:ff:ff
    inet 192.168.1.1/24 brd 192.168.1.255 scope global eth0
       valid_lft forever preferred_lft forever
3: br1: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN qlen 1000
    link/ether 00:00:00:00:00:00 brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.1/24 scope global br1
       valid_lft forever preferred_lft forever
#接下來我們切換到對應的netns中對網卡進行配置
#通過nsenter --net可以切換到對應的netns中,ip a展示了我們上面加入到r1中的網卡
# nsenter --net=/var/run/netns/r1 /bin/bash
# ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
97: veth0_11@if96: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN qlen 1000
    link/ether a6:c7:1f:79:a6:a6 brd ff:ff:ff:ff:ff:ff link-netnsid 1
#對網卡配置ip並啓動
# ip addr add 172.18.0.11/24 dev veth0_11
# ip link set veth0_11 up
# ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
97: veth0_11@if96: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state LOWERLAYERDOWN qlen 1000
    link/ether a6:c7:1f:79:a6:a6 brd ff:ff:ff:ff:ff:ff link-netnsid 1
    inet 172.18.0.11/24 scope global veth0_11
       valid_lft forever preferred_lft forever
-----> 切換至shell B執行
------------------------------------------------------------------
#在r1中ping veth1_11
# ping 172.18.0.12
PING 172.18.0.12 (172.18.0.12) 56(84) bytes of data.
64 bytes from 172.18.0.12: icmp_seq=1 ttl=64 time=0.033 ms
64 bytes from 172.18.0.12: icmp_seq=2 ttl=64 time=0.049 ms
...
#至此我們通過netns完成了創建net Namespace的小實驗

shell B

#在shell B中我們同樣切換到netns r2中進行配置
#通過nsenter --net可以切換到r2,ip a展示了我們上面加入到r2中的網卡
# nsenter --net=/var/run/netns/r2 /bin/bash
#  ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
96: veth1_11@if97: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN qlen 1000
    link/ether 5e:75:97:0d:54:0e brd ff:ff:ff:ff:ff:ff link-netnsid 0
#對網卡配置ip並啓動
# ip addr add 172.18.0.12/24 dev veth1_11
# ip link set veth1_11 up
# ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
96: veth1_11@if97: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000
    link/ether 5e:75:97:0d:54:0e brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.18.0.12/24 scope global veth1_11
       valid_lft forever preferred_lft forever
    inet6 fe80::5c75:97ff:fe0d:540e/64 scope link
       valid_lft forever preferred_lft forever
#嘗試ping r1中的網卡
# ping 172.18.0.11
PING 172.18.0.11 (172.18.0.11) 56(84) bytes of data.
64 bytes from 172.18.0.11: icmp_seq=1 ttl=64 time=0.046 ms
64 bytes from 172.18.0.11: icmp_seq=2 ttl=64 time=0.040 ms
...
#可以完成通信
切換至shell A執行 <-----

示意圖

2.2 Cgroup

2.2.1 簡介

Cgroup 和 namespace 類似,也是將進程進行分組,但它的目的和 namespace 不一樣,namespace 是爲了隔離進程組之間的資源,而 cgroup 是爲了對一組進程進行統一的資源監控和限制。

Cgroup 作用:

  1. 資源限制 (Resource limiting): Cgroups 可以對進程組使用的資源總額進行限制。如對特定的進程進行內存使用上限限制,當超出上限時,會觸發 OOM。

  2. 優先級分配 (Prioritization): 通過分配的 CPU 時間片數量及硬盤 IO 帶寬大小,實際上就相當於控制了進程運行的優先級。

  3. 資源統計(Accounting): Cgroups 可以統計系統的資源使用量,如 CPU 使用時長、內存用量等等,這個功能非常適用於計費。

  4. **進程控制(Control):**Cgroups 可以對進程組執行掛起、恢復等操作。

Cgroups 的組成:

  1. task: 在 Cgroups 中,task 就是系統的一個進程。

  2. cgroup: Cgroups 中的資源控制都以 cgroup 爲單位實現的。cgroup 表示按照某種資源控制標準劃分而成的任務組,包含一個或多個子系統。一個任務可以加入某個 cgroup,也可以從某個 cgroup 遷移到另外一個 cgroup。

  3. subsystem: ****一個 subsystem 就是一個內核模塊,被關聯到一顆 cgroup 樹之後,就會在樹的每個節點(進程組)上做具體的操作。subsystem 經常被稱作 "resource controller",因爲它主要被用來調度或者限制每個進程組的資源,但是這個說法不完全準確,因爲有時我們將進程分組只是爲了做一些監控,觀察一下他們的狀態,比如 perf_event subsystem。到目前爲止,Linux 支持 13 種 subsystem(Cgroup v1),比如限制 CPU 的使用時間,限制使用的內存,統計 CPU 的使用情況,凍結和恢復一組進程等。

  4. hierarchy**: **一個 hierarchy 可以理解爲一棵 cgroup 樹,樹的每個節點就是一個進程組,每棵樹都會與零到多個 subsystem 關聯。在一顆樹裏面,會包含 Linux 系統中的所有進程,但每個進程只能屬於一個節點(進程組)。系統中可以有很多顆 cgroup 樹,每棵樹都和不同的 subsystem 關聯,一個進程可以屬於多顆樹,即一個進程可以屬於多個進程組,只是這些進程組和不同的 subsystem 關聯。如果不考慮不與任何 subsystem 關聯的情況(systemd 就屬於這種情況),Linux 裏面最多可以建 13 顆 cgroup 樹,每棵樹關聯一個 subsystem,當然也可以只建一棵樹,然後讓這棵樹關聯所有的 subsystem。當一顆 cgroup 樹不和任何 subsystem 關聯的時候,意味着這棵樹只是將進程進行分組,至於要在分組的基礎上做些什麼,將由應用程序自己決定,systemd 就是一個這樣的例子。

2.2.2 查看 Cgroup 信息

查看當前系統支持的 subsystem

#通過/proc/cgroups查看當前系統支持哪些subsystem
# cat /proc/cgroups
#subsys_name    hierarchy       num_cgroups     enabled
cpuset              11              1           1
cpu                 4               67          1
cpuacct             4               67          1
memory              5               69          1
devices             7               62          1
freezer             8               1           1
net_cls             6               1           1
blkio               9               62          1
perf_event          3               1           1
hugetlb             2               1           1
pids                10              62          1
net_prio            6               1           1
#字段含義
#subsys_name: subsystem的名稱
#hierarchy:subsystem所關聯到的cgroup樹的ID,如果多個subsystem關聯到同一顆cgroup樹,那麼他們的這個字段將一樣,比如這裏的cpu和cpuacct就一樣,表示他們綁定到了同一顆樹。如果出現下面的情況,這個字段將爲0:
        當前subsystem沒有和任何cgroup樹綁定
        當前subsystem已經和cgroup v2的樹綁定
        當前subsystem沒有被內核開啓
#num_cgroups: subsystem所關聯的cgroup樹中進程組的個數,也即樹上節點的個數
#enabled: 1表示開啓,0表示沒有被開啓(可以通過設置內核的啓動參數“cgroup_disable”來控制subsystem的開啓).

查看進程所屬 cgroup

#查看當前shell進程所屬的cgroup
# cat /proc/$$/cgroup
11:cpuset:/
10:pids:/system.slice/sshd.service
9:blkio:/system.slice/sshd.service
8:freezer:/
7:devices:/system.slice/sshd.service
6:net_prio,net_cls:/
5:memory:/system.slice/sshd.service
4:cpuacct,cpu:/system.slice/sshd.service
3:perf_event:/
2:hugetlb:/
1:name=systemd:/system.slice/sshd.service
#字段含義(以冒號分爲3列):
# 1. cgroup樹ID,對應/proc/cgroups中的hierachy
# 2. cgroup所綁定的subsystem,多個subsystem使用逗號分隔。name=systemd表示沒有和任何subsystem綁定,只是給他起了個名字叫systemd。
# 3. 進程在cgroup樹中的路徑,即進程所屬的cgroup,這個路徑是相對於掛載點的相對路徑。

2.2.3 相關命令

使用 cgroup

cgroup 相關的所有操作都是基於內核中的 cgroup virtual filesystem,使用 cgroup 很簡單,掛載這個文件系統就可以了。一般情況下都是掛載到 / sys/fs/cgroup 目錄下,當然掛載到其它任何目錄都沒關係。

查看下當前系統 cgroup 掛載情況。

#過濾系統掛載可以查看cgroup
# mount |grep cgroup
tmpfs on /sys/fs/cgroup type tmpfs (ro,nosuid,nodev,noexec,mode=755)
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_prio,net_cls)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
#如果系統中沒有掛載cgroup,可以使用mount命令創建cgroup
#掛載根cgroup
# mkdir /sys/fs/cgroup
# mount -t tmpfs cgroup_root /sys/fs/cgroup
#將cpuset subsystem關聯到/sys/fs/cgroup/cpu_memory
# mkdir /sys/fs/cgroup/cpuset
# sudo mount -t cgroup cpuset -o cgroup /sys/fs/cgroup/cpuset/
#將cpu和memory subsystem關聯到/sys/fs/cgroup/cpu_memory
# mkdir /sys/fs/cgroup/cpu_memory
# sudo mount -n -t cgroup -o cpu,memory cgroup /sys/fs/cgroup/cpu_memory

除了 mount 命令之外我們還可以使用以下命令對 cgroup 進行創建、屬性設置等操作,這也是我們後面腳本中用於創建和管理 cgroup 的命令。

# Centos操作系統可以通過yum install cgroup-tools 來安裝以下命令
    cgcreate: 在層級中創建新cgroup。
        用法: cgcreate [-h] [-t <tuid>:<tgid>] [-a <agid>:<auid>] [-f mode] [-d mode] [-s mode]
                -g <controllers>:<path> [-g ...]
        示例: cgcreate -g *:student -g devices:teacher //在所有的掛載hierarchy中創建student cgroup,在devices   
             hierarchy掛載點創建teacher cgroup
    cgset: 設置指定cgroup(s)的參數
        用法: cgset [-r <name=value>] <cgroup_path> ...
        示例: cgset -r cpuset.cpus=0-1 student //將student cgroup的cpuset控制器中的cpus限制爲0-1
    cgexec: 在指定的cgroup中運行任務
        用法: cgexec [-h] [-g <controllers>:<path>] [--sticky] command [arguments]
        示例: cgexec -g cpu,memory:test1 ls -l //在cpu和memory控制器下的test1 cgroup中運行ls -l命令

2.3 Rootfs

2.3.1 簡介

Rootfs 是 Docker 容器在啓動時內部進程可見的文件系統,即 Docker 容器的根目錄。rootfs 通常包含一個操作系統運行所需的文件系統,例如可能包含典型的類 Unix 操作系統中的目錄系統,如 /dev、/proc、/bin、/etc、/lib、/usr、/tmp 及運行 Docker 容器所需的配置文件、工具等。

就像 Linux 啓動會先用只讀模式掛載 rootfs,運行完完整性檢查之後,再切換成讀寫模式一樣。Docker deamon 爲 container 掛載 rootfs 時,也會先掛載爲只讀模式,但是與 Linux 做法不同的是,在掛載完只讀的 rootfs 之後,Docker deamon 會利用聯合掛載技術(Union Mount)在已有的 rootfs 上再掛一個讀寫層。container 在運行過程中文件系統發生的變化只會寫到讀寫層,並通過 whiteout 技術隱藏只讀層中的舊版本文件。

Docker 支持不同的存儲驅動,包括 aufs、devicemapper、overlay2、zfs 和 vfs 等,目前在 Docker 中,overlay2 取代了 aufs 成爲了推薦的存儲驅動。

2.3.2 overlayfs

overlayFS 是聯合掛載技術的一種實現。除了 overlayFS 以外還有 aufs,VFS,Brtfs,device mapper 等技術。雖然實現細節不同,但是他們做的事情都是相同的。Linux 內核爲 Docker 提供的 overalyFS 驅動有 2 種:overlay2 和 overlay,overlay2 是相對於 overlay 的一種改進,在 inode 利用率方面比 overlay 更有效。

overlayfs 通過三個目錄來實現:lower 目錄、upper 目錄、以及 work 目錄。三種目錄合併出來的目錄稱爲 merged 目錄。

2.3.3 文件規則

merged 層目錄會顯示離它最近層的文件。層級關係中 upperdir 比 lowerdir 更靠近 merged 層,而多個 lowerdir 的情況下,寫的越靠前的目錄離 merged 層目錄越近。相同文件名的文件會依照層級規則進行 “覆蓋”。

2.3.4 overlayFS 如何工作

2.3.5 在系統裏創建 overlayfs

shell

# 創建所需的目錄
# mkdir upper lower merged work
# echo "lower" > lower/in_lower.txt
# echo "upper" > upper/in_upper.txt
# 在lower和upper中都創建 in_both文件
# echo "lower" > lower/in_both.txt
# echo "upper" > upper/in_both.txt
#查看下我們當前的目錄及文件結構
# tree .
.
|-- lower
|   |-- in_both.txt
|   `-- in_lower.txt
|-- merged
|-- upper
|   |-- in_both.txt
|   `-- in_upper.txt
`-- work
#使用mount命令將創建的目錄聯合掛載起來
# mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged
#查看mount結果可以看到已經成功掛載了
# mount |grep overlay
overlay on /data/overlay_demo/merged type overlay (rw,relatime,lowerdir=lower,upperdir=upper,workdir=work)
#此時再查看文件目錄結構
# tree .
.
|-- lower
|   |-- in_both.txt
|   `-- in_lower.txt
|-- merged
|   |-- in_both.txt
|   |-- in_lower.txt
|   `-- in_upper.txt
|-- upper
|   |-- in_both.txt
|   `-- in_upper.txt
`-- work
    `-- work
#可以看到merged中包含了lower和upper中的文件
#然後我查看merge中的in_both文件,驗證了上層目錄覆蓋下層的結論
# cat merged/in_both.txt
upper
#上面我們驗證了掛載後overlayfs的讀,接下來我們去驗證下寫
#我們在merged中創建一個新文件,並查看
# touch merged/new_file
# tree .
.
|-- lower
|   |-- in_both.txt
|   `-- in_lower.txt
|-- merged
|   |-- in_both.txt
|   |-- in_lower.txt
|   |-- in_upper.txt
|   `-- new_file
|-- upper
|   |-- in_both.txt
|   |-- in_upper.txt
|   `-- new_file
`-- work
    `-- work
#可以看到新文件實際是放在了upper目錄中
#下面我們看下如果刪除了lower和upper中都有的文件會怎樣
# rm -f merged/in_both.txt
# tree .
.
|-- lower
|   |-- in_both.txt
|   `-- in_lower.txt
|-- merged
|   |-- in_lower.txt
|   |-- in_upper.txt
|   `-- new_file
|-- upper
|   |-- in_both.txt
|   |-- in_upper.txt
|   `-- new_file
`-- work
    `-- work
#從文件目錄上看只有merge中沒有了in_both文件,但是upper中的文件已經發生了變化
# ll upper/in_both.txt
c--------- 1 root root 0, 0 Jan 21 19:33 upper/in_both.txt
#upper/in_both.txt已經變成了一個空的字符文件,且覆蓋了lower層的內容

三 、Bocker

3.1 功能演示

第二部分中我們對 Namespace,cgroup,overlayfs 有了一定的瞭解,接下來我們通過一個腳本來實現個建議的 Docker。腳本源自於 https://github.com/p8952/bocker,我做了 image/pull / 存儲驅動的部分修改,下面先看下腳本完成後的示例:

3.2 完整腳本

腳本一共用 130 行代碼,完成了上面的功能,也算符合我們此次的標題了。爲了大家可以更深入的理解腳本內容,這裏就不再對腳本進行拆分講解,以下是完整腳本。

#!/usr/bin/env bash
set -o errexit -o nounset -o pipefail; shopt -s nullglob
overlay_path='/var/lib/bocker/overlay' && container_path='/var/lib/bocker/containers' && cgroups='cpu,cpuacct,memory';
[[ $# -gt 0 ]] && while [ "${1:0:2}" == '--' ]; do OPTION=${1:2}; [[ $OPTION =~ = ]] && declare "BOCKER_${OPTION/=*/}=${OPTION/*=/}" || declare "BOCKER_${OPTION}=x"; shift; done
function bocker_check() {
    case ${1:0:3} in
        img) ls "$overlay_path" | grep -qw "$1" && echo 0 || echo 1;;
        ps_) ls "$container_path" | grep -qw "$1" && echo 2 || echo 3;;
    esac
}
function bocker_init() { #HELP Create an image from a directory:\nBOCKER init <directory>
    uuid="img_$(shuf -i 42002-42254 -n 1)"
    if [[ -d "$1" ]]; then
        [[ "$(bocker_check "$uuid")" == 0 ]] && bocker_run "$@"
        mkdir "$overlay_path/$uuid" > /dev/null
        cp -rf --reflink=auto "$1"/* "$overlay_path/$uuid" > /dev/null
        [[ ! -f "$overlay_path/$uuid"/img.source ]] && echo "$1" > "$overlay_path/$uuid"/img.source
        [[ ! -d "$overlay_path/$uuid"/proc ]] && mkdir "$overlay_path/$uuid"/proc
        echo "Created: $uuid"
    else
        echo "No directory named '$1' exists"
    fi
}
function bocker_pull() { #HELP Pull an image from Docker Hub:\nBOCKER pull <name> <tag>
    tmp_uuid="$(uuidgen)" && mkdir /tmp/"$tmp_uuid"
    download-frozen-image-v2 /tmp/"$tmp_uuid" "$1:$2" > /dev/null
    rm -rf /tmp/"$tmp_uuid"/repositories
    for tar in $(jq '.[].Layers[]' --raw-output < /tmp/$tmp_uuid/manifest.json); do
        tar xf /tmp/$tmp_uuid/$tar -C /tmp/$tmp_uuid && rm -rf /tmp/$tmp_uuid/$tar
    done
    for config in $(jq '.[].Config' --raw-output < /tmp/$tmp_uuid/manifest.json); do
        rm -f /tmp/$tmp_uuid/$config
    done
    echo "$1:$2" > /tmp/$tmp_uuid/img.source
    bocker_init /tmp/$tmp_uuid && rm -rf /tmp/$tmp_uuid
}
function bocker_rm() { #HELP Delete an image or container:\nBOCKER rm <image_id or container_id>
    [[ "$(bocker_check "$1")" == 3 ]] && echo "No container named '$1' exists" && exit 1
    [[ "$(bocker_check "$1")" == 1 ]] && echo "No image named '$1' exists" && exit 1
    if [[ -d "$overlay_path/$1" ]];then
        rm -rf "$overlay_path/$1" && echo "Removed: $1"
    else
        umount "$container_path/$1"/merged && rm -rf "$container_path/$1" && ip netns del netns_"$1" && ip link del dev veth0_"$1" && echo "Removed: $1"
        cgdelete -g "$cgroups:/$1" &> /dev/null
    fi
}
function bocker_images() { #HELP List images:\nBOCKER images
    echo -e "IMAGE_ID\t\tSOURCE"
    for img in "$overlay_path"/img_*; do
        img=$(basename "$img")
        echo -e "$img\t\t$(cat "$overlay_path/$img/img.source")"
    done
}
function bocker_ps() { #HELP List containers:\nBOCKER ps
    echo -e "CONTAINER_ID\t\tCOMMAND"
    for ps in "$container_path"/ps_*; do
        ps=$(basename "$ps")
        echo -e "$ps\t\t$(cat "$container_path/$ps/$ps.cmd")"
    done
}
function bocker_run() { #HELP Create a container:\nBOCKER run <image_id> <command>
    uuid="ps_$(shuf -i 42002-42254 -n 1)"
    [[ "$(bocker_check "$1")" == 1 ]] && echo "No image named '$1' exists" && exit 1
    [[ "$(bocker_check "$uuid")" == 2 ]] && echo "UUID conflict, retrying..." && bocker_run "$@" && return
    cmd="${@:2}" && ip="$(echo "${uuid: -3}" | sed 's/0//g')" && mac="${uuid: -3:1}:${uuid: -2}"
    ip link add dev veth0_"$uuid" type veth peer name veth1_"$uuid"
    ip link set dev veth0_"$uuid" up
    ip link set veth0_"$uuid" master br1
    ip netns add netns_"$uuid"
    ip link set veth1_"$uuid" netns netns_"$uuid"
    ip netns exec netns_"$uuid" ip link set dev lo up
    ip netns exec netns_"$uuid" ip link set veth1_"$uuid" address 02:42:ac:11:00"$mac"
    ip netns exec netns_"$uuid" ip addr add 172.18.0."$ip"/24 dev veth1_"$uuid"
    ip netns exec netns_"$uuid" ip link set dev veth1_"$uuid" up
    ip netns exec netns_"$uuid" ip route add default via 172.18.0.1
    mkdir -p "$container_path/$uuid"/{lower,upper,work,merged} && cp -rf --reflink=auto "$overlay_path/$1"/* "$container_path/$uuid"/lower > /dev/null && \
    mount -t overlay overlay \
        -o lowerdir="$container_path/$uuid"/lower,upperdir="$container_path/$uuid"/upper,workdir="$container_path/$uuid"/work \
        "$container_path/$uuid"/merged
    echo 'nameserver 114.114.114.114' > "$container_path/$uuid"/merged/etc/resolv.conf
    echo "$cmd" > "$container_path/$uuid/$uuid.cmd"
    cgcreate -g "$cgroups:/$uuid"
    : "${BOCKER_CPU_SHARE:=512}" && cgset -r cpu.shares="$BOCKER_CPU_SHARE" "$uuid"
    : "${BOCKER_MEM_LIMIT:=512}" && cgset -r memory.limit_in_bytes="$((BOCKER_MEM_LIMIT * 1000000))" "$uuid"
    cgexec -g "$cgroups:$uuid" \
        ip netns exec netns_"$uuid" \
        unshare -fmuip --mount-proc \
        chroot "$container_path/$uuid"/merged \
        /bin/sh -c "/bin/mount -t proc proc /proc && $cmd" \
        2>&1 | tee "$container_path/$uuid/$uuid.log" || true
    ip link del dev veth0_"$uuid"
    ip netns del netns_"$uuid"
}
function bocker_exec() { #HELP Execute a command in a running container:\nBOCKER exec <container_id> <command>
    [[ "$(bocker_check "$1")" == 3 ]] && echo "No container named '$1' exists" && exit 1
    cid="$(ps o ppid,pid | grep "^$(ps o pid,cmd | grep -E "^\ *[0-9]+ unshare.*$1" | awk '{print $1}')" | awk '{print $2}')"
    [[ ! "$cid" =~ ^\ *[0-9]+$ ]] && echo "Container '$1' exists but is not running" && exit 1
    nsenter -t "$cid" -m -u -i -n -p chroot "$container_path/$1"/merged "${@:2}"
}
function bocker_logs() { #HELP View logs from a container:\nBOCKER logs <container_id>
    [[ "$(bocker_check "$1")" == 3 ]] && echo "No container named '$1' exists" && exit 1
    cat "$container_path/$1/$1.log"
}
function bocker_commit() { #HELP Commit a container to an image:\nBOCKER commit <container_id> <image_id>
    [[ "$(bocker_check "$1")" == 3 ]] && echo "No container named '$1' exists" && exit 1
    [[ "$(bocker_check "$2")" == 0 ]] && echo "Image named '$2' exists" && exit 1
    mkdir "$overlay_path/$2" && cp -rf --reflink=auto "$container_path/$1"/merged/* "$overlay_path/$2" && sed -i "s/:.*$/:$(date +%Y%m%d-%H%M%S)/g" "$overlay_path/$2"/img.source
    echo "Created: $2"
}
function bocker_help() { #HELP Display this message:\nBOCKER help
    sed -n "s/^.*#HELP\\s//p;" < "$1" | sed "s/\\\\n/\n\t/g;s/$/\n/;s!BOCKER!${1/!/\\!}!g"
}
[[ -z "${1-}" ]] && bocker_help "$0" && exit 1
case $1 in
    pull|init|rm|images|ps|run|exec|logs|commit) bocker_"$1" "${@:2}" ;;
    *) bocker_help "$0" ;;
esac

README

Bocker

使用 100 行 bash 實現一個 docker,本腳本是依據 bocker 實現,更換了存儲驅動,完善了 pull 等功能。

前置條件

爲了腳本能夠正常運行,機器上需要具備以下組件:

大部分功能在 centos7 上都是滿足的,overlayfs 可以通過 modprobe overlay 掛載。

另外你可能還要做以下設置:

實現的功能

+bocker init 提供了有限的 bocker build 能力

四、總結

到此本文要介紹的內容就結束了,正如開篇我們提到的,寫出最終的腳本實現這樣一個小玩意並沒有什麼實用價值,真正的價值是我們通過 100 行左右的腳本,以交互式的方式去理解 Docker 的核心技術點。在工作中與容器打交道時能有更多的思路去排查、解決問題。

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