DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

時間を加味したモデリング

こんにちは、SWETの鈴木穂高(@hoddy3190)です。

現在SWETチームにて仕様の欠陥をなるべく早くみつける取り組みにチャレンジしています。 欠陥をみつけるタイミングが早ければ早いほど、開発中の手戻りに伴うコストを抑えられます。 たとえば、仕様作成フェーズ、実装フェーズ、QAフェーズの順で開発が進んでいくときに、仕様の欠陥が実装フェーズやQAフェーズでみつかると実装やQAのやり直しを引き起こしかねません。 そうした大きな手戻りを抑えるために仕様の欠陥をなるべく仕様作成フェーズでみつけることを目指します。

対象領域に出てくる要素をモデリングすることは、仕様に潜む欠陥を開発の早い段階でみつけるための、有効な手段のひとつです。 要素には、開発者がこれから作るシステムや、そのシステムのユーザー、そのシステムと直接的または間接的に相互作用する外部のシステムが含まれます。 単に図を書くというモデリングの他に、プログラムのように動くモデルを作るという方法もあります。動くモデルを作るとモデリングする過程での気づきや、モデルを動かしてみた時の気づきが後工程での手戻りを減らすことにつながります。

以前私が書いた「仕様記述テクニック『Promotion』の紹介」ではAlloyを使っていましたが、 本記事ではプログラミング言語OCamlをモデリング言語として使って時間を加味したモデルを記述する方法を紹介します。 多くのモデリング対象の振る舞いは時間が関係するため、モデルの記述で時間を扱えると表現の幅が広がります。 なお、OCamlの説明はしません。

OCamlでのモデリング

今回はプロセスをモデリングします。プロセスは対象領域の要素の振る舞いを指す用語として用います。 モデリングのやり方はさまざまあります。今回は対象のプロセスをプロセスが取りうる状態と遷移で表現します。 プロセスの型は次のようにします:

type ('ev, 'ch, 'state) process =
  'state -> (('ev, 'ch, 'state) trans) list

process型で求められる状態の型は次のようにします:

type state =
  | State_A
  | State_B of int

状態変数はコンストラクタの引数を使って表現します。

遷移ラベルには、イベント同期、受信、τを用意します。ここでτという遷移ラベルのついた遷移は、プロセス内部で自動的に遷移します。 遷移の型は次のようにします:

type ('ev, 'ch, 'state) trans =
  | Tau of 'state
  | Ev of ('ev * 'state)
  | Recv of ('ch * ('state, 'ev) guard * ('ev -> 'state))

コンストラクタTauはτを表します。 コンストラクタEvはイベント同期を表します。イベント同期は、チャネルを通じて値をやりとりします。 コンストラクタRecvは受信を表します。受信は、チャネルを通じて値を受け取ります。受信の場合、事後状態は受信した値によって決まります。ガードを満たすかどうかも受信した値によって決まります。 受信によって起きるイベントは、受信した値を使ってイベント同期で表します。

trans型で求められるイベントの型は次のようにします:

type ev =
  | Event_A
  | Ch1 of int

イベント型に呼応してチャネル型を定義します。イベントが属するコンストラクタを表すための型です。 OCamlでは引数をとるコンストラクタそのものを値として使うことができないので、このような構成にしています。 trans型で求められるチャネルの型は次のようにします:

type ch =
  | Ch_Ch1
  | Ch_Ch2

ここまでを踏まえて次の状態遷移図で表されるATMをモデリングしてみましょう。

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

ATMの振る舞いは、カードを受け取り(in)、PINナンバーを受け取り(pin)、PINナンバーをチェックし(check)、引き出す額を受け取り(req)、額分のお金を出し(dispense)、カードを出します(out)。

これをモデリングすると次のようになります:

let atm_process : (ev, ch, atm_state) process = fun s ->
  match s with
  | A_S1 vals ->
      [
        (* in?c *)
        Recv
          ( Ch_In,
            (fun _ _ -> true),
            fun ev ->
              let vals' = make_vals'_1 vals ev in
              A_S2 vals' );
      ]
  | A_S2 vals ->
      [
        (* pin?p *)
        Recv
          ( Ch_Pin,
            (fun _ _ -> true),
            fun ev ->
              let vals' = make_vals'_2 vals ev in
              A_S3 vals' );
      ]
  | A_S3 vals ->
      let vals' = make_vals'_3 vals in
      [ (* check *) Ev (Check, A_S4 vals') ]
  | A_S4 vals ->
      [
        (* req?n *)
        Recv
          ( Ch_Req,
            (fun _ _ -> true),
            fun ev ->
              let vals' = make_vals'_4 vals ev in
              A_S5 vals' );
      ]
  | A_S5 vals ->
      let vals' = make_vals'_5 vals in
      [ (* dispense!n *) Ev (Dispense vals.req, A_S6 vals') ]
  | A_S6 vals ->
      let vals' = make_vals'_6 vals in
      [ (* out!c *) Ev (Out (get_card vals), A_S1 vals') ]

時間遷移の追加

次に、物理的な時間経過を表現できるように表現の枠組みを拡大します。ここでは時間オートマトンという理論にある考え方を使います。 時間オートマトンにおける状態は、ロケーションと時間代入関数の組で表現します。 ロケーションは、今まで「状態」と呼んでいたものです。時間代入関数はクロック変数と時間をマッピングしたものです。クロックは経過した時間を表します。 遷移には2種類あり、あるロケーションから別のロケーションに遷移する離散遷移と、時間経過による時間遷移があります。

時間を表す型を用意します:

type datetime = int

クロック変数の型を用意します:

type clock = T

時間制約式は、時間に関する条件を表した式です。ある時刻までに遷移するといった遷移の条件や、ある時刻を越えてこの状態に留まることはできないといった状態が満たすべき条件を表すのに用いられます。時間制約式は次のようにします:

type ('clock) cons =
  | True
  | Eq of 'clock * datetime
  | Le of 'clock * datetime
  | Ge of 'clock * datetime
  | Lt of 'clock * datetime
  | Gt of 'clock * datetime
  | Not of 'clock cons
  | Or of 'clock cons * 'clock cons
  | And of 'clock cons * 'clock cons

時間代入関数は次のようにします:

type 'clock v = ('clock, datetime) Hashtbl.t

時間代入関数は時間遷移や、クロック変数のリセットを伴う離散遷移で更新されます。

不変述語は状態が満たす時間制約です。不変述語は次のようにします:

type ('state, 'clock) inv = 'state -> ('clock cons)

遷移に時間制約を与えられるようにしたいので、遷移する際に満たすべき時間制約とリセット対象のクロック変数を指定できるようプロセスの型を次のように変更します:

type ('ev, 'ch, 'state, 'clock) process =
  'state ->
  'clock v ->
  ( 'clock cons * (* 遷移する際に満たすべき時間制約式 *)
    'clock list * (* リセットするクロック変数 *)
    ('ev, 'ch, 'state, 'clock) trans ) list

遷移も合わせて変更します:

type ('ev, 'ch, 'state, 'clock) trans =
  | Tau of 'state
  | Ev of ('ev * 'state)
  | Recv of ('ch * ('state, 'clock, 'ev) guard * ('ev -> 'state))

ATMの例(モデリング)

ここまでを踏まえてさきほどのATMの振る舞いに変更を加えたものをモデリングしてみましょう。

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

pinの受け取りに時間が30かかる場合はカードを出す振る舞いを加えました。 これをモデリングすると次のようになります:

let atm_process (ev, ch, atm_state, clock) process = fun s v ->
  match s with
  | A_S1 vals ->
      [
        ( True,
          [ T ],
          (* in?c *)
          Recv
            ( Ch_In,
              (fun _ _ _ -> true),
              fun ev ->
                let vals' = make_vals'_1 vals ev in
                A_S2 vals' ) );
      ]
  | A_S2 vals ->
      [
        ( Lt (T, 30),
          [],
          (* pin?p *)
          Recv
            ( Ch_Pin,
              (fun _ _ _ -> true),
              fun ev ->
                let vals' = make_vals'_2 vals ev in
                A_S3 vals' ) );
        (Eq (T, 30), [], Ev (Out (get_card vals), A_S1 vals));
      ]
  | A_S3 vals ->
      let vals' = make_vals'_3 vals in
      [ (True, [], (* check *) Ev (Check, A_S4 vals')) ]
  | A_S4 vals ->
      [
        ( True,
          [],
          (* req?n *)
          Recv
            ( Ch_Req,
              (fun _ _ _ -> true),
              fun ev ->
                let vals' = make_vals'_4 vals ev in
                A_S5 vals' ) );
      ]
  | A_S5 vals ->
      let vals' = make_vals'_5 vals in
      [ (True, [], (* dispense!n *) Ev (Dispense vals.req, A_S6 vals')) ]
  | A_S6 vals ->
      let vals' = make_vals'_6 vals in
      [ (True, [], (* out!c *) Ev (Out (get_card vals), A_S1 vals')) ]

モデリングしたプロセスをエンジンを使ってREPL上で動かしてみます。 エンジンは、どのイベントが起こるとどの状態になるのかを可視化します。 ひとつ前の状態に戻ったり、遷移の履歴をダンプしたりもできます。 不変述語を満たしているかのチェックもエンジン側で行っています。

f:id:swet-blog:20220324180234g:plain

メニュー[0]を選ぶと時間遷移をします。 メニュー[1]を選ぶとたどってきた状態とイベントをダンプします。 メニュー[2]を選ぶとひとつ前の状態に戻ります(undo)。 メニュー[3]を選ぶとundoで戻った操作をやり直します(redo)。 メニュー[4]以降を選ぶと離散遷移をします。

状態遷移図を見ながら意図どおりに動いているかチェックします。 上の図では、初期状態A_S1から、メニュー[4]を選択していき、A_S2、A_S3、A_S4、A_S5、A_S6、A_S1と遷移することを確認しています。 その後、A_S1からA_S2に遷移し、メニュー[0]を選択して時間を30進めた後、A_S1に遷移することを確認しています。

並行合成

一般にシステムはユーザーや外部ソフトウェアなどと相互作用をもちます。 しかし、相互作用をもつ複数の要素から構成されるシステムを欠陥なく設計し実装することは難しいものです。 複数の要素から構成されるシステムをモデリングして開発の早い段階で先のように動かしてみることは、手戻りを減らすことに寄与することが期待されます。

システムを動かすためには、まず相互作用する複数のプロセスをそれぞれモデリングします。そしてそれらを合成します。合成は、複数の振る舞いを組み合わせてできたシステムの振る舞いを決定します。 並行合成は、合成する2つのプロセスそれぞれで発生する遷移のうち、同期させたい遷移を指定して合成します。指定された遷移以外の遷移は独立して起こります。

2つのプロセスを受け取り、並行合成したプロセスを返す関数の型を次のように決めます:

val composit:
  ?sync_set_by_ch:('ch -> bool) ->
  ?eq_ev:('ev -> 'ev -> bool) ->
  ('ev, 'ch, 'p_state, 'clock) process (* プロセスP *) ->
  ('ev, 'ch, 'q_state, 'clock) process (* プロセスQ *) ->
  ('ev -> 'ch) (* 対応チャネル取得関数 *) -> 
  ('ev, 'ch, 'p_state * 'q_state, 'clock) process

プロセスPとプロセスQは合成対象のプロセスです。合成されたプロセスがとりうる状態は、プロセスPがとりうる状態とプロセスQがとりうる状態の組になります。合成されたプロセスがとりうる状態はp_stateとq_stateのタプルで表現します。 対応チャネル取得関数は、与えられたイベントが属するチャネルを返します。 sync_set_by_chで同期イベント集合を表現します。同期イベント集合には、合成する2つのプロセスの相互作用に関係するイベントを指定します。たとえば2つのプロセス間でチャネルを通じて値を送受信する場合、値のやりとりをするイベントを指定します。チャネルを受け取ってtrueを返す場合、そのチャネルに属するイベントはすべて同期イベント集合に含まれることを意味します。 eq_evは受け取った2つのイベント同期を比較し等しいかどうかを判定する関数です。2つのイベント同期が同期イベント集合に含まれているとき同期するのかどうかを判定するのに使います。

ATMの例(並行合成)

さきほどのATMのモデルは変えずに、新たにユーザーの振る舞いをモデリングして、ATMと並行合成します。

ユーザーの振る舞いを次のように定めます: f:id:swet-blog:20220324180131p:plain

さきほどのcomposit関数を用いてATMの振る舞いとユーザーの振る舞いを並行合成したものを動かしてみます。 同期イベント集合は次のようにします:

let sync_set_by_ch ch =
match ch with
| Ch_In -> true
| Ch_Pin -> true
| Ch_Req -> true
| Ch_Dispense -> true
| Ch_Out -> true
| _ -> false

ユーザーとATMを合成した振る舞いの状態遷移図は次のようになります: f:id:swet-blog:20220324180045p:plain

初期状態を(U_S1, A_S1)にして動かしてみます。

f:id:swet-blog:20220324182633g:plain

まとめ

調べたい部分に限定したモデリングは実システムの実装よりもずっと小さなコストでできますから、実装が終わって調べていた振る舞いを、仕様作成の段階で調べることができます。 ここで誤りをみつけることができれば手戻り避けることができるので、開発時間を大きく短縮できます。 動くモデルを作って動かせば、仕様書を読んだだけだと気づけなかった仕様の欠陥にも気づくことが期待できます。 また設計上の選択についてさまざまな可能性を実験的に調べるといったことも可能になります。そうすることでよりよいプロダクトやサービスを開発できる可能性が高まると考えています。

参考

この1年すすめていた「プロジェクトの健康状態の可視化と予防」と「自動テストの適用範囲の拡大」という施策についての話

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

早いもので2021年度もとうとう終わりをむかえようとしています。 ふりかえりということで、ここ1年ほどの間に私も関わって進めていた次の2つの施策についてかんたんに紹介したいと思います。

  • プロジェクトの健康状態の可視化と予防(dev-vital)
  • 自動テストの適用範囲の拡大

今回紹介するこれらの施策は、SWETメンバーの今までの経験などを元に議論した中で出てきた課題から決めています。

プロジェクトの健康状態の可視化と予防(dev-vital)

私がSWETに所属してある程度の期間がたちますが、いろいろなプロジェクトに関わってきました。 その中で感じたのは、あるプロジェクトで出会った課題は他のプロジェクトでも起きていたりするということです。

今までのSWETの取り組みはプロジェクトですでに起きた課題に対してアプローチをとることが一般的でした。 たとえば、すでにテスタビリティがよくないプロダクトをリファクタしてテストをかけるようにするとか、CI/CDサービスが動いていない状態を直すといったようなところからです。

プロジェクトの早い段階からSWETのメンバーが関わっていれば、上記のようなことをあとから行わずにすんでいたかもしれません。 しかし、弊社のプロジェクトの数・規模感・スピードなどを考えるとすべてに関わるのは無理があります。

そこでどうすれば、このようなことが起きないかについて検討をしました。 その中で出てきたのが、次のような仮説です。

  • プロジェクトにおけるある指標が他の指標に影響をあたえて、最終的にユーザーに見えるレベルでの問題となるのではないか?
  • 影響をあたえる指標の関係性がわかれば、将来起こりうる問題の予兆がわかりその結果として予防ができるのではないか?
  • そのようなプロジェクトの健康状態といえるような指標を可視化できればよいのではないか?

この仮説をもとに「プロジェクトの健康状態の可視化と予防」という施策を2021年度からはじめました。 この施策を「dev-vital」と呼んでいます。

この施策では、まずプロジェクトの健康状態に必要と考えられる指標がどういったものかについてメンバーで検討を何度もおこないました。 そして決めた指標をプロジェクトからとれるようにプロセスを整え、集めた情報を保存できるようにして可視化をする必要があります。

そこで、開発プロセスの中で蓄積される次のような3種類の情報をまず可視化できるように整備を進めました。 すべてのプロジェクトで一度におこなうのは困難であるため、まずは特定の1つのプロジェクトをターゲットとして進めました。

  • (1)CI/CDサービスから得られる「ビルド時間」や「ビルドの成功・失敗率」「テストケース数」
  • (2)GitHubから得られる「エンジニア数」や「PRなどの情報」
  • (3)不具合チケット(JIRAチケット)から得られる「不具合数」や「クローズされるまでの時間」

なお取得するための情報元として上記に紐づくのもありますが「アプリ、サーバのリリース頻度」なども取得しています。

CI/CDサービスからの情報

(1)の情報を可視化するためにメンバーが開発しているCI Analyzerを活用しています。

このツールについては「CI/CD Conference 2021」で登壇をしていますので次を見ていただければと思います。

データを得るためにもCI/CDサービスを活用してPR時などをふくめビルドを継続的におこなえるような環境であることが望ましいです。 そもそもCI/CDサービスが動いてなければ、コードに問題があるのかどうかすらわかりません。

その一環も含めて、弊社の「Pococha」というサービスでCI/CDサービスを活用できるようにいかに開発に組み込んでいくかという次のような改善を進めました。

また、CI Analyzerでテストケース結果を確認できるようにCI/CDサービスの成果物としてJUnit.xml形式のファイルが保存されている必要があります。 それらの対応も併せておこなっています。

これにより、CI/CDサービスからいろいろな情報が得られるようになっています。

GitHubからの情報

(2)の情報はGitHubのGraphQL APIを活用することで得ることができます。

データを得るためにはPR時に記載する情報の整理ができていることが望ましいです。 PRには「誰が担当したのか(assignees)」「どのマイルストーンでリリースするのか(milestone)」「どのような対応なのか(label)」などいろいろな情報を含めることができます。

これらの情報はPRを出した人自身で入力することが望ましいもののどうしても人によって入力内容に差がでてしまいます。 そこで次を活用しました。

  • PR Templateの活用
  • Dangerの利用による自動設定
  • Dangerの利用による未入力箇所の指摘

これらにより、PRからの情報はある程度統一されるようになり、データの取得ができるようになりました。

JIRAからの情報

(3)は不具合チケット(JIRAチケット)の活用です。

検証を担当する品質管理部のメンバーがいる場合、検証時に不具合があればJIRAに不具合チケットを起票するようになっています。

今までも、一部のプロジェクトではこの不具合チケットのデータはまとめてBigQueryに保存してデータポータルを使って可視化していました。 今回ターゲットとしたプロジェクトにおいては、その整備ができていなかったこともあり、保存し可視化できるようにしました。

dev-vitalの現時点のまとめ

このようにいろいろと情報を取れるようにするにはプロセス含め整えることが必要でした。 その上で、データを保存できるようにし可視化をすすめました。

今は上述したようないくつかの情報について可視化できるようになっています。 この可視化された情報からだけでもある程度の情報が得られる状態ではあります。

しかし、当初かかげている仮説は次のようにそれぞれの指標が影響を与えているというものです。 「プロジェクトにおける指標が他の指標に影響をあたえる」

一歩目をあるき出したとはいえ、この仮説に対して検証ができるフェーズについてはこれからというところになります。

今まで話したようなこの1年間でおこなえたことについては次の勉強会でメンバーが登壇をしますので是非聴講してもらえればと思います。

自動テストの適用範囲の拡大

SWETは今までいろいろな自動テストを実装し、運用してきました。 ユニットテストといったフィードバックの早い自動テストは重要ですし、全社的な教育や事業部との連携などは次のブログにあるように進めてきました。

これらは今後も続けていきますが、ユニットテストよりも結合度の高い自動テストも重要です。 SWETが取り組んできた自動テストとしては、次のようなものがあります。

  • (1)パフォーマンステスト
  • (2)スクリーンショットテスト

パフォーマンステスト

1つ目のパフォーマンステストは「Pococha」のiOSアプリで取り組んできました。

Pocochaはライブ配信サービスであり「パフォーマンス」という観点は非常に重要です。 たんに機能が動けばいいわけではなく、高負荷時においてもアプリがユーザーにとって問題なく使えることが求められます。

取り組みをはじめた時点では、パフォーマンス計測自体が実施できていませんでした。 そこでまず、パフォーマンスを計測できるようにして、それを継続的に確認できる状態にする必要がありました。

そのために、パフォーマンス計測を自動でおこなえるようにしました。 このパフォーマンス計測周りについてはPocochaでおこなった事例として次のような記事を書いています。 この記事時点ではおおがかりな基盤にはなっていますが、この時点ではこのぐらいの基盤を作って計測する必要性がありました。

そして、そこから年月がたって必要なことはかわってきてこの基盤についても見直しをすすめています。

パフォーマンス計測がどのレベルで必要かはプロダクトの性質やそのときの状況によって異なります。 しかし一切関係ないというプロダクトはないともいえます。

今では、パフォーマンスに関してはiOS、Androidともにプラットフォーム側がいろいろと機能を提供してくれるようになってきています。 たとえばiOS側では、MetricKitXcode OrganizerなどがWWDCで発表されています。 Android側では、Android Vitalsといったものもあります。

これらのようなプラットフォームが提供する機能を活用することも重要です。

iOSアプリにおけるパフォーマンス計測についての話は2022/3/24に開催した「iOS Test TeaTime #4」でメンバーが登壇しました。

毎年のように変化があるので是非まとまったこの資料を見てもらえると嬉しいかぎりです。

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

2つ目がスクリーンショットテストです。 ここでいうスクリーンショットテストは、ある時点での画面をキャプチャしたものを検証に利用することを指しています。

スクリーンショットはいろいろなプロダクトで活用されています。 すぐにスクリーンショットがとれる状態になっていればよいですが、必ずしもそういうコードになっていないことはよくあります。 そこで、まずはスクリーンショットをとれるようにする必要があります。

Androidにおいて、それらについておこなったことについては2021年の「DeNA TechCon 2021」でメンバーが登壇しました。

この登壇から1年がたち、スクリーンショットの活用方法は広がってきました。 今、Pocochaでおこなっている活用方法については先日おこなわれた「DeNA TechCon 2022」でメンバーが登壇しました。

このようにいろいろな自動テストを活用できる場所で使えるようにしています。

おわりに

こんかいは2021年度におこなった施策についてかんたんに説明をしました。 こうして振り返ると、おこなったことについてはある程度アウトプットしていることがわかります。 今後もアウトプットしてくと思うので、是非ウォッチしてもらえればと思います。

さいごになりますが、私はこのたびSWETを卒業します。

入社してから、ずっとSWET(SWETがグループになる前から)としていろいろなことをおこなってきました。 そのすべてをブログにまとめようかと思いましたが、あまりにも長くなるので2021年度に私がかかわった施策についてかんたんに説明しました。

長い間、働けたのは今回説明した施策みたいな非常に魅力的な仕事に取り組めたことや関わってきたメンバーの魅力によると思っています。

SWETでは上述した施策以外にもいろいろなことをおこなっています。 次のサイトに本件以外の情報についても載せているので見てもらえればと思います。

非常に魅力的で面白い仕事が待っているので、是非とも興味がある方は次から応募していただければと思います。

DeNA TechCon 2022開催決定!今年もSWETメンバー登壇します!

SWETグループの井口(@hisa9chi)です。本記事では2022年3月に開催予定の弊社イベント DeNA TechCon 2022 に関してSWETグループ含め所属する品質管理部から5件の登壇が予定されております。

品質管理部ではDeNAのものづくりを支え、品質管理のスペシャリストとして、事業部とともにプロダクトの品質を作り込んでいっています。そのためにプロダクト開発の現場に深く入り込み、品質の担保とその改善活動を引っ張っていくことで、信頼性の高いプロダクトを素早く提供し続けます。さらに、日本のテスト業界を牽引していく存在となるために、標準化活動や高度なテスト技術を追求しており、得た技術やノウハウは内外問わず積極的に発信、共有しています。

そんな品質管理部の取り組みの一部を本イベントで発信しますので、是非とも皆様に聴講していただきたい思いで今回5件の登壇の見所を紹介させていただきます。

DeNA TechCon 2022 イベント情報

紹介セッション

今回、ご紹介するのは次の5つとなります。

Track 時間 タイトル 部門 登壇者
Talk C 16:00-16:30 CS×QAシナジー発揮!ユーザ体験向上ハンドブックのススメ QCグループ
CSチーム
柏倉 直樹(Naoki Kashiwagura)
小澤 直美(Naomi Ozawa)
Talk C 16:30-17:00 受身から攻めのQAへ!事業を成功に導くQAへの変革 QCグループ 前川 健二(Kenji Maekawa)
Talk B 17:30-18:30 Unity開発でのミスを未然に防ぐRoslynアナライザーのすゝめ SWETグループ 稲垣 和真(Kazuma Inagaki)
LT A 14:30-15:00 ゲーム開発のCI/CDを支えるJenkins運用 ~MacStadium 活用とディスク容量との闘い~ SWETグループ 井口 恒志(Hisashi Iguchi)
LT A 14:30-15:00 Pocochaにおけるスクリーンショットテストのレポート活用術 SWETグループ 外山 純生(Sumio Toyama)

CS×QAシナジー発揮!ユーザ体験向上ハンドブックのススメ

f:id:swet-blog:20220216173339j:plain

DeNAの品管は当たり前品質の担保だけではなく、サービスの価値を高めるための活動を積極的に実施しています。 たとえば、開発実装前に「本当にこの仕様でお客様が喜ぶのか?」の視点でレビューを実施するだけでなく、実装後の動作確認においても「この動きでお客様は満足するか?」を常に考えながらテストを実施し、企画・開発チームへ意見を伝えます。 この際に重要となるのが「QAメンバーのお客様目線をいかに強化するか」です。 今回のTechConでは、部門の枠を超えてCSチームと一緒に「お客様目線を強化する仕組み」を作った事例をお話しします。

受身から攻めのQAへ!事業を成功に導くQAへの変革

f:id:swet-blog:20220216173335j:plain

DeNAのヘルスケア事業領域では、開発上流での品質担保に課題があり、品質管理(テスト)にかかるコストが増加し、QCDバランスを保ったリリースが難しい領域でした。 そこで、サービスの企画が始まる上流工程からQAメンバーを参画させ、QA目線でのモノづくり強化策を実行してきました。 その結果、開発プロセス改善や開発品質向上だけでなくそのほかにも多くの成功を収めました。 今回はこれら成功事例をもとにテスト中心の受け身から、事業を成功に導くQA部隊への変革の歴史とともにリリースまでにQAがどのような活動を行なっているか紹介いたします。

Unity開発でのミスを未然に防ぐRoslynアナライザーのすゝめ

f:id:swet-blog:20220216173353j:plain

コードをビルドすることなく、ミスを検知できたらゲーム開発が楽になると思いませんか? 本セッションでは、.NETの静的解析器であるRoslynアナライザーのUnityプロジェクトへの導入と、カスタムルール実装のハードルを下げるためのノウハウを紹介いたします。 Roslynアナライザー活用のヒントとして役立ててくだされば幸いです。

ゲーム開発のCI/CDを支えるJenkins運用 ~MacStadium 活用とディスク容量との闘い~

オンプレmacやMacStadiumをビルドに活用して運用していると、どうしてもディスク容量不足という問題が発生します。 この問題は深刻であり、容量不足が発生するとビルドが途中で失敗してしまいます。 そのため、再ビルドや、ディスクの空き容量を増やすための調査と掃除という面倒な作業が増えてしまいます。 そこで我々がこのディスク容量不足問題に対して、どのように立ち向ったかをご紹介させていたきます。

Pocochaにおけるスクリーンショットテストのレポート活用術

ライブ配信サービスのPocochaでは、アプリのUIに関する問題の早期発見を目的として、自動でスクリーンショットを取得するテストを導入しています(参考:「Android スクリーンショットテスト 3つのプロダクトに導入する中で倒してきた課題」)。 その成果物となるスクリーンショットレポートの提供が始まると、当初想定していなかった活用アイデアが出てくるようなりました。

本LTでは、デザイナーやQAメンバーの課題解決にスクリーンショットレポートが活用された例をいくつか紹介します。スクリーンショットレポート活用のヒントとして役立てて下されば幸いです。

まとめ

今回は、SWETの所属する品質管理部による発表をご紹介させていただきましたが、他にもTechConではDeNAのさまざまなサービスに関する登壇があります。実際のプロダクト開発の現場で培ったノウハウが多く凝縮された内容となっております。色々と聴講していただいて、ノウハウの吸収や今抱えている問題などの解決の糸口となると幸いです。また、イベント当日はDiscordサーバーを立ち上げる予定ですので、登壇者・聴講者間でのコミュニケーションも可能となっております。ぜひとも、2022年3月17日はDeNA TechCon 2022で盛り上がりましょう。

参加登録はこちらからお願いいたします。

「テスタビリティの高いGoのAPIサーバを開発しよう」というハンズオンを公開しました

はじめに

SWETグループのGoチームの伊藤(@akito0107)です。 「テスタビリティの高いGoのAPIサーバを開発しよう」というタイトルでGoを用いてWeb APIを書くエンジニア向けのハンズオンを公開しました。 この記事ではハンズオンの内容と補足を紹介しようと思います。

ハンズオンのねらい

このハンズオンでは、APIサーバーを題材としてテスタビリティを担保した設計をするためには何が必要なのかを学んでもらうことを目的にしています。 特に、私自身がテスタビリティを考える上で重要だと考える、Dependency InversionやDependency Injection (DI), Test Doubleについて詳しく説明し、さらには自分で実装してもらう形をとっています。

Goでは他の言語と違い、デファクトのWeb Frameworkなどはありません(強いて言うなら標準がデファクトですが)。 そのため、Goで現実のプロダクトを開発する際には、必要に応じてライブラリ同士を組み合わせる、または自分たちで開発する必要があります。 その際の指標として役立つようなものを狙ってハンズオンを作成しました。

ハンズオンの内容

今回のハンズオンでは、Go言語を書いたことがある人や(言語問わずに)APIサーバーを実装したことがある人向けに、より「テスタビリティ」が高い設計にするためにはどうすればよいのか、といった内容を紹介しました。

以下がハンズオンとあわせて公開した資料となります。

speakerdeck.com

なお、Codelabの回答もrepository上に公開してありますので、もしわからないところがありましたら参照してください。

ハンズオンは[スライドを用いた講義]→[codelabを用いた実習]の流れを1Chapterとし、全部で3つのChapterから構成されております。 講義による知識のインプット + codelabで実際に手を動かすことによるアウトプットを体験することで、より実践的な技術を身に着けてもらうことを想定しております。

Codelabでは簡単なAPIサーバのサンプルアプリケーションを題材とし、テスタビリティが低い設計から高い設計へのリファクタを行っていきます。 その過程でテスタビリティの高い設計や、テストを書く際のテクニックなどが学べる構成になっています。

各チャプターは以下のような内容になっています。

  • Chapter1 テスタビリティについて
  • Chapter2 アーキテクチャについて
  • Chapter3 Test Doubleについて

以下にそれぞれのChapterの内容を簡単に説明します。

Chapter1 テスタビリティについて

最初のChapterでは、今回のハンズオンで一貫して対象とするテスタビリティの定義をしました。 テスタビリティには様々な定義があります。今回はソフトウェアテスト293の鉄則*1

テスト容易性とは可視性と操作性である

という定義を採用し、テスト対象システム(今回の場合はAPIサーバー)の可視性と操作性を上げるための設計について考えていきます。

f:id:swet-blog:20210909123728p:plain
Testabilityの定義について

Codelabではサンプルアプリケーションのビルドおよび次のChapter以降で行うリファクタの準備をしました。

Chapter2 アーキテクチャについて

このChapterではテスタビリティが高いアーキテクチャについて解説しました。 いくつか例はあるものの、APIサーバを実装する上で最も古典的な設計である3層アーキテクチャについて解説し、テスタビリティを担保する上で特に重要な概念であるDependency Inversion(依存関係逆転)について解説をしました。

f:id:swet-blog:20210909123918p:plain
3層アーキテクチャについて

CodelabではChapter1でビルドしたサンプルアプリケーションをリファクタし、3層アーキテクチャに直した上で、依存関係の整理ができるような設計にすることをゴールにしています。

Chapter3 Test Doubleについて

最後のChapterではTest Doubleについての紹介しました。mockやstubという言葉は聞いたことがあるかもしれませんが、それらの総称がTest Doubleです。 Test Doubleのメリットや分類を紹介し、最後にGoでの実装例を話しました。

f:id:swet-blog:20210909124059p:plain
Test Doubleについて

CodelabではChapter2でリファクタしたサンプルアプリケーションに対し、Test Doubleを用いたテストを追加していくということを行いました。

時間の都合上、Codelabを最後までやりきれなかった方もいらっしゃるかとは思いますが上記の通り資料は公開されており、また、Codelabの回答もrepository上に公開されていますので、時間のあるときに最後までチャレンジできるようになっています。

補足

以下にスライド・Codelabには盛り込めなかった話題について補足をしておきたいと思います。

結合度の高いテストについて

今回紹介したアーキテクチャは各レイヤーの結合度を下げ、各レイヤー単体でテストを書くことが簡単になるような点を目的として設計しています。 一方で、コードの記述量が増え、コードの見通しが悪くなる、といったデメリットもあげられるかもしれません。

講義の内部でも触れましたが、最近はミドルウェア(特にDocker)やライブラリの進化で、手元で簡単にDB等のインフラ環境が整えられるようになってきました。 Cloud Providerが提供しているコンポーネントも、以前であれば、手元に環境を再現するのが難しくテストを書こうとすると必然的にコード上でTest Doubleを用意する必要がありました。 しかし、現在ではAWSであればlocalstackや、GCPであれば公式が提供しているemulatorなどが出てきて、手元で環境を再現することが容易になってきました。 そのため、以前よりも結合度の高いテストを書くことは容易になってきており、相対的に単体のレイヤーで行うテストの重要性や必然性は薄れてきているかもしれません。

それでもあえて今回このある意味レガシーな設計を紹介したのはいくつかの意図があります。代表的なものを紹介します。

まず第一に、(これは当たり前ですが)いくらlocalで使えるコンポーネントが出てきたといっても全てがそろっているわけではないので、ある程度複雑なシステムを対象にする場合は、何かしらのTest Doubleを使う必要があるということ。

そして第二に、再現テストを書くための土台を作っておくということがあげられます。

再現テストとは、何かしらのバグが発生した際に、そのバグが発生するような状況を再現するために書くテストです。 再現テストを実装した上で、バグを修正し、テストが通ることを確認するというような使い方をします。 バグの発生原因を特定し、確実に修正されていることを確認できる手法で、これをやるかやらないかでバグ修正の際の安心感や効率が変わってくると感じています。

再現テストを実装する上では、例えばDBからエラーが帰ってきた場合など、特殊な状況・状態に依存することが多く、この状況・状態の再現は仮にエミュレータを使っていたとしても非常に難しくなる場合があります。 そういった場合でもTest Doubleなどを使い、システムの状態を簡単にいじれるようにしておくと簡単に再現テストを書くことができます。

実運用中にバグが発覚し、いざ再現テストを書こうと思ったときには外部環境に密結合でテストが書けないといった状況を防ぐためにも、やや冗長に感じられるかもしれませんが、外部から依存を注入できるような設計は今でも有効だと感じています。

より良いテストを書くために

Codelabで記述したテストコードは、繰り返しが多く冗長な実装になっていると感じるかもしれません。 本来は、テストコードを記述した後にテストコード自体のメンテナンス性を上げる必要があります。 GoであればTable Driven Testsなどを使うと良いかもしれないです。

以下の例はCodelabのChapter3のuserUsecaseのtestをTable Driven Testingを使うようにリファクタした例です。

func TestUser_Create2(t *testing.T) {
    cases := []struct {
        name      string
        mockFn    func(t *testing.T) *userRepositoryMock
        mock      *userRepositoryMock
        expectErr error
        in        *model.User
    }{
        {
            name: "success",
            mockFn: func(t *testing.T) *userRepositoryMock { // testing.Tをt.Run内で受け取る必要があるため関数でwrapしている
                return &userRepositoryMock{
                    findByEmailFn: func(ctx context.Context, queryer sqlx.QueryerContext, email string) (user *model.User, err error) {
                        if email != "test@dena.com" {
                            t.Errorf("email must be test@dena.com but %s", email)
                        }
                        return nil, apierr.ErrUserNotExists
                    },
                    createFn: func(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error {
                        return nil
                    },
                }
            },
            in: &model.User{
                FirstName:    "test_first_name",
                LastName:     "test_last_name",
                Email:        "test@dena.com",
                PasswordHash: "aaa",
            },
        },
        {
            name: "return user from repository",
            mockFn: func(t *testing.T) *userRepositoryMock {
                return &userRepositoryMock{
                    findByEmailFn: func(ctx context.Context, queryer sqlx.QueryerContext, email string) (user *model.User, err error) {
                        return &model.User{}, nil
                    },
                    createFn: func(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error {
                        return nil
                    },
                }
            },
            in: &model.User{
                FirstName:    "test_first_name",
                LastName:     "test_last_name",
                Email:        "test@dena.com",
                PasswordHash: "aaa",
            },
            expectErr: apierr.ErrEmailAlreadyExists,
        },
    }

    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            userUsecase := NewUser(c.mockFn(t), nil)
            err := userUsecase.Create(context.Background(), c.in)

            if err != c.expectErr {
                t.Errorf("expectErr is: %v but actual: %v", c.expectErr, err)
            }
        })
    }
}

まとめ

Goのハンズオンを公開し、その内容の紹介と補足をしました。 講義を受けていなくても、スライド、Codelabそれぞれで完結して内容を把握できると思いますので、興味がありましたらぜひご覧になってみてください。

DeNAではGo以外にもAndroidのTestに関するハンズオンを公開しております。 せひそちらも覗いてみていただければと思います。

最後に、SWETでは一緒に働いてくれる人を募集しています。下記職種で募集しているのでぜひご応募ください。

career.dena.jp

*1:セム ケイナー,ジャームズ バック,ブレット ペティコード. ソフトウェアテスト293の鉄則

治安維持のために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程度の節約になりました。スパースチェックアウトはまだ十分活用できていませんが、改善の余地があるのだと前向きに考えています。