Nerdctl 原生支援 Nydus 加速映象

SOFAStack發表於2023-01-12

圖片

文|李楠(GitHub ID : @loheagn)

北京航空航天大學 21 級研究生

雲原生底層系統的開發和探索工作。

本文 6369 字 閱讀 16 分鐘

圖片

OSPP 開源之夏是由中科院軟體研究所“開源軟體供應鏈點亮計劃”發起並長期支援的一項暑期開源活動。旨在鼓勵在校學生積極參與開源軟體的開發維護、促進優秀開源軟體社群的蓬勃發展、培養和發掘更多優秀的開發者。

這是去年(2022)的開源活動中,李楠同學參加 Nydus 容器開源生態整合課題的相關工作。

| 作者有話說 |

大家好!我是來自北京航空航天大學的 2021 級研究生李楠,對雲原生技術很感興趣,GitHub ID 是 @loheagn。在今年上半年,我參加了 Linux 基金會的春季實習,完成了 CNCF - Kubevela: Management of Terraform state 專案的工作,並因此培養了對開源工作的興趣。在開源之夏 2022 開放題目後,我瞭解到了 Nydus 專案,感覺這是一個很酷的專案,由於我之前對容器和映象的底層技術並不是很熟悉,覺得這是個很不錯的學習機會。於是便嘗試投遞了簡歷申請,最終很幸運地透過了篩選,並在嚴松老師的幫助下順利完成了題目。

PART. 1 專案背景

Nerdctl

Nerdctl 是一個對標 Docker CLI 和 Docker Compose 的、用於與 Containerd (當下最流行的容器執行時,Docker 的後端也是呼叫的 Containerd,通常作為守護程式出現) 互動的命令列工具。

使用者可以像使用 Docker CLI 一樣使用 Nerdctl 與 Containerd 進行互動,比如使用 nerdctl pull <image_name> 來拉取映象、使用 nerdctl run <image_name> 來執行容器等等。

相比於 Containerd 本身提供的 CTR 工具,Nerdctl 預設提供了更友好的使用者體驗,並儘量保持其使用方式與 Docker 一致。對於從 Docker 遷移到 Containerd 的使用者,往往只需要 alias docker=nerdctl 就可以與之前獲得一致的使用體驗。

OCI 映象格式

OCI 映象格式是 OCI (Open Container Initiative,開放容器計劃) 的重要組成部分。它給出了一個廠商無關的映象格式規範,即一個映象應該包含哪些部分、每個部分的資料結構是如何的、這些各個部分應該以怎樣的方式進行組織等等。

OCI 映象格式脫胎於 Docker 映象格式,它與 Docker 映象格式有著非常類似的結構;但它比 Docker 映象格式有更好的相容性,並得到了各個廠商的普遍認同。

因此,在這裡主要介紹一下 OCI 映象格式的主要內容。

通常所說的映象檔案其實指的是一個包含了多個檔案的“包”,“包”中的這些檔案提供了啟動一個容器所需要的所有需要資訊,其中包括但不限於,容器所使用的檔案系統等資料檔案,映象所適用的平臺、資料完整性校驗資訊等配置檔案。當我們使用 docker pull 或 nerdctl pull 從映象中心拉取映象時,其實就是在依次拉取該映象所包含的這些檔案。

例如,當我們使用 nerdctl pull 拉取一個 OCI 映象時:

image.png

從 Log 中可以清晰地看到,Nerdctl 依次拉取了一個 index 檔案、一個 manifest 檔案、一個 config 檔案和若干個 layer 資料檔案。實際上,一個標準的 OCI 映象通常就是由這幾部分構成的。

其中,layer 檔案一般是 tar 包或者壓縮後的 tar 包,其包含著映象具體的資料檔案。這些 layer 檔案會共同組成一個完整的檔案系統(也就是從該映象啟動容器後,進入容器中看到的檔案系統)

config 檔案是一個 JSON 檔案。其中包含映象的一些配置資訊,比如映象時間、修改記錄、環境變數、映象的啟動命令等等。

manifest 檔案也是一個 JSON 檔案。它可以看作是映象檔案的清單,即說明了該映象包含了哪些 layer 檔案和哪個 config 檔案。

下面是一個 manifest 檔案的典型例子:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:0584b370e957bf9d09e10f424859a02ab0fda255103f75b3f8c7d410a4e96ed5",
    "size": 7636
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:214ca5fb90323fe769c63a12af092f2572bf1c6b300263e09883909fc865d260",
      "size": 31379476
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:50836501937ff210a4ee8eedcb17b49b3b7627c5b7104397b2a6198c569d9231",
      "size": 25338790
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:d838e0361e8efc1fb3ec2b7aed16ba935ee9b62b6631c304256b0326c048a330",
      "size": 600
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:fcc7a415e354b2e1a2fcf80005278d0439a2f87556e683bb98891414339f9bee",
      "size": 893
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:dc73b4533047ea21262e7d35b3b2598e3d2c00b6d63426f47698fe2adac5b1d6",
      "size": 664
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:e8750203e98541223fb970b2b04058aae5ca11833a93b9f3df26bd835f66d223",
      "size": 1394
    }
  ]
}

index 檔案也是一個 JSON 檔案。它是可選的,可以被認為是 manifest 的 manifest。試想一下,一個 tag 標識的映象,比如 docker.io/library/nginx:1.20 ,會針對不同的架構平臺 (比如 linux/amd、linux/arm64 等等) 有不同的映象檔案,每個不同平臺的映象檔案都有一個 manifest 檔案來描述,那麼我們就需要有個更高層級的檔案來索引這多個 manifest 檔案。

比如,docker.io/library/nginx:1.20 的 index 檔案就包含一個 manifests 陣列,其中記錄了多個不同平臺的 manifest 的基本資訊:

{
  "manifests": [
    {
      "digest": "sha256:a76df3b4f1478766631c794de7ff466aca466f995fd5bb216bb9643a3dd2a6bb",
      "mediaType": "application\/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      },
      "size": 1570
    },
    {
      "digest": "sha256:f46bffd1049ef89d01841ba45bb02880addbbe6d1587726b9979dbe2f6b556a4",
      "mediaType": "application\/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "arm",
        "os": "linux",
        "variant": "v5"
      },
      "size": 1570
    },
    {
      "digest": "sha256:d9a32c8a3049313fb16427b6e64a4a1f12b60a4a240bf4fbf9502013fcdf621c",
      "mediaType": "application\/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "arm",
        "os": "linux",
        "variant": "v7"
      },
      "size": 1570
    },
    {
      "digest": "sha256:acd1b78ac05eedcef5f205406468616e83a6a712f76d068a45cf76803d821d0b",
      "mediaType": "application\/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "arm64",
        "os": "linux",
        "variant": "v8"
      },
      "size": 1570
    },
    {
      "digest": "sha256:d972eee4f12250a62a8dc076560acc1903fc463ee9cb84f9762b50deed855ed6",
      "mediaType": "application\/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "386",
        "os": "linux"
      },
      "size": 1570
    },
    {
      "digest": "sha256:b187079b65b3eff95d1ea02acbc0abed172ba8e1433190b97d0acfddd5477640",
      "mediaType": "application\/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "mips64le",
        "os": "linux"
      },
      "size": 1570
    },
    {
      "digest": "sha256:ae93c7f72dc47dbd984348240c02484b95650b8b328464c62559ef173b64ce0d",
      "mediaType": "application\/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "ppc64le",
        "os": "linux"
      },
      "size": 1570
    },
    {
      "digest": "sha256:51f45f5871a8d25b65cecf570c6b079995a16c7aef559261d7fd949e32d44822",
      "mediaType": "application\/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "s390x",
        "os": "linux"
      },
      "size": 1570
    }
  ],
  "mediaType": "application\/vnd.docker.distribution.manifest.list.v2+json",
  "schemaVersion": 2
}

綜上,組成映象的各個檔案相互之間形成了一個樹狀結構,樹的上層節點持有對下層節點的引用。從最上層的 index 檔案或 manifest 檔案開始,就可以“順藤摸瓜”地索引到映象的所有檔案。

圖片

需要注意的是,OCI 映象規範是一個開放的格式,它只規定了檔案的組織形式,但沒規定資料檔案的具體內容。我們完全可以將一些其他型別的檔案打包成一個 OCI 映象的格式,當做 OCI 映象進行分發,從而充分利用 DockerHub 等映象註冊中心的能力。

PART. 2 關於 Nydus

Nydus 是 CNCF 孵化專案 Dragonfly 的子專案,它提供了容器映象、程式碼包、資料分析按需載入的能力,無需等待整個資料下載完成便可開始服務。

Nydus 在生產環境已經支撐了每日百萬級別的加速映象容器建立,在啟動效能、映象空間最佳化、網路頻寬效率、端到端資料一致性等方面相比 OCI v1 格式有著巨大優勢,並可擴充套件至例如 NPM 包懶載入等資料分發場景。

目前 Nydus 由螞蟻集團、阿里雲、位元組跳動聯合開發,Containerd、Podman 社群接受了 Nydus 執行時作為其社群子專案,也是 Kata Containers 以及 Linux v5.19 核心態原生支援的映象加速方案。

Nydus 映象格式

Nydus 映象格式是對下一代 OCI 映象格式的探索。它的出現是基於以下的事實:在使用者啟動容器時,容器執行時會首先從遠端 Registry 中下載完整的映象檔案 (通常這一過程是容器啟動時最耗時的部分) ,然後才能對映象的檔案系統進行解包和掛載,最後完成容器的啟動。

但實際上,使用者在執行容器過程中,並不會用到檔案系統中的全部檔案,資料使用率通常只有 6% 左右;也就是說,花費大量時間拉取的映象檔案,卻大機率最終不會用到。

因此,如果能在容器執行時,不提前拉取完整映象,而只是在需要訪問某些檔案再動態拉取,將大大提高容器的啟動效率,並帶來網路頻寬效率、映象空間最佳化等更多好處。

下圖是相同內容的 Nydus 映象和 OCI 映象在建立容器時的耗時對比:

image.png

Nydus 映象格式並沒有對 OCI 映象格式在架構上進行修改,而主要最佳化了其中的 layer 資料層的資料結構。Nydus 將原本統一存放在 layer 層的檔案資料和後設資料 (檔案系統的目錄結構、檔案後設資料等) 分開,分別存放在 “Blob layer” 和 “Bootstrap layer” 中。並對 Blob layer 中存放的檔案資料進行分塊 (chunk) ,以便於懶載入 (在需要訪問某個檔案時,只需要拉取對應的 chunk 即可,不需要拉取整個 Blob layer)

同時,這些分塊資訊,包括每個 chunk 在 Blob layer 的位置資訊等也被存放在 Bootstrap layer 這個後設資料層中。這樣,容器啟動時,僅需拉取 Bootstrap layer 層,當容器具體訪問到某個檔案時,再根據 Bootstrap layer 中的元資訊拉取對應 Blob layer 中的對應的 chunk 即可。

image.png

從更上層的視角上,Nydus 映象格式相比於 OCI 映象格式的變化:

image.png

可以看到,Nydus 映象對外依舊可以表現出 OCI 映象格式的組織形式,因此,Nydus 映象可以充分利用原有的 Docker 映象和 OCI 映象的儲存分發的生態。

Nydus Daemon

從前文的討論中可以看出,從 Nydus 映象生成的容器,當其訪問檔案系統中的檔案時,實際上訪問的不是檔案系統,而是一個“網路檔案系統”——實際訪問的是這個“網路檔案系統”在本地的快取或 Registry 等儲存後端中的資料。

Nydus 中作為這個“網路檔案系統”中的“本地客戶端”的工具是 Nydus Daemon (簡稱 Nydusd)

下圖中的 Nydus Framework 中起主要作用的就是 Nydusd。

圖片

Nydusd 是一個使用者態程式,它可以透過 FUSE、VirtioFS 或 EROFS 等方式將網路檔案系統掛載到容器的 Rootfs 上。

下圖是使用 FUSE 的情況:

image.png

Nydus Snapshotter

Nydus 映象格式雖然在架構上與 OCI 格式保持一致,但對資料的解析、具體的 layer 檔案的 MediaType 等方面與 OCI 格式有很大區別。現有的容器執行時 (典型的如 Containerd、Podman、CRI-O 等) 及其配套的工具並不能直接與拉取和執行 Nydus 映象。

例如,對於 Containerd:

在 Containerd 拉取映象時,會根據映象的 manifest 中的描述將所有的層檔案都拉取下來。這首先就失去了 Nydus 按需載入的意義。

Containerd 在完成映象每個層檔案的拉取後,會呼叫 Snapshot 服務將每一層解包,以讀取其中的檔案。但 Nydus 映象的 Blob layer 使用了自定義的 MediaType,Containerd 在處理時會直接報錯。

Containerd 在執行容器時,會將映象所屬的各個解包後的 Snapshot 目錄作為 OverlayFS 的 Lower Dir,掛載到容器的 Rootfs 上。Nydus 必須能夠 hack 這一過程,將 Nydusd 提供的網路檔案系統作為 OverlayFS 的 Lower Dir 掛載。

幸運的是,Containerd 在設計之初就考慮到了對多種檔案系統的支援,支援使用者自定義 Snapshot 外掛,並在拉取和執行映象時指定使用對應的 Snapshot 外掛,以實現使用者所期望的功能。Nydus 所提供的這樣的 Snapshot 外掛就是 Nydus Snapshotter。

PART. 3

我的工作

Nerdctl 支援執行 Nydus 映象

在開源之夏的題目釋出時,當需要使用 Nerdctl 來執行 Nydus 映象時,不能像普通的 OCI 映象或 Docker 映象一樣,直接使用 nerdctl run 等來執行 (執行 nerdctl run 會直接報錯) ,必須首先使用 Nydus 自己提供的工具 ctr-remote 拉取映象,然後才能進一步使用 nerdctl run 執行。

透過閱讀和除錯 Nerdctl 的程式碼發現,當本地沒有要執行的映象時,Nerdctl 會執行 pull 命令從 Registry 中拉取映象,而直接使用 nerdctl run 引發的報錯正是在這個 pull 階段產生的。因此,問題轉化為解決 nerdctl pull 的報錯。同時,想到 ctr-remote 其實主要就是在做 pull 映象的功能。於是,進一步閱讀 ctr-remote 的程式碼可以發現,ctr-remote 透過在映象的拉取過程中,對 manifest 包含的各個 layer 新增相應的 annotation,使 Nydus Snapshotter 可以正確處理拉取的映象。因此,只需要將 ctr-remote 中的這部分邏輯抽離出來,並新增到 nerdctl pull 的工作流程中即可。

Nerdctl 支援轉換 Nydus 映象

相比於 Docker CLI,Nerdctl 原生支援一些更多樣化的命令,比如 nerdctl image convert 。顧名思義,該命令的作用是將一種格式的映象轉換為另一種格式。其最基礎的用法是使用 nerdctl image convert --oci <src_image_tag> <target_image_tag> 將一個常見的 Docker v2 格式的映象 (也就是大家平常用的映象格式) 轉換為一個標準的 OCI 格式的映象。

除了 Docker v2 和 OCI,截止到開源之夏題目釋出時,nerdctl image convert 還支援將映象轉換到 eStargz 格式。因此,“ nerdctl image convert 支援 Nydus” 這個題目的含義就是,擴充 nerdctl image convert 命令,使其支援將常見的 Docker V2 格式和 OCI 格式的映象轉換為 Nydus 格式的映象。這個題目是本次專案最重要的一部分工作。

nerdctl image convert 的實現主要是藉助  Containerd 本身對外開放的 API 中的 Convert 能力實現的。Containerd 對映象轉換的處理流程是,按照映象組織的樹形結構,從基礎的 layer 和 config 開始,到 index 層結束,一層層進行轉換,從而最終生成一個新的映象;其中,呼叫者可以自定義資料層的轉換邏輯,nerdctl image convert 已有的對 eStargz 格式 的支援就是透過這種方式實現的。

但目前類似 eStargz 格式的這種實現其實是預設轉換後的映象和轉換前的映象的各層之間一一對應,而 Nydus 映象除了有與轉換前的 OCI 映象中的資料層一一對應的 Blob layer 之外,還有一個 Bootstrap layer。幸好 Containerd 的處理流程中,在完成了每一層的轉換後,會呼叫一個回撥函式,給呼叫者機會做進一步的處理。因此,可以利用 Containerd 對 manifest 層處理完的回撥,在該回撥中,額外生成一個 Bootstrap layer,並相應地修改 manifest 層和 config 層中的內容,從而最終構建出一個合法的 Nydus 映象。

在開發完基本邏輯後,測試過程中發現了轉換後的 Nydus 映象檔案生成後又被意外刪除的現象。甚至在一步步除錯時,在函式返回前,轉換後的 Nydus 映象檔案依舊存在,但函式返回後檔案奇蹟般的消失了。

對此,我依次嘗試了以下排查思路:

Nydus 映象檔案的刪除是在另一個協程中進行的,因此我在當前協程的斷點沒有除錯到刪除操作。但多次除錯後發現,刪除動作一定會發生在函式返回前,這與協程的不可預測性不符。

Nydus 映象檔案觸發了 Containerd 守護程式的某種 GC 操作。我使用 Inotify 監控映象檔案的建立和刪除操作對應的程式,發現確實是 Containerd 守護程式的操作。

但問題是,Nerdctl 執行的程式碼也會與 Containerd 進行 RPC 通訊,這一操作是 Containerd 程式自己的內建邏輯呢,還是 Nerdctl 通知 Containerd 做的呢?不得而知。

在花費了一週時間排查 bug 後,發現是函式返回前執行了函式體前部分的 Defer 操作觸發了 Nydus 映象檔案的刪除操作,而在 Defer 的函式體中沒有設定斷點,因此沒有除錯到。最終,透過分析 Defer 函式體中的邏輯,問題得以解決。

總結下來,還是具體的程式設計經驗不足,沒有在一開始就想到所有可能得方面,導致繞了很大的彎路。

小結

上述兩項工作的 PR 都已合入了 Nerdctl 的主分支 (>=0.22) ,基本實現了 Nerdctl 原生支援 Nydus 映象加速的能力。

大家可以移步文件作進一步瞭解: https://github.com/containerd/nerdctl/blob/master/docs/nydus.md

PART. 4 專案總結與展望

Containerd 是當前最流行的容器執行時之一,Nerdctl 作為 Containerd 的社群中的核心專案提供了完善的使用體驗。它們都是容器領域中非常重要的基礎專案。

透過本次專案的開發工作,我逐漸瞭解了 OCI 映象的組成部分,每部分的作用和基本格式;知道了以及 Nydus 映象和 OCI 映象之間的差異,並且理解了 Nydus映象中 Blob layer 和 Bootstrap layer 之間的區別;能夠透過檢查本地映象相關檔案排查一些簡單的程式 bug。

在專案進行的過程中,我閱讀了 Nerdctl 和 Containerd 的程式碼,學到了一些實用的程式設計技巧,並最終向 Nerdctl 提交併成功合入了兩個 PR。在向 Nerdctl 提交和修改 PR 的過程中,Nerdctl 的 Maintainer 們對待程式碼的嚴謹態度讓我大受裨益———他們甚至會 review go.sum 每一行改動!

不僅如此,這些的開源工作經歷為我揭開了“頂級開源專案的神秘的面紗”,增強了我的信心,讓我更有自信和興趣進一步參與到雲原生專案的相關工作中。

本次專案的完成,將使得使用者能非常方便地使用 Nerdctl 和 Containerd 來構造、拉取、執行 Nydus 映象,這無疑會對 Nydus 映象格式的普及和進一步發展起到非常好的推動作用。

| 致謝 |

非常感謝專案組織老師趙新、本題目 Mentor 嚴松老師和專案指導助理姚胤楠同學在本次專案進行過程指導和幫助,特別是嚴松老師細緻入微的解答和指導,每次我的一個看起來很簡單甚至很愚蠢的問題都能得到嚴松老師詳細的解答,並且言辭中經常包含著肯定和鼓勵,讓人如沐春風。

在後續的學習和工作中,我希望能持續參與到 Nydus 相關的開發工作中,繼續為社群貢獻 issue 和程式碼。

瞭解更多...

Nydus Star 一下✨:

https://github.com/dragonflyoss/image-service

更多福利...

《SOFAStack 社群 2022 年報》已發出

掃描下方二維碼填寫問卷

有機會獲得 SOFAStack 新年限定周邊 哦!

圖片

本週推薦閱讀

Nydus 映象掃描加速

Nydus 映象加速外掛遷入 Containerd 旗下

Nydus | 容器映象基礎

Dragonfly 和 Nydus Mirror 模式整合實踐

相關文章