🤖

ブランチごとに最新のキャッシュだけ残すGitHub Actionsを作った

#tech#CI

2024-10-13

動機

GitHub Actionsではキャッシュを使うことができる.適切に使えばワークフローの実行時間を短縮することができるなどの恩恵がある.

一方このキャッシュには制限事項もある.代表的なもので言えば,各リポジトリに対してトータルで10GBまでしかキャッシュを保存できない.このサイズを超えた場合,古いキャッシュが削除される[1](一週間アクセスが無いものも削除される).この削除は自動的に行われるが,規模の大きいリポジトリだといらないキャッシュが様々あり,長く残しておきたいキャッシュがたまたま数日アクセスされないだけで,GitHub側から他の比較的どうでも良いキャッシュよりも優先度が低いものと判断され消されかねないし,キャッシュスラッシングなどの問題が起こりかねない.

そもそもサイズ制限を緩和できれば良いわけだが,これを行う解決策はいくつか存在する.例えばキャッシュを行うブランチを制限するであるとか,Amazon S3等外部ストレージにキャッシュを保存するなどが代表的だろうと思う.

一方で,このキャッシュはどのブランチ上のものかという情報を持っている.これは例えばGitHub REST APIのリポジトリのキャッシュ一覧を取得するAPIの仕様のレスポンス例などを見ても分かるし,GitHub Actionsのキャッシュの一覧からも確認することができる.

これを観察していると,同じブランチに対しても複数のキャッシュが保存されている場合がある.単に古いものが消去されていないだけなのだが,最新のキャッシュ以外は使わないような場面も多く存在すると思っており,その場合古いキャッシュはサイズ制限を圧迫するだけの存在となってしまう.

そこでブランチごとに最新のキャッシュだけを残すような仕組みがあれば,それだけでサイズ制限を緩和するには相当良いように思えたので,そのようなものが無いかを調べたがGitHub Actionsに組み込んで簡単に使えるようなものは存在しないように思えたので作ることにした.

コード及び使い方

実装はここに置いてある:

https://github.com/lapla-cogito/only-latest-cache

GitHub Marketplaceにも公開してある:

https://github.com/marketplace/actions/leave-only-the-latest-cache

Usage

主な使用法はREADME.mdに書いた通りだが,ワークフロー中で,そのワークフローが動いているブランチ上の最新のものだけ残したいキャッシュのプレフィックスを指定すると,最新のもの以外を削除する.

例えばあるワークフローのステップの一部分が次のようなものであり,ブランチA上で動いているとする:

- name: Use Node.js
  uses: actions/setup-node@v4
  with:
      node-version: ${{ matrix.node-version }}
      cache: 'pnpm'

これはNode.jsのセットアップとnode_modulesのキャッシュを行っている.ここでキャッシュされたnode_modulesはLinuxで動かしているならnode-cache-Linux-pnpmというプレフィックスが付いている.このまま何もしないと同じブランチA上でもサフィックス(SHA-256の部分)が異なるキャッシュが複数保存されうる.

これに対して,同一ワークフロー上で次のようなステップを行ってみる.環境変数としてのGITHUB_TOKENにはActionsへのwrite権限が付いているトークンが入っているものとする[2]

- name: Leave only latest cache
  uses: lapla-cogito/only-latest-cache@v1
  with:
      key_prefix: 'node-cache-Linux-pnpm'
  env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

これはキャッシュキーのプレフィックスがnode-cache-Linux-pnpmであるもののうち,最新のもの以外を削除するように動作する.これにより,同じブランチ上で複数のキャッシュが存在しなくなり,容量に余裕が生まれる.

適当なリポジトリで試して古いキャッシュが消されている様子
適当なリポジトリで試して古いキャッシュが消されている様子

実装

Node.jsからワークフローのメタデータなどを得るには@actions/core@actions/githubを使うことができる.これによりワークフローへの入力なども取得することができる:

import * as core from '@actions/core';
import { context } from '@actions/github';

const key_prefix = core.getInput('key_prefix');
core.info(`[INFO] branch: ${context.ref.replace('refs/heads/', '')}`);
core.info(`[INFO] key_prefix: ${key_prefix}`);

また,キャッシュの一覧を取得するにはGitHub REST APIを使う.これは@octokit/restを使うことでエンドポイントの口を意識しなくてもリクエストを行うことができる.キャッシュの一覧を得るにはoctokit.rest.actions.getActionsCacheList.endpoint.mergeを使うことができ,refでの絞り込みや,最後にアクセスされた順での並び替えなどもできる:

import { Octokit } from '@octokit/rest';

const octokit = new Octokit({
    auth: process.env.GITHUB_TOKEN,
});
const caches = await octokit.rest.actions.getActionsCacheList.endpoint.merge({
    owner: context.repo.owner,
    repo: context.repo.repo,
    ref: context.ref,
    sort: 'last_accessed_at',
});
const actionsCache: { id: number; ref: string; key: string }[] = await octokit.paginate(caches);

こうして得られたキャッシュ一覧は最終アクセス日時順にソートされているから,順に削除をかけていくだけで良い:

let deleted = 0;
await Promise.all(
    actionsCache
        .filter((cache) => cache.key.startsWith(key_prefix))
        .map(async (cache) => {
            if (core.isDebug()) {
                core.debug(`[DEBUG] delete cache: ${cache.id}`);
            }

            deleted++;
            return octokit.rest.actions.deleteActionsCacheById({
                owner: context.repo.owner,
                repo: context.repo.repo,
                cache_id: cache.id,
            });
        })
);
脚注
  1. 削除自体はもちろん手動でもできる ↩︎

  2. Actionsへのwriteが無いとキャッシュの一覧を取ったりできない ↩︎