淺談 GitHub Actions Workflow 的 Cache 機制
參與開源專案一直是我的長期目標之一,然而開源專案其實又分好幾種形式,第一種是自己打造開源專案(曾經試過開發小專案,獲得 80 幾顆星星,後來就沒在維護了😂),但要真的建立出讓許多人使用的專案還是非常有難度,因此我放在自己比較長遠的目標。第二種就是貢獻現有的開源專案,而開源專案又分為工具類型(例如打造一個框架)與應用類型(例如開發一個網頁或 App),工具類型我認為比較困難,可能光閱讀 Source Code 就有很大的問題了,所以一直遲遲沒有進度,因此目前比較適合自己的可能就是應用類型的專案了。
在四個月前我開始學習指彈吉他 Fingerstyle,是一種不需要唱歌,可以自己獨奏的風格(剛好很適合唱歌難聽的我)。我深深被這種風格吸引,並且花了蠻多時間研究相關的文化與技巧(還爛到不行,希望再過一年後可以完整彈出一些歌…)。
(放個韓國大神的影片讓大家感受一下 Fingerstyle 的魅力)
回歸正題,剛好社群上最近有人計畫要來打造指彈台灣的官網,並創建了 Open Source 的專案,正在招募前後端的開發者前來貢獻 (Github Repo List)。難得有自己感興趣的應用,當然不能錯過啦!
目前前後端的 Repo 都還在基礎建設中,因為後端是使用我不太熟悉的 Python Django,因此到現在我還沒去研究,不過前端倒是用了自己熟悉的 Tech Stack,因此就毫不猶豫決定貢獻啦!
因為還在初期建設,所以有很多東西可以玩,我選擇貢獻的是設置 GitHub Actions 的 CI Process,並建立基礎的 Build 與 Lint 流程。(不熟悉 GitHub Actions 的讀者可以參考我之前的文章)
我其實一直知道可以在 CI Process 導入 Cache 機制,當沒有必要時就不用重複安裝依賴套件、重複執行專案的 build,以此盡量優化 CI 的執行時間,不過目前為止開發的專案在我接手時都早已設定好 Cache 機制,所以我一直沒有從頭實作的機會,直到這次貢獻台灣指彈的開源專案,我才有機會好好了解一下在 GitHub Actions Workflow 中 Build Cache 的機制該怎麼設定。(各種 CI Runner 的設定都會有些許不同,本文將專注在 GitHub Actions 的快取運作機制與設定方式。)
這邊先放上 Pull Request 的連結以及貢獻的 workflows .yml 檔
GitHub Actions 的快取機制 — actions/cache
GitHub Actions 的其中一個優勢是可以直接使用別人開發好的 actions,而關於快取,我們可以直接使用官方提供的 action — actions/cache。這個 action 可以創建並且在需要時還原由一個 unique key 作為識別的快取。我們先看看它的設定方式,再介紹重要 properties 的用途。
path:指定要存入快取的內容,當你要還原 (restore) 一個快取時,快取的內容也會被塞進當前機器的這些 path 裡。以 Nextjs 的應用為例子,需要存取進 cache 的有 node_modules 資料夾以及透過 next build 產生的 .next 資料夾。
key:一個 unique 的識別碼,未來會透過這個 key 看看有沒有 match 的快取版本可以抓取。以 Nextjs 應用為例,我們會希望 yarn.lock (代表依賴套件沒有改變) 與應用相關的 source code (js | ts | jsx | tsx 檔案) 都沒有改變的狀況下,直接抓取快取版本,不要重新安裝套件與重新 build,因此這個 key 就可以由 yarn.lock 與 source code 的 hash 值組成,當這些檔案有變動時,hash 值就會改變,造成 cache miss,迫使 workflow 需要重新執行安裝套件與 build 的流程。
restore-keys:當指定的 key 發生 cache-miss 時,restore-keys 可以當作備用的還原鍵列表,並且會以「由上至下」的順序匹配 prefix match 的 cache。如果 restore-keys 有匹配了多個配置,action 將會返回最新創建的快取。假設我們創建了一個 PR,是 feature-branch-a 要 merge 回 master,並且我們的設定如下:
key:
yarn-feature-aaa
restore-keys: |
yarn-feature-
yarn-
則 Github Actions 的匹配優先順序會是以下:
feature-branch-a
分支中的 keyyarn-feature-aaa
feature-branch-a
分支中的 keyyarn-feature-
feature-branch-a
分支中的 key-yarn
master
分支中的 keyyarn-feature-aaa
master
分支中的 keyyarn-feature-
master
分支中的 key-yarn
這時候你可能會想到一個問題:
可以 access 到快取的範圍沒有限制嗎? 難道我在任一個 PR 都可以拿到其他 PR 任一個分支上的快取嗎?
想都別想,存取快取是有限制的。
GitHub Actions 可以 access 與 restore 「當前分支」、「Base Branch」、「fork 的 Repo 上的 Base Branch」與「Default Branch (通常是 main 或是 master)」中建立的快取,例如在任何的 Pull Request 都可以 access Default Branch 上建立的快取,又假設一個 PR 是 branch-b 要 merge 回 branch-a,則在 branch-b 觸發的 workflow 可以訪問 Default Branch、branch-a 與 branch-b 中建立的 cache。
所以其實不同的分支之間的快取是有隔離邊界的,例如要 merge 回 master branch 的 branch-c 就沒有辦法 access 到同樣要 merge 回 master branch 的 branch-d 上的快取。
快取的管理與刪除策略
每個獨立的快取如果在 7 天內都沒有被命中,GitHub 將會把它刪除,此外一個 Repository 中所有 Cache 的總大小限制為 10 GB,如果超過,GitHub 將會開始刪除舊的快取,直到總大小再次低於 Repo 的限制。
大致了解了 GitHub Actions 快取機制的運作邏輯後,我們可以回過頭再次看看我在 PR 中貢獻的 workflow 設定檔, 其中總共快取了兩樣東西
- Yarn Cache
- Nextjs Build Cache
基本上如果有命中快取的話整個 build process 可以省下不少時間
分別點開 step 的細節可以看到有沒有命中快取
接下來看看當 Cache Miss 時,workflow 會怎麼執行。
後來我又貢獻了另一個 PR,內容是加入 Next 官方的 bundle-analyzer,因為新增了一個套件,在 yarn.lock 改變的狀況下,快取是一定會失效的。
也可以注意到在 Cache Miss 的狀況下,yarn install 與 next build 兩步驟相比命中快取的狀況都多出很多執行時間。
有趣的是可以發現最久的步驟是 Cache 的 Post Run,當 Cache Miss 時,Cache Action 會在 workflow 即將結束時執行 post run,使用新的 key 建立全新的快取,所以才會花費比較久的時間。
至於在 build job 之後的 lint 階段因為可以直接拿 build 階段 post run 產生的快取,所以基本上就算 build step cache miss,在 lint step 也會命中快取。
結論
雖然只是一個微不足道的 PR 貢獻,但這是我第一次自己設定 CI Process 的 Cache 機制,透過實作後才比較了解 Github Actions 的快取機制是如何運作的,儘管每種 CI Runner 的機制可能會不相同,但有了這次經驗,未來要在其他 CI Runner 實作快取機制應該也不會毫無方向。最後就是能夠參與自己有興趣的開源專案還蠻興奮的,推薦大家也去多多嘗試啦!