DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

画面仕様書への静的検査器を実装したらたくさんの欠陥を発見できた話


SWET第二グループのKuniwakです。本記事では画面仕様(後述)の仕様書に対する静的検査器を開発した事例について紹介します。

伝えたいこと

  1. 画面表示と画面遷移を記述する仕様書は機械可読にできる
  2. 仕様書が機械可読であれば仕様の静的検査ができる
  3. 静的検査によって自身の担当範囲の15%の画面から計40件弱の欠陥を発見した
  4. 機械可読な仕様書にはさらなる応用が見込める

おさらい:仕様とは

仕様の定義はいくつかあります。 ここでは仕様とは実装の正しい振る舞いを定める基準とします。 ある実装が正しいと判定されることを、実装が仕様を満たしたといいます。 誰による判定でも実装が仕様を満たしたかどうかの判定結果は一致すべきです。

さて実装の欠陥と同様に、仕様にも欠陥が生じえます。 本来正しいと意図した実装の振る舞いを誤っていると判断したり、その逆に誤っていると意図した実装を正しいと判断する仕様には欠陥があります。

仕様の欠陥の典型的な例の1つに矛盾した記述の存在があります。 矛盾した記述を含む仕様はどんな実装でも満たせません。 たとえば、ある箇所でログイン画面のボタンのテキストが「ログイン」と指示されているのに、別の箇所で「サインイン」と指示されている場合、どんな実装でもこの両方を同時に満たすことはできません。 すると、仕様を満たせる実装が1つもないということになります。 仕様を満たせる実装が1つもない仕様は無価値ですから、実際には正しいとしたかった実装はあったはずでしょう。 まさにこの例は正しいとしたかった実装を誤っていると判断する仕様になっています。

別の欠陥の例として、仕様が意図どおりだったとしても、その仕様を満たした実装が解決したかった問題を解決できなかった(e.g. 売り上げが目標に至らなかった)というものもあり得ます。 ただし本記事ではこの種類の欠陥はスコープ外としています。

仕様に欠陥があるとどうなるか

さて、仕様に欠陥があるとどうなるのでしょうか。

開発プロセスにおける仕様の利用者は主に2つです:

  • 実装者:実装者の役割は仕様を満たす実装を提供すること
  • 検証者:検証者の役割は実装が仕様を満たしていることを確認すること

仕様に欠陥があると、それぞれの利用者に次の悪影響があります:

  1. 本来不要だったコミュニケーションコストの発生
    • 実装者または検証者が仕様を不審に思えれば実装前に仕様への質問でこれを発見できます。ただし本来不要であったコミュニケーションです
  2. 無駄な実装・検証コストの発生
    • 検証者が不審に思えなければそのとおりに実装されてしまい、仕様策定者がその実装を触るまで意図どおりでないことに気づけません。運よく出荷前に気づけた場合でも一部または全部の実装が無駄になります
  3. 信頼の失墜
    • 運悪く出荷前に気付けなければ、エンドユーザーからのお問い合わせ等で発覚することになります。その場合は実装が無駄になるうえ、エンドユーザーからの信頼を損ないかねません

ここまでのまとめです:

  1. 仕様とはシステムの正しい振る舞いを定める基準です
  2. 実装と同様に仕様にも欠陥を考えられます
  3. 仕様に欠陥があると余計なコストの発生や信頼の失墜が発生しえます

画面仕様とは

本記事で扱う仕様は、GUIアプリケーションの画面の見た目と画面間の遷移にまつわるものです。 この仕様を画面仕様と呼び、今回は画面表示仕様(後述)と画面遷移仕様(後述)の2つの組であるとします。

ここで画面とは主にGUIの見た目によってグルーピングされたアプリケーションの状態の集合です1。 たとえば一般的なログイン画面を例に画面について考えてみましょう。 ログインという操作には入力フォームの入力状態や認証サーバーとの通信状態が関与します。 これらのありえる組み合わせからなる状態の集合がログイン画面です。

また画面内の状態遷移や画面間の遷移は、UI要素への操作(例:クリックやホバー、スクロール)やサーバーとの通信、時刻などを引き金として起こります。 この引き金のことをイベントと呼びます。

画面表示仕様とは

多くの画面は入力フォームやボタンなどのUI要素を決まった位置に配置されています。 画面内のそれぞれの状態ごとに、UI要素をどんな見た目でどの位置に配置するかを指示する仕様が画面表示仕様です。 なお個々のUI要素のとりうる状態や見た目、受け付けるイベントについて画面表示仕様とは別にあらかじめUI要素仕様として定めるのが一般的です。 そうすることで複数の画面で共通するUI要素の仕様をそれぞれの画面表示仕様内に重複して記述しなくともよくなります。

本記事ではUI要素の表示や状態遷移にまつわる仕様をUI要素仕様で別に定めているとし、画面表示仕様では(1)UI要素の配置と、(2)画面内のUI要素間の相互作用による振る舞いを記述することとします。 ここでUI要素間の相互作用とは、イベントによって複数のUI要素の状態が連動することをいいます2。 たとえば画面に2つのUI要素として入力フォームとテキストが配置されているとします。 そして入力フォームへキーボード入力というUI操作をおこなった結果、その入力が入力規則に違反していればテキストにエラーメッセージが表示され、違反していなければテキストを非表示にされる、という振る舞いはUI要素間の相互作用による典型的な振る舞いです。

ここで画面表示仕様の例を、UI要素の配置とUI要素間の相互作用による振る舞いの順でみていきましょう。

まずUI要素の配置は、状態によってUI要素の配置が変わらなければ代表的な状態におけるUI要素の配置を示せば十分です。 たとえば次のログイン画面が状態ごとにUI要素の配置が変わらないとすると、このスクリーンショットで十分です。

状態によってUI要素の配置が異なればそれらの配置の代表的な状態の配置を示せば十分です。

次にUI要素の間の相互作用は、UI要素の配置図に加えて何らかの表現でそれを記述する必要があります。 本事例ではこれを自然言語によって記述しました。 前述の入力フォームとテキストの振る舞いの説明のように記述しています。

なお実装がこの画面表示仕様を満たしたと判断する基準は、大雑把にいうと仕様と実装に同じイベントを与えた結果の見た目が一致することです3。 この画面表示仕様の指示する見た目の集合はUI要素の配置とUI要素ごとの状態から計算できます。

画面遷移仕様とは

ほとんどのGUIアプリケーションは、ユーザーが画面を遷移しながら操作していくことを意図されています。

このような画面内の状態を点とし、イベントを辺としたラベル付き有向グラフを本記事では画面遷移仕様と呼びます。 たとえばログイン画面内のユーザー名・パスワードが未入力な状態S_0に正しいユーザー名Taroとそのパスワードの組を入力するとS_Taroへと遷移する場合、S_0S_Taroの間をユーザーTaroの正しい認証情報の入力という辺で結びます4

この状態遷移グラフの表現方法はいくつかあります。 上に示した状態遷移図や状態遷移表がその代表的な候補です。

なお実装がこの画面遷移仕様を満たしたと判断する方法については説明が長くなるため割愛します。 本事例ではCommunicating Sequential Process (CSP)知りたい方はという理論を背景にしているため、気になる方はCSPにおける「詳細化」という概念を調べてください。 CSPおよび詳細化については「並行システムの検証と実装(磯部 祥尚 著、近代科学社)」がわかりやすいです。

画面仕様を機械可読にする方法

今回の取り組みはConfluence Wikiマークアップで記述された既存の仕様書が画面仕様として十分な情報を持っていないところからスタートしました。 そこで既存の仕様書が画面仕様として十分な情報を持てるように画面ごとにそれぞれを次のようなUI要素の配置図(画像左)とUI要素表(画像右)の組を書くようにしました。

このScr.001は画面IDでログイン画面は画面名です。 本事例の仕様書ではすべての画面に画面名だけでなくIDをつけました。 画面はさまざまな箇所で言及され(例:UI要素表内の画面遷移)、その際に言及先の画面を一意に特定するためのIDが必要になるためです。

画像左はUI要素の配置図で画面表示仕様の一部です。 画像右はUI要素ごとにIDや種類、表示条件、表示内容、インタラクションを記述します。 表示条件や表示内容は画面表示仕様の一部です。 インタラクションは画面遷移仕様の一部です。

このようにUI要素表を組むことで、Confluence Wikiマークアップを解析すれば画面遷移仕様を読み取れるようになります。 また状態遷移図は画像ではなくPlantUMLマクロで描画することによって機械可読にしています。

機械可読な画面仕様への静的検査

機械可読な画面仕様に対して静的な検査が可能です。 今回の事例ではGo言語で6000行弱の静的検査器を実装しました。

この静的検査器は23の検査ルールを持っています。 この検査ルールは、エンジニアの間で事前に洗い出しておいた仕様インスペクション観点がもとになっています。 この事前に洗い出された仕様インスペクション観点は20ほどあり、いくつかを抜粋します:

  1. 画面遷移図とUI要素表が整合すること
  2. インタラクション可能なUI要素(ボタン・チェックボックス等)にインタラクションの記載があること
  3. UI要素の種類がリストのものについてはリスト内のUI要素の並び順の指示が明確であること
  4. 動的画像は表示範囲に対して画像の大きさが異なるとき拡大縮小の指示が明確であること
  5. ...

今回はこれらの観点のうち8つを自動化できました。 上の例であれば次のような基準で実装されています:

  1. PlantUMLで記述された状態遷移図と各画面それぞれのUI要素表のインタラクション列に出現する画面IDを付き合わせた辺の整合性の検査5。画面遷移図内のすべての遷移がUI要素表にあり、かつUI要素表にあるすべての遷移が画面遷移図にあればOK、それ以外はNGと判定
  2. 種類がボタンなどの場合にインタラクションの列に記載があればOK、なければNGと判定
  3. 種類が動的または静的リストの場合、「順」という文字が表示内容に出現すればOK、なければNGと判定
  4. 種類が動的画像の場合、「拡大」「縮小」「見切れ」という文字列のいずれかが出現すればOK、それ以外はNGと判定
  5. ...

また記載ミスや記載漏れによって欠陥が見逃されないように、補助的な仕様インスペクション観点を追加で15設けています。 たとえば:

  1. 重複したIDがなければOK、あればNGと判定
  2. TODOという文字列を含まなければOK、含めばNGと判定
  3. テキストや画像はそれが静的なのか動的なのかが指示されていればOK、なければNGと判定
  4. ...

余談ですが、静的検査器の実装は検査ルール1つにつき、UI要素レイアウト、UI要素表、画面遷移図のいずれかを入力とし、発見された欠陥のリストを返す関数として実装しています。 このようにすることで検査ルール間の独立性が高まり、検査ルールの追加や削除、変更を容易にできます。 また検査対象外の要素は無視するようにした方がよいです。 こうすることで仕様に対して画一的な表現を強制されなくなり、より適した表現(例:画面内の複雑な状態遷移を状態遷移図で表現する、複雑な条件判定をフローチャートで表現する、など)を仕様書へ埋め込めるようになります。 その表現についても検査対象にしたければ、その表現だけを検査する追加のルールをあとで実装すればよいのです。

画面仕様の静的検査を導入した結果

この静的検査器を使うことで、プログラマーに仕様が渡ってくる前に15%の画面から計40の欠陥を発見できました。 また検査器によって欠陥が摘出された後の仕様書への質問は、静的検査器を使っていない他事例の平均的な質問数より少ない傾向にあることがわかっています。

機械可読な画面仕様の課題

非プログラマーによって機械可読な仕様書を保守する場合、機械可読に保つハードルが高いとわかりました。 そのため残念ながら今回実装した静的検査器は継続的な運用には至れませんでした。 プログラマーが仕様書を保守するように役割を変更すれば解決できるかもしれません。 そのように役割を変更する場合、仕様書の保守にかかるプログラマーの工数の捻出が課題になるでしょう。 後述するような機械可読な仕様書による実装の自動生成分でまかなえるかどうかがポイントになりそうです。

機械可読な仕様の応用

画面仕様やその周辺を機械可読にすることで仕様策定を取り巻くプロセスの一部を自動化できることがわかっています。 たとえば本事例では仕様策定プロセスのP3とP4が自動化されました。 P3についてはUI要素の配置図はFigma上のオブジェクトと紐づけることでFigma APIを使い機械的な画像の更新を実現しています6

また仕様を機械可読にすれば実装の自動生成や検証項目の自動生成をできるかもしれません。 完全な自動生成は難しいかもしれませんが、部分的な自動生成であれば実現しやすいと推測しています。 たとえば典型的なE2Eテストは画面遷移仕様に基づく検証です。 今回紹介した画面遷移仕様にいくつかUI要素のID等のヒントを与えればE2Eテストの自動生成が可能に思えます。

また機械可読な仕様の検索や解釈をLLM向けに支援するMCPサーバーを考えられます。 これは実装や検証項目、テストの自動生成や、それらのプロセスの支援の役に立つかもしれません。

まとめ

  1. 画面表示と画面遷移を記述する仕様書は機械可読にできる
  2. 仕様書が機械可読であれば仕様の静的検査ができる
  3. 静的検査によって自身の担当範囲の15%の画面から計40件弱の欠陥を発見した
  4. 機械可読な仕様書にはさらなる応用が見込める

  1. 実際には見た目が似ていても別の画面として扱った方がよいこともあります。たとえば一般ユーザーと特権ユーザーが別れているサービスがあったとして、それぞれのログイン画面の見た目を似せることはできますが、そこに到達するまでの経路およびそこから遷移する先の画面が大きく異なる場合、別の画面として扱った方がわかりやすくできるでしょう。
  2. CSPにおける並行合成を意図しています。
  3. 仕様と実装の両方が決定的かつ内部イベントによる遷移を含まない場合です。非決定的な場合はトレース実行後の仕様と実装の両方の見た目は集合となります。このときは実装の見た目の集合が仕様の見た目の集合に包含されるか否かで判定します。内部イベントによる遷移を含む場合は不安定な状態を比較対象から取り除くとよいでしょう。このときクライアントとサーバー間の通信中の画面表示の指示もしたいことがほとんどですから通信によるイベントを隠蔽しない工夫が必要になるでしょう。
  4. この図はわかりやすさのために素朴に状態とイベントを描いています。実用的には状態変数やガード、事後条件を使ってより見通しのよい図にする方がよいでしょう。
  5. 本来は画面間の大雑把な遷移関係ではなく、個々の状態間の遷移関係を特定できるように状態変数やガードや事後条件を機械可読な形式で記述すべきです。しかし機械可読な形式が人間にとって読みやすいとは限らないことから今回の事例でそこまでは踏み込めませんでした。自然言語での説明にとどまっています。
  6. ConfluenceにFigmaを埋め込めるFigma Widgetが利用可能でしたが動作が重すぎるため利用しませんでした。

コードメトリクス収集ツールoctocovの紹介とLooker Studioによる可視化応用例


こんにちは、SWETの飯島です。

本日はコードメトリクスを管理/収集するツールoctocovの紹介と、Googleのデータ可視化ツールLooker Studioを用いたメトリクス可視化の応用例を紹介します。

なお、これから紹介するoctocovやLooker Studioの例は任意の言語で応用できますが、本記事ではC#のプロジェクトを対象に解説をします。

octocov

ファイル単位のテストカバレッジを見たい

octocovの話へ移る前に、テストカバレッジ1の管理がなぜ必要なのかを述べなくてはなりません。

テストカバレッジが管理されていないと、テストカバレッジの減少、すなわちテストの欠落に気付けなくなってしまいます。 テストの欠落は、何らかの変更を加えた後に既存の機能が意図したように動作しない現象(リグレッション)や、リファクタリング時に元の機能を壊してしまうことなどを招きかねません。

テストが欠落する問題に対して、コードのレビュー時にファイル単位のカバレッジをレビュー項目に追加するという対策が考えられます。 たとえばコードの作成者がテストを書き忘れたとしても、カバレッジが不自然に低いファイルをレビュー者がチェックできれば、テストの抜け漏れを防げるようになります。 また、レビューにカバレッジを用いることでテストへの意識がチーム内で共有され、カバレッジが高いテストを個々人が記述するようになるという副次的な効果も期待できます。

コードレビュー時にカバレッジを表示するツール

コードレビュー時にカバレッジを表示できるツールは複数リリースされています。

今回は外部にデータを送信しない点と無料で使用できる点を重視し、octocovを採用しました。

octocov

octocovはコードメトリクスを収集して管理するオープンソースツールです。 octocovが扱うコードメトリクスには、テストの実行時間やコードとテストの比率、そしてリポジトリ全体・ファイル毎のテストカバレッジも含まれています。

また、コードのレビュー時にファイル単位のカバレッジを表示させることもoctocovなら簡単に自動化できます。 PRの更新をトリガーに、テストと合わせてoctocovを実行させると、octocovはPRのコメントとしてテストカバレッジ等の各種メトリクスを投稿します。

octocovがPRにコメントしている様子

octocovを導入することで、上の画像のようにコメントされます。

octocovの実行例

octocovがPRのコメントとしてコードのメトリクスを投稿するまでの流れをC#のプロジェクトで下に再現します。

name: test

on:
  workflow_dispatch:
  pull_request: # PRのコメントとして投稿するなら、トリガーにPRを含めましょう

jobs:
  test:
    permissions: # (1) octocovの機能をフルで使うには適切な権限を与える必要があります
      contents: 'read'
      id-token: 'write'
      actions: 'read'
      pull-requests: 'read'
    env:
      DOTNET_VERSION: '8.0.x'
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Setup .NET Core SDK ${{ env.DOTNET_VERSION }}
        uses: actions/setup-dotnet@v2
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Install dependencies
        run: |
          dotnet restore MyProject.sln
          dotnet tool restore

      # (2) テストの際にメトリクス計測の形式を指定することで、テストカバレッジの収集が有効になります
      - name: Run dotnet test with measuring coverage
        run: dotnet test ./MyProject.Tests/MyProject.Tests.csproj --collect:"XPlat Code Coverage" --results-directory "TestResults" --no-restore

      # (3) 1つ上のステップで作成したレポートを1つにまとめます
      - name: Merge Coverage
        run: dotnet dotnet-coverage merge -o output.cobertura.xml -f cobertura "TestResults/**/coverage.cobertura.xml"

      # (4) octocovには専用のアクションが用意されているので、これを実行します
      - name: Run octocov
        uses: k1LoW/octocov-action@v1
        with:
          config: .octocov.yml
          install-dir: ${{ runner.temp }}/
          work-dir: ${{ github.workspace }}
          github-token: ${{ github.token }}
        env:
          GITHUB_ENTERPRISE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GOOGLE_APPLICATION_CREDENTIALS_JSON: ${{ secrets.MYPROJECT_GCP_CREDENTIALS }}

実行にあたり、いくつかの注意点があります。

1. jobs.<job_id>.permissions

適切な権限がトークンに付与されていないと、octocov-actionsの処理の一部がスキップされてしまいます。 たとえば、pull-requests: 'read'が付与されていない場合、octocov-action実行時にテストカバレッジに関する処理がスキップされ、下のように出力されます。

Skip measuring code coverage: the condition in the `if` section is not met (true): GET https://github.com/api/v3/repos/swet/my-project/pulls/100: 403 Resource not accessible by integration []

なお、権限により処理のいくつかがスキップされてもジョブとしては成功するケースもありますので、成功していてもoctocov-actionのログはしっかり確認するようにしましょう。

2. dotnet test

C#ではdotnet test--collect:"XPlat Code Coverage"を加えると、コードメトリクスを収集しつつテストが実行されます。 出力されるメトリクスのレポートはoctocovがサポートしている形式に従う必要があります。 詳細はこちらをご覧ください。

3. dotnet dotnet-coverage merge

こちらもC#特有の処理です。上記の例ではcobertura形式で複数のレポートをoutput.cobertura.xmlに集約しています。

4. octocov-action

octocovはCLIツールだけでなく、octocov-actionという専用のアクションの提供もしています。

さて、前段のステップで出力されたoutput.cobertura.xmlがoctocov-actionのwithenv内に登場していないことに疑問を持った方もいらっしゃるかもしれません。 実は、.octocov.ymlというoctocovの設定ファイルの中にメトリクスレポートへのパスも記述しています。

下に.octocov.ymlの一例を記します。設定の詳細はoctocovのREADMEをご覧ください。

coverage:
  if: true
  paths:
    - output.cobertura.xml

codeToTestRatio:
  code:
    - 'MyProject/**.cs'
  test:
    - 'MyProject.Tests/**.cs'

testExecutionTime:
  if: true

diff:
  datastores:
    - bq://swet/my-project/octocov

comment:
  if: is_pull_request

summary:
  if: true
report:
  if: is_default_branch
  datastores:
    - bq://swet/my-project/octocov

Looker Studio

octocovの力によりファイル単位のカバレッジを閲覧できる状態になりました。 とはいえ見たいコードメトリクスは他にも考えられます。

  • テストカバレッジやテストの実行時間などのコードメトリクスの推移
  • ディレクトリ毎のテストカバレッジ

コードメトリクスの推移

コードメトリクスの推移を見たい理由

octocovを導入してレビュー時にコードメトリクスを参照できても、コードの品質が良い状態を維持できるとは限りません。 レビュー時のメトリクスが少々悪化している程度では、テストが不十分だと判断するのはハードルが高くなります。 そうなれば低品質なコードがレビューに通ってしまい、さらにそれが常態化すると全体的なコードの品質も徐々に落ちてしまいます。

徐々にメトリクスが悪化するケースに対応するには、ある時点のコードメトリクスの計測だけで満足せず、コードメトリクスの推移まで追う必要があります。

コードメトリクスの推移の可視化

コードメトリクスの推移を可視化するには、大きく2つの段階に分けられます。

  • 過去のコードメトリクスをどこかに蓄積する
  • 蓄積されたデータを基にメトリクスを表示する

当然といえば当然ですが、メトリクスの推移では過去のメトリクスも参照します。 したがって、計測したコードメトリクスがどこかに蓄積され、その蓄積したメトリクスを用いて推移を表示する必要があります。

コードメトリクスを蓄積する

データの蓄積と聞くとハードルが高く感じるかもしれませんが、なんとここでもoctocovが活躍します。

ここで、先ほどの.octocov(octocovの設定ファイル)の最後の箇所を再掲します。

report:
  if: is_default_branch
  datastores:
    - bq://swet/my-project/octocov

上のreport.datastoresでは、BigQueryのURIが指定されています。 このURIがあるだけで、octocovはBigQueryのテーブルにコードメトリクスを格納します。 さらに嬉しいことに、octocovがテーブルのスキーマを自動的に設定するためテーブルの事前準備は不要になります。 これでメトリクスの蓄積部分は完了です。

なお、BigQueryのテーブルスキーマの詳細はこのようになっています。

octocovが作成するBigQueryのテーブルスキーマ

(引用元:https://github.com/k1LoW/octocov/blob/main/docs/bq/schema/README.md

コードメトリクスの推移をLooker Studioで表示する方法

まず、メトリクスの推移の表示先としてLooker Studioを選択しました。 選択の理由は下にいくつか並べますが、特に2番が大きかったと感じます。

  1. データ可視化ツールとして有名かつ人気
  2. BigQueryとLooker Studio間のデータの連携が楽
  3. 極めて安価(Looker Studio自体は無料でも使える)

説明の都合上、Looker Studioの使い方は説明しませんが、グラフとグラフ設定の例を画像として下に貼ります。 余談ですがメトリクスの計測対象にはAnjinという弊社が開発したOSSツールを選定しました。

Anjinにおけるテストの実行時間の推移

折れ線で表示しているtest_execution_secondstest_execution_timeを109で割った値として独自に定義した指標になります。

以上でコードメトリクスの推移を表示できるようになりました。

ディレクトリ毎のカバレッジ

ディレクトリ毎のカバレッジを見たい理由

octocovの導入だけでもファイル毎のカバレッジを閲覧できますが、ディクレトリ毎のカバレッジを見たいケースもあるかと思います。 たとえば、レビューするファイル数が多い場合や非レビュー時のようなケースでは、扱うファイル数が多くなりがちで、ファイル毎のカバレッジから有意義な考察を得られ難くなってしまいます。 カバレッジをディレクトリ毎にまとめることで、表示数が抑えられ、ディレクトリの傾向を把握しやすくなるというメリットを得られます。

ディレクトリ毎のカバレッジをBigQueryに用意する

ディレクトリ毎のカバレッジについて、octocovは自動的には計測してくれません。 そこで、octocovがBigQueryに保存したファイル毎のカバレッジから算出します。 どこにファイル毎のカバレッジが保存されているのか疑問に思う方もいるかもしれませんが、実は既に登場しています。

BigQueryのrawカラム

rawカラムには各ファイルのカバレッジがJSON形式で保存されています。 そこで、再帰SQLを用いてファイル毎のカバレッジをディレクトリ毎のカバレッジに変換します。

WITH RECURSIVE latest_record AS (
  SELECT
    raw
  FROM
    `swet.my-project.octocov`
  WHERE coverage_total <> 0
  ORDER BY
    timestamp DESC
  LIMIT 1
),
parsed_json AS (
  SELECT
    JSON_QUERY_ARRAY(raw, '$.coverage.files') AS files_array
  FROM
    latest_record
),
flattened_files AS (
  SELECT
    JSON_EXTRACT_SCALAR(file, '$.file') AS file_path,
    CAST(JSON_EXTRACT_SCALAR(file, '$.total') AS INT64) AS total,
    CAST(JSON_EXTRACT_SCALAR(file, '$.covered') AS INT64) AS covered
  FROM
    parsed_json,
    UNNEST(files_array) AS file
),
directory_paths AS (
  -- 初期状態でのファイルパスとその階層
  SELECT
    file_path,
    total,
    covered,
    SUBSTR(file_path, 1, IFNULL(NULLIF(INSTR(file_path, '/'), 0) - 1, LENGTH(file_path))) AS partial_path,
    SUBSTR(file_path, INSTR(file_path, '/') + 1) AS remaining_path,
    1 AS level
  FROM
    flattened_files

  UNION ALL

  -- 再帰的に階層ごとにパスを抽出
  SELECT
    file_path,
    total,
    covered,
    CONCAT(partial_path, '/', SUBSTR(remaining_path, 1, IFNULL(NULLIF(INSTR(remaining_path, '/'), 0) - 1, LENGTH(remaining_path)))) AS partial_path,
    SUBSTR(remaining_path, INSTR(remaining_path, '/') + 1) AS remaining_path,
    level + 1
  FROM
    directory_paths
  WHERE
    INSTR(remaining_path, '/') > 0
)

-- ディレクトリごとにカバレッジを集計
SELECT
  partial_path AS directory,
  SUM(total) AS total_lines,
  SUM(covered) AS covered_lines,
  ROUND(SUM(covered) / SUM(total) * 100, 2) AS coverage_percentage
FROM
  directory_paths
GROUP BY
  partial_path
ORDER BY
  partial_path;

さて、Looker StudioにはBigQueryのデータをSQLで加工して読み込む機能がありますが、残念ながら再帰SQLには対応していません。 そこで、このクエリを1日に1回実行するようスケジュール化し、クエリの結果を別テーブルに出力させます。 この別テーブルに出力されたデータをLooker Studioで表示させてディレクトリ毎のカバレッジを表示します。 成功し色付けも行うと下のようになります。

Anjinのディレクトリ毎のカバレッジ

ディレクトリ毎のカバレッジに関する注意点

今回はディレクトリ毎のカバレッジをLooker Studioで表示しましたが、C#ではreportgeneratorというテスト結果を可視化するツールでも実現できます。 単にディレクトリ毎のカバレッジを表示したいだけならreportgeneratorを使う方がよいと思います。 ただカバレッジ以外のコードメトリクスも表示させたい場合には、1箇所に集約できるLooker Studioにも分があります。

まとめ

コードメトリクスを管理/収集するツールとしてoctocovを紹介しました。 octocovはコードのレビュー時にテストカバレッジ等の各種メトリクスをPRのコメントとして投稿する機能を有しています。 また、octocovとBigQueryの連携機能をフルに使うと、コードメトリクスの推移やディレクトリ毎のカバレッジをLooker Studioで表示できます。

本記事が皆様の参考になれば幸いです。 DeNA Testing Blogではソフトウェアテストやその隣接領域に関する記事や勉強会の登壇資料などを投稿しています。 また、弊社ではDeNA EngineeringDeNA公式Xアカウント@DeNAxTechでも情報の発信をしております。 ブックマーク・フォロー等よろしくお願いします!


  1. 当記事に出現するテストカバレッジはステートメントカバレッジ(C0カバレッジ)を想定しています。

新たな一歩:SWET内のプロジェクトマネージャーとしての挑戦

2021年に新卒で入社してからずっとSWETグループのIKです。

現在SWETグループにおけるゲームチームのプロジェクトマネージャー(以下、PM)をしています。 当記事では、自分がプレイヤーからPMになっての変化について発信していけたらと思います。

はじめに

SWETグループの属するIT本部品質管理部は、ゲーム・ソーシャルライブ・ヘルスケア・スポーツなど様々な事業を展開し続けるDeNAグループ全体の様々な品質にコミットしている部署です。

その中で、SWET(SoftWare Engineer in Test の略)グループは、ソフトウェアテストを起点とした、「DeNA サービス全般の品質向上」と「DeNA エンジニアの開発生産性向上」の両方により、価値あるものを素早く提供できるようにすることをミッションとしています。

SWETでは2024年7月までは、次のようなチーム体制になっていました。

  • ゲーム領域
  • iOSチーム
  • Androidチーム
  • CI/CDチーム

これが、さまざまな事情により2024年8月から自分がゲームチームのPMをすることになりました。

はじめてのマネジメントで直面した課題と解決策

課題1. 未経験のPM業に対するハードルの高さ

PMの仕事には、遂行する施策ごとの予算と実績の管理(予実管理)が含まれます。 これはプレイヤーとして仕事をしていた時には経験したことのない仕事でした。 予実管理では、

  • 施策ごとに予算がどれだけ必要かを計算し、最適な配分を決定する
  • メンバーの進捗管理、施策の完了に対する締切の定義、管理
  • タスクに対するアサインの決定

を考慮しなければなりませんでした。

これらは単に技術開発の視点から考えるだけではなく、関わっている案件のスケジュールや状況といった「案件全体の情報」やチームメンバーのスキル、 手の空き具合といった「ゲームチーム全体のバランス」を重視する必要があります。

解決法

そもそもPMについて深く知る必要があると感じ、

を読みました。

まず、職務として次のことを行なう必要があることを書籍から学びました。

ステークホルダーエンゲージメント

  • 案件メンバーと関係構築のために何ができるかを考え、実行
  • 案件に対するゲームチームの役割と責任を明確にし、案件側と合意を得る
  • 施策の目的・生み出す価値の共有
  • 情報を案件から引っこ抜いてくる

チームメンバーのマネジメント

  • ゲームチームのメンバーに合わせた動機づけを行う
  • 情報共有
  • 各施策、タスクのアサイン

計画・進捗確認

  • ゲームチームの施策の価値とは何か言語化する
  • 予算の管理、案件側と施策の合意
  • 施策に対する開発アプローチの決定
  • タスクの優先順位の決定
  • 進捗管理
  • バックログの管理

測定

  • 各施策の成果物の効果測定

不確かさ・複雑さへの対応

  • リスク管理
    • リスクをリストアップする
    • リストアップしたリスクに対して許容範囲を決める
    • 許容範囲をはみ出そうになった時どうするか決める
    • 可能であれば案件側が握っているリスクリストを得る

引き継ぎ前は

  • 施策ごとに予算がどれだけ必要かを計算し、最適な配分を決定する
  • メンバーの進捗管理、施策の完了に対する締切の定義、管理
  • タスクに対するアサインの決定

を行なう必要があると思っていましたが、解像度がかなり低かったことがわかります。

課題2. 知るべき情報が一気に増えた

ゲームチームでは、次のような複数の施策を展開しています。

  • 静的解析器の開発、メンテナンス、導入サポート
  • 静的解析の結果を収集し、ダッシュボード化する仕組みの開発、メンテナンス、導入サポート
  • 高結合レベルテスト
    • オートパイロットフレームワークの導入、開発、メンテナンス
    • インゲームに対するマスターデータとロジックの統合テストの仕組みの開発、保守
  • その他社内ツールの開発、運用

これらの施策のうち、自分は静的解析の仕事を主体として取り組んできました。

しかし、PMになってからはゲームチームが当時行っていた施策をすべて把握する必要がありました。 自分が関わってなかった施策に対しては当然解像度が低かったため、すべての施策のキャッチアップをしなくてはなりませんでした。

解決法

当然ですが、PMはゲームチームの施策についてもっとも詳しい立場であるべきであり、これが施策の方向性を正しく示す助けとなります。 また、関わっている案件の状況やスケジュールもゲームチームの中で一番知っている必要があります。 冒頭にも書いた「案件全体の情報」を得るためにはとにかく多くの情報を手探りで探しながら取捨選択をしないといけません。 情報収集と分析を繰り返し、解像度を徹底的に向上させることが成功に繋がります。

  • 社内議事録を片っ端から読む
  • 施策上の関係者と連携し、案件の状況や担当者の困りごとのヒアリング
  • 関わっている案件の人と雑談する中で課題を拾い上げる

をし、関わっている案件の情報を積極的に入手しました。 それを持ち帰ったうえで、課題1の解決方法でも記載したPMの職務に照らし合わせて業務を遂行することで、 案件側とも信頼関係を築くことができました。

課題3. コンテキストスイッチが増えた

PMとしての業務が始まり、単純に考慮するべき事柄が増えたことで、日々の作業において多くのコンテキストスイッチが発生するようになりました。 それにより

  • 1つのタスクに深く没入しにくくなる
  • 疲れの増加

を実感する機会が増えました。

解決法

コンテキストスイッチが増えること自体は、PMとしての役割上避けられない側面もあります。 そこで、「サーヴァントリーダーシップ」というリーダーシップの1つのスタイルに着目しました。 このスタイルでは、リーダー自身が「仕える者(Servant)」としての役割を果たし、組織やチームメンバーを支えることを重視します。 その考え方に基づき、他のチームメンバーができるだけコンテキストスイッチに直面しなくて済むようにサポートすることに注力しました。

具体的には

  • 基本的に任せた仕事をやり切るまで別の仕事をアサインしない
  • 他部署とのコミュニケーションは基本的に自分がやる

の2点を意識しました。

この取り組みにより、メンバーが1つのタスクに没頭できる時間が増え、集中力向上とモチベーション維持に貢献できたと思います。 また、疲労感やストレスの軽減にも寄与できました。 この環境がよりよい成果を引き出す基盤となっています。

気づきと学び

信頼関係構築により、仕事の依頼が来る

PMとして業務を遂行する中で、改めて「人との信頼関係」の重要性を実感しました。 私はもともと人と話すことが好きだったことと、関東圏に引っ越したことを契機に意識的にオフィスへの出社を増やしました。

リモート環境が整った時代ではありますが、実際に現場に足を運び、対面でコミュニケーションを取ることで得られる 「現場の空気」や「表に出ない課題」を感じ取ることができると学びました。

また、それらをSWETに持ち帰り施策として取り組み案件に貢献しました。

結果として、案件の関係者から一定の信頼を得ることができ、 その信頼が新しい仕事の依頼に繋がることを実感しています。 いわゆる「仕事が仕事を呼ぶ」状態が生まれ、よいサイクルを構築できつつあります。

興味深いのは、信頼関係構築が進むにつれて、直接関わりがなかった別案件の関係者からも仕事の依頼をもらうケースが増えてきたことです。

チームをうまく回すコツを掴んだ

ゲームチームを運営する中で重視しているポイントをご紹介します。

締切の明確化

稀に案件側から相談が来ることがあり「優先度は高くないので、手が空いたらやってほしい」といわれることがありました。 一見できるときにタスクをやればいいと思いますが実は違いました。 どんなタスクにも、完了したら喜ばれる時期があります。 たとえば

  • QAに入るまでに完了しているとQAの負荷が減らせる
  • バージョンx.x.xまでに完了しているとリリースに間に合う

などです。依頼主が締切を示さなかった場合はPMが締切を提案するのがベストだと学びました。

コミュニケーションの取り方の工夫

最初自分は

  • できるだけMTGを減らす
  • テキストコミュニケーションでできるだけやり取りを減らす

ことがコミュニケーションのベストプラクティスだと考えていました。 しかし、PMをするうえでさまざまなバックグラウンドの人とコミュニケーションを取る必要があります。 中には、自分がベストプラクティスだと考えていたコミュニケーションが「堅苦しい」印象を与えてしまうこともあったようです。

なので、相談する相手に応じてコミュニケーションの取り方を工夫する必要があることを学びました。 工夫することで、より多くの情報を相手から引き出すことができます。

自分のモチベーションの変化

最初自分がSWETに配属されたときのモチベーションは「開発者の開発体験をよくするためのツール開発をしたい」でした。 しかし、PMになったときに上記のモチベーションはできることを自ら狭めていることに気づきました。 今ではPM業務を通じてQCD(Quality, Cost, Delivery)への影響を意識するようになり、幅広い視点で施策を検討することが自分の新しいモチベーションになりました。

未経験から学んだこと、これからの展望

PMとしての業務を通じて、視野を広げることの重要性を痛感しました。 今後はよりよい施策管理を実現するとともに、チームメンバーの成長や業務環境をよりよくするためのチームビルディングに注力していきます。 さらに、自身のマネジメントスキルを深め、SWET全体の発展に貢献していきたいと思います。

本記事が皆様の参考になれば幸いです。 DeNA Testing Blogではソフトウェアテストやその隣接領域に関する記事や勉強会の登壇資料などを投稿しています。 また、弊社ではDeNA EngineeringDeNA公式Xアカウント@DeNAxTechでも情報の発信をしております。 ブックマーク・フォロー等よろしくお願いします!

CI/CD基盤のコスト削減とDocker Hubのレートリミットを回避するためのミラーサーバーを導入した話

こんにちは、SWETの川口 ( @yamoyamoto ) です。SWETではCI/CDチームの一員として、GitHub Actionsセルフホストランナー基盤の開発・運用に取り組んでいます。

今回は、私たちのチームがDocker Hubのレートリミット回避とコスト削減のためにミラーサーバーを導入した事例を共有したいと思います。

目次

はじめに

近年、多くの組織でGitHub Actionsを始めCI/CDの活用が進み、その中でDockerコンテナの利用も当たり前のものとなっています。私たちの組織でも、さまざまなチームがCI/CD基盤 (CircleCI Server・GitHub Actionsのセルフホストランナー等) 上でDocker Hubのイメージを利用していますが、規模が大きくなるにつれて2つの重要な課題が顕在化してきました。

第一に、深刻な問題としてDocker Hubのレートリミットがあります。2024/12からは未認証時のpull数の制限が「10 pull/時間」 1 となっており、全社で共有利用しているCI/CD基盤ではこの制限だとすぐにレートリミットに達してしまいます。2

第二に、インターネット通信に関わるコストの問題です。私たちのCI/CD基盤はAWS上のプライベートサブネットに配置されているため、Docker HubからのイメージpullはNAT Gateway経由での通信となり、転送料金が発生します。一見少額に見える料金も、イメージが何度もpullされるCI/CD基盤では無視できない額となります。

本稿では、これらの課題に対してDocker Hubのミラーサーバーを構築することでどのように解決したのか、その検討過程から実装、運用結果までを共有したいと思います。

課題解決に向けた検討

先述した課題に対する解決策についてまず始めに思いついたのは、CI/CD基盤側にてあらかじめ用意したDockerアカウントでdocker loginしておくことで、認証のstepを入れていないユーザーも認証済みの状態でDocker Hubからpullできるようにする方法でした。しかし、この方法ではDocker Hubのレートリミット問題は解決できますが、通信コストの問題には対応できません。

次に考えたのは、Docker Hubのミラーサーバーを利用する方法でした。ミラーサーバーはクラウドサービスとして提供されているものを利用するか、自前で構築するかの2つの選択肢があります。今回は比較検討した結果、Distributionというツールを使って自前でミラーサーバーを構築する方法を選択しました。先に結論として今回構築したミラーサーバーの構成を次に示します。

ミラーサーバーの構成図

以降では、この選択に至るまでの検討および検証プロセスについて紹介します。

既存ソリューションの検討

まず初めに、Google Cloud・AWSで提供されている既存のソリューションを調査しました。結論としては、これらのソリューションでもレートリミットは解消できそうでしたが、通信コストの問題やイメージの指定方法に癖があったりと、十分な解決策にはならなさそうということで採用を見送りました。

Google Cloud - Artifact Registry (mirror.gcr.io)

Google Cloud が提供する mirror.gcr.io は、Docker Hubのレートリミット問題を解決できる魅力的な選択肢でした。しかし、私たちのCI/CD基盤はAWS上で運用されているため、Google Cloudへのアクセスによる新たな通信コストが発生するため、今回課題としてあげている通信コストの問題には対応できません。

AWS - Amazon ECR Public

同じくDocker Hubのミラーサーバーとして機能する Amazon ECR Public も検討しました。AWS環境との親和性は高いものの、イメージURLをたとえば nginx:latest であれば public.ecr.aws/nginx/nginx:latest のようにPrefixがついた形に変更する必要があります。これには次の2つの課題がありました。

  • Dockerfileなどで利用するイメージのURLは nginx:latest のようなDocker Hubから取得する形式であることが一般的なこともあり、組織全体でのURL形式の変更は大きな手間となる
  • GitHub Actionsのサードパーティ製Docker actionでは、Dockerfileへの対応(forkなど)を必要とすることがあり、これは管理・運用コストの増加につながる

Distribution を利用したミラーサーバーの構築

これらの検討を経て、私たちは CNCF が提供する Distribution を利用した独自のミラーサーバーの構築を選択しました。

Distributionは OCI Distribution Spec を実装したコンテナレジストリサーバーです。このDistributionを Pull Through Cache として構成し、Dockerライセンスを割り当てます。そしてクライアント(docker daemon, buildkit)からDistribution経由でイメージをpullするように設定することで、CI/CD環境からのDocker Hubへのpullレートリミットの制限を緩和します。

この選択には次のような利点があります。

  • CI/CDジョブからイメージ取得時のURL形式において、Docker Hubとの完全な互換性を保つことができる
    • つまり、ミラーサーバーの場合でもDocker Hubと同じURLでイメージをpullできるため、既存のCI/CDジョブの変更が不要
  • ネットワーク転送コストの削減が期待できる
    • キャッシュヒット時はDocker Hubへの通信が発生しないため、通信コストを削減できる

他にも、OCI Distribution Specを実装した有名なコンテナレジストリサーバーとして Harbor というツールもあります。しかしこのツールは多機能なため、運用コストは高くなる可能性がありました。後述しますが、その点Distributionはシンプルながら私たちの求める機能を備えており、パフォーマンスについても問題なかったため、今回はDistributionを選択することにしました。

Distribution について

リクエストの処理フロー

Distributionをミラーサーバーとして構成する場合、その挙動はシンプルです。クライアント(今回でいえばCI/CDジョブ上のdockerdやbuildkitなど)からのイメージのpullは次のように処理されます。

  1. ミラーサーバーのストレージにキャッシュが存在するか確認
  2. キャッシュが存在する場合はキャッシュから返却
  3. キャッシュが存在しない場合は、Docker Hubからイメージを取得してストレージにキャッシュした上で返却

この仕組みにより、2回目以降の同一イメージへのリクエストは、Docker Hubにアクセスすることなく処理できます。

検討が必要だったポイント

Distribution導入やその後の運用に関して、特に次の点について検討が必要でした。

  1. ストレージ選定(S3 vs filesystem)
    • コストとスケーラビリティ観点でS3を利用したいが、実際のワークロードで十分なパフォーマンスが出せるか確認が必要
  2. 運用に必要なメトリクスの確保
    • キャッシュヒット率やDocker Hubとの通信量など、運用に必要なメトリクスが取得できるか確認が必要
  3. 利用されていないイメージの削除について
    • キャッシュの容量が増加し続けるとストレージコストが増大するため、不要なイメージを削除する仕組みが必要

先に結論を述べます。

1についてはミラーサーバーの台数を増加させてS3への書き込みを並列化することで十分なパフォーマンスが出せることを確認しました。

2についてはAWSリソースの標準メトリクスと、Distributionが提供するPrometheusメトリクスを合わせることで運用上問題がないことを確認しました。

3については、Distributionの提供する機能で十分対応できることを確認しました。

以降では、これらの調査結果について詳しく解説していきます。

ストレージの選定とパフォーマンス検証

DistributionではストレージバックエンドとしてS3またはfilesystemを選択できます。先述したとおり、S3はコストとスケーラビリティの観点で魅力的ですが、CI/CD基盤から利用する上での実際のパフォーマンスはどうなのか検証が必要でした。

そこで、実際の利用パターンを想定した次のような観点でベンチマークを実施しました。

  • 複数個の並列ジョブからのdocker pull(5並列と50並列)
    • 5個のジョブの並列実行は、CI/CD基盤の通常時の想定
    • 50個のジョブの並列実行は、CI/CD基盤のピーク時の想定
  • キャッシュヒット時とミス時のそれぞれのケースでの測定
    • キャッシュヒット時はストレージからの読み込み、キャッシュミス時はストレージへの書き込みとなりワークロードの性質が異なるため、それぞれでパフォーマンスを検証する必要がある

このベンチマークでは、主要なコンテナイメージ(nginx, python, node, mysqlなど)約100個を対象に測定しました。実際のCI/CDジョブ環境に近づけるため、本番環境で利用している構成と同一のGitHub Actionsのセルフホストランナー上でジョブを実行し、docker pullを行うstepが完了するまでの時間を計測しました。また、現状のCI/CD基盤の構成(ミラーサーバーなし)でも計測してパフォーマンスを比較しています。

計測に利用したジョブの定義

name: Benchmark Image Pull with Pull Through Cache

on:
  workflow_dispatch:
    inputs:
      parallel:
        description: "Number of parallel jobs"
        required: true
        type: number

jobs:
  pull_images:
    runs-on:
      group: swet
      labels:
        - swet-test

    strategy:
      max-parallel: ${{ fromJson(github.event.inputs.parallel) }}
      matrix:
        image:
          - nginx:1.25.3
          - python:3.12.0
          - node:20.9.0
          (省略)

    steps:
      - name: Pull Image
        run: docker pull ${{ matrix.image }}

      - name: Image Details
        run: docker image inspect ${{ matrix.image }}

まず結論としては、S3 は十分なパフォーマンスを発揮できることがわかったため、本番環境では S3 をストレージバックエンドとして採用することにしました

以降では、ベンチマークの結果を詳しく解説します。

S3、filesystemの構成

ベンチマークするにあたって、S3とfilesystemの構成は次のようにしました。

  • S3
    • バケットはミラーサーバーと同じリージョンに配置して、VPC Endpoint(Gateway)経由で接続
    • ストレージクラスはスタンダードを利用
  • filesystem

Distributionのストレージ設定については、以下の環境変数を設定することで切り替えが可能です。詳細はドキュメントを参照してください。

S3の場合
REGISTRY_STORAGE=s3

REGISTRY_STORAGE_S3_BUCKET=sample-bucket   # S3バケット名
REGISTRY_STORAGE_S3_REGION=ap-northeast-1  # S3リージョン
filesystemの場合
REGISTRY_STORAGE=filesystem

REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY=/var/lib/registry  # イメージを保存するディレクトリパス (EBSのマウントパスと合わせる必要がある)

ミラーサーバーなしの場合の結果

まずは既存と同じ状況である、直接Docker Hubからpullする場合のパフォーマンスをベースラインとして計測しました。次に計測結果を示します。

ケース ジョブ並列数 docker pull にかかった時間の平均値
ミラーサーバー経由なし 5 37.28 s
ミラーサーバー経由なし 50 43.16 s

以降では、このベースラインと比較してS3、filesystemをストレージとしたミラーサーバーのパフォーマンスを検証します。

ミラーサーバーありの場合の結果

キャッシュヒット時の結果

キャッシュヒット時の結果は良好でした。次に計測結果を示します。

ケース ジョブ並列数 docker pull にかかった時間の平均値
S3 5 37.3 s
filesystem 5 36.7 s
S3 50 42.8 s
filesystem 50 42.1 s

S3、filesystemともに、ミラーサーバーなしの場合とほぼ同等のパフォーマンスを示し、特に並列数が増えた場合でも大きな劣化は見られませんでした。

ストレージにS3を使用した場合、Distributionはキャッシュヒット時にS3へ保存されているイメージ(blob)への署名付きURLに307でリダイレクトします。次にイメージ図を示します。

キャッシュヒットした場合の流れ

図の通り、リダイレクトによってミラーサーバーを経由せずにクライアントが直接S3からデータを取得できる仕組みとなっています。これにより、DistributionはS3にファイルが存在するかどうかと署名付きURLの発行のみを行えばよく、それにより大きなサイズのイメージをDistributionが直接返す必要はなくなります。結果、処理が比較的軽く、50個程度のジョブの並列実行であればパフォーマンスの劣化がほとんど見られないと考えられます。

キャッシュミス時の結果

一方、キャッシュミス時には興味深い結果となりました。次に計測結果を示します。

ケース ジョブ並列数 docker pull にかかった時間の平均値
S3 5 37.2 s
filesystem 5 37.4 s
S3 50 109.1 s
filesystem 50 44.3 s

50個のジョブの並列実行の際にS3が109.1秒と、大幅なパフォーマンス低下が見られました。原因にあたりをつけるために、まずはキャッシュミスした時の処理フローを確認します。次にイメージ図を示します。

キャッシュミスした場合の流れ

図のとおり、キャッシュミスしたときは次のような流れで処理が行われます。

  1. DistributionがDocker Hubからイメージを取得(②・③)
  2. 取得したイメージをキャッシュとしてS3に保存(④・⑤)
  3. クライアントにイメージを返却(⑥)

この流れにおいて、キャッシュヒットした時との違いはS3への書き込みが発生することです。 それを踏まえて、キャッシュミス時のパフォーマンス低下に対して以下2つの仮説が立ちました。

  1. Distribution(ミラーサーバー)側の問題で、マシン1台あたりのS3への並列書き込みがボトルネックとなっている
  2. Fargateのネットワーク帯域制限がボトルネックとなっている

1については、S3は巨大な分散システムであり、書き込みの並列度を上げることでパフォーマンスを向上できるためFargateのスケールアウトで対応できそうです。3 2については、帯域を増やすためにFargateのスケールアップかスケールアウトのどちらかで対応できそうです。

そこで、両仮説に対して効果がありそうなスケールアウト、つまりFargateの台数を増やすことで再検証しました。次に計測結果を示します。

ケース ジョブ並列数 docker pull にかかった時間の平均値
Fargate 1 台 (S3) 50 109.1 s
Fargate 2 台 (S3) 50 65.1 s
Fargate 5 台 (S3) 50 42.3 s
Fargate 10 台 (S3) 50 41.7 s

結果より、スケールアウトによってキャッシュミス時のパフォーマンスの問題を解消できることが確認できました。4 50個のジョブの並列実行という状況では5台程度にスケールアウトすることで、ミラーサーバーなしの場合と同等のパフォーマンスを実現できています。

仮にミラーサーバーへのアクセスが集中してきたとしても適切にスケーリング設定をすれば、S3 はストレージバックエンドとして十分に実用的であるという結論に至りました。

Distribution が提供するメトリクスの調査

運用面では、必要なメトリクスを取得できるかどうかが重要なポイントです。ミラーサーバーはAWSで構成する予定であったため、AWSリソース(ALB, ECS, S3)の標準メトリクスも利用できますが、それを踏まえてもミラーサーバーレベルで次のようなメトリクスを取得できることが望ましいと考えていました。

  • キャッシュヒット数/率
    • ミラーサーバーのキャッシュ効果を評価するために必要
  • Docker Hubとの通信量
    • キャッシュヒット率と組み合わせて通信量の削減効果を評価するために必要

Distributionは標準で Prometheus メトリクスを HTTP で公開する機能を備えていますが、具体的にどのようなメトリクスが取得できるかはドキュメントに詳しい記載がありませんでした。

そこで、ドキュメントにしたがって次のように環境変数を設定してDistributionを起動し、取得できるメトリクスを調査しました。

REGISTRY_HTTP_DEBUG_PROMETHEUS_ENABLED=true
REGISTRY_HTTP_DEBUG_PROMETHEUS_PATH=/metrics


この設定によって出力されたメトリクスの中から、次のメトリクスが運用において特に有用と判断しました。

  • キャッシュヒット数・キャッシュミス数: registry_proxy_hits_total, registry_proxy_misses_total
    • これらからミラーサーバーのキャッシュヒット率が算出できる
  • Docker Hubからpullしたデータ量: registry_proxy_pulled_bytes_total
    • これとキャッシュヒット率から通信量の削減効果を評価できる
  • ストレージの操作パフォーマンス: registry_storage_action_seconds
    • ストレージへの書き込みや読み込みのパフォーマンスを把握して適切なスケーリング設定に活用できる

参考:Prometheusメトリクスの出力結果

※ 主要なメトリクスを抜粋

$ curl 'http://<Host>:<port>/metrics'
# HELP go_gc_duration_seconds A summary of the pause duration of garbage collection cycles.
# TYPE go_gc_duration_seconds summary
...
go_gc_duration_seconds{quantile="0.5"} 5.2463e-05
go_gc_duration_seconds_sum 0.019355264
go_gc_duration_seconds_count 32
...
# HELP go_goroutines Number of goroutines that currently exist.
# TYPE go_goroutines gauge
go_goroutines 49
...
# HELP go_threads Number of OS threads created.
# TYPE go_threads gauge
go_threads 5
# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 44.92
...
# HELP registry_proxy_hits_total The number of total proxy request hits
# TYPE registry_proxy_hits_total counter
registry_proxy_hits_total{type="blob"} 0
registry_proxy_hits_total{type="manifest"} 36
# HELP registry_proxy_misses_total The number of total proxy request misses
# TYPE registry_proxy_misses_total counter
registry_proxy_misses_total{type="blob"} 260
registry_proxy_misses_total{type="manifest"} 65
# HELP registry_proxy_pulled_bytes_total The size of total bytes pulled from the upstream
# TYPE registry_proxy_pulled_bytes_total counter
registry_proxy_pulled_bytes_total{type="blob"} 5.956054585e+09
registry_proxy_pulled_bytes_total{type="manifest"} 186051
...
# HELP registry_storage_action_seconds The number of seconds that the storage action takes
# TYPE registry_storage_action_seconds histogram
registry_storage_action_seconds_bucket{action="Delete",driver="s3aws",le="0.005"} 0
...
registry_storage_action_seconds_sum{action="Delete",driver="s3aws"} 0.800432593
registry_storage_action_seconds_count{action="Delete",driver="s3aws"} 22
...

Telegraf でのメトリクス収集

先ほど示したPrometheusメトリクスは、Telegraf というエージェントツールを利用することで収集し、CloudWatchへ送信できます。Telegrafの設定の際には次のようなポイントがあります。

  • Input Pluginとして inputs.prometheus を利用する際、metric_version は1に設定する (v1.31.3時点)
    • metrics_version をより新しい2に設定するとnamepassで指定したメトリクスがCloudWatchに出力されない (v1.31.3時点)
  • 多くのメトリクスは累積値(_totalサフィックスがついているもの)となっているため、そのままではメトリクスから直感的なインサイトを得るのが難しい
    • Telegrafの aggregators.basicstats を利用して差分を取得することで特定期間ごとの増分を取得できる
  • 複数インスタンスで運用する場合の、hostごとのメトリクスと全体のメトリクスの区別について
    • デフォルトではメトリクスにhostタグ (CloudWatchメトリクスにはこれがDimensionとして出力される) が付与されるため、これをそのまま利用すると全hostを集約したメトリクスを得るのが難しい
    • Telegrafの processors.clone でhostごとのメトリクスを複製したのち、processors.override でhost Tagをexcludeすることで、hostごとのメトリクスと全体のメトリクスを分離できる


これらのポイントを踏まえたTelegrafの設定例を参考までに記載しておきます。

Telegraf の設定例

[agent]
interval = "60s"
skip_processors_after_aggregators=true

[[ inputs.prometheus ]]
urls = ["http://<host>:<port>/metrics"]

# ここに取得したいメトリック名を指定する
namepass = [
"registry_proxy_hits_total",
"registry_proxy_misses_total",
"registry_proxy_pulled_bytes_total",
"registry_storage_action_seconds"
]

# 2 だと namepass を指定した時に CloudWatch Metrics に吐かれなくなってしまう
metric_version = 1

# host ごとのメトリックとは別に全 host で集約されたメトリックを CloudWatch に吐くために clone して、後続の processor.override で host タグを除外する
[[ processors.clone ]]
name_suffix = "_all"

[[processors.override]]
namepass = ["*_all"]
tagexclude = ["host"]

# registry から提供されるメトリック値は全て累積値であるため、ひとつ前のメトリック値との差分を取ったものを出力する
[[aggregators.basicstats]]
period = "5m"
stats = ["diff"]

# 累積値はさほど有用でないため、Cloudwatch に出力しない
drop_original = true

[[ outputs.cloudwatch ]]
region = "ap-northeast-1"
namespace = "container-registry-mirror"


ここまでの調査で、Distributionが提供するメトリクスとAWSリソースの標準メトリクスを組み合わせることでミラーサーバーの状態を適切に観測できると判断しました。

利用されていないイメージの削除について

ミラーサーバーはさまざまなイメージをキャッシュしますが、利用されないイメージがキャッシュとして溜まっていくとストレージコストが増大するため、利用されていないイメージは定期的に削除する仕組みが必要です。

調査したところ、Distributionではキャッシュの TTL(Time To Live)を設定することで、一定期間利用されていないイメージの自動削除が可能となっています。

そこでドキュメントにしたがって次の環境変数を設定してDistributionを起動したところ、期待どおりキャッシュされてから10分後にイメージがストレージから削除されていることを確認しました。

# TTL を 10 分に設定
REGISTRY_PROXY_TTL=10m

また、細かいところですが次のようなポイントも確認し、運用上の問題は少ないことを確認しました。

  • キャッシュヒットが起こった場合、TTLがリセットされるため、頻繁に利用されているイメージは削除されない
    • ここですでに古いキャッシュエントリがある場合は、新しく削除をトリガーするタイマーを起動し、古いタイマーは無効化している
  • キャッシュエントリはストレージに永続化されているので、Distributionの再起動後もキャッシュエントリは有効
    • そのため、インスタンスが入れ替わったとしてもキャッシュエントリは維持される

本番環境での構成

ここまでの調査を踏まえ、本番環境での構成を決定しました。冒頭で示した構成を再掲します。

ミラーサーバーの構成図

図にあるとおり、Distributionをコンテナで動作させる環境としてECS on Fargateを採用しました。以下がEC2ではなくFargateを採用した理由になります。

  • 運用の簡素化のため
  • ストレージにS3を利用してもパフォーマンス的に問題ないことがわかったため
    • ストレージにfilesystemを使う前提だと、ベンチマークで行ったようなFargateにEBSをアタッチする形式の場合はECS Taskの終了と合わせてEBSも削除されるため、Taskの入れ替わりによってキャッシュが全て消えてしまうという懸念があった
    • この懸念を解消するためには、ECS on EC2にしてEC2インスタンスからECS Taskにボリュームをマウントする必要がある
      • しかし、ストレージにS3を採用できることがわかったことで対応の必要はなくなった

また前段にALBを配置し、ストレージはベンチマークで十分な性能が確認できたS3を採用しています。併せて、ECS Task上ではDistributionに加えてメトリクス収集用のTelegrafをサイドカーとして配置して適宜CloudWatch Metricsへ出力させています。

スケーリング

スケーリングについては、App Autoscalingの Target tracking policy を利用して自動化を図っています。具体的には、ベンチマークで計測したFargate 1台あたりの適正リクエスト数を基準として設定し、ECS Serviceのdesired countを自動的に増減させる設定としました。

このスケーリング設定について、ミラーサーバーは比較的サイズの大きなコンテンツをやり取りするサービスなので、ネットワーク通信の負荷が支配的という特性をもつことが推測されます。S3をストレージに採用した場合、キャッシュヒット時は307リダイレクトによってクライアントが直接S3からデータを取得する仕組みとなっていることから、Fargateインスタンス自体はネットワーク通信の負荷はさほど高くない可能性が高いと考え、今はリクエスト数ベースでのスケーリングを採用しています。

セキュリティ

ミラーサーバーはDocker Hubのパブリックなイメージのみをミラーしており、クリティカルなデータをもつことはないため、セキュリティ面ではできるだけシンプルな構成を目指しました。Distributionが提供するOAuth2やJWT認証の機能は使用せず、代わりにVPC内のプライベートサブネットへの配置とセキュリティグループによる制御を採用しました。これにより、社内のCI/CD基盤からの安全なアクセスを、運用負荷を増やすことなく実現できています。

メトリクスの可視化

運用面では、ミラーサーバーの稼働状況をいつでも一目で確認できるように、主要なメトリクスを集めたCloudWatchダッシュボードを構築しました。

このダッシュボードには運用時に有用なリクエスト数、エラーレート、キャッシュヒット率、upstreamからのpullしたバイト数、リソース使用率、ストレージ使用量などのメトリクスを表示しています。ここでは主要なグラフを抜粋して示します。

週次のリクエスト数 / エラーレート(5xx) / キャッシュヒット率

1 時間ごとのミラーサーバーへのリクエスト数

ALB がミラーサーバーからレスポンスを受け取るまでのレイテンシ

上記のグラフでは、高いパーセンタイル値で度々レイテンシが高くなっていますが、リクエスト数のピークとはズレた時間帯に突発的に発生しているため、これは恐らくサイズの大きいイメージをpullしたときのものと考えられます。

S3 Bucket のサイズ / オブジェクト数

導入による効果

2024年11月現在、導入から数週間が経過していますが特にエラーもなく効率的に運用できています。主要なメトリクスについては、前のセクションに載せているグラフを参照ください。

まず注目すべき点としては、約97%という非常に高いキャッシュヒット率を達成できていることです。これは、CI/CD環境における同一イメージの再利用が多いという特性をうまく活かせた結果だと考えています。

処理性能の面では、週間約14万件のリクエストを安定して捌けており、ピーク時には1分間あたり600リクエストほどのアクセスがありましたが、1 vCPU, 2GB MemoryというコンパクトなFargateインスタンスで問題なく対応できています。この結果は、事前のベンチマークで得られた結果が実環境でも有効であることを示しています。

ミラーサーバー導入によりコスト面での効果も得られています。Docker Hub(upstream)への通信量を週50GB程度まで削減できており、これはキャッシュヒット率から推測すると、ミラーサーバーがないときと比べて週あたり1600GB以上の通信量削減を実現できていることになります。また、S3のストレージ使用量も60GB程度で安定しており、97%という高いキャッシュヒット率を維持しながらも、ストレージコストを適度な範囲に抑えることができています。

まとめと今後の展望

Docker Hubのミラーサーバーにより、コストとレートリミットの問題を効果的に解決できました。

今後の課題としては以下を検討しています。

  1. Fargateのスケーリング設定の最適化
    • リクエスト数ベースのスケールよりも適切なスケーリング戦略があるか検討
  2. Docker Hubからのpull残数についてのモニタリング強化
    • 万が一にもミラーサーバー側でレートリミットが発生してしまわないように、pull数の残量とその消費傾向の把握をしたい
    • この情報を元にして、必要に応じてライセンスのローテーションの仕組みを入れるなどの判断を行えるようにしたい

本事例が、同様の課題に直面している方々の参考になれば幸いです。質問やコメントがありましたら、ぜひXなどでお気軽にご連絡ください。

また、DeNA公式Xアカウント @DeNAxTechでは、このようなブログ記事や勉強会での登壇資料を発信しています。ぜひフォローをお願いします!


  1. https://www.docker.com/ja-jp/blog/november-2024-updated-plans-announcement/
  2. 今回はGitHub Actionsのセルフホストランナー環境へミラーサーバーを導入しましたが、CircleCI Serverではすでにミラーサーバーを導入しており、レートリミット問題を解決していました。ただし既存のCircleCI側のミラーサーバーの構成そのままではGitHub Actionsのセルフホストランナーから利用することが難しかったため、今回は新しく統一的に利用できるミラーサーバーを構築することで運用・インフラコストを削減を目指しました。
  3. https://docs.aws.amazon.com/AmazonS3/latest/userguide/optimizing-performance-guidelines.html#optimizing-performance-guidelines-scale
  4. 立てた2つの仮説のうち、実際にはどちらがボトルネックだったのかまでは検証できていません

フロー解析を実装した静的解析ツールをOSS公開しました

こんにちは、SWETの秦野です。

2024/8/22のCEDEC2024にてIKさんと私でRoslynアナライザーに関する発表を行いました。 そして先日、本発表で紹介した静的解析ツールをOSSとして公開しました。

本記事では、CEDECの発表内容と公開したツールについて紹介していきます。 また、本ツールで実装されている静的解析技術(主にフロー解析)について解説します。

CEDEC2024での発表

発表資料はこちらにあるので、本ブログでは概要だけ紹介します。

本発表では、社内のあるゲーム開発プロジェクトにC#の静的解析ツールを開発・導入した事例を紹介しました。

静的解析ツールはRoslynアナライザーとして実装しました。 本ツールは構文解析だけでなくフロー解析を行っていて、文の実行順序や変数の定義と参照の関係を認識できます(この詳細については後述します)。

弊社では本ツール以外にも複数のRoslynアナライザーを導入していて、不具合の早期発見に役立てています。 これらのツールで検出したコードの情報をクラウド上に蓄積しダッシュボード化することで、コードの品質を観測できるようにしています。

公開した静的解析ツール

今回公開したMustAwaitAnalyzerTaskUniTaskawait忘れを検出する静的解析ツールです(以後、Taskを例に説明しますがUniTaskでも同様です)。

C#では、Taskクラスとasync/awaitキーワードを利用することで非同期処理を簡潔に記述できます。 たとえば下記のようなコードで、DeleteAsyncメソッドを実行しながら別の処理を実行できます。

public async Task DoAsync()
{
    using var client = new HttpClient();
    var task = client.DeleteAsync("https://example.com/hoge");

    // 何か別の処理をする

    await task;
}

async/awaitの利点の1つは、非同期に実行したメソッドで例外が発生したとき、その呼び出し元に例外を伝播してくれるという点です。 上記のコードの場合、DeleteAsyncメソッドで例外が発生すると、その呼び出し元であるDoAsyncメソッドに伝播され例外を発生させます。

しかし、awaitを記述せず、かつTaskクラスのExceptionプロパティをチェックしなかった場合、呼び出し先の例外が捕捉されません。 このようなコードは意図しない挙動を引き起こすことがあります。

public async Task DoAsync()
{
    using var client = new HttpClient();
    var task = client.DeleteAsync("https://example.com/hoge");

    // 何か別の処理をする

    // await task; awaitを忘れると、DeleteAsyncで例外が発生しても捕捉されない
}

このようなawait忘れを検知する静的解析ツールMustAwaitAnalyzerを開発しました。

フロー解析の導入

開発当初は構文解析によってawait忘れを検知していました(発表スライドのこのあたり)。 構文木を探索し、Task型の変数やTask型を返すメソッド呼び出しの親ノードがawait式であれば問題なし、そうでなければ警告するという検知ロジックです。 しかし、このロジックでは正しく検知できないことがありました。 たとえば以下のようなコードです。

async Task MultiTask(bool b1)
{
    var list = new List<Task>(); // s1
    var task1 = DoAsync();       // s2
    if (b1)                      // s3
    {
        var task2 = DoAsync();   // s4
        list.Add(task2);         // s5
    }
    list.Add(task1);             // s6
    await Task.WhenAll(list);    // s7
}

この例の場合、先述の検知ロジックではs2やs4のDoAsyncメソッドの呼び出しがawaitされていないという警告が出力されます。 しかし実際には、DoAsyncメソッドが返すTaskオブジェクトがリストに格納され、それらをs7でまとめてawaitするという問題のないコードです。 問題ないコードだと正しく判断するためには、以下のようにプログラムの実行の流れを追っていく必要があります(下記はs2のDoAsyncメソッドについてですが、s4でも同様です)。

  1. s2で、Task型を返すメソッドが呼び出されtask1に代入される
  2. s6で、s2で代入されたtask1がTask型のList(list)に追加(Add)される
  3. s7で、listを引数にTask.WhenAllメソッドが実行されている
  4. s7で、そのTask.WhenAllがawaitされている

このように、ある文の実行が変数を経由して別の文の実行に影響を与えるという依存関係を明らかにする必要があります。 これを実現する静的解析技術がフロー解析(flow analysis)です。

フロー解析には制御フロー解析とデータフロー解析の大きく2種類があります。 この2種類のフロー解析について説明します(詳細を知りたい方は末尾のリファレンスも参照してください)。 フロー解析でよく出てくる用語も紹介しながら記述しているので、コードリーディングや文献調査の際に参考になればと思います。

ちなみに、MustAwaitAnalyzerのフロー解析の実装はこのあたりです。

制御フロー解析

制御フロー解析では、文の実行順序、分岐、合流を解析し、それらを有向グラフで表現します。 下図はMultiTaskメソッドの例です。

このグラフを制御フローグラフ(control flow graph)と言います。 制御フローグラフの各ノードは、その途中に分岐も合流もない文の列になっています。 たとえばノードB1はs1, s2, s3で構成されていますが、s3から分岐が始まるためs4からは別のノードB2になっています。 また、s6がその分岐の合流地点となるため、これも別のノードB3になっています。 このノードの単位を基本ブロック(basic block)と言います。

制御フローグラフを見れば文の実行順序が分かります。 基本ブロック内は上から下の順で実行されます。基本ブロック間は辺の接続元から接続先の順で実行されます。

MustAwaitAnalyzerの目的では、制御フローグラフはデータフロー解析のための準備という位置づけですが、 制御フローグラフだけで検出できる問題もあります。 たとえば実行されることのないコードの検出です。 C#ではCS0162としてこの問題が警告されますが、これは制御フローグラフを探索することで分かります。 Entryを起点に辺をたどっていき、到達できなかったノードが実行されないコードです。

Roslynでは制御フローグラフをモデル化したクラスControlFlowGraphが用意されています。 MustAwaitAnalyzerでもこのクラスを利用しています。

データフロー解析

データフロー解析では、ある代入文で定義された変数の値がどこで参照されるかという関係を解析します。 ある代入文pとある参照文qがあり、文pで定義された値を文qで参照するとき、pの定義がqに到達する(reach)と言います。 この到達の関係を明らかにするために制御フローグラフを使います。

変数xを定義する文pと参照する文qが同じ基本ブロックにあれば、qから最も近いpがqに到達します。 たとえばMultiTaskメソッドの変数task2はs4で定義され、s5で参照されます。 これは、基本ブロックにはその途中に分岐も合流もないという性質から導けます。 仮にs4とs5の間にtask2を定義する文sxがあった場合、sxがs5に到達し、s4の定義は無効になります。 そのため「最も近い」という条件が入っています。

変数xを定義する文pと参照する文qが異なる基本ブロックにある場合は、辺の接続元の基本ブロックにある定義文から接続先の基本ブロックにある参照文に到達します。 たとえばB1のs1で定義されたlistが到達するのはB2とB3の参照文です。つまり、s5, s6, s7になります。 また、s2で定義されたtask1が到達するのはs6です(B2にはtask1の参照文がない)。 ただし、定義文のある基本ブロックBxから参照文のある基本ブロックByに至るまでに同じ変数の定義文があった場合、Bxの定義はByに到達しません。 たとえば、仮にB1とB3の間に別の基本ブロックがあり、そこでtask1が定義されていたらs2はs6に到達しません。 このような計算手順は定式化されていてデータフロー方程式(data flow equation)と呼ばれています。

以上の手順で到達の関係を解析すれば、先述したプログラムの実行の流れを機械的に追うことができます。

  1. s2で、Task型を返すメソッドが呼び出されtask1に代入される
  2. s6で、s2で代入されたtask1がTask型のList(list)に追加(Add)される
  3. s7で、listを引数にTask.WhenAllメソッドが実行されている
  4. s7で、そのTask.WhenAllがawaitされている

4は構文解析で分かりますが、1から3の順で実行されることは制御フローグラフから分かります。 また、1で代入した変数task1が2でlistに追加されるという流れは、s2の定義がs6に到達することに対応しています。

Roslynでは、どの文でどの変数を定義および参照するかを取得できます(AnalyzeDataFlowメソッド)。 MustAwaitAnalyzerでもこのクラスを利用しています。

終わりに

フロー解析を取り入れた静的解析ツールは現状あまり多くないかもしれませんが、これまでレビューに頼るしかなかった部分を自動化できる可能性があります。 フロー解析は構文解析よりも実装コストがかかることが多いのですが、RoslynのAPIがあるおかげでかなり省力化できます。 今回公開したOSSに限らず、このような静的解析ツールを広めていきたいと考えています。

リファレンス

Android Test Night #10を開催します!

SWETグループ、23新卒の若松です。

今回、2024/12/12(木)にAndroid Test Night #10をハイブリット開催します。2024年2回目のAndroid Test Nightになります!

10回目の開催となる今回は、次の方々に登壇していただき、Androidのテストについての知見を共有していただきます。

  • 納庄 宏明さん:Compose UIテストを使った統合テスト:Data LayerからUIまで繋げてテストする
  • chigichan24さん:不具合調査とTest
  • mhidakaさん:Composeで便利なテスト

発表概要

納庄 宏明:「Compose UIテストを使った統合テスト:Data LayerからUIまで繋げてテストする」

忠実度の高い統合テストを自動化できれば、リファクタリングしやすくなり、バグの検知力も向上します。反面テストが期待に反する結果になったときのデバッグで苦労することがあります。本発表では統合テストの難しさに対応する方法を紹介します。

Robolectric/Roborazzi/Hilt

chigichan24:「不具合調査とTest」

Androidはサポートするべきバージョン、デバイスが多いため、開発者が意図しなかった不具合が発生することがあります。 不具合が発生したときに、これ以上同じような不具合を発生させないようにするために、どのように対応していくとよいか、主にテストの側面から話します。 また、不具合が発生していることを早期に発見するためにできることも合わせて話します。

mhidaka:「Composeで便利なテスト」

フルCompose環境でのテスティング手法やアプリ開発環境について、手間と効果をくらべてコスパがいい品質向上のプラクティスを発表します。

終わりに

Androidのテストについて興味がある方であれば、どなたでも参加いただけます。 日々触れている方、気にはなっているけど触ったことない方、他の会社の話を聞いてみたい方など、ぜひ奮ってご参加ください!オフライン限定になりますが、懇親会も予定しています。2024/12/12のAndroid Test Night #10でみなさんにお会いできるのを楽しみにしています!

PFDとPFD Draw Toolの紹介


こんにちは、SWETの伊藤(@akito0107)です。

SWETが所属する品質管理部では開発プロセスや検証プロセスなどの業務をProcess Flow Diagram(PFD)を使って可視化し、 チーム全体の認識合わせや業務効率化に活用しています。

この記事ではPFDそのものやPFDを用いた業務可視化の実例を紹介します。 その後、SWETで開発しているPFDを記述するためのツール、PFD Draw Toolを紹介します。

PFD(Process Flow Diagram)とは

PFDとは清水吉男氏がDFD(Data Flow Diagram)を基にして、人が行う作業に使えるようにアレンジしたものです[1]。

PFDでは開発プロセスや検証プロセスなどの業務プロセスを、成果物とプロセス(1つ以上の成果物を受け取って異なる成果物へ変換する操作)に注目して可視化します。

ここでは具体例を用いつつ、PFDの書き方と構成要素について説明をします。

注:ここからの説明は、DeNAの品質管理部で用いているPFDの説明になります。オリジナルのものとは一部表現が異なっています。

下にソフトウェア開発のプロセスの例をPFDで記述しました[2]。 このPFDでは、初期成果物の要求(=ソフトウェアで実現したいこと)から始まり、仕様策定、設計、実装を経て最終成果物のソフトウェアまで至る様子が表現されています。

図1: 開発プロセスのPFDの例

図1: 開発プロセスのPFDの例

PFDの構成要素の説明をします。 図1を見て分かるとおり、PFDには次の4つの構成要素があります。

要素 画像 説明
成果物
成果物
プロセスの入力または出力となる具体的なものを表し、四角で示します。成果物ごとに一意のIDが付与されます。
プロセス
プロセス
入力を出力に変換する作業または操作を表し、丸で示します。作業は手動で行われることもあるし、自動で実行されることもあります。プロセスごとに一意のIDが付与されます。入出力がないプロセスは存在しません。
矢印
矢印
成果物とプロセスの依存関係を表現します。
点線矢印
点線矢印
初回の実行では入力がないことを示し、成果物からプロセスに向けて使用されます。

PFDでは成果物やプロセスの依存関係を矢印で表現します。 たとえば、図1のP3はA2、A3に依存していますし、A3はP2に依存しています。これはP3を開始するためにはA2、A3の成果物が揃っていることが前提条件となることを表しています。

また、品質管理部では、メインとなる図に加え、成果物・プロセスそれぞれに要素表を用意しています[3]。 要素表とはPFDに書き切れない詳細な説明を記載する表で、 たとえば図1のPFDに対応する成果物の要素表は下のようになります。

ID 名前 概要
A1 要求 PRDとして企画側から提供されるもの
A2 仕様 画面の動作イメージおよび制約事項などが記載されているドキュメント
A3 設計ドキュメント API定義、シーケンス図、状態遷移図、DB設計を含んだドキュメント
A4 ソフトウェア ビルド済みで実行可能な状態のソフトウェア。スモークテストを完了させておく。
A5 実装中に見つかった設計のミス 実装の過程で見つかった設計のミスもしくは改善点。ドキュメントに反映させ、その後実装を修正する。

成果物の定義を詳細に書き下すことにより、成果物についての認識のずれが発生していないかを確かめることができます。 たとえば、A3はAPI定義、シーケンス図、状態遷移図、DB設計を含んだドキュメント と定義しています。 今回は例示のため簡単に書いていますが、 定義や成果物の完了条件などをあらかじめ明確にして作業者間で認識をそろえておくことにより、いざ実際に開発を進めた際にドキュメントの作成漏れや作業の漏れを防ぐことができます (なお、プロセスについても同様に要素表を作成します)。

より詳細なPFDの記述方法に興味がある方は[1]等を参照してください。

PFDのメリット

さて、PFDの大まかなルールと記法がわかったところで、PFDのメリットを考えてみましょう。

業務プロセスを可視化すること自体の有用性はさまざまな場所で語られていますが、改めて整理します。 特に次の2つが挙げられます。

  1. 業務の流れを誰でも把握できるようにする。
  2. 業務改善の土台になる。

たとえば、1については新任者がチームに入ってきたときの立ち上がりや、チーム内で認識を揃えるのに必要な要素です。 2について、それぞれの作業がどう繋がってるのかを観察することにより、業務の冗長性を排除したり、品質に問題がある作業をあぶり出して改善作業を行うことができます。

業務プロセスの可視化は、PFD以外にフローチャートやバリューストリームマッピングなど、さまざまな手法が存在しています。 それらに比べると、PFDは成果物に注目する、という特徴があります。

たとえばフローチャートでは業務の手続きに注目し、それらのアウトプットである「成果物」は省略して記述されます。 上の図1をフローチャートで書くと次のようになります。

フローチャートの例

PFDで書く場合に比べ、簡潔に記述できてはいますが、作業の順番は理解できてもそれらの関連性についての情報は希薄になっています。

いざ実行してみると、作業のゴールイメージがずれていたり、成果物不足で作業同士がつながらなかったりといったことがよくあります。 プログラマーの方でしたらプロセスの『型』を明記する、といった方が伝わりやすいかもしれませんが、 PFDではプロセスだけではなく成果物に注目することにより作業の目的とそれらの関係をより明確にでき、 プロセスの実行時の不確実性の排除やチーム内外との認識齟齬を防ぐのに役立ちます。

PFDのSWETでの活用方法

PFDの一般的な説明をしたところで、SWETで活用しているPFDの具体例をいくつか紹介しようと思います。

例1: レビュープロセス

SWETの業務として、プロダクトコードのレビューがあります。 ここでのコードレビューの目的は、不具合の可能性を早期に発見するということももちろんあるのですが、 SWETメンバーのテストや対象のプログラミング言語についてのノウハウをレビューを通じて、実装者(レビュイー)に対して伝達することにあります。

SWETで行われるレビューのプロセスは次のようになります。

図2: レビュープロセスのPFD

図2: レビュープロセスのPFD

ここで注目するのは、成果物としてA4, A5のノウハウといった不定形のものや人間の脳内にしかないものを扱っているという点です。 「成果物」と書くとドキュメントやソースコードなど、具体的なものをイメージしがちですが、PFDではこのような抽象的な対象についても扱うことを許容しています。

例2: 外部勉強会の運営プロセス

それではさらに複雑な例を見てみましょう。 SWETではTest Nightなど、外部の方々を招いた勉強会を主催しています。 その際の開催や運営のプロセスを可視化したものになります。 実際のものからはかなり省略・簡略化したものになりますが、関係各所との調整や、 何をどの順番で実行しなければいけないかが明確になっています。

図3: 外部勉強会の運営プロセスのPFD

図3: 外部勉強会の運営プロセスのPFD

このような複雑な業務プロセスでもPFDを活用することでプロセスを明確に表現でき、特にチームで作業する際には共通の理解を促進するのに役立ちます。

PFD Draw Toolについて

PFDについてその概要・メリット・具体的な事例を紹介してきました。 ですが、PFDの弱点として、その記述やメンテナンスが難しいという問題点があります。

成果物・プロセスそれぞれにつける一意なIDの付与や要素表の作成がその代表的なもので、 成果物やプロセスを生成するたびにIDを編集したり、表と図の対応をそろえたりと、 とにかく人力でPFDを整合性が保たれている状態に維持するのはかなりの労力を費やします。

そこでSWETではPFDの記述をサポートするツールを作成しました。 ツールはWebベースのアプリとして実装されていて、社内の人であれば誰でもアクセスできるようにしています。

ちなみに、この記事に掲載してあるPFD(図1 ~ 3)はすべてこのツールで記述しています。

PFD Draw Toolの機能

いくつか機能があるのですが、ここでは主要な機能を3つ紹介します。

ID自動採番

成果物、プロセス生成時に自動で連番のIDを付与します。 また、ここで付与されたIDは後で自然な順序(PFDのグラフ構造の始端が若く、終端に向かうにつれ番号が増えていく)に自動で振り直すこともできます。

ID自動採番機能

要素表自動作成

成果物、プロセスを作成時に自動で要素表にも追加します。 図の方で値を修正すると、要素表に自動反映します。またその逆も自動で行います。

要素表自動作成機能

PFDのルールのチェック

PFDには、記述上のルールがいくつか存在します。 たとえば成果物と成果物は矢印で繋げない(プロセス同士についても同様)、成果物はひとつのプロセスからしか生成されない、などです。 それらのルールに反したPFDをそもそも書けないようにする仕組みが実装されています。

PFDのルールチェック機能

PFD Draw Toolの技術スタック

簡単に実装に用いた技術スタックを紹介します。

実装にはReactをベースに状態管理ライブラリとしてRedux、Canvas上のコンポーネントライブラリとしてKonvaを使っています。

Reduxを用いることにより、この手のエディターの基本機能であるUndo / Redoや、ファイルへの書き出しの実装を簡易に実装できています。

まとめ

この記事ではPFDの紹介とSWETでの活用事例、SWETで開発したPFD Draw Toolについて紹介しました。 PFDは成果物にも注目して業務プロセスを可視化することにより、それぞれのプロセスの関係を明確にし、 実行時の不確実性を抑制し、作業者間での認識齟齬を防ぐのに役に立ちます。

SWETではこのPFDを用いて各種業務の可視化や効率化に活用し、さらにPFDの記述やメンテナンスを簡易にするツールを実装しました。

今回はPFDを用いた業務の改善・効率化については触れませんでしたが、また機会がありましたら紹介させていただこうと思います。

注釈

  • [1]: 梶本 和博、派生開発推進協議会T21研究会 著 『プロセスを自在に設計するーPFDを使いこなそうー』 八木 将計、八木 香織(編集)、NextPublishing Authors Press、2021年5月29日
  • [2]: ここではPFDの説明のために簡略化した開発プロセスを記述しています。実際の開発はここに検証の工程が追加されたり、「仕様を決める」というプロセスの中にも何工程も含まれたり、かなり複雑なものとなります。
  • [3]: オリジナルのPFDでは要素表ではなく、それぞれの成果物・プロセスに定義書を記述します。