search infra teamのmrkm4ntrです。我々の運用するElasticsearchにはFunction Score Queryを使ったリクエストが送られてきます。Function Score Queryはサブクエリのスコアに任意の関数を適用できるというもので、とても便利な機能ですが、同時にTop K(スコアが大きいものからK個を取得する場合)クエリ処理の最適化の恩恵を受けられなくなるという欠点もあります。この記事では、Function Score Queryに用いる関数の性質を利用し、Function Score QueryとTop Kのクエリ処理の最適化を両立させる方法について説明します。本記事は読者が検索エンジンの仕組みにある程度詳しいことを想定しています。 Top Kのクエリ処理の最適化 Elasticsearchの検索機能を提供しているライブラリLuceneには、Top Kを取得する際に、Top Kに入る見込みのないもののスコア計算をスキップすることで、パフォーマンスの最適化を図る機能が存在します。 例えば( ”search” OR “engine”)のようなクエリがあり、”search”というtermに対応するposting listの最大スコアが5.0、”engine”というtermに対応するposting listの最大スコアが3.0だとします。 BM25 ( https://ja.wikipedia.org/wiki/Okapi_BM25 ) にて各termに対するドキュメントはスコア付けされるため、indexの構築時に最大スコアが決まります。両方を含む文書のスコアは5.0 + 3.0 = 8.0になります。 ここでスコアの高い順にTop 10を検索することを考えます。この時大きいものから10番目のスコアがmin competitive scoreと呼ばれるものになります。つまり、このmin competitive scoreよりも大きいスコアをとりえない文書はスコア計算する必要がありません。 仮にmin competitive scoreが3.0より大きい値とします。この場合”engine”のみを含む文書のスコアはmin competitive scoreより大きくはならないので”engine”のみを含む文書のスコア計算はスキップできます。本来ならばORはそれぞれのtermのposting listを全て走査する必要があるのですが、”search”のposting listに存在する文書のみのスコア計算をすれば良いことになります。このような手法によりクエリのパフォーマンスを向上させることができます。 Function Score Query Function Score Queryは以下のようなクエリです。クエリを実行した結果はそのまま使うのではなく、サブクエリを実行した結果にfunctionsで指定された関数の戻り値を結合したスコアを最終スコアとして利用します。デフォルトの結合方法は乗算ですが、 score_mode の値によって動作を変更できます。 { "query": { "function_score": { "query": { … // サブクエリ }, "functions": […], "score_mode": … } } } 上記のTop Kクエリ処理の最適化は最大スコアがindex構築時に決まることが前提でした。Function Score Queryを使った場合、サブクエリのスコアに任意の関数の戻り値を結合することができるため、文書の最終スコアがクエリの実行時に決まることになります。このような状況下では先ほど説明したTop Kクエリ処理の最適化を使うことができません。 Function Score Queryを使うとTop Kクエリ処理の最適化がされていないのは、コードを確認すると明白です。min competitive scoreをLuceneのScorerに伝えるには setMinCompetitiveScore というメソッド( https://github.com/apache/lucene/blob/releases/lucene/9.10.0/lucene/core/src/java/org/apache/lucene/search/Scorable.java#L48-L57 ) を使うのですが、ElasticsearchのFunction Score QueryのScorerである FunctionFactorScorer においては setMinCompeitiveScore を呼んでいません。これによりTop Kクエリ処理の最適化がされていないことがわかります。 https://github.com/elastic/elasticsearch/blob/v8.13.2/server/src/main/java/org/elasticsearch/common/lucene/search/function/FunctionScoreQuery.java#L371-L487 TopKクエリ処理の最適化が可能な関数 確かに任意の関数に対してTop Kクエリ処理の最適化を実現するのは不可能です。しかし、利用する関数によってはTop Kクエリ処理の最適化の恩恵を受けれるものも存在します。例えばよく使われる、作成日時からの時間経過でスコアを指数関数的に減衰させる以下のようなFunction Score Queryです。 { "query": { "function_score": { "query": { … // サブクエリ }, "functions": [ { "exp": { "created_time": { "scale": "10d", "decay": 0.8 } } } ], "score_mode": "multiply" } } } このクエリでは、最終スコアは (指数関数的減衰 * サブクエリのスコア) となります。 重要なのはこの減衰は現在日時と作成日時の差において単調減少であるということです。 Luceneでは、posting listをあるフィールドの値で構築時にソートすることができます。posting listを作成日時の降順にソートすることで、上記の減衰関数を用いる際に、posting list内の前のドキュメントよりも必ず減衰値が大きくなることが保証できます。 これにより、サブクエリのみのmin competitive scoreが5.0、今の評価しているドキュメントの減衰が0.7だとすると、実質min competitive scoreを 5.0 / 0.7 = 7.14として扱うことができます。これは単にTop Kクエリ処理の最適化が使えるだけではなく、min competitive scoreがposting listを進むたびに増幅していくことになり、より多くのドキュメントの評価をスキップできる可能性が高まります。 PoCの実装とLuceneへの貢献 それをふまえて、前述の減衰関数を受け取りTop Kクエリ処理の最適化を実現するElasticsearchの新しいクエリをElasticsearchのpluginとして実装しました。基本的にはElasticsearchのFunction Score Queryと同じですが、サブクエリのScorerの setMinCompetitiveScore を適宜呼び出す部分が異なります。 実装自体は簡単でしたが、いざ動作確認すると全くパフォーマンスに変化がありませんでした。リモートデバッグで確認したところ、サブクエリ内のBoolean QueryにTop Kクエリ処理の最適化に必要な WANDScorer ( https://github.com/apache/lucene/blob/releases/lucene/9.10.0/lucene/core/src/java/org/apache/lucene/search/WANDScorer.java ) や BlockMaxConjunctionScorer ( https://github.com/apache/lucene/blob/releases/lucene/9.10.0/lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionScorer.java ) が使われておらず、代わりに使われていたのは、それぞれ DisjunctionSumScorer ( https://github.com/apache/lucene/blob/releases/lucene/9.10.0/lucene/core/src/java/org/apache/lucene/search/DisjunctionSumScorer.java ) と ConjunctionScorer ( https://github.com/apache/lucene/blob/releases/lucene/9.10.0/lucene/core/src/java/org/apache/lucene/search/ConjunctionScorer.java ) でした。 調べてみたところ、 WANDScorer や BlockMaxConjunctionScorer はオーバーヘッドが大きいためトップレベルの句の場合でしか利用されないようです。つまり ((A AND B) OR (C AND D)) のようなクエリの場合はORは WANDScorer が使われますが、ANDには ConjunctionScorer が使われることになります。サブクエリをトップレベルの句と認識させるためには、新しく追加したクエリからサブクエリの ScorerSupplier の setTopLevelScoringClause メソッド( https://github.com/apache/lucene/blob/695c0ac84508438302cd346a812cfa2fdc5a10df/lucene/core/src/java/org/apache/lucene/search/ScorerSupplier.java#L46-L54 ) を呼ぶ必要があります。そのように修正したところ、無事に WANDScorer と BlockMaxConjunctionScorer が使われるようになりました。 これでパフォーマンスが改善するかと思われましたが、相変わらず変化がありません。さらに調べると、 WANDScorer の下の ConjunctionScorer (ORの下のAND)と BlockMaxConjunctionScorer の下の DisunctionSumScorer (ANDの下のOR)が最大スコアとしてInfinityを返していました。 Luceneの実装を見ると確かにInfinityを返すようになっています。何故Infinityを返すのか全く意図が掴めずに頭を抱えましたが、トップレベルの句以外は最適化をしないという修正( https://github.com/apache/lucene/pull/12490 ) での漏れだということがわかりました。そこで、それぞれ最大スコアに正しい値を返すように修正したところ、ようやくパフォーマンスが大きく改善することが確認できました。同様にprofile APIにおいても修正漏れがあったため、以下のプルリクエストをLuceneのupstreamに送りました。今は全てmergeされています。 https://github.com/apache/lucene/pull/13031 https://github.com/apache/lucene/pull/13043 https://github.com/apache/lucene/pull/13066 上で実装したpluginを使って我々のワークロードを模したパフォーマンステストを実施したところ、既存のクエリの95pが約35%、99pが60%下がりました。さらにコストも約1/3削減できることがわかりました。 ただし、この最適化の恩恵を受けるためにはリクエストパラメータのtrack_total_hitsをfalseにする、もしくは十分小さい値(1,000以下)に設定する必要があります。というのも最低でもこの件数はヒットするドキュメントを検索する必要があるため、スキップ対象が少なくなるからです。歴史的経緯により、この値をすぐに小さくすることは難しく、また最近この最適化ができない形のクエリがテストされているため、この最適化を実際に我々の本番環境に適用できるかは検討中です。 さいごに この記事ではElasticsearchのFunction Score Queryに使われる関数の単調減少性を利用してTopKスコア処理の最適化の恩恵を受ける方法について述べました。このタスクでLuceneのスコアリング周りの内部実装についての理解が深まりました。また、仮説を実装し、何度もうまくいかない原因を潰していくサイクルは大変でしたがエキサイティングでした。もし仮にこのような最適化を適用できるクエリを利用されている場合は、試してみていただけると幸いです。
*Security & Privacy Divisionの原動力となっているバリュー、それは「By design, by default and at scale(設計で叶える、デフォルトに組み込む、スケールに対応する)」です。 Oktaのユーザーアクセス権の棚卸し作業をPlatform Security Teamに率いてほしいという依頼が寄せられました。このプロジェクトを進める中、私たちは過去の設定や慣習と向き合わなければなりませんでした。なぜなら古いやり方が残っていることで「by design」と「by default 」な管理が難しい状態だったからです。そのような状況にも関わらず、私たちは「at scale」で組織全体を網羅した検査を実施する必要がありました。 この記事では、これらの課題に私達がどのように挑んだかを説明します。 使用したテクノロジー: Neo4j: https://neo4j.com/ Okta: https://www.okta.com/ Slack: https://slack.com 概要 メルカリでは、従業員のSaaSへのアクセスのほぼすべてをOktaを使って認証しています。アクセス権とは、許可するのは簡単ですが取り消すことが難しいものです。 不要なアクセス権を一掃するため、Neo4jを使用して組織とアプリケーションへのアクセスをグラフ化し、ユーザーインターフェースにはSlackを使って調査を実施しました。 全社的に提供しているアプリケーション以外で、現在付与されているアクセス権がすべて必要なものかを全社員に聞き取り調査。 その後、各マネージャーにそられのアクセス権がそれぞれの職責と照らし合わせて妥当かを確認。 情報を集約後、自己申告に基づいて不要なアクセス権をOkta APIを通じて直接削除。 これらをこれらを実装することで社内全体を対象とした大規模な検査を行うことができました。 これまでの道のり メルカリは今年創業11年を迎えました。今でこそ中堅企業に成長したものの、多くの10代が思春期を通過するように、成長痛に似たいくつもの苦労を乗り越えてきました。会社の拡大に伴い新たな従業員の入退社を経験し、アクセス管理に関するニーズも変化していきました。新たに導入されるサービスもあれば、廃止されるサービスもありました。過去にアクセス権の付与を決定した根拠や理由も、現在に至る過程で失われてしまいました。 メルカリはSaaSに大きく依存しているため、IDを管理するソリューションとしてOktaとGoogle Workspaceを使用しています。今回、アクセスレビューのプロジェクトに着手した時点で、Oktaのみですでに約8000のユーザー、500のアクティブアプリケーション、1400のグループが存在していました。アクセス権の削除は退職のケースであれば比較的簡単です。しかし、社内異動の場合は細心の注意を必要とする作業です。また勤務年数の短い従業員であればアクセス権の整理も比較的簡単にできますが、勤務年数が長い場合は長年の間にアクセス権が増えてしまっており見直しが大変な場合もあります。その結果、秩序が失われ、そのせいで複雑さも増してクリーンな状態にするのが難しくなっていました。 プロジェクトの目標 Security teamの最終的な目標は、アクセス権の乱用よって引き起こされる潜在的な被害を可能な限り減らすことです。 アクセス権のクリーンアップによりさまざまな副次的効果が期待できます。 認証システムにおける無秩序さを減らす 各従業員/チームがどのようなシステムを使用しているか、より明確に理解できるようになる システムオーナーにその人のアクセスがまだ必要なのかについてヒアリングし、その調査結果をドキュメント化するというSecurity teamメンバーのストレスを軽減する どのように管理されているのか、それはなぜなのかについて理解するための時間を減らす もう必要ない可能性のあるSaaSを特定する クリーンな状態に基づいて、より優れたアカウントライフサイクル管理のパターンを作成する その他 考えうる戦略 「最小特権の原則」は、事故やインシデントのリスクを軽減する最善の方法のひとつであるものの、その適用と維持には相当な労力が必要であることが予想できました。 「最小特権の原則」を適用して最終目標を達成できるということは、私たちが以下のことを理解している(または把握している)という意味でもあります。 社内にどのようなシステムがあるか それらのシステムオーナーと管理者は誰か 誰がどのアクセス権を使ってこれらのシステムにアクセスできるか 各システムが処理し保存しているデータの種類は何か これらシステムが使用される可能性のあるビジネスプロセスは何か 各社員とシステム、また取り得る行動とそれに伴う結果との間にあるつながり Oktaのデータをもとに簡単に計算してみましょう。アプリケーションは500個あり、ユーザー数は8000です。それらが直接割り当てられている、または1400のグループを通じて割り当てられています。各アプリケーションには複数のユーザーがおり、各グループにも複数のユーザーがいます。アプリケーションによっては複数のグループが存在するものもあり、それを組織体制と全ユーザーにリンクさせると、メルカリ社内には20万を超える関係性が存在するという計算になります。この段階では、各ユーザーのアクセスレベル、各システムで処理・保存されるデータの種類、ユーザーにとってどのようなアクションが可能かすらも分かりません。 仮にOktaから得られる情報のみを起点としましょう。1秒間で判断を下すために必要な情報はすべて揃っているという前提の下、1件の関係性につき1秒かかるとします。それでも前述の20万件の関係性をレビューするには丸々55時間かかってしまいます。したがって、一人の人間が全員分のアクセス状況を見ることは明らかに合理的ではありません。 では、他にも実践できそうな方法はないか見ていきましょう。 戦略1:重要なシステムのみにスコープを絞る 重要なシステムはどれなのか?どのような条件に従って決めるのか?これらの条件を定義しようとすると、考えうる要素が多すぎて誰もが容易に迷子になってしまいます。でも魔法なんて存在しないのですから、どこかしら複雑さが残るのもやむを得ないことです。もし、重要なシステムや機密性の高い情報を含むシステムを特定するという方法を選んでも、誰か(またはどこかのチーム)がすべてのシステムに目を通し、それらが何に使われ、どのようなユーザーがアクセスすべきかを理解して分類しなければなりません。 ただ同時に、私たちは社内にあるシステムを大体把握できています。とりあえず手をつけて始めてみたほうが、一通り情報をかき集めてから目の前にそびえ立つ到底登れそうにない頂に絶望するよりも理にかなうはずです。そうでもしないと、いざ山頂に辿り着いたとしても、全員が疲れ果てているか、すでに会社を辞めた後かのどちらかになっていることでしょう。 もうひとつの問題は、このレビューを行っている間も社内の環境は変化し続けるということです。レビューが完了するまでの間に新たなシステムが導入され、そこにユーザーが追加され、それらシステムは新たなユースケースのために使用されることでしょう。川の流れを止め、その間に魚を数えるようにはいかないのです。 戦略2:フルスコープ、システムオーナーに依頼する システムオーナーに依頼するというのはどうでしょう?アプリの数は500。ユーザー数は1人の場合もあれば全従業員+業務委託が含まれる場合もあります。各システムオーナーが平均10システムを担当するとしても、50人がそれぞれ約4000件のアクセスを確認し、職務内容やサービスの性質、アクセスされるデータに基づき、これらユーザーがアクセスすべきか否かを判断をしなければならないことになります。どこかの時点で、少なくともいくつかの重要なシステムにおいては必要かもしれませんが、秩序のない初期の状態においては有効なアプローチとは言えません。 また、システムオーナーの多くはマネージャーやディレクターです。彼らの時間は貴重です。時間のない人は優先順位を意識するため、この業務はどんなに重要でも後回しにされる可能性が高いでしょう。 戦略3:まずユーザーに質問し、マネージャーにその回答を確認してもらう 他の誰かに聞く前に、まだシステムへのアクセスが必要かどうかをユーザー本人に質問することは可能です。 今回採用した方法はまさにこれで、まずは従業員に以下のように聞いてみます。 これらシステムすべて対してまだアクセス権は必要ですか?はい/いいえ/分からない 回答が集まったら(または期限が過ぎたら)、彼らのマネージャーに質問します。 各メンバーの役割と責任を考慮した上で彼らの回答をレビューし、それらアクセス権が適切かどうかを確認してください。 今回はそこまで実施しなかったものの、3段階目のレビューとしてシステムオーナーへの質問も考えました。 これらのチームはあなたの管理するシステムを使用しています。このシステムの用途を考えると、彼らがアクセスすることに問題はありませんか? この方法の場合、アクセス権を維持するか取り消すかの判断をアクセス権を実際に使用する人に委ねることになります。また、権限の確認を全従業員に割り振ることができるという利点もあります。残念ながらマネージャーにはメンバーがアクセスの必要性を主張しているアプリケーションをすべて確認してもらわなければなりませんが、求められているのは確認だけなので検査は比較的早く終わるはずです。妥当かどうかを簡単に確認するだけなら、通常ひとり5分もかかりません。場合によってはもう少しかかるかもしれませんが、DM(ダイレクトメッセージ)で確認することが可能です。 このプロセスを通して私たちは「Security TeamのAさんが給与システムのアクセス権を持っている」といった、本来であれば例外的なケースを発見したいと思っていました。もし本人が「必要だ」と言ったとしても、少なくともマネージャーにその妥当性を確認してほしいからです。 このプロセスを実施している間、「このアクセス権が付与されているなんて知らなかった」「そもそもこのサービスってなに?」といったコメントが数多く寄せられました。 Oktaの使われ方からして、今回選択した方法が完璧とはいえないことは分かっています。ですが、私たちはOktaでアプリケーションのアクセス権を付与しています。メルカリの場合、アプリケーション内で権限を付与することはほとんどありません。そしてこれはシステムオーナーに委ねられています。このようなやり方のため、そもそも最初からアクセスできる対象を制限することでかなりの違いが出てきます。さらに追加のクリーンアップは後からでもできます。その時に、いくつか重要なシステムを優先的に対応することも可能です。 プロセスの実施方法 さて、ここまでに「なぜ検査を行うのか Why 」、「どのシステムを対象とするか What 、「誰が回答し、誰がレビューするのか Who 」が明確になりました。次は、「どうやって全員に質問し、回答を集めるのか How 」です。 スプレッドシートでの検査(現実的ではありません) すべてのユーザー/グループ/アプリを含めると20万行になってしまいGoogleスプレッドシートには収まらないし、全員に開いてレビューするようお願いするのもばかげています。シートの完全性を確保することは可能ではあるものの、さらに多くの作業が必要となります。 Webベースでの検査(現時点では見送る) うまくいくとは思いつつも、少なくともこの段階では検査を実施するためのウェブページは作らないことにしました。 OktaのIdentity Governance Access Certificationキャンペーン機能(我々には有効ではありません) Oktaには Identity governance access certification という機能があります。Oktaが将来的にアクセスレビューとして使用されることを承知の上で一から設定されているのであれば、この機能を利用する方法はうまくいくでしょう。ここではオーナーは特定グループに割り当てられ、そのグループはアプリケーションに割り当てられます。キャンペーンを実施している間、グループオーナーはグループのメンバーがアクセス権を所有すべきかを確認するよう依頼されます。この方法は、グループオーナーがそのユーザーがアクセス権を持つべきかを判断できることを前提としています。グループは多くの場合チームを意味するため、メンバーの管理はマネージャーに委ねられるでしょう。そのチームグループは、アプリケーションオーナーから必要なアプリケーションに割り当ててもらう必要があります。しかし、Oktaには(現時点では)アプリケーションオーナーを定義する属性がありません。 通常のケースはこの方法で問題ないのですが、例外ケースの場合は他のグループを通じて管理する必要があり、その例外を理解できる人に割り当てる必要があります。 私たちの今の状態で考えると、グループ=チームではなく、通常(必ずではないものの)アプリケーションへのアクセスを許可するために使われているので、この方法は有効な策ではありませんでした。この状態は、これらグループにオーナーが割り当てられていないという意味でもあります。システムオーナーに Slack + バックエンド + Neo4j(選んだ方法) 私たちはユーザーインターフェイスとしてSlackを、バックエンドデータベースとしてNeo4jを使うことに決めました。バックエンドにグラフデータベースを使うことで、チーム、メンバー、そのマネージャーに対する問い合わせと、彼らがどのグループを通じてどのようなアクセス権を持っているかを(比較的)簡単に照会できるからです。とりあえず今回は、アプリケーション内で付与されたアクセスのレビューは対象外にすることも決めました。 このブログ記事の残り部分では、私たちが実施したプロセスを説明します。 検査を進めるためには、いくつかのステップを経る必要がありました。 組織構造を復元する Okta上のアプリケーション、グループ、ユーザー、すべてのメンバーシップとそれらの関係を復元する 組織とアクセスを記したグラフを作成する 各チームと従業員向け:Slackのフォームを作成し、どのアクセスがまだ必要かの確認を依頼する ユーザーからの回答を集める 各マネージャー向け:Slackのフォームを作成し、メンバーが必要だと申告しているアプリに同意するかどうか質問する。ユーザーからの応答がない場合はマネージャーに決めてもらう マネージャーからの回答を集める 妥当性の確認:明らかにおかしな回答がないかレビューする Okta APIを通じてアプリケーションへのアクセスやグループメンバーシップを取り消す 変更を記録する ステップ8を除く上記のすべての操作はコードを通じて行います。そうすればこのプロセスを確実に再現することができるからです。 組織構造とアクセス権をデータベースで表す Oktaのユーザーは、チームとマネージャーを示す属性を持つように設定することができますが、いくつか実際の組織構造との相違点が見られたため、最終的には別のソースから完全な構造を抽出し、その構造をOktaのユーザーとリンクさせなければなりませんでした。組織構造をグラフ化することで、Okta上の関係ではなく実際の組織構造を明らかにすることができたので非常に便利でした。 その後、Oktaから特定の組織単位や チームにおけるアプリ、グループ、ユーザー間の関係を抽出することができました。 イメージ1:Oktaと人事データをNeo4jグラフデータベースに統合し、Mermaid.jsで可視化 スキーマ:組織、チーム、マネージャー、メンバー、グループ、アプリケーション間の関係性 オーバーエンジニアリングを防ぐために、少なくとも最初のうちはいくつかショートカットを採用し、各従業員の単位としてOktaUserノードを使用することにしました。現実はもっと複雑な権限が付与された対象を特定する必要があるのですが、この段階ではこれで十分でした。 イメージ2:Mermaid.jsを使って視覚化したデータベース内における関係性の概略図 Neo4jデータベースへの書き込みが終わると、組織、チーム、各チームが使用しているアプリケーションを照会できるようになりました。組織構造のグラフはこのような様子でした。 イメージ3:Neo4jのウェブインターフェイスを使って作成したメルカリの組織構造図 以下のクエリは以下のような意味を表します: 「Platform Security」チーム直下のメンバーで、有効なOktaアプリにアクセス権があるすべてのメンバーに対して: マネージャーを取得する 直近90日間にこれらのアプリを使用したかどうかを取得する ユーザー・アプリ間の関係性のOrgノード、マネージャーノード、関係性のプロパティ、最終使用のプロパティ、およびアプリノードを返す これを元に再度、グループメンバーシップによるアプリへのアクセスを考慮します。 // Team: Platform Security MATCH (o:OrgUnit {name: "Platform Security"})<-[:IS_MEMBER_OF]-(u:OktaUser)-[r:HAS_ACCESS_TO]->(a:OktaApp {status: "ACTIVE"}) WITH o, u, r, a MATCH (u)-[:IS_REPORTING_TO]-(m:OktaUser) WITH o, m, u, r, a OPTIONAL MATCH (u)-[p:HAS_USED]->(a) RETURN o, m, u, PROPERTIES(r) AS r, PROPERTIES(p) AS p, a MATCH (o:OrgUnit {name: "Platform Security"})<-[:IS_MEMBER_OF]-(u:OktaUser)-[r:IS_MEMBER_OF]-(g:OktaGroup)-[:HAS_ACCESS_TO]->(a:OktaApp {status: "ACTIVE"}) WITH o, u, r, g, a MATCH (u)-[:IS_REPORTING_TO]->(m:OktaUser) WITH o, m, u, r, g, a OPTIONAL MATCH (u)-[p:HAS_USED]->(a) RETURN o, m, u, PROPERTIES(r) AS r, PROPERTIES(p) AS p, g, a Query1:Neo4j Cypherを使用して、特定のチームのアプリケーションとグループのアクセスリストを取得 プロセスを開始する コントローラー(アプリ)はユーザーを特定するためにチームのリストを使用しています。チームの再帰的リストは、次のようなクエリでNeo4jデータベースから簡単に抽出できます。 MATCH (t:OrgUnit)-[:IS_PART_OF*]->(o:OrgUnit) WHERE o.name = "Security & Privacy" AND t.status = "active" RETURN t.name AS team, t.orgId AS orgId, o.name AS orgName クエリ2:Neo4j CypherでSecurity & Privacyカテゴリの再帰的チーム階層を復元 ここからスコープ内のチームリストに基づいて、コントローラーから検査開始がマネージャーに通知されます。各チームメンバーにが作成され、SlackのDMで調査フォームが送信されます。 メンバーに調査フォームを送信する Image 4: Sequential flow chart detailing the member campaign process, illustrated with Mermaid.js. The assessment form sent to members is kept simple and is meant to be quick to fill. A user can click on the application name to connect to the app and confirm if they still need access to it, then select “Access needed” or “No need anymore”. イメージ4:メンバーのキャンペーンプロセスのフローチャート 回答収集用のバックエンド 調査フォームが送られたら、あとは回答を待つだけです。バックエンドで回答を受け取り、その回答に従ってNeo4jデータベースを更新する準備は整っています。 イメージ6:調査フォームからの回答を収集する手順のフローチャート 調査を実施している間、手動でマネージャーに進捗状況を送信し、未回答の場合はチームメンバーに確認してもらうよう依頼することができます。 マネージャーによる回答のレビュー 回答の回収が済んだら、未回答・未完了のメンバーがいたとしても、マネージャーにアクセスのレビューを依頼します。メンバーからの回答は一目瞭然であり、チームに関係するアプリケーションもよく知られているはずなのでこのステップは通常すぐに終わります。 マネージャーが対応しない場合は、その上司に進捗がないことを報告することができます。 マネージャーのレビューの流れは以下です: イメージ7:Mermaid.jsを使用して、マネージャーのレビュー作業のシーケンス図 マネージャーに送信されるフォームはユーザーに送られるフォームと似ていますが、必要だと回答されたアプリだけが表示されています。マネージャーはメンバーの回答を確認し、メンバーによりアクセスが必要だと判断されたアクセス権に対して、保持か削除を選択することができます。 イメージ8:Slack内のマネージャーレビューフォームのインターフェイスの例 不要なアクセスのクリーンアップ この段階では、メンバーからの回答が集まり、マネージャーからの確認も回収済みです。個別のアクセスレビューではなく、チーム単位でのアクセス権付与に同意するかの確認をシステムオーナーに依頼することもできましたが、これは今後の検査に回すことにしました。 Okta APIによるアクセス取り消しフローは比較的シンプルです。 イメージ9:アクセス取り消しメカニズムに関するステップのフローチャート まとめ 今回のプロジェクトを通して、従業員やマネージャーが正直に回答してくれると信じることで、従業員がどのアクセス権を持ち、どのアクセス権を必要としているかをレビューすることができました。多くの規格、フレームワーク、規制、ベストプラクティスでは、企業が定期的にこういったレビューを実施することが求められています。しかし、得てして複雑な組織構造や歴史的背景がからみあい、こういったレビューはすぐに手に負えなくなるものです。そこで、従業員とアプリケーション間の複雑な関係性をグラフデータベースに移行し、まず従業員にアクセス権が必要かどうかを質問することで、会社の規模に応じて検査の規模を拡大することができました。また、今回の検査は、システム分類作業に長い時間をかけることなく実施することができました。Oktaに大きく依存しているからこそ、Oktaに焦点を当てることで、大半のシステムをカバーすることができたのです。 このフローにもまだまだ改良の余地はあり、他のシステムへの拡張も可能だと考えています。アクセス許可のルールと確認をより厳格にし、プロビジョニングプロセスに組み込むこともできるかもしれません。 一方、今回私たちはすでに、アクセス中断のリスクを負うことなく、不要となった膨大な量のアクセス権を削除することができました。これは、アクセス権を取り消すか否かを判断する際に、こちらで決めたルールを使用するのではなく、従業員とマネージャーの回答に基づいて行ったからです。
こんにちは、メルカリの生成AIチームで ML Engineer をしている ML_Bear です。 以前の記事[1]では商品レコメンド改善のお話をさせていただきましたが、今回は、大規模言語モデル (LLM) やその周辺技術を活用して30億を超える商品のカテゴリ分類を行なった事例を紹介します。 ChatGPTの登場によりLLMブームに火がついたということもあり、LLMは会話を通じて利用するものだと認識されている方が多いと思いますが、LLMが有する高い思考能力はさまざまなタスクを解決するためのツールとしても非常に有用です。他方、その処理速度の遅さや費用は大規模なプロジェクトでの活用にあたっての障壁となり得ます。 本記事では、こうしたLLMの課題を克服するためにさまざまな工夫を施し、LLM及びその周辺技術のポテンシャルを最大限に引き出して大規模商品データのカテゴリ分類問題を解決した取り組みについて説明します。 課題 まずは今回のプロジェクトの背景と技術的な課題を簡単に説明します。 メルカリは2024年にカテゴリリニューアルを行い、階層構造を見直すとともに商品カテゴリの数を大幅に増やしました。しかしカテゴリ数やその階層構造がかわるということは、それに紐づく商品のデータも変更する必要があります。 通常であれば商品のカテゴリ分類は機械学習モデルやルールベースモデルを利用します。しかし今回のケースでは過去の商品に対する「新しいカテゴリ階層での正解カテゴリ」がわからないため、機械学習を使用した分類器を作成することができませんでした。また、カテゴリ数が非常に多いため、ルールベースモデルの構築も困難でした。そこで、この課題に対してLLMを活用できないかというアイディアが出てきました。 解決策: LLMとkNNによる2ステージ構成の予測アルゴリズム 結論としては以下のような2ステージ構成のアルゴリズムを組むことで今回の課題に対応しました。 ChatGPT 3.5 turbo (OpenAI API[2])で過去商品の一部の正解カテゴリを予測する 1.を学習データとして過去商品のカテゴリ予測モデルを作成 全てをChatGPTで予測できれば楽だったのですが、メルカリの過去商品は30億商品を超えるため[3]、全てをChatGPTで予測するのは処理時間的にもAPIコスト的にも不可能でした。そのため、紆余曲折を経てこのような2ステージのモデル構成としました。(すべての商品をChatGPT 3.5 turboで分類するとコスト見積もりは約100万ドル、処理時間見積もりは1.9年という非現実的な数字でした) 以下にモデルの内容を簡単に説明します。詳細については「工夫した点」で述べるため、一旦はシンプルな解説に留めます。 1. ChatGPT 3.5 turbo (OpenAI API)で過去商品の一部の正解カテゴリを予測する まず、過去に出品された商品を数百万点サンプリングし、ChatGPT 3.5 turboにその商品の「新しいカテゴリ構成での正しいカテゴリ」を予測させました。 具体的には、各商品の商品名や商品説明文、元のカテゴリ名をもとに新しいカテゴリの候補を10個程度作成し、その候補の中から正解を答えさせました。 2. 1.を学習データとして過去商品のカテゴリ予測モデルを作成 次に、1. で作ったデータセットを正解データとして、シンプルな kNN モデル[4] を作成しました。 具体的には、まず、1.で正解カテゴリを予測した商品のEmbeddingと正解カテゴリをベクトルデータベースに保存しておきます。その後、予測したい商品のEmbeddingを元に、ベクトルデータベースから類似商品をX個抽出し、そのX個の商品の最頻カテゴリを正解カテゴリとしました。 Embeddingは各商品の商品名、商品説明文、メタデータ、元のカテゴリ名などを連結した文字列をもとに計算しました。より複雑な機械学習モデルも検討しましたが、シンプルなモデルで及第点の性能が出たためシンプルなモデルを採用しました。 工夫した点 さて、ここからは今回のプロジェクトで工夫した点をご紹介します。以下のような点を工夫したので、ひとつづつ説明します。 OSSのEmbeddingモデルの活用 Sentence Transformers ライブラリによるMulti-GPUの活用 Voyager Vector DBによるCPU上での高速な近傍検索 max_tokensとCoTの活用によるLLM予測の高速化 Numba・cuDFの活用 1. OSSのEmbeddingモデルの活用 第2ステージのモデル (kNN) では商品のEmbeddingの計算が必要でした。自前でニューラルネットワークを組むことも可能でしたが、OpenAI Embeddings API ( text-embedding-ada-002 ) [5]で十分な精度が出ることが確認できたので、当初はこのAPIを利用する方針としていました。 しかし、試算してみたところ、すべての商品にOpenAI Embeddings APIを利用するのは処理時間的にもコスト的にも少し厳しいということがすぐにわかりました。 そんな中、MTEB[6]やJapaneseEmbeddingEval[7]を眺めていると英語以外の言語でもOpenAI Embeddings APIに匹敵するOSSのモデルが多数あることに気づきました。自分たちで評価用データセットを作って試してみたところ、OpenAI Embeddings API同等の精度が出たためOSSのモデルを利用することにしました。 私たちがこのプロジェクトを行なっていた2023年10月時点のデータでは、以下のモデルが高い精度を示しており、最終的には計算コストと精度のバランスを鑑み intfloat/multilingual-e5-base を利用しました。(MTEBのランキングは常時入れ替わっているため、2024年4月現在はもっと強いモデルがあると思います) intfloat/multilingual-e5-large [8] intfloat/multilingual-e5-base [9] intfloat/multilingual-e5-small [10] cl-nagoya/sup-simcse-ja-large [11] このように、OSSでも非常に高性能なEmbeddingモデルが存在しているため、Embeddingを利用するプロジェクトを行う場合は、シンプルな問題を作成して、OSSでも十分な性能を持つモデルがあるかどうかを確認してみることをお勧めします。 2. Sentence Transformers ライブラリによるMulti-GPUの活用 OSSモデルを利用することで OpenAI Embeddings APIに比べて飛躍的に処理速度が上がったものの、数十億商品を処理するにはもう少し改善が必要でした。 A100などの強力なGPUを利用できれば話が早かったのですが、世界的なGPU枯渇の影響を受けてか、プロジェクト実施時の2023年11-12月時点では強いGPUを掴むことはなかなか困難でした。(現在もあまり状況は変わっていないかと思います) そのため、V100やL4などのGPUを複数台並列で利用して対応することにしました。幸いなことに、Sentence-Transformers[12]ライブラリを利用すると以下のようなシンプルなコードで複数台のGPUを簡単に並列化できたため、非常に助かりました。 from sentence_transformers import SentenceTransformer def embed_multi_process(sentences): if 'intfloat' in self.model_name: sentences = ["query: " + b for b in sentences] model = SentenceTransformer(model_name) pool = model.start_multi_process_pool() embeddings = model.encode_multi_process(sentences, pool) model.stop_multi_process_pool(pool) 強力なGPUを大量に使えれば理想的ですが、それが難しい状況でも工夫次第で処理を高速化することができます。Sentence-Transformersのようなライブラリを活用して、限られたリソースを最大限に活用することが重要だと感じました。 3. Voyager Vector DBによるCPU上での高速な近傍検索 kNNを利用する際にはベクトルデータベースが必要でした。サンプリングしたとはいえ数百万商品の学習データになったため、GPUのメモリに載らない状況でした。A100 80GBなどの大きなメモリを持つGPUを使えば載ったかもしれませんが、前述の通り強力なGPUは確保が困難だったので試すことすらできませんでした。 そんな折、Spotify社製のVoyager[13]がCPUでも高速に動作すると聞いたので試してみたところ、実用に足る速度を簡単に実現できました。Embedding計算に比べると近傍探索の時間はそれほど影響が大きくなかったため厳密に他のプロダクトと比較していませんが、十分な速度を出すことができていました。 Voyagerにはメタデータ管理機能がなかったので自分たちでクライアントを書く必要がありましたが、それでも全体的には良い選択だったと思っています。 4. max_tokensとCoTの活用によるLLM予測の高速化 今回のプロジェクトでは ChatGPT 4 はコスト面から利用できず、ChatGPT 3.5 turboを使わざるを得ませんでした。ChatGPT 3.5 turboはコストの割に賢いとは思いますが、精度には少し不安がありました。そのため、Chain of Thoughts[14]を利用して説明を生成させることで精度向上を図りました。 皆さまもご存知かと思いますが、ChatGPTに説明を行わせるとずっと喋り続けることもあり、処理時間が問題となりました。そこで、 max_tokens パラメータを利用して長い話を途中で打ち切ることで処理時間の短縮に努めました。 回答を打ち切ると(Function Callingの) JSONが壊れるので、LangChain[15]のllm.stream()を利用したり、もしくは自分でJSONを復元してパースする必要があり少し手間がかかります。厳密な比較はしていないものの、この手法によって処理時間短縮と精度向上の良いバランスを取れたと感じています。 以下がLangChainの llm.stream() を利用した場合のサンプルコードです。 from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from typing import Optional from langchain_core.pydantic_v1 import BaseModel, Field class ItemCategory(BaseModel): item_category_id: int = Field(None, description="商品説明から予測したカテゴリID") reason: Optional[str] = Field(None, description="このカテゴリIDを選択した理由を詳しく説明してください") system_prompt = """ 与えられる商品情報を元に、商品のカテゴリを予測してください。 商品のカテゴリは候補から選んでください。選んだ理由も説明してください。 """ item_info = " (商品データと新カテゴリ候補などを入れる) " llm = ChatOpenAI( model_name="gpt-3.5-turbo", max_tokens=25, ) structured_llm = llm.with_structured_output(ItemCategory) prompt = ChatPromptTemplate.from_messages( [ ("system", system_prompt), ("human", "{item_info}"), ] ) chain = prompt | structured_llm # streamingの最後の要素だけ取り出す # - 通常、max_tokensで回答を打ち切るとjsonが壊れてパースの処理が必要 # - langchainのstreamで実行すると常にjsonを完成させてくれるため、 # max_tokensで回答を打ち切ってもjsonをパースする必要がない for res in chain.stream({"item_info": item_info}): pass print(res.json(ensure_ascii=False)) # res: ItemCategory # {"item_category_id": 1, "reason": "商品名に「ぬいぐるみ」が含まれ"} 5. Numba・cuDFの活用 数十億商品を処理する際は些細な処理でも処理速度が気になるため、可能な限りすべての処理をcuDF[16]およびNumba[17]で高速化しました。 正直なところ Numba を書くのは苦手だったのですが、Pythonの素のコードをChatGPT 4に見せると書き直してくれるため、ほとんど自分で書く必要がなくコーディング工数を大幅に削減することができました。 まとめ ChatGPTは会話形式で利用されることが多く注目を集めていますが、その高い思考能力を活用することで、これまで面倒だったり不可能であったタスクを簡単に解決できるようになります。私たちのプロジェクトでは、膨大な商品データを新しいカテゴリに短期間で分類し直すという面倒な課題を、ChatGPTを活用することで解決することができました。 また、OSSのEmbeddingモデルやマルチGPUの活用、高速な近傍検索が可能なベクトルデータベースの採用、ChatGPTでの予測の高速化、Numbaを用いた処理の高速化など、様々な工夫を行うことで、限られた時間とリソースの中でも最大限の成果を出すことができました。 今回の事例が、ChatGPTをはじめとする大規模言語モデルの可能性の一端を示し、皆様のプロジェクトの参考になれば幸いです。ぜひ、様々な場面でLLMを活用し、これまでは難しかった課題にチャレンジしてみてください。 Refs 協調フィルタリングとベクトル検索エンジンを利用した商品推薦精度改善の試み OpenAI API フリマアプリ「メルカリ」累計出品数が30億品を突破 k-nearest neighbors algorithm OpenAI Embeddings API Massive Text Embedding Benchmark (MTEB) Leaderboard JapaneseEmbeddingEval intfloat/multilingual-e5-large intfloat/multilingual-e5-base intfloat/multilingual-e5-small cl-nagoya/sup-simcse-ja-large Sentence-Transformers Voyager Chain-of-Thought Prompting Elicits Reasoning in Large Language Models (Wei et al. 2022) LangChain rapidsai/cudf Numba: A High Performance Python Compiler
Merpay Engineering Productivity Team の goccy です。 gRPC Federation は、gRPC で通信する複数のサービスから得た結果を合成して返すようなサービスを簡単に作成するための仕組みです。DSL ( Domain Specific Language ) を Protocol Buffers 上で記述することで利用します。まずは、 GraphQL(Apollo) Federation の gRPC 用のものだと考えるとわかりやすいと思います。2023年8月に OSS として公開し、先日 Public Roadmap を公開しました。2024/6月末を目標に Version 1.0 ( GA版 ) をリリースする予定です。また、最近は Protocol Buffers のエコシステムに参加しました。 Protobuf Global Extension Registry への登録 や Buf Schema Registry への登録 、 Buf Plugin のサポート が終わり、既存のエコシステムに従って gRPC Federation を利用できます。 本稿では、Version 1.0 を目前に控えた gRPC Federation をどのような思想のもとで設計したかを説明し、現在の gRPC Federation の表現力やプラグインシステム、周辺ツールなどの機能について触れ、今後の予定を紹介します。 2023年8月の Merpay & Mercoin Tech Fest で紹介したもの から多くのアップデートがあります。ぜひ、新しいアーキテクチャを考える際の材料にしてください 設計方針 Protocol Buffers を進化させる gRPC Federation は DSL を Protocol Buffers 上に記述することで利用します。本項では、私たちがこの選択を選んだ理由を説明します。 従来、Protocol Buffers は主にAPIやデータ構造を定義する設計用途で利用されてきました。コード生成と組み合わせることで、設計に対応した実装を生成でき、設計と実装を乖離させることなく保守・運用できることが強みです。さらに、プラグインの仕組みとそれを利用したツールによって、Protocol Buffers 上で定義されたAPIやデータ構造に対してカスタムオプションを利用して付加情報を与えることができ、これにより多様な自動生成が可能になっています。 gRPC Federation はこの点に着目し、gRPC サービスを動作させるために必要十分な実装を DSL として Protocol Buffers 上で記述できるようにしました。これによって、Protocol Buffers は自身のもつ情報だけで gRPC サービスを構築できる言語へと進化します。 DSL を Protocol Buffers 上で記述すべきか、別の専用のファイルで記述すべきかは議論を重ねました。DSL を専用のファイルで記述する場合、言語のシンタックスを自由に調整でき、書き味を向上させやすいメリットがあります。しかしその反面、独自の言語を利用する場合は Parser の実装が必要になり、ソフトウェアの複雑度が飛躍的に増加する懸念や、専用のファイルをどのように管理すべきか考える必要があります。Protocol Buffers と分離することで、設計と実装を乖離させることなく保守・運用できるという恩恵を受けづらくなるともいえます。 また、開発者が普段慣れ親しんだ汎用プログラミング言語でコードを書くことに比べて、gRPC Federation のような DSL を利用する効果とは何かについても考えました。 DSL を利用することで必要最小限の記述でやりたいことを表現できるという側面はあります。ですが、DSL 自体に学習コストがあるため、慣れ親しんだ言語で書いた方が効率よく開発できそうな気がします。また、定型化できる部分をライブラリなどで提供すれば、より少ない記述で実装することもできそうです。 私は、DSL のメリットは「多様な表現ができない」こと自体にあると考えています。DSL を利用する以上、汎用プログラミング言語のように自由にコードが書けるわけではありません。逆を言えば、DSL を利用して制約のある中で生成するコードはすべて予測可能で、知らないミドルウェアやサービスにアクセスしたり、ファイルシステムにアクセスするようなことはありません。これは DSL を利用して作成されたサービスのビルドやデプロイを管理する立場からすると重要な意味を持ちます。例えばビルド時に特別な依存がないことが保証されている場合、より高速にビルドしたりビルドプロセスを自動化したりといった手段が選択できます。同様にデプロイに関しても、アプリケーションが動作するために必要十分な環境を用意しやすい、動作環境をセキュアに保ちやすいといった側面があります。 こうした理由から、私たちは Protocol Buffers 上で DSL を書く方法を選択しました。シンタックスの融通が効かないデメリットを差し置いても、すでに Protocol Buffers 上で定義されている API やデータ構造をそのまま Protocol Buffers 上で参照できるメリットは大きいと考えています。また、gRPC Federation の利用過程でサービス間の依存関係が明示されることで、サービスの循環参照の有無や、問題発生時の影響範囲の特定、APIレベルでの実行コストの計算といった様々な解析を行うことが Protocol Buffers だけでできるようになります。 DSL の限界とプラグインシステム gRPC Federation を作る上で、「DSL でどこまで表現できれば十分か」を考えることが一番難しい点でした。様々な機能をサポートしていく過程で DSL の表現力は向上していきますが、どこまでいっても DSL では実現不可能なロジックは存在します。また、DSL で表現できる範囲だったとしても、再実装せずに、すでにある3rd party製のライブラリを利用したい場合も考えられます。そこで私たちは、DSL には限界があることを理解した上で、Protocol Buffers 上で最低限記述すべき内容を決め、それ以外は DSL の外で実装する選択ができるようにしています。 Protocol Buffers 上で最低限記述すべき内容は「gRPC メソッド呼び出しの記述」としました。gRPC Federation の機能を簡潔に書くならば、「gRPC メソッドを呼び出す」ことと「メソッド呼び出しの結果を加工する」 ことを Protocol Buffers 上で書くことです。このとき、「どのgRPC メソッドを呼び出しているか」が Protocol Buffers 上に書かれなければ、Protocol Buffers を見ただけではどのサービス(のどのメソッド)に依存しているのかわからなくなってしまいます。私たちは経験上、マイクロサービス開発においてサービスの依存関係を把握することがとても重要であることを知っています。そのため、最低限「gRPC メソッド呼び出しの記述」は Protocol Buffers 上で行い、Protocol Buffers を解析するだけでサービス間の依存関係を把握できるようにしています(下図)。 DSLの外で実装する手段として、いくつかの方法を用意しています。まず、gRPC Federation では DSL で表現できない部分だけを Go 言語によって実装することができます。しかし Go で実装する部分が多くなると Protocol Buffers と Go で実装が分離し、あまり嬉しくありません。そこで、もうひとつの選択肢としてプラグインの仕組みを提供しています。Go で書く場合と違う部分は、DSL で式の評価に利用している CEL( Common Expression Language ) の API を拡張できる点です。この仕組みを利用することで、Protocol Buffers 上で独自の API を使った表現が記述でき、Go で書く場合に比べて Protocol Buffers 上に実装を集中させやすくなります。また、複数の Protocol Buffers ファイルから共通の処理を利用したい場合にも有効です。 gRPC Federation の活用場面 gRPC Federation を利用することでサービス間の依存関係が明確になり、Protocol Buffers 上で把握できる情報を増やすことが可能です。また、gRPC Federation によって生成されたコードを利用することで、サービス開発における定型化された作業に割く時間を大きく減らし、ビジネスロジックの実装に集中できるようになります。 そのため、複数のマイクロサービスの結果を合成して返すことが主な責務である BFF ( Backends For Frontends ) や Public API のような toB 向けのサービスは gRPC Federation を採用する例として最も適していますが、通常のマイクロサービス開発でも十分に利用できると考えています。 gRPC Federation がもつ表現力 次に、現状の gRPC Federation の表現力について、重要な機能をいくつか簡単に紹介します。 gRPC Federation では service / message / field など Protocol Buffers上の各要素に対して専用のオプションを用意しています。簡単な例を利用した説明は こちらに記載しました 。 本稿では、長くなりすぎてしまうので基本的な使い方については省略しますが、各項目の例を見ていただければ、なんとなく何ができるのか理解していただけると思います。 公式リファレンスはこちらです 。 変数定義と式の評価 gRPC Federation の開発を進めていくにあたって、変数や式の評価を行う仕組みが必要になりました。式の評価には、 Kubernetes の Custom Resource Definition でも利用されるようになった 、Common Expression Language (CEL) を採用しました。 こちらに言語仕様がとまっています 。 CEL は式を評価することに特化した言語で、小さくかつ洗練された仕様と豊富な拡張性をもっています。四則演算や論理演算、三項演算から関数、マクロまで様々な機能がある他、gRPC Federation では独自に CEL の機能を拡張し、例えば google.protobuf.Timestamp に対して Go の time ライブラリの機能を適応 したり、 reduce や first といったマクロ を使用できるようにしています。CEL は Protocol Buffers と親和性高く設計されており、gRPC Federation のように Protocol Buffers 上の定義を CEL の中で利用したい場合に適しています。ですが、CEL は変数の定義ができないため、gRPC Federation の仕様として 「CEL の評価結果を変数に代入できる機能」と「定義済みの変数をCELの評価式の中で参照できる機能」を追加しました。 次のように、 def キーワードを利用して式を評価した結果に名前を付けることで変数を定義できます。 grpc.federation.message option で定義された変数は grpc.federation.field option で参照することができ、次のように参照した変数の値をそのままフィールドに代入することができます。 message M { option (grpc.federation.message) = { def [ { name: "t" // 2024/4/01 00:00:00+0 by: "grpc.federation.time.date(2024, 4, 1, 0, 0, 0, 0, grpc.federation.time.UTC())" }, { name: "sum" by: "[2, 3, 4].reduce(accum, cur, accum + cur, 1)" }, // sum = 10 { name: "v" by: "[1, 2, 3, 4].first(cur, cur % 2 == 0)" } // v = 2 ] }; google.protobuf.Timestamp time = 1 [(grpc.federation.field).by = "t"]; int64 sum = 2 [(grpc.federation.field).by = "sum"]; int64 first = 3 [(grpc.federation.field).by = "v"]; } このように、message option の中でフィールドに割り当てる値を作り、 field option でその値を参照して代入するというのが基本の使い方です。 現在 gRPC Federation で利用可能な CEL API は こちらにまとめました 。 gRPC メソッドの呼び出し 必ず Protocol Buffers 上に記述してもらいたい、gRPC メソッドの呼び出し方法について説明します。 リファレンスはこちらです 。 使い方の前に、呼び出し対象のメソッドが次のように定義されているとします。 メソッドへの FQDN は foopkg.FooService/GetFoo となり、メソッドを呼び出すためには GetFooRequest メッセージの内容を作る必要があります。返り値は GetFooResponse です。 package foopkg; service FooService { rpc GetFoo(GetFooRequest) returns (GetFooResponse); } message GetFooRequest { FooParam param = 1; } message FooParam { string x = 1; } message GetFooResponse { Foo foo = 1; } message Foo { string bar = 1; } このとき、メソッドを呼び出すには、次のように call{} を記述します。 message M { option (grpc.federation.message) = { def { name: "res" call { method: "foopkg.FooService/GetFoo" request { field: "param" by: "foopkg.FooParam{x: 1}" } } } def { name: "f" by: "res.foo" } // f = foopkg.Foo{} }; string result = 1 [(grpc.federation.field).by = "f.bar"]; // assign foopkg.Foo.bar field to result field. } method に呼び出したいメソッドの FQDN を記述し、 request で GetFooRequest メッセージの各フィールドの値を指定します。ここでは CEL を使って foopkg.FooParam の内容を作成しました。 メソッドの呼び出し結果は res 変数に格納します。 次の変数定義で res 変数の foo フィールドへアクセスしているので、 foopkg.Foo の値が変数 f に代入されます。最後に、フィールドバインディング時に変数 f を参照し、 bar フィールドの値を取り出して result フィールドに代入しています。 メッセージへの依存 メソッドを呼び出した結果を欲しい形に加工する上で重要になるのが、メッセージ間に依存関係を作る機能です。 リファレンスはこちらです 。 あるメッセージは別のメッセージに依存することができます。依存関係は gRPC Federation のオプションを利用して明示的に記述することができます。例えば、 次の例にある M というメッセージを構築することが目標である場合、 M メッセージのフィールドに存在する Dep メッセージの値を作る必要があります。ここで、 Dep メッセージが GetFoo メソッドの呼び出し結果の値を利用することで作れるとすると、次のように記述することができます。 message M { option (grpc.federation.message) = { def { name: "res" call { method: "foopkg.FooService/GetFoo" request { field: "param" by: "foopkg.FooParam{x: 1}" } } def { name: "dep" message { name: "Dep" args { name: "f" by: "res.foo" } } } }; Dep dep = 1 [(grpc.federation.field).by = "dep"]; } message Dep { string bar = 1 [(grpc.federation.field).by = "$.f.bar"]; } message{} を利用することで他のメッセージの値を作ることができます。メッセージの値を作る際は args{} を利用して自由に依存先のメッセージに対して引数を渡すことができ、 name で名前を指定することで、依存先のメッセージ側で $. というプレフィックスを付けて引数にアクセスすることができます。 この例では、 res 変数から取得した foo フィールドの値に対して、 f という名前の引数を作って Dep の値を作っています。 Dep メッセージ側では、CEL の評価式の中で $.f と記述することで引数にアクセスしています。 バリデーション サービスを実装する上で、メソッドを呼び出した結果に対するバリデーションは常に意識しなければいけません。バリデーションの結果、エラーを返す場合は gRPC の慣習に従ってエラーを作る必要もあります。Protocol Buffers でバリデーションと聞くと、 protovalidate が有名だと思います。これはリクエストパラメータのバリデーションに利用するものですが、 gRPC Federation の場合はリクエストに限らず、参照可能なあらゆる変数に対して行うことができます。また、gRPC エラーを返すために特化した機能も用意しています。 リファレンスはこちらです 。 例えば次の例のように、 GetFoo メソッドを呼び出した結果が期待値かどうかを確認することが可能です。エラーは google.rpc.Status を作るようになっており、 error_details.proto で定義されているものがサポートされています。加えて、独自のメッセージを作ってエラーに含めることも可能です。 例えば Go 言語では、 errdetails パッケージを使って grpc.Status を作る処理に該当します。 message M { option (grpc.federation.message) = { def { name: "res" call { method: "foopkg.FooService/GetFoo" request { field: "param" by: "foopkg.FooParam{x: 1}" } } } def { validation { error { if: "res.foo.bar != 'xxx'" code: FAILED_PRECONDITION message: "'unexpected foopkg.Foo.bar value'", } } } }; } ここで紹介した機能は全体のごくわずかです。gRPC Federation は他にも多くの機能が存在するので、お時間のある際にぜひ見てみてください。 WebAssembly を利用したプラグインシステム gRPC Federation では、DSL 中に記述する CEL API や gRPC Federation がもつコード生成パイプラインを WebAssembly を利用して拡張することができます。プラグインを WebAssembly として実行することで、WebAssembly ランタイム側で制約を設けることができます。これにより、例えばネットワークやファイルシステムへのアクセスを禁止することで、プラグインによる予期しない動作を防止しています。 DSL からコードを生成する際に、Logger や gRPC Interceptor など、ドメイン固有の実装を同時に生成したい場合があります。そのような場合にコード生成パイプラインをプラグインによって拡張することで、gRPC Federation がもともとコード生成に使用している情報と全く同じものをプラグインで受け取り、自由にコード生成を行うことができるようになります。 Protocol Buffers からコード生成を行って gRPC サーバをビルドするまでの過程とプラグインの関係を図にすると次のようになります。 周辺ツール DSL を提供する上で、周辺ツールの整備も重要だと考えています。今回は Protocol Buffers のプラグインとして動作するため、 protoc のプラグインを用意するのはもちろんですが、他にも専用の Linter や Language Server 、コード生成ツールを用意しています。今回はこの中から、Language Server と コード生成器について紹介します。 protoc-gen-grpc-federation: protoc プラグイン grpc-federation-linter: Linter grpc-federation-language-server: Language Server grpc-federation-generator: コード生成器 Language Server DSL を書いてもらう上で当初から Language Server の提供は必須だと考えており、 専用の Language Server を提供しています。専用といっても、通常の Protocol Buffers の開発で最低限必要な Syntax Highlight や コードジャンプなどは実装済みなので、Protocol Buffers の Language Server としても利用することができます。 コードエディタによって Language Server の利用方法は様々ですが、VSCode では利用しやすいように、 すでに Extension を公開しています 。他の IDE 向けの対応も現在進めていますので、どうぞご期待ください。 Language Server によって Syntax Highlight された Protocol Buffers は次のようになります。文字列中の CEL の式などが適切にハイライトされているのが確認できると思います。 コード生成器 コード生成に関して、 protoc を利用した方法 以外に、 Buf を利用する方法 や、 gRPC Federation 独自のコード生成ツールによる方法 をサポートしています。 独自のツールを作った背景には、Protocol Buffers を編集した瞬間に gRPC サービスが立ち上がるような開発体験を提供したいという思いからでした。独自のツールには -w オプションを付けることで Protocol Buffers の変更を検知して即座にコンパイル、コード生成を実行する仕組みがあります。この機能と Air などのホットリローダを組み合わせることで、コード生成された側から Go のコンパイルを行う仕組みを作れるため、他に gRPC サービスを起動するために必要な情報をプラグインの形で外から与えさえすれば、Protocol Buffers を編集した瞬間に gRPC サービスが立ち上がる状態を作ることができます。 個人的にはこれを Protocol Buffers Driven Development と呼んでおり、スキーマ駆動開発を促進できると考えています。図にすると以下のようになります。 今後 メルペイ社内では、 gRPC Federation を使ったサービスがそろそろ本番環境で稼働し始めようとしています。そこで、最終的な機能の精査を行い、6月末を目標に Version 1.0 ( GA版 ) を提供する予定です。 1.0 以降は、基本的に破壊的な変更を入れず後方互換性を保ち、どうしても変更したい場合は十分な変更期間をとるなど社外のユースケースを想定してメンテナンスしていくことを考えています。 そのため、gRPC Federation の導入を考えるとてもいい機会だと考えています。導入のご相談は随時受け付けていますので、ぜひお気軽にご連絡ください。また、 OSS に関しても積極的にコントリビューションを受け付けています。こちらもあわせてよろしくお願いします。
はじめに こんにちは、mercari.go スタッフの hiroebe です。 3月21日にメルカリ主催の Go 勉強会 mercari.go #25 を YouTube でのオンライン配信にて開催しました。この記事では、当日の各発表を簡単に紹介します。動画もアップロードされていますので、こちらもぜひご覧ください。 Learning TLS1.3 with Go 1つめのセッションは @shu-yusa さんによる「Learning TLS1.3 with Go」です。 発表資料: Learning TLS1.3 with Go TLS1.3 におけるハンドシェイクのプロセスについて、Go のコードを交えて説明しました。TLS1.3 では TLS1.2 から多くの変更が入っていて、ハンドシェイクの改善もそのうちの1つです。Go において TLS に関連する暗号技術は crypto/ 以下のパッケージで提供されていて、発表ではこれらのパッケージを用いたコード例が多く紹介されています。 個人的に普段触れる機会の少ないパッケージも多く、とても興味深かったです。コードとともに理解することで、ハンドシェイクの各ステップにおける処理の流れがつかみやすくなっていると感じました。 Exploring Go Runtime Metrics 2つめのセッションは @Chin-Ming さんと @mohit さんによる「Exploring Go Runtime Metrics」です。 発表資料: Exploring Go Runtime Metrics Go の runtime/metrics パッケージについて、導入された背景や内部実装について紹介しました。Go における従来のランタイムメトリクスの取得方法にはいくつかの問題点があり、それが runtime/metrics パッケージによってどのように解決されたかについて説明されています。 将来的なランタイムの変更にも対応するためにどのような API デザインとするか、という点は非常に興味深かったです。発表の後半では、現在サポートされているメトリクスの一覧とそれらのユースケースについても紹介されていました。 Securing Code with Govulncheck 3つめのセッションは同じく @Chin-Ming さんと @mohit さんによる「Securing Code with Govulncheck」です。 発表資料: Securing Code with Govulncheck Go プログラムの脆弱性チェックを行うための Govulncheck というツールについて紹介しました。ツール自体の利用方法に加えて、チェック対象とする脆弱性が Go においてどのように収集・管理されているか、という点についても説明されています。 Go Vulnerability Database についての説明など個人的にも知らなかった点が多く、とても勉強になりました。Govulncheck は CI にも容易に組み込めるそうなので、興味のある方は試してみてはいかがでしょうか? おわりに 今回は Go の標準ライブラリやツールを題材とした3つの発表をお送りしました。Go を通して TLS の仕組みやソフトウェアの脆弱性についても知ることができて、運営としても非常に勉強になりました。 ライブで視聴いただいた方も録画を観ていただけた方も本当にありがとうございました! 次回の開催もお楽しみに! イベント開催案内を受け取りたい方は、connpassグループのメンバーになってくださいね! メルカリconnpassグループページ
こんにちは。メルペイ Engineering Engagement チームの mikichin です。 3月22日から3日間開催された「 try! Swift Tokyo 2024 」にメルカリはPLATINUMスポンサーをしており、会場ではブースを出していました。今回は参加レポートをお届けします! try! Swift Tokyo 2024 について try! Swift Tokyo は、Swiftを使った開発のコツや最新の事例を求めて、世界中から開発者が集うカンファレンスです。 開催概要 開催日時 カンファレンス:2024年3月22日(金) 〜 23(土) ワークショップ:2024年3月24日(日) 場所 ベルサール渋谷ファースト 当日の様子をご紹介 メルカリブース メルカリブースでは、iOSメンバーでアイディアを出し合って作成したクイズを準備しました。 クイズは全部で9問、平均の正答数は約5問でした。 ご参加いただいた方々からは「難しかった」「勉強になった」「すごく楽しかった」と感想をいただきました。ご参加いただいたみなさま、ありがとうございました! クイズとは別に、「ビルド時間が長い場合、どのような工夫をしますか?」というお題に対して、いろいろなアイディアを書いてもらいました。 今回のイベントにあわせて、メルカリブースに遊びにきていただいた記念に撮影してもらえればとフォトフレームを準備。 本当に多くの方々にブースにお越しいただき、ありがとうございました 🙂 スポンサーブース 今回、17社のスポンサーブースがあり、わたしも全ブースに遊びにいきました! 各社いろいろなコンテンツが準備されていて、とても楽しく、たくさんかわいいオリジナルグッズをいただきました。 中でも、わたしが個人的に印象に残ったのはZOZO社のコンテンツです。 社内企画で、普段からSlackで共有されているというみんなの失敗。何かしらの失敗を投稿すると、トイレットペーパーをもらえるということでそれを今回のイベントでも実施していました。 ZOZO社の社風も伝わるコンテンツであり、失敗を水に流すということでトイレットペーパーをグッズにするというユーモアもくすっと笑えて素敵なコンテンツだなと思いました 🙂 セッションについて わたしは基本的にずっとスポンサーブースにいたため、今回はひとつもセッションをきいておりません。(残念….) ちょうどメルカリブースの目の前に「Ask the Speaker」のスペースがあり、毎回多くの人がきて情報交換をされている様子をみており、どのセッションも盛り上がったことを感じていました。次回はひとつくらいはきけるように、シフトを調整しようかなと思います。 Party try! Swift Tokyo で名物 @jollyjoester の「カー→ンパ↑ーイ!(※)」でスタート! After Partyでは、いろいろな方と交流することができて楽しかったです。Xでつながっていたり、一方的に認知していたりする方と直接お会いしてお話ができて大満足。 会場でもいたるところで会話が盛り上がっていました。特に、本イベントは海外から参加している方々も多かったので、コロナ前の日常を取り戻したんだなとしみじみ実感しました 🙂 △※:弊社Slackのbotででてくる表現を引用。 まとめ わたしはTech PRという仕事柄、いろいろなカンファレンスに参加しています。 5年ぶりの開催ということもあり、try! Swift Tokyoは初めての参加で、こんなにも海外の方が多く参加するとは思っていなかったのでいい意味で驚きました!お話をしてみると、このイベントのために日本にきているという方も多かったです。 個人的には最近英語の勉強をさぼっていたので(笑)、このイベントに参加したことで刺激になりました。 最後に、try! Swift Tokyo 2024の企画運営、おつかれさま & ありがとうございました! また、次回を楽しみにしています!
iOSエンジニアの takecian です。 株式会社メルカリでは YOUR CHOICE という「働く場所・住む場所」を自由に選択できる制度があります。そのため同僚とはリモートワークでコミュニケーションを取りながら仕事を進めることが多いです。(六本木にオフィスはあるので出社して仕事をすることも可能です) リモートワークで働いている時にアプリのバグを見つけたり、気になる挙動を見つけた時にアプリの画面を録画して共有することがあります。「ここの動作がおかしい気がする」「この順で操作すると画面表示が変になる」など、操作中の画面を録画してもらい、ビデオを受け取って確認してみます。ですが画面にはどこをタッチしたかは表示されないので、「どういう操作をしているか」「どこをタップしたか」が分かりにくいと思った経験が iOS エンジニアだと誰もがあるのではないでしょうか。 このエントリでは、iOSアプリで見つけたバグの再現手順をリモートワーク中にスムーズに共有するために行った取り組みについて紹介します。 例としてメルカリのアプリを操作をした画面を録画してみました。 Your browser does not support the video tag. この例ではマイページからいくつかの項目をタップして別の画面に遷移していますが、どこをタップしたのか分かりにくいですよね。 そこでタップした箇所が表示されるようにしてみたいと思います。iOS のタップイベントは UIWindow の sendEvent メソッドで送られてくるので method_exchangeImplementations を使って sendEvent メソッドを自前のメソッドに差し替えてみます。 private static func swizzleSendEvent() { guard let originalMethod = class_getInstanceMethod(UIWindow.self, #selector(sendEvent)), let swizzledMethod = class_getInstanceMethod(UIWindow.self, #selector(swizzledSendEvent)) else { return } method_exchangeImplementations(originalMethod, swizzledMethod) } すると画面をタップした時に差し替えたメソッドが呼ばれるようになるので、そのメソッドの中で元の UIWindow.sendEvent を呼び出しつつ、画面に触れている場所の座標を取得します。(この処理をおこなわないとタップしたというイベントが伝搬せず止まってしまいます) @objc func swizzledSendEvent(_ event: UIEvent) { // 自身を呼び出すことで差し替え前のメソッド(`UIWindow.sendEvent`)を実行します swizzledSendEvent(event) guard case .touches = event.type, let touches = event.allTouches else { return } // UITouch の画面に触れているものを集合に追加する(複数箇所の同時タップを想定) let beganTouches = touches.filter { $0.phase == .began } UIWindow.touches.formUnion(beganTouches) // 画面から離れた分を集合から取り除く let endedTouches = touches.filter { $0.phase == .cancelled || $0.phase == .ended } UIWindow.touches = UIWindow.touches.subtracting(endedTouches) // 座標に変換する let touchLocations = UIWindow.touches.map { $0.location(in: self) } // touchLocations に入っている座標が画面に触れている場所なので描画する。 // コードは省略。 } 取得した座標上に UIView を表示することでタッチしている箇所が分かるようにしてみました。先ほどと同じ操作を録画してみたのがこちらです。 Your browser does not support the video tag. どういう操作をしているかが簡単に分かりますね。この機能を実現するために使用した method_exchangeImplementations はメソッドの呼び出し先を変更してしまうというとても強力なものなので、大人数で開発している環境ではできるだけ使うのは避けたいものです。そこでこの機能は compiler directives (#if DEBUG という書き方で特定の環境でのみコードが動作する仕組み) を使って開発中のアプリでのみ動作するようにしています。 この機能を紹介したところ、特にQAチームの人から喜ばれました。Bug の再現手順を分かりやすく共有できるようになったのではないかと思います。 このようなちょっとした工夫を入れることでリモートで仕事をしていても効率的に仕事を進めることができます。 株式会社メルカリには全国様々な場所から働いている同僚がいて、新たな価値を生みだす世界的なマーケットプレイスを創るために日々楽しみながら開発しています。興味のある方は こちら から募集を見てみてください。
こんにちは。メルペイ Engineering Engagement チームの mikichin です。 2月29日に開催された「 DeNA TechCon 2024 」のオフライン会場にご招待いただきましたので、参加レポートをお届けします! DeNA TechCon 2024 について DeNA TechCon(テックコン) は、DeNA のエンジニアが業務で得た知見を発信することで社会の技術向上に貢献する目的で、2016年より開催している技術カンファレンスです。 今年はオンライン、オフラインの同時開催。「POLYPHONY」というテーマでゲーム、ライブストリーミング、AI、Web3、ヘルスケア、メディカルなど幅広いトピックに触れながら、各事業のチャレンジをご紹介。また、セッションだけではなく体験ブース、技術コミュニティイベントもありました。 オフラインに関しては、久しぶりの開催ということで招待制となっていました。招待制って特別感があって招待いただいたほうもとてもうれしいですね 🙂 参考記事: https://dena.com/jp/press/5084/ 当日の様子をご紹介 オフライン会場は、渋谷ストリームホールでした。方向音痴のわたしには、駅チカでとても助かりました。 セッションについて 3トラック同時進行で24セッションありました。オフラインで参加していると、「体験ブースに行きたい」「技術コミュニティイベントにも行きたい」となり、ききに行くセッションを絞るのが大変でした。最終的にはアーカイブ動画が公開されると思うのでそちらも確認しようと思います。 個人的に一番おもしろかったのは、「 LIGHTNING TALKS 」です。5分という時間制限の中、すべての内容をききたいという気持ちはありますがドラが鳴るかなというわくわく感も楽しいですよね…! LIGHTNING TALKSでは、3名の方がLTをされました。 「新卒による全社横断コミュニティと社内外勉強会の運営への挑戦」 わたしもTech PRとして社内外勉強会の企画をしています。「わたしも同じようなこと思っている!」「そこ、難しいよね…」など、話をきいていてすごく共感していました(笑) TechConでも5つの技術コミュニティイベントが開催されていました。現場のエンジニアが課題感を持ち、自発的に勉強会を企画して楽しんでいる方々が多いからこういった幅広い技術コミュニティのイベントが継続開催されているんだなぁと様子や社風が伝わる素敵なLTでした。 「新人インターン生が海外 Web3 ハッカソンに参加した話」 インターン生が海外で開催されているハッカソンに参加し、賞を受賞してくるというLT自体が素晴らしいのですが、顔出しがNGということで黒衣姿で登壇をしていたのがとても新鮮でおもしろかったです(笑) 「TechCon 2024 ハイブリット開催の舞台裏:ネットワーク構築編」 すべて内製で運営しているというDeNA TechConならではのLTだなと思います。今回のイベントでは、当たり前のようにWiFiが提供されていましたがどのフロアでも問題なく使用することができました。 こういったイベントでWiFiが使えるというのは、全然当たり前ではなく素晴らしい準備があったからこそだなと思い、大変感謝しています。 体験ブースやスタンプラリー 6つの体験ブースがありました。どのブースも大変盛り上がっていました!(写真は人があまりいないときに撮影しました) 音声変換AI体験ブースでは、男性の声になった自分の声をほぼリアルタイムで感じることができたり、新感覚Vtuberアプリ「IRIAM」の体験では自分の動きにあわせて動く様子を体験できたりとおもしろかったです。 セッションをきいたり、体験ブースに行くとスタンプを押してもらえます。スタンプラリーでは8つ集めるとClosing Keynoteで行われる豪華景品のあたる抽選会に参加できるということで、8つ集めました!(はずれました。残念…) After Party 4Fは屋台、5FはDJブースにビュッフェ形式と違った雰囲気を楽しむことができました。 After Partyもみなさんそれぞれ会話を楽しみ、すごく盛り上がっていました! わたしも他社のTech PRの方とTechConの感想を共有しあうことはもちろん、いろいろ情報交換ができとても有意義な時間でした。 まとめ 最近、企業カンファレンスやコミュニティカンファレンスが完全オンラインからオフラインに移行してきています。オンラインはオンラインのよさ、オフラインはオフラインのよさがありますよね。TechConはハイブリットでそれぞれのよさを活かしながら開催されているように感じました。 昨年の「 Merpay & Mercoin Tech Fest 2023 」では完全オンラインで開催しました。ハイブリッド開催をするということは考えなくちゃいけないことが膨大に増えるので、かなりのチャレンジになりますが、わたしもそろそろオフラインでメルペイの魅力を伝えていきたいなーと思っています 🙂 最後に、DeNA TechCon 2024の企画運営、おつかれさま & ありがとうございました!社員の方々がTechCon自体を楽しんでいる様子を直接感じることができ、とても素敵なイベントでした。また、次回を楽しみにしています!
目次 はじめに eBPFとは? eBPFのCTFチャレンジ Flagの獲得 おわりに はじめに 初めまして、Threat Detection and ResponseチームのChihiroです。昨年の7月に株式会社メルカリに入社して、主にクラウド向けのDetection Engineeringや、インシデントレスポンスを担当しています。また、メルカリで自社開発している SOAR (Secuirty Orchestration Automation and Response)プラットフォームの開発や運用も担当しています。 メルカリには、 部活 を支援する社内制度が存在し、様々な部活があります。その部活の一環として、私は最近、CTF(Capture The Flag)と呼ばれるサイバーセキュリティの競技を楽しんでいます。そこで今回は、参加したCTFの中で面白かった eBPF に関するリバースエンジニアリングの問題を例にして、eBPFプログラムがどのように構成されており処理されていくのか解説します。 eBPFとは? eBPFは、Linuxカーネル空間で動作し、パケットフィルタリングやパフォーマンス調査のためのトレーシングなどに活用されている技術です。また、近年はクラウドやセキュリティといった文脈でも活用されています。例えば、CNCFのプロジェクトとして有名なCNI(Container Network Interface)の1種である Cilium や、コンテナのランタイムセキュリティのツールである Falco などに利用されています。 eBPFのプログラムは、Linuxカーネル上にて、サンドボックスのような仮想マシン上で実行されルため、独自の命令仕様をもっています。そこで、簡単にeBPFのバイトコードを実行する仮想マシンの仕様についてご紹介します。詳しい仕様は、 eBPF Instruction Set に記載されているので、合わせてご覧ください。 通常プログラム言語には、変数のような算出された数値を格納するための場所があります。今回の仮想マシンでは、それに相当するレジスタと呼ばれる小規模な記憶領域が利用されます。eBPFの命令セットでは10個の汎用レジスタが存在します。 R0: 関数からの戻り値や、eBPFプログラムが終了するときのステータスコードを格納 R1 – R5: 関数の引数が格納される R6 – R9: 汎用的に用いることができる R10: スタックフレームのアドレスを格納する 次に、命令について見ていきます。eBPFの命令はRISCアーキテクチャで使われる命令のように固定長です。1命令は、64bitになっています。具体的には、下記のように構成されています。 オペコードとは、命令の種類を表しており、数値を転送先レジスタ代入する命令や、加減算をする処理、条件分岐のための処理などが存在します。そして、このオペコードはさらに細かく構成されています。即値には、実際に代入する数値のデータが格納されることがあります。 1つ例を見てみましょう。下記のような64bitの数値の命令を明らかにしていきます。リトルエンディアンの表記になっているため一番左が下位byteな点に注意してください。 b7 01 00 00 44 04 05 1c まず、b7の部分がオペコードの8 bitsになります。b7を2進数に直すと1011 0111となります。下位3bitの111、つまり7はBPF_ALU64という命令の種類を表します。 1011は、BPF_ALU64においては、BPF_MOVという命令として定義されており、転送元レジスタから転送先レジスタへ代入をする命令となります。残りの4bit目の0は転送元レジスタが、32bitの即値であるかレジスタであるかを決めるパラメータとなっています。この値が0の場合は、即値が利用されます。 次に2byte目の01です。これは、2進数として表すと0000 0001となります。図に示したように、それらは4bitずつ転送元と転送先レジスタに分割されます。つまり、転送先が1すなわちR1レジスタ、転送元がR0レジスタとなっています。 しかしながら、先ほど見たように転送元はレジスタではなく即値を使うため、0x1c050444という即値をR1レジスタに代入する命令だと解釈することができます。 eBPFのCTFチャレンジ 本問題は、Backdoor CTFというCTFの初心者向けのリバースエンジニアリング問題になります。 CTFtime.org によると、Backdoor CTFは2013年頃から開催されているようです。 CTFでは、様々なコンピュータサイエンスやサイバーセキュリティに関するクイズを解いて、フラグと呼ばれる特定のフォーマットの文字列、 FLAG{COOL_FLAG_NAME} を見つけ出すことがゴールになります。例えば、リバースエンジニアリングの問題では、バイナリファイルを解析することで、隠されているフラグを得ることができる問題が一般的です。 リバースエンジニアリングの問題では、LinuxやWindowsのバイナリファイルを解析することが多いです。しかし、別のファイルフォーマットを解析することもあります。そのため、最初に file コマンドを使って、ファイルタイプを特定することが有用です。下記の通り、このファイルは、eBPFのプログラムだと判明しました。 root@6d1def7da3d3:~# file babyebpf.o babyebpf.o: ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV), not stripped この問題に対しては、2つのアプローチがあります。1つ目は実際にこのeBPFのコードを動かすことです。そしてもう1つは実際にどんな命令が記載されているのかを読んでいく手法です。今回は、興味のために、後者のアプローチでやっていこうと思います。 しかしながら、先ほど見たように、バイナリファイル内に含まれるすべての命令を手作業で解析していては大変です。そこで、これらの作業を自動化するための手法である逆アセンブルと呼ばれる変換作業をします。逆アセンブルでは、機械語を人間が読みやすい ニーモニック と呼ばれる機械語に対応する文字列命令に変換します。 eBPFバイトコードの場合は、 llvm-objdump コマンドがおすすめです。 -d フラグを使うことで対象ファイルの逆アセンブルをすることができます。通常、ニーモニックと同時に16進数も表示されるのですが、ここでは冗長なので --no-show-raw-insn フラグを使って非表示にしています。 root@6d1def7da3d3:~# llvm-objdump --no-show-raw-insn -d babyebpf.o babyebpf.o: file format elf64-bpf Disassembly of section tp/syscalls/sys_enter_execve: 0000000000000000 <detect_execve>: 0: r1 = 0x1c050444 1: *(u32 *)(r10 - 0x8) = r1 2: r1 = 0x954094701340819 ll 4: *(u64 *)(r10 - 0x10) = r1 5: r1 = 0x10523251403e5713 ll 7: *(u64 *)(r10 - 0x18) = r1 8: r1 = 0x43075a150e130d0b ll 10: *(u64 *)(r10 - 0x20) = r1 11: r1 = 0x0 0000000000000060 <LBB0_1>: 12: r2 = 0x0 ll 14: r2 += r1 15: r2 = *(u8 *)(r2 + 0x0) 16: r3 = r10 17: r3 += -0x20 18: r3 += r1 19: r4 = *(u8 *)(r3 + 0x0) 20: r2 ^= r4 21: *(u8 *)(r3 + 0x0) = r2 22: r1 += 0x1 23: if r1 == 0x1c goto +0x1 <LBB0_2> 24: goto -0xd <LBB0_1> 00000000000000c8 <LBB0_2>: 25: r3 = r10 26: r3 += -0x20 27: r1 = 0x1c ll 29: r2 = 0x4 30: call 0x6 31: r0 = 0x1 32: exit 簡単に逆アセンブル結果での命令の読み方を解説します。例えば、 r1 = 10 の場合は、r1レジスタに10を代入するという例です。他にメモリにデータを代入する際には *(u32*)(r10) = r1 のような表記を用います。これは、r10レジスタの値をアドレスとして捉えて、そのアドレスが指すメモリにr1の値を代入するという意味になります。 では、実際に detect_execve 関数から処理を読んでいきます。 0000000000000000 <detect_execve>: 0: r1 = 0x1c050444 1: *(u32 *)(r10 - 0x8) = r1 2: r1 = 0x954094701340819 ll 4: *(u64 *)(r10 - 0x10) = r1 5: r1 = 0x10523251403e5713 ll 7: *(u64 *)(r10 - 0x18) = r1 8: r1 = 0x43075a150e130d0b ll 10: *(u64 *)(r10 - 0x20) = r1 11: r1 = 0x0 はじめに、r1レジスタに0x1c050444(10進数で470090820)を代入しています。次に、そのr1をr10-8が指すアドレスのメモリに格納しています。なお、r10レジスタはスタックフレームのアドレスを指すレジスタであることに注意してください。そのため、この処理は関数のローカル変数に値を代入しているコードだと読み解くことができます。そして似たような、データの代入をするコードがその後続いているのがわかります。また、最後にr1レジスタに0が格納されています。この処理が終わった後のスタックのイメージは下記の図の通りです。 さらに、逆アセンブル結果を読み進めていきます。ここでは先に関数の末尾の方を見てみましょう。 0000000000000060 <LBB0_1>: 12: r2 = 0x0 ll 14: r2 += r1 15: r2 = *(u8 *)(r2 + 0x0) 16: r3 = r10 17: r3 += -0x20 18: r3 += r1 19: r4 = *(u8 *)(r3 + 0x0) 20: r2 ^= r4 21: *(u8 *)(r3 + 0x0) = r2 22: r1 += 0x1 23: if r1 == 0x1c goto +0x1 <LBB0_2> 24: goto -0xd <LBB0_1> そこには、 if 文があり、r1レジスタと0x1c(10進数で28)と比較しています。これらの値が等しかったら、LBB0_2ラベルにgotoします。そうでなければ、LBB0_1ラベルの先頭に戻ります。こうした処理は、高級言語におけるループ構文として認識することができます。事実、 if 文の前では、比較対象であるr1レジスタに1を加算する処理、つまりインクリメントが行われています。 では、このコードブロックにはループ文があるという前提で先頭から読んでいきます。まずr2レジスタに0を代入し、さらにr1レジスタの値を加算しています。初めはr1レジスタは detect_execve 関数で言及したように0が格納されているため、r2は加算されても0のままです。次にr2レジスタをアドレスとして使って、脱参照しr2レジスタに格納されているメモリ上の実際のデータを格納しています。 次に、命令の対象はr3レジスタへと変わります。r3レジスタにr10レジスタ、つまりスタックフレームのアドレスを格納します。その後、32を減算しています。この32は、ちょうどスタックフレームのアドレスから、先ほど代入したローカル変数のアドレスへのオフセットとなっています。さらに、そのアドレスに対してr1レジスタの値を足して、脱参照し、ローカル変数の値をr4レジスタに格納しています。そして、r2レジスタとr4レジスタの値をXORして、その結果をr2レジスタに格納し、最終的にr3レジスタが指す先、つまりローカル変数のアドレスが指すメモリ上のデータを、計算結果で書き換えています。 それ以降は、先ほど述べたように、r1レジスタを加算して、ループ処理の if 文へと続きます。これにより、1byteずつずれながら、メモリ上の二つのデータへアクセスして、各1byteをXORして、ローカル変数の中身を上書きする処理が実行されていきます。つまり、何かしらのデータに対して、ローカル変数を使ってデータをデコードしている処理がこのeBPFプログラムの本質だとわかります。また、r1が28と比較していることから、両者のデータの想定されるデータ長は28byteだと推定することができます。 さて、少し話を戻します。r2レジスタにはどんなデータが入っているのでしょうか。逆アセンブル結果だけだと判断ができないため、少し視点を変えてバイナリを調査してみます。一般に、バイナリファイルには、特徴的な文字列などが含まれていることが多いです。そこで、GNU Binary Utilitiesの strings コマンドを使って文字列を調査してみます。 root@6d1def7da3d3:~# strings -tx -a babyebpf.o 5c G T { 148 marinkitagawamarinkitagawama 16e W>@Q2R 179 G T D 2a5 .text 2ab detect_execve.____fmt 2c1 _version 2ca .llvm_addrsig 2d8 detect_execve 2e6 .reltp/syscalls/sys_enter_execve 307 _license 310 baby_ebpf.c 31c .strtab 324 .symtab 32c .rodata 334 LBB0_2 33b LBB0_1 342 .rodata.str1.1 いくつか特徴的な文字列はありますが、先ほど得た28byteというデータ長に着目して見ると、 marinkitagawamarinkitagawama という文字列は興味深いです。実際、byte数を確認してみると28byteでした。 root@6d1def7da3d3:~# echo -n marinkitagawamarinkitagawama | wc -c 28 では、最後にLBB0_2ラベルの処理を読んでいきます。 00000000000000c8 <LBB0_2>: 25: r3 = r10 26: r3 += -0x20 27: r1 = 0x1c ll 29: r2 = 0x4 30: call 0x6 31: r0 = 0x1 32: exit このコードブロックで注目すべきは、 call 命令です。本命令の引数は6となっています。 call 命令は、eBPFプログラム内で定義したローカル関数とは別に、引数の整数値によって特定の関数を実行することができます。それらの関数と整数値のマッピングは、Linuxの ソースコード 上で定義されており、6は trace_printk 関数のようです。つまり、このコードは、何かしらデータを表示するコードだとわかります。また、r3レジスタに、ローカル変数のアドレスを格納しています。したがって、このプログラムは、XOR処理をしたデータを表示しようとするものだと推測することができます。 Flagの獲得 ここまでで、わかったことをスクリプトとして作成してみます。私は普段CTFで問題を解く際に、Rubyをよく使っているので、ここではRubyで書いたスクリプトを下記に示します。どんな言語でも問題ありません、ご自身の好きな言語で作成してみてください。 #!/usr/bin/env ruby encoded = [ 0x43075a150e130d0b, 0x10523251403e5713, 0x954094701340819, 0x1c050444 ].pack('Q*').chars key = "marinkitagawamarinkitagawama".chars key.zip(encoded) do |k, e| print (k.ord ^ e.ord).chr end 上記のRubyのスクリプトは、ローカル変数に代入されていた値と、バイナリファイル内に含まれていた文字列をバイト毎にXORした値を表示します。 これを実行すると、下記のように最終的にフラグを得ることができました。 root@6d1def7da3d3:~# ruby solve.rb flag{1n7r0_70_3bpf_h3h3h3eh} おわりに この記事では、CTFの問題を題材に、eBPFプログラムの内部を解説しました。eBPFを間接的に使っている人は多いと思いますが、こうした裏側について知っている人は多くないと思います。本知識を直接的に、業務で使う機会は少ないかもしれませんが、デバッグやかなり細かい調査になってくると、もしかしたら役に立つ機会はあるかもしれません。 最後まで読んでくださってありがとうございました。本記事が何かの役に立てば幸いです。
search infra teamのmrkm4ntrです。我々のチームではElasticsearchをKubernetes上で多数運用しています。歴史的経緯によりElasticsearchのクラスタは全てElasticsearchクラスタ専用のnode pool上で動作していました。ElasticsearchのPodは使用するリソースが大きいため、このnode poolのbin packingが難しくコストを最適化できないという問題がありました。そこで全てのElasticsearchクラスタを専用のnode poolから他のワークロードと共存可能なnode poolへ移行しました。ほとんどのクラスタが問題なく移行できたのですが、唯一移行後にlatencyのスパイクが多発してしまうものがありました。 この記事では、その原因を調査する方法と発見した解消方法について説明します。 発生した現象 共用node poolへ移行後にピーク時間帯において95pのlatencyが下図の青線のようにスパイクしました。 一旦このクラスタを専用node poolに戻すと、latencyは元どおりに落ち着きました。各メトリクスを見てもsearch thread poolのキューのサイズが上がっている他は特に怪しいものは見当たりません。CPUやmemoryのリソースが不足しているわけでもありません。search thread poolのキューのサイズが上がっているのはlatencyが上がったことによりキューのサイズが上がったと考えられるため原因ではなく結果だと思われます。該当クラスタのElasticsearchのversionは7.10.2でした。 プロファイラの利用 メトリクスを見ても原因がわからなかったため、プロファイラを使ってflame graphを表示することにしました。まずはkube-flame ( https://github.com/yahoo/kubectl-flame )を使ってJVMのprofilerであるasync-profiler ( https://github.com/async-profiler/async-profiler )を動かします。以下が得られたflame graphです。 Elasticsearchの検索処理にはquery phaseとfetch phaseという二つのphaseがあり、query phaseでは各シャードにて転置インデックスを使って検索処理を行い、実際にヒットしたドキュメントのidのリストを取得します。一方fetch phaseではそのドキュメントのfieldを取得します。上のflame graphからはこのクラスタにおいてはfetch phaseが支配的ということがわかります。多くの場合はquery phaseが支配的になるため少々特殊な使用方法です。 何度かプロファイラを動かすと怪しそうなグラフが取得できました。 黄色の箇所をズームするとCPUがNativeThreadSetのaddとremoveにおいてスピンロックを取得しようとしていることがわかります。 下記の syncrhonized(this) の箇所ですね。 https://github.com/AdoptOpenJDK/openjdk-jdk15u/blob/49dc2dfcefa493a9143483e11144343e83038877/src/java.base/share/classes/sun/nio/ch/NativeThreadSet.java#L50 https://github.com/AdoptOpenJDK/openjdk-jdk15u/blob/49dc2dfcefa493a9143483e11144343e83038877/src/java.base/share/classes/sun/nio/ch/NativeThreadSet.java#L75 とはいえこのコード自体におかしいところはありません。 ここで調査が暗礁に乗り上げるかと思われましたが、async-profilerについて調べている際にLINEヤフー社のKafkaチームの方が発表された下記の資料を見つけました。 https://speakerdeck.com/line_developers/time-travel-stack-trace-analysis-with-async-profiler こちらによるとasync-profilerによって出力されるJFRファイルを基に、各threadが各時点において何のメソッドを実行していたのかを可視化するツール( https://github.com/ocadaruma/jfrv )を作って公開されたそうです。 早速async-profilerにJFR形式で出力させ、jfrvで読み込んでみました。 NativeThreadSetでフィルタリングした結果、確かにlatencyのスパイクが発生した時点でNativeThreadSetのaddやremoveがロックを待機しています。 次はこれらのメソッドを呼び出している箇所でlatencyのスパイク中に出現回数が上がったものを探します。以下のとおり、LuceneのDataInputクラスのskipBytesというメソッドが見つかりました。 これはElasticsearchのドキュメントの_sourceが入っているLZ4圧縮されたLuceneのStoredFieldを読み込む際に呼び出されています。 https://github.com/apache/lucene-solr/blob/2dc63e901c60cda27ef3b744bc554f1481b3b067/lucene/core/src/java/org/apache/lucene/codecs/lucene87/LZ4WithPresetDictCompressionMode.java#L110-L118 ではなぜこのメソッドの出現回数が増加したのでしょうか?この現象が発生する直前に下図のように大きなmerge処理が走り、refreshによってそれが検索可能になったことがわかります。 Elasticsearchにおいて新しく追加されたデータは、refreshによってセグメントと呼ばれるファイル(実際はpage cacheですが)に書き出されます。これらのファイルはimmutableであり、小さなセグメントがrefreshのたびに新しく次々に書き出されるのですが、バックグラウンドで複数のセグメントはmergeされ、新しく大きなセグメントとして書き出されます。このクラスタではインデックスは新しい順でソートされており、基本的にクエリにヒットするのは新しく追加されたばかりの小さなセグメントに入っているドキュメントでした。 ここで仮に新しく追加されたばかりのセグメントが、大きなセグメントにmergeされた場合を考えてみます。その場合、query phaseではインデックスはソートされているためlatencyは変わらないでしょう。しかし、fetch phaseではLZ4の辞書の後ろに_sourceが格納されているため、大きなセグメントでは辞書も大きくなり、ヒットしたドキュメントの_sourceを取得するためには毎回大きな辞書の分をskipする必要がでてきます。skipBytesは内部で1024バイトずつループでskipするため,これがskipBytesの出現回数を増やす原因だと考えました。 MergePolicyのパラメータ変更 Elasticsearchでは、LuceneのTieredMergePolicyというmerge policyを用いてどのセグメントをmergeするべきかどうかを選んでいます。このmerge policyではmergeするセグメントのサイズの差をskewという尺度で定義し、そのskewが小さいものを選択します。つまり基本的には上記のようなmergeはほとんど起きないはずです。 TieredMergeのパラメータを調べたところ、 floor_segment と max_merge_at_once というものを見つけました。前者はskewを計算する際にその値よりも小さいセグメントを floor_segment の値まで切り上げて計算するというもので、後者はその名のとおり一度にmergeできるセグメントの最大数を表します。 新しく追加されたセグメントが floor_segment より小さかった場合、 floor_segment (デフォルト値は2MB)のサイズとして計算されるため、より大きなセグメントにmergeされる可能性が上がってしまいます。またskew計算時の分母はmerge後のトータルサイズなので max_merge_at_once が大きければ小さいセグメントと大きいセグメントを含んだmergeのskewがあまり大きくならない可能性があり、そのようなmergeが選択されてしまう可能性が上がります。そこでこれらのパラメータの値を小さな値に変更することとしました。結果が下図です。 破線が変更前である前日のもの、実線が変更後です。見てのとおりスパイクが綺麗になくなっています。仮説が正しかったであろうことがわかりました。 DataInputのskipBytesの詳細 NativeThreadSetのaddとremoveはJVMからpread64システムコールを呼ぶ際に使われています。DataInputのskipBytesは不要な箇所をスキップするためにpread64で読んだものを捨てるという処理を実行しています。_sourceが格納されているStoredFieldのファイルはmemory mappedファイルなので不要な場所をスキップするためにファイルを読む必要など全くなく、現在のアドレスを加算するだけで事足りるはずです。実はこの修正は既にLuceneに入っており、Elasticsearchのv8以降にはその実装が使われています。 https://github.com/apache/lucene/commit/84a35dfaea27581174c1104e239187112a1b5d43 可能な限りElasticsearch v8を使いましょう。 先ほどはfetch phaseでパフォーマンス問題が発生する話でしたが、別のElasticsearch v7を使っているクラスタではquery phaseにおいてDataInputのskipBytesによりパフォーマンスが悪化する現象が起きていました。DataInputのskipBytesは転置インデックスのposting listをskipする際にも使われています。該当のクラスタのインデックスにはstatusがon_saleのものしか入っていなかったのですが、クエリのfilterにstatus=on_saleが指定されていました。これは全てのドキュメントが入っているposting listをスキャンすることを意味しますが、posting listはスキップリストで実装されているためそれほど高コストではないはずです(勿論ないに越したことはないですが)。ところがskipBytesはpread64を何度も呼ぶため非常に高コストな処理となってしまっていました。そこでstatus=on_saleのfilterをクエリから削除するとlatencyが以下のように劇的に改善しました。 さいごに この記事ではJVMのプロファイラを用いてElasticsearchのlatencyのスパイクの原因を調査する方法と発見した原因とその対処法について述べました。jfrvを使って必要な部分のみ抜き出したflame graphは眺めていると色々な発見があり、またソースコードリーディングにも役立つのでおすすめです。 またlatencyスパイクの原因となったmergeについては発見できましたが、共用node poolに移行すると望ましくないmergeが発生する具体的な原因についてはまだ特定できていないので、今後究明していきたいと思います。 さいごにjfrvという素晴らしいツールを公開してくださったocadarumaさん( https://github.com/ocadaruma )ありがとうございました!
Platformチームでエンジニアをしている sanposhiho です。メルカリのPlatformチームでオートスケーリング周りの課題の解決を担当しており、Kubernetes UpstreamでもSchedulingやAutoscaling周りの開発に参加しています。 メルカリでは全社的にFinOpsに取り組んでおり、Kubernetesリソースは最適化の余地があるエリアです。 メルカリではPlatformチームとサービスの開発チームで明確に責務が分かれています。Platformではサービス構築に必要な基礎的なインフラストラクチャを管理し、それらを簡単に扱うための抽象化された設定やツールなどの提供を行っています。サービスの開発チームは、それらを通してサービスごとの要件に応じたインフラストラクチャの構築を行います。 サービスやチームの数も多く、そのような状況での全社的なKubernetesリソースの最適化には多くの課題がありました。 この記事ではメルカリにおいて、これまでPlatformが行ってきたKubernetesリソースの最適化の取り組みと、その取り組みの課題から生まれた Tortoise と呼ばれるオープンソースのツールの紹介をします。 これまでの Kubernetes リソースの最適化の取り組み Kubernetesリソースの最適化は以下の2つに分解することができます。 Podレベルの最適化: サービスの信頼性を損なわない範囲で、1Podあたりのリソース割り当て量やPod数を調節し、サービス全体で見た時の割り当てられるリソースの量を減らす。 Nodeレベルの最適化: 各Podから割り当て要求されたリソースをできるだけ安いコストで動作させる。 後者に関しては、PlatformがKubernetesクラスターレベルの設定を変更することで最適化をできる部分が大きく、クラスター全体のスケジューリングの調節(bin packing)や価格の安いインスタンス(spot instance)への移行などが手法として存在します。直近のメルカリにおける施策だと、 Instance TypeのT2Dへの変更 もありました。 対して前者のPodレベルの最適化では、サービスごとのリソースの使用の仕方の特性に応じて、Resource Request/Limitを変更したり、オートスケーラーの設定を調整する必要があります。 リソース最適化には、サービスの信頼性を損なうことなく、リソースの使用を効率化することが求められ、そのように安全な最適化を行うためにはしばしばKubernetesに関わる深い知識が必要です。 他方、メルカリではマイクロサービスのアーキテクチャーを採用していることもあり、1000以上のDeploymentが存在し、マイクロサービスごとに開発チームも独立して存在しています。 このような状況で個々のサービスの開発者にKubernetesの深い知識を要求するのは難しく、その一方でPlatformが各サービスごとに最適化して回るには限界があります。 そのため、Platformチームがツールの提供やガイドラインの策定を行い最適化をできるだけ簡略化し、それぞれのサービスの開発チームはそれらに沿って最適化を行う、という形を取り全社的なKubernetesリソースの最適化を推進してきました。 メルカリにおけるオートスケーラーの現状 Kubernetesが公式に提供しているオートスケーラーには以下の二つが存在します。 Horizontal Pod Autoscaler(HPA): Podのリソース使用量に応じて、Podの数を増減する。 Vertical Pod Autoscaler(VPA): Podのリソース使用量に応じて、Podが使用できるリソース量を増減する。 メルカリではHPAがかなり普及しており、ある程度の規模を持ったDeploymentはほぼ全てHPAで管理されています。対して、VPAに関してはほとんど使用されていません。HPAはCPUに対してのみ設定されていることが多く、Memoryは手動で管理されているケースがほとんどです。 記事の理解が進みやすいように、HPAの設定についてのみ軽く紹介します。 HPAではそれぞれのコンテナのそれぞれのリソースに対して、理想のリソース使用率(閾値)を設定することができます。以下の例では、 application という名前のコンテナのCPUに対して、理想の使用率を60%と定義しており、HPAはPodの数をリソース使用率が60%に近くなるように調整します。 apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: <HPA_NAME> namespace: <NAMESPACE_NAME> //… metrics: type: ContainerResource containerResource: name: cpu container: application target: type: Utilization averageUtilization: 60 その他、 minReplicas と呼ばれる、Podの最低数を決めるパラメータなど、多くの補助的なパラメータが存在します。より詳細な内容は 公式のドキュメント を参照してください。 Resource Recommender Slack Bot リソースの最適化に対して、メルカリのPlatformが内部で独自に提供している代表的なツールがResource Recommenderと呼ばれるものです。これはSlack Botで月に一度最適なリソースのサイズ (Resource Request) を計算し、サービス開発チームにお知らせします。これによりリソースの最適化を簡略化することを目的にしています。 内部的には前述のVPAを使用しており、過去数ヶ月のVPAの推奨値から最適で安全な値を算出しています。 ただ、このResource Recommenderにはいくつかの課題点がありました。 まずは、 推奨値の安全性 です。推奨値は本来送られた瞬間が賞味期限で、時間が経つほど推奨値の正確性は薄れていきます。アプリケーションの実装の変更やトラフィックのパターンの変化によって、推奨値が大きく変わる可能性もあり、OOMKilledなどの危険な状況につながる危険性がありました。 そして、 サービス開発者がこれらの推奨値を適応してくれるとは限らない 点です。前述の危険性の観点から、開発者は推奨値を適応する前にその推奨値が安全か、適応後に何も問題が起こっていないかを注意深く確認する必要があり、エンジニアの時間を少なからず取ってしまうことになります。また、例えばメモリを3 GBから1 GBに減らすように推奨値が送られてきた場合、段階的に2GBを適応する、といったケースもあり、単純に推奨値がどれほど役に立っているのかの計測が難しいという観点もありました。 最後に、 最適化はサービスが動き続ける限り終わらない 点です。前述のように様々な状況の変化により、推奨される値というのは変化し続けます。開発者は一度Resource Recommenderに即してResource Requestを調整したら最適化が終了するのではなく、定期的に調整し続ける必要があります。 HPA の最適化 上記のResource Recommenderの課題とは別に、大きな問題点となっているのがHPAの最適化です。 HPAに管理されているリソースに関しては、基本的にリソースのサイズではなく、HPAの設定を最適化する必要があります。しかし、Resource RecommenderはHPAの設定の推奨値の算出に対応していません。 前述のように、メルカリでは規模の大きなサービスはほぼHPAを持っており、CPUをターゲットにしていることから、クラスターで使用されているCPUのほとんどはResource Recommenderによって最適化できないことを意味しています。 まず、最適化のためにはHPAに設定している理想のリソース使用率(閾値)をサービスの信頼性を損なわない範囲で上げる必要があります。 また、設定された閾値が十分に高いとしても、実際のリソース使用率が閾値に達していないというシナリオは多く存在し、その場合閾値以外のパラメータやResource Requestなどを調節する必要が出てきます。 HPAの最適化はかなり奥が深く、別でもう一本記事がかけるくらいにはかなりの知識を要します。( このスライド ではHPAの最適化について難しさと考慮すべきシナリオが軽く説明されています。興味のある方は確認してみてください。) その複雑性からResource Recommenderに単純に組み込むことは難しく、とはいえ膨大な数のHPAに対して多くのチームに定期的に手動の最適化を行い続けてもらう、というのは現実的ではありません。 …ここまで辿り着いて私たちは気がつきました。「…無理じゃね?」と。 現状のHPAとResource Recommenderの構成では、クラスターを最適化された状態に維持するにはどうしても 手動 で 複雑 な作業が全てのチームで 定期的に 、そして 永遠に 必要になります。 Tortoiseを用いたリソース最適化 そこで開発されたのが、 Tortoise です。(Tortoise: 日本語でリクガメの意味です) このTortoiseは可愛いだけではなく、Kubernetesのリソース管理と最適化を全て自動で行なってくれるように訓練されています。 Tortoiseは過去のリソースの使用量や過去のレプリカの数を記録しており、それを元にHPAやResource Request/Limitを最適化し続けます。詳しいリコメンデーションのロジックが知りたい方は、 公開されているドキュメント を参照してみてください。Tortoiseが単なるHPAやVPAのラッパーではないことが理解できると思います。 前述のようにこれまでサービスの開発チームがリソース/HPAの設定や最適化を行なっていましたが、Tortoiseはそれらの責務をサービスの開発チームからPlatformチームに完全に移すことを意図しています。サービス開発チームはTortoiseを一度セットアップすることでリソースの管理のことを完全に忘れることができ、もしTortoiseによって十分に最適化されていないマイクロサービスがあればPlatformがTortoiseの改善を行います。 Platformでは、メルカリの全てのPodをTortoiseによって最終的に管理することを目標にしています。 ユーザーは以下のようにCRDを通して、Tortoiseを設定します。 apiVersion: autoscaling.mercari.com/v1beta3 kind: Tortoise metadata: name: lovely-tortoise namespace: zoo spec: updateMode: Auto targetRefs: scaleTargetRef: kind: Deployment name: sample Tortoiseは非常にシンプルなユーザーインターフェースにデザインされており、ほとんどのサービスに対する設定は上記で完了します。その後、Tortoiseは自動でHPAやVPAなどの必要なものを作成し、オートスケールを開始します。 HPAは複数のパラメーターがユーザーに対して公開されています。これはユーザーに対して柔軟な設定を可能にする一方、現状のメルカリのように、HPAの設定やResource Requestを改善しないとHPAが本来のパワーを発揮できない、という状況に繋がり得ます。 メルカリでは運の良いことに、ほとんどのマイクロサービスがGoで書かれており、gRPC/HTTP サーバーであり、内部で公開されているマイクロサービスのテンプレートをベースに作成されています。そのため、HPAの設定もほとんどのサービスで非常に似ており、サービスのリソース使用量の変化やレプリカ数の変化などの特性も非常に似ています。 そのため、HPAの複数のパラメーターをTortoiseの背後に隠し、Tortoise側で共通のデフォルト値を与え、内部のリコメンデーションのロジックを通してそこから最適化をし続ける、というのがうまく働いています。 また、シンプルなユーザーインターフェース(CRD)とは打って変わり、Tortoiseは クラスター管理者向けの多くの設定 を備えています。 これによって、そのクラスターにおけるサービスの振る舞いを元に、クラスター管理者が全てのTortoiseの挙動を管理するということが可能になっています。 Tortoiseへの安全な移行と検証 前述のようにTortoiseはHPAやVPAの代替となるツールです。Tortoiseを作成することでHPAは必要がなくなる一方で、前述のようにMercariには非常に多くの数のDeploymentがHPAと共にすでに動作しています。 この状況でHPAからTortoiseに移行するには、Tortoiseの作成からHPAの削除など、煩雑なリソース操作を安全に行う必要がありました。 そのような移行をできるだけ簡略化し安全な移行を確保するために、Tortoiseには「既存のHPAをTortoiseに管理させる」ための機能が実装されています。 apiVersion: autoscaling.mercari.com/v1beta3 kind: Tortoise metadata: name: lovely-tortoise namespace: zoo spec: updateMode: Auto targetRefs: # 既存のHPAを指定することで、Tortoiseは新たなHPAを作成する代わりに、このHPAを最適化し続ける。 horizontalPodAutoscalerName: existing-hpa scaleTargetRef: kind: Deployment name: sample horizontalPodAutoscalerName を使用することで、既存のHPAをTortoise-managedなHPAにシームレスに移行することができ、移行のコストを下げています。 現在私たちはメルカリの開発環境で複数のサービスをTortoiseに移行して、安全性の検証を行っています。TortoiseはDryRunを行うための updateMode: Off を備えており、 Tortoise Controllerから公開されているメトリクス を通して、推奨値の妥当性を検証することができます。 開発環境では、かなり多くの数のサービスですでにOffモードのTortoiseによる検証が始まっており、50ほどのサービスではすでにTortoiseを用いたオートスケーリングが使用され始めています。 本番環境での検証、そしてTortoiseへの移行も近い将来に計画されており、Tortoiseはより洗練されたツールとなっていくことでしょう。 まとめ この記事ではメルカリのこれまでのKubernetesリソース最適化の取り組みと、そこに見えた課題から生まれたTortoiseと呼ばれるツールを紹介しました。 メルカリではPlatformで一緒に働く仲間を探しています。 一緒にCI/CDを改善したり、抽象化を色々作ったり、リクガメを飼育したり(!?)しませんか? 興味のある方は こちら からどうぞ!
こんにちは、メルカリのQAエンジニアのFunakiです。今回は品質改善と可視化のための取り組み、特にバグ管理(Bug Management)に焦点を当てて、QAチームがどのような活動を行っているのかをご紹介します。 我々は2018年頃からバグ管理の取り組みを始め、試行錯誤を重ねてきました。製品の品質に関する課題を抱えた方や、品質の可視化を進めたいと考えている方にとって、当ブログが現状を改善するきっかけになれば幸いです。 (出典: https://loosedrawing.com/ ) なぜBug Managementを実施しているのか? 我々はプロダクトの品質を推測するために、バグチケットの管理や可視化するすることを目指しています。品質を推測するために、品質の可視化するための環境構築(ダッシュボード)や、バグのチケット管理ルール(Bug Management Guideline)を作成しています。 もともと、メルカリでは各開発チームが独自にバグの管理をしていました。多くのチームではJIRAを使用してしましたが、JIRA以外で管理をしているチームもありました。 また、チームストラクチャの再編により軽微なバグの担当者がいなくなり、長期間未対応のまま放置されることがありました。 それらの影響でバグチケットの全容が十分に把握できなくなっていました。 これらの問題を改善していくためにBug Mnagement を実施しています。 Bug Management Guideline とは? 我々はバグチケットを健全に管理出来るようにして、品質の見える化をするために、Bug Management Guideline を作成して開発チームへ展開をすることにしました。 ルールを作ると、守らなければならない事項が多くなりがちで、結果として誰もルールを守れなくなることがあり得ます。そうならないよう、私たちは以下の最低限の目標を設定しました。 目標: 1. バグ管理環境をJIRAへ統一 2. バグの発生状況や修正の優先順位が判断できること 3. バグが長期間放置されないこと 目標1を達成するために、まずは各開発チームが使用しているバグ管理ツールを調査し、JIRAを使用していないチームにはJIRAへ変更をお願いしていきました。 目標2を達成するために、バグ修正の優先順位やバグの発生傾向などを分析が出来るように、バグチケットに情報を記載するフィールドを追加しました。 目標3を達成するために、バグチケットの有効期限を設定しました。有効期限が切れたバグチケットが無いか定期的チェックし、期限が切れたチケットはクローズするか、優先順位を上げてすぐにに修正するかを判断するルールを策定しました。 Bug Management Guideline を作成し、各チームが共通の環境とルールを使用することで、Bugチケットの全容が把握するための準備が整いバグチケットの状態の可視化をすることが出来るようになりました。 バグチケットの状態の可視化 JIRAにもチケットの情報を可視化するダッシュボードの機能がありますが、我々がチェックしたい情報を可視化する事は出来ませんでした。そのため、当初はJIRAで管理されたバグチケット情報をLookerで可視化していました。 JIRAのバグチケットの情報は直接Lookerで利用することが出来ないため、JIRAのバグチケット情報を trocco を利用してBigQueryにインポートし、BigQueryの情報からLookerで様々なグラフを作成してダッシュボーを構築していました。 以下の画像は、取り込まれたデータの流れと作成したダッシュボードのサンプルです。 過去の取り組みについて少し紹介した関連記事については、以下のURLをご覧ください。 関連記事: メルカリのQAエンジニアの取り組み2020 可視化(ダッシュボード)の改善 ダッシュボードは開発チーム毎に作成し運用していましたが、メルカリの開発体制やメンバーが頻繁に変動するため維持管理が大変でした。さらに、troccoやBigQueryの環境はQAチームが構築した環境では無かったため、環境のメンテナンスやアクセス権管理、グラフの更新・追加のためのデータ変更が複雑になってしまいました。そのため、メンテナンスがしやすい環境に切り替えることを検討しました。 バグ管理を更に効率化する方法を調査していたところ、データ取得が手軽で、グラフの資料作成も簡単に行える新しい手法を見つけました。具体的には、「 Jira Cloud for Sheets 」というスプレッドシートのアドオンと、「 Looker studio 」 というデータの分析や管理、レポート作成が簡単に行えるBIツールを使うことに決めました。 Jira Cloud for Sheetsは、JIRAの開発元であるAtlassianが提供している拡張機能で、JIRAで管理しているバグ情報をスプレッドシートに直接取り込むことが可能になります。スプレッドシートに取り込んだJIRAの情報は、関数を使い情報を分類したり、集計することで自分たちの知りたい情報を作成することができま。 またJIRAは今の情報しか取得することが出来ません。そこでGoogle Apps Script(GAS)を使用して集計データの履歴を日別に作成しました。履歴を作成したことで、バグチケットの作成や対応件数の傾向を確認することが出来るようになりました。 Looker Studioは、スプレッドシートからデータを直接読み込んでグラフや表を好きなレイアウトでダッシュボードを作成することが出来ました。そのため、他チームに依存することが無くなったため、任意のタイミングでダッシュボードメンテナンスが可能にりました。 また、データの取得や表示データの更新は、アドオンの機能やGASのスケジューリング機能を使って定期的に実行しているため、毎日自動的に情報が更新されるようになっています。 これらの改善により、マニュアルでのメンテナンが最小限になり、バグ追跡と分析もスムーズに行えるようになりました。 今後の Bug Management Bug Management Guidelineを作成し、Looker Studioでの可視化のおかげで、バグチケットが修正されずに残っている場合や、いつ何件のバグチケットが作成され、クローズされたかなどが一目でわかるようになりました。定期的にバグチケット作成からの経過時間をチェックし、優先順位の見直しをすることで、バグチケットが長期間放置されなくなりました。 これらの取り組みにより、適切にバグチケットが管理することができるようになりつつあります。しかし、開発体制の再編や新しいメンバーの増加など影響で、取り組みがリセットされないようにBug Managementの周知が必要です。さらに、バグ発見の傾向や件数から製品の品質を推測し、バグの作り込みを防止する施策の検討などを続けていく予定です。
はじめに こんにちは、メルカリの日本リージョンのCTOを担当している@kimuras と申します。2023年4月にCTOに就任して現在Marketplace、Merpay、Mercoinの技術的な責任者を担当しています。本稿では、この1年間で注力してきた、Engineering Roadmapの作成についてお話したいと思います。内容によっては、ある程度の組織の規模感にならないと適さない内容となってしまうかもしれませんが、サービスの方向性やそれに合わせたエンジニアリング組織の作成について、今後整理しなければならない局面でご参考にしていただけたら幸いです。 メルカリのロードマップとは メルカリには、グループ全体の指針となるグループロードマップ(以下ロードマップと呼びます)があります。このロードマップのおかげで、私たちは今後進むべき方向が明確になり、社員全員が提供したい価値についての共通の認識を持つことができます。ロードマップは単なる実現したい事項のTODOリストではなく、私たちのミッションやビジョンを正確に理解するための重要なツールです。メルカリのロードマップについては、こちらの メルカンの記事 を参照してください。 Engineering Roadmapの必要性 ロードマップがうまく運用されていることで、わたしたちはこれまでに多くの新しい価値を提供してきました。その中にはメルカードやMerpayのような時間もかかり、難易度も高いプロジェクトも含まれています。しかし、エンジニアリング組織としては、このロードマップに対してより先行して技術的な準備ができていたら、より高速かつ計画的にビジネス展開をできたのではないかと感じることがありました。 事業の未来がロードマップで示されているので、エンジニアリングとしてはその道標に対して、それを実現するための Foundation やPlatformを事前に提供できることが理想的です。しかし、これまでメルカリグループでは各Divisionごとに個別のEngineering Roadmapが存在していたものの、全社横断でのものは存在しませんでした。(※ 12/26 10:30 初稿ではロードマップが一切存在しないようにとれる表現になっていましたが、正しく修正しました) メルカリではビジネスや開発者をスケールさせるためにMicroservices Architectureを導入したり、 インドの開発拠点 を作ったりと、チャレンジングなことを通じて継続的なエンジニアリングの改善を行ってきました。しかし、事業のロードマップに対して、Engineering Roadmapも同時に用意することで、よりエンジニアリングも含めたVisionがクリアーになり、効率性が上がるのではないかと考えました。 Engineering Roadmapがあることのメリット 前提として、私たちの開発のレイヤーは主にProduct、Foundation、Platformの3つのレイヤーに分かれています。Product開発は主にBFF、BackendやFrontendの開発を含めたFeature開発となります。そのひとつ下のレイヤーであるFoundationはLogisticsやTrsansactionやPaymentなどのProductとは疎結合ではあるものの、さまざまなサービスから呼ばれる重要なバックエンドのAPI群となります。そして、Platformはさらに一番下のレイヤーであり、Microserviceを容易に作るためのMicroservices PlatformやCI/CD、Infrastracture、Networkなどのすべてのサービスを支える基盤となっています。したがって、下のレイヤーになるほど支えているサービスが多くなるため、PlatformやFoundationは上位レイヤーのことを考慮しなくてはならないことが多く、開発や変更の時間軸は長くなってしまいます。 このような私たちの状態を前提として、Engineering Roadmapが存在することの意義を以下に述べていきます。なので、序盤にも述べたように、スタートアップのような開発の初期段階のフェーズやFoundation領域が小規模なサービスでは、本稿で述べるEngineering Roadmapの作成する意義や戦略とは違った打ち手の方が良い可能性があることをご容赦ください。 スケジュールに対する期待値調整が容易になる 抽象的な表現となってしまいますが、何か新しい価値提供を実現するためのリアーキテクチャや、新たにFoundation/Platformを開発をするには、想定以上に時間がかかってしまうことが一般的に多くあります。開発を計画的に行わず、間に合わせでライブラリを少し修正するだけですませてしまったり、本来であればアーキテクチャを改修しなければならないところを、改修せずに無理に既存のアーキテクチャに新機能を詰め込んでしまったがゆえに、後のメンテナンス性が落ちてしまったり、リファクタリングが困難になることが起こりがちです。 したがって、エンジニアリングとしては極力新しい要件仕様に対して、適切なFoundation/Platformを新規で開発したり、リアーキテクチャをしたうえで新規機能を実装することが理想的です。しかし、これらの開発には調査や設計、実装方針について関係者とコンセンサスをとるなど、実現するのに数日どころか数ヶ月、あるいは年単位で時間がかかってしまうという問題があります。 このため、新規サービスの開発を始めるタイミングで、Product開発と並行してリアーキテクチャやFoundation/Platform改善をおこなうと、時間軸が合わなかったり、スペックの調整をしながら開発することで要件漏れや大きなバグを作ってしまうことの原因となってしまいます。加えてProduct開発に対してFoundation/Platform側の対応が遅れてしまい、リリーススケジュールに悪影響を与えてしまうこともしばしば発生してしまいます。 ただ、上述のように事業のロードマップが示されている状況においては、エンジニアリングとしてもそれを実現するためのFoundation/Platform開発を事前に計画性をもって行うことができれば、よりスムーズに開発することができるし、メンテナンス性や安全性もより担保された開発を行うことができます。 Engineeringの改善施策のコンセンサスを得ることができる 上述のようにリアーキテクチャやリファクタリング、Foundation/Platform開発などのエンジニアリングに関する改善施策は中長期にわたることがしばしばあります。このため、明確な目的意識を持って施策を実施しなければ、途中経過でプロジェクトの意義を問われることや、プライオリティを下げざるを得ない状況となってしまうことが、残念ながらよく発生します。エンジニアリングには各改善プロジェクトの意義について説明責任はあるものの、事前にコンセンサスがとれておらずに説明の難易度が上がったり、プライオリティが変更されてしまうことは生産性に悪影響があるし、モチベーションにも大きな影響を与えてしまいかねません。 しかし、エンジニアリング主導の改善施策についても、始める前にそれぞれの意義やゴールを明確化して、かつロードマップにアラインできていれば、たとえ中長期な開発であってもステークホルダーからも賛同を得られ、サポートを得ることができるはずです。時には事業のロードマップにアラインすることが難しい中長期の改善施策、例えばMicroservices Architectureの根本的なアーキテクチャの改善や、BCPの改善などについても、ゴール設定と得られるメリットを明確化して、Engineering Roadmapとして事前にステークホルダーや経営から同意を得られていれば、ストレスなく改善プロジェクトを継続することができます。 先を見通したアーキテクチャを作ることができる 基本的にシステムアーキテクチャはビジネスの成長やエンジニアリング組織の規模感、ビジネスの方向性などにあわせて常に改善を続けなければなりません。加えて、極力メンテナンス性や拡張性を高くすることで継続的に新たなニーズに応えられることが理想的です。 しかし、ビジネスの方向性が定まっていなければ、ある程度は想像でシステムの拡張性を担保しなければならず、仮にニーズを満たすことができなれけば、近い将来にリアーキテクチャを実施しなければならなくなります。 一方、事業ロードマップやEngineering Roadmapが作成されていれば、3年ほどの近い将来については概ね方向性がわかっているため、拡張性の観点で確度の高い設計をすることができます。これは、設計を担当するアーキテクトやTech Lead(技術的なリーダーのことであり、以下TLと呼ぶ)に限らず、エンジニアが日々のコーディングでの細かい意思決定を手助けすることができるため、すべてのエンジニアが意識的に将来を見据えた設計を心がけられるようになることが好ましい。 例えばIDに関する設計をしているときに、将来的にどのような事業展開をするのか、またパートナー企業が存在するようなビジネスをするときにパートナーアカウント、あるいはID連携が必要になる事業計画がある、といった計画が事前にわかっていれば、それらのニーズに合わせたアーキテクチャの設計ができます。これはFoundation/Platformやインフラストラクチャなどさまざまな要素技術にとっても重要であり、ビジネス成長には欠かせないことです。 Visionに対する解像度が深まる Visionを作ることは、組織にとってとても大事なことです。Visionを示すことによって、これから先に新たにお客さまに提供したい価値や、組織のありたい姿などを掲げて、組織で一体感を持ってタスクに取り組むことができます。 しかし、Visionだけではそれをどういう手順や手段で実現していくかはわからず、説明される方もうまく咀嚼できないことがあります。ありたい姿をVisionで示し、それに対してどのようにそれを実現していくかをEngineering Roadmapに記載することで、Visionに到達するまでのストーリーが各エンジニアにも伝わり、より理解を得ることができます。 これは説明する側のコストも下がりますし、ミスコミュニケーションを防ぐためにも重要だと考えています。 Engineering Roadmapを作るためのTips Engineering Roadmapの必要性や効果がわかったところで、次に実際にロードマップを作るためのTipsについて説明します。ここでは主に2通りのアプローチを突き合わせる手法について紹介します。 まずは大胆な理想像とVisionを作る 自分の場合は、あまり多くのことを気にしすぎて進められなくなるよりも、まずは実現可能性や周りの考えなどは考慮せずに、大胆な理想像を決めてしまいます。 実際にVisionやEngineering Roadmapを作ることは容易ではありません。理想的なゴールは何なのか、ステークホルダーはどのようにゴールを考えているのか、お客さまは何を求めているのか、それを実現することが可能なのか。それらの多くの関連する要素を考慮すると、なかなかVisionやロードマップを定めることができなくなってしまいます。 ただ自分は、あえて実現することが難しいのではないかと思うくらいの大胆で理想的なゴールを決めます。それから、それを実現するためのロードマップを作りながら実現可能性を考慮して、Visionを少しずつ現実的なものに落とし込んでいくことで、多少難易度が高いが、納得感のある形に落ち着くことができます。万人には当てはまらないとは思いますが、まずはあまり固くならずに、大胆で理想的なVisionを書き出してみると良いと思っています。 TLとのコミュニケーション強化 ある程度の組織規模のCTOの立場になると責務のスコープが広くなり、開発現場での解くべき課題や理想的な状態などが把握しづらくなってしまうことがあります。 普段からVPoEやEMとのコミュニケーションを取ることで、組織課題を把握することができますが、より開発現場に近い課題感を把握するためにはTL(TLを指定していない場合はエンジニアチームをリードしている立場の方が良いでしょう)とのディスカッションをすることで情報を得ることができます。 TLとEMとのディスカッションをすることで開発現場でも納得感の高く、かつ的確に組織課題を捉えたRoadmapを作成することができると、自身の経験から強く感じています。 理想は「現実的でワクワク感」があること ここまで2つのアプローチについて紹介しましたが、進め方としては、まずはフィージビリティを気にせずに、技術的にチャレンジングでかつビジネスに貢献するようなVisionを「トップダウンのアプローチ」で作成してみます。しかし、これだけでは現実離れしすぎてしまうかもしれませんし、本質的な開発現場の課題をとらえられず多くのエンジニアから共感を得られないかもしれません。そこで、各領域での本質的な課題を把握しており、強いVisionを持っているTLや、組織課題を理解しているVPoEやEMからの「ボトムアップ」の意見をぶつけあうことで、チャレンジングでかつ現実的なVisionやEngineering Roadmapを作成できることが理想的です。 このトップダウンとボトムアップの意見をすり合わせることによって、私たちのこれからの開発を一人一人のエンジニアが自分事として捉えて、積極的にコメントをくれるようになり、かつコミットしてくれるようになります。CTOとしては、エンジニアがこれまでに挑戦したかったけど、挑戦できなかったような難しくもおもしろい課題に挑戦するための理詰めを支え、最終的にその挑戦に対してスポンサーとなって一緒に実現しようとする姿勢が大事なのだと思います。このように難しい課題であっても、みんなで同じ方向を見て、一緒に解決していくことで、エンジニアたちのワクワク感が生まれ、結果的に自信を持って自分たちで誇りに思える技術を使い、お客さまに新たな価値を提供できるのだと信じています。 最後に 最後までお読みいただき、ありがとうございました。Engineering Roadmapは作っただけではなくて、今後どのように運用していくか、進捗させていくか、あるいはEngineering Roadmap自体を更新していくかもとても大事だと思います。運用していく中での困難や発見があれば、また記事にしてまとめてお伝えしていきたいと思います。
この記事は Merpay Advent Calendar 2023 の 24 日目の記事です。 こんにちは、メルコインの @pooh です。 メルカリグループでは金融事業を営んでいるメルペイとメルコインのEngineering Manager(EM)で普段とは別の場所に集まって1日集中して議論をするOffsitesを定期的に実施しています。 この投稿ではOffsitesそのものを紹介するのではなく、Offsitesでよく実施されるワークショップ(参加型作業)についての4つの工夫を紹介します。 複数人が集まって、何かのテーマについて意見を出し合い、意見をまとめて発表するというワークショップはよくあると思います。これから紹介する方法を使用することでより活発な成果が望めます。 本記事では私の経験とメルカリという組織での実践上の知見を書いています。そのため、組織ごとに別のよりよいやり方もあると思いますので、参考程度にそういう考えもある、ぐらいの気持ちで読んでください。 1.付箋に書いてから発表する ワークショップではチームに分かれて、チーム内でディスカッションをして意見やアイディア出しをしていきます。例えば、EM Offsitesでは「2023年10月〜12月を振り返って良かったこと・悪かったこと」といったテーマを設定し意見を出しあいました。このときに思いついた人から口頭で順次発表していくことがあります。ここで1つ目の提案となります。 最初に時間をとって各自で意見やアイディアを手元の付箋に書く 各自で書き出しをした後に発表をしていくとよいかもしれません。最初に付箋に書き出すことによる期待効果は次の通りです。 考えて書き出しているので意見がまとまる 発表に時間がかからない 書いている間は他人の意見が見えないこと 1人ずつ書かずに口頭で発表する場合、2、3人目からは「私もそうなんですけど…」という意見が出やすくなったり、他の人の意見に左右される可能性があります。最初に付箋に書いて発表することで、主体的に自分で考えたアイディアや意見を発表できるようになります。 付箋にあらかじめ書いてあることを読み上げるので、1つの発表が長くなったり、発表の始めと終わりで内容が異なる状態を防ぐことができます。 オンラインとのハイブリッド開催の時 オフラインとオンラインのハイブリッド開催をする時には、付箋ではオンライン参加者には見えずに不便でした。ハイブリッド開催の時にはオンラインホワイトボードを使いました。オンラインホワイトボードを使う場合でも、各自で考えている時には別のファイルやPC上のエディタを使って他の人から見えないようにすると付箋に手書きと同じ効果を得られそうです。 この投稿の本題ではないのですが、ハイブリッド開催の時にはPCのマイクとスピーカーでは音量面でオンライン、オフライン相互に聞き取りにくいことがあります。外付けのスピーカーとマイクの用意をお勧めします。マイクロソフトの「Modern USB-C Speaker」は持ち運びがしやすく、音量も大きく良かったです。 2.順番に1つずつ発表する グループディスカッションでは付箋に書き出して発表します。発表する際には1人ずつ順番に発表していき、最終的に各自で書いたものをまとめてグループの成果として発表する形式を取ります。ここで2つ目の提案となります。 順番に1つずつ自分の書いた付箋を読む。1つ読んだら次の人が読んで、を繰り返して何周か回す。自分の番がきて、もうすべて自分の書いたものを読んでしまった人はパスしていい この方法で発表していくと1人の意見で全体の雰囲気が動くのではなく、みんなの意見が順番に出てくるので発言の機会が均一になります。意見が平均的に出せるようになるため雰囲気が悪くなりません。「けっこういい」とみんなが共感できる意見が色々な人から出てくるため、雰囲気がよくなります。 発表するときには書いたことを発表し時間をかけないようにします。書いた理由や背景などの説明はせずに付箋に書いたことを発表します。小さな付箋を使うと単語しか書けないので大きな付箋を使い単語ではなく発表する内容を書いておきます。 EM Offsitesでは「良かったこと」ではつぎのような発表がありました。 メルペイのTech PRで新しい取り組みができている リリースした口座入金経由で直接メルコイン口座に残高反映する機能が使われている メルコインのEngineer All-Hands(エンジニアメンバーを対象とした毎月開催している全社会)が前回評判よかった気がする 「悪かったこと」ではつぎのような発表がありました。 リソース不足でPJのスケジュールが遅延したり厳しいスケジュールになった プラットフォーム機能開発が進まなかった 情報共有が不足していた 3.問題を「どのようにすれば」に置き換える ワークショップでは「xxxxに関してどんな問題点や懸念点があるか」や「悪い点」などをリストアップすることがあります。それをリストアップしていくと、当然ながら「なかなか難しいね」となることがあります。ここで3つ目の提案になります。 問題を発表した後、それぞれを「どのようにすれば〜〜〜か?」の疑問に言い換える 効果を説明する前に具体例を出してみます。 「プラットフォーム機能開発が進まなかった」という問題を発表した場合、「どのようにすればプラットフォーム機能開発ができるか?」となります。 このように「どのようにすれば」の質問文にすることで、答えを考えられるようになります。「問題なのはわかったから、改善策を言って欲しい」という言葉をたまに耳にすることがあります。課題を質問文に言い換えてもらうことで、自然と答えを考え始められます。 4.もっと面白い質問にする 「どのようにすれば」の質問文にすることで、答えを考えてしまう状況を作れました。もう一歩進められるようにします。 もっと面白い質問のかたちにして、もっといろんな人が考えてくれるようにする もっと面白い質問にするために次のようにします。 「これが起こったらいいな〜」と思うような文章にする 「日本一」「世界一」と言った言葉をいれる 先ほどの質問文「どのようにすればプラットフォーム機能開発ができるか」を「どのようにすれば日本一便利なプラットフォーム機能を開発できるか?」に変えてみます。これでただ単に課題を解決するだけではない、ベストな素晴らしい解決策を多くの人が考え始めたはずです。 まとめ この記事では、つぎの4つの工夫を紹介しました。 付箋に書いてから発表する 順番に1つずつ発表する 問題を「どのようにすれば」に置き換える もっと面白い質問にする これらの工夫のうち、1つ目と2つ目は実際に私がワークショップでファシリテーターをする時にしていることです。EM Offsitesのときにも利用しました。3つ目はOffsitesで使ったわけではないですが、日頃心がけています。自分で使用したり、問題を提起してくれた人に使ったりしてます。4つ目はなかなかできていないです。 実際に1つ目と2つ目について利用したメンバーに感想を聞いてみました。 みんなの意見を聞くことができる & たくさん話したい人は後半話す時間がある、ということで時間効率を最大化できてると思いました 参加者の意見を満遍なく聞けることと、意見の数によってはスキップする自由みたいなところもあったので、意見が出やすくてよかったのでは無いかと思いました ワークショップの目的の認識合わせが不十分だったので、手法以前の改善ポイントがあったように思います。その点からワークショップとしては消化不良でした。 手法としてはポジティブなフィードバックをもらえました。一方でワークショップのゴールが何か、ワークショップが終わった後に何を達成したいのかの認識合わせを最初に実施することの重要性を再認識しました。 ここで紹介したことは、大橋禅太郎氏の「すごい会議」で紹介されていたものになります。この投稿を書くにあたって改めて読みましたが、同僚にも勧めたいと思える書籍です。 書籍では手法も紹介していますが、ワークショップの目的の認識合わせをする「このワークショップが終わったときにどんな成果をあげることを期待しているか」から意見出しをしていました。 会議進行は方法によって効果的になったり非効率になったりします。効果的になる方法については全員で共有していければと思います。 明日の記事は kimurasさんです。引き続きお楽しみください。
こんにちは。メルカリMarketplace, Foundation EngineeringのDirector, @mtsukaです。日々新しい技術を追い求め、挑戦を続けるMercari Engineeringですが、そんな部門にしては少し毛色の違った部類のチームです。どちらかというと、中長期の視点から、より良いビジネス貢献であったり、より良い開発体験を支える基盤開発を中心に、じっくり腰を据えた仕事をしています。 この記事は、 Mercari Advent Calendar 2023 の23日目の記事です。 メルカリは2021年10月から既存のシステムの解析、改善を大規模かつスピーディに行うという、難易度の高い全社的なリファクタリングプロジェクトRobust Foundation for Speed (RFS) に中期的に取り組んできました。本取り組みは、2023年7月末に各ドメインの改善が無事一段落し、プロジェクトという形は一旦解散としました。こういった取り組みの主要な結果は、具体的な成果として認知されるまでに、数ヶ月数年を要することもままあります。幸運なことに、すでにいくつか具体的に成果として示せることがありますので、昨年に引き続き、うまくいったところ、うまくいかなかったところ、今後の方針など、RFS全体をプロジェクトのオーナーの視点で振り返っていきたいと思います。また、各ドメインについては、ドメイン知識・疎結合化・文化醸成の観点からプロジェクトの成果を解説しています。 プロジェクト発足の背景 改めて、RFSプロジェクトは2021年10月に正式な中期プロジェクトとして発足しました。詳細な説明は 連載:技術基盤強化プロジェクト「RFS」の現在と未来 | メルカリエンジニアリング に譲りますが、事業に関わる共通基盤をうまく抽象化していく、保守性を良くしていくことで、機能実装のリードタイムを一定以下に維持し、これによって間接的に事業貢献をしていこうという取り組みです。このプロジェクトへ参画したいという意思表明が多くのドメインからありましたが、結果的にビジネスインパクトなどを鑑み、Transactions & Checkout(以降Transactionsと呼称)、CSTool, Logistics, ID Platform(以降IDPと呼称)の4ドメインでこの取り組みを行うことにしました。 プロジェクトスコープの設定 リファクタリングなどの改善プロジェクトを発足するときに、改めて気付かされるのはあらゆる資源は有限であるということです。こういったプロジェクトは、ともすれば全てを作り替えてしまいたい衝動に駆られるのが人の性でしょう。特にエンジニアであればそういう気持ちになる方は多いのではないでしょうか。もちろん気持ちとしてはとてもよくわかるのですが、やはりこの取り組みも事業の一環ですから、あれもこれも全てに資源投下というわけには参りません。また、 人月の神話で言われているセカンドシステム症候群 のようなことも避けねばなりません。これらのポイントを考慮して、最終的にはシステムの変更頻度や他システムとの結合度合いを考慮してスコープを決めました。この点については、人によっては不満もあったと思いますし、実際に一部ドメインの調査・分析や関連する議論が収束するまでには半年位の期間がかかってしまいました。個人的には、現場のメンバーが一番知見をお持ちなので、それを尊重しつつ納得感をある程度持ってもらいたかったのですが、正直少し時間をかけすぎてしまったので、明確なしきい値や基準など、もう少し具体的に事前に提示したほうが良かったと感じています。 プロジェクトの定点観測 スコープが決まればロードマップを引いて、OKRを設定し、あとは手を動かしていくだけですが、ビジネスグロースの案件などとバランスをとりながら、うまく説明責任を果たしていく必要がありました。このため、あまり好まれるやり方ではないにせよ、週に一度の定期チェックインミーティングを用意し、CTO/VP同席のもとプロジェクトの進捗を管理しました。原則としてOKRとその進捗をDivision全体で共有し、トラッキングすることでプロジェクトの透明性担保や早期のブロッカー除去に務めました。また、最初期にはカンパニーのOKRとして経営層への定期的な進捗インプットも行いました。 担当チームが解散してしまっていたり、メイン開発者が退職していてドキュメントも存在しないようなコンポーネントを含むドメインでの作業なので、透明性を担保しながら情報を共有し続けることは極めて重要であったと思います。一方で、忘れられた仕様が発見されたりするなど、スケジュールを遵守するという観点では苦労も多かったです。スケジュールマネジメントの観点ではThe Six Week Cycleの Tracking Work on the Hill Chart の考え方を大いに参考にしました。 プロジェクト全体の振り返り さて、このようなプロジェクトでは成果を既存事業への貢献として評価することはとても難しいです。定量的に計測したものは、リードタイムの増減、データベース分離数、マイグレーション数、削除したコードや廃止したAPI, それぞれの費用対効果などを計測しました。その他、定性的にはリファクタリングのマイルストーン達成状況や実際のチームの体感等などを集計しました。このような取り組みを経て、RFSが会社の期待にどのように答えたのか、振り返りを実施しました。かんたんなプロジェクトの総括としては、今後の事業計画・成長を見据えた基盤そのものと基盤維持の仕組みがある程度構築されたので、この取り組み自体は将来へ繋がる意味のある投資であったと確信しています。その旨をMercari Engineering Boardへ報告する形で説明責任を果たしました。個別の詳細については、後述の「各ドメインの振り返り」を参照ください。 各ドメインの振り返り 以下に、各ドメインでの取り組みと振り返りをまとめていますので、ご覧ください。 Transactions Transactionsはお客様が商品を購入してから手元に届くまでの各ステップを司るメルカリのビジネスを構成するAPI郡(以降mercari-apiと呼称)のいちコンポーネントです。複雑化したモノリシックなmercari-apiからこれら関連するコンポーネントを切り離し、保守性を担保しながら抽象化、単純化していくことでプロダクト開発の後押しをするために、チームの組成から着手しました。チームの成り立ちがRFS起因のため、スコープの決定や抽象化のプランの検討は比較的スムースでした。 Transactionsドメインとして mercari-apiから切り離された機能は以下のとおりです: チェックアウト (モジュール化完了) 購入履歴 (モジュール化完了) チェックアウト料金計算機能 (Golangの独立したマイクロサービスとして実装) 配送 (モジュール化完了) これらのモジュール化やリファクタリングを通じて下記のような成果を得ました。 ドメイン知識 綿密なコード解析とリバースエンジニアリングを行い、アプリケーション全体の中で最もビジネス上複雑で重要なドメインに関する知識を、組織として得ることができました。 疎結合化 TransactionsドメインはC2C Marketplaceシステムの中心的なコンポーネントなので、多くの新機能が恒常的にTransactionsドメインの連携を必要とします。このMonolithicな実装のサブコンポーネント郡をモジュラーモノリスとしてリファクタリングすることで、その後の開発に多くの良い影響をもたらしました。例えば機能境界が明確になったので、不具合の発見やリスクのコントロールがしやすくなり、Transactionsドメインに変更を加える成果物の品質が向上しました。また、本取り組みにおける調査の結果が知見として蓄積されたことに加え、認知的負荷の軽減により、オンボーディングも比較的簡単になりました。 上記から派生した効果として、新しい機能や要件実装のリードタイムを大幅に短縮できるようになりました。実例をあげると、チェックアウト料金計算機能によって料金管理が一本化されたため、料率の変更や新しい決済方法の導入などの実装工数が最大3ヶ月から1週間未満に短縮されました。また、CSToolやLogisticsなどの他のドメインとの依存関係を切り離すことができたので、より独立してシステムを維持していくことができるようになりました。 文化醸成 リバースエンジニアリングとリファクタリングと並行して、チームには "reading parties" という独創的で魅力的なコード分析の文化が生まれました。ここから生まれたドキュメントはオンボーディングへ応用されるだけでなく、他のチームとドメイン知識を共有するためにも活用されています。また、今後もTransactionsドメインの変更には多くのチームが関与し続けていくことになるため、将来に渡って意味のある成果になるでしょう。 参考記事 Understanding and Modernizing a Legacy Codebase メルカリの取引ドメインにおけるモジュラーモノリス化の取り組み クライアント・サーバサイドに分散する計算ロジックのマイクロサービス化 Logistics メルカリは多様な配送手段をサポートしています。Logisticsは言葉通りこれらの配送方法を司るコンポーネントです。ご存知の通り、配送方法はメルカリというサービスの成長とともに時間をかけて増えていったものなので、Logisticsコンポーネントも時間の経過とともに複雑さが増してきました。そのため、スコープの確定は早かったものの、他ドメインと比較してゴールの設定難易度が非常に高かったです。最終的にLogisticsドメインでは、重複しているクラスを排除してシンプルにするなど、主にシステムの再設計を行いました。具体的には、メルカリの歴史とともに育ってきた22のコンポーネントの疎結合化を目指したかったのですが、これらすべてを疎結合化するには現実的な時間が足りませんでした。そのため、将来につながるメンテナンスの一環としてインターフェースやデザインを極力共通化することにしました。すでに動いており、しかもビジネスの根幹を担うシステムを改善するわけなので、そういう観点でも難易度は相当なものです。成果自体は次に繋がる良いものでありつつも、残念ながら、この取り組みの見た目的な成果はドメインの中では一番物足りないものでもありました。このあたりは、より良いアプローチを模索していきたいです。 取り組みを通じて下記を達成しました。 ドメイン知識 重複排除などの作業を通じて新しい配送方法の追加、配送料金の変更方法などが統一され、結果としてチームのドメイン知識が増しました。配送手段の仕様はパートナー企業の仕様に依存するものの、社内で扱うインターフェースをある程度共通化することで、全体の把握がしやすくなりました。また、共通化の恩恵として学習コストも格段に下がりました。 疎結合化 Logisticsドメインはパートナー企業との関係もあるので、利用料金の変更や提供プランの変更などが発生した場合、直ちに対応を行わなければなりません。このため、システムの複雑さを解消することは極めて重要な取り組みでした。結果として、疎結合化自体は進みませんでしたが、デザインパターンの適用と再設計を通じて、機能追加や変更が簡単になりました。実際に、配送サービス利用料改定に関わるリードタイムは、チェックアウト機能との連携も完了し、期間も約3ヶ月から1/3の約1ヶ月に短縮されました。今後も新しい配送方法の追加、料金改定、その他将来発生するであろうユースケースについても同じような対応ができることでしょう。 文化醸成 チームがリファクタリング用のバックログを持つようになりました。このバックログは定期的に内容の確認と改善の検討が行われ、必要に応じてメンバーがアサインされるようになりました。 CSTools CSToolsはいわゆる顧客対応ツールです。お客さま対応のためのツールですから、その機能やサポート範囲は多岐にわたり、システムは年々複雑化していく一方でした。このため、スコープの策定議論は一番紛糾したのではないでしょうか。そもそもお客さま対応のためのデータベース数が膨大なため、これをどこまで疎結合化するのかなどが論点になってしまい、スケジュール的にもマイルストーン的にも難しい展開が発生していましたが、最終的に他3ドメインの改善に関わるブロッカー除去を優先するという前提でスコープを設定することで、議論を収束しました。 ドメイン知識 重複排除などの作業を通じて新しい配送方法の追加、配送料金の変更方法などが統一され、結果としてチームのドメイン知識が増しました。配送手段の仕様はパートナー企業の仕様に依存するものの、社内で扱うインターフェースをある程度共通化することで、全体の把握がしやすくなりました。また、共通化の恩恵として学習コストも格段に下がりました。 疎結合化 詳細は後述のブログポストに譲りますが、注力ドメインとの関係性と変更頻度を軸にDBの疎結合化を行いました。これによりCSTools開発に関わる調整相手が減って、クイックに改善活動ができるようになりました。また、追加で古くから一部のQA業務が依存していたシステムのGKEマイグレーションとサービスアウトを実施できました。これにより、QAやテスト環境の統合が進み、システムのコスト面でも貢献することができました。 文化醸成 Post RFSの一環としてCSToolsドメインにFoundationチームが組成されました。このチームはCSToolsドメインの共通基盤やフレームワークをパッケージとして各エンティティに提供することで、個別のエンティティに個別のツールを作らなくても良い状況をもたらすことに責任を負っています。正式にこういった組織を持つことを認められたのも成果の一つと言って良いかもしれません。 参考記事 メルカリCSツールにおけるDBの疎結合化への取り組み GKE 移行を進める上で発見したシステムの問題をどの様に解決したか CS Toolのフロントエンドのリプレイスプロジェクトについて ID Platform ID Platform(以降: IDP)は、メルペイ創業前後にmercari-apiから認証認可の機能を中心にスピンオフしてPlatform化したものです。チームの組成当初からビジネスプランを後押しすべく、然るべきタイミングで然るべきことをやっていくという方針でチームが運営されていたと記憶しています。一方で、どうしても急ぎの実装や設計が先行しがちなことは変わりません。内外各ステークホルダーとのアカウント連携など、常に現状のビジネスを改善する業務に追われている状況で、後々に判明した考慮漏れの修正、リファクタリングなどの時間を確保することが簡単ではない状況でした。RFSでの注力対象にピックアップされたタイミングのIDPチームは、当時メルコインサービスの開発をサポートしていました。もともとチームの思想がRFSに近く、ある程度成熟していたチームなので、RFSとして直接何かの機能改善やリファクタリングをお願いするようなことはせず、メルカリグループ全体の方針などをシェアしながら、現在の設計や実装が今後の抽象化にうまく繋がるように支援しました。 ドメイン知識 IDと認証認可の領域は比較的専門家が少ないため、知見が偏る傾向があります。現在チームはTLの育成と知見の共有などを行いやすい構造になりました。RFSの取り組みと考え方は、この文化醸成の一助になったと信じています。 疎結合化 IDPはもともとはメルカリとメルペイというサービスだけが存在する世界線で実装されたものですから、比較的明確に密結合な場所がありました。日々メルカリを利用してくれるお客さまに新しい価値を提供するためには、この密結合が段々と足かせになりつつあります。今後の取組次第でどうなるかはわかりませんが、この結合度合いをある程度疎に維持できるようになりました。 文化醸成 元来IDPでは、一部の専門家が特定のユースケースを基に知恵を絞って将来を視野に入れたデザインを行う傾向が強いのですが、外部の専門家も交えたドメイン知識の共有や議論などを行えるようになりました。直接は関係ないですがFIDO AllianceのAuthenticate 2023 Conferenceにてメルカリの取り組みを紹介するなどの機会にも恵まれました。 参考記事 Applying OAuth 2.0 and OIDC to first-party services Using the OAuth 2 token exchange standard for managing the identity platform resources まとめ さて、ここまで日々進化するメルカリのアプリケーションを支える基盤開発のエピソードを、長期プロジェクトの振り返りを通じてお伝えしました。 メルカリのFoundation Engineeringチームは、これからもRFSで得られた知見やユースケースを参考に、重要な共通基盤技術の保守性を維持ながらし、プロダクト開発エンジニアがサービス開発を行っていく上で不可欠なコンポーネントを提供し続けます。 明日の記事はQAチームのjyeさんです。引き続きお楽しみください。
こんにちは。メルコインのバックエンドエンジニアの iwata です。 この記事は、 Merpay Advent Calendar 2023 の23日目の記事です。 私はいまメルコインのCoreチームに属しています。Coreチームでは主にお客さまからの暗号資産の売買注文を受け付ける部分のマイクロサービスを開発運用しています。 メルコインではCI環境として GitHub Actions self-hosted runner を使用しています。またCIだけでなく、さまざまな自動化のためのワークフローの構築もこの環境を用いて実行しています。この記事では私の所属しているCoreチームにおいてGitHub Actions上に構築しているオートメーションについて紹介したいと思います。 PR-Agent PR-Agent はOpenAI APIを使って、PRのコードレビューなどを自動化してくれるActionです。 LayerXさんの紹介記事 を読んで導入しました。 機能はたくさんあるのでここでは詳細は割愛しますが、主に活用しているのはPR作成時にコメントしてくれるコードレビューと /describe コマンドで生成されるPRのタイトルと説明の自動生成です。 PR-Agentによるコードレビュー あまり具体的な例を記事中にだすことができませんが、例えば上記画像のような内容をコメントしてくれます。これによりレビュアーがぱっとこのPRの内容を理解するのに役立つことができます。また /describe を使うと自分のようにSSIAなどで説明文を端折ってしまう面倒くさがりな人でもいい感じのタイトルと説明文をAIが考えてくれて非常に便利です。 /add_docs を使うとコードコメントをSuggestしてくれてこれも便利です。 OpenAIのAPI Keyさえあれば簡単に導入できる点もよいです。一方でGitHub自体にも 似たような機能 がリリースされているので試してみたいなと思っています。 Lint いくつかのLintツールを併用していますが、ここではYAMLで記述されるGitHub Actions(GHA)のワークフローファイルに対するLintについて紹介します。実際のワークフローは以下です。 name: Actions Lint on: pull_request: paths: - ".github/workflows/*.yml" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: actionlint: runs-on: self-hosted permissions: checks: "write" contents: "read" pull-requests: "write" steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - uses: reviewdog/action-actionlint@82693e9e3b239f213108d6e412506f8b54003586 # v1.39.1 with: fail_on_error: true filter_mode: nofilter level: error reporter: github-pr-review ghalint: runs-on: self-hosted permissions: contents: "read" steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Setup aqua uses: ./actions/setup-aqua with: aqua_version: v2.21.0 - name: ghalint run run: ghalint run このワークフローでは actionlint と ghalint の2つのLinterを実行しています。それぞれセキュリティを含めたベストプラクティスに則っているかをチェックしてくれるので非常に有益です。 ワークフローについては 社内のセキュリティガイドライン に準拠する形で記述しています。3rd Party ActionはFull Change Hashで固定(このフォーマットでも Dependabot および Renovate を使うことで自動更新可能) し、 permissions は最低限の権限を使うようにしています。(以後記載するワークフローファイルはすべてこのガイドラインに則って記述してあります。) また ghalint のバージョン管理には aqua を使っています。 aqua はCLIツールのバージョンマネージャで チェックサムの検証 ができたり、Lazy Installなど便利な機能もあるため使用しています。 ghalint 以外にも golangci-lint や gci など開発に必要なさまざまなCLIツールを aqua で管理しています。( aqua についてより詳しく知りたい方は aqua CLI Version Manager 入門 をご参照ください) したがって aqua は他のさまざまなワークフローで利用することになるため、以下の Composite Action を作って再利用しやすいように工夫しています。 name: Setup aqua with caching describe: Install tools via aqua and manage caching inputs: aqua_version: required: true description: | aqua version for installer, e.g. v2.9.0 aqua_opts: required: false default: -l description: | aqua i's option. If you want to specify global options, please use environment variables policy_allow: required: false default: "" description: | If this is true", the aqua policy allow command is run. If a Policy file path is set, aqua policy allow "policy_allow" is run require_checksum: required: false default: "true" description: | Set an environment variable as `AQUA_REQUIRE_CHECKSUM` cache_version: description: The prefix of cache key required: false default: "v1" runs: using: "composite" steps: # ref. https://aquaproj.github.io/docs/products/aqua-installer/#-caching - name: Restore aqua tools uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 # v3.3.2 id: restore-aqua with: path: ~/.local/share/aquaproj-aqua key: ${{ inputs.cache_version }}-aqua-installer-${{hashFiles('.aqua/*.yaml')}} restore-keys: | ${{ inputs.cache_version }}-aqua-installer- - name: Aqua install uses: aquaproj/aqua-installer@928a2ee4243a9ee8312d80dc8cbaca88fb602a91 # v2.2.0 with: aqua_version: ${{ inputs.aqua_version }} aqua_opts: ${{ inputs.aqua_opts }} policy_allow: ${{ inputs.policy_allow }} env: AQUA_REQUIRE_CHECKSUM: ${{ inputs.require_checksum }} - name: add path shell: bash run: | echo "$HOME/.local/share/aquaproj-aqua/bin" >> "$GITHUB_PATH" Auto Correct Lintとともに goimports などのコードフォーマッタの活用も重要です。 golangci-lint によって goimports などのフォーマッタのかけ忘れを弾くことは可能ですが、GHA上でフォーマットしてあげて自動でコミットをしてあげるとさらに便利です。コードフォーマッタだけでなく、 wire など自動生成ツールも使っているのでそれらもあわせて実行し、差分があればGHA上でコミットするようにしています。使っているツールをまとめると以下のようになります。 コードフォーマッタ goimports gofumpt gci Linter golangci-lint (auto fix) 自動生成 wire gomockhandler yo GitHub Workflow pinact これらのツールはすべて aqua でバージョン管理しています。 pinact はワークフローファイル内のバージョンをFull Change Hashに自動で固定してくれるツールでとても有用です。Auto Correctのワークフローは以下になります。 name: Correct codes by auto generation on: pull_request: paths: - ".github/**/*.ya?ml" - "**.go" - "**/go.mod" - "**.sql" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true defaults: run: shell: bash jobs: auto-correct: runs-on: self-hosted permissions: contents: "read" steps: - name: Check out uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: "go.mod" cache-dependency-path: "**/go.sum" - name: Setup aqua uses: ./actions/setup-aqua with: aqua_version: v2.21.0 - name: Auto generation run: make gen # make taskでformatter, linter, code generationを実行 - name: pinact run run: pinact run - name: Generate token id: generate_token uses: suzuki-shunsuke/github-token-action@350d7506222e3a0016491abe85b5c4dd475b67d1 # v0.2.1 with: github_app_id: ${{ secrets.GH_APP_ID }} github_app_private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Push diff run: | set -euo pipefail if git diff --quiet; then echo "::notice :: There is no difference." exit 0 fi echo "::notice :: There are some differences, so a commit is pushed automatically." if ! ghcp -v; then echo "::error :: int128/ghcp isn't installed. To push a commit, ghcp is required." exit 1 fi branch=${GITHUB_HEAD_REF:-} if [ -z "$branch" ]; then branch=$GITHUB_REF_NAME fi git diff --name-only | xargs ghcp commit -r "$GITHUB_REPOSITORY" -b "$branch" \ -m "chore(gen): auto correct some files" env: GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} このワークフローは複雑になっているので順をおって説明しようと思います。 Protected Branchの設定 マージ先のブランチは Protected Branch で保護されています。このワークフローに関連する設定としては、 署名つきコミット と ステータスチェック を必須にしている点です。すなわちAuto Correctによるコミットがこれらを満たせるようにワークフローを構築しておかないとマージできなくなってしまいます。 署名つきコミット ワークフロー内でgitコマンドを使ってコミットをすると署名がつきません。これを簡単に回避する方法としては GitHub APIを使う方法 があります。GitHub API で生成したコミットにはGitHubが署名してくれます。 ghcp を使うとGitHub APIを使ったコミットを簡単に作成できるのでこれを使ってコミットするようにします。次のコードが実際にコミットをしている部分になります。 git diff --name-only | xargs ghcp commit -r "$GITHUB_REPOSITORY" -b "$branch" \ -m "chore(gen): auto correct some files" 差分がでたファイル名をパイプで渡してコミットを生成しています。 GitHub Appを使ったトークンの生成 コミットに使うGitHubトークンにも注意が必要です。 GitHubのドキュメント に以下のような記述があります。 When you use the repository’s GITHUB_TOKEN to perform tasks, events triggered by the GITHUB_TOKEN, with the exception of workflow_dispatch and repository_dispatch, will not create a new workflow run. つまりよく使われる secrets.GITHUB_TOKEN を使ってコミットをするとそのコミットをトリガーに他のワークフローを起動できません。ワークフローが起動しないということはCIが実行されず、したがってProtected Branchのステータスチェックをパスすることができません。 これを回避するためにGitHub Appから生成したトークンを使ってコミットをする必要があります。上記のワークフローでは suzuki-shunsuke/github-token-action を使ってトークンを生成しています。 - name: Generate token id: generate_token uses: suzuki-shunsuke/github-token-action@350d7506222e3a0016491abe85b5c4dd475b67d1 # v0.2.1 with: github_app_id: ${{ secrets.GH_APP_ID }} github_app_private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} このGitHub Appには次の権限が必要になります。 contents:write workflows:write リポジトリに自分で用意したGitHub Appをインストールして使います。GitHub Apoの準備は最初は面倒ですが、一度設定すると自動化ができることが格段に増えるので便利になります。 aquaの自動更新 CLIツールは aqua で管理しています。バージョンの更新は公式に提供されているRenovate Presetを使うことで可能です。詳細は Renovateによる自動update を参照してください。( GitHubのDependabotl にはPresetのような機能がないため、 aqua の自動更新はRenovate前提になっています。) 前述しましたが、 aqua ではチェックサムの検証ができます。 aqua では aqua-checksums.json でチェックサムを管理しており、バージョン更新時でもチェックサム検証をパスするためには、一緒にこのファイルのチェックサムも更新する必要があります。便利なことにそのための Reusable Workflow が公式に提供されているのでこれを使うことでチェックサムの更新も自動化することができます。 name: Update aqua-checksums.json automatically on: pull_request: paths: - .aqua/aqua.yaml - .aqua/aqua-checksums.json - .github/workflows/update-aqua-checksums.yaml jobs: update-aqua-checksums: uses: aquaproj/update-checksum-workflow/.github/workflows/update-checksum.yaml@3598c506108a2e0e9e31a0c6ef9c202c77049420 # v0.1.9 permissions: contents: read with: aqua_version: v2.21.0 prune: true secrets: gh_app_id: ${{ secrets.GH_APP_ID }} gh_app_private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} このワークフローにおいても前述のコミットの問題が発生するので、GitHub Appを使う必要があります。このAppには contents:write 権限があれば十分です。 リリースフローに関する自動化 ブランチ管理 Coreチームでは git-flow を簡素化したブランチ管理を採用しています。main、develop、feature、hotfixブランチはそのままですが、releaseブランチは作成せず、リリースの際にdevelopブランチをmainブランチにマージしてリリースするようにしています。これらのブランチのうち、Protected Branchの設定しているのはmainとdevelopブランチになります。 オリジナルのgit-flow、releaseブランチに違いがある (出典: atlassian.com ) 定期的なリリースタイミングでdevelopをmainブランチにマージし、タグを作成すると本番環境にデプロイできるようになっています。 develop to mainのPull Request作成 リリース時にはdevelop to mainのPRが必要になるため、developブランチへPushがあると自動でmainブランチへのPRを作成するようにしています。 name: git-pr-release on: push: branches: - develop jobs: git-pr-release: runs-on: self-hosted permissions: contents: read pull-requests: write container: image: ruby:3.2@sha256:e3f503db7f451e6fd48221ecafbf1046ad195cddec98825538b35a82538b8387 steps: - name: Check out uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: fetch-depth: 0 # git-pr-release needs the git histories - name: Install git-pr-release run: gem install --no-document git-pr-release --version 2.2.0 - name: Update git config run: git config --global --add safe.directory "$(pwd)" - name: Create PR run: git-pr-release --squashed env: GIT_PR_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} GIT_PR_RELEASE_BRANCH_PRODUCTION: main GIT_PR_RELEASE_BRANCH_STAGING: develop GIT_PR_RELEASE_LABELS: Release GIT_PR_RELEASE_TEMPLATE: .github/PR_RELEASE_TEMPLATE.erb TZ: Asia/Tokyo PRの作成には git-pr-release を使っています。このワークフローにより次の画像のようなPRが自動で生成されるようになります。各PR毎にチェックボックスがつくので、リリース時にPR内容を確認してもらって問題なければチェックをいれるようにしてからリリースしています。 リリースの作成 mainブランチにマージした後はGitHub UI上からリリースをパブリッシュすることでタグを作成します。この際のリリース作成も自動化しています。 name: Release Drafter on: pull_request: branches: - main types: - closed jobs: release-draft: runs-on: self-hosted if: github.event.pull_request.merged permissions: contents: write pull-requests: write steps: - name: release drafter uses: release-drafter/release-drafter@09c613e259eb8d4e7c81c2cb00618eb5fc4575a7 # v5.25.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} mainブランチへのPull Requestがマージされると、 release-drafter を使ってリリースをドラフト状態で作成します。これはかなり便利で、 release-drafter 導入前はマニュアルでリリースを作成していましたが次のような課題がありました。 バージョン番号を自分でインクリメントしないといけないのが地味に面倒 デフォルトブランチがdevelopになっているので、ターゲットブランチをmainに切り替え忘れるとインシデントになってしまう 自動化によりこれらは解消することができました。 Hotfixに関する自動化 hotfixブランチはdevelopブランチを経由せず、直接mainブランチにマージします。hotfixブランチのマージの際はpatchバージョンをインクリメントするようにしています。 name: Release Drafter Label on: pull_request: branches: - main types: - opened jobs: release-draft-label: runs-on: self-hosted if: github.event.pull_request.head.ref != 'develop' permissions: contents: read pull-requests: write steps: - name: detect version label uses: actions-ecosystem/action-add-labels@18f1af5e3544586314bbe15c0273249c770b2daf # v1.1.3 with: labels: patch このワークフローによってhotfixブランチのPRが作成されると patch ラベルがつくようになっています。このラベルがつくと release-drafter がpatchバージョンをあげるように 設定して あります。 またhotfixの差分はdevelopブランチにもマージする必要があります。この作業は面倒は意外と面倒です。なぜかというと、直接mainからdevelopへのPRを作成することができないためです。またこの作業はよく忘れてしまうので自動化しておくのが得策です。それを実現するのが以下のワークフローです。hotfixがmainにマージされると起動します。 name: Create a pull request to merge hotfix into develop on: pull_request: branches: [main] types: [closed] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true defaults: run: shell: bash jobs: create-pull-request: runs-on: self-hosted if: github.event.pull_request.merged == true && github.head_ref != 'develop' permissions: {} steps: - name: Generate token id: generate_token uses: suzuki-shunsuke/github-token-action@350d7506222e3a0016491abe85b5c4dd475b67d1 # v0.2.1 with: github_app_id: ${{ secrets.GH_APP_ID }} github_app_private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Decide a branch name id: decide-branch run: | branch=main-to-develop/hotfix-${{ github.event.pull_request.head.sha }} echo "branch=${branch}" >> "$GITHUB_OUTPUT" - name: Create a pull request uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: github-token: ${{ steps.generate_token.outputs.token }} script: | const {owner, repo} = context.repo const mainBranch = "main" const devBranch = "develop" // fetch commit sha of develop branch const {data} = await github.rest.git.getRef({ owner, repo, ref: `heads/${devBranch}`, }) // create a new branch const branch = "${{ steps.decide-branch.outputs.branch }}" await github.rest.git.createRef({ owner, repo, ref: `refs/heads/${branch}`, sha: data.object.sha, }) const {actor, payload} = context const {title, number} = payload.pull_request // merge main into a new branch await github.rest.repos.merge({ owner, repo, base: branch, head: mainBranch, commit_message: `Merge ${title}`, }) // create a pull request const pull = await github.rest.pulls.create({ owner, repo, base: devBranch, head: branch, title: `Merge hotfix to ${devBranch}: ${title}`, body: `Merge #${number} for ${devBranch} branch, too`, }) // assign an actor as reviewer github.rest.pulls.requestReviewers({ owner, repo, pull_number: pull.data.number, reviewers: [actor], }) github-script 用のScriptが長いですが次のことをやっています。 developブランチからPR用のブランチを作成 作成したブランチにmainブランチをマージ 上記ブランチからdevelopへのPRを作成 hotfixをマージしたアカウントをPRのレビュワーにアサイン このワークフローにおいてもコミットの問題が発生するので、GitHub Appからトークンを生成しています。このAppでは次の3つの権限が必要になります。 contents:write pull-requests:write workflows:write レビューワーも設定しているのでマージ忘れがないように工夫しています。 まとめ GitHub Actionsを使った自動化の事例を紹介してきました。セキュリティと自動化とは相反するところもあるので、両立するためにはバランス感覚と知識の更新が不可欠だなと思っています。だいぶ長い記事になってしまいましたが、そういった面で参考になれば幸いです。 明日の記事は poohさんです。引き続きお楽しみください。
こんにちは。Mercari USの検索エンジニアの @pakio です。 この記事は、 Mercari Advent Calendar 2023 の22日目の記事です。 Query Understandingは検索システム最も重要なシステムの一つで、検索意図を解釈し、また正しい検索を促すためのコンポーネントです。例えば検索ボックスでのクエリの提案やスペル修正、クエリの意図解釈、類似した検索条件の提案などシステム側・ユーザとの対話含めて様々な技術が用いられています。 Mercari USでは日々35万件以上の新しい商品が出品されています。それに比例して検索対象の商品も分増えていくため、お客さまの検索ニーズを正しく理解し、適切な商品を提案するためにもQuery Understandingが重要な課題と捉えています。今回はそんなQuery Understandingの中でもQuery Categorizationについての手法比較と、弊チームで実際に検証した結果についてご紹介します。 Query Categorizationの定義は様々あるかと思いますが、本記事の中では「特定の検索クエリから、お客さまが求めている検索結果がどの事前に定義されたタクソノミ(分類)に当てはまるか推測する」と定義します。 ルールベースのアプローチ ルールベースのアプローチは最もシンプルに実装ができ、かつ変更もしやすく説明可能性にも優れた手法です。 Algolia や Vespa など一部の検索エンジンではこの機能がデフォルトで提供されていることからも重要度が高いことがわかりますし、また実際に導入しているサービスも多いことでしょう。ここでは例として単純にカテゴリフィルタ条件を追加する変換を挙げていますが、実装方法によっては更に複雑な、例えばフィルタリングの代わりにスコアのブースティングを行ったり、複数の条件を追加するなども考えられます。 ルールベースのQuery Categorization その簡単さからとても魅力にも思える手法ですが、一方のデメリットとしてメンテナンス性が挙げられます。 もっとも単純なルールの生成方法として手動で辞書をメンテナンスする方法が考えられますが、確実な変換だけに対象を絞れる一方で入力の多様性に対応するためには莫大なメンテナンスコストがかかります。これについてはマスターデータからの生成などである程度自動化することは可能ですが、例えば同義語への対応や名称同士のコンフリクトなどイレギュラーなケースにはある程度人の手が必要となります。運用にあたってはその人的コストをあらかじめ織り込んでおかなければなりません。実際に弊チームでもこの辞書型のアプローチを数年ほど前から運用していますが、リスティングのトレンドの変化や新製品の対応などに伴う人手による定期的な見直しが必要とされている状況です。 機械学習的なアプローチ ルールベースからもう少し発展した手法として、クエリログやそれに付随するクリックログ、検索結果に表示されたドキュメントの統計情報を用いる方法などが提案されてきました。この手法はデータ量が膨大になりがちであるため、ルールベースなアプローチの代わり に機械学習的なアプローチと組み合わせて利用される事例を多く見かけます。 2018年末に公開されたLinらの論文 では、実際にECのプロダクト検索においてQuery Categorizationにクリックログを用いた手法が紹介されています。 ここでは約4000万件のクエリに対して、実際に検索結果に表示され行動(クリック/カートに追加/購入)が起こされたアイテムのカテゴリを取得し、クエリからカテゴリを予測するテキスト分類タスクとして学習を行わせています。 ここで使用されたカテゴリは階層構造になっているとのことですが、最も優れたモデルで1階層目の予測がmicro-F1スコア 0.78、最下層の予測が0.58程度とある程度高い精度で予測できていることがわかります。 TABLE I: Best micro-F1 score of multi-class single-label LR (logistic regression), SVMs, XGBoost, fastText and Attentional CNN classifier at different levels. – E-commerce Product Query Classification Using Implicit User’s Feedback from Clicks, Lin et al., Source: https://ieeexplore.ieee.org/document/8622008 条件・モデル構造は異なりますが弊チームでも同様にクエリログ及びクリックログを用い、クエリから商品の各カテゴリのクリックされやすさを予測するマルチクラス分類予測の学習をさせた機械学習モデルを作成しました。その結果、我々のテストデータではmicro-F1スコア 0.72となりました。 言語モデル的なアプローチ 上記の論文は2018年末に発表されたものでしたが、同じく2018年末に発表された言語モデル BERT が様々な分野で優れた性能を発揮しているのは皆さんご存知のことでしょう。BERTの特徴として、そのアーキテクチャにより上記で比較されていたACNNなどの従来のモデルと比較してもよりコンテキスト情報に強く、また様々な事前学習済みモデルが公開されている為手軽に試せることが挙げられます。また利用する事前学習済みモデルによっても異なりますが、自社のクエリログなどから学習したモデルと異なり一般的な語彙が用いられていることも特徴の一つです。これには未知のクエリに強い、汎用性があるなどのメリットもありますが、一方でドメイン固有の単語などには弱いといったデメリットも考えられます。 ここでQuery Categorizationのタスクに対して、このBERTの軽量派生モデルである DistilBERT を用いて弊チームにて実装した手法をご紹介します。 大まかなアーキテクチャとしては ①query embeddingsを取得するためのDistilBERT ②クエリ-カテゴリ分類器 で構成されています。 DistilBERTを用いたQuery Categorization 前段部分にあたるDistilBERTは事前学習済みのモデルから自社のデータでFine Tuningしたものを流用しており、今回の検証では後段の分類器のみを先述の機械学習的アプローチと同様にクエリログ及びクリックログから学習させた形になります。学習させたモデルのパフォーマンスは、我々のテストデータでの評価ではmicro-F1スコア 0.80となりました。 実際に本モデル及び前項に記載した機械学習モデルをオンラインテストで比較したところ、変換対象となったキーワードのカバレッジが本構成において2倍になっており、今後改善を行う上で汎用性の高い言語モデルであるBERTを用いるメリットが確認できました。 まとめ 本記事では弊チームで実装・検証を行ったQuery Categorizationに対しての複数アプローチについて紹介しました。特に最後のDistilBERTをベースとした手法に関しては、既存の言語モデルを流用することが可能で学習自体も1日未満で完了と、省エネながら確かな結果が得られる点が興味深かったです。当初の目的であった「お客さまの検索ニーズを正しく理解し、適切な商品を提案する」については、統計的有意差のある結果にはならなかったものの、検索結果上位のアイテムのCTRが増加したことが確認できました。より優れた検索体験を提供できるよう、更なる改善を今後も継続していきます。 検索エンジニアとして面白みを感じる分野である一方、今後ベクトルベースの検索がメジャーになったシーンにおいて既存のQuery Understanding技術がどう適用されるのか、進化していくのかがとても興味深いところです。 明日は@mtsukaさんが担当します。お楽しみに!
こんにちは。株式会社メルペイのSolutionsチームのデータエンジニアの @orfeon です。 この記事は、 Merpay Advent Calendar 2023 の22日目の記事です。 Solutionsチームは、社内向けの技術コンサルや技術研修、部門を跨いだ共通の問題を発見して解決するソリューションの提供などを行っています。 私は主に社内のデータ周りの課題を解決するソリューションを提供しており、一部の成果はOSSとして公開しています。 過去の記事 では全文検索OSSである Apache Solr を Cloud Run 上で利用して手軽に検索APIを構築する構成を紹介しました。 社内向けのソリューションの一つとして社内向けの検索APIを使ったサービスなど小規模な検索システムの構成に役立てています。 前回の記事の時点では、検索対象として搭載できるデータサイズなどにいくつかの制約がありました。 今回の記事では、構成をブラッシュアップすることで機能を追加したり、制約を一部克服できるようになりましたので、その実現方法と構成を紹介します。 はじめに 新しい構成を紹介するにあたって、まずは過去の記事で紹介したSolr検索サーバをCloud Runにデプロイする構成をおさらいします。 この構成を大雑把に説明すると、事前に作成した検索インデックスをSolrのコンテナイメージに直接同梱してそのままCloud Runにデプロイしてしまうというアイデアになります。 以下、定期的にデータソースからインデックスを生成して同梱したSolrコンテナをCloud Runにデプロイする構成図の例です。 大きく分けて、検索インデックスファイルを指定したデータソースから生成するバッチジョブと、Solrのコンテナイメージに完成した検索インデックスを追加したイメージを作成し、Cloud Runにデプロイする2つのステップから構成されます。 検索インデックスファイルの生成には Cloud Dataflow を、コンテナイメージの生成とCloud Runへのデプロイには Cloud Build を利用しています。 Cloud DataflowとCloud Buildを Cloud Scheduler から定期実行することで、指定したデータソースを元に検索インデックスをビルドし、Solr検索APIサーバとしてCloud Run上に自動的に反映される仕組みが構築できます。 Cloud Run上で動いているSolrサーバでの逐次的な検索インデックスの更新は行わない想定のため、同一で不変のインスタンスが負荷に応じてスケールするというとてもシンプルな構成になります。 一方で以下のような制約があります。 検索インデックスのサイズがコンテナイメージに載せられる量に制限される データの更新頻度はそれほど高くはできない(1日数回程度) 今回の記事ではこの構成をベースとして追加した新しい機能や、上に挙げた制約を回避するための構成として次の項目について紹介します。 複数コア検索対応 ベクトル検索インデックス構築支援 分散検索対応 複数コア検索 最初に紹介するのは複数コア検索対応です。 Apache Solrでは検索対象となるデータセットを コア という単位で管理しています。 コアは検索データセットのインデックス、スキーマ、設定情報を管理しており、RDBにおけるテーブルのような位置付けになります。 検索時に複数のコアを利用することで異なるデータセットを横断した検索を手軽にできるようになります。 例えばECマーケットで自分がお気に入りに登録したショップの商品だけ検索したい場合を考えます。 お気に入りのショップの数が少ない場合は、Solr APIを呼び出すアプリケーション側でお客さまのお気に入りショップを取得して、検索時のフィルタ条件に追加することで実現することもできます。 しかし、フィルタ条件を動的に組み立てる仕組みをアプリケーション側が管理しないといけません(お気に入りショップをDBから取得しORのフィルタ条件を組み立てるなど)。またショップの数が多いとリクエストサイズの制限に引っかかる可能性も出てきます。 そこで商品検索用のコア(Items)とは別に、お客さまのお気に入りショップ情報を管理するコア(FavoriteStores)を用意しておきます。 商品検索用のコアとお気に入りショップのコアを検索時にショップIDで結合することで、検索結果をお気に入りショップのみを対象に絞り込んだ上で該当するショップの取り扱っている商品だけを検索することができます。 Solrでは検索時にコア間の関係を正規化するためのクエリパーサーとして Join Query Parser が提供されています。 以下はJoin Query Parserを利用した検索リクエストの例です。 https://{solr url}/solr/Items/select?q=GCP&fq={!join from=ShopID fromIndex=FavoriteStores to=ShopID}UserID:0123456789 上記の検索リクエストは以下のようなSQLクエリと同等のものになります。 SELECT * FROM Items WHERE ShopID IN ( SELECT ShopID FROM FavoriteStores WHERE UserID = "0123456789" ) 過去に紹介した記事では単一のCloud Dataflowパイプラインでは単一のコアのインデックスのみ生成することができました。 そこでSolrのインデックスを生成するMercari Dataflow Templateの localsolr sinkモジュール を機能拡張して、複数のコアを一度に作成できるように対応しました。 これにより異なるデータセットを横断検索できるSolrサーバを手軽に構築できるようになりました。 以下はMercari Dataflow Templateで2つのBigQueryデータソースからそれぞれ対応する2つのコアの検索インデックスファイルを生成するsinkモジュールの設定の例になります。 コアごとに入力とスキーマ等の設定ファイルを指定します。 "sinks": [ { "name": "LocalSolr", "module": "localSolr", "inputs": ["BigQueryItems", "BigQueryFavoriteStores"], "parameters": { "output": "gs://${bucket}/output/index.zip", "cores": [ { "name": "Items", "input": "BigQueryItems", "schema": "gs://${xxx}/Items/schema.xml" }, { "name": "FavoriteStores", "input": "BigQueryFavoriteStores", "schema": "gs://${xxx}/FavoriteStores/schema.xml" } ] } } Mercari Dataflow Templateのlocalsolr sinkモジュールは生成したSolrのインデックスファイルをzipファイルとしてoutputで指定されたCloud Storageのパスに保存します。 複数のコアをコンテナイメージに同梱するDockerfileは以下のようになります。 インデックスファイルはコアごとにディレクトリが分かれています。 zipを解凍してコアごとのインデックスのディレクトリをSolrのデータディレクトリにそれぞれコピーします。 FROM solr:9.4.0 USER solr COPY --chown=solr:solr Items/ /var/solr/data/Items/ COPY --chown=solr:solr FavoriteStores/ /var/solr/data/FavoriteStores/ ENV SOLR_PORT=80 ベクトル検索インデックス構築支援 次に紹介するのはベクトル検索インデックスの構築支援についてです。 Solr 9.0から ベクトル検索がサポート されました。 ベクトル検索により検索キーワードが含まれているコンテンツだけでなく、検索キーワードに意味的に似ているコンテンツを検索することができるようになります。 しかし、コンテンツの内容を表すベクトルは検索インデックス構築時に自分で用意する必要があります。 テキストや画像などのコンテンツからベクトルを生成するには、自前のembeddingモデルを用意して推論したり、embedding用のAPIを利用するなどいくつか方法があります。 しかし検索インデックス構築パイプラインに案件ごとでこうしたコンテンツのベクトル化の処理を挟み込むのは少し面倒です。 そこで検索インデックスを生成するCloud Dataflowで、あらかじめデータをベクトル化するために作成した ONNXモデル を使って、入力データをベクトル化するための onnx transformモジュール を開発しました。 これにより、データ取得からベクトル化、検索インデックス構築を一筆書きのパイプラインで実現できるようになりました。 以下、Mercari Dataflow Templateで入力データの指定したフィールドをベクトル化するonnx transformモジュールの設定の例になります。 あらかじめ作成してGCSに保存しておいたONNXファイルをモデルとして指定して、入力データのフィールドやベクトル化出力とONNXモデルの入出力のマッピングを指定しています。 "transforms": [ { "name": "OnnxInference", "module": "onnx", "inputs": [ "BigQueryContentInput" ], "parameters": { "model": { "path": "gs://example-bucket/multilingual_v3.onnx", "outputSchemaFields": [ { "name": "outputs", "type": "float", "mode": "repeated" } ] }, "inferences": [ { "input": "BigQueryContentInput", "mappings": [ { "inputs": { "inputs": "Content" }, "outputs": { "outputs": "EmbeddingContent" } } ] } ] } } ] 検証では、TensorFlow Hubで公開されている universal-sentence-encoder-multilingual/v3 モデルをONNX化して、Solr検索インデックス構築時のテキストデータのベクトル化に利用しました。 2,000 程度の日本語のPDFファイル(250MB)のベクトル化を6vCPU程度のリソースコストで完了することができました。 現状ではONNX推論はCPU環境のみ対応ですが、今後はGPU環境対応なども検討していきたいと思っています。 ※ちなみにこの機能を追加した後に、 BigQueryの機能追加 によりSQLでテキストデータから手軽にベクトルを生成できるようになりました。 BigQueryではGoogleが構築済みのembeddingモデルをすぐに利用することができます。 手軽にベクトル検索を試してみたい方はまずこちらの機能を利用してみると良さそうです。 分散検索 最後に紹介するのは分散検索対応です。 過去の記事ではSolrのスタンドアロンモードでの起動を前提としていました。 スタンドアロンモードの通常の検索だと、検索インデックスは単一の検索ノード上に閉じるため、検索インデックスのサイズにはCloud Runインスタンスに載せられるだけという上限があります。 しかしSolrではスタンドアロンモードでも複数ノードにまたがった分散検索に対応しています。 そこでCloud RunでもSolr分散検索に対応した構成にすることで、単一のCloud Runインスタンスに載らない大規模なデータセットも検索できるようにしました。 Solrの分散検索 まず前提となるSolrのスタンドアロンモードでの 分散検索機能 を紹介します。 Solrでは1つの巨大なインデックスをシャードと呼ばれる小さなインデックスに分割して、複数のノードに分散配置することができます。 分散検索では、これらの複数のノードに分散配置されたシャードに対して一括検索することができます。 分散検索の実行には特別な設定は必要なく、検索対象としたいシャードを持つノードのエンドポイントを検索リクエストのshardsパラメータで指定することで実現します(複数ノード指定も可)。 分散検索では最初に検索リクエストを受け付けたノードが、shardsパラメータで指定されたノードに対して同じ検索リクエストを発行して検索結果を受け取り、マージして最終的な検索結果として返す仕組みになっています。 以下、3つのエンドポイントへの分散検索リクエストの例です。 https://{solrShardA}/solr/Items/select?q=GCP&shards=https://localhost:8983/solr/Items,https://{solrShardB}/solr/Items,https://{solrShardC}/solr/Items Cloud Runへの分散検索の適用 Solrの分散検索の仕組みをCloud Run上で実現する構成を考えます。 先に紹介した通り、Solrの分散検索ではシャードごとに異なるエンドポイントを持つ必要があります。 Cloud Runでは役割に応じてサービスという単位でエンドポイントを分けることができます。 そこでシャードごとにサービスを分割して、リクエスト時にこれらのシャードに対応するサービスのエンドポイントをshardsパラメータで指定することで分散検索を実現しました。 Cloud Runではサービスごとにノード数をスケールさせることができます。 そのためデータセットが不均衡で一部検索処理が重いシャードがあっても、そのサービスのノードだけ自動でスケールさせることができます。 Solr分散検索をCloud Run上で運用するに当たって、検索インデックスの生成ステップでは追加の開発は特に必要ありません。 Cloud Runのサービスをシャードごとにデプロイするようにするだけです。 そのためにCloud Dataflowによるインデックスの生成をシャードごとに生成するように変更します。 Cloud BuildによるCloud Runへのデプロイもシャードごとにサービスを分けてデプロイするようにします。 Solrの分散検索の注意点ですが、検索結果のX件目から10件取得するといったオフセットを指定して取得する場合、オフセットに比例して消費メモリや処理が重くなることが挙げられます。 これは複数ノードから取得した検索結果を一箇所に集めてソートするために起こります。 分散検索はなるべくこうした問題が顕在化しない、トップX件のみ利用するようなケースに適用するのが望ましいでしょう。 別の注意点としては、検索リクエストを送る側や各サービスはシャードごとのエンドポイントを把握しておく必要があることが挙げられます。 データセットを分割するシャードが変わらない場合は問題にならないのですが、頻繁にシャードが追加されたり変更されるような場合は、シャードと紐づくエンドポイントの情報をサービスやアプリケーション間で共有するための工夫が必要になります。 おわりに 今回の記事では、Cloud Run上で手軽に検索APIを構築するための構成について、前回の記事から新しく追加した機能や構成を紹介しました。 過去の他の記事 でもCloud RunでNeo4jを動かす構成を紹介しました。 Cloud Runはフルマネージドなサービスであり、比較的小規模なデータを扱うAPI手軽に構築するにはとても便利なサービスだと思っています。 今後もCloud Runなどを通じて様々なデータを手軽に扱う仕組みを検証して、社内のデータ活用に役立てていきたいと思います。 今回紹介したSolrの検索インデックスの生成に用いた Mercari Dataflow Template はOSSとして公開しており、技術書典の全文検索にも活用されています。 もしCloud RunでSolrを使った検索APIを手軽に構築してみたい方はぜひお試しもらえればと思います。 また今回の記事に向けて、データの更新頻度をニアリアルタイムに近づけるための仕組みも検証中だったのですが、残念ながら間に合いませんでした。 次回の記事でニアリアルタイム検索の仕組みについても紹介できればと思います。 明日の記事は @iwata さんによるGitHub Actionsを使った自動化です。引き続きお楽しみください。
はじめに こんにちは。メルカリ Director of Engineering の @motokiee です。この記事は、 Mercari Advent Calendar 2023 の21日目の記事です。 メルカリのサービス開始から10周年ということで、2023年9月に iOSDC Japan 2023 カンファレンスで「メルカリ10年間のiOS開発の歩み」について 発表を行いました 。 この発表は、10年間のiOS開発の歴史を40分のトークにまとめたものです。メルカリはこの10年多くの技術的なチャレンジをして断続的にアプリケーションをアップデートしてきました。自分が見てきた歴史と、見ていない歴史については git log を手繰りながら調査した集大成となっています。 サービスの歴史が長くなると、アプリケーションのリファクタリングはもちろん、作り直す話も出てくると思いますが、そういった意思決定の際の参考になればと思い作成しています。 なお 発表のアーカイブ動画 もありますが、動画を見るのも以外と腰が重かったりするため、文章のほうが自分の都合で見やすく、良い選択である場面もあると思います。また、テキストの方がChatGPTなどLLMでサマリを作るコストも低くなりタイパ(タイムパフォーマンス)重視の方には良いのではないかと思い、トークスクリプトを公開してみると良いのではないか、と考えました。 ぜひご覧ください。 トークスクリプト全文 よろしくお願いします。「メルカリ10年間のiOS開発の歩み」というタイトルで発表します。 自己紹介です。motokieeといいます。 現在は株式会社メルカリで Director of Engineering をしています。メルカリには2016年に入社し、丸7年が経過しました。 メルカリでは、メルカリ本体や新規事業にエンジニアやエンジニアリングマネジャーとして携わってきました。 現在はMobile, Web, Backend の アーキテクトチームをDirector of Engineeringとして管轄しています。ちなみに現在はiOSの開発はしていません。なのでお手柔らかにお願いします。 iOSDCは2016年から2019年までコアスタッフをしていました。スピーカーとしての参加も久しぶりなのでとても緊張しています。よろしくお願いします。 まずはこのトークでオーディエンスのみなさんが得られるものについて簡単にご紹介します。 メルカリはこの度10周年を迎えることができました。 これもひとえに使っていただいたお客さまのおかげではありますが、この10年間でどのように会社、サービス、そしてiOS関連技術が変化してきたかをご紹介します。 また10年間のメルカリアーキテクチャやTech Stackの変遷についてもご紹介します。これまでの10年に負けないくらいの変化が今後も起こるはずだと考えており、エンジニアとしてこれからの変化にどう対応していくかのヒントが得られるのではないかと思います。 そして最後に、昔からiOS開発をしている方々には温故知新、少し懐かしい気持ちになってもらえるのではないかと思います。 最近iOS開発を始めた方々には、昔の出来事を振り返って、自分たちがこれから取り組むかもしれない開発へのヒントにしていただければ幸いです。 それではトークに移りますが、メルカリについて簡単にご紹介させてください。 私達はミッションとバリューをとても大切にしています。 まずミッションですが、今年10年を迎えミッションが「あらゆる価値を循環させ、あらゆる人の可能性を広げる」にアップデートされました。 そしてバリューです。Go Bold, All for One, Be a Proです。 日本語に訳すと、大胆にやろう、全ては成功のために、プロフェッショナルであれ、をValueとして掲げています。 続いてはサービスについて、特にフリマ事業がサービス開始から10年でどのような立ち位置にいるか簡単にご紹介します。 2023年7月時点で、メルカリの月間利用者数は2200万人以上となっています。累計でメルカリに出品された商品は30億品以上、さらに 2022年の取引件数を1年間の秒数で割ったところ、1秒間に7.9個売れていることがわかりました。 サービス開始当初の2013年は20-40代の方を中心に使われていましたが、現在ではシニア層の方も含め幅広くバランスよくご利用されています。 続いて取扱いカテゴリですが、2014年にはレディースファッションカテゴリが最もシェアが大きかったのですが、現在は、本・ゲーム・おもちゃといったインドア向けアイテムがトップシェアを占めています。 メルカリはアメリカでも事業を展開していますが、日本のフリマから国境を超えて取引が展開されています。代理購入サービスで海外のお客さまでもメルカリの商品を購入できるという取り組みが行われており、世界110か国以上の国・地域のお客さまに「メルカリ」でのお買い物をお楽しみいただけるようになっています。 以上、簡単なメルカリのフリマサービスについてのご紹介でした。 続いて今日のトークの全体像についてご紹介します。 今回、10年分の歴史を振り返るにあたって独自に年表を作成しました。使用されていた技術、重要なプロジェクト、その時々のスクリーンショットを集めて参考資料として作成しました。 少し文字が小さいですが、ざっくりと流れをご紹介します。 1年ごとの取り組みを分析してみてタイトルを付けてみました。2013年から2015年は Build 期 だったと言えそうです。 このころはフリマサービスに必要な機能を次々と実装していた期間でもありますが、同時に新しい事業・技術ともに新しい領域への探索がスタートした時期でもありました。立ち上げ期、Buildにフォーカスした時期だったのかなと思います。 続いて2015年から2017年あたりは、Explore, 探索期ですね。次々と新規事業が生まれていった時期だったと思います。 Swiftはもちろん、Reactive Programming の導入も始まっていて、新しい技術の探索を始めた時期だったのかなと思います。 2018年から数年は Re-architecture and Foundation 期です。 2018年には Re-architecture が始まり、2019年頃から Design System, Weekly Release, ログの改善など、アプリ開発周辺基盤の強化に力を入れていた期間でもありました。この間、開発基盤のために事業を止めていたわけではなく、スマホ決済のメルペイもサービスローンチされたりしています。 そして2020年-2022年はRewrite期です。 日本のメルカリアプリをRewriteする取り組みの期間でしたし、US、新規事業でもフルスクラッチで開発を行っていました。 2023年現在、いまは Post Rewrite と呼べる時期で、また新しいことに取り組んでいたりします。 以上が10年をフェーズに分けた全体像となりますが、ここから各年掘り下げてご紹介していきたいと思います。 まずは2013年です。この年はメルカリが誕生した年です。最初にiOS周辺技術での出来事を簡単に振り返りましょう。 2013年はiOS7が発表された年です。いわゆるスキューモフィズムからフラットデザインへの大きな変更が行われた年と言っても良いでしょう。 iPhone 5s、iPhone 5c が発売リリースといった出来事がありました。 メルカリのサービスとしては、2013年7/2にAndroid版が、少し遅れて2013年7/23にiPhone版の提供がスタートしたようです。 iPhone版はわずか半年後に 「App Store Best of 2013 今年のアプリ」を受賞していて、急速にサービスが伸びていったことがうかがえます。 ちなみにメルカリで最初に売れた商品は「ドット柄のカットソー」みたいです。 サービス開始当初はどんなUIだったかというと… こちらは当時のプレスリリースに掲載されていた画像です。ロゴやUIに時代を感じますね。 また、 App Store Connect API を使ってApp Storeに設定されたスクショを全て取得しています。こちらは2013年7月にリリースされた際、App Store に設定されていたスクリーンショットです。2013年って感じですね。 続いて メルカリのiOSリポジトリの git log から2013年がどんな年だったか見てみましょう。 主要なデータとして、コミッター数、コミット数、そしてdiffを1年ごとに集計しています。コミッター数は重複を含むため、正確な数字ではありませんが、スタートアップらしい少人数体制で開発をしていた時期です。 そしてこちらが記念すべき最初のコミットログです。タイムゾーンがなぜか(アメリカ・カナダ)の山岳部標準時 – MSTになっているのですが、JSTでは2013年03月15日(金) 21:17でした。ちなみに調べたところ大安でした。 また、メルカリiPhone版が提供されたのは7/23のv1.0.1ですが、それより以前に App Store で v1.0.0が審査を通過しています。メルカリ最初のiOSエンジニアのoobaさんに背景を伺ったところ、当時 App Store の審査に 2週間から1ヶ月かかることもあったため、rejectされないかの確認のためのサブミットを行った、とのことでした。 先程のv1.0.1の配信開始が2013年7/18、その後7/23にプレスリリースを出しています。2013年3月の最初のコミットから約4ヶ月の開発期間を経てのリリースでした。 ちなみに7.23のリリース初日はわずか2000ダウンロードでした。これが2013年末までの半年弱で100万ダウンロードを突破することになるので、すごいスピードだと思います。 2013年の技術トピックをまとめてみました。 このころは Objective-C かつ MVC でアプリケーションが書かれていました。なぜならSwiftは2014年発表だからですね。ちなみに iOS4~iOS7がサポートバージョンとなっていました。 メルカリの商品リストは CollectionView で実装されましたが、UICollectionViewは iOS6で登場したAPIだったため、それ以前のバージョンにはPSTCollectionViewというOSSが使われていました。昔からiOS開発をしている皆さんにはおなじみではないでしょうか。 また、AFNetworking, SVProgressHUD などお馴染みのライブラリに加え、まだ Apple に買収される前のTestflight SDK も利用されていました。 あとはスキューモフィズムデザインですね。本物の物質に寄せてディテールを細かく施すデザインですかね。メルカリiOSの最初のPull Request をチェックしてみたら、こんな感じで立体感のあるデザインになっていました。 この点もoobaさんに伺ったところ、iOS7が発表されて「やばい」となって急いでフラットデザイン対応をされたとのことでした。 また最初期はWebViewベースのガワアプリを検討していたようですが、結果的に体験を重視してネイティブアプリの開発に切り替えています。 WebViewベースで押し切った時にメルカリがサービスとしてどうなったのか知るすべはありませんが、とても気になりますね。 続いて2014年です。この年、初のTV CM放映がされました。2013年末までに100万ダウンロードを突破していましたが、さらに加速的にサービスが成長していきます。 先に2014年のiOS周辺技術の出来事を見てみましょう。 この年Swiftが発表されます。iOSアプリの開発に携わる皆さんにとってエポックメイキングな出来事だったと思います。メルカリも例外ではなく、この後数年、Swiftを軸に様々な技術的な取り組みが続くことになります。 またiPhone6, iPhone6 Plusが発売された年でした。フォームファクターが増えたことは大きな出来事ですが、@3x 画像の登場で画像アセットの更新が大変だったり、AutoLayoutに対応せず 3.5inch と 4inch 画面で分岐するようなコードを書いていた方には思い出深いできごとではないでしょうか。僕もたくさんの画面のAutoLayout対応を行った覚えがあります。 サービス、会社として2014年の大きな出来事はこちらです。なんと500万ダウンロードを突破します。またUSでのサービススタートなど2年目にしてかなりの打ち手がありました。 App Store のスクショはこんな感じになりました。 まずGit log を見てみましょう。 そこまで大きな変化はないですね。コミッター数としては増えていますが、ユニーク数ではないので実際にエンジニアが増えたどうかは分かりません。 このスクショはv3にメジャーアップされた後のものです。v3へのメジャーバージョンアップはデザインリニューアルを主な理由としています。 2014年7月にリリースされた v3系 は2019年にメルペイがリリースされるまで5年弱続くことになるとても長寿なバージョンとなりました。 引き続きObjective-Cが使われていました。2014年はSwiftが発表された年ですが、この年のコミットにSwiftのコードは入っていませんでした。 一方この年、メルカリでReactiveCocoaがライブラリとして取り入れられ、一部の画面がMVVMで実装され始めていました。また cocoapods が package manager として取り入れられていました。 また、USのサービス開始に伴い、日本とアメリカのサービスでソースコードが共有されるようになったことも大きな出来事です。コードは共有しながら、国ごとにターゲットを分けて別バイナリを配布するアプローチを取っていました。 以上が2014年です。サービスとしてはかなり伸びていましたが、まだまだ技術を見直すようなタイミングにはなっていません。 そして2015年です。 この年は commit log や チケットなどをたどるとフリマサービスとしての基礎体験の磨き込みに力を入れていた時期だったように思います。 iOS周辺技術としてはこんな感じです。 サービスとしては2015年1月に1000万ダウンロードを突破します。機能的には、「らくらくメルカリ便」という便利な配送方法の提供を開始した時期でもあります。 また、2015年後半には新規事業を手掛ける子会社ソウゾウが設立されました。 App Store のスクショはあまり変化がないですね Git log もそこまで大きな変化はありませんが、コミッターが増えています。 この頃からiOSの勉強会に行くと、メルカリで働いているという人を見かけるようになった覚えがあります。 技術トピックとしては、この年からSwiftが実戦投入され始めます。新しい画面や Extension がSwiftで実装され始めています。 Git logをたどると、機能開発ですごく忙しかったような印象を受けましたが、チケットのタイトルを見てもUXを向上させるような施策に集中して数多く実装していた時期だったようです。 また新規事業でフルSwift, RxSwiftでの開発が始まり、新しい技術の探索が始まったタイミングとも言えるのではないかと思います。 以上が2015年のできごとです。 このあと数年メルカリの規模に合わせた開発を模索していくことになるのですが、振り返ってみるとその礎がこの2015年あたりに築かれたような気がしています。 続いて2016年です。この年はUSへのフォーカスと、メルカリ初の新規事業がローンチした年でもあります。 そんな2016年はiOS10, iPhone 7 が発表されました。ジェットブラックありましたね。 そして、 第一回 iOSDCである iOSDC Japan 2016 が開催された年でもあります。ちなみに第一回は早稲田キャンパスではなく、練馬のココネリホールでの開催だったんですね。 僕も当時スタッフとして関わっていたのですが、「みんな来てくれるかな〜」「スポンサーさん集まるのかな〜」 「まぁでも、誰も来てくれなかったら会場費用自腹でもくもく会をやればいいだけだしね」と度々主催者の長谷川さんが言っていました。 ちなみにそんなiOSDCをメルカリは第一回はもちろん、かれこれもう8年連続でスポンサーとして応援しております! というわけで本題に戻ります。 2016年は匿名配送の提供開始、あとはアメリカのApp StoreでUS版メルカリがTop3にランクインするという出来事もありました。 それからメルカリアッテというクラシファイドサービスのリリースですね。こちらのサービスはすでにクローズしております。 スクショはこんな感じです。ちょっとだけ変わりました。 git log はこんな感じです。なお新規事業のリポジトリは含んでいません。 Diff がかなり多いのですが、ちょっとなぜこんなに多いのかまでは追いきれませんでした。 この年から、メルカリ本体でも新規事業でも リアクティブライブラリを使っての開発が行われるようになり、リアクティブライブラリの知見が社内に溜まっていくことになります。 メルカリ本体はObjective-CとSwiftの併用、新規事業がこのあと続々立ち上がっていくのですが、そちらはフルSwift + RxSwift での開発となっていきました。また Carthage がこの年導入されていました。 以上が2016年のできごとでした。 続いて2017年ですが、この年は新規事業がたくさん立ち上がります。 iOS周辺技術の出来事としては、iPhone Xが登場します。ノッチの登場ですね。 サービスとしてはAI出品機能、「ゆうゆうメルカリ便」が提供開始となり、さらにアプリは世界1億ダウンロードを突破します。 この年はニュースが多くて、USメルカリアプリが書き直されてリニューアルされます。 またイギリスでもサービスがスタート、他にも新サービス・新機能が続々とリリースされますがこれはすでにクローズされています。後半にはメルペイが設立され、数年後のスマホ決済サービスの準備がスタートします。 スクショはこんな感じです。あんまり変わらないですね。 Git log はというと、増えてはいますが、これもそこまで変わりません。 この年はUSアプリを書き直す・リライトする “Double” というプロジェクトがUSメルカリアプリで行われました。Swiftで書き直されたのですが、ネイティブのコードに加えて React Native も導入されていました。 また、2015年に立ち上がったメルカリ アッテの設計をベースとして、Swift/RxSwift/MVVMでいくつも姉妹アプリが立ち上がりました。 メルカリ本体のメルカリNow、メルカリチャンネルのような新機能もSwiftがメインで開発されるようになりました。 このころから少しずつ技術的な課題が出てきます。 Objective-C と Swift だったり、新しい画面と古い画面が混在するようになってきたため、コンテキストスイッチのコストが高くなってて少しずつ課題となってきていました。また、事業として重要なコンポーネントや画面のメンテナンスがかなり困難になってきていました。 エンジニアの人数も順調に増えていたので、複数人が同じ画面に改修を入れるケースも増えていき、結果コンフリクトが発生しやすくなり、他の人の作業に自分の作業がブロックされるようなことも増えていき、結果として開発の速度が上がりづらくなっていました。 そして2018年、ここから技術基盤を強化するプロジェクトがいくつも走っていくことになります。その最初のプロジェクトが Re-Architecture でした。 2018年のiOS周辺技術の主な出来事としては、iOS12, iPhone Xsの発売ですかね。 サービスとしてはシェアサイクルサービスであるメルチャリがリリースします。こちらは現在事業譲渡済みです。 また6月にマザーズ上場、メルカリロゴのリニューアル、2016年から2017年で立ち上げたサービスが2018年の間にいくつもクローズされました。 そして日本のメルカリチームでも海外からの採用が加速してきます。僕もこのころから仕事で英語を使う機会がかなり多くなりました。 Git log は激変しました。2017年は6000台だったコミット数が3倍強に増えています。 コミッター数も90を超えましたが、これはユニークではないため数十人いた、くらいに捉えていただければ良いと思います。 そしてロゴはこのようにリニューアルされました。2013年当初から続いていた箱が開くデザインから変更されました。このロゴは現在も使われています。 こちらは2018年7月ごろ、ロゴが変わる前のApp Storeのスクショです。ロゴが変わった後の2018年10月のものをみてみましょう。 ちょーーーっと変わりました。翌年に大きなサービスローンチを控えていたため、この段階で大幅なデザインのアップデートまでは行いませんでした。文字ロゴが変わっただけでそこまで大きな変化はないですね。 この年の大きな技術トピックは Re-architecture です。 方針としてはアプリのフル書き換えは選択せず、王道の少しずつ画面を書き換えていくアプローチをとりました。MicroViewController と読んでいたのですが、コンポーネントベースで同時並行での開発を可能にするアーキテクチャへのアップデートでした。 複雑な画面の書き換えを目的とし、テストや仕様書を充実させながらプロジェクト進行させていきました。 このときのアーキテクチャについては、 2018年にtarunonさんがiOSDCで発表を行っています 。ご興味のある方はぜひご参照ください。 Re-architecture のロールアウトプランについてもご紹介します。 書き換えを行う際、どのようにロールアウトしていくかは判断の難しい問題だと思います。 我々のアプローチは全く同じ画面を実装し、新旧でA/Bテストを行いながらKPIに劣後が出ないかを確認しながらロールアウトしていきました。 全く同じ画面だったので、細かすぎる微妙な仕様の差を知っていないと自分の端末にどちらが表示されているのか本当に分かりませんでした。 また、Feature Flag で新旧画面の比率を調整しながら徐々に公開していきクラッシュやエラーを監視しました。 クラッシュ等以外のビジネス指標は、BIチームとも連携してトラッキング、KPIに異常が出たらすぐにFeature Flagで旧画面に切り戻すという運用を行いました。 Re-architecture は全体として良い結果をもたらしました。主要画面の書き換えを完了できたことはもちろん、一部の画面では旧画面よりもパフォーマンスが向上し、事業KPIに良い影響を与えたことも分かりました。 赤いドットがRe-architecture 後の画面、青いドットが旧画面のある指標です。なにが良かったかは公開できませんが、パフォーマンス向上によってビジネス上の指標に良い影響があったとご理解いただければよいかと思います。 取り組みとしては結果的に1年を掛けてターゲットとしていたすべての画面の書き換えが完了することができました。また、テストも Re-architecture 前に比べてかなり充実しました。 特にロジックを含むようなコンポーネントは80%のカバレッジを持つようOKR(Objectives and Key Results)を設定して達成していきました。 残念な点としては、仕様書については継続的にアップデートが行われず、数年後に行われるリライトプロジェクトでも課題となりました。 エンジニア観点で一番大きな効果はスケーラブルな開発体制を構築できたことではないでしょうか。エンジニアの人数も増えたのですが、並行して開発ができるようになったこともあり、コミット数が前年比3倍に増えています。 Re-architectureで画面を書き換えていったことも大きいと思いますが、コードの追加・削除もかなり増えています。 GitHubのContributersのグラフを見ても、Re-architectureを前後でトレンドが大きく変わっていることが分かります。 また、この年から全員プロダクト開発を行うエンジニア、という体制に変化が訪れます。 横断的な改善の重要度が上がり、 iOS Coreチームが組成されます。 2023年現在、このCoreチームは iOS Architect チームとして継続しています。 この年はバックエンドでも大きな変化がありました。PHPのモノリスアプリケーションからマイクロサービスアーキテクチャへの移行を目指すMicroservice Migrationがスタートしています。バックエンドでgRPCが使われ始めたこともあり、クライアントでは Protocol Buffersが利用され始めました。 2018年は技術的な取り組みとしてはRe-architectureという大きな動きがありました。会社全体としても技術刷新に取り組む環境へと大きく変わった年でもありましたが、2019年も大きな変化が起こることになります。 それがメルペイというスマホ決済事業のスタートです。 メルペイは2019年2月にスタートしました。タイムラインとしては、2018年にはかなり本格的に開発が行われていていました。 iOS周辺技術においては、SwiftUIが発表され、これもエンジニアリングとして後に重要な出来事となります。 こちらがメルペイリリース時のApp Storeのスクリーンショットです。 これまでフリマアプリがメインでしたが、スマホ決済機能を強く打ち出しています。 2018年はRe-architectureが進行していましたが、メルペイはどのように開発を進めていたのでしょうか? メルカリ社内ではRe-architectureと同時進行で “Merpay Integration” というスマホ決済機能をメルカリのアプリに取り込むプロジェクトが2018年頃から進行していました。 Re-architecture への影響を考慮し、 Merpay 機能を SDK としてモジュール化して提供する手法を選択し、Re-architectureもメルペイの開発も止まらないようプロジェクトが進行されました。 またアプリ上のUIの大きな変化として、メルペイスタートと同時に、メルカリアプリは下タブUIへと変更されています。 2013年のリリース当初からハンバーガーメニューのUIが続いていましたが、メルペイリリースとともに現在も続く下タブへのアップデートが行われました。 Re-architeture後も開発はさらに加速してきました。 なお Merpay は別リポジトリで管理されていたので、メルカリグループ全体としてはさらに大きい数字になっていたと思います。 主要画面以外のRe-architectureも完了、さらに 下タブ化をともなう Merpay Integration が終了し、Re-architectureは約1年で一区切りとなりました。 Re-architecture によって大部分が書き換えられました。Re-architecture前の2018年と2019年末を比較すると、プロジェクト内の Swift 比率は約20%から約85%にまで高まりました。 Objective-Cは75%から15%に減少していますが、それでもObjective-Cはプロジェクト内に残っていました。 また、Re-architecture をベースとして Design System プロジェクトがスタートしました。 Design System を進めた理由としては、スケーラブルな開発の実現と一貫したデザインと体験の両立と、そのためのPM/Designer/SWEの共通言語の導入、の必要性があがっていったためです。 この年は、Re-architecture 済みの画面に対して Design System コンポーネントを全社で適用していきました。 以上が2019年のできごとでした。 2018年以降の流れとして、スケーラブルな開発の重要性が上がった、ということが上げられます。採用も日本だけではなく海外にも目を向け、より広い市場にアプローチしていくことになりました。 一方でスケールする開発を実現するためのアプリ開発基盤のアップデートが重視された期間であり、この流れはいまに至るまで続くことになります。 2020年。Re-architectureが一旦の終わりを迎え、Design System などアプリ開発基盤の強化に力を入れ始めたタイミングで、GroundUpというプロジェクトが始動します。これは何かというと、アプリをゼロから書き直すプロジェクト です。 2020年はiOS14, iPhone12等、あとはApple Silicon が発表された年です。 2020年7月 App Storeのスクショがこちらです。2019年に引き続きメルペイを前面に据えています。 引き続きかなりたくさんのコミットが行われていました。 2020年の技術トピックとしては、先程も触れた通りアプリ開発周辺基盤の強化が挙げられます。 2019年にスタートした Design System に続いて、 Test Automation強化、Weekly Release の検討開始、 Client Event Logging の刷新などがプロジェクト化され、投資が行われました。 これらを進める理由として、エンジニアを取り巻く環境が変わったことも挙げられます。 サービスとしてはシングルアプリですが、メルカリとメルペイは別の会社になっています。スマホ決済事業が導入されたことにより、結果として、メルカリ・メルペイのグループ会社をまたぐ活動が増えました。 両者で求められるガバナンスも異なるのですが、足並みをそろえ、機動力を維持しながら開発する体制が求められていました。 そのような動きもありますが、2020年はGroundUp App の始動が最も大きな出来事であったと言えるでしょう。 リーアキテクチャのようなリファクタリングを行うアプローチではなく、アプリをゼロから書き直し、”式年遷宮”を行う意思決定でした。 2019年に発表されたSwiftUIで書き直すことが方針として設定されました。 また、Re-architectureを選択しなかった理由として、今後数年、プラットフォームの提供する新機能に素早く対応していけるようにベースから書き直す判断をしました。 このプロジェクトは、プロダクト開発を行うチームから独立して開発がスタートしました。 また GroundUp では Bazel をビルドツールとして採用し、 Bazel のビルドキャッシュなどの強みを生かした Micro Modular Architecture を採用しています。 この Micro Modular Architecture については、 いまも iOS Lead Architect を務める Aoyama さんが iOSDC Japan 2020 で発表を行っている ので、興味があればそちらをご参照ください。 さて、git log をこの年から2種類見ていくことにしましょう。 これまで見てきた初代iOSリポジトリはレガシーリポジトリと呼んでみましょう。引き続きすごい数のコミットが行われています。 こちらは Ground Up リポジトリです。まだまだ産声を挙げたばかりのプロジェクトと言えそうですが、コミッターはそれなりにいたように見えます。 以上が2020年のできごとでした。 俯瞰してみると、Re-architecture が終わった後すぐに Rewrite プロジェクトが開始されており、とても決断が早かったように感じます。やはり、2019年に発表されたSwiftUIはメルカリのiOS開発においては大きな転換点だったと言えます。 さて、2021年は再チャレンジが行われた年と言えるかもしれません。 まず iOS周辺技術では、iOS15などが発表されました。 2021年7月のスクリーンショットはこちらです。フリマ機能が再度押し出されています。 レガシーリポジトリは少しコミット数が落ち着いてきます。前年3万近くあったコミットから1万6千にまで減少しています。 一方、GroundUpはコミット数こそあまり変化がありませんが、コミッター数が増えているように見えます。 この年からメルカリは アプリのリリース周期を2週に一回から毎週アップデートに頻度を上げる改善を行いました。 Delivery の頻度を増やすということが目的だったのですが、これを実現するためにはいろいろなものを整備する必要がありました。 約半年ほど掛けてプロセスやオペレーションのアップデート、QA期間短縮のための自動化などの準備を行い実現されました。 サービス的には事業者向けのメルカリShopsが立ち上がりました。 メルカリShopsは、クライアントアプリだけでなくバックエンドもフルスクラッチで開発しました。この機能はネイティブではなくWebViewでメルカリアプリ内に提供されています。 WebViewへのチャレンジは2013年にWebViewベースでの開発を諦めてからの再チャレンジとも言えるものでした。 メルカリUSでは、2017年のDouble以来、2度目の書き直しプロジェクトである Denali がスタートします。 以前の Double プロジェクトでは部分的に採用していた React Native をフルで使って書き直すプロジェクトです。プロジェクト名のDenaliは、北アメリカ大陸の最高峰の山の名前らしいです。 以上が2021年の出来事です。 振り返ってみると、USメルカリ、日本のメルカリ、メルカリShops という3つのプロジェクトがフルスクラッチで開発を行っていたことになります。 そして2022年はエンジニアリングとしても会社としてもGroundUpにフォーカスした年となりました。 この年はiOS16, iPhone14が発表されました。PassKey についてもこの年WWDCで発表が行われました。 2022年は メルカリIndia が設立されたり、メルカードの提供を開始したりと、組織、サービスとしてもさらなる広がりを持った年になりました。 そしてメルカリアプリのリプレースの完了です。 こちらが GroundUp リリース前の 最後のv4系、4.106.0 のスクリーンショットです。GroundUp でリプレイスされた v5系を見てみましょう。 はい、何も変わってません。でも裏側は全部変わっているんですね。 Git log チェックしましょう。 2022年途中でレガシーリポジトリにはコードフリーズが入ったため、コミット数が16,000から十分の1以下に減っています。 Ground Up は逆に約2000から倍以上に増えています。 GroundUpのリリースについてご紹介します。 先程説明した通り、Legacy リポジトリにコードフリーズを実施しました。これまで Legacy で機能開発に取り組んでいたエンジニアも全員が GroundUp の開発に移りリリースを目指しました。会社としては GroundUp を前提に取り組んでいたサービスもあったため、全社を挙げての取り組みとなりました。 ロールアウトプランですが、Re-architecture のときのように画面ごとにロールアウトしていくという戦略は取れません。 4月からTestflight で外部テスターを募り、βテストを実施し、ここでバグリポートを集め修正を行っていきました。 その後、 7月にApp Store で実際にGroundUpアプリをリリースするフェーズに移ります。ここでは Weekly Release は維持しつつ、v4系のレガシーアプリのストアリリースを停止、v5系のGroundUpを実際にストアにリリースしていきます。 この際、段階リリースを行い 1%, 2% など小さいパーセンテージでリリースを停止し、バージョン浸透率をコントロールしながら徐々にロールアウトを実行していきました。もちろん、この段階ではKPIの監視も行いました。 これを1ヶ月ほど続け、9.20に v5系GroundUpアプリを100%公開し、2020年にスタートしたリライトプロジェクトであるGroundUpが2年をかけて完了しました。 このリライトプロジェクトにより、Objective-Cはメルカリのアプリから完全になくなりました さらにこれまで別リポジトリで管理されていた Merpay SDK などを、 GroundUpリポジトリに統合する モノレポ化が実施されました。 また、USアプリの React Native への書き換えも4月に完了しています。 GroundUpプロジェクトについては、 CTO や Lead Architect のインタビュー記事が出ていますので、興味があればご参照ください。 以上が2022年のできごとでした。 日本とUSどちらもリライトプロジェクトが完了したという年で、モバイルアプリに関わるチームにとってはハードな1年となりました。 しかし、書き換えて終わりというわけではありません。 そして、2023年、メルカリは10周年を迎えました。 今年はiOS17が発表されましたね。 そして Apple Vision Pro, visionOSも発表されました。 メルカリはすでに次の10年に向けて動き出しています。 ビットコインが買えるようになりました。 また、パスキーの対応も開始しています。ChatGPTプラグインの提供も開始などなど、 Go Bold にチャレンジを続けていきます。 そして、7月にアプリローンチ10年を迎えました。GroundUp が終わったあと、レガシーリポジトリはどうなったのか見てみましょう。こちらです。 はい、全て0です。 GroundUp でのコードフリーズ以降、レガシーアプリでの開発がストップしたため、2023年には誰もコミットを行っていません。 レガシーアプリであるv4系アプリはサポートが続いていたため、メンテナンスのためにリポジトリは残されていました。 しかし、2023年に入り、v4系アプリのサポートを切る強制アップデートが実施されました。これにより、 レガシーiOSリポジトリは役目を終え、アーカイブされることになりました。 2013年3月の Initial Commit から10年を経てその歴史に幕を降ろしました。 10年間の歴史を振り返ってみました。 通算コミッターはボットや重複を含みますが213、通算コミット数152,456、通算Pull Request 35,969 という数字でした。 GroundUpはこの様になっています。7月時点での数字です。 ※ 元気に開発が続けられていますね。 ※GroundUpリポジトリ は Squash and merge で運用しているため、Squash and merge を使っていなかったレガシーリポジトリよりもコミット数が少なくなっています。 7月時点のApp Store上のスクリーンショットはこの様になっています。 2013年と比べてみましょう。 デザインを見てもかなり歴史、月日の流れを感じますね。 2023年、いまiOS開発として力を入れていることをご紹介します。 Architecture v2という、すでに新しいアーキテクチャに取り組み始めています。GroundUpという取り組みが終わってすぐのように見えますが、いまの設計自体は3年前の2020年に考えられたものなんですね。 これまでの歴史を振り返ってみると3年という月日は決して早すぎるわけではないとも思っています。 それから2022年にWWDCで共有されたPassKeysも重要な取り組みの一つ。 すでにメルカリのプロダクションで導入が開始 されていますが、シームレスな認証を提供していきたいと考えていて、これからサポートを増やしていきたいと考えています。 アプリの Observability の強化 にも取り組んでいます。これは DataDog Real User Monitoring (DataDog RUM) を使い、エラーやクラッシュはもちろん、API Latency 含めてe2eの読み込み速度の計測などを行う取り組みです。 またリリースサイクルについても、週一回をキープしているものの、人の手で解決していることが多い状況で、 改善 に取り組んでいます。 まだ2023年は終わっていませんが、7月までの動きを振り返りました。 かなり長かったですが、以上が10年間の振り返りです。 最後にまとめていきたいと思います。 10年振り返ってみて感じたことは、変化は徐々に起こることも、突然表れることもある、ということです。技術の変化はもちろん、プロダクトやビジネス、そして組織の変化もあります。 エンジニアはどのようにこれらの変化に適応していけば良いのでしょうか?ということについて考えてみました。 まずは技術変化への適応ですが、幸い、iOSアプリ開発では、一定のリズムがあります。だいたい2-3年を掛けて新しいスタンダードへの適応が行われていきます。 メルカリの場合、2014年にSwift が発表されてから2年後の2016年にフルSwift アプリが登場しています。 また、2019年にSwiftUI 発表されてから3年後の2022年にアプリが SwiftUIへ書き換えられています。 プロダクト、ビジネス、組織による環境の変化への適応はどうでしょうか。 ここに関しては会社や組織によって課題感が異なると思いますが、メルカリではご覧のような取り組みが行われてきました。 Re-design, re-architecture, re-write, さらに横断的な取り組みを行うチームの設立や、周辺基盤の強化が環境の変化に適応するための取り組みでした。 今回あらためて振り返ってみて、メルカリは変化に対してかなりプロアクティブに対応してきたことを再認識しました。 ただ、振り返ってみると当たり前のように感じるターニングポイントも、当時はそこまで確信を持って意思決定が行われたわけではなかったと感じています。 Q. Re-architecture が終わって間もなくゼロから書き直す判断ができるか?すべきか? Q. いま Cross Platform や WebView を選択すべきか? もしかしたらプロダクトにいま携わっている方は、いままさにこのような問いにさらされているかもしれません。 完璧な答えはないものの、それでもエンジニアとして最善と思える答えを僕らは一つに絞って出さなければならない。きっと迷うこともあると思います。 そんなとき、「メルカリはあんなことやっていたな」「この課題にはこうやってアプローチしたのか」「ちょっと参考にしてみるか」と言う感じでですね、この10年の振り返りが少しでもみなさんの力になれば嬉しいと思っています。 はい、ということで以上になります。 今回スライドの中で紹介できなかった取り組みもたくさんあります。今回紹介した取り組みも全てが大成功だったわけではありません。 たくさんの失敗もありましたが、そういった失敗を糧にこれまでメルカリは取り組んできています。 これからもメルカリは Go Bold, All for One, Be a Pro を掲げながら チャレンジを続けていきます! もしこのトークを聞いてメルカリに興味持っていただけたら、ぜひお気軽にお声がけください。 以上となります。それではご清聴ありがとうございました! おわりに 以上、「メルカリ10年間のiOS開発の歩み」でした。 プレゼンテーションは40分と比較的長いトーク時間のように感じますが、10年の歴史をまとめるには40分は非常に短く、初期段階のトーク時間は60分を超えてしまっていました。余計な内容を削り規定の時間内で終わらせるべくトークスクリプトを準備し、本番ではきっちり40分でトークを終わらせることができました。 文章以外でのフォーマットで参照したい方は、当日発表を行った際のスライドと動画を こちら から参照することができます。 2023年はありがとうございました。2024年もよろしくお願いいたします!
メルペイSREの @myoshida です。この記事は、 Merpay Advent Calendar 2023 の21日目の記事です。 メルカリグループではGoogle Cloud Platform(GCP) を広く利用しており、一般的にはGCPを利用したシステム構築が推奨されています。しかし、他のプラットフォームを利用した方が要件を実現しやすかったり、よりスマートに構築できる場合はAmazon Web Services(AWS)なども利用することあります。 今回は AWS Transfer Family を利用してSFTPでファイルを送受信する環境を構築した件について簡単にお伝えできればと思います。 SFTPでのファイル送受信について SFTP(SSH File Transfer Protocol)は、その名の通り、SSHを利用してファイル転送を行います。SSHを利用して暗号化通信が行えるため、FTPと比べて安全に利用できます。 ログインには、SSHで使用する鍵をそのまま認証に利用できます。鍵認証でログインできるため、パスワードは不要です。 一方でFTP(File Transfer Protocol)は、IDとパスワードでログインします。また、暗号化がサポートされていないため、セキュリティ面で問題があり、利用は推奨されません。 SFTPは昔から存在する枯れた方式だと思いますが、業務の現場では今も根強く採用されています。日次のバッチで処理して作られたCSVを、連携先の外部企業に渡すといった場面で利用されたりします。 AWS Transfer Family での SFTP環境構築 AWS Transfer Family を利用したシステム構成は以下のようになります。 構成図 SFTPサーバーに該当する Transfer Server を用意し、利用するサブネットの数だけEIPを払い出し、Transfer Serverに紐づけます。それによりTransfer Serverに専用のエンドポイントが割り当てられ、ユーザーはそれを指定してSFTPクライアントで接続できます。 エンドポイントが割り当てられた様子 SFTPユーザーはTranfer Serverに紐づいており、ユーザーごとに公開鍵を複数持つことができます。IAMユーザーを作成する必要はありません。 ストレージはS3バケットを利用します。1つのS3バケットにユーザーごとのホームディレクトリを定義して共用することも可能ですし、ユーザーごとにS3バケットを用意して、ログインするユーザーごとに専用のS3バケットに接続させることも可能です。今回は後者を採用しました。 構築にはTerraformを利用します。locals を利用してユーザー名を変数とすることで、S3バケット・SFTPユーザー・SFTPユーザーが利用するIAMロールなどをまとめて作成することが可能です。 localsの定義例 sftp_name = "merpay-foo-bar" sftp_users = { test-user-1 = { ssh_keys = [ "ssh-rsa dummy", ] } test-user-2 = { ssh_keys = [ "ssh-rsa dummy", ] } } sftp_user_keys = flatten([ for user, attrs in local.sftp_users : [ for ssh_key in attrs["ssh_keys"] : { user = user ssh_key = ssh_key } ] ]) } ログインに利用する公開鍵は、上記terraform内の ssh-keys にリストで列挙することでterraform経由でSFTPユーザーに保持させることも可能ですが、今回はユーザー作成後にAWSにログインして、手動で登録することにしました。 S3バケットの定義例 resource "aws_s3_bucket" "sftp_bucket" { for_each = local.sftp_users bucket = "${local.sftp_name}-${each.key}" versioning { enabled = true } logging { target_bucket = aws_s3_bucket.sftp-bucket-log[each.key].id target_prefix = "log/" } tags = { } } IAMポリシーの定義例 resource "aws_iam_policy" "s3_read_write" { for_each = local.sftp_users name = "s3_rw_merpay-sftp-${each.key}" path = "/system/" description = "for enabling file tansfer to buckets" policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:ListBucket", "s3:GetBucketLocation" ], "Resource": "arn:aws:s3:::${local.sftp_name}-${each.key}" }, { "Effect": "Allow", "Action": [ "s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:GetObjectAcl", "s3:PutObjectAcl", "s3:GetObjectVersion", "s3:DeleteObjectVersion" ], "Resource": "arn:aws:s3:::${local.sftp_name}-${each.key}/*" } ] } EOF } IAMロールの定義例 resource "aws_iam_role" "sftp_user" { for_each = local.sftp_users name = "transfer-server-user-role-${each.key}" assume_role_policy = <<-EOF { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "transfer.amazonaws.com" }, "Action": "sts:AssumeRole" } ] } EOF } resource "aws_iam_role" "transfer_server_to_cloudwatch" { name = "transfer-server-to-cloudwatch-role" assume_role_policy = <<-EOF { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "transfer.amazonaws.com" }, "Action": "sts:AssumeRole" } ] } EOF } IAMロールのポリシーアタッチメントの定義例 resource "aws_iam_role_policy_attachment" "s3_bucket_read_write" { for_each = local.sftp_users role = aws_iam_role.sftp_user[each.key].name policy_arn = aws_iam_policy.s3_read_write[each.key].arn } Transfer Serverの定義例 "aws_transfer_server" は endpoint_type を “VPC” にし、endpoint_details ブロック内でEIPを割り当てることで、マネージドなドメインが生成されます。 resource "aws_transfer_server" "sftp" { identity_provider_type = "SERVICE_MANAGED" endpoint_type = "VPC" logging_role = aws_iam_role.transfer_server_to_cloudwatch.arn endpoint_details { address_allocation_ids = [for eip in aws_eip.sftp : eip.id] subnet_ids = aws_subnet.sftp_subnet[*].id vpc_id = aws_vpc.sftp.id } tags = { Name = local.sftp_name } lifecycle { ignore_changes = all } } SFTP Userの定義例 SFTPユーザーのホームディレクトリは、"aws_transfer_user" 内の home_directory で、S3バケットのルートを指定しました。localsを参照してユーザーごとに作られるS3バケットをそのまま指定しているので、ユーザーごとに別のS3バケットを利用できるようになります。 resource "aws_transfer_user" "sftp_user" { for_each = local.sftp_users server_id = aws_transfer_server.sftp.id user_name = each.key role = aws_iam_role.sftp_user[each.key].arn home_directory = "/${aws_s3_bucket.sftp_bucket[each.key].id}/" } 環境を構築してみて感じた利点 SFTPサーバの環境を作るにあたって、AWS Transfer FamilyとTerraformで利用することで、以下のようなメリットがあると感じました。 手動管理の量が少ない マネージドな環境ですので、一度構築してしまえば、かなりメンテナンスフリーな感じで利用することができます。EC2などのサーバインスタンスを用意することもないため、管理がラクです。 アカウント追加・削除の作業もTerraformを更新することで行なうので、GitHubのPull Requestを通じてチーム内で確認を取りながら進められて安全です。 S3にはライフサイクルを指定しているため、古いファイルを削除するといった作業も発生しません。 横展開がしやすい これは単純にTerraformの利点なのですが、.tfファイルにほぼすべての構築内容が定義されているため、類似の案件が発生した場合に流用しやすいです。 他のシステムとのつなぎ込みがしやすい ファイルはS3に保存されるため、AWSのAPIを利用してファイルを取得したりすることで、業務の後続処理もスムーズに行わせることができます。 おわりに 今回はメルカリグループでは利用例が少ないAWSを利用したSFTP環境の構築について説明しました。既存のSFTP環境のリプレイスなどのお役に立てば幸いです。 Google Cloud Platformでも同様のサービスが登場してほしいなと思います。 明日の記事は @orfeonさんです。引き続きMerpay Advent Calendar 2023をお楽しみください。