本文將用三種方法來創建 CI/CD 流水線。Monad 不能對流水線進行靜態分析,Arrow 語法很難用,我稱之為 Dart(不知道它是否已經有名字了)的一種輕量級的 Arrow 方法可以像 Arrow 一樣進行靜態分析,但語法比 Monad 更簡單。
我需要構建一個用于創建 CI/CD 流水線的系統。它起初是為了構建一個 CI 系統,測試 Github 上的 OCaml 項目(針對多個版本的 OCaml 編譯器和多個操作系統,測試每個提交)。下面是一個簡單的流水線,獲取某個 Git 分支最新的提交,構建,并執行測試用例。
【譯者注】CI/CD:持續集成(Continuous Integration)和持續部署(Continuous Deployment)簡稱,指在開發過程中自動執行一系列腳本來減低開發引入 bug 的概率,在新代碼從開發到部署的過程中,盡量減少人工的介入。
這里的配色標識是:綠色的方框是已經完成,橙色的是正在進行,灰色的意味著這一步還不能開始。
這里有一個稍微復雜點的例子,它還下載了一個 Docker 基礎鏡像,使用兩個不同版本的 OCaml 編譯器并行構建提交,然后測試得到的鏡像。紅框表示此步驟失敗:
一個更復雜的例子是測試項目本身,然后搜索依賴它的其他項目,并根據新版本測試這些項目:
在這里,圓圈意味著在檢查反向依賴項之前,我們應該等待測試通過。
我們可以用 YAML 或類似的方法來描述這些管道,但這將是非常有限的。相反,我決定使用一種特定于領域的嵌入式語言,這樣我們就可以免費使用宿主語言的特性(例如字符串操作、變量、函數、導入、類型檢查等)。
最明顯的方法是使每個框成為正則函數。然后上面的第一個例子可以是(這里,使用 OCaml 語法):
let example1 commit =
let src = fetch commit in
let image = build src in
test image
第二個可能是:
let example2 commit =
let src = fetch commit in
let base = docker_pull "ocaml/opam2" in
let build ocaml_version =
let dockerfile = make_dockerfile ~base ~ocaml_version in
let image = build ~dockerfile src ~label:ocaml_version in
test image
in
build "4.07";
build "4.08"
第三個可能是這樣的:
let example3 commit =
let src = fetch commit in
let image = build src in
test image;
let revdeps = get_revdeps src in
List.iter example1 revdeps
不過,我們想在語言中添加一些附加功能:
- 管道步驟應盡可能并行運行。上面的 example2 函數將一次完成一個構建。
- 管道步驟應在其輸入更改時重新計算。e、 當我們作出新的承諾時,我們需要重建。
- 用戶應該能夠查看每個步驟的進度。
- 用戶應該能夠為任何步驟觸發重建。
- 我們應該能夠從代碼中自動生成圖表,這樣我們就可以在運行管道之前看到它將做什么。
- 一步的失敗不應該使整個管道停止。
對于這篇博客文章來說,確切的附加功能并不重要,因此為了簡單起見,我將重點放在同時運行步驟上。
Monad 方法
【譯者注】Monad:函子,單子,來自 Haskell 編程語言,是函數式編程中,一種定義將函數(函子)組合起來的結構方式,它除了返回值以外,還需要一個上下文。常見的 Monad 有計算任務,分支任務,或者 I/O 操作。
如果沒有額外的功能,我們有如下功能:
val fetch : commit -> source
val build : source -> image
您可以將其理解為“build 是一個獲取源值并返回(Docker)鏡像的函數”。
這些函數很容易組合在一起,形成一個更大的函數來獲取提交并構建它:
let fab c =
let src = fetch c in
build src
我們還可以將其縮短為 build(fetch c)或 fetch c |>build。OCaml 中的|>(pipe)運算符只調用其右側的函數,而參數在其左側。
為了將這些函數擴展為并發的,我們可以讓它們返回承諾,例如,
val fetch : commit -> source promise
val build : source -> image promise
但是現在我們無法使用 let(或|>)輕松組合它們,因為 fetch 的輸出類型與 build 的輸入不匹配。
但是,我們可以定義一個類似的操作,let(或>>=)來處理承諾。它立即返回對最終結果的承諾,并在第一個承諾實現后調用 let* 的主體。那么我們有:
let fab c =
let* src = fetch c in
build src
換句話說,通過在周圍撒上幾個星號字符,我們可以將簡單的舊管道變成一個新的并發管道!使用 let* 編寫 promise returning 函數的時間規則與使用 let 編寫常規函數的時間規則完全相同,因此使用 promise 編寫程序與編寫常規程序一樣簡單。
僅僅使用 let *不會在我們的管道中添加任何并發(它只允許它與其他代碼并發執行)。但是我們可以為此定義額外的函數,比如 all 一次計算一個列表中的每個承諾,或者使用 and 運算符指示兩個事物應該并行運行:
除了處理承諾外,我們還可以為可能返回錯誤的函數(只有在第一個值成功時才調用 let 的主體)或為實時更新(每次輸入更改時都調用主體)或為所有這些東西一起定義 let*。這是單子的基本概念。
這其實很管用。在 2016 年,我用這種方法做了 DataKitCI,它最初用于 Docker-for-mac 上的 CI 系統。之后,Madhavapeddy 用它創建了 opam-repo-ci,這是 opam-repository 上的 CI 系統,OCaml 上主要的軟件倉庫。這將檢查每個新的 PR 以查看它添加或修改了哪些包,針對多個 OCaml 編譯器版本和 linux 發行版(Debian、Ubuntu、Alpine、centos、Fedora 和 OpenSUSE)測試每個包,然后根據更改的包查找所有包的所有版本,并測試這些版本。
使用 monad 的主要問題是我們不能對管道進行靜態分析。考慮上面的 example2 函數。在查詢 GitHub 以獲得測試提交之前,我們無法運行該函數,因此不知道它將做什么。一旦我們有了 commit,我們就可以調用 example2commit,但是在 fetch 和 docker_pull 操作完成之前,我們無法計算 let* 的主體來找出管道接下來將做什么。
換言之,我們只能繪制圖表,顯示已經執行或正在執行的管道位,并且必須使用和 * 手動指示并發的機會。
Arrow 方法
Arrow 使管道的靜態分析成為可能。而不是我們的一元函數:
val fetch : commit -> source promise
val build : source -> image promise
我們可以定義箭頭類型:
type ('a, 'b) arrow
val fetch : (commit, source) arrow
val build : (source, image) arrow
a('a,'b)arrow 是一個接受 a 類型輸入并生成 b 類型結果的管道。如果我們定義類型('a,'b)arrow='a->'b promise,則這與一元版本相同。但是,我們可以將箭頭類型抽象化,并對其進行擴展,以存儲我們需要的任何靜態信息。例如,我們可以標記箭頭:
type ('a, 'b) arrow = {
f : 'a -> 'b promise;
label : string;
}
這里,箭頭是一個記錄。f 是舊的一元函數,label 是“靜態分析”。
用戶看不到 arrow 類型的內部,必須使用 arrow 實現提供的函數來構建管道。有三個基本功能可用:
val arr : ('a -> 'b) -> ('a, 'b) arrow
val ( >>> ) : ('a, 'b) arrow -> ('b, 'c) arrow -> ('a, 'c) arrow
val first : ('a, 'b) arrow -> (('a * 'c), ('b * 'c)) arrow
arr 接受純函數并給出等價的箭頭。對于我們的承諾示例,這意味著箭頭返回已經實現的承諾。>>>把兩個箭頭連在一起。首先從“a”到“b”取一個箭頭,改為成對使用。該對的第一個元素將由給定的箭頭處理,第二個組件將原封不動地返回。
我們可以讓這些操作自動創建帶有適當 f 和 label 字段的新箭頭。例如,在 a>>>b 中,結果 label 字段可以是字符串{a.label}>>{b.label}。這意味著我們可以顯示管道,而不必先運行它,如果需要的話,我們可以很容易地用更結構化的內容替換 label。
我們的第一個例子是:
let example1 commit =
let src = fetch commit in
let image = build src in
test image
to
let example1 =
fetch >>> build >>> test
雖然我們不得不放棄變量名,但這似乎很令人愉快。但事情開始變得復雜,有了更大的例子。例如 2,我們需要定義幾個標準組合:
(** Process the second component of a tuple, leaving the first unchanged. *)
let second f =
let swap (x, y) = (y, x) in
arr swap >>> first f >>> arr swap
(** [f *** g] processes the first component of a pair with [f] and the second
with [g]. *)
let ( *** ) f g =
first f >>> second g
(** [f &&& g] processes a single value with [f] and [g] in parallel and
returns a pair with the results. *)
let ( &&& ) f g =
arr (fun x -> (x, x)) >>> (f *** g)
Then, example2 changes from:
let example2 commit =
let src = fetch commit in
let base = docker_pull "ocaml/opam2" in
let build ocaml_version =
let dockerfile = make_dockerfile ~base ~ocaml_version in
let image = build ~dockerfile src ~label:ocaml_version in
test image
in
build "4.07";
build "4.08"
to:
let example2 =
let build ocaml_version =
first (arr (fun base -> make_dockerfile ~base ~ocaml_version))
>>> build_with_dockerfile ~label:ocaml_version
>>> test
in
arr (fun c -> ((), c))
>>> (docker_pull "ocaml/opam2" *** fetch)
>>> (build "4.07" &&& build "4.08")
>>> arr (fun ((), ()) -> ())
我們已經丟失了大多數變量名,而不得不使用元組,記住我們的值在哪里。這里有兩個值并不是很糟糕,但是隨著更多的值被添加并且我們開始嵌套元組,它變得非常困難。我們還失去了在 build~dockerfile src 中使用可選標記參數的能力,而是需要使用一個新操作,該操作接受 dockerfile 和源的元組。
假設現在運行測試需要從源代碼獲取測試用例。在原始代碼中,我們只需使用:src 將測試圖像更改為測試圖像。在 arrow 版本中,我們需要在生成步驟之前復制源代碼,使用帶 dockerfile 的 first build_ 運行生成,并確保參數是新測試使用的正確方法。
Dart 方法
我開始懷疑是否有一種更簡單的方法來實現與箭頭相同的靜態分析,但是沒有無點語法,而且似乎有??紤]示例 1 的一元版本。我們有:
val build : source -> image promise
val test : image -> results promise
let example1 commit =
let* src = fetch commit in
let* image = build src in
test image
如果你不知道蒙娜茲的事,你還有別的辦法。您可以定義 build 和 test,將 promises 作為輸入,而不是使用 let* 等待獲取完成,然后使用源調用 build:
val build : source promise -> image promise
val test : image promise -> results promise
畢竟,fetching 給了你一個源代碼承諾,你想要一個圖像承諾,所以這看起來很自然。我們甚至可以以承諾為例。然后看起來是這樣的:
let example1 commit =
let src = fetch commit in
let image = build src in
test image
很好,因為它和我們剛開始的簡單版本是一樣的。問題是效率低下:
- 我們用承諾的方式調用 example1(我們還不知道它是什么)。
- 我們不必等待找出要測試的提交,而是調用 fetch,獲取某個源的承諾。
- 不需要等待獲取源代碼,我們就調用 build,獲取圖像的承諾。
- 不用等待構建,我們調用 test,得到結果的承諾。
我們立即返回測試結果的最終承諾,但我們還沒有做任何真正的工作。相反,我們建立了一長串的承諾,浪費了記憶。
但是,在這種情況下,我們希望執行靜態分析。i、 我們想在內存中建立一些表示流水線的數據結構……這正是我們對 monad 的“低效”使用所產生的結果!
為了使其有用,我們需要基本操作(比如 fetch)來為靜態分析提供一些信息(比如標簽)。OCaml 的 let 語法沒有為標簽提供明顯的位置,但是我能夠定義一個運算符(let**),該運算符返回一個接受 label 參數的函數。它可用于生成如下基本操作:
let fetch commit =
"fetch" |>
let** commit = commit in
(* (standard monadic implementation of fetch goes here) *)
因此,fetch 接受一個提交的承諾,對它執行一個單字節綁定以等待實際的提交,然后像以前一樣繼續,但它將綁定標記為一個 fetch 操作。如果 fetch 包含多個參數,則可以使用 and* 并行等待所有參數。
理論上,let**In fetch 的主體可以包含進一步的綁定。如果那樣的話,我們在一開始就無法分析整個管道。但是,只要原語在開始時等待所有輸入,并且不在內部進行任何綁定,我們就可以靜態地發現整個管道。
我們可以選擇是否將這些綁定操作公開給應用程序代碼。如果 let*(或 let**)被公開,那么應用程序就可以使用 monad 的所有表達能力,但是在某些承諾解決之前,我們將無法顯示整個管道。如果我們隱藏它們,那么應用程序只能生成靜態管道。
到目前為止,我的方法是使用 let* 作為逃生艙口,這樣就可以建造任何所需的管道,但我后來用更專業的操作來代替它的任何用途。例如,我添加了:
val list_map : ('a t -> 'b t) -> 'a list t -> 'b list t
這將處理運行時才知道的列表中的每個項。然而,我們仍然可以靜態地知道我們將應用于每個項的管道,即使我們不知道項本身是什么。list_map 本來可以使用 let* 實現,但這樣我們就無法靜態地看到管道。
下面是另外兩個使用 dart 方法的示例:
let example2 commit =
let src = fetch commit in
let base = docker_pull "ocaml/opam2" in
let build ocaml_version =
let dockerfile =
let+ base = base in
make_dockerfile ~base ~ocaml_version in
let image = build ~dockerfile src ~label:ocaml_version in
test image
in
all [
build "4.07";
build "4.08"
]
與原來相比,我們有一個 all 來合并結果,并且在計算 dockerfile 時有一個額外的 let+base=base。let+ 只是 map 的另一種語法,在這里使用,因為我選擇不更改 make_dockerfile 的簽名?;蛘?,我們可以讓你的 dockerfile 接受一個基本圖像的承諾,并在里面做地圖。因為 map 需要一個純實體(make_dockerfile 只生成一個字符串;沒有承諾或錯誤),所以它不需要在圖表上有自己的框,并且我們不會因為允許使用它而丟失任何東西。
let example3 commit =
let src = fetch commit in
let image = build src in
let ok = test image in
let revdeps = get_revdeps src in
gate revdeps ~on:ok |>
list_iter ~pp:Fmt.string example1
這顯示了另一個自定義操作:gate revdeps~on:ok 是一個承諾,只有在 revdeps 和 ok 都解決后才能解決。這將阻止它在庫自己的測試通過之前測試庫的 revdeps,即使如果我們希望它可以并行地這樣做。而對于 monad,我們必須在需要的地方顯式地啟用并發(使用和 *),而對于 dart,我們必須在不需要的地方顯式地禁用并發(使用 gate)。
我還添加了一個 list-iter 便利函數,并為它提供了一個漂亮的 printer 參數,這樣一旦知道列表輸入,我們就可以在圖表中標記案例。
最后,雖然我說過不能在原語中使用 let*,但仍然可以使用其他一些 monad(它不會生成圖)。實際上,在實際系統中,我對原語使用了一個單獨的 let>操作符。這就要求主體使用底層 promise 庫提供的非圖生成承諾,因此不能在原語的主體中使用 let*(或 let>)。
和 Arrow 進行比較
給定一個“dart”,您可以通過定義例如。
type ('a, 'b) arrow = 'a promise -> 'b promise
那么 arr 就是 map,f>>>g 就是有趣的 x->g(fx)。第一個也可以很容易地定義,假設你有某種函數來并行地做兩件事(比如上面的和我們的)。
因此,dart API(即使有 let*hidden)仍然足以表示任何可以使用箭頭 API 表示的管道。
Haskell 箭頭教程 使用一個箭頭是有狀態函數的示例。例如,有一個 total 箭頭,它返回它的輸入和以前調用它的每個輸入的總和。e、 g. 用輸入 1 2 3 調用三次,產生輸出 1 3 6。對輸入序列運行管道將返回輸出序列。
本教程使用 total 定義 mean1 函數,如下所示:
mean1 = (total &&& (arr (const 1) >>> total)) >>> arr (uncurry (/))
因此,此管道復制每個輸入編號,將第二個編號替換為 1,將兩個流相加,然后用其比率替換每對。每次將另一個數字放入管道時,都會得到迄今為止輸入的所有值的平均值。
使用 dart 樣式的等效代碼是(OCaml 使用 /。對于浮點除法):
let mean values =
let t = total values in
let n = total (const 1.0) in
map (uncurry (/.)) (pair t n)
這對我來說更容易理解。通過定義標準運算符 let+(對于 map)和 +(對于 pair),我們可以稍微簡化代碼:
let (let+) x f = map f x
let (and+) = pair
let mean values =
let+ t = total values
and+ n = total (const 1.0) in
t /. n
無論如何,這不是一個很好的箭頭示例,因為我們不使用一個狀態函數的輸出作為另一個狀態函數的輸入,所以這實際上只是一個簡單的 Applicative.
不過,我們可以很容易地用另一個有狀態函數擴展示例管道,也許可以添加一些平滑處理。這看起來像箭頭符號中的 mean1>>>平滑,省道符號中的值|>平均值|>平滑(或平滑(平均值))。
注意:Haskell 還有一個 Arrows 語法擴展,它允許 Haskell 代碼編寫為:
mean2 = proc value -> do
t <- total -< value
n <- total -< 1
returnA -< t / n
這更像是飛鏢符號。
更多示例
我在 ocurrent/ocurrent 上建立了一個使用這些思想的稍微擴展版本的庫。子目錄 lib_term 是與這篇博客文章相關的部分,在 TERM 中描述了各種組合詞。
其他目錄處理更具體的細節,例如與 Lwt promise 庫的集成,提供管理 web UI 或 Cap’n Proto RPC 接口,以及帶有用于使用 Git、GitHub、Docker 和 Slack 的原語的插件。
OCaml Docker 基礎鏡像構建
ocurrent/docker-base-images 包含一個管道,用于為各種 Linux 發行版、CPU 架構、OCaml 編譯器版本和配置選項構建 OCaml 的 Docker 基本映像。例如,要在 Debian 10 上測試 OCAML4.09,可以執行以下操作:
$ docker run --rm -it ocurrent/opam:debian-10-ocaml-4.09
:~$ ocamlopt --version
4.09.0
:~$ opam depext -i utop
[...]
:~$ utop
----+-------------------------------------------------------------+------------------
| Welcome to utop version 2.4.2 (using OCaml version 4.09.0)! |
+-------------------------------------------------------------+
Type #utop_help for help about using utop.
-( 11:50:06 )-< command 0 >-------------------------------------------{ counter: 0 }-
utop #
以下是管道的外觀(單擊可查看完整尺寸)
它每周提取 opam 存儲庫的最新 Git 提交,然后為每個發行版構建包含該內容的基本映像和 opam 包管理器,然后為每個受支持的編譯器變體構建一個映像。許多圖片是建立在多個架構(amd64、arm32、arm64 和 ppc64)上的,并被推到 Docker Hub 的一個暫存區。然后,管道將所有散列組合起來,將一個多架構清單推送到 Docker Hub。還有一些別名(例如,debian 表示 debian-10-ocaml-4.09)。最后,如果有任何問題,則管道會將錯誤發送到松弛通道。
您可能想知道,我們是否真的需要一個管道來實現這一點,而不是從 cron 作業運行一個簡單的腳本。但是擁有一個管道可以讓我們在運行它之前看到管道將要做什么,觀察管道的進度,單獨重新啟動失敗的作業,等等,幾乎與我們編寫的代碼相同。
如果你想看完成的流水線,可以閱讀 pipeline.ml。
OCaml CI
ocurrent/ocaml-ci 是一個用于測試 OCaml 項目的(實驗性的)GitHub 應用程序。管道獲取應用程序的安裝列表,獲取每個安裝的已配置存儲庫,獲取每個存儲庫的分支和 PRs,然后針對多個 Linux 發行版和 OCaml 編譯器版本測試每個存儲庫的頭部。如果項目使用 ocamlformat,它還會檢查提交的格式是否與 ocamlformat 的格式完全相同。
結果作為提交狀態被推回到 GitHub,并記錄在 web 和 tty ui 的本地索引中。這里有很多紅色,主要是因為如果一個項目不支持特定版本的 OCaml,那么構建會被標記為失敗,并在管道中顯示為紅色,盡管在生成 GitHub 狀態報告時會過濾掉這些失敗。我們可能需要一個新的顏色跳過階段。
結論
編寫 CI/CD 管道很方便,就好像它們是一次連續運行這些步驟并始終成功的單點腳本一樣,然后只要稍作更改,管道就會在輸入更改時運行這些步驟,同時提供日志記錄、錯誤報告、取消和重建支持。
使用 monad 可以很容易地將任何程序轉換為具有這些特性的程序,但是,與常規程序一樣,在運行某些數據之前,我們不知道該程序將如何處理這些數據。特別是,我們只能自動生成顯示已經開始的步驟的圖表。
傳統的靜態分析方法是使用箭頭。這比單元格稍微有限,因為流水線的結構不能根據輸入數據而改變,盡管我們可以增加有限的靈活性,例如可選的步驟或兩個分支之間的選擇。但是,使用箭頭符號編寫管道是很困難的,因為我們必須使用無點樣式(沒有變量)編程。
通過以一種不尋常的方式使用 monad(這里稱為“dart”),我們可以獲得靜態分析的相同好處。我們的函數不是接受純值并返回包裝值的函數,而是接受并返回包裝值。這導致語法看起來與普通編程相同,但允許靜態分析(代價是無法直接操作包裝的值)。
如果我們隱藏(或不使用)monad 的 let*(bind)函數,那么我們創建的管道總是可以靜態地確定的。如果我們使用綁定,那么管道中會有隨著管道運行而擴展到更多管道階段的孔。
基本步驟可以通過使用單個“標簽綁定”創建,其中標簽為原子組件提供靜態分析。
我以前從未見過使用過這種模式(或者在 arrow 文檔中提到過),它似乎提供了與 arrow 完全相同的好處,而且難度要小得多。如果這個名字是真的,告訴我!
這項工作由 OCaml 實驗室資助。
原文鏈接:
https://roscidus.com/blog/blog/2019/11/14/cicd-pipelines/