再見 Dockerfile,擁抱新型映象構建技術 Buildpacks

語言: CN / TW / HK

作者:米開朗基楊,方闐

雲原生正在吞併軟體世界,容器改變了傳統的應用開發模式,如今研發人員不僅要構建應用,還要使用 Dockerfile 來完成應用的容器化,將應用及其依賴關係打包,從而獲得更可靠的產品,提高研發效率。

隨著專案的迭代,達到一定的規模後,就需要運維團隊和研發團隊之間相互協作。運維團隊的視角與研發團隊不同,他們對映象的需求是安全標準化。比如:

  • 不同的應用應該選擇哪種基礎映象?
  • 應用的依賴有哪些版本?
  • 應用需要暴露的埠有哪些?

為了優化運維效率,提高應用安全性,研發人員需要不斷更新 Dockerfile 來實現上述目標。同時運維團隊也會干預映象的構建,如果基礎映象中有 CVE 被修復了,運維團隊就需要更新 Dockerfile,使用較新版本的基礎映象。總之,運維與研發都需要干預 Dockerfile,無法實現解耦。

為了解決這一系列的問題,湧現出了更加優秀的產品來構建映象,其中就包括 Cloud Native Buildpacks (СNB)。CNB 基於模組化提供了一種更加快速、安全、可靠的方式來構建符合 OCI 規範的映象,實現了研發與運維團隊之間的解耦。

在介紹 CNB 之前,我們先來闡述幾個基本概念。

符合 OCI 規範的映象

如今,容器執行時早就不是 Docker 一家獨大了。為了確保所有的容器執行時都能執行任何構建工具生成的映象,Linux 基金會與 Google,華為,惠普,IBM,Docker,Red Hat,VMware 等公司共同宣佈成立開放容器專案(OCP),後更名為開放容器倡議(OCI)。OCI 定義了圍繞容器映象格式和執行時的行業標準,給定一個 OCI 映象,任何實現 OCI 執行時標準的容器執行時都可以使用該映象執行容器。

如果你要問 Docker 映象與 OCI 映象之間有什麼區別,如今的答案是:幾乎沒有區別。有一部分舊的 Docker 映象在 OCI 規範之前就已經存在了,它們被成為 Docker v1 規範,與 Docker v2 規範是不相容的。而 Docker v2 規範捐給了 OCI,構成了 OCI 規範的基礎。如今所有的容器映象倉庫、Kubernetes 平臺和容器執行時都是圍繞 OCI 規範建立的。

什麼是 Buildpacks

Buildpacks 專案最早由 Heroku 在 2011 年發起, 被以 Cloud Foundry 為代表的 PaaS 平臺廣泛採用。

一個 buildpack 指的就是一個將原始碼變成 PaaS 平臺可執行的壓縮包的程式,通常情況下,每個 buildpack 封裝了單一的語言生態系統的工具鏈,例如 Ruby、Go、NodeJs、Java、Python 等都有專門的 buildpack。

你可以將 buildpack 理解成一坨指令碼,這坨指令碼的作用是將應用的可執行檔案及其依賴的環境、配置、啟動指令碼等打包,然後上傳到 Git 等倉庫中,打好的壓縮包被稱為 droplet

然後 Cloud Foundry 會通過排程器選擇一個可以執行這個應用的虛擬機器,然後通知這個機器上的 Agent 下載應用壓縮包,按照 buildpack 指定的啟動命令,啟動應用。

到了 2018 年 1 月,PivotalHeroku 聯合發起了 Cloud Native Buildpakcs(CNB) 專案,並在同年 10 月讓這個專案進入了 CNCF

2020 年 11 月,CNCF 技術監督委員會(TOC)投票決定將 CNB 從沙箱專案晉升為孵化專案。是時候好好研究一下 CNB 了。

為什麼需要 Cloud Native Buildpacks

Cloud Native Buildpacks(CNB) 可以看成是基於雲原生的 Buildpacks 技術,它支援現代語言生態系統,對開發者遮蔽了應用構建、部署的細節,如選用哪種作業系統、編寫適應映象作業系統的處理指令碼、優化映象大小等等,並且會產出 OCI 容器映象,可以執行在任何相容 OCI 映象標準的叢集中。CNB 還擁抱了很多更加雲原生的特性,例如跨映象倉庫的 blob 掛載和映象層級 rebasing

由此可見 CNB 的映象構建方式更加標準化、自動化,與 Dockerfile 相比,Buildpacks 為構建應用提供了更高層次的抽象,Buildpacks 對 OCI 映象構建的抽象,就類似於 Helm 對 Deployment 編排的抽象

2020 年 10 月,Google Cloud 開始宣佈全面支援 Buildpacks,包含 Cloud Run、Anthos 和 Google Kubernetes Engine (GKE)。目前 IBM Cloud、Heroku 和 Pivital 等公司皆已採用 Buildpacks,如果不出意外,其他雲供應商很快就會效仿。

Buildpacks 的優點:

  • 針對同一構建目的的應用,不用重複編寫構建檔案(只需要使用一個 Builder)。
  • 不依賴 Dockerfile。
  • 可以根據豐富的元資料資訊(buildpack.toml)輕鬆地檢查到每一層(buildpacks)的工作內容。
  • 在更換了底層作業系統之後,不需要重新改寫映象構建過程。
  • 保證應用構建的安全性和合規性,而無需開發者干預。

Buildpacks 社群還給出了一個表格來對比同類應用打包工具:

可以看到 Buildpacks 與其他打包工具相比,支援的功能更多,包括:快取、原始碼檢測、外掛化、支援 rebase、重用、CI/CD 多種生態。

Cloud Native Buildpacks 工作原理

Cloud Native Buildpacks 主要由 3 個元件組成: BuilderBuildpackStack

Buildpack

Buildpack 本質是一個可執行單元的集合,一般包括檢查程式原始碼、構建程式碼、生成映象等。一個典型的 Buildpack 需要包含以下三個檔案:

  • buildpack.toml – 提供 buildpack 的元資料資訊。
  • bin/detect – 檢測是否應該執行這個 buildpack。
  • bin/build – 執行 buildpack 的構建邏輯,最終生成映象。

Builder

Buildpacks 會通過“檢測”、“構建”、“輸出”三個動作完成一個構建邏輯。通常為了完成一個應用的構建,我們會使用到多個 Buildpacks,那麼 Builder 就是一個構建邏輯的集合,包含了構建所需要的所有元件和執行環境的映象。

我們通過一個假設的流水線來嘗試理解 Builder 的工作原理:

  • 最初,我們作為應用的開發者,準備了一份應用原始碼,這裡我們將其標識為 “0”。
  • 然後應用 “0” 來到了第一道工序,我們使用 Buildpacks1 對其進行加工。在這個工序中,Buildpacks1 會檢查應用是否具有 “0” 標識,如果有,則進入構建過程,即為應用標識新增 “1”,使應用標識變更為 “01”。
  • 同理,第二道、第三道工序也會根據自身的准入條件判斷是否需要執行各自的構建邏輯。

在這個例子中,應用滿足了三道工序的准入條件,所以最終輸出的 OCI 映象的內容為 “01234” 的標識。

對應到 Buildpacks 的概念中,Builders 就是 Buildpacks 的有序組合,包含一個基礎映象叫 build image、一個 lifecycle 和對另一個基礎映象 run image 的應用。Builders 負責將應用原始碼構建成應用映象(app image)。

build imageBuilders 提供基礎環境(例如 帶有構建工具的 Ubuntu Bionic OS 映象),而 run image 在執行時為應用映象(app image)提供基礎環境。build imagerun image 的組合被稱為 Stack

Stack

上面提到,build imagerun image 的組合被稱為 Stack,也就是說,它定義了 Buildpacks 的執行環境和最終應用的基礎映象。

你可以將 build image 理解為 Dockerfile 多階段構建中第一階段的 base 映象,將 run image 理解為第二階段的 base 映象。


上述 3 個元件都是以 Docker 映象的形式存在,並且提供了非常靈活的配置選項,還擁有控制所生成映象的每一個 layer 的能力。結合其強大的 cachingrebasing 能力,定製的元件映象可以被多個應用重複利用,並且每一個 layer 都可以根據需要單獨更新。


LifecycleBuilder 中最重要的概念,它將由應用原始碼到映象的構建步驟抽象出來,完成了對整個過程的編排,並最終產出應用映象。下面我們單獨用一個章節來介紹 Lifecycle。

構建生命週期(Lifecyle)

Lifecycle 將所有 Buildpacks 的探測、構建過程抽離出來,分成兩個大的步驟聚合執行:Detect 和 Build。這樣一來就降低了 Lifecycle 的架構複雜度,便於實現自定義的 Builder。

除了 Detect 和 Build 這兩個主要步驟,Lifecycle 還包含了一些額外的步驟,我們一起來解讀。

Detect

我們之前提到,在 Buildpack 中包含了一個用於探測的 /bin/detect 檔案,那麼在 Detect 過程中,Lifecycle 會指導所有 Buildpacks 中的 /bin/detect 按順序執行,並從中獲取執行結果。

那麼 Lifecycle 把 DetectBuild 分開後,又是怎麼維繫這兩個過程中的關聯關係呢?

Buildpacks 在 Detect 和 Build 階段,通常都會告知在自己這個過程中會需要哪些前提,以及自己會提供哪些結果。

在 Lifecycle 中,提供了一個叫做 Build Plan 的結構體用於存放每個 Buildpack 的所需物和產出物。

type BuildPlanEntry struct { 
    Providers `toml:“providers”`     
    Requires  `toml:"requires"` 

同時,Lifecycle 也規定,只有當所有產出物都匹配有一個對應的所需物時,這些 Buildpacks 才能組合成一個 Builder。

Analysis

Buildpacks 在執行中會建立一些目錄,在 Lifecycle 中這些目錄被稱為 layer。那麼為了這些 layer 中,有一些是可以作為快取提供給下一個 Buildpacks 使用的,有一些則是需要在應用執行時起作用的,還有的則是需要被清理掉。怎麼才能更靈活地控制這些 layer

Lifecycle 提供了三個開關引數,用於表示每一個 layer 期望的處理方式:

  • launch 表示這個 layer 是否將在應用執行時起作用。
  • build 表示這個 layer 是否將在後續的構建過程中被訪問。
  • cache 則表示這個 layer 是否將作為快取。

之後,Lifecycle 再根據一個關係矩陣來判斷 layer 的最終歸宿。我們也可以簡單的理解為,Analysis 階段為構建、應用執行提供了快取

Build

Build 階段會利用 Detect 階段產出的 build plan,以及環境中的元資料資訊,配合保留至本階段的 layers,對應用原始碼執行 Buildpacks 中的構建邏輯。最終生成可執行的應用工件。

Export

Export 階段比較好理解,在完成了上述構建之後,我們需要將最後的構建結果產出為一個 OCI 標準映象,這樣一來,這個 App 工件就可以執行在任何相容 OCI 標準的叢集中。

Rebase

在 CNB 的設計中,最後 app 工件實際是執行在 stack 的 run image 之上的。可以理解為 run image 以上的工件是一個整體,它與 run image 以 ABI(application binary interface) 的形式對接,這就使得這個工件可以靈活切換到另一個 run image 上。

這個動作其實也是 Lifecycle 的一部分,叫做 rebase。在構建映象的過程中也有一次 rebase,發生在 app 工件由 build image 切換到 run image 上。

這種機制也是 CNB 對比 Dockerfile 最具優勢的地方。比如在一個大型的生產環境中,如果容器映象的 OS 層出現問題,需要更換映象的 OS 層,那麼針對不同型別的應用映象就需要重寫他們的 dockerfile 並驗證新的 dockerfile 是否可行,以及新增加的層與已存在的層之間是否有衝突,等等。而使用 CNB 只需要做一次 rebase 即可,簡化了大規模生產中映象的升級工作。


以上就是關於 CNB 構建映象的流程分析,總結來說:

  • Buildpacks 是最小構建單元,執行具體的構建操作;
  • Lifecycle 是 CNB 提供的映象構建生命週期介面;
  • Builder 是若干 Buildpacks 加上 Lifecycle 以及 stack 形成的具備特定構建目的的構建器。

再精減一下:

  • build image + run image = stack
  • stack(build image) + buildpacks + lifecycle = builder
  • stack(run image) + app artifacts = app

那麼現在問題來了,這個工具怎麼使用呢?

Platform

這時候就需要一個 Platform,Platform 其實是 Lifecycle 的執行者。它的作用是將 Builder 作用於給定的原始碼上,完成 Lifecycle 的指令。

在這個過程中,Builder 會將原始碼構建為 app,這個時候 app 是在 build image 中的。這個時候根據 Lifecycle 中的 rebase 介面,底層邏輯是是用 ABI(application binary interface) 將 app 工件從 build image 轉換到 run image 上。這就是最後的 OCI 映象。

常用的 Platform 有 Tekton 和 CNB 的 Pack。接下來我們將使用 Pack 來體驗如何使用 Buildpacks 構建映象。

安裝 Pack CLI 工具

目前 Pack CLI 支援 Linux、MacOS 和 Windows 平臺,以 Ubuntu 為例,安裝命令如下:

$ sudo add-apt-repository ppa:cncf-buildpacks/pack-cli
$ sudo apt-get update
$ sudo apt-get install pack-cli

檢視版本:

$ pack version
0.22.0+git-26d8c5c.build-2970

注意:在使用 Pack 之前,需要先安裝並執行 Docker。

目前 Pack CLI 只支援 Docker,不支援其他容器執行時(比如 Containerd 等)。但 Podman 可以通過一些 hack 來變相支援,以 Ubuntu 為例,大概步驟如下:

先安裝 podman。

$ . /etc/os-release
$ echo "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/ /" | sudo tee /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list
$ curl -L "https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/Release.key" | sudo apt-key add -
$ sudo apt-get update
$ sudo apt-get -y upgrade
$ sudo apt-get -y install podman

然後啟用 Podman Socket。

$ systemctl enable --user podman.socket
$ systemctl start --user podman.socket

指定 DOCKER_HOST 環境變數。

$ export DOCKER_HOST="unix://$(podman info -f "{{.Host.RemoteSocket.Path}}")"

最終就可以實現在 Podman 容器執行時中使用 Pack 來構建映象。詳細配置步驟可參考 Buildpacks 官方文件

使用 Pack 構建 OCI 映象

安裝完 Pack 之後,我們可以通過 CNB 官方提供的 samples 加深對 Buildpacks 原理的理解。這是一個 Java 示例,構建過程中無需安裝 JDK、執行 Maven 或其他構建環境,Buildpacks 會為我們處理好這些。

首先克隆示例倉庫:

$ git clone https://github.com/buildpacks/samples.git

後面我們將使用 bionic 這個 Builder 來構建映象,先來看下該 Builder 的配置:

$ cat samples/builders/bionic/builder.toml
# Buildpacks to include in builder
[[buildpacks]]
id = "samples/java-maven"
version = "0.0.1"
uri = "../../buildpacks/java-maven"

[[buildpacks]]
id = "samples/kotlin-gradle"
version = "0.0.1"
uri = "../../buildpacks/kotlin-gradle"

[[buildpacks]]
id = "samples/ruby-bundler"
version = "0.0.1"
uri = "../../buildpacks/ruby-bundler"

[[buildpacks]]
uri = "docker://cnbs/sample-package:hello-universe"

# Order used for detection
[[order]]
[[order.group]]
id = "samples/java-maven"
version = "0.0.1"

[[order]]
[[order.group]]
id = "samples/kotlin-gradle"
version = "0.0.1"

[[order]]
[[order.group]]
id = "samples/ruby-bundler"
version = "0.0.1"

[[order]]
[[order.group]]
id = "samples/hello-universe"
version = "0.0.1"

# Stack that will be used by the builder
[stack]
id = "io.buildpacks.samples.stacks.bionic"
run-image = "cnbs/sample-stack-run:bionic"
build-image = "cnbs/sample-stack-build:bionic"

builder.toml 檔案中完成了對 Builder 的定義,配置結構可以劃分為 3 個部分:

  • [[buildpacks]] 語法標識用於定義 Builder 所包含的 Buildpacks。
  • [[order]] 用於定義 Builder 所包含的 Buildpacks 的執行順序。
  • [[stack]] 用於定義 Builder 將執行在哪個基礎環境之上。

我們可以使用這個 builder.toml 來構建自己的 builder 映象:

$ cd samples/builders/bionic

$ pack builder create cnbs/sample-builder:bionic --config builder.toml
284055322776: Already exists
5b7c18d5e17c: Already exists
8a0af02bbad1: Already exists
0aa0fb9222a5: Download complete
3d56f4bc2c9a: Already exists
5b7c18d5e17c: Already exists
284055322776: Already exists
8a0af02bbad1: Already exists
a967314b5694: Already exists
a00d148009e5: Already exists
dbb2c49b44e3: Download complete
53a52c7f9926: Download complete
0cceee8a8cb0: Download complete
c238db6a02a5: Download complete
e925caa83f18: Download complete
Successfully created builder image cnbs/sample-builder:bionic
Tip: Run pack build <image-name> --builder cnbs/sample-builder:bionic to use this builder

接著,進入 samples/apps 目錄,使用 pack 工具和 builder 映象,完成應用的構建。當構建成功後,會產出一個名為 sample-app 的 OCI 映象。

$ cd ../..
$ pack build --path apps/java-maven --builder cnbs/sample-builder:bionic sample-app

最後使用 Docker 執行這個 sample-app 映象:

$ docker run -it -p 8080:8080 sample-app

訪問 http://localhost:8080,如果一切正常,你可以在瀏覽器中看見如下的介面:

現在我們再來觀察一下之前構建的映象:

$ docker images
REPOSITORY                               TAG              IMAGE ID       CREATED        SIZE
cnbs/sample-package                      hello-universe   e925caa83f18   42 years ago   4.65kB
sample-app                               latest           7867e21a60cd   42 years ago   300MB
cnbs/sample-builder                      bionic           83509780fa67   42 years ago   181MB
buildpacksio/lifecycle                   0.13.1           76412e6be4e1   42 years ago   16.4MB

映象的建立時間竟然都是固定的時間戳:42 years ago。這是為什麼呢?如果時間戳不固定,每次構建映象的 hash 值都是不同的,一旦 hash 值不一樣,就不太容易判斷映象的內容是否相同了。使用固定的時間戳,就可以重複利用之前的構建過程中建立的 layers。

總結

Cloud Native Buildpacks 代表了現代軟體開發的一個重大進步,在大部份場景下相對於 Dockerfile 的好處是立杆見影的。雖然大型企業需要投入精力重新調整 CI/CD 流程或編寫自定義 Builder,但從長遠來看可以節省大量的時間和維護成本。

本文介紹了 Cloud Native Buildpacks(CNB) 的起源以及相對於其他工具的優勢,並詳細闡述了 CNB 的工作原理,最後通過一個簡單的示例來體驗如何使用 CNB 構建映象。後續的文章將會介紹如何建立自定義的 Builder、Buildpack、Stack,以及函式計算平臺(例如,OpenFunction、Google Cloud Functions)如何利用 CNB 提供的 S2I 能力,實現從使用者的函式程式碼到最終應用的轉換過程。

本文由部落格一文多發平臺 OpenWrite 釋出!