DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

治安維持のためにCI/CDサービスを活用しておこなったこと

SWETグループの平田(@tarappo)です。

10/21(木)にiOS Test TeaTime #3を開催しました。

その時の私の登壇資料は次のとおりです。

資料では不足しているであろう情報もあるので、本稿ではその点も補いつつ説明していきたいと思います。

はじめに

SWETメンバーとして、プロジェクトに関わるときは「CI/CDサービス」が問題なく動いているかどうかを確認することはよくあります。

CI/CDサービスが文字通り継続的に動いているのであれば「プロジェクトのコードが手元でも動かせる」「コードの状態がある程度わかる」という状態ともいえます。

そのため、これはプロジェクトの状況を把握してなにをするべきかを判断するためには重要な指標の1つともいえます。

その中で最初にチェックする箇所の例としては、次のようなものがあります。

  • どのようなことを実行しているか
    • 自動テストがあって実行されているか
  • どのようなタイミングで実行しているか
    • 継続的に動かせているか
  • ビルドの成功率はどういった状態か
    • 仮に失敗をある程度していてもすぐ直せているか

本稿では、私が今回関わったプロジェクトの「関わった頃の状況」とそれを元にどのような理由からどのようなことをおこなっていったかについて次から説明をしていきます。

関わった頃の状況

本稿で話す関わったプロジェクトではiOS周りをメインで関わりました。 そのプロジェクトで利用しているCI/CDサービスはBitriseになります。

Bitriseの詳細は本稿では説明をしませんが、Bitriseでは実行するものを「ワークフロー」という単位で用意します。 その「ワークフロー」は特定の機能をおこなうことができる「ステップ」と呼ばれるものを組み合わせて作ります。

このBitriseの関わった頃の利用状況としては次のようなものでした。

  • Q:どのようなことを実行しているか
    • 一般的なワークフロー(ビルド、テスト、App Store Connectへのアップロード)は揃っている
    • コードにテストケースは一定あるが「テスト」のワークフローはNightly実行のみ
  • Q:どのようなタイミングで実行しているか
    • 継続的に動かせているのは一部であり、次のような感じになっていた
      • PR単位:Dangerのみ実行しており、他は実行していない
        • Dangerは本プロジェクトの他リポジトリと併せてCircleCIに移行しました
      • Push時(特定ブランチ):アプリのビルドと配布
      • Nightly(1日1回):テスト
  • Q:ワークフローの実行結果の成功率はどのぐらいか
    • アプリのビルドにおいて、マージ後に直せているがテストにおいては失敗したまま放置傾向になっている

上記のように、PR単位で何かしらのワークフローは動かしておらずコードのチェックは行えていない状態ではありました。

この状態の理由

関わった頃はまだ「開発者がそこまで多くなく、みんながある程度プロダクトについて理解している状態」という前提がありました。

そのような前提がある中なので、

  • Bitriseの運用に対してコストをあまりかけられない
  • 「ビルド」や「テスト」が失敗するようなコードであっても、原因に気づきやすくコードを直すコストはそこまで高くない

結果として、この時点ではBitriseは「アプリの配布用」の用途ぐらいになっていました。

今後もこのような体制であれば一定問題がおきないかもしれませんが、関わった時点で人が増えつつある時期でもありました。 このまま進んでいくと、次のような問題が起こるであろうと思われました。

今後起こるであろう問題

関わる人が増えてくると、次のような問題が発生していきます。

  • 手元で「ビルドができない」「テストが通らない」といったケースが増える
    • 自分が関係していないコードのことが多くなり、原因の特定コストが一定かかる
  • 誰もがマージできるためビルドできないコードが一定発生し「検証をするためのアプリ」のビルドが検証当日になっても出来ていないといったことが起こる

この手のことがある程度増えて、結果として「コミュニケーションコスト」「失敗の原因の特定コスト」「修正コスト」といったいくつものコストが必要になってしまうということが起こりえます。

この状態が今後起こることを想定し、Bitriseをもう少し活用し「コードの品質」をある程度担保できるようにするのが良いだろうと判断しました。

そこで、「今後に向けた対応」として次を検討しました。

  • Step1:PR時点で「ビルド」「テスト」のワークフローを動かす
  • Step2:「ビルド」「テスト」のワークフローが失敗したものはマージできないようにする

これらをBitriseやGitHubなどで設定することはかんたんです。 しかし、実際に利用され続けなければ意味がありません。

そこで、この「Step1」と「Step2」をおこなった場合の課題などについて関係者にヒアリングをおこないました。 その結果として次の課題がわかりました。

  • 課題1:実行時間がかかりすぎて待っていることができない
    • 特に「テスト」の実行時間が長い
  • 課題2:必要性を強くは感じていない
    • 現時点ではそこまで問題が起きていないと考えているため

実行時間がかかりすぎてもこれらを行おうとするかはプロダクトをとりまく状況にも依存します。 この時点では、スピード優先してリリースしていくという形とも言えました。

しかし「課題1」「課題2」において何かしらのアプローチをおこなうことは今後を考えると必要だろうと判断しました。 それらについて次に説明をしていきます。

課題1:実行時間について

Bitrise Insightsという機能を利用して実際に「テスト」にかかっていた時間を出すと次のとおりです。

f:id:swet-blog:20211104113334p:plain

この画像をみたら分かるとおりですが、

  • テストの実行時間は40分以上
  • 成功率は高くない

PR時において40分以上も結果を待つのは厳しく、1度失敗して修正するとすぐに1時間以上の時間が発生するためPR時に設定したくない理由も分かります。

また成功率の低さは手元で全テストを実行するのが面倒という側面もあったと考えられます。 本稿では詳細は割愛しますが、このプロジェクトではモジュール単位で分割しており、モジュール単位でテスト実行できるものの、それら全てのテスト実行がXcodeからできるようになっていませんでした。 ※なお、この課題については全実行できるような仕組みを別途チームメンバーが用意してくれました。

このような状況下では、テストが壊れやすくその上に壊れたテストが乗ってしまい、さらに直しづらいというループが出来てしまいます。

これらを解消するには「実行時間」をできる限り短くする必要があるといえます。

課題2:必要性の認識

「定期的にコードのチェックをしてなくても問題があまり起きていない」状況ではありますが、将来的には炎上リスクがあると言えます。

必要性を認識してもらうためには「治安の良い状態に慣れてもらう」「現状を認識できるようにする」といった状態にして、このような環境が当たり前と思ってもらうことです。

  • 「Bitriseの治安の良い状態(グリーンが当たり前)」を作って、その環境に慣れてもらう
  • 「Bitriseの状態の可視化」をおこないコードの状態を常にわかるようにする

このような状況を当たり前にすることで、なにか問題が発生したときに以前と比べて「問題が起きた」というのを強く感じてもらえるようにするというのがあります。

おこなったことの内容

上述した「課題1」「課題2」を解決するために行なったことについて次のとおりです。

  • 前提としてやる必要のあること(まずはキレイにすること)
    • (1)Bitriseのワークフローの現状把握と整理
  • 「課題1」へのアプローチ
    • (1)Bitriseのワークフローの現状把握と整理(一部)
    • (2)実行時間の短縮
  • 「課題2」へのアプローチ
    • (3)情報を追えるようにするための可視化
    • (4)安定運用するための対応

(1)Bitriseのワークフローの現状把握と整理

既存のワークフローの把握と整理

BitriseはWebからワークフローを簡単に作れるため、ワークフローやワークフロー内のステップは増えやすく誰が作ったか分かりづらいという問題があります*1

結果として次のようなことがしばしば起きますし、実際に起きていました。

  • 「使っていないワークフロー、ステップ」が残されたままになることがある
    • 例えば、調査用に作った一時的なものは放置されやすい
  • ワークフロー名に統一性がなかったり、行なっていることとワークフロー名があっていない

そこで、次の方針をもとに整理整頓を進めました

  • 必要のないものは削除
    • これでステップが削除されると実行時間の削減にもつながる
  • ドキュメント化
    • ルール化したほうが良いものについてはルールについて明記
    • ワークフローやトリガーなどBirise利用に関する情報をドキュメントにまとめ、適宜更新

この方針のもとにおこなったこととしては、次のようなものです。

  • (1)メンバーにヒアリング
    • 使っていないワークフロー、ステップの削除
  • (2)ワークフローで行なっていることをチェック
    • 別名のワークフローだけど中身は同じというのもあったため整理
    • 使っているステップをチェックし必要なければ削除
  • (3)ワークフローの作り方についてルール化
    • ワークフロー名の命名規則
    • ユーティリティワークフローの用意と利用
  • (4)今後のために整理した内容は全てドキュメント化

ヒアリングして削除できたものはそこまで多くなく、実際はワークフローならびにステップ、そしてステップがよんでいる先(たとえばfastlaneのlane)をチェックして、本当に必要なのかを確認しつつ進めました。

次図は実際のドキュメントの目次ですが、最終的にはこのようなドキュメントを用意することで、これから利用する人も現状把握がある程度しやすいようにしています。

これらによって現状のBitriseのワークフローなどの状態がわかるようになりました。 ここからがスタートとも言えます。

また、これらは地道な作業ですがこれだけでも多少の実行時間の削減に繋がっています。

既存のワークフローの失敗を直す

あまり定期的に動かしてないこともあり「テスト」のワークフローは失敗していました。

壊れた状態のままにしておくと、他の壊れたコードも追加されていってさらに直すのが大変になります。 結果として、放置されてしまうということはよくあることです。

あまり動かしていなかったこの「テスト」では次のような問題が起きていました。

  • 例1)Compile errorになっている
  • 例2)期待値が少し変更されていた(例えば表示文言が変わったとか)
  • 例3)手元では動くがBitriseの環境依存で落ちる
  • 例4)Xcode12 x XcodeGenで起きたCycle Inside App問題

例1)や例2)では落ちている箇所を伝えて、直してもらえれば大丈夫です。 しかし例3)や例4)は調査、対応コストが一定かかるためどうしても放置されてしまいがちです。

そこで例1)や例2)はSWETメンバーで対応はしつつも、少したったらプロジェクトメンバーに伝えて直してもらうように依頼しました。 例3)や例4)についてはSWETメンバー側でおもに関わって対応をしました。

対応をした結果、1度All Greenになりましたが再度テストが失敗することは何度も起きました。 しかし、例3)や例4)のようなコストが一定かかるものは何度も起きるわけではありません。

何度も対応を続けることで、All Greenにするための修正コストもそこまでかからないようになってきました。

(2)実行時間の短縮

「課題1」のアプローチは分かりやすく、実行時間をどれだけ減らせるかになります。

PR時にワークフローが動いている状態を当たり前にするためには、この状態が良いなと思ってもらわなくてはなりません。 そのためには、実行時間は大きな課題でした。

そこで、多少ワークフローでおこなっていることがわかりづらくなっても、実行時間を減らすことを最優先としました。

実行時間の短縮

まずは実行時間がかかっているテストをどうにかするところからはじめました。

当初のテストのワークフローの例としては次のような感じでした。 ※実際のワークフローはもう少しステップが多いです。 f:id:swet-blog:20211104113459p:plain

この状態における実行時間短縮に向けた対応として次をおこないました。

  • (1)並列化ができる箇所(ステップC)は並列化
  • (2)並列化しても共通して利用されるステップ(C以外のステップ)で時間を減らせる箇所がないかの調査と対応

ワークフローの並列化

Bitriseでのワークフローの並列化は公式のステップを利用します。 実際に用意した並列化したワークフローの図を元に説明をしていきます。

f:id:swet-blog:20211104113346p:plain

上の図を元に説明をしていきます。 ワークフロー1がメインのワークフローとなります。 ワークフロー2〜4までがワークフロー1きっかけで動く他のワークフローです。

ワークフロー1では「Bitrise Start Build」ステップを利用して他に動かすワークフローを指定します。 また、他のワークフローが動いている間にワークフロー1でもテストを実行します。

そして、ワークフロー1にある「Bitrise Wait for Buildステップ」で他のワークフローが終わるのを待機します。 また、ここで他のワークフローの成果物をまとめてテストケース数などを計算するようにしています。

ステップの実行時間のチェック

上記の並列化は「テスト」においては効果がありますが「ビルド」は並列化できません。 そこで、共通で利用するようなステップがどれぐらい時間がかかっているかをチェックしました。

ここの実行時間が削減できれば、ワークフロー全体に効果があります。

調べた結果として次の3つのステップで多少時間がかかっていました。

  • (1)git cloneステップ
  • (2)fastlaneステップ
  • (3)Cacheステップ

1つ目はgit cloneステップで、Repositoryの肥大化によってcloneをすると2〜3分もかかっている状態でした。

公式のステップはPR時にdepthの設定が解除される作りだったため、独自にステップを作るかこの公式ステップにPRを出すかを検討しましたが、4.0.27バージョン頃から対応されるようになったため、depthを指定するようにしました。

これはgit cloneステップの「Checkout options」にある「Limit fetching to the specified number of commits」で指定できます。 これにより2〜3分かかっていたのが20秒前後にまで削減されました。

2つ目はfastlaneステップです。

fastlaneを使ってビルドやテストをするためのセットアップをおこなうlaneを用意していて、それをBitriseでよんでいました。 Pluginfileに指定してある独自のプラグインも含めgem install時のdependencyが多くインストールに多少時間がかかっている状態でした。

これらはキャッシュに乗せておくことで、その時間は緩和されますがfastlaneはバージョンアップ頻度も多く、ある程度バージョンアップする必要もあります。

しかし、このセットアップ処理自体は別にfastlaneを使わないといけないわけではありませんでした。 そこで、今回はfastlaneを使わない形に変更しました。

最後はCacheステップ(Pull、Push)です。

ビルドを早くするためになんでもCacheしたいという気持ちは出がちですが、Cacheするものが増えてくると、どうしてもCacheのPullとPushに時間がかかってしまいます。

そこで、必要なもののみキャッシュをするようにしました。

また、プロジェクトで利用しているGemfileに記載されていたgemについて整理整頓をおこない使っていないものは全て削除しました。

ビルドマシンのスペックの変更

上述したように色々と実行時間を短縮させるための行為をおこないました。

しかし、最後はある意味「金の弾丸」です。

BitriseはGen2を今年(2021年)から提供しはじめました。 Gen2についての情報については次のブログを参考にしてください。

BitriseがGen2を提供したことにより、利用するビルドマシンのスペックがさらによくなりました。 現在、利用しているGen2のビルドマシン(Elite XL)と今まで利用していたGen1のEliteを比べると次のとおりです。

  • Gen1 Elite:4vCPU@3.5GHz、8GB RAM
  • Gen2 Elite XL:12vCPU@3.2GHz、54GB RAM

このスペックの変更により、アプリのビルドは40%程度実行時間が削減されました。 また、テストにおいてはビルドほどではありませんがある程度の実行時間が削減されました。

テストの実行時間の短縮結果

これらの対応の結果、どの程度実行時間が短縮したかについて説明します。

直近4週間の結果(Bitrise Insightsより)は次のとおりです。 これは全てのテスト実行(PR時、PUSH時)での結果になります。

f:id:swet-blog:20211104113308p:plain

以前の結果(41分22秒)と比べて30%ほどになっています。 当初予定していた時間は10分程度ですが、一応達成できたと言えます。

実行時間を削減する案は他にもあるのですが、まずは慣れる場を用意することを優先するためこの時間で一旦はOKとしました。

(3)情報の可視化

Bitriseでいろいろと実行するようになったこともあり、Bitriseの実行履歴から情報を追えます。 しかし、Bitriseの実行履歴を見て情報を追うのはなかなか面倒です。

情報をあとからおいやすくすることで、問題が起きたときに分かりやすくしました。 そこで次のような情報の可視化をおこないました。

  • GitHub Commit Statusの利用による可視化
  • ビルド結果の可視化

GitHub Commit Statusの活用

デフォルトのBitriseの機能ではCommit Statusに対する情報は成功、失敗といった情報ぐらいです。 そこであとからCommit Statusを見たときにどのような状態だったかが分かるようにしたいというのがありました。

そこでワークフローの種類によって、Commit Statusにそれぞれ結果を反映させるようにしました。 f:id:swet-blog:20211104113427p:plain

上記の図にあるように単にワークフローの成否だけでなく、テストケース数やカバレッジ率も設定するようにしています。 これにより、例えばある時点から特定のワークフローが失敗しているとか、そのときに特定のテストケースが失敗しているのか、Compile Errorなのかといったことまで分かるようになっています。

情報の可視化

Bitriseで実行したワークフローの結果を、SWETメンバーが開発し導入しているCIAnalyzerを利用して下図のように可視化をおこなっています。

f:id:swet-blog:20211104113300p:plain

これにより、ビルドの状態(ワークフローやステップ単位での実行時間やビルドの成功率など)を後からでも簡単に確認できるようになりました。 ただし、この手の可視化をおこなったとしても見てくれるとは限りません。

そこで、定期的に関係者に状況をシェアするようにしていました。 例えば「xx月と比べてテストの実行時間がyy%削減されました」とか「最近xxというワークフローの実行時間が少しずつ上昇しているようです」などです。

また、すでに紹介していますがBitriseでもInsightsという機能を提供しています。 この機能でも同様に履歴がわかるようになっていますし、ステップ単位での実行時間も分かります。 最近リリースしたばかりで、どのプランでも30日間の無料アクセスを提供しているとのことなので、1度見てみると良いと思います。

(4)安定運用するための対応

CI/CDを安定的に運用するためには上記の対応も重要なことです。

しかし、実行時間が短くなってPRのたびに動いたとしても、ビルドが失敗した場合に対応をせずに放置してしまうという状態はよくありません。

失敗した際に放置してしまう理由はいくつかあります。

  • 失敗していることに気づいていない
    • 単にSlackの通知チャンネルに通知しているだけでは通知数の多さでスルーされてしまうことはあります
  • 失敗した原因の対応コストが高い
    • 上述したとおり環境起因で落ちる場合は対応コストがどうしてもある程度かかってしまいます

そこで安定運用するためにおこなったことについて次に説明をしていきます。

期間限定の門番

最初に述べた「Step2」の対応が出来ない間(システム的に守れない間)の一定期間はBitriseに対する門番となりました。

門番としておこなったこととしては、おもに次のとおりです。

  • ワークフローが失敗したときの初期調査
  • Bitrise周り全般の相談窓口

失敗したときの初期調査においては、より失敗を見つけやすいようにSlackの通知周りの整理整頓もおこないました。 これについては後述します。

失敗を見つけたらBitriseのログを調査し、原因を追求した上で「対応をおこなう」「対応方法を伝える」のどちらかを行いました。

これを繰り返しおこないつつ、少しずつ自分で対応をするという範囲を狭めていきました。

失敗を知ってもらい対応してもらうための工夫

失敗した場合に、それに気づいてもらう必要があります。 そこで、何かしら問題が起きたときにすぐに気づけるような状態にしておきます。

  • Slack通知の整理整頓
  • 失敗時におけるSlack通知先の変更

Slack通知は多くなりがちです。 通知する箇所の整理や通知内容について整理整頓をおこないました。 表示する内容も変更し、テスト結果や実行時間なども表示するようにしました。

次に失敗時におけるSlackでのメンションです。 当初は失敗した場合にSlackのユーザーグループにメンションをするというのもおこないましたが、PR単位も含めると失敗することはよくあることです。

結果、メンションをしすぎてしまい逆に放置されてしまう原因になってしまいます。

そこで、ワークフローが失敗してはいけないような「デフォルトブランチ」や「リリースブランチ」においてだけ、失敗した時点で開発メンバーがメインで利用しているチャンネルにSlack通知をするようにしました。

これで、問題に気づきやすくなりました。

最終的には問題が起こらないようにする必要があります。 通知をおこなうのではなく、そもそも「失敗したものがマージされないようにする」のが望ましいです。 しかし、この段階でその設定までおこなうのはまだ時期尚早なため、まずは知ってもらうというのを優先しました。

おこなった結果

行いたかった「Step1」と「Step2」は次のとおりです。

  • Step1:PR時点で「ビルド」「テスト」のワークフローを動かす
  • Step2:「ビルド」「テスト」のワークフローが失敗したものはマージできないようにする

これらがどうなったかについて次に説明していきます。

Step1に対する結果

実行時間の短縮により「課題1」は解決しました。 そこで、PRに対してもワークフローを実行するようにしました。

ただし、一度に「ビルド」と「テスト」を設定するのではなく、まずは「ビルド」を動くようにしました。 PR時に「ビルド」を動かすようになってある程度たってから、追加で「テスト」についても動くように設定しました。

Step2の対応と結果

「Step1」が達成した段階時点では、まだ「Step2」はそこまで必要と認識されていない状況でした。 ある程度、今の状態が慣れるのを待ちつつタイミングを待つこととしました。

ブランチに失敗したままのコードがマージされるケースは、この時点でもたまに起きていました。 それでも、まだStep2についてはおこなえていない状態でした。

しかし、実際に検証前にビルドができないといったような「誰もがこの状態は良くない」と思える事件が発生した時点で、今後の予防として「Branch Protection Rule」の導入を提案しました*2

この時点ではPR時に「ビルド」「テスト」が動くようになってからある程度たっている状態でした。 これらのワークフローの実行時間があまりかかってないことを共有し、導入することになりました。

Branch Protection Ruleの導入

一度に導入するとなにかあったときの対応コストは多くなります。 その結果として導入をやめようという流れにもなりかねないため、少しずつ設定を追加するようにしました。

まずは次の設定を入れました。

  • PRの向き先のブランチ:「リリースブランチ」
    • 「Require status checks to pass before merging」をオン
      • ビルドを必須
        • GitHub Commit Statusをそれぞれ独自におこなっているので設定はかんたん

設定を入れた状態にし、なにかしら問題が起きたら即時対応をするようにしました。 例えば、Bitrise起因でビルドが出来ないなどの対応があります。

上記の設定を導入し、ある程度たったのちに次のように拡大をしていきました。

  • PRの向き先のブランチを拡大:「リリースブランチ」「デフォルトブランチ」
    • 「Require status checks to pass before merging」
      • ビルドを必須
      • テストを必須

この設定を入れて、慣れてくるようになるとエンジニアメンバーからも提案が出てくるようになりました。 そこで、次の設定を入れることにしました。

  • Require pull request reviews before merging
    • レビュー必須(approve 1件以上)

これにより「ビルド」「テスト」ができないもの、コードレビューがされていないものは基本マージができなくなり、事故が起こることは減りました。

これで当初予定していた「Step1」と「Step2」のそれぞれの対応が終わりました。

おわりに

求める姿を想定し、そこに進めていくのは必ずしも一直線とは限りません。

その状態自体が本当に自分たちにとって良いのかも分からないこともしばしばありますし、その状態にすることによる大変さがあるかもしれないと思うかもしれません。

今回は、上述したように少しずつ対応を進めていきました。 そして、少しずつメンバーに慣れていってもらうようにしました。

その結果として、当初予定していた「求める姿」になりました。

この状態がベストではなくまだ先の「求める姿」があると思っています。 また、プロジェクトの状態が変わっていけばまた変えるべき箇所も出てくるとは思います。

今後も現在の状況を見ながら、「求める姿」を考えて対応を進めていければと思います。

最後に、上記のような活動に興味を持ってくれた方、一緒に働いてみたいなと思ってくれた方。 現在、SWETはメンバーを募集中ですのでぜひ応募ください。

*1:Bitriseではリポジトリにあるbitrise.ymlを正として利用する設定がありますが、弊社の場合はGitHub:eを利用しており本機能をそのまま利用できません。

*2:Branch Protection Ruleを用いることで、PRのコードがCI/CDサービスで成功していない場合は特定ブランチにマージさせないといった設定できます。

8年ぶりの新卒としてSWETにjoinした話

はじめまして、2021年に新卒としてSWETに加わったIKです。 今回はSWETに入って感じたこと、思ったことなどを新卒目線で書いていきたいと思います。

IKってどんな新卒?

学生時代にVim scriptばかり書いていた新卒です。 主に、ソフトウェアのメンテナンスに興味をもち、OSSにパッチを送る活動をしていました。

パッチを送った主なリポジトリ

また、VimConf2019に登壇し、以下の発表をしました。

しかし、一般的なWebシステムやアプリ開発には疎い、そんな状態で入社しました。

どうしてSWETへの配属を志望したのか

私は学生時代、1からソフトウェアを作ることよりも、ある程度形になったソフトウェアのメンテナンスに参加することの方が多かった傾向にあります。 そこで行ったメンテナンスの内容を以下に示します。

  • 具体的なメンテナンスの内容
    • バグの修正
    • feature requestに応える
    • パフォーマンスの改善
    • Pull Requestのレビュー
    • 既存のドキュメントの改善

ソフトウェアのメンテナンスに参加することで、メンテナンスのフェーズは1からソフトウェアを作成するフェーズよりも遥かに長いこと、メンテナンスを継続することはとても大変であることを思い知りました。 その経験から、ソフトウェアのメンテナンスの負担を減らすこと、開発時に発生する手戻りを減らすことはソフトウェア開発が行われる限り常に要求されることだと考え、それらに興味を持つようになります。 その私の考えがSWETのミッションとマッチしていると考えたため、私はSWETへの参加を志望しました。

SWETのミッション

ソフトウェアテストを起点とし、DeNAのサービス全般の品質向上とエンジニアの開発生産性向上に対して貢献していくことをミッションとする

SWETに配属されてどんな仕事をしているのか

Unityプロジェクトに導入するための静的解析器の開発をしています。 C#向けの既存の静的解析器はいくつかOSSとして公開されていますが、以下の理由により自作する必要があります。

  • 社内独自フレームワークに起因する問題を検査できない
  • 既存の静的解析器のルールでは、検出したい問題を見逃してしまうことがある

では、自作の静的解析器を作成するために行った/行っているフローを以下に示します。

  1. 診断の対象を決める
    • ECMA334やMicrosoft社のドキュメントを参考に、C#の構文を調べながら、診断の対象になるものを決めて、洗い出す(ここが一番大変)
  2. 仕様をデシジョンテーブルにまとめ、テストケースを洗い出す
  3. 作成したデシジョンテーブルをもとにテストコードを1件ずつ追加
  4. 静的解析器を実装し、テストを通す
  5. 3に戻る

静的解析器を開発プロジェクトで使ってもらうためには、適用する開発プロジェクトの人の信頼、すなわち静的解析器に対して好印象を持って貰う必要があります。 そのため、正確に静的解析ができているかを検証し、証明するためにテストを書くことは重要です。

要約すると、テストのノウハウを学びつつ、静的解析器を実装する仕事をしています。

SWETの人たちが、新卒目線でどう見えるか

ミスを未然に防ぐことや手戻りを防ぐことに意識をおき、そういった可能性がある箇所に対しての改善を妥協しない人が多い印象です。 特にメンターから教わったことの中から印象に残ったものを以下に示します。

テストメソッドの命名をしっかり意識する

SWETにjoinする前の自分は、テストメソッドの命名が雑でした。極端な例を出すと、goodcaseとかbadcaseなどと言った命名をしていました。

FizzBuzzを使った例

[TestMethod]
public void GoodCase()
{
    var actual = FizzBuzz(3);
    Assert.Equal("Fizz", actual);
}

このように、テストメソッドの命名が雑だと以下のようなデメリットがあります。

  • テストが失敗したとき、失敗したメソッド名やメッセージを見ただけでは失敗箇所の特定が行えず、深くコードを追う必要がある

そこで、テスト対象メソッド名_テスト対象に与える条件_期待する結果のような命名を意識しました。

[TestMethod]
public void FizzBuzz_入力値が3_Fizzを返す()
{
    var actual = FizzBuzz(3);
    Assert.Equal("Fizz", actual);
}

これにより、上記のデメリットを解消できます。

テストの無いリファクタリングはリファクタリングとはいわない

この言葉は自分にとても刺さりました。 リファクタリングとは、プログラムの外部から見た振る舞いを変えずに、内部構造を変えることだとした上で話しを進めます。

テストを書き、実行することで、リファクタリングを試みた際に発生したリグレッションを検知できます。 しかし、当時の自分は自動テストを実施せずに目視のみの確認でリファクタリングを完了としていました。 そのため、抜け漏れが発生し、リグレッションを起こしてしまったことが多々ありました。

リグレッションを放置していると、ボディーブローのように後々効いて、気づいたときには巨大なバグになっていることも少なくありません。 こういった状態を作らない、妥協しない姿勢をSWETの方から見習い、吸収していきたいと思いました。

新卒でSWETに入って良かったと感じたこと

テストケースの考え方や、テスト自動化の導入方法は、どんなソフトウェア開発にも必要です。 また、テストを導入するためには、テストを導入しやすい環境を作らなければなりません。

これらのテクニックは、これからも陳腐化することはないスキルだと自分は確信しています。 そういった潰しの効くスキルを基盤として身につけながら業務に携わることができてとても良かったと感じています。

また、こういったテスト技術を基盤として様々なソフトウェアに関わることで、様々なソフトウェアの知識を吸収ができるのも良さだと考えています。

終わりに

新卒でSWETに入って、うまく仕事を遂行できるか不安ではありましたが、毎日新しい発見がありとても学びになっています。 また自分の施策が、ソフトウェア開発を促進するための施策に関われていることを実感でき、今の仕事にとてもやりがいを感じています。 新卒でSWETに入ったことを自分は正解だと感じており、今後もSWETで学んだことを様々なソフトウェア開発の促進のために生かしていきたいと考えています。

SWETは中途しか採用していないイメージを持たれている方が多いのかも知れませんが、新卒で入ることも可能です。SWETは学びが多い部署だと思っているので、新卒でSWETに入る選択もありだと思います。 もしSWETに興味を持った方がいれば、是非お話しましょう!

大きなGitリポジトリをクローンするときの工夫を図解します

こんにちは、SWETでCI/CDチームの前田( @mad_p )です。 SWETではCI/CDチームの一員として、Jenkins運用のサポートや、CI/CD回りのノウハウ蓄積・研究をしています。

はじめに

Gitリポジトリをクローンすると、ローカルフォルダにはそのリポジトリの全体がダウンロードされ .git というフォルダに格納されます。ブランチをチェックアウトすると、ブランチ内のファイルがワーキングツリーとして展開されます。この様子を図にするとこのようになります。

クローン+チェックアウト

この .git とワーキングツリーの使うディスク容量を節約しようというのが今回のお話です。特にJenkinsにおいて、大きめのGitリポジトリをクローンしてくる場合に課題があり、いろいろ工夫してみたので、その結果を紹介します。同じCI/CDチームの加瀬による記事「大規模リポジトリで高速にgit cloneするテクニック」と内容的に重なる部分もあるので、そちらの記事も参考にしてください。

今回、Jenkinsでの設定方法を多めに解説していますが、紹介する工夫はJenkins以外でも使えるものです。図を見て仕組みを理解しておくだけでも、後で役に立つと思います。

Gitリポジトリは大きくなっていく

アプリコードやアセットのリポジトリは、開発が進むにしたがって大きくなっていきます。これはゲーム開発では特に顕著です。とあるタイトルのアセットリポジトリは、そのままクローンすると .git が17GiB、チェックアウトした部分も含めると45GiB程度になっています。これは日々大きくなっていきます。

大きくなる原因

リポジトリ内のオブジェクトを簡略化して図にすると以下のようになります。丸がコミット、四角がファイルに対応します。

リポジトリ内のオブジェクト

リポジトリが時間とともに大きくなっていくのは、以下のような要因によるものです。それぞれに対して、何か対策があるか見てみましょう。

  • コミットされるファイル数が多くなる
    • これは必要だからコミットされているので、仕方がありません
  • コミットされるファイルが大きい
    • これの対策としては、大きいファイル(画像、音声データなど)をGit LFSに置くことで、リポジトリ内のオブジェクトとしては保存しない、という方法があります。LFSについては後でもう少し詳しく見てみます
  • 歴史が長くなる
    • これも長く開発が続いていくと増えるものなので、仕方がありません

コミットされるファイル数が多くなる方向、歴史が長くなる方向の2つの軸を意識しておくと、この後紹介する工夫の理解がしやすくなると思います。

大きくなると困ること

Gitリポジトリが大きくなると様々な問題が発生します。

  • クローンに時間がかかり、待ち時間やJenkinsジョブ実行時間が長くなる
  • クローンしたフォルダのディスク使用量が多くなり、ディスク枯渇が発生しやすくなる

これに加え、Jenkinsエージェント特有の事情として以下があり、巨大なリポジトリのクローンによる困り事は増幅されることになります。

  • Jenkinsではジョブごとにワークスペースが分離される。同じリポジトリを使うジョブが複数個あると、ひとつのリポジトリでも複数回別のフォルダにクローンされる。その回数分ディスク容量を必要とする
  • ワークスペースは、特に明示しない限り、次回の実行に備えて保存される。次回実行時にリポジトリのフェッチの負荷が低くなるというメリットとなる(クローン済の .git を使って差分だけを取ってくればよいため)。一方で、取っておく分のディスクを消費する

Gitリポジトリからローカルフォルダへのダウンロード量や通信量を減らすことで、これらの困り事を軽減できます。クローン時、チェックアウト時、LFSの順で、どのような工夫ができるかの方法を紹介します。また、その結果どのように節約できるのかを図にして見ていきましょう。

GitHub公式ブログ

ここで取り挙げる節約方法はGitHubブログの以下の記事でも解説されています。図が多くわかりやすいので、参照してみてください。本記事中の図も、この公式ブログの図を真似しています(わかりやすさのため、本記事ではツリーオブジェクトなど一部省略しています)。

クローン時のディスク・ダウンロード節約方法

大きめのリポジトリをクローンする場合のコツについて説明します。

git clone では通常、前述したオブジェクトの全体をダウンロードしてきて、 .git/objects 配下に置きます。以下では、この全部を取得するのではなく、一部を取得するためのテクニックを紹介します。これによって、クローン後のディスク使用量だけでなく、クローン時のネットワーク転送量(すなわち時間)も節約できます。

シャロークローン(shallow clone)

最新版だけを取ってきて、過去の歴史を取ってこない方法です。取得したい歴史の長さ(深さ)をdepthというパラメータで指定できます。通常は1でよいでしょう。シャロークローンの動作を図にすると以下のようになります。

シャロークローン

歴史の一番新しいほうからdepth分だけをサーバーから取得し、 .git 内に置きます。取得されない部分を図中では点線で表現しています。最新情報だけ取ってくるので、取得するオブジェクト数を節約できます。これは大きくなるリポジトリを歴史の長さ方向に限定して取得することに相当します。

シャロークローンのやりかた

  • CLIによる方法
    • git clone 時に --depth オプションで取得したい歴史の深さを指定する
      • git clone --depth=1 git@github.com:org/repo.git .
  • Jenkinsfileによる方法
    • checkout ステップに CloneOption を渡して shallowtruedepth を指定
checkout([$class: 'GitSCM',
    extensions: [[$class: 'CloneOption',
        shallow: true,  // ← shallow cloneを指定
        depth: 1,  // ← depthを指定
        timeout: 60]],
    branches: [[name: "feature/mybranch"]],
    gitTool: 'Default',
    userRemoteConfigs: [[url: 'git@github.com:org/repo.git']]])
  • JenkinsのFreestyleジョブでの設定方法
    • 「ソースコード管理」の「追加処理」で「Advanced clone behaviors」を追加し、shallow cloneをチェック、shallow clone depthを入力

シャロークローンの欠点

シャロークローンには以下のような欠点があります。歴史に対する操作を使うことがわかっている場合は利用しないほうがいいかもしれません。

  • シャロークローンで取ってきていると、歴史をあやつる作業はできなくなる
    • git log で過去のコミットを見る
    • 過去のコミットをチェックアウトする
    • git diff で差分を見る
    • マージする
  • 公式ブログの説明によると、シャロークローン後のフェッチで、場合によっては結局過去の歴史を全部取ってしまうような場合もあるとのこと
    • 一度クローンした場所で、追加でフェッチをするような用途にはシャロークローンは向かない。つまりJenkins向きではない。一方でCircleCIなどクローンした結果を一度しか使わない環境では有効

なお、いったんはシャロークローンしたものの明示的に全部を取り直したいという場合は、 git fetch--unshallow オプションをつけます。

  • git fetch --unshallow

パーシャルクローン(partial clone)

コミット内の各ファイルは、そのメタ情報(ツリーオブジェクト)とファイル実体(ブロブオブジェクト)から成っています(上述のGitHubブログを参照)。パーシャルクローンは、ブロブやツリーオブジェクトを必要な部分のみ取得します。ローカルには、必要に応じてオブジェクトを取得するという情報(下の図では中空の四角で表現)が記録され、checkoutやdiffなど、実際に必要になった時点でオンデマンドにブロブオブジェクトがダウンロードされます。

パーシャルクローン

パーシャルクローンは操作に必要な要素のみを取得するので、歴史の長さ×ファイル数を2次元的な広がりと見て、必要な部分だけを取得することに相当します。この図はクローン後、いくつか操作して必要なものが取得された状況を表現しています。

パーシャルクローンのやりかた

ここではブロブを必要な部分のみ取得し、ツリーはすべて取る方法(ブロブレスクローン)を紹介します。

  • CLIによる方法
    • git clone 時に、 --filter オプションでブロブを取得しないことを指定する
      • git clone --filter=blob:none git@github.com:org/repo .
  • Jenkinsfileによる方法
    • checkout ステップには機能がない。 sh ステップでCLIのgitを利用する
  • Freestyleジョブでの設定方法
    • 同様にシェルスクリプトからgitを利用

パーシャルクローンの欠点

パーシャルクローンには以下のような欠点があります。

  • git diff など、ファイルの内容を必要とする操作をすると、オンデマンドでダウンロードされる
    • その時点でネットワーク接続が必要であることと、ダウンロード時間がかかることに注意

リファレンスリポジトリ(reference repository)の活用

Jenkinsでは複数のジョブから同一のリポジトリ(アプリソースやアセットなど)を参照することが多くあります。その場合、ジョブごとに別のフォルダに同じリポジトリをクローンしてくることになります。2回クローンした状態を図にするとこうなります。

複数回のフォルダにクローン

このとき、 .git はフォルダAとBの下にそれぞれ作成され、歴史を記述したオブジェクトはそれぞれの下に保存されます。ブランチを切ったりコミットしたり、特定のブランチだけフェッチすると、内容は完全に同一とはならないのですが、サーバーからコピーした分は同一のものがディスク上で別の場所に複数回保管されていることになります。特定のリポジトリに関するジョブが十数個にもなると、その数だけ複製を持つことになります。

Gitにはすでにクローン済の .git/objects を参照する機能があります。これを使うと、次の図のようになります。

リファレンスクローン

B/.git/objects の下に、「 A/.git/objects を見ろ」というファイルが記録され、そちらも参照してくれるようになります。AになくてBに必要なオブジェクトはサーバーからダウンロードされ、Bの .git/objects の下に保存されます(このため、Aの情報が多少古くても問題ありません)。これによって、Bの初回クローンは圧倒的に速くなり、ディスクも大きく節約できます。このとき、フォルダBから見てフォルダAを「リファレンス」と呼びます。リファレンスを利用したクローンを、この記事では「リファレンスクローン」と呼ぶことにしましょう。

リファレンスクローンのやりかた

フォルダBにクローンする時点で、すでにフォルダAにクローン済である場合、Aのパスをリファレンスとして指定できます。

  • CLIによる方法
    • git clone 時に、 --reference または --reference-if-able オプションでリファレンスとして使うクローン済みのフォルダを指定する
    • git clone --reference-if-able=/path/to/A git@github.com:org/repo ./B
      • --reference で指定するとリファレンスが存在しなければエラーとなる
      • --reference-if-able では存在しなければ無視される(警告のみ)
    • サブモジュールの取得にリファレンスを使いたい場合は、サブモジュールごとに指定する
      • git submodule update --init --reference=/path/to/A -- submodule/path
  • Jenkinsfileによる方法
    • CloneOptionreference を指定できる。リファレンスが存在しないと無視される(エラーメッセージが出るが処理は続行される)
checkout([$class: 'GitSCM',
    extensions: [[$class: 'CloneOption',
        reference: "/path/to/A", // ← リファレンスのパスを指定
        timeout: 60]],
    branches: [[name: "feature/mybranch"]],
    gitTool: 'Default',
    userRemoteConfigs: [[url: 'git@github.com:org/repo.git']]])
  • Freestyleジョブでの設定方法
    • 「Advanced clone behaviors」→「リファレンスリポジトリのパス」に設定する。存在しないと無視される

通常どおりにクローンしたフォルダに対して、後からリファレンスを加えることもできます。

  • .git/objects/info/alternates というファイルを作成し、リファレンスの .git/objects のフルパスを書き込む
    • サブモジュールの場合は .git/modules/<submodule_path>/objects/info/alternates
  • alternates 作成後、以下のコマンドを実行すると、リファレンスに存在するオブジェクト(重複分)を削除できる
    • git repack -a -d -l

リファレンスクローンの注意点

AをリファレンスとしてBをクローンした後に、A側でオブジェクトが削除されると、Bから参照しようとしたときにオブジェクトが見つからずにエラーとなります。 git pull などの操作をきっかけとして、A側で git gc が自動実行されることがあり、これが発生します。フォルダAをまるごと削除した場合も同様です。 その場合、以下のようなエラーにぶつかるでしょう。

  • fatal: bad object HEAD
  • fatal: bad object <commit_hash>
  • fatal: unable to read <blob_hash>

一度こうなってしまうと修復はなかなか大変なため、リファレンスクローンは事情をよく知って使う必要があります。 git help clone--reference の項目を見ると、 --shared と同様の注意が必要と書いてあり、その注意書きはこうです。要約すると「A側でブランチが削除されるとB側で必要なものが消され、Bが壊れる場合がある。何をやっているかよく理解して使いなさい」ということです。

NOTE: this is a possibly dangerous operation; do not use it unless you understand what it does. If you clone your repository using this option and then delete branches (or use any other Git command that makes any existing commit unreferenced) in the source repository, some objects may become unreferenced (or dangling). These objects may be removed by normal Git operations (such as git commit) which automatically call git maintenance run --auto. (See git-maintenance(1).) If these objects are removed and were referenced by the cloned repository, then the cloned repository will become corrupt.

ローカルミラー活用法

これらの問題を回避するために、リファレンスで指す先はリファレンスで使われることだけに特化したミラーフォルダとして用意するのがよいと思います。組織的に運用しないと安定した稼動は難しいかもしれません。以下に、自分達のプラクティスを紹介しますので、参考にしてみてください。

  • ローカルマシン(Jenkinsエージェントなどごと)にリファレンス用途のクローン(以下ミラー)を作成する
export MIRROR_DIR=/path/to/mirror/dir
mkdir -p $MIRROR_DIR
git clone --no-checkout git@github.com:org/repo $MIRROR_DIR/repo
(cd $MIRROR_DIR/repo; git config gc.pruneExpire never)
  • ミラーでは git config gc.pruneExpire never しておくことで、自動gcによるオブジェクトの削除が起こらないように設定する
  • 環境変数 MIRROR_DIR でミラー群の位置を指すよう、 .bash_profile や、Jenkinsのノード管理で定義しておく
  • ミラーはベア(bare)リポジトリとしてクローンするか、 --no-checkout で作るとワーキングツリー分のディスクを節約できる
  • 定期的にフェッチするジョブをしかけておく
    • .git/gc.log に「too many unreachable loose objects」が出力されるが、これは無視してよい。リファレンスする側のフォルダで使っているオブジェクトがloose objectsとして検出されることが多い
    • ミラーにtmpオブジェクトが残ってしまったような場合など、どうしてもgcやpruneコマンドを使いたい場合は、以下のオプションをつける
      • git prune --expire=never; git gc --no-prune
  • クローン時は --reference-if-able=$MIRROR_DIR/repo オプションをつける

リファレンスクローンを利用する場合、サーバーから取得する歴史はリファレンスにないオブジェクトのみになります。このため、シャロークローンやパーシャルクローンを利用しなくても十分な節約になります。プロジェクトで利用する全部のリポジトリについてミラーを作る必要はなく、複数の場所に何度もクローンされやすいリポジトリを選んで設定するだけで、大きな効果を得ることができるでしょう。

チェックアウト時のディスク節約方法

ここまで、クローンのときの工夫を見てきました。続いてチェックアウトのときの工夫を紹介します。

ファイル数が多くなったリポジトリは、チェックアウトした結果のワーキングツリーもかなりディスクを使うことがあります。通常のチェックアウトの様子を図に示します。チェックアウト時にはHEADから参照されるすべてのファイルのコピーがワーキングツリーとしてコピーされます。

チェックアウト

スパースチェックアウト(sparse checkout)

必要とするファイルがリポジトリ内の一部だけである場合、例えば特定のシェルスクリプト数個だけの場合、リポジトリ全体ではなく、一部のフォルダやファイルだけをチェックアウトすることで、ワーキングツリーを小さくできます。これをスパースチェックアウトと呼びます。

スパースチェックアウト

スパースチェックアウトはファイル数方向を限定してワーキングツリーを小さくすることに相当します。歴史オブジェクトは全部ローカルに持ってきているので、歴史をあやつる作業もできます。逆に言えば、歴史の取得や .git の容量は節約していません。

スパースチェックアウトについては、こちらのブログ記事が参考になります。

スパースチェックアウトのやりかた

  • CLIによる方法 (上記ブログ記事も参照してください)
    • git clone 時に --sparse オプションでスパースチェックアウトであることを、 --no-checkout オプションで初回のチェックアウトしないことを指定する
    • クローン後 git sparse-checkout add でチェックアウトしたいフォルダを指定する
    • 必要なフォルダやファイルを指定するにはパターンも使える。パターンの書き方の詳細は git help sparse-checkout を参照
git clone --sparse --no-checkout git@github.com:org/repo .
cd repo
git sparse-checkout add want_folder1 want_folder2
git checkout
  • Jenkinsfileによる方法
checkout([$class: 'GitSCM',
     extensions: [[$class: 'SparseCheckoutPaths',
         sparseCheckoutPaths: // checkoutしたいフォルダ
             [[path: "want_folder1"],
              [path: "want_folder2"]]
     ]],
     branches: [[name: "feature/mybranch"]],
     gitTool: 'Default',
     userRemoteConfigs: [[url: 'git@github.com:org/repo.git']]])
  • Freestyleジョブでの設定方法
    • ソースコード管理の「追加の処理」から「Sparse checkout paths」に設定

前述のパーシャルクローンと組み合わせて使うと、さらにディスクと通信時間を節約できます。

パーシャルクローン+スパースチェックアウト

このように組合せて使うと、ファイル数方向、歴史の長さ方向の両軸で限定したことに相当します。

LFS (Large File Storage) の活用

Gitリポジトリに大きいバイナリファイルをコミットしていくと、その歴史で .git は大きくなってしまいます。また、GitHubにはひとつのファイルの大きさは100MiBまでという制限があるため、これを超える大きさのファイルはそもそもコミットできません。ゲームのアセットなど、画像や音声ファイルを多く扱うリポジトリでは、ここで説明するLFSを利用するのがよいでしょう。

LFSはファイルの実体を別のストレージに置き、その参照(SHA256ハッシュ)のみをコミットします。図にすると以下のようになります。 .git 内には参照しか入らないため、 git clone の段階ではファイルの実体はダウンロードされません。 git lfs pull により、必要なオブジェクトの実体がダウンロードされる仕組みです(通常はクローン直後のチェックアウトで自動的に行われます)。

Git LFS

Git LFSの設定は、リポジトリ作成直後から行うのがおすすめです。先に大きなオブジェクトをコミットして、後からLFS化しても、コミットしてしまった分は大きなオブジェクトが歴史に残ってしまうからです。以下の手順でLFSの設定ができます。

  • マシンにgit-lfsをインストールする
    • brew install git-lfs , apt install git-lfs など
  • アカウントごとにLFSを使うことを設定する
    • git lfs install
      • チェックアウト時に自動的にLFSからダウンロードされるようになります
  • LFSを導入したいリポジトリで、特定の拡張子のファイルをLFSに格納するよう設定する
    • git lfs track "*.psd" "*.ogg"
  • 通常どおりコミットしてプッシュすると、設定した拡張子のファイルは、実体がLFSに送られる
  • クローンした側ではチェックアウト時に自動的にLFSファイルが取得される。明示的に取得する場合は以下コマンドを実行する
    • git lfs pull (現在チェックアウトしているブランチのオブジェクトを取得し、ワーキングツリーに置く)

LFSの設定・動作ついては、以下の公式ドキュメントや記事が参考になります。

すでに運用中のリポジトリを後からLFS化したい場合、 git lfs migrate というコマンドが使えます。LFSのマイグレーションは手順が多いため、ここでは説明しません。以下のチュートリアルが参考になるでしょう。

LFSのディスク節約方法

git lfs pull の動作は以下の2つに分けられます。

  • git lfs fetch
    • 現在のブランチで利用されているLFSオブジェクトをダウンロードし、ローカルストレージに置く
    • ローカルストレージは通常、クローンしたフォルダの .git/lfs (サブモジュールでは .git/modules/<submodule_path>/lfs
  • git lfs checkout
    • ローカルストレージからワーキングツリーにオブジェクトをコピーする

git lfs pullgit lfs fetch の後 git lfs checkout を行うことに相当します。

これを図にするとこうなります。 git lfs checkout でファイルがコピーされるため、LFSで管理されているファイルは、ローカルストレージ内とワーキングツリー内、通常2個ずつディスクに置かれることになります。

クローン後のLFSチェックアウト

ワーキングツリーのディスクを節約する

ワーキングツリー内のファイルは書き換えてコミットする使い方もあるので、 git lfs checkout では(ハードリンクなどではなく)ファイルをコピーすることが必要です。CIなど、read-onlyの作業しかしないことがわかっている場合には、コピー分のディスクを節約したくなります。

macOSではAPFSというファイルシステムの機能で、ファイルを複製するときに同じディスク領域を使い、ディスクを節約できます。書き込みが発生した時点でcopy-on-writeする動作になります(cpコマンドの -c オプション)。 git lfs dedup というコマンドを実行すると、LFSのローカルストレージとワーキングツリーを比較し、ファイル内容が同一のとき cp -c を使ってひとつにまとめるということをしてくれます。

ローカルストレージを節約する

ローカルストレージには過去のチェックアウトで使ったデータがたまっていくので、時々プルーン(おそうじ)したほうがよいかもしれません。とはいえ、よほど大きくなっていない限り、明示的にプルーンする必要はありません。

  • git lfs prune

ローカルストレージを共有する

同一のリポジトリをJenkinsの複数ジョブなどで複数回クローンすると、ローカルストレージはそれぞれの .git 下に作られます。つまり、クローンした回数分のオブジェクトがディスク上に保存されます。

複数フォルダとLFSローカルストレージ

ローカルストレージを変更し、フォルダAとBの両方で共有するよう設定すると、この重複分を減らすことができます。

  • git config --local lfs.storage /path/to/shared/storage
    • LFSのローカルストレージを特定のパスに設定します

同じリポジトリに対するクローンの各フォルダで、同一のパスを指定してこのコマンドを実行するとよいでしょう。

共有ローカルストレージ

この設定を使うと、大きくなりがちなLFSのローカルストレージを共有して、全体の容量を減らすことができます。なお、ストレージの共有を使っている場合は git lfs prune しないほうがよいです。図中のフォルダAで git lfs prune した場合、フォルダBからしか参照されていないオブジェクトは消されてしまいます(次回チェックアウト時にダウンロードし直されるので、エラーにはなりません)。

この設定をグローバルに行うこともできます。

  • git config --global lfs.storage /path/to/shared/storage

--global に設定すると $HOME/.gitconfig に設定が書き込まれ、そのアカウントでクローンするリポジトリすべてのLFSローカルストレージが同一の場所になります。これには以下のように利点・欠点がありますので、それに注意して利用するとよいでしょう。

  • 利点
    • クローンするたびにいちいちローカルストレージの設定をしなくてよい
    • サブモジュールでもLFSを使っている場合、すべてのサブモジュールで設定する必要がない
  • 欠点
    • 異なる元リポジトリに由来するオブジェクトが、ローカルストレージ内で混在する
      • 機密レベルの異なるリポジトリを扱う場合には、混在するとよくないかもしれない
      • なお、SHA256ハッシュを使っているので、別のオブジェクトが同名になってしまう(ハッシュの衝突)心配はない

まとめ

.git は気がつくと大きくなってしまうものです。うまく工夫することで、ディスク容量や転送時間を減らすことができます。歴史の長さとファイル数の増加どちらがより大きな要因であるか、また、クローンしたフォルダをどう使うかなどを考慮して、シャロークローンやパーシャルクローンなど適切な方法を選択しましょう。

Jenkinsエージェントのような環境では、同一のリポジトリを複数のフォルダにクローンすることが多く、 .git はその数ぶんだけ作られてしまいます。リファレンスクローンで共有したり、LFSのローカルストレージを共有すると、この「複数回」が原因となる重複を減らすことができるでしょう。

これらの工夫を使って、冒頭で例に出したタイトルでは、Jenkinsエージェントあたり300GiB程度のディスク容量を節約できました。いくつかのリポジトリが何度も何度もクローンされていたので、リファレンスクローンの効果が大きく250GiB程度の節約、LFSローカルストレージの共有が50GiB程度の節約になりました。スパースチェックアウトはまだ十分活用できていませんが、改善の余地があるのだと前向きに考えています。

タクシーアプリ「GO」Android版へ自動テストを導入するまでの道のり

こんにちは、Androidチームの田熊(fgfgtkm)と外山(sumio)です。SWETのAndroidチームでは、Androidのプロダクトに対して自動テストのサポートをしています。

この度株式会社Mobility Technologiesが提供するタクシーアプリ「GO」のAndroid版に対する、おおよそ2年間に渡る取り組みが終了しました。

本記事では、この取り組みで行ってきた次の2点を紹介したいと思います。

  • 「GO」のAndorid版に対してどのような自動テストを導入したか
  • 開発チームに自動テストを定着させるまでにやったこと

タクシーアプリ「GO」に対しての自動テスト導入

SWETのAndroidチームは「社内のAndroidエンジニアが自動テストを書くことを習慣化している」ことをゴールの1つとして、自動テストを普及させるための取り組みをしています。 その中でAndroidのテストナレッジの社内展開を目的としたハンズオンを実施してきました。

しかし、社内の多くのチームはテストが書きにくい設計になっているという課題を持っており、既存機能にテストを書くためには設計を改善する必要がありました。(参考:テストの社内普及のための取り組みとして、Androidテストハンズオンを実施しました

今回紹介する「GO」(旧サービス名:MOV)も例外ではありません。 ハンズオンではカバーしきれない現場の課題を解決して自動テスト導入を促進したい、そして「GO」をそのモデルケースにしたい、と考えたのが本取り組みのきっかけです。

それから約2年間、開発チームと協力しながら次の施策を実施しました。

  • コード改善 + ユニットテストの導入
  • スクリーンショットテスト

それぞれどのようなことをやってきたか、具体的にお話したいと思います。

コード改善 + ユニットテストの導入

当時の「GO」は歴史的経緯から一貫したアーキテクチャが定められておらず、多くの処理がFragmentやActivityに記述されていました。メイン機能となる地図を表示している画面(以降、地図Fragmentと呼びます)も同様で、配車に関連する複雑なロジックが地図Fragmentに実装されていました。

最初に行ったことは、この地図Fragmentに実装されたロジックを別クラスに切り出した上でユニットテストを書けるようにし、MVVMの構成にすることでした。以降、地図Fragmentや関連する画面クラスの改善を地道に進めていきました。

画面から切り出したほうが良い処理をピックアップし、画面からの分離⇔ユニットテストを実装する。そのサイクルを繰り返すことで、少しずつユニットテストが書けるコードを増やしていきました。 

こうしてテストコードが追加された結果、そのテストコードを参考に開発メンバー自身が自発的にユニットテストを実装してくれるようになりました。(具体的な数値については「自動テストを書くことを習慣化できたか?」のセクションで紹介します)

また、もともと開発チームでもMVVMアーキテクチャにしていきたいという思いがあったため、開発チーム主導でMVVM化がどんどん進んでいき、今ではほとんど全ての機能がMVVMアーキテクチャで実装されています。

改善した内容の一部はMOV Android版に対する「コード改善+テスト導入」の取り組みの紹介でもまとめていますので、是非ご参照ください。

スクリーンショットテスト

ユニットテスト導入をある程度進めたタイミングで、一度リリース前検証で出た不具合チケットを整理しました。その結果、画面まで結合させないと発見が難しい不具合、ViewModel以下のレイヤのユニットテストでは発見の難しい不具合が高い割合で起票されていることがわかりました。

Androidアプリ開発においてこのような不具合はつきもので、問題を早期発見するためにUIテストを導入できないか考えました。そしてUIテスト実装のアイディアについて開発チームと議論を重ねた結果、スクリーンショットを活用したテスト(以降、スクリーンショットテストと呼びます)を導入することで合意しました。

SWETではスクリーンショットテストを導入するために、主に次の2つのことを実施しました。

  • スクリーンショットテストを書きやすくするための基盤を整備する
  • スクリーンショット一覧レポートの確認や画像差分の比較ができるようにCI/CDを整備

スクリーンショットテストを書きやすくするための基盤

スクリーンショットテストは画面が任意の状態になるようにデータをセットし、さらに非同期処理やレンダリング完了まで待った上でスクリーンショットを撮る必要があります。ユニットテストと比較するとテストコード側でセットアップする必要のあることが多くなり、ボイラープレートが増えてしまいます。

我々はできるだけスクリーンショットテストを書く大変さを軽減したいと考え、書きやすくするための基盤の実装に着手しました。そして開発チームと一緒に様々なパターンのスクリーンショットテストを実装しながら、機能をブラッシュアップしていきました。

この基盤は次のことを実現しています。

  • Junit5のExtensionとして提供され、テストに適用するだけで「GO」で安定してスクリーンショットテストを取得するために必要なセットアップが完了する(非同期処理の待ち合わせ設定など)
  • Activity・Fragment・DialogFragmentそれぞれの起動を簡潔に書けるようにする手段の提供
  • 画面単位・View単位など様々なスクリーンショット取得方法の提供
  • テストでよく使用されるEspressoのコードを汎用化

基盤の実装の一部はGOGO Screenshot Test for AndroidとしてOSS化しています。是非ご参照ください。

CI/CDの整備

取得したスクリーンショットは一覧化したレポートを作成することで、エンジニア以外のメンバーでもデザインのレビューがしやすくなります。 また、reg-suitといったツールを使えば、Visual Regression Testにも活用できます。 例えば、前回リリース時点のスクリーンショットと現在のバージョンのスクリーンショットを比較すれば、意図せぬUIの変更が含まれていないか検証できます。

Andorid スクリーンショットテスト 3つのプロダクトに導入する中で倒してきた課題より引用)

これらのことを実現するために、次のようなワークフローをBitriseを使って整備しました。このワークフローはマージやタグ追加のタイミングで実行されます。

  • Instrumentation Testを実行し、スクリーンショットを取得する
  • 取得したスクリーンショットの一覧レポートを作成する
  • 任意のバージョン間のスクリーンショットを比較してreg-suitのレポートを作成する

あわせて、不必要な差分が含まれないスクリーンショットを安定して取得できるように基盤の改善をしました。

スクリーンショットテストの導入および、スクリーンショットを安定して取るためにやったことはAndorid スクリーンショットテスト 3つのプロダクトに導入する中で倒してきた課題にもまとめています。是非こちらもご参照ください。

自動テストを書くことを習慣化できたか?

冒頭でSWETのAndroidチームは「社内のAndroidエンジニアが自動テストを書くことを習慣化している」ことをゴールの1つとしていると述べました。 それでは自動テスト導入の取り組みの結果、「GO」の開発チームにおいて自動テストを書くことを習慣化できたのでしょうか。

最近のリリース時点での自動テスト件数とカバレッジの推移は次のようになっています。

まずはLocal Testの推移です。Local Testには主に画面以外のユニットテストが含まれます。

次に、スクリーンショットテストが含まれるInstrumentation Testの推移です。スクリーンショットテストを導入したバージョン5.3.0から集計しています。

ユニットテスト・スクリーンショットテストともに各バージョンで増加しています。また、これらのテストはほとんどSWETメンバーによる実装ではなく、開発メンバー自身が実装したものです。取り組みを始めた当初はユニットテストが十数件ほどでしたが、現在ではコンスタントに自動テストが増えています。

この結果から、「GO」の開発チームにおいては自動テストを書くことを習慣化できたと考えています。

自動テストの習慣化が実現できたのは、開発チームの尽力によるところが大きいです。一方でSWETからも自動テストが定着するような働きかけを行ってきました。

次のセクションでは、自動テストを定着させるためにどのような取り組みを行ってきたかを紹介します。

自動テストがチームに根付くまで

導入したテストが効果を発揮するためには、テストの継続的運用が不可欠です。 そのためには、開発チーム自身がテストの追加・修正といったメンテナンスをし続ける必要があります。

  • テストを導入したものの、導入した担当者が異動を機にメンテナンスされなくなってしまった
  • テストを書く気持ちはあるけれども、ついついプロダクトの実装を優先してしまう

といった話は色々なところで耳にします。 開発チームのメンバーひとりひとりがテストを書けるようになり、更にその状態を継続するのは大変なことです。

幸い、「GO」開発チームでは自動テストを書くことを習慣化できました。 自らテストを書く習慣は「こうすれば身に付く」といった答えのあるものではありませんが、一例として「GO」開発チームにおける習慣化のためにSWETが働きかけてきたことを紹介します。

ユニットテスト

「コード改善 + ユニットテストの導入」で触れたようなテスタビリティ改善の取り組みによって、開発メンバーの書いたテストが少しずつ増えるようになりました。

この段階になってから重要なのがトラブル発生時のサポートです。 特に次のようなトラブルは、一歩間違うと未解決のまま放置されてしまうことになりがちです。

  • モックライブラリMockkで意図通りにモックできず、テストが失敗してしまう
  • CI動かしたときだけ、時々テストが失敗してしまう

失敗したテストが残ったままの状態だと「テストをオールグリーンの状態に保つ」という気持ちが折れてしまい、テストがメンテナンスされず放置されるきっかけになってしまいます。 そうなってしまわないように、Slackに常駐して、テスト関連のトラブルに気付いたときには積極的にサポートするようにしました。

現在では、このようなトラブルも開発メンバー自身で解決し、SWETのサポート無しでテストの追加・修正を継続できています。

スクリーンショットテスト

一方スクリーンショットでは、先に触れたようなスクリーンショットテスト基盤やワークフローの整備だけでは、開発チームが自らテストを追加するまでには至りませんでした。 そこからテストを書く習慣が根付くまでの道のりは試行錯誤の連続でした。

モブプロでテストを書くハードルを下げる

当時は、新たに構築したテスト基盤の使い方とテストの書き方の説明会を実施し、その後のテストの追加は開発チームに任せようという考え方でいました。 ところが、説明会終了後もテストが増えないままだったため、テストを書くハードルをもっと下げる必要があるのではないか、と考えました。

そこで、更にテストを書くハードルを下げるために、とある画面のスクリーンショットを撮るテストをモブ・プログラミング(以降モブプロと表記します)で実装してみることにしました。
モブプロの役割分担は次の通りです。

  • ドライバー(プログラムを書く人):SWET
    • テスト基盤の使い方などを説明しながらコーディングしました
  • ナビゲーター(ドライバーに指示する人):開発メンバー
    • スクリーンショット対象画面を表示するために必要なデータのセットアップ方法を案内しました

このモブプロで、「シンプルなコードで目的の画面のスクリーンショットを撮る」方法が開発チームに伝わったという手応えがありました。

モブプロで開発メンバーにコーディングしてもらう

その後新機能のリリースが立て続けに発生し、長い間テストが追加されない状態でした。

大きなリリースが終わり、開発チームが一息ついた頃に「そろそろスクリーンショットテスト書いてみませんか」と呼び掛けてみたところ、 「前回のモブプロでは自分が手を動かさなかったので書き始められる自信がない」という意見を多数いただきました。

確かに、前回のモブプロでは主にSWETがドライバーをしていたため、開発メンバーがコードを書く機会はほとんど有りませんでした。 そこで、開発メンバーをドライバーに据えて、改めてモブプロを開催することにしました。

  • ドライバー:開発メンバー1名
  • ナビゲーター:SWETと、残りの開発メンバー
    • SWETはテスト基盤の使い方を案内しました
    • 残りの開発メンバーは、狙った画面を表示するための方法を案内しました

このモブプロではスクリーンショット撮影に必要な一通りのコードを全員に書いて欲しかったため、開発メンバー全員がドライバーを担当するまで毎週開催することにしました。 その甲斐あって、スクリーンショットテストが追加されるようになりました!
・・・が、しばらく経つとテストが追加されない状態に戻ってしまいました。

短期的なゴールを共有する

そうこうしている内に、私達が支援できる期限は残り3か月を切ってしまいました。 当初はテストで撮影したスクリーンショットをデザイナーさんにレビューしてもらう計画だったにもかかわらず、肝心のテストが増えないのでレビューしてもらう画像も無いという状況でした。

この状況を打開するため、開発チームに次のような提案をしてみました。

  • 次回リリースのデザインレビューでは、テストで撮ったスクリーンショットをデザイナーさんにレビューしてもらうようにしたい
  • デザインレビューまでに、次回リリースで追加・修正される画面だけで良いからスクリーンショットを撮れるようにしたい
  • スクリーンショットテストを1人で書くのはまだ不安があると思うので、引き続きモブプロで書けるだけ書いていきたい

有り難いことに、開発メンバーの皆さんに賛成してもらえたので、3巡目となるモププロを開催しました。 今回のモブプロは役割分担こそ2巡目と同じものの、次の点が異なっていました。

  • テスト完成の期限が明確になった
  • スクリーンショットを撮る対象画面の範囲が明確になった

期限と範囲が明確なため、モブプロだけではカバーしきれなかった画面のテストが次々と追加されていきました。 モブプロだけでは必要なスクリーンショットが撮れそうになかったため、デザインレビューの期限に間に合うよう頑張ってもらえたのだと思っています。

また、提案したときは意図していなかったのですが、対象に難易度の高い画面が含まれていた点が良い方向に作用しました。 モブプロで難易度の高い画面に取り組んだことで、意図通りの状態で対象画面(Fragment)を起動するスキルが格段に向上したのです。 合わせて、難易度の高い画面に対応するための改善を、テスト基盤に多数取り込むことができました。

スキルが向上してスクリーンショットテストを書く手間が減っていくと、デザインレビュー以外の方法でも便利に使えるという声が開発メンバーから聞かれるようになりました。

  • Pull Requestを出すときにスクリーンショットを貼り付けるのが楽になった
  • 複雑な操作をしないと出せない画面を確認するのが楽になった

最終的には、デザインレビュー当日に必要なスクリーンショットテストが全て揃い、色々な画面サイズ・解像度でのスクリーンショットをデザイナーさんに渡すことができました。 開発メンバーの皆さんには、私達が離脱した後も継続してスクリーンショットテストを活用してもらえています。

まとめ

「GO」のAndroid版にユニットテストとスクリーンショットテストを導入し、開発チームが自動テストを書く習慣が根付くまでに取り組んだ内容を紹介しました。

今回の取り組みを通して、次のような気付きを得ることができました。

  • ユニットテストのようなシンプルなテストは、最初に参考となるテストを追加することで他のメンバーも書き始めやすくなる
  • 一方で、スクリーンショットテストのような複雑なテストを書いてもらうには、基盤の導入だけでは不十分
  • テストの書き方を身につけてもらうには、当人がドライバーとなってモブプロを行うのが効果的
  • 目標や期限が決まるとモチベーションがアップする

その一方で、次のような幸運に恵まれた点も多かったと思います。

  • 開発メンバーの皆さんが、自発的に自動テスト実装やアーキテクチャの改善をしてくれたこと
  • 開発メンバーの皆さんが、デバッグが難しい複雑なトラブルでも解決できる高いスキルを持っていたこと
  • 開発メンバーの皆さんがいつも協力的だったこと
  • 私達の支援期間が終了するまでに何とかしたいという気持ちを全員が持っていたこと

他のチームで、ラッキーな面を差し引いたとしてもなおこの方法が通用するのかは分かりませんが、今回の経験で得られた学びはとても大きなものでした。 本記事が、皆さんがテスト導入に取り組むときのヒントになれば幸いです。

謝辞

Mobility Technologiesの「GO」Android版開発チームの皆さんには、本取り組みのために貴重な時間を割いていただきました。 また、UIテスト基盤のOSS化について快諾していただきました。改めて感謝いたします。

Unityプロジェクト向けRoslynアナライザの作りかた

SWETグループの長谷川(@nowsprinting)です。

Unity 2020.2以降、Unityエディタ上でRoslynアナライザによる静的解析 (static analysis) を実行可能になりました。 また、それ以前のバージョンで作られたUnityプロジェクトであっても、JetBrains RiderなどのC#向けIDE(統合開発環境)上でRoslynアナライザの実行がサポートされています。

静的解析を充実させることで、コンパイラだけではチェックしきれないようなバグや性能劣化の原因を早期に検出できます。 例えば弊社では、実行時に動的にインスタンス化されるクラスのコンストラクタがIL2CPPビルド時にストリップされないように [Preserve]アトリビュートの指定漏れを検出するアナライザを導入し、ショーストッパーとなりえる問題を早期発見できるようにしています。

通常、こうした問題の検出はシニアエンジニアによるコードレビューに頼られます。静的解析によってレビュー負荷を軽減できれば、より高度な問題に目を向けられるという副次的効果も見込めます。

本記事では、社内フレームワークやプロジェクト固有のルールに対応するカスタムアナライザを作る手順やTipsを紹介します。

Roslynアナライザとは

Roslyn(ろずりん)とは、C# 6.0から導入された.NETコンパイラプラットフォームの通称です。C#およびVisual Basic向けコンパイラのほか、コード生成API、コード解析APIが公開されています。

Roslynのコード解析APIでは、構文解析 (syntactic analysis) および意味解析 (semantic analysis) を行なうことができ、これを利用して様々なアナライザを自作できます。 これを、本稿ではRoslynアナライザもしくは単にアナライザと呼びます。

なお、一般的なUnityプロジェクトで利用できるオープンソースのアナライザもあり、例えば以下のものが知られています。

UnityプロジェクトでRoslynアナライザを使用する

Roslynアナライザの作り方に先立って、Unityプロジェクトでアナライザを使用する方法について述べます。

Unity 2020.2以降、Unityプロジェクト内のアナライザ(および依存する)DLLファイルを適切に設定することで、Unityエディタ上で静的解析が実行されるようになりました。しかし、実用するにはまだ使い勝手が良いとは言えません *1 *2 *3 *4 *5 *6 *7

一方で、多くの方がコーディングに使用されているC#向けIDE(具体的にはJetBrains Rider、Visual Studio、Visual Studio Code)では、プロジェクトの.csprojファイルに使用するアナライザを定義してあれば静的解析が実行されます。 アナライザによる診断がもっとも欲しいタイミングは、まさにIDEでコードを書いているときです。アナライザを導入するのであれば、IDEでの診断をメインに据えることをおすすめします。

まずUnityプロジェクトでのDLL設定を、続いて、その設定を.csprojに反映する方法を紹介します。

UnityプロジェクトでのDLL設定

Unityプロジェクト(2019.4で確認しています)に配置したDLLファイルはPlugin Inspectorウィンドウで設定を変更でき、その設定はDLLの.metaファイルに書き込まれます。

f:id:swet-blog:20210517154955p:plain:w300
Plugin Inspectorウィンドウ

ProjectウィンドウでAssetsフォルダ下にあるアナライザのDLLを選択してPlugin Inspectorウィンドウを表示し、以下のように変更します。

  • Select platform for plugin下のチェックをすべてoff
  • Asset Labelsに RoslynAnalyzer を追加 *8

DLLがAssetsフォルダ下にない場合、Asset Labelsは設定できませんので注意してください。

アナライザ設定を.csprojファイルへ反映する

IDEが使用する.csprojファイルは、IDEに対応したプラグインパッケージ *9 によって自動生成されます。 以下のプラグインでは、前述のようにUnity 2020.2向けに設定されたアナライザDLLの設定を.csprojに反映してくれますので、Unityプロジェクト側で前述の設定を済ませるだけでIDEでも静的解析が実行されるようになります。

上記以外のIDEをお使いの場合、また、AdditionalFilesを使用するアナライザを使用する場合は、.csproj生成時にアナライザの定義を挿入する必要があります。 この設定をサポートしてくれるエディタ拡張がCysharpさんにより公開されていますので、こちらを利用してみてください。

github.com

重要度設定とサプレス設定

アナライザには、診断項目ごとにデフォルトの重要度 (severity) が設定されています。プロジェクトによって重要度を上げたい/下げたい場合、これを上書き設定できます。

UnityエディタおよびVisual Studioの場合、ルールセットファイルを使用します。設定方法は公式マニュアルの Roslyn analyzers and ruleset files を参照してください。 Riderの場合は、Preferences... を開き、Editor | Inspection Settings | Roslyn Analyzersの中で設定できます。

また、特定のコードにおいてのみ、診断の対象外にしたいケースもあります。

  • 対象となるクラスやメソッド定義に対して [SuppressMessage]アトリビュートをつけることで、そのクラス/メソッド内では指定した診断をサプレスできます
  • 対象となるコード行の前後に #pragma warning disable <DiagnosticID>#pragma warning restore <DiagnosticID> ディレクティブを書くことで、指定した診断をサプレスできます

なお、Riderにはインスペクションを行単位でサプレスできるコメント書式がありますが、Rider上であってもRoslynアナライザの診断に対しては無効です。 #pragma warning disable/restore ディレクティブを使用してください。

Roslynアナライザの作成(基礎編)

続いて、基本的なRoslynアナライザ作成の手順を紹介します。

プロジェクトの準備

Roslynアナライザは、.NETプロジェクトとして作成します。 .NET SDK コマンドラインツールのほか、.NETプロジェクトを扱うことのできるIDEが使用できます。

ただし、アナライザ用のプロジェクトテンプレートは、Windows版のVisual Studioでのみ提供されています。 ここではVisual Studio 2019でプロジェクトを作成する手順を紹介します *11

ひな形の作成

  1. Visual Studio Installerを起動し、インストール済みVisual Studio 2019 *12 の「変更」をクリック。「個別のコンポーネント」タブにある「.NET Compiler Platform SDK」を選択してインストールします
  2. Visual Studio 2019を起動し、「新しいプロジェクトの作成」をクリック。プロジェクトテンプレートとして「Analyzer with Code Fix (.NET Standard)」のC#のほうを選択します(同じ名称でVisual Basic向けもあるので注意)

これでアナライザのひな形ができました。 ひな形のソリューションは、例えば名称を HogeFugaAnalyzer としたとき、下記5つのプロジェクト (.csproj) で構成されています。

  • HogeFugaAnalyzer: アナライザ本体。この名称はソリューションと同じものです
  • HogeFugaAnalyzer.CodeFixes: アナライザで検出した問題のオートフィックス機能を提供するプロジェクトです。本記事では解説しません
  • HogeFugaAnalyzer.Package: アナライザ本体とCode FixをNuGetパッケージ (.nupkg) ファイルにパッケージングするプロジェクトです。Unityプロジェクト専用のアナライザではDLLファイルを直接扱うため使用しません
  • HogeFugaAnalyzer.Test: アナライザのユニットテストを記述するプロジェクトです
  • HogeFugaAnalyzer.Vsix: Visual Studio拡張機能としてアナライザをデバッグ実行するためのプロジェクトです。本記事では解説しません *13

Windows以外の環境でビルドする設定

以降の開発をWindows以外の環境で行なう場合、ひな形のままではビルドできないため、アナライザ本体の PackageID 属性を書き換えます。

HogeFugaAnalyzer.csprojを任意のエディタで開き、

<PackageId>*$(MSBuildProjectFullPath)*</PackageId>

の部分を、例えば

<PackageId>HogeFugaAnalyzer</PackageId>

に変更します。 ただし、この名称はNuGetパッケージ (.nupkg) の名前としてパッケージングプロジェクト (HogeFugaAnalyzer.Package.csproj) 内で定義されています。同時にHogeFugaAnalyzer.Packageプロジェクトは削除しましょう。

もしNuGetパッケージとして配布を予定しているのであれば、パッケージングにまつわる設定をアナライザ本体の.csprojに記述するか、もしくは本体のPackageIDを例えば下記のように変更することで回避できます。

<PackageId>HogeFugaAnalyzer.Diagnostic</PackageId>

テストプロジェクトの設定

生成されたひな形ではテストプロジェクトが.NET Core App 2.0向けに設定されているため、開発環境に合わせて変更します。

例えば.NET SDK 5.0で開発する場合、 HogeFugaAnalyzer.Test.csprojをエディタで開き、

<TargetFramework>netcoreapp2.0</TargetFramework>

の部分を

<TargetFramework>netcoreapp5.0</TargetFramework>

に変更します。

以上で、IDE上でビルドが成功する状態になります。

アナライザの動作解説

アナライザのひな形プロジェクトでは、コード上にあらわれる型の名称を検査し、小文字が含まれていれば警告するサンプルが実装されています。 これをもとに、簡単に診断の流れを解説します。

DiagnosticAnalyzer

アナライザは、DiagnosticAnalyzer を継承かつ [DiagnosticAnalyzer]アトリビュートがつけられたクラスとして定義されます。

DiagnosticAnalyzerを継承したクラスには、SupportedDiagnosticsプロパティおよび Initializeメソッドの実装が必要です。

SupportedDiagnostics

このアナライザが提供する診断内容 (DiagnosticDescriptor) を配列で返します。

DiagnosticDescriptor は、IDEのインスペクション設定で一覧表示され、重要度 (severity) を設定させるために使われます。 また、実際に問題を検出した際に表示されるメッセージもここに設定します。

ひな形では private static readonly DiagnosticDescriptor Rule を定義し、これを ImmutableArray でラップして返す実装になっています。

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
{
    get
    {
        return ImmutableArray.Create(Rule);
    }
}
Initialize

Roslynコンパイラによってアナライザがロードされると呼ばれるメソッドです。

コンパイラは、ソースコードに対して、字句解析・構文解析・意味解析といったフェーズを経て中間言語 (Intermediate Language) を出力します。 アナライザは、その診断内容に適したフェーズでコンパイラからコールバックを受けて動作します。 Initializeメソッドでは、コールバックを受け取りたい契機を AnalysisContext に登録します。

ひな形では、シンボルの意味解析完了ごとに動作させたいメソッド AnalyzeSymbol() を、 RegisterSymbolAction() で登録しています。

public override void Initialize(AnalysisContext context)
{
    (snip)
    context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
}

アクションには、シンボルのほかにもコンパイルの開始・終了、メソッド呼び出しなど多数のアクションが定義されており、自由に組合わせてアナライザを作ることができます。

ただし、各アクションの呼び出し順は保証されていません *14。たとえばsymbol actionの時点でsyntax tree actionの処理が完了していることに依存するようなアナライザは動作しない恐れがあります。

アクションに関する詳細は、Roslynのドキュメント Analyzer Actions Semantics を参照してください。

診断の実行

コールバックを受けるよう登録した private static void AnalyzeSymbol() が実際の診断を行なうメソッドです。 診断に必要な情報は引数で受け取れます。ここではシンボルの名前に小文字を含む場合、診断結果である Diagnostic を生成して返しています。

private static void AnalyzeSymbol(SymbolAnalysisContext context)
{
    var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;

    if (namedTypeSymbol.Name.ToCharArray().Any(char.IsLower))
    {
        var diagnostic = Diagnostic.Create(
          Rule,
          namedTypeSymbol.Locations[0],
          namedTypeSymbol.Name);

        context.ReportDiagnostic(diagnostic);
    }
}

Diagnostic.Create() の引数は、DiagnosticDescriptorLocation、メッセージ挿入語句となっています。 この結果を受けたIDE上では Location で指示された位置にポップアップ等で DiagnosticDescriptor に定義されたメッセージが表示されます。

f:id:swet-blog:20210517155031p:plain
Riderでの診断結果表示例

以上のように、アナライザプロジェクトのひな形は、そのままでアナライザとして動作するものが生成されています。 アナライザの開発がはじめてであれば、この時点で一度Unityプロジェクトに組み込んでその振る舞いを確認することをおすすめします。

アナライザのテスト

ユニットテスト

アナライザ作成においても、できるだけ早い段階かつ小さい単位でユニットテストを行なうことは役に立ちます。 しかし、Roslynコード解析APIで提供されているテストAPIは、解析対象コードと期待する Diagnostic を引数に取って合否検証まで行なうという粒度の大きいものしかありません。 このAPIでは、複雑な診断を行なうアナライザのテストは困難です。

そこで、アナライザの実行と結果検証を分けて使えるフレームワークDena.CodeAnalysis.Testingを作成し、使用しています。 Dena.CodeAnalysis.Testingは、GitHubおよびNuGet Galleryで公開しています。

github.com

www.nuget.org

Dena.CodeAnalysis.Testingを使用するには、テストプロジェクトの.csprojに下記の定義を追加します。

<PackageReference Include="Dena.CodeAnalysis.Testing" Version="1.0.0" />

Dena.CodeAnalysis.Testingを用いたテストの書きかた

テストは次のように記述できます。

var analyzer = new YourAnalyzer();

var diagnostics = await DiagnosticAnalyzerRunner.Run(
    analyzer,
    @"
public static class Foo
{
    public static void Bar()
    {
        System.Console.WriteLine(""Hello, World!"");
    }
}");

var actual = diagnostics
    .Where(x => x.Id != "CS1591") // Ignore "Missing XML comment for publicly visible type or member"
    .ToArray();

Assert.AreEqual(1, actual.Length);
Assert.AreEqual("YourAnalyzer0001", actual.First().Id);

この例では DiagnosticAnalyzerRunner.Run にアナライザのインスタンスと検証対象コードを渡し、診断結果を受け取った後に Assert で期待通りの結果であるかを検証しています。

アナライザの実行と Assert が分離されているため、最終的な診断結果だけでなく、アナライザ内の中間情報を検証するテストを書くこともできます。 また、アナライザをテストダブル(モック、スタブ、スパイ等)に置き換えてのテストも可能です。

ユニットテストを考えるとき、例えば INamedTypeSymbol などをテストデータとして生成できればよいのですが、Roslyn APIでは困難です。そのため上記のように診断対象コードをテストの入力データとしています。

最後の Assert はテスト対象や目的に応じて様々な書きかたがありますが、ひとつ注意すべき点があります。 上例では診断結果から CS1591 を取り除いた上で、件数および DIagnostic.Id を検証しています。 もしこれを直接

Assert.IsTrue(diagnostics.Any(x => x.Id == "YourAnalyzer0001"));

のように書いてしまうと、想定外の診断結果バグを見逃してしまったり(上例では2つ目の Diagnostic が無いことを確認できません)、返るはずの Diagnostic が返らなかったとき原因判明に時間がかかったりします(テストデータのミスによるコンパイルエラーは多々発生します)。 少々面倒に感じますが、関係ないものを明示的に取り除いてから Assert する手順を踏むことをおすすめします。

LocationAssert.HaveTheSpan

Dena.CodeAnalysis.Testingでは、補助的なツールとして LocationAssert.HaveTheSpan も提供しています。 次のように、 DiagnosticResult に含まれる Location が正しく設定されていることの検証を簡単に記述できます。

LocationAssert.HaveTheSpan(
    new LinePosition(7, 34),  // 期待する始点
    new LinePosition(7, 42),  // 期待する終点
    diagnostic.Location
);

なお、Location の行およびカラムはコードでは0オリジンで指定しますが、診断メッセージには1オリジンで(つまり+1して)表示されます。 1足すか引くかは混乱しがちなポイントですが、 LocationAssert.HaveTheSpan では次のようにアサートメッセージに両方の数値が並べて表示され、判別しやすくなっています。 また、メッセージはそのままテストコードのexpectedとしてコピー&ペーストできる書式となっています。

f:id:swet-blog:20210524145808p:plain
LocationAssert.HaveTheSpanの差分表示例

テストデータについてのTips

ユニットテストの効率を高めるため、テストで使用するデータ(アナライザにおいては診断対象コード)の質には気を配るべきです。 特に、実際の検証対象コードと乖離したデータでテストを書いてそれがパスしてしまうと、アナライザを実際のプロジェクトへと組み込んでから問題に気づくこととなり手戻りが大きくなります。

そのため、よほどシンプルなものを除き、テストデータは string ではなく個別のテキストファイルに定義することをおすすめします。 拡張子を.csにすることで、コンパイルエラーや警告をあらかじめ修正でき、テスト実行時に自作アナライザとは無関係の診断結果に煩わされることもなくなります。

複数のテストデータで同名のクラスを使用している場合、ファイルを小分けすることで使いまわしができます。逆に同名のクラスをあえて使い分けたい場合、テストケースごとに namespace を使い分けることで実現できます。

その他、以下の点にも注意してみてください。

  • メソッド呼び出しを検出するとき、それが拡張メソッドでないか。拡張メソッドは明示的に型が異なるため、テストデータも拡張メソッドとして記述しなければ検出できません
  • アトリビュートのクラス名に Attribute を付け忘れていないか。例えば、アトリビュートのクラス名が HogeAttribute でも Hoge でも、[Hoge] と記述できてしまいます。しかし、アナライザから見ると別物です

Unityプロジェクトに組み込んでのテストTips

アナライザをDLLにビルドし、それをUnityプロジェクトに組み込んでテストする際のTipsを紹介します。

極力ユニットテストで品質を上げておく

Unityプロジェクトに組み込んでからのテストではデバッグしづらく、また修正・再実行に時間もかかります。 大前提として、極力、ユニットテストでリアリティの高いテストデータ(診断対象コード)を使って品質を上げておくべきです。

dotnet buildコマンドを使用する

IDEでアナライザを実行する場合、IDEによってアナライザ自体がキャッシュされるため、何度も安定したテストを実行することは困難です。 そのため、初期の段階ではdotnetコマンドでインクリメンタルコンパイルを無効にした実行を試すことをおすすめします。

次のコマンドで実行できます。

$ dotnet build --no-incremental YOUR_PROJECT_NAME.csproj

ファイルロガーを使用する

アナライザをUnityプロジェクトに組み込むと、コンソール出力を観測できないため、いわゆるprintデバッグは不可能になります。 そのため、デバッグ困難な事象に突き当たってしまったら、早めにファイルロガーの仕組みを入れることをおすすめします。

なお、OSSなど外部のロガーソリューションを利用する場合、依存するDLLもすべてアナライザ本体同様 <Analyzer> ノードに書く必要がある点に注意してください。

Roslynアナライザの作成(応用編)

実用的なアナライザとして、冒頭で触れた「実行時に動的にインスタンス化されるクラスのコンストラクタがIL2CPPビルド時にストリップされないように [Preserve] アトリビュートの指定漏れを検出するアナライザ」の作成方法を紹介します。

このアナライザの実現には、二段階の処理が必要です。 まず、メソッドの呼び出し箇所でコールバックを受け、それが特定のメソッド呼び出しであるかを判断し型引数を取得します。 続いて、その型のコンストラクタに [Preserve] アトリビュートが定義されてるかを診断します *15

では、順に見ていきましょう。まず Initialize でメソッド呼び出し箇所のコールバックを登録します。

public override void Initialize(AnalysisContext context)
{
    context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
    context.EnableConcurrentExecution();

    context.RegisterOperationAction(
        AnalyzeAttributes,
        OperationKind.Invocation
    );
}

コールバックに渡される OperationAnalysisContext からは、呼び出しているメソッドの情報が取得できます。 ここでは、クラスを型引数として取り、実行時に動的にインスタンス化するクラスを登録しているメソッド呼び出しを検出します。

private void AnalyzeAttributes(OperationAnalysisContext context)
{
    var invocation = (IInvocationOperation)context.Operation;
    var methodSymbol = invocation.TargetMethod;

    if (methodSymbol.ContainingType.Name != "ServiceCollection")
        return;

    if (methodSymbol.Name != "AddSingleton")
        return;

    if (methodSymbol.TypeArguments.Count()==0)
        return;

特定のメソッド呼び出し(ここでは ServiceCollection.AddSingleton<>())であったならば、続いて型引数のコンストラクタについているアトリビュートを診断します。

    var injectedClass = methodSymbol.TypeArguments[0] as INamedTypeSymbol;
    var ctorShouldBePreserved = new List<IMethodSymbol>();

    foreach (var ctor in injectedClass.Constructors)
    {
        foreach (var attribute in ctor.GetAttributes())
        {
            if (attribute.AttributeClass?.Name == "PreserveAttribute")
            {
                ctorShouldBePreserved.Add(ctor);
            }
        }
    }

以上で、ServiceCollection.AddSingleton<>() に型引数として渡しているクラスのコンストラクタに [Preserve] アトリビュートがついているか否かを確認できました。

最後に、診断結果をコンパイラに渡します。

    if (ctorShouldBePreserved.Count == 0)
    {
        var diagnostic = Diagnostic.Create(
          CtorDoesNotHaveInject,  // [Preserve]付きコンストラクタが存在しない意のDiagnosticDescriptor
          location,               // 割愛していますが、呼び出し部分の型引数部分を指すLocation
          injectedClass.Name);
        context.ReportDiagnostic(diagnostic);
    }
}

コードでは割愛していますが、この診断結果の Location (primary location) には、本来の問題箇所である型引数のコンストラクタ定義箇所ではなく ServiceCollection.AddSingleton<>() 呼び出し位置(の型引数部分)を指定しています。

IDE上でアナライザが実行されるときはエディタタブで開かれているファイルを起点とした診断しか行われないため *16 *17、このアナライザは呼び出し側のファイルでしか機能せず、またそのファイル以外を指す診断結果は無効となるためです。

f:id:swet-blog:20210524145620p:plain
Preserveアトリビュートの指定漏れを検出するアナライザの診断例

まとめ

なかなか情報も少なく、ハードルが高い印象のRoslynアナライザです。しかし、導入してしまえばエンジニアへの負担なく問題を早期発見できる、とても効果の高いものです。

プロジェクトのコーディング規約やレビューでの観点のうち明文化されているものがあれば、それをアナライザにはできないか、検討してみてはいかがでしょうか。

最後に。以上のような活動に共感していただけた方、興味を持たれた方、一緒に働いてみようと思ってくれた方。 下記職種で採用しておりますので、ぜひご応募ください。お待ちしております。

career.dena.jp

*1:診断結果は、GUIではコンソールウィンドウ、CLI (Batch mode) ではビルドログに混ざって出力されます

*2:Unity 2020.2では、診断は当該ファイルのコンパイルもしくはReimportの契機でのみ実行されます(Unity 2020.3.4より、通常のコンパイルステップで動作するように修正されました)

*3:Unity 2020.2では、ルールセットファイルによる重要度の設定変更は、Reimportの契機でのみ反映されます(Unity 2021.1で修正されました)

*4:Unity 2020.2では、CLI実行ではルールセットファイルによる重要度設定は無効です(Unity 2021.1で修正されました)

*5:Packages/下のDLLには、アナライザとして識別させるためのラベル設定ができません

*6:Packages/下にアナライザとして設定したDLL(と.metaファイル)を配置しても、アナライザとして動作しません

*7:その他、Unity 2020.2時点のRoslynアナライザサポート状況はこちらにまとめてあります https://www.nowsprinting.com/entry/2021/04/18/200619

*8:ラベルは、ウィンドウ右下のしおり状アイコンをクリックすることで入力できます。アイコンが表示されていない場合は"Asset Labels"の文字をクリックすると表示されます

*9:Package Managerウィンドウでインポートおよびアップデートできます

*10:Visual Studio Code (VSCode) 用プラグインです。Visual Studio用ではないのでご注意ください

*11:その他の環境でアナライザプロジェクトを作成する場合は、 Roslyn SDK内にあるテンプレート https://github.com/dotnet/roslyn-sdk/tree/main/src/VisualStudio.Roslyn.SDK/Roslyn.SDK/ProjectTemplates/CSharp/Diagnostic もしくはテンプレートリポジトリ https://github.com/nowsprinting/RoslynAnalyzerTemplate を利用するか、こちらの記事を参考に設定してください https://zenn.dev/naminodarie/articles/32973a36fcbe99

*12:Community EditionでもRoslynアナライザは作成できますが、for macではできません

*13:Visual Studio 2019 v16.10ではデバッグ実行の手段が変わるため不要になるようです。参考 https://qiita.com/ryuix/items/36dabbf3c7e4e395e49e

*14:compilation start/endのような対になっているものの呼び出し順は保証されます。また、compilation end actionは必ず最後に1回実行されます

*15:あからじめSymbolActionでコンストラクタのアトリビュートを収集しておき、後でメソッド呼び出し箇所のコールバックで処理する、という二段構えの方法も考えられますが、2つの理由で採用していません。1つ目は、アクションの呼び出し順が未定義であること。2つ目は、IDE上で実行されるときはエディタタブで開かれているファイルを起点とした診断しか行われないため、型引数に使われている型が別ファイルにあるとSymbolActionが呼ばれないためです

*16:インスペクション機能はプロジェクト全体を診断してくれますが、この場合も実行単位はファイルごとのため同様の制限があります

*17:先に紹介したdotnet buildコマンドではこの制限を受けないため、診断されているはずなのにIDEに表示されないときはdotnet buildコマンドを試してみると原因にたどり着きやすいこともあります

Jenkins Shared Librariesの活用事例の紹介

1. はじめに

SWETグループの井口です(@hisa9chi)です。現在はスマホ向けゲーム開発案件にてゲーム開発者がゲーム開発に集中できるようにCI/CD関連を幅広くサポートしています。 本稿では、その中でも Jenkins Pipeline Job で利用可能な Shared Libraries に関して弊社でどのように活用しているか事例を紹介してみたいと思います。

Jenkinsと聞くとおそらく皆さんは、昔は利用していたが今は運用コストが高いなどの理由から、マネージドなクラウドのCI/CDサービスへ移行したという方が多いのではないでしょうか。しかし、ゲーム開発の現場ではJenkins master / agentのクラスタ構成を構築して、運用を続けているプロジェクトが弊社内にも多く存在します。なぜ、運用コストが高いにもかかわらず構築して運用しているかというと、ゲーム開発特有の理由からです。

ゲーム開発において、修正後にビルドして実機で動作やレイアウトなどを確認するサイクルはとても重要です。このサイクルが短ければ短いほどゲームの修正確認がスムーズに行え、ゲーム開発者はゲーム開発に集中できると思います。しかし、ゲーム開発では大容量のデータを扱うことが多く、開発が進むにつれてビルド時間が増加する傾向にあります。そのため、ビルド時間を少しでも短くするために、高スペックな物理マシンを手元に用意してJenkins master / agentのクラスタ構成を構築して運用しています。

この運用コストに関しては本稿では触れませんが、SWETとして取り組んでいることをCEDEC 2020にて発表しております。発表資料は以下にありますので、そちらも参考にしていただければ幸いです。

CEDEC 2020「モバイルゲーム開発におけるJenkinsクラウド時代のJenkins構築と管理テクニック」

2. 前提

本稿では以下を前提としております。

3. Jenkins Shared Libraries

3.1. 概要

Jenkinsのジョブ作成にてPipeline Jobが多く利用されるようになると、共通処理が複数のPipeline Jobで出現することがあります。この共通処理をPipeline Jobとは切り離して定義できれば、Pipeline Jobのメンテナンス性向上や共通処理等の再利用性も向上します。この共通処理等をPipeline Jobの外部に定義して、Pipeline Jobごとにロードして利用する仕組みが、Shared Librariesです。

3.2. 特徴

Shared Librariesの特徴として以下があります。

  1. ジョブ毎に異なるバージョンの利用が可能
    ライブラリをSCM管理しているため、ブランチ/タグ/コミットハッシュ指定でジョブごとに異なるバージョンのライブラリをロードして利用可能です。

  2. メンテナンス性と再利用性の向上
    共通処理等をPipeline Jobの外に定義しているため、変更はライブラリ内に閉じることが可能です。また、Jenkins masterが異なる場合でも、ライブラリが管理されているリポジトリへアクセス可能であれば容易に利用可能です。他にも、Pluginの利用をラップしたライブラリを作成しておくことで、Pluginの更新に伴う変更もライブラリ内に閉じることが可能な場合もあります。

  3. classメソッドのapproveが不要
    Pipeline Job内でgroovyを用いてclassメソッド1を利用した処理を定義していた場合、そのclassメソッド毎にapproveが必要となる場合があります。Jenksinfile内のgroovyコードで多くのclassメソッドを利用していてapproveされていない場合、このapprove作業は非常に面倒な作業となります。しかし、Shared Librariesであればapproveする必要がないためそのような面倒な作業は発生しません。

3.3. 作成

基本的にはライブラリ群を決められたディレクトリ構造で作成して、そのリポジトリをSCM管理します。そのリポジトリをJenkins側に設定してジョブごとでライブラリを読み込むように設定することで、ジョブごとのワークスペースにライブラリ群がチェックアウトされて利用可能になります。

先に示した公式のドキュメントに詳細は記載されていますが、簡潔に説明するとディレクトリ構造として大きく以下となります。

  • src
    • パッケージに分けて独自のクラスを定義可能
  • var
    • pipeline jobで利用可能な変数定義(.groovy)とヘルプファイル(.txt)
    • xxx.groovyのファイル名を変数として利用可能でありそのファイル内に定義されているメソッドを {ファイル名}.{メソッド名} で呼び出し可能
  • resources
    • groovyではないファイル(xxx.jsonやxxx.sh等)を格納
    • libraryResourceを利用してメソッド内で利用が可能

src/var/resourcesに関しての実装サンプルは公式のサンプルがわかりやすいのでそちらを確認してください。

3.4. 利用

作成したShared Librariesを利用する場合は以下の2ヵ所に設定が必要となります。

  1. Jenkinsのシステム設定
  2. ジョブごとにライブラリをインポート

3.4.1. Jenkinsのシステム設定

Pipeline Jobにてロード可能なライブラリをGlobal Pipeline Librariesへ設定します。JenkinsからSCM経由でライブラリにアクセス可能であることが前提となります。

設定箇所は以下のように 「Jenkinsの管理」-「システムの設定」内の Global Pipeline Libraries の項目になります。

f:id:swet-blog:20201217214645p:plain

項目 設定内容 設定例
Name ライブラリ名。ジョブでインポートする際に利用 mylibrary
Default version ブランチ名、タグやコミットハッシュを指定 master
Retrieval method 利用するSCM Modern SCM
Source Code Management 利用するSCMサービス Git
プロジェクトリポジトリ リポジトリURL SSH or HTTPSアクセス用URL
認証情報 リポジトリへの認証情報 credentialsに登録済みの物を指定

3.4.2. ジョブごとにライブラリをインポート

基本的には、Pipeline Job内で @Library を活用して先ほど設定したライブラリを読み込みます。

// ライブラリの読み込み
// デフォルトバージョンの読み込み
// "Global Pipeline Libraries 設定" の "Default version"
@Library( 'ライブラリ名' ) _

// ブランチやタグを指定する場合
@Library( 'ライブラリ名@{branch名 | tag名}' ) _

// 複数ライブラリの読み込み
@Library( ['ライブラリ名1', 'ライブラリ名2'] ) _

注意が必要なのは最後の _ を忘れないことです。これがなければエラーとなるので注意してください。 _ を記載することでライブラリ側に定義した、var 配下のスクリプトが利用可能になます。利用には var 配下のファイル名をそのまま変数名として利用できます。

また、src などで独自に定義したclassの利用に関してはそのクラスをimportする必要があります。

// srcで定義した class を利用する場合
// 利用したい class を import する必要あり
@Library( 'ライブラリ名' ) import org.foo.Sample

// 利用時は script { } ブロック内で利用する
script {
  def sample = new org.foo.Sample()
  sample.hello()   // Sample 内に定義されている hello() メソッドの呼び出し
}

srcで定義されているclassを利用する場合は script {} ブロックに入れる必要があることに注意してください。

4. 活用事例

では、実際に弊社でどのように利用しているか実際の以下の3つの事例を紹介させていただきます。

  1. 成果物等のGCSアップロード
  2. ジョブ終了時に自動メンション(Slack通知)
  3. ジョブ失敗時のstage名の取得

4.1. 成果物等のGCSアップロード

ジョブを実行した際の成果物やログなどはarchiveArtifacts Pluginを活用してJenkins masterに保存しておくことが一般的かと思います。しかし、ジョブの成果物はサイズが大きい物などもありJenkins masterのディスク容量を圧迫してしまいます。この対策として、ジョブごとにビルド履歴の保存件数を制限するbuildDiscarderを設定する方法があります。ただし、以下の問題があります。

  • ジョブ数が多い場合は結果ディスクフルになってしまいジョブが正しく動作しない
  • ビルド履歴を制限すると過去の成果物の取得ができない

これらを解決する施策として、クラウドのストレージであるGCSやS3にアップロードして保存しておくことが挙げられます。今回はGCSへのアップロードを例にShared Libraryの利用事例をご紹介します。

GCSへのアップロードにはGoogle Cloud Storage Pluginを利用することで簡単に行えます。ただ、アップロードの際には対象のバケット配下に {ジョブ名}/{ビルド番号} のディレクトリを作成してアップロードしたいという要望があります。Pluginをそのまま活用するとアップロード先にバケット名だけでなく {バケット名}/{ジョブ名}/{ビルド番号} とジョブ毎に指定する必要があります。バケット名に関してはジョブ毎に変更する可能性はありますが、ジョブ名ビルド番号は共通してJenkinsの環境変数から取得して指定します。この共通部分をジョブごとに記載するのは煩わしいためライブラリ化しています。

// var/gUploadArtifactsToGCS.groovy
#!/usr/bin/env groovy

// Google Cloud Storage Uplaod(for Google Cloud Storage Plugin)
def call( Map params = [:] )
{
  // 必須パラメータチェック
  if ( params.bucket == null ) {
    println "gUploadArtifactsToGCS: 'bucket' param is not specified."
    return
  }
  if ( params.credentialsId == null ) {
    println "gUploadArtifactsToGCS: 'credentialsId' param is not specified."
    return
  }
  if ( params.pattern == null ) {
    println "gUploadArtifactsToGCS: 'pattern' param is not specified." 
    return
  }

  def uploadDir = "gs://${params.bucket}/${env.JOB_NAME}/${env.BUILD_NUMBER}"
  
  // Google Cloud Storage Plugin の提供メソッド
  googleStorageUpload( bucket: uploadDir, credentialsId: params.credentialsId, pattern: params.pattern )
}
// Jenkinsfile
@Library( 'mylibrary' ) _

pipeline {
  agent { label 'master' }
  stages {
    stage ( 'sample' ) {
      steps {
        sh 'touch sample.txt'
        gUploadArtifactsToGCS( bucket: ARTIFACTS_BUCKET_NAME, credentialsId: GCS_CREDENTIAL_ID, pattern: 'sample.txt' )
      }
    }
  }
}

上記のようにShared Libraryを呼び出すことで指定したバケット ARTIFACTS_BUCKET_NAME 配下に {ジョブ名}/{ビルド番号}/sample.txt としてアップロードされます。

他にも以下のような情報をGCSへアップロードするようなライブラリを作成して利用しています。

  • ジョブ実行時に指定したビルドパラメータとビルドログ
    Jenkinsにて定義済みのclassを活用してファイルに出力してGCSへアップロード

  • ジョブを実行したビルドマシンの環境情報
    agentのOSやツールのバージョンを取得するスクリプトをShared Library側に登録してそれを呼び出して情報を収集してGCSへアップロード

4.2. ジョブ終了時に自動メンション(Slack通知)

ジョブが終了した際に結果をSlackなどへ通知していることは多いかと思います。弊社でもJenkinsのジョブを手動でトリガーした場合やcronで実行された際の結果をSlackへ通知(Slack Notification Pluginを利用しています)しています。しかし、ジョブも多く頻繁に実行されると通知の数が増えるため、Slackの通知が埋もれてしまい気づけない状況が発生するため、特定の人へメンションしたいというケースがあります。

あるプロジェクトでは、ジョブ毎にメンション先をビルドパラメータ化して実行時に設定してもらうという運用がなされていました。しかし、この方法は利用側からすると面倒であり指定を間違えてしまうこともあります。そのため、ジョブをトリガーしたユーザの情報から自動でそのユーザにメンション付きでSlack通知するようにライブラリを作成して利用しています。

仕組みを簡単に説明すると、トリガーしたユーザのメールアドレス(Slackに登録しているアドレスと同一)を元に、SlackのIDを検索してそのユーザへメンションするための文字列である <@UserID> を返却しています。2

// var/gGetSlackUsersMentionString.groovy
import java.util.concurrent.TimeUnit
import groovy.json.*

@Grab( group='com.squareup.okhttp', module='okhttp', version='2.7.5' )
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;

def call( Map params = [:] )
{
  // 必須パラメータチェック
  if ( params.userEmails == null ) {
    println "gGetSlackUsersMentionString: 'userEmails' param is not specified."
    return
  }
  // 必須パラメータチェック
  if ( params.slackAPIToken == null ) {
    println "gGetSlackUsersMentionString: 'slackAPIToken' param is not specified."
    return
  }
  
  String slackApiUrl = 'https://slack.com/api/'
  String apiMethod = 'users.list'
  String query = 'token=' + params.slackAPIToken;
  String requestUrl = slackApiUrl + apiMethod + '?' + query

  Request request = new Request.Builder()
                          .url( requestUrl )
                          .header( 'User-Agent', 'jenkins' )
                          .get()
                          .build();
  
  OkHttpClient client = new OkHttpClient();
  client.setConnectTimeout( 5, TimeUnit.MINUTES );    // connect timeout
  client.setReadTimeout( 5, TimeUnit.MINUTES );       // socket timeout

  // Response
  Response response = client.newCall( request ).execute();

  // json へ変換
  Object json = new JsonSlurper().parseText( response.body().string() )
  
  // User の mention 用 ID 文字列
  String mentionIdsStr = '';

  // email から User ID を検出
  for ( user in json.members ) {
    for ( target in params.userEmails ) {
      if ( user.profile.email == target ) {
        mentionIdsStr += '<@' + user.id + '> ' 
      }
    }
  }

  return mentionIdsStr;
}
// Jenkinsfile
@Library( 'mylibrary' ) _

pipeline {
  agent { label 'master' }
  stages {
    stage ( 'sample' ) {
      steps {
        script {
          def mentionStr = gGetSlackUsersMentionString( userEmails: [ EMAIL_ADDRESS ], slackAPIToken: SLACK_API_TOKEN )
          // Slack Notification Plugin の提供メソッド
          slackSend( color: 'good', message: "${mentionStr}\n Message from Jenkins Pipeline" )
        }
      }
    }
  }
}

上記のように利用することで、ジョブをトリガーしたユーザへのメンション付きでSlack通知することを可能にしています。

f:id:swet-blog:20201217214705p:plain

4.3. ジョブ失敗時のstage名の取得

こちらもSlackに通知する際の便利機能の1つになります。Jenkins Pipeline Jobでは複数のstageが定義されています。ジョブが失敗した際のSlack通知にて、どのstageで失敗したかも補足情報として通知されているとエラー箇所の確認などに役立ちます。しかし、エラーとなったstageを特定するのは以下のように面倒であり、Jenkinsfileへのコード量も増えてしまいます。

// Jenkinsfile
pipeline {
  agent { label 'master' }
  stages {
    stage( 'sample' ) {
      ....
    }
    post {
      // 失敗となった場合に stage 名称を環境変数に設定
      failure {
        script {
          env.ERROR_STAGE='sample'
        }
      }
      // ここまでがエラー stage 名称の設定
    }
  }
  // Declarative: Post Actions
  post {
    failure {
      // Slack Notification Plugin の提供メソッド
      slackSend( color: 'danger', message: "ErrorStage: ${env.ERROR_STAGE}" )
    }
  }
}

上記のように全てのstageの post { failure { script {} } } ブロックにて env.ERROR_STAGE='ステージ名' というような設定を記載する必要があります。これはかなり面倒であり、多くのメンバーでメンテナンスをしていると記載を忘れてしまうこともあります。 そのため、最後の Declarative: Post Actions でfailureの際にエラーとなったstageを取得できれば余計なコードもなくなりスマートになります。Pipeline Jobの環境変数などでエラーstage情報が提供されていないため、Jenkinsのクラスからstageを取得する以下のライブラリを作成して利用しています。

// vars/gGetFailedStageName.groovy
#!/usr/bin/env groovy

import hudson.model.Run;
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
import org.jenkinsci.plugins.workflow.graphanalysis.ForkScanner;
import org.jenkinsci.plugins.workflow.pipelinegraphanalysis.StageChunkFinder;
import com.cloudbees.workflow.rest.external.StageNodeExt;
import com.cloudbees.workflow.rest.external.StatusExt;
import com.cloudbees.workflow.rest.external.ChunkVisitor;

def call() {
  String errorStageName = '';

  WorkflowRun workFlowRun = currentBuild.getRawBuild();
  ChunkVisitor visitor = new ChunkVisitor( workFlowRun );

  // stage 情報の取得
  ForkScanner.visitSimpleChunks( workFlowRun.getExecution().getCurrentHeads(), visitor, new StageChunkFinder() );

  // 全ての Stage のステータスを検索
  for ( StageNodeExt stageExt : visitor.getStages() ) {
    if ( stageExt.getStatus() == StatusExt.FAILED ) {
      errorStageName = stageExt.getName();
      break;
    }
  }

  return errorStageName;
}
// Jenkinsfile
@Library( 'mylibrary' ) _

pipeline {
  agent { label 'master' }
  stages {
    stage ( 'sample' ) {
      ....
      error '強制的にエラー'
      // 以降の stage はスキップ stage のステータスはエラー状態
    }
  }
  // Declarative: Post Actions
  post {
    failure {
      // Slack Notification Plugin の提供メソッド
      slackSend( color: 'danger', message: "ErrorStage: ${gGetFailedStageName()}" )
    }
  }
}

上記のように利用することでエラーとなったstage名をSlackのメッセージ内に入れることが可能になります。

f:id:swet-blog:20201217214649p:plain

ただし、先頭から確認して最初にエラーとなったstage名のみ(上記のサンプルでは "sample" stage名のみ)を返却するようになっています。基本的には最初にエラーとなったstage以降のstageはスキップされ失敗した状態となっています。そのため、最初にエラーとなったstage名だけを返却する方針で良いと判断したためこのような仕組みとなっております。

5. 終わりに

今回は弊社で利用しているJenkins Piepline JobにおけるShared Librariesの利用事例を紹介しました。今回紹介したのは一部であり、Shared Librariesは独自クラスを定義してJenkins Pipeline Jobで利用が可能になるなど他にも多くの利用方法が挙げられます。Shared Librariesに関しては私たちも日々どのように利用するのが良いか色々と模索しています。ですので、自分たちも利用しているという方がおられましたら、これを機会にどのように活用しているかなどのノウハウや情報をお互いに交換できればと思っています。 もし、ざっくばらんに話してみたいなどありましたら井口(@hisa9chi)までご連絡いただければと思います。


  1. 例えばユーザID情報を取得のメソッドであるhudson.model.Cause$UserIdCause getUserId()など

  2. Shared Libraryを使ってSlackのUserIDを取得していますが、現在ではPluginでemailからSlackのUserIdを検索できるようになっています。slackUserIdFromEmail

大規模リポジトリで高速にgit cloneするテクニック

ニッチな話題ですが、業務におけるCI/CDの現場では避けることのできない大規模リポジトリと戦うためのgit cloneのテクニックを紹介します。

この記事はDeNA Advent Calendar 2020の10日目の記事です。
CI/CDマニアの@Kesin11です。SWETではCI/CDチームの一員として、CI/CDの啓蒙活動やJenkinsを必要とするチームのサポートなどの業務を行っています。

はじめに

おそらくどこの会社でも1つぐらいは巨大なリポジトリが存在しているかと思いますが、歴史あるリポジトリはgit cloneするだけで数分を要し、checkout後のリポジトリサイズがGB単位になることも珍しくないでしょう。業務で古くから存在するプロジェクトのリポジトリを触ったことがある方はきっと経験があるかと思います。

git cloneを実行するのは最初のセットアップ時だけなのであまり問題にならないと思われるかもしれませんが、実は1日に何度もgit cloneを行う環境もあります。そう、CI/CDの環境ですね。

CircleCIやGitHub Actionsといった一般的なCIサービスでは、毎回まっさらな環境にリポジトリをgit cloneするところからスタートします。巨大リポジトリではこの1ステップ目のgit cloneだけで数分かかってしまうため、単にLintを行うだけのジョブであってもすぐに結果が返ってこないという状況になります。

gitでは仮にファイルを削除しても昔のコミットから復元可能です。つまり、過去全ての情報が保存されているためリポジトリは歴史と共に太っていく一方であり、改善の見込みもありません1

ところで、git cloneは知らない人がいないほどポピュラーなコマンドですが、実はオプションが豊富に存在することはご存知でしょうか?手元でman git-cloneを実行してみください。きっと知らないオプションがたくさんあり驚くと思います。オプションの中には不要な過去のコミット、ブランチ、ファイルなどを取得しないことでgit cloneを効率的にできるものがありますので、今回はこれらを活用するテクニックをお伝えします。

git cloneの各種オプション

オプションなし

この後に各種オプションを駆使したgit cloneを紹介するのですが、まずは比較のために何もオプションを付けないときの実行時間と、checkout後のリポジトリのサイズを計測しておきます。実験に使用するリポジトリには、歴史がとても長いgit/gitを採用しました。

$ time git clone git@github.com:git/git.git
Cloning into 'git'...
remote: Enumerating objects: 279, done.
remote: Counting objects: 100% (279/279), done.
remote: Compressing objects: 100% (99/99), done.
remote: Total 298430 (delta 193), reused 248 (delta 180), pack-reused 298151
Receiving objects: 100% (298430/298430), 148.71 MiB | 10.06 MiB/s, done.
Resolving deltas: 100% (222617/222617), done.
Updating files: 100% (3866/3866), done.

real    0m27.057s
user    0m14.959s
sys 0m3.038s

$ du -sh git git/.git
214M    git
169M    git/.git

時間についてはインターネット回線、時間帯などに依存するためあくまで参考程度にしてください。時間に加えてcheckout後のリポジトリそのものと、昔のコミットやgitのオブジェクトなどの情報を全て格納している.gitだけのサイズを計測しています。

これを基準とし、どこまで改善できるのかを見ていきましょう。

depth

まずは知ってる人も多いであろうdepthです。depthを指定すると、その数のコミットの歴史だけに限定したデータを取得します。これをshallow cloneと呼ぶことがあり、gitやCIサービスのドキュメントなどにも頻出しますので、shallow = depthなどのオプションによって歴史を省略したcheckoutと覚えておくとよいです。

過去のコミット分の情報が不要ということは、昔のファイルの状態を記録しているGitオブジェクトが軽くて済むということです。2

ではどれほど効果があるのか試してみましょう。

$ time git clone --depth=1 git@github.com:git/git.git git_depth1
Cloning into 'git_depth1'...
remote: Enumerating objects: 3960, done.
remote: Counting objects: 100% (3960/3960), done.
remote: Compressing objects: 100% (3506/3506), done.
remote: Total 3960 (delta 356), reused 1917 (delta 287), pack-reused 0
Receiving objects: 100% (3960/3960), 9.30 MiB | 7.25 MiB/s, done.
Resolving deltas: 100% (356/356), done.
Updating files: 100% (3866/3866), done.

real    0m11.401s
user    0m0.466s
sys 0m0.862s

$ du -sh git_depth1/ git_depth1/.git
 56M    git_depth1/
 11M    git_depth1/.git

(繰り返しになりますが、git cloneにかかる時間自体は様々な要因によって安定しない可能性が高いので、あくまで参考にしてください)

--depth=1を指定すると、指定したブランチ(指定しない場合はデフォルトブランチ)の最後のコミットだけを取得します。オプションを何も付けなかった場合と比較して、時間は1/2以下、リポジトリの容量は約1/4で.gitだけに限定すれば約1/15程度になりました。時間に関しては誤差があるとはいえかなり改善できました。

代わりに、depth付きでgit cloneしたリポジトリではgit logをしても過去のコミットを見ることはできませんし、昔のコードをcheckoutもできません。とりあえず最新のリポジトリの状態でビルドしたいというCIなどの用途においては過去のコミットの情報は不要なので、メリットだけを享受できるでしょう。

single-branch

git cloneについて調べるとdepthの次に目にすることが多いのはsingle-branchではないでしょうか。その名の通り、単一のブランチだけの情報を取得するので通常よりは取得するデータ量が減ります。しかし、depthとは異なり指定したブランチの歴史は全て取得するので、削減量としてはdepthには及ばないはずです。実際に試してみましょう。

$ time git clone -b master --single-branch git@github.com:git/git.git git_single_branch
Cloning into 'git_single_branch'...
remote: Enumerating objects: 832, done.
remote: Counting objects: 100% (826/826), done.
remote: Compressing objects: 100% (804/804), done.
remote: Total 289165 (delta 28), reused 814 (delta 22), pack-reused 288339
Receiving objects: 100% (289165/289165), 143.57 MiB | 9.93 MiB/s, done.
Resolving deltas: 100% (216511/216511), done.
Updating files: 100% (3866/3866), done.

real    0m25.670s
user    0m13.348s
sys 0m2.899s

$ du -sh git_single_branch/ git_single_branch/.git
201M    git_single_branch/
155M    git_single_branch/.git

期待通りの結果になりました。通常よりは多少軽くなりましたが、depth=1には及びません。single-branchを使う場合の注意点としては、今後fetchやpullをした場合でもsingle-branchで指定したブランチ以外のリモートブランチは取得されないことです。つまり、pull-requestが出ているブランチをcheckoutしてエディタ上でコードレビューする、みたいな使い方はできなくなります。

この挙動になる理由はfetchのrefspecを見てもらうと分かります。各オプションでgit cloneしてきたリポジトリで比較してみます。

$ git config --get remote.origin.fetch

# 通常
+refs/heads/*:refs/remotes/origin/*

# depth=1
+refs/heads/master:refs/remotes/origin/master

# -b master --single-branch
+refs/heads/master:refs/remotes/origin/master

refspecを解説するとそれだけで1つ記事が書けてしまうので今回は省略しますが、depthsingle-branchのどちらもこのrefspecによって取得するブランチをmasterだけに限定していることが高速化3のポイントです。refspecについてもっと深く知りたい方はドキュメントを見てみましょう。

補足:depth=1single-branchの併用について

git cloneの高速化でよく目にする例として、git clone --depth=1 -b master --single-branchのように2つのオプションを併用するものですが、これは高速化の観点では意味はないはずです。なぜならdepthを指定している時点で取得するコミットの歴史は制限されていますし、先ほど示したようにrefspecも特定のブランチに限定されているためです。

shallow-since

これは少しマイナーなオプションですがdepthの日付版だと思ってください。depthではコミットの数で限定しましたが、shallow-sinceはある時点以降のコミットに限定して取得できます。CIの用途ではdepthで十分だと思いますが、例えば歴史が長いリポジトリにおいてある時期に全体的に書き換えてv2にしたのでもう昔のv1時代のコミットはgit logで見ることもない、というケースなどで自分のマシンにcloneする場合は便利かもしれません。

$ time git clone --shallow-since="2020/01/01" git@github.com:git/git.git git_shallow_since1
Cloning into 'git_shallow_since1'...
remote: Enumerating objects: 27410, done.
remote: Counting objects: 100% (27410/27410), done.
remote: Compressing objects: 100% (12816/12816), done.
remote: Total 27410 (delta 19562), reused 20870 (delta 14326), pack-reused 0
Receiving objects: 100% (27410/27410), 26.83 MiB | 9.33 MiB/s, done.
Resolving deltas: 100% (19562/19562), done.
Updating files: 100% (3866/3866), done.

real    0m17.672s
user    0m2.304s
sys 0m1.225s

$ du -sh git_shallow_since1/ git_shallow_since1/.git
 74M    git_shallow_since1/
 28M    git_shallow_since1/.git

本当に2020/01/01以降のコミットだけを取得しているのか調べてみましょう。結果に1つだけ2019-12-31が含まれていますが、おそらくタイムゾーン周りをあまり厳密に考慮しなかったからだと思われます。

$ git log --format="%H %as" | tail
0d2116c6441079a5a1091e4cf152fd9d5fa9811b 2020-01-05
9d48668cd51220f1d8b83c7e1aa65416520aeee6 2020-01-04
3a05aacddd8e3d65ba7988dc6e4f8a88bc4e3320 2020-01-04
4c5081614c0fa5a9060ae294cc4364f990254f04 2020-01-03
5bb457409c15d221ee38240f83362b3c6fd96421 2020-01-03
63020f175fe26f3250ac8d19d02ef9ee271006e5 2020-01-02
224c7d70fa14ed44d8e7e3ce1e165e05b7b23725 2019-12-31
9c8a294a1ae1335511475db9c0eb8841c0ec9738 2020-01-02
763a59e71cee1542665e640f63141a0bf89e6381 2020-01-01
44143583b76decf93c55b73adaf2367c22c88998 2019-12-31

sparse-checkout

ここまでは過去のコミット、つまり時間方向へのデータ量削減でした。別の方向としてcheckoutするファイルを限定するということも可能です。

sparse-checkoutによって、巨大なリポジトリの中から必要なファイルとそれに関係するコミットだけを取得できます。例えば、複数のプロジェクトが1つのリポジトリに同居するmonorepo構成の場合に、自分の作業に関係するプロジェクトだけをcheckoutできれば十分というケースで重宝します。

これを実現するためのgit sparse-checkoutというコマンドがv2.25.0から追加されたようなのですが、v2.29.0時点ではまだEXPERIMENTALとのことなのでここでは昔ながらの方法を使います。

# 自前でディレクトリを作って空のリポジトリを作るところからスタート
$ mkdir git_sparse
$ cd git_sparse
$ git init
$ git remote add origin git@github.com:git/git.git

# git/gitのDocumentationディレクトリのみをcheckoutするように設定
$ git config core.sparsecheckout true
$ echo "Documentation" > .git/info/sparse-checkout

# ここで初めてデータを取得しにいく
$ time git fetch --depth=1 origin master
remote: Enumerating objects: 3960, done.
remote: Counting objects: 100% (3960/3960), done.
remote: Compressing objects: 100% (3506/3506), done.
remote: Total 3960 (delta 356), reused 1918 (delta 287), pack-reused 0
Receiving objects: 100% (3960/3960), 9.30 MiB | 7.35 MiB/s, done.
Resolving deltas: 100% (356/356), done.
From github.com:git/git
 * branch            master     -> FETCH_HEAD
 * [new branch]      master     -> origin/master

real    0m11.582s
user    0m0.259s
sys 0m0.126s

# `git init`で作られたmasterブランチとfetchしたorigin/masterは全く別物なので、masterを上書きして完成
$ git reset --hard origin/master

$ du -sh git_sparse/ git_sparse/.git
 16M    git_sparse/
 10M    git_sparse/.git

git cloneが裏で行っている手順を手動で行っているので手間がかかりますが、こうして出来上がったリポジトリはgit/gitのDocumentationディレクトリだけがcheckoutされた状態なのでとても軽くなりました。

git cloneのオプションまとめ

ここまでの結果をまとめるとこのようになります。

オプション 時間(timeのreal) checkout後のサイズ .gitのサイズ
なし 0m27s 214M 169M
--depth=1 0m11s 56M 11M
-b master --single-branch 0m25s 201M 155M
--shallow-since="2020/01/01" 0m17s 74M 28M
sparse-checkout + depth=1 0m11s 16M 10M

実行時間に関してはあくまで参考値ですが、depthを付けると相当に改善が見込めそうです。checkout後のリポジトリのサイズは、当然ですがsparse-checkoutによってファイルを限定できれば無駄な容量を削減できることが分かるかと思います。

submodule

ここまではgit cloneによる1つのリポジトリのケースを見てきましたが、実際の業務ではsubmoduleをたくさん抱えているリポジトリに出会うこともあるかと思います。git cloneにはcloneと同時にsubmoduleを取得し、さらにそのときの振る舞いを制御するオプションも存在します。

歴史あるOSSをいくつか調べてみたのですがサンプルになるようなsubmoduleをたくさん抱えているリポジトリが見つからなかったため、社内のリポジトリを使って実験しました。そのため、詳細なコマンドは省いて結果だけを示します。

オプション 時間(timeのreal) checkout後のサイズ .gitのサイズ
なし(--recurse-submodules 3m15s 4.2G 2.7G
--depth=1 --recurse-submodules --shallow-submodules 2m2s 2.5G 1.0G
--depth=1 --recurse-submodules --shallow-submodules -j4 1m57s 2.5G 1.0G

--recurse-submodulesgit submodule update --init --recursiveと同じ処理をgit cloneと同時にするオプションです。git cloneだけでsubmoudleのセットアップも完了するので便利です。

--shallow-submodulesを付けるとsubmoduleのリポジトリがdepth=1git cloneされます。submoduleのリポジトリについて過去のコミットが不要であれば速度の向上、少なくともリポジトリのサイズを減らすことができます。

-j4(--jobs=4)はドキュメントによるとsubmoduleのfetchを同時に行う数とのことです。個人的には4並列にすることでもう少し時間が短縮されることを期待していたのですが、ほぼ変化はありませんでした。

今回実験したリポジトリはsubmoduleの数自体は多いのですが、一部のsubmoduleだけが突出してサイズが大きいという構成上、並列化の恩恵を受けにくかったのかもしれません。あるいは社内ネットワークに由来する何かがボトルネックだった可能性もありますが、今回の本題から外れてしまうので深く調査は行いませんでした。

各CIサービスの対応状況

git cloneの各オプションの効果について理解できたと思いますので、実際のCIサービスでこれらが活用できるのかを見ていきましょう。

なお、調査したのは執筆時点の2020/12です。各サービスとも日進月歩で更新されていますし、フォーラム等で機能要望を受け付けているので将来的には改善されているかもしれません。

CircleCI

CircleCIでは何も意識せずに最初のステップでcheckoutしていると思いますが、実はこの裏ではdepthオプション無しでgit cloneが実行されているため過去の全コミットが取得されています。これはCircleCIのログでCheckout codeのステップのログから確認できます。

CircleCIのサポートからは以下のページにて、checkoutの代わりに自前で--depth--shallow-sinceオプション付きでgit cloneするか、そのような機能を提供しているOrbsの利用が提案されています。

Shallow Repository Cloning

他に探したところ、30GBを超える超巨大なリポジトリをdepthsparse checkoutで対応した事例もあるようです。

巨大なリポジトリのJenkinsからCircleCIへの移行においてshallow cloneとsparse checkoutで前処理を高速化する

Bitrise

Bitriseのsteps-git-cloneはデフォルトではdepthのオプションが有効になりません。ですが、clone_depthというオプションを設定するとdepthオプション付きでcloneされるようになります。

https://github.com/bitrise-steplib/steps-git-clone/blob/b1cbe45b5704863c4c930111edeb7aa67d38f5c1/step.yml#L101-L107

sparse checkoutや、submoduleへのdepthの対応は自分がコードを読んだ限りでは無いように見えました。それらの対応が必要な場合は自分でgit cloneのコマンドを書く必要があります。

GitHub Actions

GitHub Actionsのactions/checkoutは、v2からデフォルトでdepth=1のオプションが有効になっています。逆にdepth無しでcloneが必要な場合はdepth: 0を指定するようにとREADMEに書いてあります。

submoduleについてはREADMEに詳細は書かれていませんでしたが、fetch-depthを指定するとsubmoduleを取得するときにもdepthが有効になりそうです。(v2.3.4のコードより)

https://github.com/actions/checkout/blob/v2.3.4/src/git-command-manager.ts#L317

sparse checkoutはサポートされていないようでしたので、必要な場合は自分で一連の処理を書く必要があります。

まとめ

大規模リポジトリでgit cloneするときのニッチなテクニックを紹介しました。

業務のリポジトリは何年も生き続けて肥大化していく一方なので、CIにおける最初のgit cloneにいつの間にか数分かかってしまうということも珍しくありません。そこはCIサービス側がいい感じに対応してくれていると思いきや、意外とそんなことはなかったりします

git cloneのオプションについて理解しておくことで、CIサービス側が用意している機能にオプションを渡すだけで改善の余地があるのかどうかを判断できるようになります。最悪、用意されている機能には頼らずに自分でgit cloneのコマンドを書くことでCIの実行時間を短縮できる可能性があります。

git cloneのオプションは他にもたくさん存在しており、なかなか奥深いのでman git-cloneを実行するか、Webのドキュメントをぜひ一読してみてください。

宣伝

DeNAでは今年、以下の3種のアドベントカレンダーを書いてます!それぞれ違った種類なのでぜひ見てみてください。

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければTwitterやfacebook、はてなブックマークにてコメントをお願いします。

また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!


  1. コミットの歴史を修正してファイル自体がなかったように歴史を改変してしまう場合はその限りではないですが、相当に難しい作業です。

  2. .git/objectsの中身について興味がある方は10.2 Gitの内側 - Gitオブジェクトが参考になるでしょう。

  3. このrefspecについて理解できるとPinterestで1行追加しただけでgit cloneが99%高速化されたという理由が分かります。