在前文Docker基礎入門總結中咱們已經學習了Docker相關的基礎概念以及經常使用命令等,下面咱們開始深刻學習Docker的核心技術以及實現原理。html
Docker 的出現必定是由於目前的後端在開發和運維階段確實須要一種虛擬化技術解決開發環境和生產環境環境一致的問題,經過 Docker 咱們能夠將程序運行的環境也歸入到版本控制中,排除由於環境形成不一樣運行結果的可能。可是上述需求雖然推進了虛擬化技術的產生,可是若是沒有合適的底層技術支撐,那麼咱們仍然得不到一個完美的產品。本文會介紹幾種 Docker 使用的核心技術,若是咱們瞭解它們的使用方法和原理,就能清楚 Docker 的實現原理。python
命名空間(namespaces)是 Linux 爲咱們提供的用於分離進程樹、網絡接口、掛載點以及進程間通訊等資源的方法。在平常使用 Linux 或者 macOS 時,咱們並無運行多個徹底分離的服務器的須要,可是若是咱們在服務器上啓動了多個服務,這些服務其實會相互影響的,每個服務都能看到其餘服務的進程,也能夠訪問宿主機器上的任意文件,這是不少時候咱們都不肯意看到的,咱們更但願運行在同一臺機器上的不一樣服務能作到徹底隔離,就像運行在多臺不一樣的機器上同樣。linux
在這種狀況下,一旦服務器上的某一個服務被入侵,那麼入侵者就可以訪問當前機器上的全部服務和文件,這也是咱們不想看到的,而 Docker 其實就經過 Linux 的 Namespaces 對不一樣的容器實現了隔離。git
Linux 的命名空間機制提供瞭如下七種不一樣的命名空間,包括 CLONENEWCGROUP、CLONENEWIPC、CLONENEWNET、CLONENEWNS、CLONENEWPID、CLONENEWUSER 和 CLONE_NEWUTS,經過這七個選項咱們能在建立新的進程時設置新進程應該在哪些資源上與宿主機器進行隔離。github
進程是 Linux 以及如今操做系統中很是重要的概念,它表示一個正在執行的程序,也是在現代分時系統中的一個任務單元。在每個 *nix 的操做系統上,咱們都可以經過 ps 命令打印出當前操做系統中正在執行的進程,好比在 Ubuntu 上,使用該命令就能獲得如下的結果:redis
$ 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 的註冊進程,然後者負責管理和調度其餘的內核進程。docker
若是咱們在當前的 Linux 操做系統下運行一個新的 Docker 容器,並經過 exec 進入其內部的 bash 並打印其中的所有進程,咱們會獲得如下的結果:編程
root@iZ255w13cy6Z:~# docker run -it -d ubuntu b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79 root@iZ255w13cy6Z:~# docker exec -it b809a2eb3630 /bin/bash root@b809a2eb3630:/# ps -ef UID PID PPID C STIME TTY TIME CMD root 1 0 0 15:42 pts/0 00:00:00 /bin/bash root 9 0 0 15:42 pts/1 00:00:00 /bin/bash root 17 9 0 15:43 pts/1 00:00:00 ps -ef
在新的容器內部執行 ps 命令打印出了很是乾淨的進程列表,只有包含當前 ps -ef 在內的三個進程,在宿主機器上的幾十個進程都已經消失不見了。ubuntu
當前的 Docker 容器成功將容器內的進程與宿主機器中的進程隔離,若是咱們在宿主機器上打印當前的所有進程時,會獲得下面三條與 Docker 相關的結果:後端
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
在當前的宿主機器上,可能就存在由上述的不一樣進程構成的進程樹:
這就是在使用 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 經過命名空間成功完成了與宿主機進程和網絡的隔離。
若是 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 容器可以優雅地爲宿主機器或者其餘容器提供服務。
整個網絡部分的功能都是經過 Docker 拆分出來的 libnetwork 實現的,它提供了一個鏈接不一樣容器的實現,同時也可以爲應用給出一個可以提供一致的編程接口和網絡層抽象的容器網絡模型。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(change root),在 Linux 系統中,系統默認的目錄就都是以 / 也就是根目錄開頭的,chroot 的使用可以改變當前的系統根目錄結構,經過改變當前系統的根目錄,咱們可以限制用戶的權利,在新的根目錄下並不可以訪問舊系統根目錄的結構個文件,也就創建了一個與原系統徹底隔離的目錄結構。
咱們經過 Linux 的命名空間爲新建立的進程隔離了文件系統、網絡並與宿主機器之間的進程相互隔離,可是命名空間並不可以爲咱們提供物理資源上的隔離,好比 CPU 或者內存,若是在同一臺機器上運行了多個對彼此以及宿主機器一無所知的『容器』,這些容器卻共同佔用了宿主機器的物理資源。
若是其中的某一個容器正在執行 CPU 密集型的任務,那麼就會影響其餘容器中任務的性能與執行效率,致使多個容器相互影響而且搶佔資源。如何對多個容器的資源使用進行限制就成了解決進程虛擬資源隔離以後的主要問題,而 Control Groups(簡稱 CGroups)就是可以隔離宿主機器上的物理資源,例如 CPU、內存、磁盤 I/O 和網絡帶寬。每個 CGroup 都是一組被相同的標準和參數限制的進程,不一樣的 CGroup 之間是有層級關係的,也就是說它們之間能夠從父類繼承一些用於限制資源使用的標準和參數。
Linux 的 CGroup 可以爲一組進程分配資源,也就是咱們在上面提到的 CPU、內存、網絡帶寬等資源,經過對資源的分配,CGroup 可以提供如下的幾種功能:
在 CGroup 中,全部的任務就是一個系統的一個進程,而 CGroup 就是一組按照某種標準劃分的進程,在 CGroup 這種機制中,全部的資源控制都是以 CGroup 做爲單位實現的,每個進程均可以隨時加入一個 CGroup 也能夠隨時退出一個 CGroup。
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.cfsquotaus 文件中的內容可以對 CPU 的使用做出限制,若是當前文件的內容爲 50000,那麼當前控制組中的所有進程的 CPU 佔用率不能超過 50%。
若是系統管理員想要控制 Docker 某個容器的資源使用率就能夠在 Docker 這個父控制組下面找到對應的子控制組而且改變它們對應文件的內容,固然咱們也能夠直接在程序運行時就使用參數,讓 Docker 進程去改變相應文件中的內容。
$ docker run -it -d --cpu-quota=50000 busybox 53861305258ecdd7f5d2a3240af694aec9adb91cd4c7e210b757f71153cdd274 $ cd 53861305258ecdd7f5d2a3240af694aec9adb91cd4c7e210b757f71153cdd274/ $ ls cgroup.clone_children cgroup.event_control cgroup.procs cpu.cfs_period_us cpu.cfs_quota_us cpu.shares cpu.stat notify_on_release tasks $ cat cpu.cfs_quota_us 50000
當咱們使用 Docker 關閉掉正在運行的容器時,Docker 的子控制組對應的文件夾也會被 Docker 進程移除,Docker 在使用 CGroup 時其實也只是作了一些建立文件夾改變文件內容的文件操做,不過 CGroup 的使用也確實解決了咱們限制子容器資源佔用的問題,系統管理員可以爲多個容器合理的分配資源而且不會出現多個容器互相搶佔資源的問題。
Linux 的命名空間和控制組分別解決了不一樣資源隔離的問題,前者解決了進程、網絡以及文件系統的隔離,後者實現了 CPU、內存等資源的隔離,可是在 Docker 中還有另外一個很是重要的問題須要解決 - 也就是鏡像。
鏡像究竟是什麼,它又是如何組成和組織的是做者使用 Docker 以來的一段時間內一直比較讓做者感到困惑的問題,咱們可使用 docker run 很是輕鬆地從遠程下載 Docker 的鏡像並在本地運行。
Docker 鏡像其實本質就是一個壓縮包,咱們可使用下面的命令將一個 Docker 鏡像中的文件導出:
$ docker export $(docker create busybox) | tar -C rootfs -xvf -
$ ls
bin dev etc home proc root sys tmp usr var
你能夠看到這個 busybox 鏡像中的目錄結構與 Linux 操做系統的根目錄中的內容並無太多的區別,能夠說 Docker 鏡像就是一個文件。
UnionFS 實際上是一種爲 Linux 操做系統設計的用於把多個文件系統『聯合』到同一個掛載點的文件系統服務。而 AUFS 即 Advanced UnionFS 其實就是 UnionFS 的升級版,它可以提供更優秀的性能和效率。AUFS 做爲聯合文件系統,它可以將不一樣文件夾中的層聯合(Union)到了同一個文件夾中,這些文件夾在 AUFS 中稱做分支,整個『聯合』的過程被稱爲聯合掛載(Union Mount):
每個鏡像層或者容器層都是 /var/lib/docker/ 目錄下的一個子文件夾;在 Docker 中,全部鏡像層和容器層的內容都存儲在 /var/lib/docker/aufs/diff/ 目錄中:
$ ls /var/lib/docker/aufs/diff/00adcccc1a55a36a610a6ebb3e07cc35577f2f5a3b671be3dbc0e74db9ca691c 93604f232a831b22aeb372d5b11af8c8779feb96590a6dc36a80140e38e764d8 00adcccc1a55a36a610a6ebb3e07cc35577f2f5a3b671be3dbc0e74db9ca691c-init 93604f232a831b22aeb372d5b11af8c8779feb96590a6dc36a80140e38e764d8-init 019a8283e2ff6fca8d0a07884c78b41662979f848190f0658813bb6a9a464a90 93b06191602b7934fafc984fbacae02911b579769d0debd89cf2a032e7f35cfa ...
而 /var/lib/docker/aufs/layers/ 中存儲着鏡像層的元數據,每個文件都保存着鏡像層的元數據,最後的 /var/lib/docker/aufs/mnt/ 包含鏡像或者容器層的掛載點,最終會被 Docker 經過聯合的方式進行組裝。
上面的這張圖片很是好的展現了組裝的過程,每個鏡像層都是創建在另外一個鏡像層之上的,同時全部的鏡像層都是隻讀的,只有每一個容器最頂層的容器層才能夠被用戶直接讀寫,全部的容器都創建在一些底層服務(Kernel)上,包括命名空間、控制組、rootfs 等等,這種容器的組裝方式提供了很是大的靈活性,只讀的鏡像層經過共享也可以減小磁盤的佔用。
Docker 使用了一系列不一樣的存儲驅動管理鏡像內的文件系統並運行容器,這些存儲驅動與 Docker 卷(volume)有些不一樣,存儲引擎管理着可以在多個容器之間共享的存儲。想要理解 Docker 使用的存儲驅動,咱們首先須要理解 Docker 是如何構建而且存儲鏡像的,也須要明白 Docker 的鏡像是如何被每個容器所使用的;Docker 中的每個鏡像都是由一系列只讀的層組成的,Dockerfile 中的每個命令都會在已有的只讀層上建立一個新的層:
FROM ubuntu:15.04 COPY . /app RUN make /app CMD python /app/app.py
容器中的每一層都只對當前容器進行了很是小的修改,上述的 Dockerfile 文件會構建一個擁有四層 layer 的鏡像:
當鏡像被 docker run 命令建立時就會在鏡像的最上層添加一個可寫的層,也就是容器層,全部對於運行時容器的修改其實都是對這個容器讀寫層的修改。容器和鏡像的區別就在於,全部的鏡像都是隻讀的,而每個容器其實等於鏡像加上一個可讀寫的層,也就是同一個鏡像能夠對應多個容器。
AUFS 只是 Docker 使用的存儲驅動的一種,除了 AUFS 以外,Docker 還支持了不一樣的存儲驅動,包括 aufs、devicemapper、overlay二、zfs 和 vfs 等等,在最新的 Docker 中,overlay2 取代了 aufs 成爲了推薦的存儲驅動,可是在沒有 overlay2 驅動的機器上仍然會使用 aufs 做爲 Docker 的默認驅動。
不一樣的存儲驅動在存儲鏡像和容器文件時也有着徹底不一樣的實現,有興趣的讀者能夠在 Docker 的官方文檔 Select a storage driver 中找到相應的內容。想要查看當前系統的 Docker 上使用了哪一種存儲驅動只須要使用如下的命令就能獲得相對應的信息:
$ docker info | grep Storage
Storage Driver: aufs
Docker 目前已經成爲了很是主流的技術,已經在不少成熟公司的生產環境中使用,可是 Docker 的核心技術其實已經有不少年的歷史了,Linux Namespace、Cgroups和 UnionFS 三大技術支撐了目前 Docker 的實現,也是 Docker 可以出現的最重要緣由。
參考文獻:
https://github.com/docker/libnetwork/blob/master/docs/design.md
https://github.com/opencontainers/runc/blob/master/libcontainer/SPEC.md
https://forums.docker.com/t/does-the-docker-engine-use-chroot/25429
https://www.quora.com/Do-Docker-containers-use-a-chroot-environment
https://www.ibm.com/developerworks/cn/linux/l-cn-chroot/index.html
https://www.ibm.com/developerworks/cn/linux/1506_cgroup/index.htm
https://www.cnblogs.com/bakari/p/8560437.html
https://www.cnblogs.com/bakari/p/8971602.html
https://linux.cn/article-6975-1.html
--- END ---