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





































