可執行鏡像 - 開發環境的Docker化之路
每位開發者都經歷過軟件不兼容之痛。當我們需要同時開發幾個使用不同Java運行時版本的項目時,這些問題會急劇爆發,特別是在OsX平臺上。為 此,Ruby使用自己的版本管理工具。我的兩個同事曾用了幾小時來調試他們各自用Homebrew管理的OpenSSL和Python版本之間的不兼容。 我們是否可以使用容器來解決這些問題呢?答案是肯定的!
容器的主要目標是交付軟件。新成立的 開放容器項目 給出以下定義:
標準容器的目標是使用自描述和可移植的格式,封裝軟件組件及其全部依賴,以便任何兼容運行時都可以運行,無需額外的依賴,不必關心底層機器和容器的內容。
這份定義沒有提及任何關于軟件分發類型的描述。這是有意而為之的,因為容器的設計是內容無關的。我們要交付什么以及如何使用完全取決于我們自己。在這篇文章中,我將闡述服務鏡像和可執行鏡像之間的區別,并建議讀者使用可執行鏡像。
可執行鏡像沒有服務鏡像那么常見,但卻是一個非常有用的補充。可執行鏡像要解決的是軟件兼容性等問題。我們拿 官方的Maven鏡像 作為例子,探索可執行鏡像是什么、它們是如何工作的,以及我們如何創建可執行鏡像。其中,Dockerfile中的ENTRYPOINT指令是演繹可執行鏡像的核心角色。
1 服務鏡像 VS. 可執行鏡像
傳統上,容器鏡像被用作長時間運行的進程:在服務器上運行的服務,不會影響主機,因為它們存在與容器內。我們稱其為 服務鏡像 。Web服務器、負載均衡服務器和數據庫服務器都是服務鏡像的好例子。這類容器可以很容易與虛擬機對比。
容器鏡像也可以用作短暫的進程:在我們計算機上運行的、容器化的可執行命令。這些容器執行單一的任務,生命周期短暫,而且通常可以在使用后被刪除。我們稱之為 可執行鏡像 。舉例來說,比如編譯器(Golang)或者構建工具(Maven)、演示軟件(我很喜歡用Markdown格式寫一個演示,然后用 RevealJS Docker鏡像將其展示出來),以及瀏覽器。可執行鏡像的終極布道者是Docker公司的Jessie Frazelle。如果你希望獲得更多啟發,一定要閱讀 她博客中相關的內容 ,或者看下她在DockerCon 2015上的 演講 。
其實,服務鏡像和可執行鏡像之間的界限并非涇渭分明。鏡像都是可執行的,因為它們的任務就是運行一個進程。在容器中運行一個演示或者瀏覽器是非常 好的本地工具示例,因此我將稱其為可執行鏡像。縱然他們是長時間運行的進程。話雖如此,我希望讀者能夠認同這樣分類的道理。如此定義的出發點,更多是從鏡 像的目的,而不是進程存活的長短。
2 可執行鏡像的優勢
那么,可執行鏡像的優勢是什么呢?它們是如何解決前述問題的呢?
其中一個原因是,對可執行鏡像的體驗是一種很好的開始使用Docker的方式。這種體驗非常有用,而且不會影響生產環境。此中的趣味無窮!
另一個原因是安裝方便。眾所周知的包管理器apt-get、yum、MacPorts和homebrew等,通常在大部分時間有完美的表現,但是 當我們真的需要它們的時候……問題在于,這些工具的偉大之處是同一件事情:管理依賴。但是,它們沒有強大到可以管理同一個包的兩個版本,包括其依賴關系 樹。容器的設計沒有依賴性:所有的依賴都被固化到鏡像中。安裝本身只意味著運行Docker、執行命令。如果鏡像不存在于系統中,Docker會自動下載 (pull)該鏡像。通過將軟件與其依賴一起封裝在容器鏡像中的方式,實現了可靠的軟件分發。測試容器鏡像即是測試依賴是否能與主要功能一起工作。
容器化的可執行文件僅指容器化,換個說法叫沙箱。這降低了運行不完全信任軟件的風險,避免了許多程序的漏洞。一個例子是瀏覽器中的可疑鏈接。在一個干凈的 文件系統中運行一個全新的瀏覽器會更安全。另一個例子是關于幾個月前Valve軟件的Steam刪除了所有用戶的文件,包括連接的驅動器的 缺陷 !Docker的沙箱機制并非完美,但它肯定會避免發生清除照片庫這樣的事情。
因為進程及其依賴是封裝在容器中的,運行同一軟件的不同版本變得非常簡單!通常情況下,要開始一個Java/Maven項目,我們需要安裝所需版 本的Java開發套件(JDK)和Maven。而使用Docker,我們就可以跳過這步。 JDK和Maven由某個團隊安裝在一個可執行鏡像中。于是,其他人就可以在此基礎上遷出源代碼,并直接編譯和測試它們。我們可以為另一個使用不同JDK 版本的項目使用另一個鏡像。甚至可以在同一時間編譯這些項目!而不需要擔心$JAVA_HOME環境變量。
3 Maven鏡像
構建服務鏡像的目的是以指定的方式運行一個服務。這也許需要一些環境相關的信息,比如數據庫地址,但不會很多。構建可執行鏡像的目的是運行一個以 指定方式與系統交互的工具。有很多技術可以實現這一目的。我們將以Maven編譯器鏡像作為這一技術的實現示例。需要注意的是,這里所指的技術是通用的, 所以縱然你不喜歡Java,請稍安勿躁。
4 使用卷傳遞文件
假設我們有一個包含Java源代碼的Maven項目,該項目至少在根目錄下,包含一個pom.xml文件和/src/main/java目錄。對 于本文而言,可以采用任何你想用的Maven項目。如果你沒有任何Maven項目,你可以去下載Spring Boot(選擇Maven類型)。使用命令行cd到項目目錄(包含pom.xml文件的目錄),執行如下命令:
user:project$ docker run --rm \ -v $(pwd):/project \ -w /project \ maven:3.3.3-jdk-8 mvn install
該命令做了如下的事情:
docker run
創建了maven:3.3.3-jdk-8鏡像的一個實例。該實例中執行了mvn install
命令。原則上,這不會影響主機系統。-v $(pwd):/project
將當前目錄掛載到容器中,作為/project目錄。這樣以來,容器就可以讀寫主機系統的當前目錄了。-w /project
設置了/project作為工作目錄。這意味著執行mvn命令將在project目錄中有效。--rm
將在執行完畢后刪除容器。甩掉包袱!
這與在主機上直接運行mvn install的結果是一樣的,只是不必實際安裝Java或Maven。我們以在項目目錄下,獲得target目錄而告終,該目錄包含了編譯好的Java應用程序。
可以運行maven clean命令清理項目:
user:project$ docker run --rm \ -v $(pwd):/project \ -w /project \ maven:3.3.3-jdk-8 mvn clean
5 使用entry point傳遞參數
Maven鏡像的功能是運行mvn [args]。因此,我們可以認為在Docker命令中指定mvn是多余的。為此,可以使用Docker提供的entrypoint。這個 entrypoint是與命令強關聯的。可以在Dockerfile中分別使用ENTRYPOINT和CMD指令。這兩個指令將作為容器鏡像的元數據,覆 蓋 docker run
命令。我們可以這樣執行 mvn clean install
:
user:project$ docker run --rm \ -v $(pwd):/project \ -w /project \ --entrypoint mvn \ maven:3.3.3-jdk-8 clean install
entrypoint和命令將連接在一起執行。它的優點是關注點分離。對于可執行容器鏡像而言,entrypoint可以用作定義恒定部分,命令可以用作定義可變部分。
如果我們將entrypoint融入容器鏡像,分離會更加優雅。為此,我們在另一目錄中創建一個Dockerfile文件,內容如下:
FROM maven:3.3.3-jdk-8 WORKDIR /project ENTRYPOINT ["mvn"] CMD ["-h"]
其中,我們同樣增加了一個工作目錄,因此我們的新鏡像希望Maven項目掛載在/project目錄之下。Dockerfile以exec的形式定義了 ENTRYPOINT和CMD,方括號內的參數最終被解析為shell。在Dockerfile文件所在的目錄下,執行 docker build -t my_mvn .
命令構建鏡像,這個鏡像簡化了前述的執行命令:
user:project$ docker run --rm \ -v $(pwd):/project \ my_mvn clean install
其中, clean install
當然可以替換為mvn的其他參數。如果我們忘記包含命令參數,將會打印 maven help
,因為在Dockerfile文件中定義了默認的命令參數, -h
即表示help。
entrypoint的另一個很好的用途是在方括號內定義輔助腳本。例如,如果在實際服務正常啟動之前,我們需要執行一些命令,輔助腳本可以很好地處理。 另外,這樣的腳本還可以檢查當前是否具備了必要的全部運行時配置,比如鏈接或環境變量等。命令本身作為啟動腳本的參數,但是對執行腳本是透明的。關于這一 點的更多信息以及簡單示例,請參閱Docker文檔中的 Dockerfile最佳實踐 。
6 為可執行鏡像創建別名
我們可以為可執行鏡像創建一個別名。這樣,我們就可以輸入簡短的指令,就像普通程序一樣。在~/.profile中添加:
mvn() { docker run --rm \ -v $(pwd):/project \ my_mvn $* }
因為我們要傳遞參數,所以使用函數代替了別名。在執行 source ~/.profile
命令,加載變更后,我們就可以這樣簡單地使用了:
user:project$ mvn clean install
7 使用卷緩存Maven本地倉庫
當前方案的缺點是,每次執行時都需要下載Maven工件。本地Maven安裝總會包含一個倉庫目錄,其中存儲了所有的Maven工件。目前的方法是很簡潔,但是并不實用。讓我們將Maven倉庫作為卷添加進來。創建一個目錄,比如 /usr/tmp/.m2
,然后運行:
user:project$ docker run --rm \ -v $(pwd):/project \ -v /usr/tmp/.m2:/root/.m2 \ my_mvn install
現在,主機上的 /usr/tmp/.m2
目錄中存儲了Maven下載下來的工件。我們以后每次用這種方式啟動Maven容器鏡像,因為引入了這個目錄,所以Maven會重用那些工件。可以重復執行 mvn install
兩次來檢驗不同。
我們只是讓Maven構建更快了。但是,為此,我們不得不在主機上管理一個目錄。在本文的最后一步中,我們將使用Docker管理這個卷。首先,我們創建一個叫data的容器:
user:project$ docker run --name maven_data \ -v /root/.m2 \ maven:3.3.3-jdk-8 echo 'data for maven'
容器創建完畢會打印“data for maven”,該容器創建了一個卷。這里使用什么鏡像不是核心問題,在本例中使用maven:3.3.3-jdk-8是方便,因為它已經下載到主機了,而 使用my_mvn不太方便,因為entrypoint要預先考慮echo聲明。注意,這里沒有 -v /root/.m2:
中的冒號,因為我們不再引入主機目錄。而是讓Docker在主機上創建自己的數據目錄。使用“data”作為名字并非是必需的命令,但是這樣是為了顯式說明這是一個數據容器, 當執行 docker ps
時,該名稱將會反射顯示。我們可以通過 --volumes-from
使用這個容器的卷,而無需考慮Docker持有的實際目錄。這樣做會引入容器中的/root/.m2作為掛載卷。這種技術對共享容器之間的數據也非常有用。我們修改~/.profile如下:
mvn() { docker run --rm \ -v $(pwd):/project \ --volumes-from maven_data \ my_mvn $* }
現在,當我們運行mvn時,Maven主目錄將映射到這個卷。Maven容器自身會被刪除,但是卷會在緩存的本地倉庫中保留。如果我們希望清理系統,可以使用如下命令刪除數據容器:
docker rm -v maven_data
-v
表示與之相關的容器滿足如下條件時,刪除該卷:
- 卷是由Docker管理的
- 沒有其他容器引用
一個忠告:如果你忘記了使用 -v
選項,最終會產生孤兒卷目錄。
8 總結
可執行容器鏡像是一種強大的Docker應用程序。對于軟件分發,以及以限制和驗證的方式在計算機上運行時,非常有用。此外,這是一種有趣的開始Docker體驗的方式。我希望你能通過此文,在開始嘗試Docker和使用相關技術上,得到了啟發。
關于作者
Quinten Krijger 在開始他的IT職業生涯前,曾經研究過物理學和一年的古典唱法。后來,他搬到阿姆斯特丹的Trifork,主要的工作是繼續使用開源技術,比如Java、 Spring、ElasticSearch和MongoDB,完成項目后端的工作,對最新的前端設計頗有感覺。他熱衷于縮短反饋周期和啟用敏捷開發:測 試、CI和DevOps是此中的關鍵詞。在Docker出現后不久,他便產生了興趣,并深刻地認識到,有效的容器可以提供非常廣泛的可能性。他致力于啟動 容器解決方案上已經有半年了,目前是ING的一名DevOps。
查看英文原文: Executable Images - How to Dockerize Your Development Machine