Docker容器構建優秀實踐
在容器和虛擬化廣泛流行的今天,如何構建出一個安全、潔凈的容器是大家都關心的問題。和安全領域對系統的要求一樣"只安裝必須的應用"的最小化原則也是容器構建的基本法則。一方面最小化的應用可以減小鏡像的大小,節省上傳下載的時間,同時減少了容器中的應用也就減少了可入侵點,使容器更安全。本文介紹我們給大家介紹了一套總結了業界實踐的容器的優秀實踐。目的是讓容器構建更加快速,更安全,也更具彈性。本文假設讀者有一定Docker和Kubernetes了解,但也可以單獨作為Docker容器構建守則。
一個容器一個應用程序
當開始使用容器時,常見的一個誤區是把容器當成虛擬機來使。這樣做往往會讓他們不能輕易滿足某些需求而非常痛苦,同時也背離了容器的最大優勢點。很多初學者在群里問了很多為么:為什么Docker不能aaa?怎么實現Docker bbb?然后他需要的答案其實上只是需要一個虛擬。雖然現代容器已經可以滿足這些需求,但是這會極大減弱容器模型的大多數優點。以經典的Apache/MySQL/PHP堆棧為例,你可能很想在一個容器中運行所有組件。但是,最佳實踐是使用兩個或三個不同的容器:Apache容器,MySQL容器,和運行PHP-FPM的php容器。
由于容器設計思想是容器和托管的應用程序具有相同的生命周期,因此每個容器應當只包含一個應用程序。當容器啟動時,應用程序隨之啟動,當容器停止時,應用也會停止。
容器如果部署了多個應用,則它們有可能具有不同的生命周期或處于不同的狀態。例如,一個正在運行的容器,但其核心組件之一突然崩潰或無響應。由于沒有額外的運行狀況檢查,整個容器管理系統(Docker或Kubernetes)將無法判斷容器是否健康。在Kubernetes集群中,容器默認是不會重啟的。
有些公共鏡像中可能會使用如下有一些的操作,但不遵循原則:
使用進程管理系統(比如supervisor)來管理容器中的一個或多個應用。使用bash腳本作為容器中的入口點,并使其產生多個應用程序作為后臺作業。
信號處理,PID 1和僵尸進程
Linux信號是控制容器內進程生命周期的主要方法。為了與前一條最佳實踐保持一致,為了將應用程序的生命周期和容器關連,請確保應用程序能夠正確處理Linux信號。其中最重要的一個Linux信號是SIGTERM,因為它用來終止進程。應用可能還會收到SIGKILL信號,用于非正常地終止進程,或者SIGINT信號,用于接受,鍵入的Ctrl + C指令。
進程標識符(PID)是Linux內核為每個進程提供的唯一標識符。 PID具有名稱空間,容器具有自己的一組PID,這些PID會被映射到宿主機系統的PID。Linux內核啟動時會創建第一個進程具有PID1。用來init系統用來管理其他進程,比如systemd或SysV。同樣,容器中啟動的第一個進程也是PID1。Docker和Kubernetes使用信號與容器內的進程進行通信。Docker和Kubernetes都只能向容器內具有PID 1的進程發送信號。
在容器環境中,需要考慮兩個PIDs和Linux信號的問題。
Linux內核如何處理信號?
Linux內核處理PID 1的進程方式與對其他進程不同。PID1,不會自動注冊信號量SIGTERM,所以SIGTERM或SIGINT默認對PID 1無效。默認必須使用SIGKILL信號來殺掉進程,無法優雅的關閉進程,可能會導致錯誤,監視數據寫入中斷(對于數據存儲)以及一些不必要的告警。
典型的初始化系統如何處理孤立進程?
典型的初始化系統(例如systemd)也用被用來刪除(捕獲)孤立的僵尸進程。僵尸進程(其父進程已死亡的進程)將會被附加到具有PID 1的進程下,被其捕獲關閉。但是在容器中,需要映射到容器PID 1的進程來處理。如果該進程無法正確處理,則可能會出現內存不足或其他資源不足的風險。
面對這些問題有幾種常見的解決方案:
1. 以PID 1運行并注冊信號處理程序
該方案用來解決第一個問題。如果應用以受控方式生成子進程(通常是這種情況)是有效的,可以避免第二個問題。最簡單方法是在Dockerfile中使用CMD和/或ENTRYPOINT指令啟動你的進程。例如,下面的Dockerfile中,nginx是第一個也是唯一要啟動的進程。
- FROM debian:9
- RUN apt-get update && \
- apt-get install -y nginx
- EXPOSE 80
- CMD [ "nginx", "-g", "daemon off;" ]
注意:Nginx進程注冊其自己的信號處理程序。使用此解決方案,在許多情況下,你必須在應用程序代碼中執行相同的操作。
有時可能需要準備容器中的環境以使進程正常運行。在這種情況下,最佳實踐是讓容器在啟動時運行shell初始化腳本。該Shell腳本的用來配置所需的環境并啟動主要進程。但是,如果采用這種方法,則shell腳本擁有PID 1,這就是為什么必須使用內置exec命令從shell腳本啟動進程的原因。exec命令將腳本替換為所需的程序,進程將繼承PID 1。
2. 在Kubernetes中啟用進程名稱空間共享
為Pod啟用進程名稱空間共享時,Kubernetes對該Pod中的所有容器使用單個進程名稱空間。 Kubernetes Pod基礎容器成為PID 1,并自動捕獲孤立的進程。
3. 使用專門的初始化系統
就像在更經典的Linux環境中一樣,也可以使用init系統來解決這些問題。但是,如果用于這個目的,普通的初始化系統(例如systemd或SysV)太復雜且太重,建議使用專門的容器創建的初始化系統(例如tini)。
如果使用容器專用的初始化系統,則初始化進程具有PID 1,并執行以下操作:
- 注冊正確的信號處理程序。
- 確保信號對你的應用程序有效。
- 捕獲所有僵尸進程。
可以通過使用docker run命令的--init選項在Docker中使用此解決方案。要在Kubernetes中使用,則必須在容器鏡像中先安裝init系統,并將其用作容器的入口。
優化Docker構建緩存
Docker的構建緩存可以極大的加速容器鏡像的構建。在容器系統中鏡像是逐層構建的,在Dockerfile中,每條指令都會在鏡像中創建一個層。在構建期間,如果可能,Docker會嘗試重用先前構建中一層,盡可能跳過其底層來減少構建消耗成本的步驟。Docker只有在所有先前的構建步驟都使用它的情況下,才能使用其構建緩存。盡管這種做法通常使構建更快,但需要考慮一些情況。
例如,要充分利用Docker構建緩存,必須將需要經常更改的構建步驟放在Dockerfile的后面。如果將它們放在前面,則Docker無法將其構建緩存用于其他更改頻率較低的構建步驟。通常為源代碼的每個新版本構建一個新的Docker鏡像,所以應盡可能在Dockerfile的后面將源代碼添加到鏡像。如下圖,你可以看到,如果要更改了STEP 1,則Docker只能重用FROM FROM debian:9步驟中的層。但是,如果更改STEP 3,則Docker可以將這些層重新用于STEP 1和STEP 2。
圖中藍色表示可以重用的層,紅色表示必須重建的層。層的重用原則導致另一個后果,如果構建步驟依賴于存儲在本地文件系統上的任何類型的緩存,則該緩存必須在同一構建步驟中生成。如果未生成此緩存,則可能會使用來自先前構建的過期的緩存來執行的構建步驟。通過apt或yum等程序包管理器中最常有這個問題,必須在一個RUN命令中同時安裝所有必須要的庫。如果更改下面Dockerfile中的第二個RUN步驟,則不會重新運行apt-get update命令,從而導致過期的apt緩存。
- FROM debian:9
- RUN apt-get update
- RUN apt-get install -y nginx
而是,在單個RUN步驟中合并兩個命令:
- FROM debian:9
- RUN apt-get update && \
- apt-get install -y nginx
刪除不必要的工具
為了保護你的應用免受攻擊者的侵害,請嘗試通過刪除所有不必要的工具來減少應用的攻擊面。例如,刪除諸如netcat之類的實用程序,因為可以用necat隨便就能構建一個反向shell。如果容器中不安裝netcat,則攻擊者無法這樣簡單利用。
即使沒有容器化,此最佳實踐也適用于任何工作負載。區別在于,與經典虛擬機或裸機服務器相比,使用容器實現起來要容易得多。
其中一些工具可能對于調試有用。例如,如果你將此最佳實踐推得足夠遠,那么詳盡的日志,跟蹤,概要分析和應用程序性能管理系統將成為必不可少的。實際上,你不再可以依賴本地調試工具,因為它們通常具有很高的特權。
文件系統內容
鏡像中應盡可能少的保留內容。如果可以將應用程序編譯為單個靜態鏈接的二進制文件,則將該二進制文件添加到暫存鏡像中將使獲得最終鏡像,該鏡像僅包含一個應用程序,無其他內容。通過減少鏡像中打包的工具數量,可以減少潛在的可在容器中執行的操作。
文件系統安全
鏡像中沒有工具是不夠的。必須防止潛在的攻擊者安裝工具。可以在此處組合使用兩種方法:
首先,避免以root用戶身份在容器內運行。該方法提供了第一層安全性,并且可以防止攻擊者使用嵌入在鏡像中的包管理器(如apt-get或apk)修改root擁有的文件。為了使用該方法,必須禁用或卸載sudo命令。
以只讀模式啟動容器,可以通過使用docker run命令中的--read-only標志或使用Kubernetes中的readOnlyRootFilesystem選項來執行此操作。可以使用PodSecurityPolicy在Kubernetes中強制執行此操作。
注意:如果應用需要將臨時數據寫入磁盤,也可以使用readOnlyRootFilesystem選項,只需為臨時文件添加emptyDir卷。Kubernetes中不支持emptyDir卷上的掛載,所以不能在啟用noexec標志的情況下掛載該卷。
最小化鏡像
生成較小的鏡像具有諸如更快的上載和下載時間等優點,這對于Kubernetes中pod的冷啟動時間尤為重要:鏡像越小,節點下載就越快。但是,構建小型鏡像很難,因為可能會在無意中給最終鏡像引入了構建依賴項或未優化的鏡像層。
使用最小的基礎鏡像
基礎鏡像是Dockerfile中FROM指令中所引用的鏡像。Dockerfile中的所有指令均基于該鏡像構建。基礎鏡像越小,生成的鏡像就越小,下載和加載就越快。例如,alpine:3.7鏡像比centos:7鏡像就小好幾十M。
我們甚至還可使用 scratch基礎鏡像,這是一個空鏡像,可以在其上構建自己的運行時環境。如果需要運行的應用程序是靜態鏈接的二進制文件,使用暫存基礎鏡像非常容易:
- FROM scratch
- COPY mybinary /mybinary
- CMD [ "/mybinary" ]
GoogleContainerTools的Distroless項目提供了多種語言(Java,Python(3),Golang,Node.js,dotnet)的基礎鏡像。鏡像僅包含語言的運行時,剔除了Linux發行版的很多工具,例如Shell,應用包管理器等,下面項目的一個Golang的列子:
減少鏡像無效刪減
要減小鏡像的大小,需要嚴格遵守只安裝必須的應用的原則。可能有時候需要臨時安裝一些工具的軟件包,使用后在后面的步驟中再刪除。但是,這種方法也是有問題的。因為Dockerfile的每條指令都會創建一個鏡像層,創建后,再在稍后的步驟中刪除的方法,實際不能減少鏡像的大小。(數據還在,只是被隱藏在底層而已)。比如:
錯誤Dockerfile:
- FROM debian:9
- RUN apt-get update && \
- apt-get install -y \
- [buildpackage]
- RUN [build my app]
- RUN apt-get autoremove --purge \
- -y [buildpackage] && \
- apt-get -y clean && \
- rm -rf /var/lib/apt/lists/*
正確Dockerfile:
- FROM debian:9
- RUN apt-get update && \
- apt-get install -y \
- [buildpackage] && \
- [build my app] && \
- apt-get autoremove --purge \
- -y [buildpackage] && \
- apt-get -y clean && \
- rm -rf /var/lib/apt/lists/*
在錯誤版本Dockerfile中,[buildpackage]和/var/lib/ap /lists/*中的文件仍然存在于與第一個RUN相對應的鏡像層中。該層是鏡像的一部分,盡管里面的數據在最終鏡像中不可訪問,但也會和其他鏡像層一起上傳和下載。
在正確版本Dockerfile中,所有操作都在構建的應用程序的同一層中完成。 /var/lib/apt/lists/*中的[buildpackage]和文件在最終鏡像中不會存在,真正起到了刪除的效果。
減少鏡像無效刪除的另一種方法是使用多階段構建(Docker 17.05中引入)。多階段構建允許在第一個"構建"容器中構建應用程序,并在使用相同Dockerfile的同時在另一個容器中使用結果。
在下面的Dockerfile中,hello二進制文件內置在第一個容器中,并注入了第二個容器。因為第二個容器是從頭開始的,所以生成的鏡像僅包含hello二進制文件,而不包含構建期間所需的源文件和目標文件。但是,二進制文件是必須靜態鏈接才能正常工作。
- FROM golang:1.10 as builder
- WORKDIR /tmp/go
- COPY hello.go ./
- RUN CGO_ENABLED=0 go build -a -ldflags '-s' -o hel
- lo
- FROM scratch
- CMD [ "/hello" ]
- COPY --from=builder /tmp/go/hello /hello
嘗試創建具有公共鏡像層的鏡像
如果必須下載Docker鏡像,則Docker首先檢查鏡像中是否已經包含某些層。如果你具有這些鏡像層,就不會下載。如果以前下載的其他鏡像與當前下載的鏡像具有相同的基礎鏡像,則當前鏡像的下載數據量會少很多。
在企業內部,可以為開發人員提供一組通用的標準基礎鏡像來減少必要的下載。系統只會下載每個基礎鏡像一次,初始下載后,只需要使每個鏡像中不同的鏡像層,鏡像的共同層越多,下載速度就越快。如下圖中紅色框的基礎鏡像就只需下載一次。
容器注冊表進行漏洞掃描
對服務器和虛擬機,軟件漏洞掃描是常用的一個安全手段,通過集中式軟件掃描系統,列出了每臺主機上安裝的軟件包和存在的漏洞源,并及時通知管理員修補漏洞,比如蟲蟲之前的文章中介紹過的Flan Scan系統。
由于容器原則上是不可變的,所以不建議對存在漏洞的情況下對其進行漏洞修補。最佳實踐是對其重建鏡像,打包補丁程序,然后重新部署。與服務器相比,容器的生命周期要短得多,身份標識的定義要好得多。因此,使用類似的集中檢測容器中漏洞的一種不好的方法。
為了解決這個問題,可以在托管鏡像的容器注冊表(Container Registry)中進行漏洞掃描。這樣就可以在發現容器鏡像中的漏洞。將鏡像上傳到注冊表時或漏洞庫更新時,就會進行掃描,或者啟動計劃任務定期掃描。
檢測到漏洞后,可以使用腳本來觸發自動漏洞修補過程。最好是結合版本管理(比如Gitlab)CI/CD管道來持續進行鏡像構建來進行漏洞修補。一般步驟如下:
- 將鏡像存儲在容器注冊表中并啟用漏洞掃描。
- 配置一個作業,該作業定期從容器注冊表中獲取新漏洞,并在需要時觸發鏡像重建。
- 構建新鏡像后,通過持續部署系統CD來將鏡像部署到驗證環境中。
- 手動檢查驗證是否正常。
- 如果未發現問題,請手動推送灰度部署到生產環境。
正確標記鏡像
Docker鏡像通常由兩個部分標識:它們的名稱和標簽。例如,對于centos:8.0.1鏡像,centos是名稱,而8.0.1是標簽。如果在Docker命令中未提供最新標簽,則默認使用最新標簽。名稱和標簽對在任何給定時間都是應該唯一的。但是,可以根據需要將標簽重新分配給其他鏡像。構建鏡像時,需要正確標記,遵循統一一致標記策略。
容器鏡像是一種打包和發布軟件的方法。標記鏡像可讓用戶識別軟件的特定版本進行下載。因此,將容器鏡像上的標記系統關系到軟件的發布策略。
使用語義版本標記
發行軟件的常用方法是使用語義化版本號規范(The Semantic Versioning Specification)版本號來"標記"(如git tag命令中的)特定版本的源代碼。語義化版本號規范是為了改善各種軟件版本號格式混亂,語義不明的現狀由semver.org提出的一種處理版本號的規整方法。在該規范中軟件版本號由三部分構成:X.Y.Z,其中:
- X是主要版本,有向下不兼容的修改或者顛覆性的更新時增加。
- Y是次要版本,有向下兼容的修改或者添加兼容性的新功能時增加1。
- Z是補丁程序版本,僅僅是打一些兼容性補丁,做一些兼容性修復時增加。
- 次要版本號或補丁程序版本號中的每個增量都必須是向后兼容的更改。
如果該系統或類似系統,請按照以下策略標記鏡像:
- 最新標簽始終指的是最新(可能穩定)的鏡像。創建新鏡像后,該標簽即被移動。
- X.Y.Z標簽是指軟件的特定版本。請不要將其移動到其他鏡像。
- X.Y標記是指軟件X.Y次要分支的最新修補程序版本。當發布新的補丁程序版本時,它將被移動。
- X標記是指X主要分支的最新次要版本的最新補丁程序版本。當發布新的修補程序版本或新的次要版本時,它將移動。
使用此策略可以使用戶靈活地選擇他們要使用的軟件版本。他們可以選擇特定的X.Y.Z版本,并確保鏡像永不更改,或者可以通過選擇不太具體的標簽來自動獲取更新。
用Git提交哈希標記
如果你用持續交付系統并且經常發布軟件,則可能不能使用語義版本控制規范中描述的版本號。在這種情況下,處理版本號的常用方法是使用Git commit SHA-1哈希(或它的簡短版本)作為版本號。根據設計原理,Git的提交哈希是不可變的,并引用到軟件的特定版本。
可以將git提交哈希用作軟件的版本號,也可以用作軟件特定版本構建的Docker鏡像的標記。這樣可以使Docker鏡像具有可追溯性,在這種情況下image標記是不可變的,因此可以立即知道給定容器中正在運行哪個特定版本的軟件。在持續交付管道中,自動更新用于部署的版本號。
權衡公共鏡像的使用
Docker的一大優點是可用于各種軟件的大量公共可用鏡像。這些鏡像使你可以快速入門。但是,在為線上環境設計容器策略時,可能會遇到一些限制,使得公共提供的鏡像無法滿足要求。以下是可能導致無法使用公共鏡像的一些限制示例:
- 精確控制鏡像內部的內容。
- 不想依賴外部存儲庫。
- 想嚴格控制生產環境中的漏洞。
- 每個鏡像都需要相同的基礎操作系統。
對所有這些限制的對策都是相同的,并且但是有很高的代價,那就是必須構建自己的鏡像。對于數量有限的鏡像,可以自己構,但是當數目有增長的時。為了有機會大規模管理這樣的系統,可以考慮使用以下方法:
- 以可靠的方式自動生成鏡像,即使對于很少生成的鏡像也是如此。
- 解決鏡像漏洞,可以在容器注冊表中漏洞掃描。企業中不同團隊創建的鏡像執行內部標準的方法。有幾種工具可用來幫助在生成和部署的鏡像上實施策略:
- container-diff:可以分析鏡像的內容,甚至可以比較兩個鏡像之間的鏡像。
- container-structure-test:可以測試鏡像的內容是否符合定義一組規則。
- Grafeas:是一種工件元數據API,可以在其中存儲有關鏡像的元數據,以便以后檢查這些鏡像是否符合你的策略。
- Kubernetes具有準入控制器,在Kubernetes中部署工作負載之前,可以使用該準入控制器檢查許多先決條件。
- Kubernetes還具有Pod安全策略,可用于在群集中強制使用安全選項。
- 也能采用一種混合系統:使用諸如Debian或Alpine之類的公共鏡像作為基礎鏡像,然后基于該鏡像構建其他鏡像。或者可能想將公共鏡像用于某些非關鍵鏡像,并為其他情況構建自己的鏡像。
關于軟件可
在Docker鏡像中包含第三方庫和軟件包之前,請確保相應的許可允許這樣做。第三方許可證可能還會對重新分發施加限制,當將Docker鏡像發布到公共注冊表時,這些限制就適用。
總結
本文中介紹了容器構建過程中應該遵循的一些基本的原則,通過這些原則可以確保構建的容器安全、精煉,可收縮,可控,當然這些條款也只是建議性質的,在滿足需求的基礎上請盡量遵循。其中涉及的一些方法僅供參考,你也可以在遵守基本原則情況下使用更適合自己的解決方法。