Docker核心原理之namespace
Docker背后的內核知識
當談論docker時,常常會聊到docker的實現方式。很多開發者都知道,docker容器本質上是宿主機的進程,Docker通過namespace實現了資源隔離,通過cgroups實現了資源限制,通過寫時復制機制(copy-on-write)實現了高效的文件操作。當進一步深入namespace和cgroups等技術細節時,大部分開發者都會感到茫然無措。尤其是接下來解釋libcontainer的工作原理時,我們會接觸大量容器核心知識。所以在這里,希望先帶領大家走進linux內核,了解namespa和cgroups的技術細節。
namespace資源隔離
linux內核提拱了6種namespace隔離的系統調用,如下圖所示,但是真正的容器還需要處理許多其他工作。
| UTS | CLONE_NEWUTS | 主機名或域名 |
| IPC | CLONE_NEWIPC | 信號量、消息隊列和共享內存 |
| PID | CLONE_NEWPID | 進程編號 |
| Network | CLONE_NEWNET | 網絡設備、網絡戰、端口等 |
| Mount | CLONE_NEWNS | 掛載點(文件系統) |
| User | CLONE_NEWUSER | 用戶組和用戶組 |
實際上,linux內核實現namespace的主要目的,就是為了實現輕量級虛擬化技術服務。在同一個namespace下的進程合一感知彼此的變化,而對外界的進程一無所知。這樣就可以讓容器中的進程產生錯覺,仿佛自己置身一個獨立的系統環境中,以達到隔離的目的。
需要注意的是,本文所討論的namespace實現針對的是linux內核3.8及以后版本。
1.進行namespace API操作的4種方式
namespace的API包括clone()、setns()以及unshare(),還有/proc下的本分文件。為了確定隔離的到底是哪6項namespace,在使用這些API時需要指定一下6個參數中的一個或多個,通過|(位或)操作實現。這6個參數分別是CLONE_NEWUTS、CLONE_NEWIPC、CLONE_NEWPID、CLONE_NEWNET、CLONE_NEWNS、CLONE_NEWUSER。
- 通過clone()在創建新進程的同時創建namespace
使用clone()來創建一個獨立的namespace,是最常見的用法,也是docker使用namespace最基本用法。
int clone(int (*child_func)(void *),void *child_stack,int flags,void *arg);
clone()實際上是linux系統調用fork()的一種更通用的實現方式,他可以通過flag來控制使用多少功能。一共有二十多種CLONE_*的flag(標志位)參數來控制clone進程的方方面面。
- 通過setns()加入一個已經存在的namespace
在進程都結束的情況下,也可以通過掛載的形式把namespace保留下來,保留namespace的目的是為以后有進程加入作準備。在docker中,使用docker exec命令在意境運行的容器中執行新的命令,就需要用到該方法。通過setns()系統調用,進程從原先的namespace加入到某個已經存在的namespace,通常為了使新加入的pid namespace生效,會在setns()函數執行后使用clone()創建子進程繼續執行新命令,讓原先的進程結束運行。
- 通過unshare()在原先進程上進行namespace隔離
最后要說明的系統調用是unshare(),它與clone()很像,不同的是,unshare()運行在原先的進程上,不需要啟動一個新進程。
調用unshare()的主要作用就是,不啟動新進程就可以起到隔離的作用,相當于跳出原先的namespace進行操作,這樣,就可以在原進程進行了一些需要隔離的操作。linux中自帶unshare命令,就是通過unshare()系統調用實現的,docker目前并沒有使用這個系統調用。
- fork()系統調用
系統調用函數fork()并不屬于namespace的API,當程序調用fork()函數時,系統會創建新的進程,為其分配資源,例如存儲數據和代碼的空間,然后把原來進程的所有值復制到新的進程中,只有少量數值與原來的進程不同,相當于復制了本身。那么程序的后續代碼邏輯要如何區分是子進程還是父進程呢?
fork()的神奇之處在于它被調用一次,卻能返回兩次(父進程與子進程各返回一次),通過返回值的不同就可以區分父進程與子進程。他可能有以下3種不同的返回值:
使用fork()后,父進程有義務監控子進程的運行狀態,并在子進程推出后才能正常退出,否則子進程就會成為“孤兒”進程
下面將根據docker內部對namespace資源隔離使用方式分別對6種namespace進行解析。
2.UTS namespace
UTS(UNIX Time-sharing System)namespace提供了主機名與域名的隔離,這樣每個docke容器就可以擁有獨立的主機名和域名了,在網絡上可以被視為一個獨立的節點,而非宿主機上的一個進程。docker中,每個鏡像基本都以自身所提供的服務名稱來命名鏡像的hostname,且不會對宿主機產生任何影響,其原理就是使用了UTS namespace
3.IPC namespace
進程間通信(Inter-Process Communication,IPC)涉及的IPC資源包括常見的信號量、消息隊列和共享內存。申請IPC資源就申請了一個全局唯一的32位ID,所以IPC namespace中實際上包含了系統IPC標識符以及實現POSIX消息隊列的文件系統。在同一個IPC namespace下的進程彼此可見,不同IPC namespace下的進程則互相不可見。
目前使用IPC namespace機制的系統不多,其中比較有名的有PostgreSQL。Docker當前也使用IPC namespace實現了容器與宿主機、容器與容器之間的IPC隔離。
4.PID namespace
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中的init進程
在傳統的Unix系統中,PID為1的進程時init,地位非常特殊。它作為所有進程的父進程,維護一張進程表,不斷檢查進程狀態,一旦某個子進程因為父進程錯誤成為了“孤兒”進程,init就會負責收養這個子進程并最終回收資源,結束進程。所以在要實現的容器中,啟動的第一個進程也需要實現類似init的功能,維護所有后續啟動進程的狀態。
當系統中存在的樹狀嵌套結構的PID namespace時,若某個子進程成為了孤兒進程,收養孩子進程的責任就交給了孩子進程所屬的PID namespace中的init進程。
至此,讀者可以明白內核設計的良苦用心。PID namespace維護這樣一個樹狀結構,有利于系統的資源的控制與回收。因此,如果確實需要在一個Docker容器中運行多個進程,最先啟動的命令進程應該是具有資源監控與回收等管理能力的,如bash。 -
信號與init進程
內核還為PID namespace中的init進程賦予了其他特權—信號屏蔽。如果init中沒有編寫處理某個信號的代碼邏輯,那么與init在同一個PID namespace下的進程(即使有超級權限)發送非他的信號都會屏蔽。這個功能主要作用就是防止init進程被誤殺。
那么,父節點PID namespace中的進程發送同樣的信號給子節點中的init的進程,這會被忽略嗎?父節點中的進程發送的信號,如果不是SIGKILL(銷毀進程)或SIGSTOPO(暫停進程)也會誒忽略。但如果發送SIGKILL或SIGSTOP,子節點的init會強制執行(無法通過代碼捕捉進行特殊處理),也就是說父節點中的進程有權終止子進程。
一旦init進程被銷毀,同一PID namespace中的其他進程也所致接收到SIGKIKLL信號而被銷毀。理論上,該PID namespace也不復存在了。但如果/proc/[pid]/ns/pid處于被掛載或打開的狀態,namespace就會被保留下來。然而,被保留下來的namespace無法通過setns()或者fork()創建進程,所以實際上并沒有什么作用。
當一個容器內存在多個進程時,容器內的init進程可以對信號進行捕獲,當SIGTERM或SIGINT等信號到來時,對其子進程做信息保存、資源回收等處理工作。在Docker daemon的源碼中也可以看到類似的處理方式,當結束信號來臨時,結束容器進程并回收相應資源。 -
掛載proc文件系統
前文提到,如果在新的PID namespace中使用使用ps命令查看,看到的還是所有的進程,因為與PID直接相關的/proc文件系統(procfs)沒有掛載到一個與原/proc不同的位置。如果只想看到PID namespace本身應該看到的進程,需要重新掛載/proc,命令如下。
-
unshare()和setns()
本文開頭就談到了unshare()和setns()這兩個API,在PID namespace中使用,也有一些特別之處需要注意。
unshare()允許用戶在原有進程中建立命名空間進行隔離。但創建了PID namespace后,原先unshare()調用者進程并不進入新的PID namespace,接下來創建的子進程才會進入新的namespace,這個子進程也就隨之成為新的namespace中的init進程。
類似地,調用setns()創建新PID namespace時,調用者進程也不進入新的PID namespace,而是隨后創建的子進程進入。
為什么創建其他namespace時unshare()和setns()會直接進入新的namespace,二唯獨PID namespace例外呢?因為調用getpid()函數得到的PID是根據調用者所在的PID namespace而決定返回哪個PID,進入新的PID namespace會導致PID產生變化。而對用戶態的程序和庫函數來說,他們都認為進程的PID是一個常量,PID的變化會引起這些進程崩潰。
換句話說,一旦程序進程創建以后,那么它的PID namespace的關系就確定下來了,進程不會變更它們對應的PID namespace。在Docker中,docker exec會使用setns()函數加入已經存在的命名空間,但是最終還是會調用clone()函數,原因就在于此。 -
mount namespace
mount namespace通過隔離文件系統掛載點對隔離文件系統提供支持,它是歷史上第一個Linux namespace,所以標示位比較特殊,就是CLONE_NEWNS。隔離后,不同的mount namespace中的文件結構發生變化也互不影響。也可以通過/proc/[pid]/mounts查看到所有掛載在當前namespace中的文件系統,還可以通過/proc/[pid]/mountstats看到mount namespace中文件設備的統計信息,包括掛載文件的名字、文件系統的類型、掛載位置等。
進程在創建mount namespace時,會把當前的文件結構復制給新的namespace。新namespace中的所有mount操作都只影響自身的文件系統,對外界不會產生任何影響。這種做法非常嚴格的實現了隔離,但對某些狀況可能并不適用。比如父節點namespace中的進程掛載了一張CD-ROM,這時子節點namespace復制的目錄結構是無法自動掛載上這張CD-ROM的,因為這種操作會影響到父節點的文件系統。
一個掛載狀態可能為以下一種:
傳播事件的掛載對象稱為共享掛載;接收傳播事件的掛載對象稱為從屬掛載;同時兼有前述兩者特征的掛載對象為共享/從屬掛載;既不傳播也不接受事件的掛載對象稱為私有掛載;另一種特殊的掛載對象稱為不可綁定掛載,它們與私有掛載相似,但不允許執行綁定掛載,即創建mount namespace時這塊文件對象不可被復制。
6.netword namespace
network namespace主要提供了關于網絡資源的隔離,包括網絡設備、IPv4和IPv6協議棧、IP路由表、防火墻、/proc/net目錄、/sys/class/net目錄、socket等。一個物理的網絡設備最多存在于一個network namespace中,可以通過創建veth pair(虛擬網絡設備對:有兩端,類似管道,如果數據從一端傳入另一端也能接受,反之亦然)在不同的network namespace間創建通道,以達到通信目的。
也許你會好奇,在建立起veth pair之前,新舊namespace該如何通信呢?答案是pipe(管道)。以Docker daemon啟動容器的過程為例,假設容器內初始化的進程稱為init。Docker daemon在宿主機上負責創建這個veth pair,把一段綁定到docker0網橋上,另一端介入新建的network namespace進程中。這個過程執行期間,Docker daemon和init就通過pipe進行通信。具體來說,就是在Docker deamon完成veth pair的創建之前,init在管道的另一端循環等待,直到管道另一端傳來Docker daemon關于veth設備的信息,并關閉管道。init才結束等待的過程,并把它的“eth0”啟動起來。
與其他namespace類似,對network namespace的使用其實就是在創建的時候添加CLONE_NEWNET標識符位。
7.user namespace
user namespace主要隔離了安全相關的標識符(identifier)和屬性(attribute),包括用戶ID、用戶組ID、root目錄、key(指密鑰)以及特殊權限。通俗地講,一個普通用戶的進程通過clone()創建的新進程在新user namespace中可以擁有不同的用戶和用戶組。這意味著一個進程在容器外屬于一個沒有特權的普通用戶,但是它創建的容器進程卻屬于擁有所有權限的超級用戶,這個技術為容器提供了極大的自由。
user namespace時目前的6個namespace中最后一個支持的,并且直到linux內核3.8版本的時候還未完全實現(還有部分文件系統不支持)。user namespace實際上并不算完全成熟,很多發行版擔心安全問題,在編譯內核的時候并未開啟USER_NS。Docker在1.10版本中對user namespace進行了支持。只要用戶在啟動Docker daemon的時候制定了–user-remap,那么當用戶運行容器時,容器內部的root用戶并不等于宿主機的root用戶,而是映射到宿主機上的普通用戶。
Docker不僅使用了user namespace,還使用了在user namespace中涉及的Capability機制。從內核2.2版本開始,Linux把原來和超級用戶相關的高級權限分為不同的單元,稱為Capability。這樣管理員就可以獨立的對特定的Capability進行使用或禁止。Docker同時使用namespace和Capability,這很大程度上加強了容器的安全性。
說到安全,namespace的6項隔離看似全面,實際上已久沒有完全隔離Linux的資源,比如SELinux、cgroups以及/sys、/proc/sys、/dev/sd*等目錄下的資源。關于安全我會在接下來的文章中進一步討論。下一張暉介紹cgroups。
下面鏈接是關于docker的源碼分析的電子版圖書,可自由下載。
鏈接:https://pan.baidu.com/s/1gRQHd4SC8Hhmmh-TJPB0Gw 密碼:v8dl
總結
以上是生活随笔為你收集整理的Docker核心原理之namespace的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: mac包安装kafka
- 下一篇: Docker核心原理之cgroups