Docker容器實現原理及容器隔離性踩坑介紹

2019-07-16   IT技術分享

正如Docker官方的口號:「Build once,Run anywhere,Configure once,Run anything」,Docker被貼上了如下標籤:輕巧、秒級啟動、版本管理、可移植性等等,這些優點讓它出現之初就收到極大的關注。現在,Docker已經不僅僅是開發測試階段使用的工具,大家已經在生產環境中大量使用。今天我們給大家介紹關於容器隔離性的一個「坑」。在此之前,我們先來回顧一下Docker容器的底層實現原理。

容器底層實現

我們都知道,虛擬機與容器的底層實現原理是不同的,正如下圖對比:

虛擬機實現資源隔離的方法是利用一個獨立的Guest OS,並利用Hypervisor虛擬化CPU、內存、IO設備等實現的。例如,為了虛擬化內存,Hypervisor會創建一個shadow page table,正常情況下,一個page table可以用來實現從虛擬內存到物理內存的翻譯。相比虛擬機實現資源和環境隔離的方案,Docker就顯得簡練很多,它不像虛擬機一樣重新加載一個作業系統內核,引導、加載作業系統內核是一個比較耗時而又消耗資源的過程,Docker是利用Linux內核特性實現的隔離,運行容器的速度幾乎等同於直接啟動進程。

關於Docker實現原理,簡單總結如下:

  • 使用Namespaces實現了系統環境的隔離,Namespaces允許一個進程以及它的子進程從共享的宿主機內核資源(網絡棧、進程列表、掛載點等)里獲得一個僅自己可見的隔離區域,讓同一個Namespace下的所有進程感知彼此變化,對外界進程一無所知,仿佛運行在一個獨占的作業系統中;
  • 使用CGroups限制這個環境的資源使用情況,比如一台16核32GB的機器上只讓容器使用2核4GB。使用CGroups還可以為資源設置權重,計算使用量,操控任務(進程或線程)啟停等;
  • 使用鏡像管理功能,利用Docker的鏡像分層、寫時複製、內容尋址、聯合掛載技術實現了一套完整的容器文件系統及運行環境,再結合鏡像倉庫,鏡像可以快速下載和共享,方便在多環境部署。

正因為Docker不像虛機虛擬化一個Guest OS,而是利用宿主機的資源,和宿主機共用一個內核,所以會存在下面問題:

注意:存在問題並不一定說就是安全隱患,Docker作為最重視安全的容器技術之一,在很多方面都提供了強安全性的默認配置,其中包括:容器root用戶的 Capability 能力限制,Seccomp系統調用過濾,Apparmor的 MAC 訪問控制,ulimit限制,鏡像簽名機制等。

1、Docker是利用CGroups實現資源限制的,只能限制資源消耗的最大值,而不能隔絕其他程序占用自己的資源;

2、Namespace的6項隔離看似完整,實際上依舊沒有完全隔離Linux資源,比如/proc 、/sys 、/dev/sd*等目錄未完全隔離,SELinux、time、syslog等所有現有Namespace之外的信息都未隔離。

容器隔離性踩過的坑

在使用容器的時候,大家很可能遇到過這幾個問題:

1、在Docker容器中執行 top、free 等命令,會發現看到的資源使用情況都是宿主機的資源情況,而我們需要的是這個容器被限制了多少CPU,內存,當前容器內的進程使用了多少;

2、在容器里修改/etc/sysctl.conf,會收到提示」sysctl: error setting key 『net.ipv4….』: Read-only file system」;

3、程序運行在容器裡面,調用API獲取系統內存、CPU,取到的是宿主機的資源大小;

4、對於多進程程序,一般都可以將worker數量設置成auto,自適應系統CPU核數,但在容器裡面這麼設置,取到的CPU核數是不正確的,例如Nginx,其他應用取到的可能也不正確,需要進行測試。

這些問題的本質都一樣,在Linux環境,很多命令都是通過讀取/proc 或者 /sys 目錄下文件來計算資源使用情況,以free命令為例:

lynzabo@ubuntu:~$ strace free
execve("/usr/bin/free", ["free"], [/* 66 vars */]) = 0
...
statfs("/sys/fs/selinux", 0x7ffec90733a0) = -1 ENOENT (No such file or directory)
statfs("/selinux", 0x7ffec90733a0) = -1 ENOENT (No such file or directory)
open("/proc/filesystems", O_RDONLY) = 3
...
open("/sys/devices/system/cpu/online", O_RDONLY|O_CLOEXEC) = 3
...
open("/proc/meminfo", O_RDONLY) = 3
+++ exited with 0 +++
lynzabo@ubuntu:~$複製代碼

包括各個語言,比如Java,NodeJS,這裡以NodeJS為例:

const os = require('os');const total = os.totalmem();const free = os.freemem();const usage = (free - total) / total * 100;複製代碼

NodeJS的實現,也是通過讀取/proc/meminfo文件獲取內存信息。Java也是類似。

我們都知道,JVM默認的最大Heap大小是系統內存的1/4,假若物理機內存為10G,如果你不手動指定Heap大小,則JVM默認Heap大小就為2.5G。JavaSE8(<8u131) 版本前還沒有針對在容器內執行高度受限的Linux進程進行優化,JDK1.9以後開始正式支持容器環境中的CGroups內存限制,JDK1.10這個功能已經默認開啟,可以查看相關Issue (Issue地址:https://bugs.openjdk.java.net/browse/JDK-8146115 )。熟悉JVM內存結構的人都清楚,JVM Heap是一個只增不減的內存模型,Heap的內存只會往上漲,不會下降。在容器裡面使用Java,如果為JVM未設置Heap大小,Heap取得的是宿主機的內存大小,當Heap的大小達到容器內存大小時候,就會觸發系統對容器OOM,Java進程會異常退出。常見的系統日誌列印如下:

memory: usage 2047696kB, limit 2047696kB, failcnt 23543
memory+swap: usage 2047696kB, limit 9007199254740991kB, failcnt 0
......
Free swap = 0kB
Total swap = 0kB
......
Memory cgroup out of memory: Kill process 18286 (java) score 933 or sacrifice child複製代碼

對於Java應用,下面提供兩個辦法來設置Heap

1、對於JavaSE8(<8u131)版本,手動指定最大堆大小。

docker run的時候通過環境變量傳參確切限制最大heap大小:

docker run -d -m 800M -e JAVA_OPTIONS='-Xmx300m' openjdk:8-jdk-alpine複製代碼

2、對於JavaSE8(>8u131)版本,可以使用上面手動指定最大堆大小,也可以使用下面辦法,設置自適應容器內存限制。

docker run的時候通過環境變量傳參確切限制最大heap大小

docker run -d -m 800M -e JAVA_OPTIONS='-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:MaxRAMFraction=1' openjdk:8-jdk-alpine複製代碼

對比這兩種方式,第一種方式缺乏靈活性,在確切知道內存限制大小的情況下可以使用,第二種方法必須在JavaSE8(>8u131)版本才能使用。

當你啟動一個容器時候,Docker會調用libcontainer實現對容器的具體管理,包括創建UTS、IPS、Mount等Namespace實現容器之間的隔離和利用CGroups實現對容器的資源限制,在其中,Docker會將宿主機一些目錄以只讀方式掛載到容器中,其中包括/proc、/dev、/dev/shm、/sys目錄,同時還會建立以下幾個連結:

  • /proc/self/fd->/dev/fd
  • /proc/self/fd/0->/dev/stdin
  • /proc/self/fd/1->/dev/stdout
  • /proc/self/fd/2->/dev/stderr

保證系統IO不會出現問題,這也是為什麼在容器裡面取到的是宿主機資源原因。

了解了這些,那麼我們在容器里該如何獲取實例資源使用情況呢,下面介紹兩個方法。

從CGroups中讀取

Docker 在 1.8 版本以後會將分配給容器的CGroups信息掛載進容器內部,容器裡面的程序可以通過解析CGroups信息獲取到容器資源信息。

在容器裡面可以運行mount 命令查看這些掛載記錄

...
cgroup on /sys/fs/cgroup/cpuset type cgroup (ro,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/cpu type cgroup (ro,nosuid,nodev,noexec,relatime,cpu)
cgroup on /sys/fs/cgroup/cpuacct type cgroup (ro,nosuid,nodev,noexec,relatime,cpuacct)
cgroup on /sys/fs/cgroup/memory type cgroup (ro,nosuid,nodev,noexec,relatime,memory)
cgroup on /sys/fs/cgroup/devices type cgroup (ro,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/freezer type cgroup (ro,nosuid,nodev,noexec,relatime,freezer)
cgroup on /sys/fs/cgroup/blkio type cgroup (ro,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/perf_event type cgroup (ro,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (ro,nosuid,nodev,noexec,relatime,hugetlb)
...複製代碼

在這裡我們不講解CGroups對CPU和內存的限制都有哪些,只介紹基於Kubernetes編排引擎下的計算資源管理,對容器CGroups都做了哪些支持:

  • 當為Pod指定了requests,其中requests.cpu會作為--cpu-shares 參數值傳遞給docker run 命令,當一個宿主機上有多個容器發生CPU資源競爭時這個參數就會生效,參數值越大,越容易被分配到CPU,requests.memory不會作為參數傳遞給Docker,這個參數在Kubernetes的資源QoS管理時使用;
  • 當為Pod指定了limits,其中limits.cpu會作為--cpu-quota 參數的值傳遞給docker run 命令,docker run命令中另外一個參數--cpu-period 默認設置為100000,通過這兩個參數限制容器最多能夠使用的CPU核數,limits.memory會作為--memory 參數傳遞給docker run 命令,用來限制容器內存,目前Kubernetes不支持限制Swap大小,建議在部署Kubernetes時候禁用Swap。

Kubernetes 1.10以後支持為Pod指定固定CPU編號,我們在這裡不詳細介紹,就以常規的計算資源管理為主,簡單講一下以Kubernetes作為編排引擎,容器的CGroups資源限制情況:

1、讀取容器CPU核數

# 這個值除以100000得到的就是容器核數
~ # cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
400000複製代碼

2、獲取容器內存使用情況(USAGE / LIMIT)

~ # cat /sys/fs/cgroup/memory/memory.usage_in_bytes 
4289953792
~ # cat /sys/fs/cgroup/memory/memory.limit_in_bytes
4294967296複製代碼

將這兩個值相除得到的就是內存使用百分比。

3、獲取容器是否被設置了OOM,是否發生過OOM

~ # cat /sys/fs/cgroup/memory/memory.oom_control 
oom_kill_disable 0
under_oom 0
~ #
~ #複製代碼

這裡需要解釋一下:

  • oom_kill_disable默認為0,表示打開了oom killer,就是當內存超時會觸發kill進程。可以在使用docker run時候指定disable oom,將此值設置為1,關閉oom killer;
  • under_oom 這個值僅僅是用來看的,表示當前的CGroups的狀態是不是已經oom了,如果是,這個值將顯示為1。

4、獲取容器磁碟I/O

~ # cat /sys/fs/cgroup/blkio/blkio.throttle.io_service_bytes
253:16 Read 20015124480
253:16 Write 24235769856
253:16 Sync 0
253:16 Async 44250894336
253:16 Total 44250894336
Total 44250894336複製代碼

5、獲取容器虛擬網卡入/出流量

~ # cat /sys/class/net/eth0/statistics/rx_bytes 
10167967741
~ # cat /sys/class/net/eth0/statistics/tx_bytes
15139291335
~ #複製代碼

如果你對從容器中讀取CGroups感興趣,可以點擊最下方「閱讀原文」了解docker stats源碼實現。

使用LXCFS

由於習慣性等原因,在容器中使用top、free等命令仍然是一個較為普遍存在的需求,但是容器中的/proc、/sys目錄等還是掛載的宿主機目錄,有一個開源項目:LXCFS。LXCFS是基於FUSE實現的一套用戶態文件系統,使用LXCFS,讓你在容器裡面繼續使用top、free等命令變成了可能。但需要注意,LXCFS可能會存在很多問題,建議在線上環境先不要使用。

總結

容器給大家帶來了很多便利,很多公司已經或正在把業務往容器上遷移。在遷移過程中,需要清楚上面介紹的這個問題是不是會影響應用的正常運行,並採取相應的辦法繞過這個坑。