こんにちは、株式会社ライトハウスでエンジニアをしている宮城です。
今回は、RustでGitHub Actionsの実行時間を集計するツールを作成した話をします。
目次
GitHub Actionsの実行時間を可視化するにいたった背景
要約
困っていたこと
- 一回の変更で多数のCIが実行され、多くの課金時間が消費される
解消方法
- ダミーJobを用意して、実行時間を短縮する
この体験を通して気がついた課題
- GitHub Actionsの情報を把握出来てない
詳細
困ったていたこと
8月より新規プロダクトの開発が始まりました。
このプロダクトは、複数のサービスをオンプレミスサーバー上で動かす想定です。
この複数のシステムは、同一レポジトリ上に存在していた方が開発をしていくフローと相性が良いため、モノレポを採用しました。
また、弊社はGitHub ActionsでCI/CDを行うことが多いため、今回もGitHub Actionsを使用することにしました。
最初にサービスを1つ作成したとき、下記のようなディレクトリ構造になりました。
. ├── .github/workflows/service_a.yaml └── service_a
また、このサービス配下のディレクトリにコードの変更があったときのみCIを実行するというルールを設定しました。
on: push: branches: - '**' paths: - 'service_a/**' jobs: service-a-lint: name: service-a-lint
その後、このjobをブランチプロテクションルールに加えました。
ほどなくして、 2つ目のサービスを作成しました。
ディレクトリ構成は下記のようになりました。
. ├── .github/workflows/service_a.yaml ├── .github/workflows/service_b.yaml ├── service_a └── service_b
1つ目のサービスと同様に下記のルールでCIを作り、このjobをブランチプロテクションに加えました。
on: push: branches: - '**' paths: - 'service_b/**' jobs: service-b-lint: name: service-b-lint
ここで困ったことに遭遇しました。 service_aのみ変更した場合、pathsトリガーではなくブランチプロテクションがトリガーとなり、service_bのCIも起動します。 このとき、service_bに対する変更がないため、いつまでもservice_bのCIが終わりません。
開発の初期で、CI対象のサービスが2つしか存在しなかったため、どのCIも全ての変更に対して実行することにしました。 これにより、pathsルールとブランチプロテクションを併用している際に発生してしまう問題を一旦回避しました。
しかし、開発から2か月後、さらにサービスが増えたことにより1回の変更で実行されるCIが増えていきました。
それに伴い、GitHub Actions上で実行時間も増え、下記のようなメールが届きました。
ついに会社のGithubアカウント全体に影響を与えてしまう状態になってしまいました。
解消方法
このままではCIが実行できなくなるので、一旦課金設定を行い、全てのリポジトリのCI/CDが止まらないようにしました。
ただ、このプロダクトのサービスが増えるたびに、1回の変更で実行されるJobが増えていきます。
それは、GitHub Actions上の課金時間が増えていくことを表しています。
額が小さいうちは問題ありませんが、いずれ改善しなければいけない項目になるのは目に見えていました。
そこで、 今回はダミージョブを作ることにしました。
具体的には、まずdummy.yamlをworkflows配下に作成します。
. ├── .github/workflows/dummy.yaml ├── .github/workflows/service_a.yaml ├── .github/workflows/service_b.yaml ├── service_a └── service_b
そして、dummy.yamlの中にservice-a-lint.yamlとservice-b-lint.yamlに定義されているjob名と同一のものを定義します。
このjobに対して、必ず成功で終わるstepを記載します。
name: Dummy CI on: push: branches: - '**' jobs: dummy-service-a-lint: name: service-a-lint runs-on: ubuntu-latest steps: - name: exit with success run: exit 0 dummy-service-b-lint: name: service-b-lint runs-on: ubuntu-latest steps: - name: exit with success run: exit 0
これにより、あるサービスのディレクトリに変更があったときのみCIを実行しつつ、ブランチプロテクションも行えるようにしました。
この体験を通して気がついた課題
今回は上記案で解消出来たので良かったのですが、本当の課題はGitHub Actions上で動作しているworkflowの情報を把握出来ていないことだと感じました。
具体的には、各workflowがどれくらいの頻度と時間で実行されているか、またそれに対する課金時間はどれくらいなのかということです。
今回はすぐに解消できる問題だから無事に済みましたが、状態や傾向を把握しておかないと、何かあったときにCI/CDが実行できない状態になり開発を止めかねません。
また、実は極端に実行時間が長いジョブや全くデプロイが行われていないサービスなど、改善が必要なCI/CDの発見やサービスそのものの改善を知ることも出来ません。
そのために、GitHub Actions上で動作しているworkflowの情報を把握したくなりました。
Rustでツールを作った背景
上述したとおり、やりたいことは、GitHub Actions上で動作している各workflowの情報を把握することです。
特に欲しい情報は、各workflowの実行時間、実行頻度、課金時間です。
当初はGitHubの支払いから簡単に分かると思っていました。
しかし、リポジトリごとの実行回数は記載されていますが、実際どのジョブにどれくらい時間がかかったかを把握することが出来ませんでした。
そのため、やりたいことを実現するためには、 GitHub CLIやGitHubのAPIを使う必要がありました。
困ったことにGitHub CLIのgh run list
だと、課金時間が取れませんでした。
しかし、リクエストを送らないといけないエンドポイントが複数あり、シェルを書くよりもコード化した方が楽だなと思いました。
これが、今回ツールを作ろうと思ったきっかけです。
また、やりたいことを実現するツールも探し、見つけました。
しかし、以下の理由から、Rustでツールを作ることにしました。 - 今後のIoT開発にRustを使用するための事前調査をしたい。 - ジョブごとの実行時間を集計するだけなので、スコープが狭くて丁度よい。 - Rustかっこいい。
現在、緊急でこのツールが欲しいというわけでもなかったので、個人の時間で作成してみることにしました。
ツール紹介
作成したツールは、こちら にあります。
GitHub Actionsを使用しているリポジトリと期間を指定すると、指定した期間の実行時間をworkflow runごとに取得し、CSV形式で出力するプログラムになっています。
実行時間の可視化
弊社には、ETLや分析用途のEKSがあります。
上記で作成したバイナリーを実行するCronJobを作り、Big Queryに結果を格納します。
この結果を見るためのダッシュボードをRedash上に作成し、開発メンバーで毎週行っているモニタリングチェック時に見るようにしています。
実装時にハマった箇所
正直ハマらなかった箇所がないくらい、全ての工程でハマりました。
その中で一番実現に時間を要したのが、非同期処理とプログレスバー表示の組み合わせです。
非同期処理を取り入れた方が良いと考えた理由
実行時間を取得するために、各workflow runのidを用いて、GitHub APIのtimingエンドポイントにリクエストを送る必要があります。 workflowによってはかなりのrun数が存在します。 ツールの開発当初は、同期的にリクエストを送っていましたが、実行時間に20分以上かかることがあるため、改善が必要でした。
プレグレスバーがあったほうが良いと考えた理由
上記実行時に、待ち時間が発生するworkflowが存在するため、処理が進んでいることを伝えるために進捗が可視化されていたほうが良いと感じました。 また、ローディングしてるさまが描画されていると、少しワクワクした気持ちになり退屈さが軽減されるからです。
使用したライブラリ
非同期ランタイムにはtokio
、プログレスバーの表示にはindicatif
を使用しました。
具体的にハマったこと
indicatif
のexamplesをみて自分のプログラムに組み込みましたが、非同期処理と組み合わせると、自分が思うような形で進捗を表示することが出来ませんでした。
具体的には、各非同期タスクをmap内で処理すると、最後の集計結果のみがプログレスバーに表示されてしまいました。
let pb = new_progress_bar(workflow_runs.get_length() as u64, workflow.get_name()); let workflow_runs_tasks: Vec<JoinHandle<Result<WorkflowRuns>>> = workflows .get_workflows() .into_iter() .progress_with(pb) .map(|w| { let api = api.clone(); tokio::spawn( async move { api.find_timing_from_workflow_run(run.get_id()).await }, ) }) .collect::<Vec<_>>(); let workflow_runs_lst = try_join_all(workflow_runs_tasks) .await? .into_iter() .collect::<Result<Vec<WorkflowRuns>>>()?;
改善策
これを解消するために、チャネルを使用することにしました。
具体的には、mspcを使ってチャネルを生成し、非同期処理内でapiリクエストに対するレスポンスの受け取りとチャネルへの送信を行うようにしました。
これにより、非同期時でも処理が終わる度にカウントアップされ、プレグレスバーが正しく表示されるようになりました。
最後にチャネルのレシーバーをドロップしないと処理が終わらないので注意が必要です。
let pb = new_progress_bar(workflow_runs.get_length() as u64, workflow.get_name()); let (tx, mut rx) = mpsc::channel(1); for run in workflow_runs.get_workflow_runs().into_iter() { let tx = tx.clone(); let api = api.clone(); tokio::spawn(async move { match api.find_timing_from_workflow_run(run.get_id()).await { Ok(timing) => { if tx.send(timing).await.is_err() { println!("receiver dropped"); } } Err(msg) => println!("failure: {}", msg), } }); } // Note: drop the last sender to stop `rx` waiting for message drop(tx); while let Some(t) = rx.recv().await { // some implementations pb.inc(1); } pb.finish();
終わりに
Rustでツールを作るための実用的な課題を見つけられたことにより、課題の解決のみでなく、今後のIoT開発に役立てそうな知見を収集することが出来ました。
今後は、今回得た知見をチームにも還元していきます。
弊社は、新しい技術や興味のある技術の導入に対してとても柔軟です。
色んな技術に挑戦したい方には、とてもおすすめです。
現在、エンジニア採用活動中です!
もしドメインや使っている技術に興味もっていただけましたら、カジュアル面談でお話しましょう!