TECH PLAY

株式会社メルカリ

株式会社メルカリ の技術ブログ

267

この記事は、 Merpay Tech Openness Month 2023 の4日目の記事です。 こんにちは。メルコインのバックエンドエンジニアの @goro です。 はじめに このGitHub Actionsのセキュリティガイドラインは、社内でGithub Actionsの利用に先駆け、社内有志によって検討されました。「GitHub Actionsを使うにあたりどういった点に留意すれば最低限の安全性を確保できるか学習してもらいたい」「定期的に本ドキュメントを見返してもらい自分たちのリポジトリーが安全な状態になっているか点検する際に役立ててもらいたい」という思いに基づいて作成されています。 今回はそんなガイドラインの一部を、社外の方々にも役立つと思い公開することにしました。 ガイドラインにおける目標 このガイドラインは事前に2段階の目標を設定して作成されています。まず第1に「常に達成したいこと」として「外部の攻撃者からの攻撃を防ぐ」こと。そして、第2に「可能であれば考慮したいこと」として「内部と同等の権限を持つ攻撃者からの攻撃を防ぐ」ことを目標としています。 ガイドラインの構成 このガイドラインは3部で構成されています。まず1部でGitHub Actionsにおいて起こりうる脅威を紹介しています。2部ではその脅威に対する対策を記載しています。そして最後の3部ではより実践的な対策を講じられるようにセルフチェックリストを用意しました。 それでは実際のガイドラインをお楽しみください。 GitHub Actions Guideline 脅威を知る 権限設定の不備を突く攻撃 Pull Requestを契機に起動するトリガー トリガーの基本的な仕組みについては参考情報の「ワークフローのトリガー」のセクションに記載した。 PRを契機に起動するトリガーは攻撃者がなにかを仕掛ける余地が大きい。不注意にワークフローを構築するとシークレットを外部に送信されて攻撃を受ける可能性がある。 シークレットなどを外部に送信される可能性 ビルドスクリプトに細工をする 依存関係にあるライブラリを悪意のあるものに差し替えられる 自動実行の仕組みに相乗りされる(npmのpreinstall, postinstallなど) 過去、人気のライブラリでローカルファイルをスキャンする事例があった https://ezoeryou.github.io/blog/article/2018-07-13-npm-malware.html 本ドキュメントにおけるシークレットという用語は、GitHub Organization、リポジトリ、またはリポジトリ環境で作成する暗号化された環境変数を意味する。詳しくは GitHubの「Encrypted secrets」 を参照。 上記の攻撃の結果、次のような被害が発生する可能性がある。 攻撃者に、悪意のあるアクションまたは侵害されたアクションによってGitHub Actionsの計算リソースを不正に利用される可能性がある 侵害された、または悪意のあるアクションによって、リポジトリの自動ワークフローが中断される可能性がある Deployment Keyやアクセストークンなどのシークレットへの読み取りアクセスは、攻撃者が他のリソースを侵害するために利用される可能性がある インジェクションによる攻撃 一見安全に見えるワークフローにおいてもコードやコマンドインジェクションを引き起こす可能性がある。 インジェクションによる攻撃例1 例えば、以下のようなコードにはインジェクションの脆弱性がある。 uses: foo/bar@2.0.1 with: comment: | Comment created by {{ event.comment.user.login }} {{ event.comment.body }} コメントに {{ 1 + 1 }} のような二重中括弧が含まれていた場合、Actionは内部で{{ }}の値を補間するためにlodashを使っているため、node.jsコードが実行され出力が2になる。 ワークフローのインラインスクリプトに直接インジェクションを配置するシナリオもある。また、ブランチ名やメールアドレスへのコマンドインジェクションもできる。 インジェクションによる攻撃例2 次のようなコードを例にする。 - name: Check PR title run: | title="${{ github.event.pull_request.title }}" if [[ $title =~ ^octocat ]]; then echo "PR title starts with 'octocat'" exit 0 else echo "PR title did not start with 'octocat'" exit 1 fi 内部の式 ${{ }} が評価され、結果の値に置き換えられるため、コマンドインジェクションに対して脆弱になる可能性がある。 攻撃者は a"; ls $GITHUB_WORKSPACE" といったタイトルのPRを作成する可能性がある 出典: Security hardening for GitHub Action この例では " を使用して title="${{ github.event.pull_request.title }}" ステートメントを中断し、ランナーでコマンドを実行できるようにする。lsコマンドの出力を確認できる。 > Run title="a"; ls $GITHUB_WORKSPACE"" README.md code.yml example.js インジェクションによる攻撃の影響 インジェクションをされると攻撃者は任意のコマンドを実行できるため、単純に攻撃者が管理する 外部のサーバーにシークレットを送信するHTTPリクエストを行うことが可能になる 。 リポジトリへのアクセストークンを取得してもワークフローが完了すると失効するので攻撃は簡単ではない。しかし、攻撃者が自動化し、管理するサーバーにトークンを呼び出して、コンマ数秒で攻撃を実行することは可能となる。その場合GitHub APIを利用してリリースを含むリポジトリのコンテンツを変更することが可能になる。 攻撃者は悪意のあるコンテンツを GitHub Context 経由で追加できる 潜在的に信頼できない入力として扱う必要がある これらのコンテキストは以下の文字列をinjectすることができる body, default_branch, email, head_ref, label, message, name, page_name,ref, title Ex: github.event.issue.title , github.event.pull_request.body たとえば zzz";echo${IFS}"hello";# は有効なブランチ名であり、ターゲットリポジトリの攻撃となる可能性がある。 対策を考える 最小権限の原則に従う 最小権限の原則( Wikipedia: 最小権限の原則 )は、ソフトウェアがタスクを達成するために必要な最小限の権限セットで実行されるべきであるというものになる。これは、ワークフローで利用可能な シークレット の権限と、ワークフロートリガーの種類に基づいて自動的に提供される一時的なリポジトリトークンの両方に当てはまる。 自動的に提供されるリポジトリトークンGITHUB_TOKENの権限は、フォークからのpull_requestイベントの場合には制限されている。 GitHub の推奨するセキュリティ対策 としては、ワークフローでは必要としないGITHUB_TOKEN の権限をすべて削減することとなっている。したがって組織やリポジトリのデフォルト設定を「読み取りと書き込み」権限から「読み取り専用」に変更すべきである。 設定はGitHubの対象リポジトリの Settings > Actions > General から変更できる。 出典: GitHub 必要であれば、特定のワークフローに対して個別に追加権限を付与することができる。権限はワークフロー単位でも設定できるが、Job単位で設定を行うことで権限を最小化できるケースが大半である。参考情報のGITHUB_TOKENの権限に権限の一覧と、Job単位での設定方法へのリンクを記載した。 jobs: job_name: ... permissions: issues: write クロスリポジトリアクセスを考慮した、ワークフローが利用するべき推奨されるアプローチを優先度の高い順に説明する。 GITHUB_TOKEN 可能な限りGITHUB_TOKENを利用する Repository deploy key Managing deploy keys – GitHub Docs GitHub App tokens GitHub Appは、選択したリポジトリにインストールでき、リポジトリ内のリソースに対するきめ細かい権限がある Personal access tokens 使わないこと やむを得ず利用しなくてはならない場合、 Fine-grained personal access token を利用すること SSH keys on a personal account 絶対に使わないこと シークレットの利用について シークレットを利用する場合は以下を考慮すること。シークレットの利用を避けられるのであれば、利用しない。 Long-Lived tokenを利用しない Workload identity federationを用いたSecret Managerの利用を検討する Workload identity federation  |  IAM Documentation  |  Google Cloud Workload Identity – Developer Documentation 構造化データ(JSON, XML, YAMLなど)をシークレットにしない GitHub Actionsは全文をマスクデータとして扱ってくれるが部分文字列はマスクされないため 構造化データ(JSON, XML, YAMLなど)のblobを使用してシークレットを登録しない ひとつずつ個別にシークレットにする ワークフロー内で使用されるすべてのシークレットをマスクするよう登録する シークレットを使用してワークフロー内で別の機密値を生成する場合、その生成された値もシークレットとして登録する シークレットの登録方法は以下を参照 Encrypted secrets – GitHub Docs たとえば、秘密鍵を使用して署名付きJWTを生成してWeb APIにアクセスする場合は、必ずそのJWTもシークレットとして登録する シークレットに保存されたアクセストークンの利用状況を監査する スコープが最小限のクレデンシャルを使用する 登録されたシークレットを監査およびローテーションする シークレットへのアクセスについてレビューを要求することを検討 イベントトリガー PRの処理には pull_request イベントを使えるなら使う リポジトリへのwriteはできないよう制限されている Dependabotなどもシークレットにアクセスできない(社のorganizationの別リポジトリにアクセスできない)ためビルドできない可能性がある ただしDependabotシークレットに登録されていればアクセスできる Configuring access to private registries for Dependabot – GitHub Docs すこし制限を緩めたものとしてpull_request_targetがある GitHub Actionsのワークフロー自体は pull_request_target だと default branch のものが使われる ワークフローのyamlに直接記載する場合は攻撃者によって上書きされない チェックアウトしたコードに含まれるComposite Actionを使う場合注意が必要となる Composite Actionについては参考情報に詳しく記載した pull_request_target – Events that trigger workflows – GitHub Docs に記載されている以下の内容に注意すること 警告: pull_request_target イベントによってトリガーされるワークフローでは、permissions キーが指定され、ワークフローがフォークからトリガーされてもシークレットにアクセスできる場合を除き、読み取り/書き込みリポジトリのアクセス許可が GITHUB_TOKEN に付与されます。 ワークフローはPull Requestのベースのコンテキストで実行されますが、このイベントでPull Requestから信頼できないコードをチェックアウトしたり、ビルドしたり、実行したりしないようにしなければなりません。 さらに、キャッシュではベース ブランチと同じスコープを共有します。 キャッシュ ポイズニングを防ぐために、キャッシュの内容が変更された可能性がある場合は、キャッシュを保存しないでください。 詳細については、GitHub Security Lab の Web サイトの GitHub Actions およびワークフローのセキュリティ保護の維持: pwn 要求の阻止 に関するページを参照してください。 信用できないPRが作成されることを想定する場合は pull_request を使うべき ただし信用できないPRが作成される時点で、大きな問題となるため、これを防ぐべきである Job / Stepの単位 シークレットの内容を露出する単位は可能な限り狭くする Job単位 より Step単位 のほうがよりよい Step間のファイルによるデータやりとりは全ステップから可視であると考える Jobは処理の単位によって分ける テスト & ビルド & デプロイ はそれぞれJobを分けたほうがよい 必要なGitHub Actions上のPermissionやクラウドプロバイダー の権限を細かく制御するため たとえばテストの時にデプロイできる権限は必要ない 上記の場合、id-token: write は不要なはずである id-token: writeについては参考情報に詳しく記載 Dependabot / Renovateを利用したGitHub Actionsの更新 Actionsはバグ修正や新機能によって頻繁に更新される。Dependabot、RenovateでGitHub Actionsの依存関係を最新の状態に保つことができるため、設定を行うこと。 Dependabot: Keeping your actions up to date with Dependabot – GitHub Docs Renovate: Automated Dependency Updates for Github Actions – Renovate Docs | Renovate Docs サードパーティのActionを利用する場合の対応 サードパーティのActionを利用する場合、基本的にFull Changeset Hashに固定する。以下のようにFull Changeset Hashとバージョンコメントを記載することで、どのバージョンを使っているのかわかりやすくなる。 Dependabot Update version comments for SHA-pinned GitHub Actions by jproberts · Pull Request #5951 · dependabot/dependabot-core Dependabot now updates comments in GitHub Actions workflows referencing action versions | GitHub Changelog Renovate Automated Dependency Updates for Github Actions – Renovate Docs | Renovate Docs それぞれの指定の違いは以下の通り。 Full Changeset Hash uses: owner/action-name@26968a09c0ea4f3e233fdddbafd1166051a095f6 # v1.0.0 衝突の成功例はあるが困難 Short Changeset Hash uses: owner/action-name@26968a0 衝突に対して脆弱 Tag / Release uses: owner/action-name@v1 タグを後で変更され、意図しない変更が混入してしまう可能性がある Branch Name uses: owner/action-name@main 将来壊れる可能性がある 意図しない変更が混入してしまう可能性がある Actionのソースコードを監査する サードパーティのホストにシークレットを送信するなどの疑わしいことがないか確認する Managing GitHub Actions settings for a repository を参考に、ワークフロー内で利用している3rd Party ActionsのAction permissionsをセキュリティ観点で見直す。可能であれば、Allow enterprise, and select non-enterprise, actions and and reusable workflowsを設定する。 出典: GitHub 不要なワークフローやJobは削除する 設定はしてあるが必要なくなったものは削除して依存を減らす インジェクションを防ぐ 信頼されない式の入力値を中間環境変数(intermediate environment variable)に設定する。 これによって${{ github.event.issue.title }}式の値はスクリプトの生成に影響するのではなく、メモリに保存されて変数として使用される - name: print title env: TITLE: ${{ github.event.issue.title }} run: echo "$TITLE" シェル変数をダブルクォートして単語の分割を避ける(シェルスクリプトを書く際の一般的な推奨事項) GitHub Security Labの開発する CodeQL queries を利用する script_injections.qlは、記事で紹介されている式注入をカバーしており、精度が高い。しかしワークフローのステップ間のデータフローを追跡することはできない pull_request_target.qlの結果は、pull requestからのコードが実際に安全でない方法で処理されているかどうかを特定するために、より多くの手動レビューが必要。 GitHub のカスタムアクションやワークフローを書くときは、信頼できない入力に対して書き込み権限でコードを実行することがよくあることを考慮する actionlintによるインジェクションの検知 外部Actionとなるが、actionlintを利用することでインジェクション対策ができるので、導入を検討する。 https://github.com/rhysd/actionlint また、 reviewdog/action-actionlint を利用するとGitHub Actionsでactionlintを実行することも可能。 name: Actionlint on: - pull_request_target jobs: actionlint: runs-on: ubuntu-latest permissions: checks: "write" contents: "read" pull-requests: "write" steps: - uses: actions/checkout@v3.1.0 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: reviewdog/action-actionlint@1fa528d6a483f3df85059e206eadea033044edd7 with: fail_on_error: true filter_mode: nofilter level: error reporter: github-pr-review その他 完全に攻撃を防ぐことは不可能と考え、問題が発生したときに受ける影響を最小限に抑える Ex. Production環境に影響を及ぼす(サービスを停止させる、不正なImageを送り込む etc)ことがが最悪のケースとなる GitHub ActionがPRを作成またはオーナーとして承認しないようにする OpenSSF Scorecardsを使用したワークフローの保護(ただし利用するにはGitHubのPersonal Access Tokenが必要になる) ossf/scorecard – Security health metrics for Open Source OSSF Scorecard action – GitHub Marketplace actions/starter-workflows: Accelerating new GitHub Actions workflows セルフチェックリスト 本章の内容を定期的にチェックすることでGitHub Actionsの安全な利用につなげる。ガイドラインで学習した内容が本チェックリストでカバーされることを目指す。 CODEOWNERSの設定を見直す CODEOWNERS ファイルで .github ディレクトリ以下に対して適切にCode Ownerが設定されていることを確認する Protected Branch でDefault BranchへのPull Requestのマージには、Code Ownerによる承認が必須になっていることを確認する GITHUB_TOKENのPermissionsを見直す GITHUB_TOKEN に付与される権限を見直す。 デフォルトで付与されるGITHUB_TOKENの権限がReadのみになっているか確認する 「Read and write permissions」になっている場合は「Read repository contents permission」に変更する \ 出典: GitHub Managing GitHub Actions settings for a repository 可能であれば「Allow GitHub Actions to create and approve pull requests」を無効にする 設定方法などは以下を参照 Disabling or limiting GitHub Actions for your organization permissions をJob単位で設定する permissionsはWorkflow全体かあるいはJob単位で設定できるが最小権限にするためにJob単位で設定する Workflow syntax for GitHub Actions – permissions permissions の見直し 以下のリストを元に権限が最小になっているかを確認する Automatic token authentication – permissions-for-the-github_token ビルドやテストなどのジョブを分けることで、強い権限で実行されるステップが少なくなるのであれば分割を検討する GitHub Actions Secretsを見直す GitHub Actions用に設定されているシークレットを見直す。 使っていないシークレットはGitHub上から削除する シークレットの発行元でも無効化しておく 構造化データ(JSON, XML, YAMLなど)をシークレットに設定していないか確認する 個別登録するなどして設定し直す ワークフロー内で使用されるすべてのシークレットやログ出力すべきではない値をマスクするよう登録する シークレットを使用してワークフロー内で別の機密値を生成する場合、その生成された値もシークレットとして登録する 定期的(1年に1回など)にシークレットをローテーションする 新しいシークレットに置換し、それを終えたら古いシークレットは無効化する シークレットにTTL(Time To Live)を設定できる場合は適切な長さのTTLを設定する ローテーションと併せてシークレットに設定されている権限が最小限になっているのか確認する 必要以上に強い権限が付与されている場合は不要な権限を落とす ワークフロートリガーを見直す コードプッシュをトリガとする場合、pull_request か、それが難しければ pull_request_target を使う on: pushをPR用に使っていたら見直す サードパーティのActionsを見直す 不要なWorkflowやJobは削除する 設定はしてあるが必要なくなったものは削除して依存を減らす バージョン指定を確認する 基本的にFull changeset hashに固定し、Full Changeset Hashとバージョンコメントを記載する Dependabot - uses: actions/checkout@01aecc # v2.1.0 Dependabot now updates comments in GitHub Actions workflows referencing action versions | GitHub Changelog Renovate - uses: actions/checkout@af513c7a016048ae468971c52ed77d9562c7c819 # renovate: tag=v1.0.0 Automated Dependency Updates for Github Actions – Renovate Docs | Renovate Docs Managing GitHub Actions settings for a repository を参考に、ワークフロー内で利用している3rd Party ActionsのAction permissionsをセキュリティ観点で見直す。 インジェクション対策を見直す actionlintが導入済みであれば、actionlintで問題がないことを確認する actionlintを導入できない場合、最低限の対応として信頼されない式の入力値を中間環境変数(intermediate environment variable)に設定する おわりに 今回は社内の有志メンバーによって作成された社内用GitHub Actionsのセキュリティガイドラインの一部を紹介しました。 GitHub Actionsは、開発者がよりスムーズで効率的な開発を行うための強力なツールであると言えますが、使用する際にはガイドラインに記載したようなさまざまな観点でセキュリティに十分注意する必要があります。常にセキュリティを考慮し、最適なプラクティスを意識して実践することの重要性をガイドラインを作成する中で強く感じました。GitHub Actionsにおけるセキュリティのベストプラクティスは今後も変化していくと思います。本ガイドラインはこれで完成ではなく、今後も適切に更新していき、よりスムーズで安全な開発をサポートできるよう努めていきたいと思います。 明日の記事は sapuriさんです。引き続きお楽しみください。 Appendix 参考情報 ワークフローのトリガー ワークフローはイベントによってトリガーされる。イベントには、以下のものがある。 ワークフローのリポジトリで発生したイベント GitHubの外部で発生し、GitHub上で repository_dispatch イベントを発生させるイベント 時間指定での実行 手動実行 たとえば、リポジトリのデフォルトブランチにプッシュが行われたときやリリースが作成されたとき、あるいはIssueがオープンされたときなどにワークフローを実行するように設定することができる。 詳しくは About workflows を参照すること。またイベントの一覧は Events that trigger workflows を参照すること。 GITHUB_TOKENの権限 GITHUB_TOKENの権限は以下にまとめている。 Permissions for the GITHUB_TOKEN Jobごとに権限を変更する方法は以下に記載されている。 Assigning permissions to jobs – GitHub Docs 権限をジョブに割り当てる – GitHub Docs Composite Action Composite ActionはカスタムActionの一つであり、使用することでワークフローの複数のステップを組み合わせて 1 つのアクションにすることができる。 たとえば、複数の run コマンドを 1 つのアクションにまとめて、そのアクションを 1 つのステップとしてワークフローから呼び出して実行することが可能。 作成方法に関しては以下に記載されている Creating a composite action – GitHub Docs シークレットのマスク ログ中での値のマスク – GitHub Actions のワークフロー コマンド Workflow commands for GitHub Actions 以下のような記述を行うことで値をマスキングすることが可能。マスキングされた単語は「*」 に置き換えられ、ログに出力されなくなる。マスク可能な値は環境変数または文字列である。 ::add-mask::{value} 例:Stringをマスクする 以下のような設定を行った上でログに「Mona The Octocat」を出力すると「***」が表示される。 echo "::add-mask::Mona The Octocat" 例:環境変数をマスクする 以下のような設定を行った上でログに環境変数 MY_NAMEと”Mona The Octocat"を出力すると *** が表示される。 jobs: bash-example: runs-on: ubuntu-latest env: MY_NAME: "Masking on GitHub Action" steps: - name: bash-version run: echo "::add-mask::$MY_NAME" - run: run: | echo "Mona The Octocat" echo "::add-mask::Mona The Octocat" echo "Mona The Octocat" echo "$TITLE" echo "::add-mask::$TITLE" echo "$TITLE" 以下のように表示される。 Mona The Octocat *** Masking on GitHub Action *** actions/toolkitを利用する場合 actions/toolkit はGithub Actionsの作成を容易にする一連のパッケージを提供している。 toolkitの@actions/coreパッケージを利用することで、以下のような記述でシークレットのマスクを設定することも可能。 core.setSecret('Mona The Octocat') Setting a secret – toolkit/packages/core ログ中での値のマスク – GitHub Actions のワークフロー コマンド / Workflow commands for GitHub Actions id-token:writeで実現できること id-token: write はGitHubによる署名が行われたOpenID ConnectのID Tokenが取得できるようになる権限。これを使うとどういうことができるかは 公式ドキュメント を参照する。 例えばGCPのWorkload identity federationの機能を通じて、GitHub ActionsのID Tokenがあれば設定されたService AccountのAccess Tokenを手に入れることができる。つまり、id-token: write をGitHub Actions中で利用するということは短時間(デフォルトでは1時間)ながら、GCPプロジェクトへのアクセス権限を渡すのと同義となる。secretsに固定のcredentialをもたせるのに比べれば圧倒的にセキュリティが高いが、それでもID Tokenにアクセス可能な範囲を適切にコントロールすることは重要となる。 References GitHub Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests Keeping your GitHub Actions and workflows secure Part 2: Untrusted input Keeping your GitHub Actions and workflows secure Part 3: How to trust your building blocks Security hardening for GitHub Actions About code owners – GitHub Docs About protected branches – GitHub Docs Automatic token authentication – GitHub Docs Contexts – GitHub Docs Managing GitHub Actions settings for a repository – GitHub Docs Setting the permissions of the GITHUB_TOKEN for your repository – Managing GitHub Actions settings for a repository Automatic token authentication – GitHub Docs Modifying the permissions for the GITHUB_TOKEN – Automatic token authentication – GitHub Docs Managing deploy keys – GitHub Docs Encrypted secrets – GitHub Docs Creating encrypted secrets for a repository – Encrypted secrets – GitHub Docs Configuring code scanning – GitHub Docs Preventing GitHub Actions from creating or approving pull requests – Disabling or limiting GitHub Actions for your organization Verified Creator – GitHub Marketplace · Actions to improve your workflow Others rhysd/actionlint: Static checker for GitHub Actions workflow files
アバター
この記事は、 Merpay Tech Openness Month 2023 の3日目の記事です。 こんにちは。メルペイBackendエンジニアの@yushi0010です。 私が所属するPartner Platformチームでは社内向け管理ツールを開発しています。この記事では、そのツール内でのページネーションで起きたバグを解消した話を紹介します。 概要 今回のページネーションを利用していた管理ツールの検索ページでは、あるテーブルが持つカラムに対して条件を指定し、その条件に合うレコードを取得して一覧表示する機能がありました。しかし、ある特定の条件下でどれだけ次ページに遷移するボタンをクリックしてもページ遷移が行われないというバグが発生しました。 バグが起きた状況 どのようにしてページ遷移が行われなくなったのかを説明するために、その時の状況を共有します。 まず、検索の対象とするテーブルは以下のようなスキーマです。 table ( id INT64 NOT NULL, month DATE NOT NULL, status1 INT64 NOT NULL, status2 INT64 NOT NULL, (中略) created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, ) それぞれのカラムに入る値について、 month はDate型で表現されていますが年月だけの情報を保持しており、何日なのかという情報は必要がないため全て1日で固定されています。また、 status1 や status2 はカテゴリカルな値が入り、とりうる値の範囲はせいぜい0から9までの一桁に収まるくらいです。 このスキーマに対して条件を指定して一覧表示をさせていました。実際の条件は以下のような内容です。 month が2023年5月より以前になっている status1 が (0, 1, 3) のどれかである status2 が (1, 2, 4, 5) のどれかである ページネーションを実現するアルゴリズムとしては、典型的なものとしてOFFSET句を利用するパターンと、前のページの最終行の情報をカーソルとして保持し次のページでそのカーソル以降のレコードを表示させるパターンが主に考えられます。今回のコードでは後者を使用していました。 また、カーソルとして使用したカラムは month 、 status1 、 status2 、 created_at の4つです。その4つのカラムでOrdey Byさせた後、ページで表示させる件数+1つのレコードを取得してその+1つめのレコードの値をカーソルとし、ページ遷移するときにはそのカーソルを含むそれ以降のレコードを取得するという実装になっていました。 例えば一つのページに50件を表示させたいとき、 1ページ目を取得する場合は、 SELECT * FROM table ORDER BY month, status1, status2, created_at LIMIT 51; で51件取得し、50件をページに表示させ、51件目をカーソルの値に使用していました。 次に2ページ目を取得する場合は、先ほどの51件目のカーソル以降(51件目を含む)となるレコードを取得すれば良いので、 SELECT * FROM table WHERE (month > @cursor_month) OR (month = @cursor_month AND status1 > @cursor_status1) OR (month = @cursor_month AND status1 = @cursor_status1 AND status2 > @cursor_status2) OR (month = @cursor_month AND status1 = @cursor_status1 AND status2 = @cursor_status2 AND created_at >= @cursor_created_at) ORDER BY month, status1, status2, created_at LIMIT 51; で取得をします。 バグが起きた原因 以上のようなコードによってページネーションロジックが実装されていましたが、どのようなことが原因で前述のバグが発生していたでしょうか? 自分で考えてみたい人はスクロールをここで一旦ストップしてください。ここまでに共有した情報の中にそのバグを発生させていた原因が含まれています。以下でその原因を示します。 予想はつきましたでしょうか? 今回のバグの原因となっていたのは、カーソルとして使用していたカラムの組み合わせがユニークではないことでした。 実装当初の想定では、 created_at を含む4つのカラムを組み合わせてカーソルを作成することで、カーソルは各レコードにわたってユニークになるだろうと考えていました。しかし、実際にはデータマイグレーションの際に一括Insertをしたことで created_at を含む4つのカラムが全て同じになっているレコードがページの表示件数以上に存在していました。 ではユニークでないレコードが大量に存在することで、どのようにページ遷移が出来なくなるのでしょうか。 例えばページの表示件数が50件で、カーソル (month, status1, status2, created_at) が (2023年5月, 4, 2, 2023年4月10日) となるユニークでないレコードが51件より多く存在する場合を考えてみます。 ページ遷移を行っていたところ51件目がユニークでないレコードとなり、カーソルが (2023年5月, 4, 2, 2023年4月10日) となってしまいました。このとき、次ページに遷移するときに取得するレコードは (2023年5月, 4, 2, 2023年4月10日) が含まれるので、先ほど取得したはずのユニークでないレコードが再度取得され、このユニークでないレコードは51件以上存在するのでカーソルも再度 (2023年5月, 4, 2, 2023年4月10日) に設定されます。これ以降はどれだけページを次に遷移をさせても同じ情報が取得され続けます。このようにしてページネーションロジックはエラーなく動作しているもののページが遷移できないバグに陥りました。 このバグを発生させないためにはカーソルの値が常にユニークでなければならないので、今回このバグの解決策としてとった対応は、カーソル (month, status1, status2, created_at) に レコードごとにユニークな値である id カラムを created_at の代わりに含めて (month, status1, status2, id) とすることで、カーソルが重複してしまうレコードが存在しないようにしました。 バグからの学び 今回の実装でよくなかったところは、ページネーションで利用されるカーソルにユニークなカラムが含まれていなかったことはもちろんなのですが、 created_at にレコード作成日より大きな意味を持たせてしまったことにあると考えています。 ページネーションロジック実装時には created_at に対してカーソルで利用するユニークなカラムという役割を持たせましたが、データマイグレーションを行う人は created_at にそのような役割があるということが認識することができず、 created_at が同じ値となるようにレコードの一括挿入を行いました。 一括挿入時以外においては created_at が重複することはないと仮定したとして(実際には重複することが十分考えられます)カーソルとして利用はできそうです。しかし、一般的に作成日として認識されているカラムに対してそれ以上の意味を持たせることで、そのカラムの使い方に齟齬が生じ、それが原因となって今回のようにバグが発生することが考えられます。 カラムの使い方に限らず、一般的な利用方法について共通の認識があるものに対してどうしても特別な意味を持たせたいときには、ドキュメントやコメントによってその意図を伝える方法が考えられます。しかし、利用者がそのドキュメントを確認して実装者が想定する意図を汲み取ってくれるとは限りませんし、そもそもそれを認識しなければならないという利用者への不要な負担を強いる状況を発生させています。よって特別な意味を持たせることは避けるべきであり、意味を持たせる用の項目を別で新たに定義するべきだと学びました。 まとめ この記事では、メルペイの管理ツールのページネーションに発生したバグの概要、原因、そこからの学びを紹介しました。 明日の記事はBackendエンジニアの@komatsuさんです。引き続きお楽しみください。
アバター
この記事は、 Merpay Tech Openness Month 2023 の2日目の記事です。 メルペイFrontendエンジニアの @togami2864 です。普段はPartner Platformというチームで加盟店申込みフォームや審査・管理を行うためのMerchant Supportツールの開発・運用を担当しています。 本記事ではRust製TypeScriptコンパイラであるstcについて筆者の観測範囲での概要、開発状況、課題等を紹介します。なお、内容は全て2023年5月時点のものです。また、本記事の一部は Node学園 41時限目 書籍について で発表したものと重複していることをご了承ください。 概要 stcは2022年10月にオープンソース化されたRust製のTypeScriptコンパイラです。 https://github.com/dudykr/stc 製作者はRust製のトランスパイラ swc の作者である kdy1氏 で、Rustとparallelな解析によってTypeScriptのビルドとイテレーションを短縮して DX を改善することを目的としています。 1 また、tscの動作に準拠したコンパイラ(drop in replacement)を目指すという立場をとっており、tscの挙動を仕様として追従していく予定です。 一時はRustの採用を諦め、Goで開発していた時期もあったようですが、最終的にはRustで作ることを決定しました。 2022/01/26 元々Rustで作っていたが、tscが多くの共有可変性やGCに依存していることを理由にRustの採用を見送り。ZigとGoで実験した結果Goを採用 2 2022/10/10 tscを実直にGoで行ごとに移植していたものの、量が膨大すぎるためTypeScriptコンパイラのコードを行ごとにGoに変換し、コンパイラを生成するコンパイラを考案 3 2022/10/27 Goを使っているとはいえ、コンパイラを通して生成したGoのコードには非効率なものが多く含まれること、不要な部分の移植も必要なため結局Rustに戻すことを決定 4 移植難易度の高さ 言うまでもなく、tscの他言語への移植は非常に困難で挑戦的なプロジェクトです。その主な理由の一つは、他のプログラミング言語と異なり、TypeScriptには明確な仕様書が存在しない点です。 5 そのため、stcはtscの挙動を仕様とみなしています。また、開発に際しては以下の3つのリソースを参考にしています。 1. 機能が追加された時のPRを見る TypeScriptには、基本的な型に加えて、conditional types、mapped types、template literal typesといった独自の型が存在します。これらの機能に関する詳細な説明やエッジケースはPRに書いてあります。 ちなみに大きな機能追加のほとんどはTypeScriptの共同創案者である Anders Hejlsberg氏 のものです。彼のPRは詳細な説明を書いている上に、テストケースも豊富に書いているため非常に重要です。 例: conditional types unknown type variadic tuple 2. テストケースを参考にする TypeScriptのリポジトリには、 tests/casesディレクトリ に多数のテストケースがあります。これらのテストケースの入力・出力とコメントを参考にして開発が進められます。また、stcではcompilerとconformanceディレクトリ内のテストケースを流用してテストが実施されています。 3. tscのソースコードとコメントを読み解く これが最も確実な方法でありながら、非常に高難易度です。TypeScriptのコンパイラのコードベースは10年以上にわたる開発が続けられているため巨大かつ複雑です。 https://twitter.com/kdy1dev/status/1652531146138464259 結構有名な話ですが型検査のコードがあるchecker.tsのみでファイルサイズは約2.7MBあり、GitHub上で表示できません。 GitHub上のchecker.ts 引用: microsoft/TypeScript 仕組み 次のようなシンプルなTypeScriptコードに対し、型チェックを行うとしましょう。 const foo: number = 1 + 1 定数fooを宣言し、型注釈としてnumber型を指定しています。値として1 + 1を代入しています。 型チェックを行うためにまずソースコードを字句解析、構文解析を通してASTにする必要があります。stcではTypeScriptのコードをASTにするためのlexerとparserは swc を使っています。あくまでstcが担当するのは型チェックのみです。 そこで生成されたswcのASTを使って型検査を行ないます。簡略化したASTは次のようになります。 stcでは Visitor pattern を実装しています。 Visitorとして Analyzerという構造体 が用意されており、 各ASTのタイプに対応するvisitメソッドが実装 されています。ASTをたどりながらAnalyzerに実装されている操作を呼び出し、そのタイプごとに独自の処理を行います。 このサンプルコードでは、単純に型注釈に対して右の式の結果の型が代入可能であるか(つまり部分型であるかどうか)をチェックします。 明示的な型注釈により、変数の型がnumberと判断されます。次に式1 + 1ですが、BinaryExpressionに到達したときに演算子が+であることがわかります。その後、leftとrightの式の型が分かれば、結果がどのようになるかチェックできます。もし+演算が適用できない型同士であれば、ここでエラーが出されます。 今回は両方ともnumber型の値なので、式の結果もnumber型になることがわかります。 number型に対してnumber型は代入可能ですから無事に型チェック完了です。 現在の開発状況 stcは現在TypeScript5.0のブランチのconformance testをもとに開発されています。 基本的な型、構文、演算子、builtin typesのサポート 基本的な型に加え、Generics、オーバーロード、mapped types、conditional typesといった高度な型もサポートしており、2023年4月現在TypeScript4.9のsatisfies operatorまでサポートしています。また、ES2022までのbuiltin typesの解析が可能です。 tscとの互換性 stcはTypeScriptとの互換性を重視して開発されています。そのため、TypeScriptとの動作の違いを把握することが重要です。そこで、stcのリポジトリには tsc-stats.rust-debug というファイルが用意されています。 Stats { required_error: 3538, matched_error: 6497, extra_error: 771, panic: 74, } このファイルでは、本家tscが出力した結果とstcが出力した結果を比較して、エラーの一致数やパニックの発生数などを集計しています。tscのリポジトリからコピーした/conformanceディレクトリ内のテストケースを使って集計されています(stcにはconformanceテスト以外にもテストケースがありますが、このファイルの数値には含まれていません)。 required_error (false-negative) これは、tscがエラーを出しているのに対して、stcがエラーを出していないケースの数を示しています。現在、3538箇所存在しています。この値はできるだけ減らしたいものです。 matched_error (true-positive) これは、tscとstcの両方が正しくエラーを表示できている箇所の数を示しています。現在、6497箇所存在しています。この値はできるだけ増やしたいものです。 extra_error (false-positive) これは、tscではエラーを出していないのに、stcだけが誤ってエラーを表示している箇所の数を示しています。 この値は最優先で減らすべきです。 現在、771箇所存在しています。 理想的には0になってほしい値ではありますが、現状では多くのエッジケースが含まれており、どこまでサポートするかは今後の課題となります。 panic この項目は、panicによってプログラムが終了するケースの数を示しています。これらのケースは主にparser (swc) の問題や、解析中のオーバーフローが原因となっています。 これらの数値は、4月まで https://stc.dudy.dev/ で週に1回進捗が共有されていました。しかし、最近の大きなタスクや容易に修正できる部分がほぼ解決されたため、更新頻度が月1回に変更されています。 @typesパッケージの解析 @types/node や @types/react といった有名なツールの型定義ファイルは、普段の開発でほぼ必須となります。stcも実用段階に達するためには、これらのパッケージを解析できることが必須でしょう。 ただし、namespaceを使用している部分が並列解析できなかったり(特に@types/nodeはnamespaceを多用している)、単純なプロパティの多さからくるデバッグの難しさなどの理由で、まだ十分な進捗がありません。 未対応のケース 現状では、基本的な型の多くは解析できますが、対応できていないケースも存在しています。 例): https://github.com/dudykr/stc/blob/7c76ed2314a82040efba2f82db951eee6c2c88bb/crates/stc_ts_type_checker/tests/conformance/controlFlow/controlFlowAliasing.ts#L6-L14 // @strict: true // @declaration: true // Narrowing by aliased conditional expressions function f10(x: string | number) { const isString = typeof x === "string"; if (isString) { let t: string = x; // 本当はエラーにならないのにTS2322が表示 } else { let t: number = x; // TS2322 } } 変数xがif句、else句内でそれぞれnarrowingされることが期待されますが、現在のstcではif-else句内でもxを(string | number)と判断しています。そのため、 TS2322: Type '(string | number)' is not assignable to type 'number'. というエラーが誤って表示されます。 おそらく、式typeof x === "string"の結果の型を判定する際に、その式がifステートメントの条件として使用されていない場合、変数xをnarrowingする処理が行われていないものと思われます。 このようなfalse-positiveケースが多数存在しており、特にclass構文周りではfalse-positiveが多いようです(メンバーやメソッドなどの解析の順番を決めるのが難しいため)。 false-positiveをどこまで妥協するか false-positiveは極力減らすべきです。しかしながら、false-positiveの中には現実的なユースケースとして本当に現れるのかというケースも大量にあります。例えば次のコードは現在のfalse-positiveのケースの一つです。 https://github.com/dudykr/stc/blob/main/crates/stc_ts_type_checker/tests/conformance/classes/members/privateNames/privateNameComputedPropertyName3.ts // @target: esnext, es2022, es2015 class Foo { #name; constructor(name) { this.#name = name; } getValue(x) { const obj = this; class Bar { #y = 100; [obj.#name]() { // <----------- Umimplemented return x + this.#y; } } return new Bar()[obj.#name](); } } console.log(new Foo("NAME").getValue(100)); TypeScriptのコードとしては不正ではないものの、重箱の隅をつついてくるようなfalse-positiveのテストケースが大量に存在しておりそれらをどう扱うかははっきりしていません。 今後の動き まだまだ開発途中であり、決まっていることは少ないですが、アルファ版へのロードマップは https://stc.dudy.dev/docs/roadmap で公開されています。 assign ruleの改善 tscの挙動を仕様とし、互換性や@typesパッケージの解析のために改善が続けられるでしょう。またGenerics推論の改善や解析順序の改善が予定されています。 VSCode拡張 2023年4月に開発が始まったようです。現在は開発者向けのVSCode拡張機能の開発が進行しています。 開発中のVSCode拡張機能 引用: This week in stc, 23 独自の構文拡張はあり得るか (筆者の意見ですが)非常に可能性は低いと考えられます。なぜなら、作者のkdy1氏は標準遵守の意識が強く、 swcでもその姿勢を維持しているからです。 また、 bun がJSXの独自構文を導入した際にもかなり難色を示していました。 https://twitter.com/kdy1dev/status/1609013152590725120?ref_src=twsrc%5Etfw%7Ctwcamp%5Etweetembed%7Ctwterm%5E1609013152590725120%7Ctwgr%5Ed9b51ff7ef2db59201ba768191816a2788474fff%7Ctwcon%5Es1_&ref_url=https%3A%2F%2Fkdy1.github.io%2Fpost%2F2023%2F2%2Fstc-ethics%2F 独自の破壊的変更によるコミュニティの混乱を避けるため、stcがTypeScriptに独自の拡張を導入する可能性は非常に低いと考えられます。 6 まとめ stcの概要について紹介しました。高速なtscと聞くと非常に魅力的に聞こえますが、まだ鋭意開発中であり、うまくいくかどうかはtscの複雑さも相まって全くもって未知数です。また、実際に高速に動作するのか正確なベンチマークが用意されていないため、その点も確認できません。 7 しかし、swcのASTを中心としたエコシステムの一員として、個人的には非常に期待しています。 脚注 [1] Rewriting TypeScript in Rust? You’d have to be… https://www.totaltypescript.com/rewriting-typescript-in-rust [2] I’m porting tsc to Go https://kdy1.dev/posts/2022/1/tsc-go [3] Status update of my tsc port https://kdy1.dev/posts/2022/10/tsc-port-status [4] Open-sourcing the new TypeScript type checker https://kdy1.dev/posts/2022/10/open-sourcing-stc [5] 正確にいうと、TypeScript v1.8までの仕様書は存在しています。しかしながら、それ以降の更新はなく このissue を見る限りほぼ放置されていると思って良いでしょう。 [6] stc의 윤리적 문제 https://kdy1.github.io/post/2023/2/stc-ethics [7] csstypesの解析がtscの57倍高速であったという 報告 や、@types/reactの解析にわずか0.174秒しかかからなかったという 報告 があります。これらから期待はできますが、実行環境や条件が明確でないため、参考程度に留めておくのが良いでしょう。
アバター
この記事は、 Merpay Tech Openness Month 2023 の1日目の記事です。 背景 メルペイのバックエンドエンジニアのa-r-g-vとsminamotです。私達はメルペイ加盟店の管理システムを開発しているチームに所属しています。私達のチームには、複雑な条件を持つBigQueryのSQLクエリがいくつか存在しています。例えば、加盟店管理に関する費用計算などの計算クエリのように、外部環境の変化によって要件が定期的に変更され、マイクロサービス化などのシステム化が難しいクエリがあります。このようなクエリは複雑であるだけでなく、テスタビリティにも問題がありました。そのため、開発者がテストを実施することが困難になっており、クエリの変更を安心して行うことができない状態にありました。 クエリの複雑性 抽出条件の複雑さと複数のマイクロサービスへの依存により、クエリが複雑になっていました。 抽出条件の複雑さ 契約条項に基づく複雑なビジネス要件が、クエリの複雑さを増す要因となっていました。例えば加盟店管理費用を計算するビジネス要件においては、正しく費用を計算するために審査通過日 、加盟店獲得後の決済情報、決済用QRコードの要否のような情報を組み合わせてクエリを行う必要があります。このような条件がクエリを複雑にしているのです。 複数のデータベースへの依存 クエリが複数のマイクロサービスのデータベースを横断して参照することが、複雑さを増していました。メルペイではマイクロサービスアーキテクチャを採用しており、業務ドメイン単位でサービスが分割されています。例えば加盟店の申込み、審査、事業者の情報、決済、QRコード配送などは、それぞれ別のマイクロサービスとして分割されています。一方で前述した管理費用を計算するためには、これらのデータベースやテーブルを横断的に参照する必要があります。また、依存しているマイクロサービスの中には、別のチームが管理しているものもあります。このような、依存するテーブル数の多さがクエリを複雑にさせていました。 課題 クエリに対する開発者テストの煩雑さ 開発者テストを煩雑にしていたのは主に以下2つの点でした。 一つ目はテストデータの投入が煩雑であったことです。複数のマイクロサービスのテーブルに依存しているために、投入する対象のテーブルの数や投入データ行数が多くなってしまっていました。また、クエリの抽出条件が複雑であるため、必要なテストパターン数が多く、そのためデータとして投入しないといけない量も多くなっている課題がありました。 二つ目の課題は、手作業が多いことです。実際のテスト環境のテーブルに対して、マイクロサービスが生成していないデータを投入することは問題です。そのため、クエリをテストするために新しくテーブルを作り、そこにテストデータを投入した後に、そのテーブルを使用するようにクエリを書き換え、クエリ実行と結果検証・クリーンアップという手順を行う必要があります。これを毎回のクエリ改修や、テストパターン毎に行うのが大変であるという課題がありました。 クエリに対する自動テストの不存在 クエリに対する自動テストの欠如も課題でした。デグレード(機能低下)を検知できる自動テストスイートが存在しませんでした。そのため、クエリの変更を安心して行うことができない状況でした。 解決策 この問題を解決するために、Go言語を用いてクエリに対するユニットテストを実装する仕組みを作りました。主に、以下の2点を実施しました。 Goのテストコードの中でテストデータを投入し、BigQuery上でのSQL実行を簡単に行えるように、専用のヘルパー関数を作成した テストデータ作成を支援するために、クエリからGo構造体を自動生成するツールを作成した これらにより、クエリの実行結果が意図通りなっていることをGo言語のtesting packageを使って可読性・メンテナンス性が高い形でテストできるようになりました。 動作イメージ 全体の動作イメージを説明します。クエリのテストはGoのテストとしてテストケースを実装するようにしました。テストケースごとに、以下を実行します。 テスト対象のクエリが利用しているテーブルを抽出し、テスト用データセット配下にテーブルを1件ずつ作成します。 テストケースで指定されているテストデータを、テスト用テーブルに挿入します。 テスト対象のクエリのFROM句に書かれているテーブル名を、上記で作成したテスト用テーブルを利用するように書き換えます。 書き換えたクエリを実行し、期待している結果と同じか確かめます。 テストケースのクリーンアップ動作で、作成したテスト用テーブルをすべて削除します。 また、テストケースからのデータ投入を支援するために、クエリが利用しているテーブルを Goの構造体として自動生成する仕組み https://github.com/ginokent/bqschema-gen-go をベースに作成しました。具体的には、同一リポジトリに存在する全てのSQLファイルを読み、コード生成を行うコマンドを作りました。コマンドは2つの構造体を生成します。 クエリが利用しているテーブル一覧を表すGo構造体 クエリが利用しているテーブルを全列挙し、対応関係をGoの構造体として生成します。利用テーブルの列挙は正規表現を利用し、FROM句をパーズして行います。 クエリが利用しているテーブル定義に対応するGo構造体 クエリが利用しているテーブルのスキーマ定義を実際のステージング環境のBigQueryテーブル定義を参照し生成します。 例えば、以下のようなクエリがあったとします。 SELECT SUM(Charge.Amount) As TotalAmount FROM `querytest-demo.user_service.Users` Users INNER JOIN `querytest-demo.payment_service.Charge` Charge ON Users.UserID = Charge.UserID WHERE Users.ReferralType = "ORGANIC" ここから、コード生成コマンドを実行すると、以下のような2つの構造体が生成されます。 // Code generated by bigqueryschema; DO NOT EDIT. package bigqueryschema import "cloud.google.com/go/bigquery" // Charge is BigQuery Table `querytest-demo:payment_service.Charge` schema struct. type Charge struct { ChargeID bigquery.NullString `bigquery:"ChargeID"` UserID bigquery.NullString `bigquery:"UserID"` Amount bigquery.NullInt64 `bigquery:"Amount"` Status bigquery.NullString `bigquery:"Status"` CreatedAt bigquery.NullTimestamp `bigquery:"CreatedAt"` UpdatedAt bigquery.NullTimestamp `bigquery:"UpdatedAt"` } // Users is BigQuery Table `querytest-demo:user_service.Users` schema struct. type Users struct { UserID bigquery.NullString `bigquery:"UserID"` Name bigquery.NullString `bigquery:"Name"` ReferralType bigquery.NullString `bigquery:"ReferralType"` CreatedAt bigquery.NullTimestamp `bigquery:"CreatedAt"` UpdatedAt bigquery.NullTimestamp `bigquery:"UpdatedAt"` } // Code generated by gentestqueries; DO NOT EDIT. package testqueries import ( "context" "testing" "github.com/stretchr/testify/require" "github.com/a-r-g-v/querytest-demo/src/bigquery" "github.com/a-r-g-v/querytest-demo/src/bigqueryschema" "github.com/a-r-g-v/querytest-demo/src/querytest" ) var QueryQueriesTotalUserAmount = querytest.NewQuery("queries/total_user_amount.sql") type QueriesTotalUserAmountParams struct { Charge []bigqueryschema.Charge Users []bigqueryschema.Users } func (i *QueriesTotalUserAmountParams) ToMap() map[string]interface{} { return map[string]interface{}{ "querytest-demo.payment_service.Charge": i.Charge, "querytest-demo.user_service.Users": i.Users, } } func QueriesTotalUserAmount(t *testing.T, bq *bigquery.Client, i *QueriesTotalUserAmountParams, options ...querytest.Option) *querytest.QueryTest { t.Helper() qt, err := querytest.NewQueryTest(t, context.Background(), bq, QueryQueriesTotalUserAmount, i.ToMap(), options...) require.NoError(t, err) return qt } この 2つのファイルを利用して、コーダーは以下のようなテストコードを書くことができます。 package test import ( "context" "fmt" "testing" "cloud.google.com/go/bigquery" "github.com/a-r-g-v/querytest-demo/src/bigqueryschema" "github.com/a-r-g-v/querytest-demo/test/testqueries" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAmount(t *testing.T) { userID := uuid.NewString() qt := testqueries.QueriesTotalUserAmount(t, bigQueryClient, &testqueries.QueriesTotalUserAmountParams{ Charge: []bigqueryschema.Charge{ { ChargeID: bigquery.NullString{}, UserID: ValidString(userID), Amount: ValidInt64(1000), Status: bigquery.NullString{}, CreatedAt: bigquery.NullTimestamp{}, UpdatedAt: bigquery.NullTimestamp{}, }, }, Users: []bigqueryschema.Users{ { UserID: ValidString(userID), Name: bigquery.NullString{}, ReferralType: ValidString("ORGANIC"), CreatedAt: bigquery.NullTimestamp{}, UpdatedAt: bigquery.NullTimestamp{}, }, }, }) result, err := bigQueryClient.RunQuery(context.Background(), qt.Query()) require.NoError(t, err) assert.Equal(t, 1000, result[0]["TotalAmount"]) } クエリテストの動作デモ 効果 この仕組みを導入したことにより、以下の効果がありました。 開発エンジニアによるテストへの効果 開発者テストの実施が容易になり安心してクエリを変更できるようになった テストデータのコーディングに型の支援を得られるようになった 列名やデータ種別の誤指定の防止 IDEによるコード補完の恩恵 テストデータの共通化やテーブルテストの活用が可能になり差分テストケースの追加が簡単になった 境界値のテストケースのような 1つの値だけを変更してテストを行うというようなケースの追加が簡単になった 共通化によりクエリに対するテストを網羅的に実施するコストが低下した 開発者テストケースの蓄積によりデグレート検知できるようになった 自動化されたテストケースが蓄積されたことによりクエリ変更に際するデグレートの検出が簡単に行えるようになった より安心感を持ってクエリ変更を行うことが可能になった QA エンジニアによるテストへの効果 テストデータの作成が効率化された 関係するマイクロサービスが多いこともあり、テストデータを作成するためにかなりコストがかかっていました。例えばテストしたいパターンが100通りある場合、手動でテストデータを100通り作成する必要があったのですが、この仕組みによりQA エンジニアはテストデータのパターンを考え、テストデータの投入をお願いするという形になりテストデータ作成にかかっていた工数はかなり削減されました。 ※今後はQAエンジニアでテストデータ投入まで行えるようになる予定です。 より精度の高いテストが行えるようになった 今まではテストデータの作成が困難で諦めていたテストパターンについてもテストが行えるようになりました。例えば時間の条件として2023年4月1日 0:00:00という条件があった場合、2023年3月31日 23:59:59と2023年4月1日 0:00:00のテストデータを作成する必要があります。ただ、こういったテストデータを手動で作成することは不可能に近く、厳密な境界値でのテストは諦めていました。 この仕組みを活用することでこのようなテストデータの作成も容易になり、今まで諦めていたテストパターンについてもテストが行えるようになったため、より精度の高いテストが行えるようになりました。 導入後の課題 上記のクエリテストの仕組みを導入することで複雑なクエリに対してもテストを行うことができ、クエリの修正時も安心感を持って修正作業を行うことができるようになりました。 一方でテストコードが拡充していく中で次のような問題に直面しました テストケースが増えることによるテスト実行時間の増加 テスト実行時間を抑えるためにテストの並列化を行ったことでBigQueryの最大同時実行クエリ数を超える割り当てエラーの発生 エミュレータの導入 上記の課題を解決するために、BigQueryのエミュレータを導入することにしました。エミュレータを利用することで、テストケースやテスト内で実行するクエリ数が増えても、BigQuery自体にリクエストが行われないため、安定したパフォーマンスが期待できます。 BigQueryでは公式のエミュレータが提供されていません。そこでメルペイ Architect の@goccy により作成されOSSとして公開されている bigquery-emulator を利用しました。 bigquery-emulator はGoで実装されたBigQueryのエミュレータサーバです。betaプロジェクトではありますが、すでに多くの機能が実装されています。 テストと同一のプロセスでエミュレータを起動することができるため、テストの前処理としてエミュレータサーバを起動し、BigQueryクライアントのリクエスト先に起動したエミュレータサーバを指定するように変更しました。 package test import ( "context" "testing" "cloud.google.com/go/bigquery" "github.com/goccy/bigquery-emulator/server" "github.com/goccy/bigquery-emulator/types" "google.golang.org/api/option" ) func NewClient(t *testing.T, useBQEmulator bool, projectID, datasetID string) (*bigquery.Client, error) { t.Helper() var opts []option.ClientOption if useBQEmulator { bqServer, err := server.New(server.TempStorage) if err != nil { return nil, err } if err := bqServer.Load( server.StructSource( types.NewProject( projectID, types.NewDataset( datasetID, ), ), ), ); err != nil { return nil, err } ts := bqServer.TestServer() t.Cleanup(ts.Close) opts = append(opts, option.WithEndpoint(ts.URL), option.WithoutAuthentication()) } ctx := context.Background() return bigquery.NewClient(ctx, projectID, opts...) } エミュレータの導入により、BigQueryの最大同時実行クエリ数を超える割り当てエラーを起こすことなくテストを実行できるようになり、テストの実行速度も改善され導入前後で約55%のテスト実行時間の削減が実現できました。 今後の展望 今後、さらに追加したい機能や応用の方法については以下の3つを考えています。 QAテストケースの置き換えの検討 クエリテストをQAフローにも導入することによりQAテストにおけるテストデータ投入の効率化ができましたが、現状はQAチームが作ったテストケースをもとにエンジニアがデータ投入用のテストロジックを作成・実行し、再度QAチーム側でそのデータを利用した確認を行っています。 QAテストにおいてもテストケースに応じて柔軟かつより容易なテストデータ投入からQAテストの実施、テストケースのメンテナンスをQAチーム側で完結できる仕組みを作成したいと考えています。 クエリテストのケース網羅性可視化 クエリのテストケースの網羅性を可視化するためのメトリクスを導入したいとチームメンバーで議論しています。 Goの通常のテストでは、コードカバレッジ等のテストケース網羅性を計算・可視化するためのメトリクスを簡単に利用できます。 SQLクエリに対してMC/DCカバレッジを使用する研究 があり、類似の仕組みを本手法にも導入していきたいです。 投入テストデータの正しさの検討 投入テストデータとマイクロサービスが実際に生成するデータに不一致がある場合、テストの意味がなくなってしまいます。現状はクエリテストに利用するテストデータを作成する際、依存マイクロサービスの振る舞いを理解してデータを作成しています。この不一致のリスクを最小限にするために、データインターフェースの明文化を検討したいと考えています。
アバター
こんにちは。メルペイ Engineering Engagement チームの mikichin です。 メルペイは単なる決済サービスではなく、新しい「信用」を基盤として、それに基づく循環型社会、なめらかな社会を創ることを目指しています。そのためには、お客さま・企業・金融機関など、さまざまなステークホルダーに対して「OPENNESS」な姿勢で向き合うことで、あらゆる世の中のお金の流れを、もっと身近なものに変えていきたいと考えています。 「Merpay Tech Openness Month」は、技術も「OPENNESS」にしていこうという考えのもと、2019年にスタートした企画です。 メルペイのエンジニア組織がテクノロジーでお客さまの課題解決を実現することを大切にし、その挑戦の中で得た知見を6月6日から約1ヶ月間に渡り毎日公開していきます!技術、開発設計や思想、組織ストラクチャー、Tips、その他最近の取り組みなど、幅広くお伝えします。 2019年は こちら 2020年は こちら 2021年は こちら 2022年は こちら ▼公開予定表 (こちらは、後日、各記事へのリンク集になります) Theme / Title Author Go言語によるSQLクエリテストの取り組み @a-r-g-v, @gen(sminamot) Rust製TypeScriptコンパイラstcの現状と今後 @togami2864 ページネーションのバグを解消した話 @yushi0010 社内用GitHub Actionsのセキュリティガイドラインを公開します @goro 新人編集長の技術書典14参戦記 @knsh14 メルペイ決済基盤における Source Payment による決済手段の抽象化 @komatsu メルコイン決済基盤における分散トランザクション管理 @sapuri Terraformモジュールを使ったCloud Spannerの設定標準化の取り組み @t-nakata なめらかなナレッジシェアリング文化を創る @tanaka0325 Resilient Retry and Recovery Mechanism: Enhancing Fault Tolerance and System Reliability @Amit.Kumar 非エンジニアのためのデータ集計環境について @katsukit メルペイ Tech PR が実際にまわしている PDCA サイクル @mikichin お手軽なグラフデータベース活用 @orfeon 与信モデル更新マニュアルを作成した話 @fukuchan テストコードの改革を進めている話 @r_yamaoka Cloud Tasksで外部APIへの流量制御をするときに考えたこと @panorama Designing iOS Screen Navigation for Best UX @kris Cloud ComposerとSecret ManagerでAirflowをセキュアにSlack連携する @champon Goでテスト用のフィクスチャを生成する @youxkei , @fivestar New Member として見たMerpay Tech Asset First Impression @nu2 TBD @kimuras どんな知見が得られるのか、毎日が楽しみです。 Merpay Tech Openness Month 2023 の1日目は、BackendEngineer @a-r-g-v と @gen(sminamot) が執筆予定です。 ひとつでも気になる記事がある方は、この記事をブックマークしておくか、 エンジニア向け公式Twitter をフォロー&チェックしてくださいね!
アバター
こんにちは!メルカリ Engineering Office チームの@aisakaです。 メルカリのエンジニア組織は、メンバーが相互に学び合い、メンバー自身が自走し、成長できる組織を目指し、「互いに学び合い、成長し合う文化」の醸成を行っています。 こうしたメルカリの「互いに学び合い、成長し合う文化」を体現する仕組みの一つが、社内技術研修「DevDojo」シリーズです。 昨年から、一部のDevDojoシリーズを外部公開( 参考 )していますが、今回さらに新しいコンテンツを公開することになりました! 今日のブログでは公開するセッションとその内容をご紹介します! Learning materials Website 技術研修DevDojoとは DevDojoは、技術開発を学ぶ場として「Development」と「Dojo(道場)」をかけ合わせて名付けられた完全In-houseの社内研修シリーズです。 シリーズを構成するコンテンツは多岐にわたり、メルカリ、メルペイのエンジニアの知見やアイディアが詰め込まれたものとなっております。(研修の全体像や概要は こちらのブログ で紹介しています。) 毎年4月と10月に実施しており、今年も4月に新卒社員が多く入社したタイミングで研修を行いました! また、研修は社内のメンバーであれば誰でも受講できるようにオープンにしており、今回も様々な組織に所属するメンバー50名ほどが参加しました。 公開コンテンツはこちら! メルカリのエンジニア組織は、半数以上が海外籍社員です。こうした背景からDevDojoの講義は、半分は英語、半分は日本語で行われるように調整しています。 すべての研修に同時通訳チーム(GOT)が入り語学のサポートをしています。 それでは、新たに公開したメルカリ、メルペイの8コンテンツをご紹介します! Introduction to Machine Learning (メルカリのMachine Learning入門) メルカリのユニークな機能の一つである写真検索機能は、膨大なデータをAIに機械学習させることで実現しています。このコンテンツでは、一般的な機械学習の考え方や、AI・MLの基礎知識について解説しています。また、メルカリでは実際にMLをどう実装しているのか、実際のプロジェクトについても紹介しています。 Slide英語 Design System for Mobile (メルカリのDesign System Mobile) 持続的に一貫したサービス体験をお客さまに提供できるよう、メルカリではDesign Systemにとても力を入れています。このコンテンツでは、モバイルにおけるDesign Systemの基礎知識から、メルカリで実際に行っているデザインの作り方、運用方法について解説します。 Slide英語 Introduction to Mobile Development (メルカリのモバイル開発入門) より使いやすいサービスを迅速に提供していくため、メルカリのモバイル開発はリリースサイクルや運用プロセスのルール化を行っています。このコンテンツでは、メルカリのモバイルアプリ開発において実際に運用している開発サイクルとプロセスについて解説しています。 Slide英語 Successful Scrum Team at Mercari (成功するスクラムチームとは) メルカリのプロダクト開発に取り入れられているスクラム開発 (Scrum) とはアジャイル手法のひとつで、少人数のチームに分かれ短期間の開発サイクルをくり返し行うフレームワークです。このコンテンツでは、基本的なスクラムの考え方と、メルカリにおける開発プロセス、そしてその目的を解説しています。 Slide日本語 / Slide英語 Introduction to Design Doc (メルカリのDesign Doc入門) プロダクト開発に必要なDesign Docの基礎知識を解説し、メルカリが今実際に使っているテンプレートを紹介します。また、良いDesign Docの書き方やメルカリでDesign Docをどのように使っているかについても説明しています。 Slide英語 Introduction to Authentification Platform (メルペイの認証基盤入門) 決済プラットフォームであるメルペイは、安全に外部通信を行うために認証と認可が必要です。このコンテンツでは、アカウントと認証、AuthN/AuthZに関する基本的な知識を解説し、メルカリグループの認証基盤について紹介しています。 Slide英語 KYC in Action (メルペイにおけるKYCの活用) メルペイは決済サービスを提供しているため、メルペイを利用して取引を行うお客さまには本人確認を実施しています。このコンテンツでは、KYCの基本的な知識やKYCの種類、メルペイでの活用について解説しています。 Slide英語 Quality Assuarance Policy (メルペイ品質保証ポリシー) 安心安全に早い開発サイクルでサービスを持続的に提供していくためには、Quality Assuaranceは非常に大切です。このコンテンツでは、どのようなQAのプロセス、ツール、テクニックで問題を迅速に特定し、解決しているのかを解説しています。 Slide日本語 / Slide英語 最後に 研修資料を社内だけでなくコミュニティに還元し、日本、海外のエンジニア業界全体の活性化に貢献できるよう、引き続きDevDojoシリーズのアップデートを行っていきます。 今回は講義の箇所をメインに公開しましたが、将来的にはHands-onのRepositoryなど実際に研修でHands-on練習用につかっているコードなども公開していきたいと思っております。 最後になりますが、社内で研修を実施し、そしてコンテンツを一般公開するには、公開箇所の選定、編集、ブランディング、レビュー等、様々な方々の協力が不可欠です。今回のコンテンツ公開にも、多くのエンジニアの方々、チームメンバー、セキュリティチーム、知財チーム、そしてデザインチームの協力があって実現できました。 また、メルカリグループでは、積極的にエンジニアを採用しています。ご興味ある方、ぜひご連絡お待ちしております! Open position – Engineering at Mercari
アバター
こんにちは!メルカリ Engineering Office チームの@aisakaと、HR Learning and Development チーム の@anzuです。 メルカリでは、新入社員の方が入社後から立ち上がるまでの期間を短縮するためのオンボーディングサポートをとても重要視しています。よりよいオンボーディング体験を提供していくため、私たちは戦略や仕組みづくりに携わっています。 日本では一般的に、春は新卒の方が入社する季節ですが、メルカリでも今年もたくさんの新卒メンバーが国内外から入社してくれました。 様々なバックグラウンドをもつ新卒メンバーがチームに配属してすぐに活躍できるよう、HRとエンジニアリング組織が協働して、新卒向けオンボーディング研修を1からデザインして実施しています。 今日は、2023年の新卒メンバー向けに実施した新卒オンボーディングをご紹介します! 2023年新卒オンボーディングを一挙ご紹介! 新卒オンボーディングは、大きく分けて、共通研修と技術研修DevDojoの2つに分かれています。 メルカリのエンジニアリング組織のYouTubeチャンネルであるGears channel にて、密着取材をしていただいたので、ぜひこちらの動画 をご覧ください! Gears YouTube – 新卒オンボーディング 特に力をいれて実施したビジネスマナー研修と技術研修DevDojoをピックアップしてご紹介します! ビジネスマナー研修について メルカリでは「オンボーディングを大切にしよう。新卒をみんなで育てよう。」というカルチャーが醸成されており、新卒採用も入社オンボーディングにもとても力をいれています。 新卒の場合は初めて社会人として働くため、中途に比べてオンボーディングを少し手厚くする必要があると考えており、共通研修として様々な研修や入社オリエンテーションを実施しています。 その中でも、新卒ならではの研修として力を入れて実施したのはビジネスマナー研修です。 メルカリの社内はとてもフラットでカジュアルな社風が浸透しているのであまりビジネスマナーを意識するシーンが少ないですが、一歩会社の外へ出ると、一般的な日本のマナーが期待されたり、求められることがあります。 そのため、社会人として最低限知っておいたほうが良いマナーの型を知識として学ぶ機会を設けています。メルカリを代表する社員として、プロとして働く上で必要なスキルやマインドセットの醸成が目的です。 技術研修DevDojoについて 技術研修DevDojoでは、専門領域を超え、幅広い知識を学ぶことを目的とし、下記3点を達成できるように構成しています。 メルカリ、メルペイで使われている Tech stack を理解すること チーム開発やプロダクト開発のフローを理解すること メルカリのValue、そして Engineering Ladder を理解し、体現できるようにすること ※Engineering Ladderはエンジニアに期待されるスキルや行動を明文化した指標です。 上記の目的を達成するために、メルカリのアプリやWebの開発を支えるClient側からBackendだけでなく、サービスの信頼性を支えるプラットフォーム、データ、インフラのエリア、またプロダクト開発のフローまで幅広くをカバーした構成になっています。これは自分の専門領域だけではなく、広い視野を持って活躍してほしいという期待を持って研修を作っているためです。 また、研修は講義形式のものと実際にcodingをするHands-on形式のものを組み合わせて提供しています。講義で習ったものを実際に触ってみることで、理解度をより深められるよう工夫しています。 またこちらの技術研修DevDojoのコンテンツは、実際に利用したスライドやビデオの録画をMercari Engineering Websiteで一部を一般公開しています。 Learning materials Website 今年4月に実施したコンテンツは来週公開予定です!お楽しみに! 終わりに 約1ヶ月かけて新卒研修を実施しました。 オンラインとオフラインのハイブリッドで実施したため、出社の際には皆でチームビルディング行うなど、新卒同期ならではのワイワイ感があったこともとても印象的でした。 海外ではあまり新卒採用が一般的ではないのですが、日本では新卒採用のカルチャーが根強く残っています。私たちも含めて多くの方が、新卒で入社した会社の同期を長く付き合う特別な存在として大事にされてる方が多いと思います。 メルカリの新卒メンバーたちにも、研修やチームビルディングを通して、新卒同士の絆を深め、かけがえのない関係性を構築できる手助けとなれば、作り手としてこんなに嬉しいことはありません。 最後になりますが、メルカリグループでは新卒採用やインターンシップに力をいれており、通年で採用活動を行っています。ご興味のある方はぜひご連絡ください。お待ちしております! Open position – Engineering at Mercari
アバター