TECH PLAY

キャディ株式会社

キャディ株式会社 の技術ブログ

61

この記事は CADDi Tech/Product Advent Calendar 2025 14日目の記事です。 Executive Summary 生成 AI アプリで評価プロセス改善 PoC をした 評価制度をアセット化し、生成 AI ツールを組み合わせることによって、評価プロセスを支援した 「メンバーの思考の整理」「メンバーからマネージャーへのコミュニケーションの改善」というポジティブな効果が得られた (画像は実際のシステム、入力されているテキストは架空の人物・チーム・業務) はじめに キャディのエンジニアリングマネージャーの橋本です。 突然ですが、この記事を読んでくださっている皆さんにひとつ聞きたいことがあります。あなたの会社にある評価プロセス、たとえば目標設定、月次振り返り、期末の自己評価、評価フィードバックなどは、あなたの仕事にとってポジティブに働いているでしょうか? 評価プロセスと業務はつながっていないこともある この質問に対する回答は、人によってグラデーションが出やすいものだと思います。「もちろん Yes さ、目標を設定することで集中できるし、フィードバックによって最高の成長機会を得られているよ!」という人もいれば、「うーん、どちらかといえば No かな。評価って時間を取られるけど、あんまり業務に対してポジティブに働いている実感はないんだよね」という人もいると思います。 私自身、エンジニアリングマネージャーになる前は典型的な後者のタイプで、正直に白状すると「評価って面倒だな」と思いながら、会社の評価プロセスに従って毎期の評価を受けていました。 そんな中、ここ1~2年は生成 AI の登場によって、エンジニアリングにおける設計やコーディング、サービス運用における働き方は大きく変化してきました。 今や生成 AI は設計の頼もしい相棒ですし、コーディングに関しては業務のあり方そのものが変わりつつあります。サービスの運用においても、生成 AI のポテンシャルはすでに広く知られているところであります。 生成 AI がエンジニアリングを変える、マネジメントは? エンジニアリングマネージャーになった私にとっては、生成 AI のマネジメント領域への適用については非常に興味深いものでした。特に評価の領域については、前述のとおり、エンジニアリングマネージャーに就任する前からぼんやりとした課題感を持っており、取り組んでみるのにちょうどよい題材だと考えました。 この記事では、エンジニアリングマネージャーというエンジニアでありかつマネージャーである私が、マネジメントの課題を生成 AI とエンジニアリングの力で解消しようとした、そんな試みを紹介します。 課題領域 どうして評価プロセスがしばしばエンジニアにとって自分の仕事を支援してくれるものと感じられないのでしょうか? 評価プロセスはエンジニアリングより手触り感が低い 要因はいくつかあるとは思いますが、ひとつの大きな仮説として、エンジニアリングで取り扱う題材にくらべて評価プロセスの “手触り感” が薄い、ということを仮説として立てました。 すなわち、エンジニアリングの業務は実行される行為を思い浮かべることが容易であるのに対し、評価プロセスにおける記述は実際の行動を想像することが難しいという特徴があり、エンジニアリングの業務と結びつけられていないのではないか、という仮説です。 実際、上の画像にキャディの評価基準からの抜粋と、実際の業務でありがちなタスクを並べてみましたが、実際の業務タスクと比べ、評価基準が抽象的で手触り感がないことが確認できると思います。 では、なぜ評価プロセスはこんなにも手触り感がないのでしょうか?私はその原因を、会社の外と中のそれぞれに分けて考えてみました。 評価制度の手触り感を一般知識と会社特有の観点に分解 まず、会社の外側にある理由として、「評価制度」に関する一般知識が説明から省かれがちであるこということが挙げられます。 たとえば、キャディ Tech では、「5軸に沿ったコンピテンシー評価」を導入しています。評価制度のプロであれば、この説明文からこの評価制度がどういう設計なのか、すなわち コンピテンシー評価とは何か 他の評価手法、例えば成果評価とは何が違うのか どのような課題を解決する評価制度なのか メンバー、マネージャーがそれぞれ注意するべきことがなにか ということが想像できることでしょう。(まるでソフトウェアエンジニアがマイクロサービスアーキテクチャについて生き生きと語るときのように!) しかし、実際にはエンジニアは評価制度のプロではないので、これらの知識を必ずしも有しているわけではありません。 また、会社の内側にある理由として、評価制度はその会社固有の価値観や信念を取り扱うものであるから、というものがあります。評価制度は、その会社にどのような人が集まるか、どのような行動が促進されるかに強い影響力を持ちます。そのため、より良い評価制度を設計すればするほど、評価制度にはその会社の個性が出やすいという特徴があります。別の言い方をすれば、同じ「ソフトウェアエンジニア」という職種であっても、ある会社と別の会社では評価体系が全然違うということすらありえるのです。 結果的に、評価制度はしばしばハイコンテキストなものになり、過去の経験による知識による解釈を難しくします。その会社に新しく入社したメンバーにとって解釈することが難しいのはもちろん、ずっとその会社にいたメンバーにとっても、事業の特性やフェーズが変わることによって評価制度はしばしば影響を受けるため、正しく評価制度を理解しつづけることは難しいという特徴があります。 解決領域 既存のアプローチ メンバーとマネージャーの間の対話によって知識差分を埋めてきた 課題領域に対する従来のアプローチでは、業務を深く理解しているマネージャーが、メンバー一人ひとりに合わせたコーチングを通じて会社全体に理解と納得を作り上げていくというアプローチが取られてきました。 この方法は、うまくいくケースがある一方で、以下のような課題があります 効率性の課題:マネージャーがメンバーのことをよく知る必要があり、メンバー・マネジメント双方のリソースを使う 実効性の課題:マネージャーに高い対人スキルと業務理解を要求するため、実効性にばらつきがある 従来のマネジメントは効率性・実効性に課題があることも ちょうどこの課題は冒頭に紹介した、 評価って時間を取られる → 効率性が低い あんまり業務に対してポジティブに働いている実感はない → 実効性が低い という実感とも繋がってきます。 生成 AI を統合したアプローチ - 概要 マネジメントの代わりに生成 AI と社内資料の整備を組み合わせる 既存のソリューションの課題に対し、私は生成 AI を活用することで「一人ひとりの状態に合わせたコーチング」を実現し、評価制度に関する一般知識、およびメンバーごとの理解度の差分を埋められるのではないかと考えました。 ただし、生成 AI では会社固有の知識を取り入れることができないため、合わせて生成 AI がアクセス可能なアセットを作ることを実施しました。 「生成 AI がアクセス可能なアセット」とは 生成 AI がアクセス可能なアセットとはなんでしょうか?これはただの社内ドキュメントとは違うのでしょうか? 生成 AI の活用を考える際に、最も重要な制約のひとつがコンテキストウィンドウです。LLM(Large Language Model; 生成 AI の基盤となる機構)には、特定の長さの文章までしか文脈を正しく捉えられないという課題があります [1]。コンテキストウィンドウとはあるモデルが捉えられる経験上の文脈の長さを示しており、例えば ChatGPT 4o では約13万トークンまでは妥当に取り扱えるとされています [2]。コンテキストウィンドウの制約は、LLM への入力を無制限に大きくできるわけではないということを示唆しています。 コンテキストウィンドウの制約を回避する方法は今までに多く研究されており、その代表的なもののひとつが AI エージェントです [3]。AI エージェントはデータ取得と推論を交互に繰り返すことによって、必要な情報を必要な分だけ取得し、必要な分だけ覚えておくことを実現し、アセットを効果的に利用することができます。 必要な情報だけを取得することで限られたコンテキストウィンドウを活用 AI エージェントの選定 では AI エージェントにとって優しいアセットはどのようなものでしょうか? 実際に PoC (proof of concept; 不確実性が高い部分を切り出してクイックに検証をすること)アプリケーションを作る際には、まず利用する AI エージェントを選定し、その AI エージェントにとって優しいアセットを作ることにしました。 今回は AI エージェントとして、すでに開発業務で使っていた Cline を使用することにしました。Cline はテキストエディタである VSCode の拡張機能で、通常コーディングの AI 支援として使われます。 今回 Cline を AI エージェントとして採用したのは、今回の取り組みがうまくいくかどうか不確実性が高く、クイックにユーザーを巻き込んだ検証を行うため すでに活発に開発・検証されたコンテキストエンジニアリング技術を利用する ユーザーとの対話インターフェースをすでに提供してくれているアプリケーションを利用する ユーザーもエンジニアなので開発業務で使っていた Cline を活用することに障壁がない という観点で利点が大きいと考えたためです。 評価制度アセットの構築 Cline は標準でディレクトリ内部のテキストファイルを解析し、検索してくれます [4]。既存の社内のアセットは Confluence 上に記載されたドキュメント Google Docs 上に記載されたドキュメント Google Spreadsheet 上に記載されたドキュメント があったため、これらをすべてマークダウンに変換し、Git レポジトリとして管理をすることにしました。Confluence、および Google Spreadsheet 上のドキュメントは Google Docs に貼り付けることができ、Google Docs はそのままマークダウンに変換できるため、マークダウンへの変換は容易に行うことができました また、元ファイルに対してコピー操作を行うため、新たに作成されたアセットについては、 インポート日 インポート作業者 元資料の URL を YAML フロントマターを利用して付与しました。この操作によって、アプリケーションの利用者がアセットが古くなっていた場合に自動的に最新の情報を確認し、必要に応じて更新できるようにしました。 分散した社内資料を Git repo に集約し管理する 具体的には以下のような YAML フロントマターがすべての資料の冒頭に書き込まれています。 --- title: "評価軸定義" import_date: "2025-06-24" source_url: "https://docs.google.com/spreadsheets/d/..." imported_by: "システム管理者" version: "1.0" --- # 評価軸定義 ... また、より AI エージェントが関連するドキュメントを探索できるように、アセットの追加時には関連資料を末尾に追加するようにしました。 AI エージェントが資料を連鎖的に辿れるようにアセットを作る 例えば評価制度の FAQ アセットについては、以下のように評価制度の概要や、評価軸の定義に対するドキュメントへのリンクを記載しました。 --- title: "評価制度FAQ(よくある質問と回答)" import_date: "2025-06-24" source_url: "https://docs.google.com/spreadsheets/d/..." imported_by: "システム管理者" version: "1.0" --- # 評価制度FAQ(よくある質問と回答) ... ## 関連資料 - [HELIX制度概要](helix-system-overview-2025.md) - [Career Track別の期待活躍イメージ](career-track-expectations-2025.md) - [評価軸定義](evaluation-axis-definitions-2025.md) - [Track別・Grade別期待活躍レベル一覧](track-grade-expectations-matrix-2025.md) - [Helix目標設定ガイド](helix-goal-setting-guide-2025.md) アセットの構築上の工夫 上記の YAML フロントマターの設定や、メタデータの更新を人力で行うと必ず抜け漏れが発生します。 特に、今回はアセットを作成したり、管理したりするために、エンジニアリングマネージャーである私だけでなく、Tech HR(Human Resource; 人事のこと)にも協力してもらいました。 そこで今回アセットを構築する際には、コミット前に AI エージェントにレビューを行わせ、自動で付け加えられる場合には AI エージェント自ら追記し、そうでないメタデータについては修正を要求するような工夫を行いました。 すなわち、AI エージェントを利用者だけが使うのではなく、アセット開発者も活用できるようにすることで、アセットの品質を保つようにしました。 AI エージェントに対する指示 AI エージェントをさらに有効に働かせるために、初期プロンプトを自動で与える Cline rules [5] も活用しました。 Cline では初期プロンプトをカスタマイズでき、またその組み合わせも変えることができます。 今回はアセットを作る人と使う人、それぞれで注目するべきポイントが違うため、Cline rules を複数作成し、Cline rules の切り替え機能を活用することで、アセットを作る人も使う人も使いやすい環境を整えました。 使う人のペルソナに応じたプロンプトを事前に用意 特にメンバー向けの Cline rules には以下のような指示を記載したことによって、AI エージェントに期待していることを明示し、また AI エージェントが回答できる限度を超えている場合には、適切にエスカレーションされるように配慮しました。 ### 評価アシスタントとしての心構え - 中立性の維持: 特定の評価結果に偏らない客観的なアドバイス - 継続的な学習: 評価制度の変更や更新に対応 - 実用性の重視: 理論だけでなく実践的なアドバイスを提供 ## サポートが必要な場合 - 評価制度の詳細: 人事担当者に確認を依頼 - 複雑な目標設定: 上司やメンターとの相談を推奨 結果 実際の PoC アプリケーションの挙動 以下は私が架空のエンジニアを想定し、自己評価の支援を依頼したときの実際のシステムの挙動です。 ユーザーが「自己評価の支援をして」と入力する AI エージェントがエントリーポイントとなるドキュメントを読み込む AI エージェントが関連するドキュメントを辿ってさらに読み込む AI エージェントがユーザーを対話的にコーチングしながら実務に連動した評価プロセスを提供する どのような支援が必要かをヒアリング 評価対象期間を特定する 役職・職位を確認する ユーザーの現状に合わせて対話を続ける。この後に複数の自由記述を含むステップが入る 最終的に自己評価として以下のような markdown が出力される 評価期間: 2025年4月~2025年9月 グレード: xxx 所属: Tech本部 xxx Team --- ## エグゼクティブサマリー FY25Q1-Q2期間においては、検索領域における機能拡充として、xxx の開発をリードしました。要件定義から設計、実装、デリバリーまでの全フェーズを通じて、複数チーム(xxx Team、xxx Team、xxx Team)を横断的に率い、技術的な不確実性を段階的に解消しながらプロジェクトを成功に導きました。 ... --- ## 評価軸別の実績と自己評価 ### 1. Expertise(技術力・専門性) #### 実績 本プロジェクトでは、以下の技術的な課題に対して高度な専門性を発揮しました: 技術的課題の特定と解決 - 既存の検索インデックス(Elasticsearch)のアーキテクチャを分析し、複数データソースを統合した新しいインデックス設計を主導 - ... ... #### 自己評価(ノッチ: x2) MG4 の評価基準である「大きなチームや他チームも関係するプロダクトに対して、全体的な構想を描き、設計することができる」 、... の要件を満たしていると考えます。 一方で、 ... については今後の課題と認識しています。 --- ### 2. Delivery(価値創造・提供) ... --- ## 総合評価と今後の成長課題 ### 強みとして発揮できた点 1. 技術的専門性とリーダーシップの両立: 検索技術の専門性を活かしながら、複数チームをリードする役割を果たせました ... ### さらに伸ばせる可能性がある点 1. 定量的な評価指標の活用: プロセス改善において、より定量的な指標(DORA metrics等)を活用した継続的改善サイクルの確立 ... ### 次期(FY25Q3-Q4)に向けた改善アクション 1. 本部戦略への貢献: 本部全体の技術戦略策定プロセスに積極的に参加し、検索領域以外の知見も獲得 ... --- ## 証跡・参考資料 - プロジェクト計画書: [Confluence](https://example.com) ... メンバーからの定性的なフィードバック 今回、実験的に半期の評価サイクルにあわせて、実際の評価プロセスで PoC アプリケーションを利用可能にしユーザーのフィードバックを収集しました。 対象はエンジニアリング組織全体で、利用は任意とする形式で運用しました。最終的に、全体の約3割のメンバーが実際に利用しました。 利用したメンバーから、ポジティブなフィードバックとして以下のようなものがありました 自己評価を書くときの初動ハードルが下がった 自分では気づいていなかった観点(協働や影響範囲など)を指摘してくれるのがありがたい 全体的に、文章の整形よりも思考の整理についてポジティブなフィードバックが多かったことが特徴的でした。AI が自分の行動を、評価の考え方に沿って構造化してくれるため、「何を書けばいいか」が明確になり、記述の負荷が軽減されたという声が多くありました。 一方で、以下のようなフィードバックも多く見られました。 AIが出してくれた文章をたたき台として修正する形がちょうどいい 実際、生成結果をそのまま使う人はほとんどおらず、多くの利用者が「AI の出力を土台にして、自分の言葉にリライトする」スタイルをとっていました。これは、生成 AI が解決できる課題として、文書化そのものよりも、知識へのアクセスを補助する部分が大きいという当初の仮説を支持する結果だと考えられます。 マネージャーからのフィードバック また、メンバーが PoC アプリケーションを利用したマネージャーからもフィードバックを得ることができました。 全体的にポジティブな内容が多く、 各評価軸に沿った構造的な書き方をされていて読みやすかった 行動と成果のつながりが明確でわかりやすかった トーンや文体が統一されており、比較しやすかった というフィードバックが得られました。これまでは文章表現の違いによって、評価プロセスのアウトプットの解釈が割れることがありましたが、一定のフォーマットでアウトプットが整理されることで、よりコンテンツの議論に集中できるようになったものだと考えられます。 今後の課題 PoC アプリケーションの実験的な導入によって見えてきた課題もありました。 利用者は全体の約3割にとどまり、まだ十分に浸透していない 評価制度そのものの記述や記載が曖昧な場合、AI エージェントの出力もぶれる つまり、AI が整理してくれるのは「構造化」と「言語化」の部分であって、評価の根幹にある制度設計やマネジメントの解像度については、アセットの継続的な改善を要するということがわかりました。 また、浸透課題については、体験の改善が必要だと考えられます。現状ではエディタと拡張機能のインストール、さらには Git レポジトリからのアセットダウンロードをすべてメンバー自身が実行する必要があるため、利用開始のハードルが高いという課題が見られました。PoC によって利用をすることによって得られるベネフィットが明らかになったため、踏み込んで社内サーバにホスティングし、環境構築を不要にしていき、より多くのメンバーが活用できる環境を作っていこうと考えています。 まとめ 今回の記事では、私たちが取り組んだ評価 x 生成 AI PoC、すなわち評価制度を AI が利用可能なアセットとして整備し、それらを適切に連携させることが、評価プロセスの効率化と質的向上に効果的であったことをご紹介しました。 実は、今回ご紹介した、AI の力で「ハイコンテキストな情報を構造化し、活用する」アプローチは、キャディの製造業領域におけるビジネスやプロダクトにおいても核となる考え方です。 AI と情報資産の連携は、まだまだこれからどんどん発展していく領域です。もし今回のブログでキャディってどんな会社なんだろう?と興味を持っていただいた方は、ぜひカジュアル面談で一緒にお話ししましょう! https://open.talentio.com/r/1/c/caddi-jp-recruit/pages/78398 参考文献 [1] Hahn, M. (2020). Theoretical Limitations of Self-Attention in neural sequence models. Transactions of the Association for Computational Linguistics, 8, 156–171. [2] OpenAI Platform . Available at: https://platform.openai.com/docs/models/gpt-4o (Accessed: 09 December 2025). [3] Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K., & Cao, Y. (2022). ReAct: Synergizing Reasoning and Acting in Language Models. arXiv preprint arXiv:2210.03629 [4] Cline-tool guide. Available at: https://docs.cline.bot/exploring-clines-tools/cline-tools-guide (Accessed: 09 December 2025). [5] Cline Rules. Available at: https://docs.cline.bot/features/cline-rules (Accessed: 09 December 2025).
アバター
こんにちわ、Core Infrastructure チームの前多です。膝が痛い。 こちらは キャディ株式会社のアドベントカレンダー の3日目の記事です。 先日、弊社の同僚からCADDiのアーキテクチャと開発組織に変遷に関する発表が行われました。 14:55〜E会場 キャディ株式会社/CADDiの発表資料 「事業状況で変化する最適解。進化し続ける開発組織とアーキテクチャ」を公開しました🙌 よろしければお手元でもご覧ください! https://t.co/DrStp16fon #アーキテクチャcon_findy — CADDi.tech (@CaddiTech) 2025年11月21日 私たちのプロダクトのインフラは Terraform で構成しています。 プロダクトがロンチされてから3年以上経っていて、その発展に従ってTerraformの構成も大きく変化してきました。 この記事ではプロダクトのTerraformがどのように変化してきたかを紹介していきます。 というのは建前で、どこかでTerraformネタで発表しようと思って溜めていてたネタだったんですが、機会がなかったのでここで記事にしました。 CADDi Drawer 初期(2021-2022) 図面管理SaaSとしての基本的な機能を作ってロンチした頃。 この頃は、SaaSの機能は少なくTerraformの構成も次のようにシンプルなものでした。 terraform ├ environments │ ├ dev │ │ ├ main.tf │ │ └ variables.tf │ ├ stg │ └ prod └ modules   ├ cloudsql   │ └ main.tf   ├ iam   ├ gke   ├ gcs   ├ network   ├ pubsub   └ secret environments は terraform のapply対象、state管理の対象となるルートモジュールで、ここでGoogle Cloudのプロジェクトや環境ごとのパラメータを持っています。 moduels配下は、ルートモジュールから参照されるもので、Google Cloud に作成する実際のリソースを管理しています。 当時はモジュールは、Google Cloudの機能相当で分割していたようです。 networkモジュールでVPCやサブネットを、gkeモジュールでGKEクラスタやノードプールを、のようにインフラの共通リソースの定義から始まって、 それを踏襲して Cloud Pub/Subが欲しくなったので pubsubモジュールを、のようにモジュールをクラウドの機能単位で作っていました。 (後から振り返りますが、これはモジュールの作りとしてはあまり良くはないことがわかってきます) また当時から、このリポジトリには Terraformと Terraform Providerの更新を自動化する仕組みや、PullRequestのステータスに合わせて Plan/Applyを自動化する仕組みを導入していました。 これがあったからこそ、後続のリファクタリングがうまくいったと言っても過言ではありません。 では次にどうなったのかを見てみましょう。 CADDi Drawer 成長期(2023-2024) 機能強化やCADDi Quoteなどのプロダクトの追加といった様々な追加開発を行なっていた頃です。 開発に関わるメンバーも増え、チーム体制を取ったりと組織面でも大きな変化があった時期です。 この頃の Terraform の構成はおおよそ次のようになっていました。 terraform ├ environments │ ├ dev │ │ ├ main.tf │ │ └ variables.tf │ ├ stg │ └ prod └ modules   ├ cloudsql   │ ├ main.tf   │ └ service_a.tf   ├ bigquery   ├ iam   │ ├ main.tf   │ └ service_a.tf   ├ gke   ├ network   ├ pubsub   ├ gcs   │ ├ main.tf   │ └ service_a.tf   ├ secret   │ ├ main.tf   │ └ service_a.tf   └ some_saas ディレクトリ構成的にはあまり変わっていません。 Google Cloud で利用するAPIの追加に従ってモジュールが増える他、 この頃には Google Cloud以外の外部SaaSも使い始めてその管理用のモジュール(some_saasとしておきます)も追加されました。 そして、Google Cloud の機能単位で作成された pubsub,secret,iamなどのモジュールは 複数の開発チームが相乗りして、それぞれ必要とするリソースを追加していました。 この状態のまま、Terraform のコードが増えていったため、次のような困りごとが出てくるようになりました。 1つの修正で複数モジュールを修正する必要がある Google Cloudのリソース同士は依存性を持つことがあります。最も良くあるのが、リソースに対してサービスアカウントのIAM Roleを付与するパターンです。 この場合、リソース単位でまとめたモジュールだとモジュール間の依存性が生まれます。 GCS バケットとサービスアカウントを作成してIAM Roleを割り当てるには現状のモジュール構成だと以下のようになります。 iamモジュール内でサービスアカウントを設定して、memberをoutputで返す。 resource "google_service_account" "some_sa" { account_id = "some_sa" } output "some_sa_member" { value = resource.google_service_account.some_sa.member } gcsモジュールでGCSバケットを作成し、variableでSAメンバー名を受け取り、IAMロールを付与する # gcs module resource "google_storage_bucket" "some_bucket" { name = "some_bucket" project = var.project_id location = "ASIA-NORTHEAST1" force_destroy = false } resource "google_storage_bucket_iam_member" "iam_member_example" { bucket = google_storage_bucket.some_bucket.name role = "roles/storage.user" member = var.some_sa_member } ルートモジュールでmodule間のoutputとvariableを渡します。 module "iam" { source = "../../modules/iam" } module "gcs" { source = "../../modules/iam" # iam moduleの outputのSA memberを渡す some_sa_mamber = module.iam.some_sa_member # リソースが追加されるたびに variableが増えていく hoge_sa_member = module.iam..... } とある開発チームが、GCSバケットと権限を設定するためには、二つのモジュールを修正し、モジュール間のパラメータの受け渡し(outputとvariable)を追加する必要があります。 こういったことがGCSやPub/Subなど様々なリソースで起きるので、何かしらの変更が起きるたびに複数のモジュールにまたがる修正が必要でした。 複数のリリース対象が混じっている Google Cloudと 他のSaaS のTerraform 構成が一つのstateに混在した結果、SaaSのみをアップデートしたくてもGoogle Cloud側のリソースの修正も混じっていてリリースタイミングの調整が必要になることがありました。 このようなことから、今後更なる機能追加の障害になると考え、モジュール構成の見直しとstateの分割を検討しました。 モジュール構成の見直し 一般的なプログラミングにおける良いモジュールとは、モジュール間の依存が少なく、モジュールの中には関連が強いものが集まる、つまり疎結合・高凝集であることです。 なんからの修正に対して単一のモジュールのみの修正で済んだり、他のモジュールに影響を与えずにモジュールの追加・削除ができることが望ましい姿であると言えます。 複数の開発チームが同時に開発している状況では、チームが開発している各サービスでリソースをまとめるのが適切だろうと判断しました。 次のようなモジュール構成にすることにしました。 terraform ├ environments │ ├ dev │ │ ├ infra │ │ │ ├ main.tf │ │ │ ├ infra.tf │ │ │ ├ app_a.tf │ │ │ └ app_b.tf │ │ └ some_saas │ │   └ main.tf │ ├ stg │ │ ├ infra │ │ └ some_saas │ └ prod │   ├ infra │   └ some_saas └ modules   ├ cloudsql   ├ network   ├ gke   ├ service_a   │ ├ iam.tf   │ ├ gcs.tf   │ ├ pubsub.tf   │ └ secret.tf   ├ service_b   └ service_c modulesについては、開発チームが作成しているサービス単位でモジュールを作成しそこにそのサービスで使うリソースをまとめます。 ただし、VPCや GKEなどの共通基盤として利用するリソースはそのままです。 ルートモジュールについては、Google Cloud とその他のSaaSについてはこの時点でstateを分けることにしたので、階層を下げました。 Google Cloudのルートモジュールについては単一のtfファイルでモジュールの呼び出しをしていたものを、アプリケーション単位でファイル分割します。 これは将来的にstateを分割することも考慮しています。 こうすることで、前述の GCSバケットとIAMの設定については同一モジュール内で定義が済むことになります。 resource "google_service_account" "some_sa" { account_id = "some_sa" } resource "google_storage_bucket" "some_bucket" { name = "some_bucket" project = var.project_id location = "ASIA-NORTHEAST1" force_destroy = false } resource "google_storage_bucket_iam_member" "iam_member_example" { bucket = google_storage_bucket.some_bucket.name role = "roles/storage.user" member = google_service_account.some_sa.member } outputもvariableも不要になり、すっきりします。 ですが、モジュールの構成を変えるというは単にソースコードを直せば良いというわけではありません。 どうやってこれを達成したかを次に解説します。 同一state内のリソースの移動 Terraformのリソース定義は、名称を変更するだけでも stateとの差分が発生するので リソースの削除と新規作成という結果になります。 これは、Terraformの仕様上しょうがない部分で、stateを tarraform state mv のようなコマンドで直接修正するという方法があります。 developer.hashicorp.com ただコマンドによるstate の変更は、stateを直接更新してしまうので、試行錯誤しながら作業を進めていくのは難しいです。 Terraform 1.1 から moved block, import block, removed block という機能が提供されました。 developer.hashicorp.com これは、stateの変更をしたい内容をルートモジュールに記載しておくことで、変更の結果を加味してplan/apply を行なってくれる機能です。 これを使えば、変更の結果を試行錯誤しつつ作業を進められます。 例えば、前述のgcs, iam モジュールの内容を service_a というモジュールに移動する場合、移動先のモジュールのソースコードを書いて、 次のような moved block を書きます。 moved { from = module.gcs.google_storage_bucket.some_bucket to = module.service_a.google_storage_bucket.some_bucket } moved { from = module.gcs.google_storage_bucket_iam_member.iam_member_example to = module.service_a.google_storage_bucket_iam_member.iam_member_example } moved { from = module.iam.google_service_account.some_sa to = module.service_a.google_service_account.some_sa } movedブロックがある状態で planをすると、モジュールを変更した状態で比較が行われるので基本的に差分は無しになります。 名称のミスなどがあって差分が出た場合でも、安心して修正ができます。 余談ですが、この作業はモジュール内のリソースの一覧を出力するなどしてある程度は機械化できるのですが、結構大変でした。 当時はcopilotが登場したくらいの頃だったので、今ならAIツールでもっと賢くできるかもしれません。 以下の画像が一気にモジュール構成を変えた時のPRのサマリです。この量でもplan結果はほぼ差分なしでした。 補足 import, removed block 基本的には moved ブロックだけで事足りるのですが、作業を進めていく上でいくつか個別対処したことがあります。 1つめは、複数のモジュールで同一のリソースが定義されているというものでした。 IAM ロールを割り振る iam_member リソースは、色々なモジュールで定義されていて、モジュールの変更を見直していたら全く同じ内容が出てきて一つにマージする必要がありました。 単純にまとめてしまうと、片方のiam_memberリソースが削除扱いになるので場合によってはiam_memberが消えてしまう可能性もあります。 この場合、removed blockによってTerraformのstateでだけそのリソースを無かったことにします。 removed { from = modue.iam.google_storage_bucket_iam_member.some_member lifecycle { destroy = false } } destroy = false でGoogle Cloudからはリソースを削除しないとを明示することに注意します。 2つめは、Terraformで管理されていないリソースがある環境でだけあったというもので、これは import block でterraform stateに取り込みます。 import { to = module.service_c.google_service_account.some_sa id = "projects/$ { var.project_id } /serviceAccounts/some_sa@$ { var.project_id } .iam.gserviceaccount.com" } import は import したいリソースごとに id を指定します。idに何を書くかはリソースによって異なるので、ドキュメントを読んで正しいIDを指定することに注意します。 state分割でのリソースの移動 state を分割する場合、 前述の moved ブロックは使用できません。 state mv コマンドでモジュール単位で別のstate に移動していきます。 state の移動元と移動先それぞれで、removedブロック,importブロックを使えばひょっとすると代替できるかもしれません。 しかし import ブロックは上で述べた通りID指定が必須なので移動したいリソースのIDを列挙するのは困難なのでお勧めしません。 stateをまたいだリソースの移動は次の手順で行います。 移動元、移動先それぞれのルートモジュールでstateをローカルにダウンロードして、ローカルのstateを参照する stateまたぎでmoduleを移動する 両方のルートモジュールで plan を実行し、差分がなければローカルstateをリモートにpushする 次のスクリプトで1,2を自動化します。 #!/bin/bash # 移動元ルートモジュールのパス SRC = $1 # 移動先ルートモジュールのパス TARGET = $2 # スペース区切りでルートモジュール内の移動するmodule 名のリスト。 MODUELS = $3 base_dir = $( pwd ) echo " SRC ローカルにstateをダウンロード " cd $SRC terraform init terraform state pull > ${base_dir} / ${TARGET} /src.tfstate echo " TARGET ローカルにstateをダウンロード " cd $base_dir cd $TARGET terraform init terraform state pull > target.tfstate # localのstateを使うように一時ファイルで上書き cat << EOF > override.tf terraform { backend "local" { path = "target.tfstate" } } EOF # ローカルstateを使うようにinit をやり直す terraform init -reconfigure # モジュールリストごとにstateをmove for module in $( tr ' ' ' \n ' <<< ${MODUELS}) do echo " move module. ${module} " # state-out で移動先のstate ファイルを指定する terraform state mv -state = src.tfstate -state-out = target.tfstate module. ${module} module. ${module} done この状態で plan を実施して、差分がないようなら 次のスクリプトで更新後のstateを反映します。 #!/bin/bash SRC = $1 TARGET = $2 base_dir = $( pwd ) echo " TARGET: リモートのstateを使うように設定し直して、state をpushする " cd ${TARGET} # push target state rm override.tf terraform init -reconfigure terraform state push target.tfstate echo " SRC: state をpushする " cd ${base_dir} mv $TARGET /src.tfstate $SRC /src.tfstate cd $SRC terraform state push src.tfstate planのチェックもスクリプトで自動化してしまえば全作業が自動化できそうです。 現在そして将来 これまでの作業で、ある程度モジュールの独立性が確保されたため、Terraformのコード修正は比較的楽になりました。 その結果、Terraform コードが増えたので今は次のような問題を抱えています。 plan/apply にかかる時間が増えている, 3-5分かかっている リソースが増えすぎていて、モジュールやリソースのオーナーがわかりづらくなっている これらの問題についてどのように解決するかは現在進行形ですが、次のように考えています。 stateをアプリケーション単位で分割し、plan/applyを並列化する ルートモジュールごとに default labelを付与して、生成されるリソースのオーナーがわかるようにする stateを分割すると、state間の情報共有をどうするかやapplyの順序といった問題が出てきます。 多分完璧なやり方はないだろうと思っているので、ある程度の妥協をしつつ進めていくのかなと思っています。 何か良いアイデアをお持ちの方はぜひ教えてください。 まとめ モジュールは関連の強いリソースでまとめましょう。そして関連は技術的な軸ではなく開発組織の軸で考えましょう そこが思い浮かばないなら、無理にモジュールにしなくても良いです モジュールの構成をしくじっても、どうにかなります。気合と根性で解決したことも今ならAIで楽になるはず moved blockが使えなければ詰んでいたので、Terraformのアップデートは運用に組み込みましょう これを見ているあなたもぜひ、我が家のTerraformの歴史を公開してみてください
アバター
こんにちは、柴犬がかわいい。Tech本部の前多です。 先日、弊社でApache IcebergとTrinoによる活用事例についての記事を上げました。 caddi.tech 記事では、Icebergへのデータ投入について次の記述がありました。 ユーザがアップロードしたCSVファイルをパースしてIcebergに保存する 図面の解析結果を一定間隔のバッチで受け取りIcebergに保存する 実際のところ、ファイルからIcebergへのデータ投入はサイズによっては困難なことがありました。 今回はIcebergへのデータ投入に関するTopicをお伝えします。 データ投入で発生した課題 私たちは、クエリエンジンとしてTrinoを採用しています。 データ投入の経路はCSVファイルしかないので、CSVファイルを解析して一行ごとに TrinoのInsert文 を発行すれば十分だろうと考えていました。 また、TrinoのInsert分は以下のような複数行の一括投入も可能なので、それである程度効率よく処理ができるだろうと踏んでいました。 INSERT INTO iceberg.some_schema.some_table VALUES ( 1 , ' test1 ' ), ( 2 , ' test2 ' ),,,,,; 少量のデータでは、この方法でも問題はありませんでした。 しかし性能テストのために1000万件程度のデータを投入しようとし始めた時から、次の問題がでてきました。 1. 時間がかかりすぎる テストデータの投入を前述のtrinoの複数行INSERTを使って、10行から200行の範囲でまとめて挿入する方法で当初行っていました。 10万件程度の投入はおおよそ15分程度で終わっていたので許容範囲だと思っていましたが、 100万件の投入を超えたあたりからどんどん一度のINSERTにかかる時間が伸びていくようになりました。 Icebergのメタデータファイルの増加、GCSの負荷増加、Trinoクラスタの負荷増加などさまざまな理由が考えられますが、Trinoで連続したデータ投入を行うのは難しいのではと思い始めました。 Trinoクラスタのスケールアップなどにより改善した可能性はありますが当時は後述する別の手段を採用しています。 2. Iceberg メタデータが増え続ける Icebergはテーブル単位のレコード操作についてトランザクションのサポートがあり、トランザクションごとにデータファイルやメタデータ、マニフェストファイルが作成されます。 これは、細かいトランザクションを何度も行うとメタデータファイルが肥大化していきます。 Icebergには、古いメタデータファイルをコミット時に破棄する write.metadata.delete-after-commit.enabled というオプションがあるのですが、 これは現時点ではTrinoでサポートされていません。Issueはありますが、まだ進行中です。 iceberg.apache.org github.com 数百万件のレコードを投入した時点で、メタデータファイルは何度もInsertを繰り返した結果100MBを超える状態となっているものもあり、 これがデータ投入が遅くなった要因の1つであったと考えます。 なるべく一度のトランザクションでデータをまとめて投入する、メタデータファイルをメンテナンスするなどの必要性がわかりました。 3. ファイル単位のトランザクション制御ができない Trinoはトランザクションに関するSQLはありますがほとんどのコネクタではサポートされていません。 SQL statement support — Trino 474 Documentation Trino Iceberg connectorも同様で、原則的にauto commitで動作します。auto commit以外を設定するとエラーになりました。 そのため、Icebergに対する複数のSQL実行に対するトランザクションはなく、Trinoでは1回のINSERTでIcebergのトランザクションとなります。 よって、ファイルの各行を分割してINSERT文を発行すると、細かいコミットが詰まれていくので、ファイルデータの途中にエラーがあって処理を停止した場合、Icebergには中途半端なデータが残ったままになります。 ただしこの仕様は事前に把握していました。 そこで、今回は投入するデータに投入元のファイルIDを持たせて、中途半端なデータは後から削除できる仕様としています。 そのため、大きな問題にはなりませんが、できるならファイル単位でIcebergへのデータ投入が成功したか失敗したかのどちらかになっているのが望ましいです。 このように、私たちのケースのようなそこそこのサイズのファイルをIcebergに投入するにあたって、Trino経由のデータ投入では扱いづらいことがわかってきました。 Trino以外の手段ではApache Sparkを使うのが王道だったと思いますが、当時Trinoに加えてSparkクラスタも構築するのは現実的ではありませんでしたし、上記全ての問題が解決するのかはわかっていませんでした。 そこで、IcebergのJava APIを使用して直接Icebergにデータを書き込むことにしました。 なお、余談ですがその時に Apache Beam® (Google Cloudのマネージドサービス、Dataflowの中身)も使えないかを見ていました。 確認したところApache BeamのIcebergサポートはバッチモードではレコード1件につき1コミットとなるようで、今回の要件にはマッチしないと判断しました。基本的にはApache Beamはストリームで扱った方が良さそうです。 Iceberg Java APIについて Iceberg Java APIはIcebergテーブルフォーマットに従ったデータファイル、メタデータ、マニフェストファイルを作成し、Catalogと連携してファイルのコミットを行ってくれるライブラリです。 あまり解説されているサイトは少ないのですが公式や、Tarbularのブログのほか日本での事例解説があり、参考にさせていただきました。 iceberg.apache.org www.tabular.io knowledge.sakura.ad.jp 今回解説するソースコードの全量は こちら にあります。 Docker compose, テストコードもあるので手元で試せます。 Catalogの取得、テーブルの作成 まずは、Catalogを取得します。 今回はREST Catalogを使用し、CatalogのURIやオブジェクトストレージの認証情報を設定して初期化します。 public static RESTCatalog getCatalog(String catalogUri) { var catalog = new RESTCatalog(); Map<String, String> catalogConfig = new HashMap<>(); catalogConfig.put( "type" , "rest" ); catalogConfig.put( "uri" , catalogUri); // TODO , 実際の環境に合わせて設定内容を変えること catalogConfig.put( "io-impl" , "org.apache.iceberg.aws.s3.S3FileIO" ); catalogConfig.put( "s3.endpoint" , "http://localhost:9000" ); catalogConfig.put( "s3.path-style-access" , "true" ); catalogConfig.put( "s3.region" , "us-east-2" ); catalogConfig.put( "s3.access-key-id" , "admin" ); catalogConfig.put( "s3.secret-access-key" , "password" ); catalog.initialize( "rest" , catalogConfig); return catalog; } Catalog経由でIcebergテーブルを操作します。 テーブルの取得や作成、スキーマ変更などができます。 テーブルを作る場合はスキーマやパーティションなどの定義が必要で、今回は4項目を持つスキーマを用意します。 /** 4項目を持つテーブルのスキーマの例 */ public static final Schema SCHEMA_SAMPLE = new Schema( List.of( Types.NestedField.required( 1 , "id" , Types.UUIDType.get()), Types.NestedField.required( 2 , "name" , Types.StringType.get()), Types.NestedField.required( 3 , "price" , Types.IntegerType.get()), Types.NestedField.required( 4 , "registered_at" , Types.TimestampType.withZone()))); /** name属性のハッシュ値によるパーティションの例 */ public static final PartitionSpec SAMPLE_PARTITION = PartitionSpec.builderFor(SCHEMA_SAMPLE) .bucket( "name" , 16 ).build(); Catalogにスキーマ、パーティション、テーブルプロパティなどを設定してテーブルを作成します。 テーブルのオブジェクトストレージ上のパスも自分で決めます。論理的なテーブル名と同じにしてしまうとリネームや名前の衝突などで困るため、ハッシュ値などを含めた方が良いでしょう。 // namespaceの取得 var ns = catalog.loadNamespaceMetadata(Namespace.of(namespace)); // オブジェクトストレージのテーブルのパス var location = ns.get( "location" ) + "/" + table + "-" + UUID.randomUUID().toString().replaceAll( "-" , "" ); var table = catalog // ネームスペース、テーブル名、スキーマの指定 .buildTable(TableIdentifier.of(Namespace.of(namespace), table), schema) .withLocation(location) // パーティション、ソートオーダーなどの指定 .withPartitionSpec(partitionSpec) .withSortOrder(sortOrder) // テーブルプロパティの指定 .withProperties( Map.of( "write.metadata.delete-after-commit.enabled" , "true" , "write.metadata.previous-versions-max" , "100" , "write.object-storage.enabled" , "true" )) .create(); ここまでが下準備です。次から実際にIcebergテーブルにデータを書き込んでいきます。 シンプルなデータ投入手順 Icebergテーブルはデータファイルやメタデータファイル、マニフェストテーブルから構成されています。 Java APIを使ったプログラムでは、主にデータファイルを作成します。 データファイルに連なるマニフェスファイルやコミットで生成するメタデータファイルについてはAPIやCatalogの内部で隠蔽されているので、あまり意識する必要はありません。 まずはパーティションがないテーブルのようなシンプルな実装例を紹介します。 Catalog, tableがある前提で、トランザクションを開始してAppendオペレーションを開始し、データファイルを作成するための DataWriter を取得します。 var catalog = TableUtil.getCatalog(restCatalogUri); var tbl = TableUtil.getOrCreateTableAndNamespace( catalog, namespace, table, SampleDefinition.SCHEMA_SAMPLE, PartitionSpec.unpartitioned(), SortOrder.unsorted()); // トランザクションを開始して、Appendオペレーションを開始する。 var transaction = tbl.newTransaction(); var append = transaction.newAppend(); // データファイルのパスは自分で決める。ハッシュ、日時などを入れて衝突しないようにする。 var fileId = OffsetDateTime.now().format(DateTimeFormatter.ofPattern( "yyyy-MM-dd_HHmmss" )) + "_" + UUID.randomUUID(); // メタデータ類とは異なるパスに配置されるように /data を含める String filepath = tbl.location() + "/data/" + fileId + ".parquet" ; // DataWriterの取得 var file = tbl.io().newOutputFile(filepath); var dataWriter = Parquet.writeData(file) .schema(tbl.schema()) .createWriterFunc(GenericParquetWriter::buildWriter) .overwrite() .withSpec(PartitionSpec.unpartitioned()) .build(); 上記では、テーブルから newTransaction でトランザクションを開始して、次にトランザクションから newAppend でAppendオペレーションを開始していますが、 オペレーションが1つだけならtableから直接Appendオペレーションを作成できます。 オペレーションの種類は こちら にあります。 Appendはデータを追加するだけの単純なオペレーションで、トランザクション競合も起きません。そのほかに削除、データ更新など複数のオペレーションがありますが、今回のケースはデータ追加だけを行いますので触れません。 興味がある方は以下の記事を参考にしてください。 bering.hatenadiary.com データファイルのパスも自分で決める必要があります。ハッシュ値、タイムスタンプを含めて衝突を避けたり、オブジェクトストレージのパスを分散させて効率を高めたりするなどの工夫は自分で行います。 DataWriterへ、ファイルの一行ごとにParquet形式のデータに変換して書き込んでいきます。 最初に定義したテーブルスキーマのフィールドと型を一致させる必要があります。型変換についてはGitHubのコードを参照してください。 var record = GenericRecord.create(tbl.schema()); try ( var lines = new JsonlReader(input)) { // data add to parquetWriter while (lines.hasNext()) { var r = lines.next(); var row = record.copy(TableUtil.convertRecord(tbl.schema(), r)); dataWriter.write(row); } } レコードの追加が終わったら、書き込みを close で終了させ、その後データファイルに変換した後Appendオペレーションにデータファイルを登録します。 もし、レコードの件数やサイズに応じて複数のデータファイルを生成したい場合は、繰り返しデータファイルの生成を行なってオペレーションに登録します。 最後にAppendオペレーションのcommit、トランザクションのcommitを行うと、Catalogで競合状態を確認します。 問題なければ各種マニフェストファイルが生成、コミットされます。 これで、Icebergのデータ投入は完了です。 // writing finish, then commit data file. dataWriter.close(); var dataFile = dataWriter.toDataFile(); append.appendFile(dataFile); // commit append.commit(); transaction.commitTransaction(); データの取得、確認 ユニットテストでIcebergからデータを取得してみます。 データ取得は Scan で行います。データファイル単位で取得する方法とレコード単位で取得する方法があり、ここでは後者の方法を採用します。 以下のように、 IcebergGenerics.read(table) からselect, whereを指定してScanオブジェクトを取得し、Scanから1件ずつレコードを取得していきます。 var result = new ArrayList<Map<String, Object>>(); var scan = IcebergGenerics.read(table) // select, where の指定ができる //.select("id", "name") //.where(Expressions.lessThan("price", 100)) .build(); for ( var i = scan.iterator(); i.hasNext(); ) { var data = i.next(); var map = new HashMap<String, Object>(); for ( int k = 0 ; k < data.size(); k++) { var field = SampleDefinition.SCHEMA_SAMPLE.findField(k + 1 ); map.put(field.name(), data.get(k)); } result.add(map); } 余談ですが、通常のSQLと異なり、集計、関数、ソートといった操作や結合はサポートされていませんので、こういった操作はクエリエンジン側で行います。これらの操作がコストが高くなる理由がわかります。 テストを実行すると、Icebergへのデータ投入とその確認が検証できます。 また、以下のようにMinioコンソールで作成されたファイルを確認できます。 メタデータファイルと、1件のデータファイルが確認できます。 また、このデータはTrinoからクエリすることももちろん可能です。 Partitionに対応したデータファイルの書き込み 前述の方法は単純で件数が少ないテーブルであれば十分ですが、一方でJava APIが提供する機能はプリミティブなものだと私は感じました。 例えば、パーティションキーごとにデータファイルを分けたり、サイズに応じてデータファイルを分割するのは自分で行う必要があります。 そういった時に役立つのが org.apache.iceberg.ioパッケージの便利クラス です。 Partitionに対応したデータファイルを作成可能な PartitionedFanoutWriter がありますのでこれを使ってみます。 以下のように、appenderFactory, outputFileFactoryを生成してこれをPartitionedFanoutWriterに渡します。 outputFileFactoryはファイルを作成する情報となるpartitionId,taskId,ファイルフォーマットを受け取り、パーティションごとに分割したデータファイルのパスに含めます。 もし対象テーブルにパーティションがない場合は、 UnpartitionedWriter を代わりに使います。 ファイルサイズを指定でき、 UnpartitionedWriter を使う場合でもファイルサイズでデータファイルが分割できるので便利です。 Writerを作った後のレコード挿入はこれまで通りです。 var appenderFactory = new GenericAppenderFactory(tbl.schema()); // 複数プロセスで同時に挿入する場合は、partitionId, taskIdをプロセスごとに分けないと、同名のファイルを作ってしまう。 int partitionId = 1 ; int taskId = 1 ; var outputFileFactory = OutputFileFactory.builderFor(tbl, partitionId, taskId).format(FileFormat.PARQUET).build(); final PartitionKey partitionKey = new PartitionKey(tbl.spec(), tbl.spec().schema()); // partitionの有無に応じて、 Writerの実装を分ける。 // writerはサイズを加味してデータファイルを分割し、 // さらにPartitionedFanoutWriterは、パーティションの値でデータファイルを分割する。 var writer = partitioned ? new PartitionedFanoutWriter<Record>( tbl.spec(), FileFormat.PARQUET, appenderFactory, outputFileFactory, tbl.io(), DATAFILE_MAX_SIZE) { @Override protected PartitionKey partition(Record record) { partitionKey.partition(record); return partitionKey; } } : new UnpartitionedWriter<Record>( tbl.spec(), FileFormat.PARQUET, appenderFactory, outputFileFactory, tbl.io(), DATAFILE_MAX_SIZE); var record = GenericRecord.create(tbl.schema()); try ( var lines = new JsonlReader(input)) { // data add to parquetWriter while (lines.hasNext()) { var r = lines.next(); var row = record.copy(TableUtil.convertRecord(tbl.schema(), r)); writer.write(row); } } レコードの挿入が終わったら、writerが作ったデータファイル一覧をappendオペレーションに追加してコミットします。 for ( var dataFile : writer.dataFiles()) { append.appendFile(dataFile); } LOG.info( "insert complete. append commit" ); append.commit(); transaction.commitTransaction(); テストを実行し生成されたファイルを見ると、 /data/nnnn/nnnn/nnnn/nnnnnnnn/<field>_bucket=[hash]/ というパスでデータファイルが分割されていることがわかります。 マニフェストリストファイル(avro形式)にはデータファイルの情報が含まれています。これを確認すると、4つのデータファイルに分割されていることがわかります。 このスナップショットは、Web上でAvroを解析してくれる https://konbert.com/ の表示内容です。 これで、Java APIを使用したPartitionありのテーブルのデータ投入もできました。 まとめ Java APIを直接利用することで、私たちの場合以下のような改善ができました。 1000万件のデータ投入が、全く終わらない状況から15分に短縮できた 性能テストのデータ投入のための改善だったが、ユーザーファイル取り込みや他システムのデータ取り込みの高速化に流用できた 1ファイルのデータ投入がIceberg上の1トランザクションで実行できるようになった 一方で、Java APIの利用は、データ追記、あるいは洗い替えのための全データ削除といった単純なオペレーションで高速化が必要な場合のみに留めています。 その理由は、Java APIはデータファイル単位での操作に特化しているためです。 例えば、データの更新は、更新対象のレコードを含むデータファイルを特定し、更新後のレコードを含むデータファイルを作成し直して上書きするか無効化するといった操作が必要です。 org.apache.iceberg.ioパッケージ には変更操作をまとめてくれるような機能がありそうですが、それでも難しい操作であることには変わりはなく、このような場合はSparkやTrinoで抽象化された仕組みを使った方が良いでしょう。 以上です、クエリエンジンの仕組みと気持ちがちょっとわかるようになりました。
アバター
こんにちは。Drawer Growth グループの江良です。 キャディが「製造業 AI データプラットフォーム」の構想を打ち出してから半年ほどが経ちました。 caddi.com このコンセプトの実現にあたっては、「AI」の部分だけでなく、「データ」の部分を支える仕組みづくりも重要になってきます。今回は、私が携わっているプロジェクトで導入した Apache Iceberg とその使いどころについて紹介したいと思います。 製造業におけるデータ活用の難しさ 本題に入る前に、まずは背景について少し補足します。 (Iceberg の話だけを読みたい人は「採用したアーキテクチャ」のところまでスキップしてください。) モノづくり産業における会社には多種多様なデータが存在する 製造業の世界で登場するデータにはさまざまなものがあります。 詳しくは キャディ、製造業AIデータプラットフォームとしての、第二章。|加藤/キャディCEO でも紹介されていますが、具体例を挙げると以下の通りです。 分類 具体例 構造化データ ・実績データ(見積実績、受注実績、発注実績、製造実績、検査実績、出荷実績、請求実績、在庫実績など) ・マスタデータ(顧客情報、製品、仕入れ先、工程、設備情報、検査器具、チャージなど) 半構造化データ ・CAD 非構造化データ ・図面 ・写真 ・文書(仕様書、不具合報告書、議事録など) (会社の規模にもよりますが)少なくとも十数種類 〜 百数種類のデータが企業内に存在することがイメージできるかなと思います。 当然ながら、それぞれのデータのスキーマは異なります。データのサイズや更新頻度も様々です。実績データに関しては、一億件近くの規模のデータが存在するケースもあります。 データのフォーマットは会社ごとに異なる 図面は、書き手の意図を確実に読み手に伝達するため、JIS 規格に基づいて標準化されています。一方で、表題欄と呼ばれる図面のメタデータ(図面番号、尺度、部品名称、設計者名、承認者名、使用する材質など)を記載する欄の様式は各社が自由に設定できます。 CAD に関しても、どのソフトウェアを使用しているかは各社でバラバラです。 実績データやマスタデータの管理方法は当然各社で異なります。PLM/PDM や ERP といったソフトウェアで管理されていることが多いですが、製造業全体で「標準」と言えるような規格はありません。 データの「活用」に向けたハードル こういった多種多様なデータを活用するためには、まず、非構造化データや半構造化データをなんらかの方法で構造化する必要があります。その上で、データ同士をなんらかの方法で紐づけて、データ同士の連関がわかるようにする必要があります。 データのフォーマットは会社ごとに異なり、さまざまなバリエーションがあります。そのため、「データ同士がどうすれば紐づくか」も一意には決まりません。 ここまでの話をまとめると、 さまざまなスキーマのデータを柔軟に取り扱うことができ、 データ同士をどのカラムで紐づけるべきかを柔軟に選択でき、 大規模なデータセットを取り扱える こういった要件を満たすことが、製造業におけるデータの「活用」を実現する上では求められます(製造業に限った話ではないかもしれませんが)。 データを活用するための一般的な解決策 さて、ここまで説明してきたような課題を解決するためにはどうすればいいでしょうか?一般的には、データエンジニアリングによるアプローチが考えられるかなと思います。 三行くらいで簡単にまとめるとこんな感じ。 データエンジニアリングを専門とするチームを組成し、 データレイクに生データを集め、 ETL パイプライン等を通じてデータを活用可能にする Snowflake 等の登場により、企業がデータ分析を始める際のハードルは大きく下がってきている印象があります。しかしながら、こうしたことを実現するためには、依然としてデータエンジニアリングを専門とするエンジニアが手を動かす必要があります。 改めて、先ほどまとめた課題を再掲します。 さまざまなスキーマのデータを柔軟に取り扱うことができ、 データ同士をどのカラムで紐づけるべきかを柔軟に選択でき、 大規模なデータセットを取り扱える (加えて、製造業に特有のユースケースに特化した機能を提供できる) 上記のような機能を SaaS として提供することで、データをよりかんたんに活用できる状態にしたい、そのための方法を考えてほしい、というのが、ぼくの所属するチームのここ半年のミッションでした。 データレイクハウスの登場 先ほど、データを活用するための一般的な解決策としてデータレイクについて触れました。大規模なデータセットを活用していく上で、データレイクのアーキテクチャは有効ですが、一方で課題もあります。 代表的な課題としては、データの一貫性に関する課題があります。データはあくまで GCS 等のストレージに配置されているだけの状態にあるため、RDBMS でいうところのトランザクションのような概念はありません。そのため、複数のプロセスから同時に書き込みをするとデータが壊れてしまう可能がありますし、中途半端に書き込みがされた状態のデータが予期せず参照されてしまう可能性もあります。 こうした課題から、近年、データレイクハウスと呼ばれるアーキテクチャが注目されてきています。 データレイクハウスアーキテクチャは、データを保存するストレージのレイヤと、データに対して SQL を実行するクエリのレイヤを分離し、その間にメタデータのレイヤを設けているのが大きな特徴です。メタデータのレイヤを設けることで、ストレージ上のデータをテーブルであるかのように抽象化したり、ACID トランザクションを実現したりすることができます。 www.databricks.com それぞれのレイヤで採用できる代表的なツールは以下の通りです。 メタデータのレイヤでは、Open Table Format と呼ばれる仕様に従ってデータが管理されます。この仕様に従ってデータを保存することで、トランザクションなどの便利な機能が使えるほか、クエリのレイヤでどのツールを使うか(Spark、Hive、Flink、Trino など)がユースケースに応じて選択可能になります。 採用したアーキテクチャ 前置きが長くなりました。キャディでの Iceberg の使いどころについての話に移ります。 キャディでは、CADDi Drawer が扱うデータのうち、構造化データを扱うサービスにて Iceberg を使用しています。構造化データのうち、特に実績にまつわるデータはレコード件数が多い傾向にあります。スキーマが不定だったり、紐付け項目が一意に定まらなかったりするという特徴も相まって、RDBMS を素朴に利用してアプリケーションを設計すると、中長期的に期待するパフォーマンスが出せないのではないか、という懸念がありました。 一方で、データの更新頻度は少なく、データの追加操作がメインのユースケースであることから、「RDBMS 以外の選択肢は本当にないのか?」を検討し、紆余曲折を経て Iceberg に辿り着きました。 各レイヤで何を採用したか 先ほど、データレイクハウスアーキテクチャはクエリ、メタデータ、ストレージの 3 つのレイヤで構成される、ということについて説明しました。それぞれのレイヤで採用できるツールにはいくつか選択肢がありますが、CADDi Drawer では Trino、Iceberg、GCS(Google Cloud Storage)を採用しました。 Open Table Format が掲げるテーマとして代表的なものに「バッチとストリーミングの統合」があります。ストリーミングのユースケースを満たすなら、Apache Spark を採用し、Structured Streaming 機能を活用するといった選択肢も考えられます。 iceberg.apache.org ですが、SQL のインタフェースを通じてデータをクエリできれば十分であり、検討時点ではストリーミングのユースケースが見当たらなかったため、比較的導入コストの小さい Trino を採用しています。(リリースまでのスケジュールが非常にタイトであったこと、今回ユーザに提供する機能はあくまでベータ版であったこと、といった事情もあったりします。) Iceberg に関しては AWS など BigTech 各社が力を入れていることから興味を持ち、採用を決めました。 データレイヤーに関しては、キャディでは Google Cloud を全面的に採用していることから GCS を採用することに決めました。 「ベータ版としての提供なのであれば BigQuery でもいいのでは…?」という考えも頭をよぎりましたが、不特定多数のユーザーに BigQuery を用いた機能を解放するとクエリコストのコントロールが難しくなりそうなため、候補からは外しました。 アーキテクチャの詳細 アーキテクチャ図は以下の通りです。 構造化データを扱うマイクロサービスは、キャディの中では珍しく Java を採用しています。静的型付けのある言語で開発したかったのと、Trino や Iceberg などのライブラリとの親和性の高さから採用を決めています。 処理の大まかな流れは以下の通りです。 ユーザがアップロードした CSV をパースして Iceberg に保存する 図面の解析結果を一定間隔のバッチで受け取り Iceberg に保存する Iceberg のデータを用いてデータの紐付けを解決し、「図面に紐づく構造化データ」を UI に表示できるようにする 緑色の線が「ユーザが CSV をアップロードしてから Iceberg に登録されるまで」の流れを表し、赤色の線が「図面の解析結果が Iceberg に登録されるまで」の流れを表しています。別のジョブを通じてデータ同士の紐付けを解決して Iceberg に書き戻し、この「解決済み」のデータを REST API から返却して、ユーザ向けの画面に表示しています。 Trino は GKE クラスタ上に用意した専用のノードにデプロイして稼働させています。コーディネータがクエリを受信し、実行計画を立てて、ワーカに対して指示を送ります。ワーカはコーディネータからタスクを受け取り、データを実際に処理します。 Iceberg Catalog としては Databricks 社の iceberg-rest-image を利用しており、こちらも GKE クラスタ上にデプロイして稼働させています。カタログの情報は AlloyDB に永続化し、ファイルの実態は GCS に保存しています。 github.com Iceberg Catalog にも選択肢がいくつかあります。詳しく知りたい方は下記の記事を参照ください。 bering.hatenadiary.com 大量のデータの INSERT 操作は、パフォーマンスの観点から Iceberg Java API を通じて実施しています。 iceberg.apache.org 所感 Iceberg および Trino を採用したことにより、 テナントごとに異なる、さまざまなスキーマのデータを柔軟に取り扱うことができる データ同士をどのカラムで紐づけるべきかを柔軟に選択できる 大規模なデータセットを取り扱える といった、当初目的としていたアーキテクチャ特性を満たすサービスを構築できました。 データの書き込み性能のスループットに関しては、1000 万件規模のデータの登録が 15min 程度で完了し、読み込み性能に関しても一般的な Web アプリケーションとして違和感のないレスポンスタイムで安定して結果を返すことを確認できました。 今後の課題 ここまで、Iceberg 導入の背景と使いどころについて説明してきました。 直近のゴールは達成できたものの、今後取り組みたいこと、改善したいポイントはたくさんあります。 全社を横断したプラットフォームへの進化 Iceberg を使った仕組みは、現在、あくまで CADDi Drawer の中の一機能という立ち位置です。将来的には CADDi Drawer のデータだけではなくCADDi Quote のデータも横断して取り扱えるよう、アプリケーションとプラットフォームに分割し、アプリケーションを横断して利用できるようにしていく必要があります。 また、こちらのインタビューでも語られている通り、製造業 AI データプラットフォーム CADDi には、今後も新規アプリケーションを追加していくことを想定しています。 www.fastgrow.jp 「3 年で数十個」 という目標を達成する上で、Iceberg を使った基盤を全社を横断したプラットフォームに進化させていく取り組みは急務といえます。 Iceberg の機能をもっと使い倒したい Iceberg にはトランザクション管理に関する仕様が定義されています。この仕様に従って実装されたクエリエンジンを利用することで、更新データの競合が疑われる場合に該当の操作を abort し、データの一貫性を保証することができます。 現時点ではデータの追記(AppendFiles)しか利用していないため、下記の資料で解説されているような同時書き込み時における課題には直面していません。 speakerdeck.com また、Iceberg には in-place table evolution という仕様が定義されています。これはテーブルのスキーマを ALTER TABLE 文を発行して変更したり、テーブルのパーティションを行うキーを後から変更したりすることができる、という機能です。 iceberg.apache.org 現時点では、一度定義したテーブルのスキーマを変更するような機能を提供していないため、この課題には直面していませんが、早晩対応が必要になりそうな予感がしています。 また、Iceberg を全社を横断したプラットフォームに進化させていく上では、各アプリケーションのデータベースに永続化されているデータを、ストリーミング処理を通じてニアリアルタイムに連携できるようにしていく必要も出てきそうです。 やることがたくさんあって大変なわけですが、これはこれで「Iceberg の真価を発揮できるチャンスがたくさんある」と言い換えることもできそうです。 マルチテナント SaaS におけるテナント分離の課題 書籍『マルチテナント SaaS アーキテクチャの構築』でも語られている通り、SaaS を提供する事業者としては、異なるテナントのデータが誤って参照されてしまうことのないよう、テナントの分離を強制する仕組みの構築が重要となります。 CADDi Drawer では、Iceberg のスキーマをテナントごとに作成し、テナントごとのテーブルをスキーマ内に作成することでデータを物理的に分離しています。異なるテナントのデータを参照できないようにする仕組みはアプリケーションのレイヤに実装しています。 こういった仕組みはアプリケーションのレイヤだけでなく、インフラのレイヤにも導入し、多層的なテナント分離を実現したいところです。ですが、現在採用している Iceberg Catalog にはそういったアクセスコントロールに関する機能はないため、やむなく断念しています。 Apache Polaris では、RBAC モデルをベースとした柔軟なアクセスコントロールの仕組みが提供されるようです。現時点では Incubation のステータスにあるため採用を見送ったのですが、正式版がリリースされた際には載せ替えを検討しています。 polaris.apache.org Iceberg の利用を検討している方は動向をウォッチしてみると良いかもしれません。 おわりに いかがだったでしょうか。 Iceberg の採用を検討している方の参考になれば幸いです。 最後に宣伝で、キャディではエンジニアを採用しています。本記事を読んで、「製造業の AI データプラットフォーム」構想に興味を持った方、今後の課題を一緒に解決していきたいと感じた方はぜひご連絡ください。 recruit.caddi.tech
アバター
こんにちは、Data&Analysis部(D&A)です。 D&Aでは週1回、機械学習の勉強会を開催しており、本記事は、勉強会の内容を生成AIを活用して記事にまとめたものものです。 ※勉強会内容公開の経緯は こちら ※過去の勉強会は「社内勉強会」タグからもご覧いただけます。 概要 Qwen2-VL の概要 技術的な特徴 主なベンチマーク結果と性能 関連モデル モデルの利用とライセンス 結論と感想 参考リンク 概要 今回の勉強会ではAlibaba Cloud が開発した Vision-Language Model (VLM) である Qwen シリーズ、特に Qwen2-VL の特徴、性能、関連モデルについて話しました。 調査した動機は、Qwenシリーズは日本語の性能が高いとされており、そのマルチモーダルモデルが画像解析を扱う我々の事業領域にマッチしていることです。またDeepSeek R1の蒸留モデルの中にQwenシリーズがあることが調査の更なる動機です。 具体的にはQwen2-VL の技術的な詳細、ベンチマーク結果、多言語対応、そして最新の Qwen 2.5 VL についてです。 また検索エンジンモデルへの応用事例や、今話題のdeepseekの開発したVLMの簡単な紹介も行います Qwen2-VL の概要 Alibabaが開発しているQwen シリーズには複数のモデルが存在します。今回はその中でマルチモーダルモデルのQwen2-VL に焦点を当てました。 Qwen2-VL は、静止画像だけでなく、ビデオや UI 操作など、多様な視覚モダリティに対応することを目指しています。 モデルサイズには複数のバリエーションがあり、最大で 720億パラメータ、最小で 20億パラメータ程度のものがあります。 パラメータの比較(論文より引用) 技術的な特徴 ここではQwen2-VLで紹介されている特徴の中で特に興味深いものを挙げます。 任意の解像度への対応: 後に解説するRoPEの2次元拡張である2D-RoPEで画像と位置情報をエンコードすることで様々な画像サイズに対応できます。論文中で「Naive Dynamic Resolution」というキーワードで紹介されています。 M-RoPE: RoPE (Rotary Position Embedding) を拡張した Multimodal Rotary Position Embedding (M-RoPE) を導入し、文字列から動画までのモダリティを扱えるようになっています。これにより、1D (文字列)、2D (画像)、そして3D(動画)のエンコードが可能になっています。 主なベンチマーク結果と性能 ここではQwen2-VLで紹介されているベンチマークの結果のうち興味深いものを挙げます。 ベンチマーク比較(論文より引用) 主要なベンチマークで、GPT-4V(ision) や Gemini Pro などの競合モデルと比較して、遜色ない、あるいは一部で上回る性能を示しています。 特に、ドキュメント理解 (VQ) やチャート理解 (UA) のタスクにおいて、良好な結果が得られています。 また複数の言語でのベンチマーク結果で、日本語においても一定の性能を発揮することが示されています。 特にマルチリンガル OCR ベンチマークの結果として、Qwen2-VL が日本語にも比較的良く対応しており、日本語を扱う用途での利用が期待されます。 GPT-4oとQwen2-VL-72Bの多言語での性能の比較(論文より引用) 関連モデル Janus-Pro: DeepSeek が開発したマルチモーダルモデルで、エンコーダーに SigLIP-L を採用しています。SigLIPは固定解像度での入力で、文書画像のような高密度なタスクにおいては Qwen2-VL の方が優位性があるかもしれません。 ColQwen2: Qwen2-VL-2B-Instruct をベースに、画像検索 (Visual Retriever) 用に ColBERT strategy を用いて訓練されたモデルです。 Google の PaliGemma を用いた場合と比較して、Qwen2-VL を用いることで日本語文書検索の性能向上が期待されます。 Qwen 2.5 VL: 最新のバージョンとして言及されており、言語モデルのデコーダーに Qwen 2.5 の言語モデルを使用し、ビジョンエンコーダーの一部を効率化したものが採用されています。既存の API 提供モデルと比較しても遜色ない性能を発揮するようです。 モデルの利用とライセンス Qwen シリーズのモデルは Hugging Face で公開されており、容易に試すことができます。 ただしモデルのライセンスについては注意が必要で、ソースコードのライセンスとモデル自体のライセンスが異なる場合があります。特に商用利用を検討する場合は、ライセンス契約の詳細を確認する必要があります。 具体的には、Qwen2VL-72Bは Qwenライセンス であり、商用利用かつユーザー数が一定以上いるサービスに利用する場合にはライセンス契約が必要です。Qwen2-VL-2B, やQwen2-VL-7Bであれば apache-2.0 なので、もう少し気軽に利用できます。 結論と感想 Qwen2-VL は、画像から動画までの推論や任意の解像度での推論を可能にする Vision-Language Model であり、高いベンチマーク性能と多言語対応能力を持っています。 日本語のベンチマークで高い性能を持った公開モデルは嬉しいですね。 Qwen2.5-VLの動向から今後は言語モデルの進化による推論能力の向上や学習の効率化が見込めそうです。また画像や動画に限らず他のモダリティの拡張もあり得るのではないでしょうか。公開されてるモデルなので今後も動向を伺いたいと思います。 参考リンク リンク一覧はこちらをクリック [2409.12191] Qwen2-VL: Enhancing Vision-Language Model's Perception of the World at Any Resolution Qwen2-VL [2410.07073] Pixtral 12B [2104.09864] RoFormer: Enhanced Transformer with Rotary Position Embedding [2307.06304] Patch n' Pack: NaViT, a Vision Transformer for any Aspect Ratio and Resolution Qwen2.5 Technical Reportの中に潜る - ABEJA Tech Blog Large Vision Language Model (LVLM) に関する最新知見まとめ (Part 1) - Speaker Deck 【Qwen2-VL】画像や動画を異なる解像度で処理できる最新VLM | AI-SCHOLAR | AI:(人工知能)論文・技術情報メディア Qwen2-VL : ローカルで動作するVision Language Model | by Kazuki Kyakuno | axinc | Medium vidore/colqwen2-v0.1 · Hugging Face [2412.15115] Qwen2.5 Technical Report [2501.15383] Qwen2.5-1M Technical Report deepseek-ai/Janus-Pro-1B · Hugging Face deepseek-ai/deepseek-llm-7b-base · Hugging Face GitHub - deepseek-ai/Janus: Janus-Series: Unified Multimodal Understanding and Generation Models https://zenn.dev/yumefuku/articles/pdf-search-colqwen2
アバター
はじめに こんにちは。 バックエンドエンジニアの松本です。今回は、会計システムの開発を通じて、 CADDi におけるプロダクト開発の様子を紹介します。 2024年3月現在、CADDiでは2つのサービスを提供しています。1つは図面データ活用クラウド「CADDi Drawer」で、もう1つは加工品製造サービス「CADDi Manufacturing」です。 今回、後者の加工品製造サービス「CADDi Manufacturing」向けに、 会計システムを構築しました。これは、生産管理システムや拠点管理システムから取得した各種情報を基にして、会計仕訳データを生成し、経理部門に公開する役割を持ちます。 はじめに 会計システムのアーキテクチャとその狙い 計算処理を少しずつ進める 会計数値の妥当性をダッシュボードに表示する 会計システムのモデリングと最初の開発 仕訳の流れを整理して、ドメインモデル、データベースモデルを作る ユーザーの言葉で話す 最初の開発をどの機能にするか検討する 会計というドメインを Rust で表現する New Type Pattern と Phantom Type Pattern 会計台帳を Rust で表現する State Machine を型で表現する おわりに 会計システムのアーキテクチャとその狙い 「CADDi Manufacturing」は、以下の特徴があり、会計システムとしての難しさはここにあります。 多品種小ロットの取引のため、1つ1つの取引ごとの数量が少なく取引数が多い 多くの顧客、多くのサプライパートナーと取引を行うため、サプライチェーンが複雑 計算処理を少しずつ進める システムは生産管理システムや拠点管理システムがデプロイされているKubernetesクラスタ上にCronJobとしてデプロイされています。 CronJobの処理が始まると、対象月の入出荷などのイベントを上流システムのBigQueryから抽出します。そのイベントを会計データに変換し、アプリケーションのCloudSQLに永続化します。最後に、その月の会計データとして経理部門が参照するBigQueryに転送します。 flowchart LR 上流システムのBigQuery -- イベント --> CronJob CronJob -- 会計データ --> CloudSQL CloudSQL -- 会計データ --> 経理部門のBigQuery 会計システムは月に一度、「締め」を行い計算結果を確定し、BigQueryのデータをバランスシートなどを生成するシステムに登録します。月末になり、全てのイベントが上流システムで登録されないと、その月の会計データは確定しません。しかし、後続の会計プロセスが存在するために、「締め」は翌月上旬の数日間のうちに実施する必要があります。 実際にはユーザの入力不備やシステムの不具合が発生することも考えられますから、かなりタイトなスケジュールで原因を特定し修正する必要があります。そこで、もっと早期にこれらの問題を発見できないかと考え、CronJobを毎日実行するようにして、対象月の初日から実行した日の前日までの会計計算を行う仕組みとしました。 gantt dateFormat MM-DD axisFormat %d tickInterval 1month section 3月2日の処理 3月1日まで計算 :2014-03-01, 1d section 3月3日の処理 3月2日まで計算 :2014-03-01, 2d section 3月4日の処理 3月3日まで計算 :2014-03-01, 3d section 3月5日の処理 3月4日まで計算 :2014-03-01, 4d この仕組みにより、月末を待つことなく、毎日少しずつ増えるイベントを対象に実際の処理を実行し、チェックを行うことができるようになりました。結果として、「締め」を余裕を持って行うことができるようになっています。 達人プログラマー第二版 Tip 42 「少しずつ進めること―――常に」 会計数値の妥当性をダッシュボードに表示する 「CADDi Manufacturing」では毎月大量の取引を行っており、人間による妥当性チェックには限界があります。できるだけ自動的に検証することはできないかと考えて、検証機能をデザインしました。 検証機能の1つを紹介しますと、一定期間中の製品の入庫と出庫のイベントによって変動した在庫数量の合計と、その期間の開始と終了の間の在庫数の差分が一致しているかをチェックしています。 flowchart LR 1a[入庫: 2個] --> 1b["イベントの合計: (2 - 1 = 1) 個"] 1c[出庫: 1個] --> 1b 1d[開始時点の在庫数: 1個] --> 1f["在庫数の差分: (2 - 1 = 1) 個"] 1e[終了時点の在庫数: 2個] --> 1f 1b --> 1g[1 == 1: OK] 1f --> 1g 2a[入庫: 2個] --> 2b["イベントの合計: (2 - 2 = 0) 個"] 2c[出庫: 2個] --> 2b 2d[開始時点の在庫数: 1個] --> 2f["在庫数の差分: (2 - 1 = 1) 個"] 2e[終了時点の在庫数: 2個] --> 2f 2b --> 2g[0 != 1: NG] 2f --> 2g この検証機能により、次の項目を検証することが可能になりました。 上流システムが、ヌケモレやダブりなく、入庫、出庫イベントを送信しているか? 会計システムが、間違いなく入庫、出庫イベントを会計データに変換しているか? この検証結果は Datadog 上にダッシュボード化されていて、一目で異常が発生したかどうか、異常の発生した割合がどれくらいかが分かる仕組みとなっています。 Datadog Dashboard 会計システムのモデリングと最初の開発 開発初期は以下の流れで設計を進めました。 仕訳 *1 の流れを整理する ドメインモデルとデータベースモデルを作る 最初の開発をどの機能にするか決める 仕訳の流れを整理して、ドメインモデル、データベースモデルを作る まず、以下の様な図で仕訳の流れを整理しました。 flowchart LR k[買掛金] -- 入荷 --> s[仕掛品] s -- 製品完成 --> p[製品] p -- 原価計上 --> 売上原価 イベントによって、どのように仕訳の勘定科目が移り変わって行くのかを図示しています。例えば1つ目の矢印では、「入荷」というイベントによって、「買掛金」という勘定科目の金額が増えるととともに、「仕掛品」という勘定科目の金額が増えることを示しています。 この図を用いて、生産管理システムで発生する入荷や製品完成などのイベントによって、どのような仕訳が生まれるのかを経理部門と認識を合わせます。 Miro上に描かれたラフなポンチ絵を使っておおまかに擦り合わせていきます。 そして、以下のようなドメインモデルとデータベースモデルを初期に作成し、経理部門にレビューしてもらいながら進めていたのですが、ここで違和感を感じ始めます。 データベースモデル ドメインモデル ユーザーの言葉で話す レビュー会では目立った指摘を受けることなく設計が進んでいました。手戻りが少ないのは嬉しいですが、正しいものがきちんと設計できているのか、不安視する声もエンジニアからは上がってきます。 そんなある日、とあるレビュー会で処理の内容を説明するために、仕訳の表を用いて説明をしたときのことです。経理部門からはいつもよりも多くの発言を頂き、とても有意義なディスカッションが実施できたのを記憶しています。 仕訳の表 考えてみれば、ドメインモデルやデータモデルはエンジニアの言語です。仕訳の表は経理部門の言語です。経理部門の言語でエンジニアが会話したことにより、経理部門の理解が進んだ結果、有意義なディスカッションが発生したのだと考えています。 ドメインエキスパートの日々の仕事内容にまで踏み込んで会話して初めて、良いプロダクトができる、ということを実感したエピソードでした。 達人プログラマー第二版 Tip 78 「ユーザーとともに働き、ユーザーのように考える」 最初の開発をどの機能にするか検討する 設計は進めていたものの、開発すべき仕訳の種類は多種多様で、どこから手をつければ良いか全く検討がついていませんでした。ただ、チームでは次に該当する機能を開発してリリースしよう、と話をしていました。 仕訳はごく一部にしぼる システムアーキテクチャ全体を串刺す 一部でもビジネスに貢献できる 最終的に、製品仕訳についてイベントを収集して検証する機能を開発することに決定しました。 製品仕訳に関わるイベントの収集 製品仕訳に関わる仕訳の生成と保存 製品仕訳と在庫数の検証 製品仕訳について、システムアーキテクチャ全体を串刺して開発することにより、アーキテクチャに起因するリスクを早期に洗い出す狙いです。この機能はうまく完成し、その後は取り扱う仕訳の種類を増やしていくことで開発を進めることができました。これは、「曳光弾」と呼ばれる開発手法です。 達人プログラマー第二版 Tip 20 「目標を見つけるには曳光弾を使うこと」 会計というドメインを Rust で表現する New Type Pattern と Phantom Type Pattern 金額や数値、IDなどの単純な項目は基本的に "New Type Pattern" を使用しています。"New Type Pattern"を使用することで、在庫数を金額に代入してしまうような、単純な代入のミスによる不具合の発生を防ぐことができます。 同種の値は同じようなロジックを持つ事が多いですから、 "Phantom Type Pattern" の利用も積極的に行います。 "Phantom Type Pattern" については以下の記事を参照ください。 caddi.tech 下の例をご覧ください。加工後の数である ProcessedQuantity と在庫数である InventoryQuantity を別の型として表現しています。さらに、"Phantom Type Pattern"を使用して i32 との相互変換処理は共通のものを定義しています。 use std :: marker :: PhantomData; pub struct TaggedQuantity < T: quantity_type :: QuantityType > { value: i32 , quantity_type: PhantomData < T > , } pub type InventoryQuantity = TaggedQuantity < quantity_type :: Inventory > ; pub type ProcessedQuantity = TaggedQuantity < quantity_type :: Processed > ; pub mod quantity_type { use std :: fmt :: Debug; // Trait 制約をつけるための trait pub trait QuantityType : Eq + PartialEq + Debug {} // PhantomData の型パラメータに渡すための抽象的な型 #[derive( Debug , Eq , PartialEq )] pub struct Inventory ; impl QuantityType for Inventory {} // PhantomData の型パラメータに渡すための抽象的な型 #[derive( Debug , Eq , PartialEq )] pub struct Processed ; impl QuantityType for Processed {} } impl< T: quantity_type :: QuantityType > TaggedQuantity < T > { pub fn signum ( & self ) -> i32 { self .value. signum () } } impl< T: quantity_type :: QuantityType > From < i32 > for TaggedQuantity < T > { fn from (value: i32 ) -> Self { Self { value, quantity_type: PhantomData :: < T > {}, } } } 会計台帳を Rust で表現する 会計台帳を表現する会計仕訳のコードサンプルは以下です。 // 一定期間の台帳全体 pub struct AccountingJournal { id: JournalId, transactions: Vec < AccountingTransaction > , } // 台帳の1行 pub struct AccountingTransaction { id: AccountingTransactionId, accounting_date: AccountingDate, occurred_at: EventDateTime, entries: AccountingEntrySet, } pub enum AccountingEntrySet { // 製品完成というイベントに対応するレコード ProductComplete ( AccountingInventoryEntry, AccountingWorkInProcessProductEntry, ), // ・・・ 各種イベントごとの定義が続く } // 台帳の1行を構成する要素で、勘定科目「製品」の金額を示す pub struct AccountingInventoryEntry { id: EntryId, amount: TotalAmount, quantity: InventoryQuantity, } // 台帳の1行を構成する要素で、勘定科目「仕掛品」の金額を示す pub struct AccountingWorkInProcessProductEntry { id: EntryId, amount: TotalAmount, quantity: InventoryQuantity, } 台帳全体を表す AccountingJournal 、台帳の一行を表す AccountingTransaction 、1つの金額と勘定科目をセットにした Accounting**Entry などの要素を用いて台帳という概念を表現しています。 最終形に至るまで何度もこのドメインの設計は見直しを行っています。最初はチームに会計知識が少ないところからスタートしましたが、開発を経るごとに知識が高まり、以前に書かれたコードの見直しが必要になったためです。 ドメイン知識をRustのような言語で厳密にコード化すると、コンパイラに指摘された箇所からドメインへの理解が曖昧な点が分かることがあります。そのような気づきからドメイン知識をアップデートしてコードを改善し、ドメインへの理解を深めていく活動はとても楽しいものです。 達人プログラマー第二版 Tip 65 「早めにリファクタリングすること、そしてこまめにリファクタリングすること」 State Machine を型で表現する もう1つコード例を紹介しましょう。 バッチ処理は以下の流れで実行されます。 初期化 イベントから仕訳(Journal)を生成する 検証してReportを生成する 以下は、1回のバッチ処理の進捗状況を示すクラスです。 // 初期化後の状態 pub struct CreationSetInitialized { id: JournalCreationSetId, target_month: YearMonth, } impl CreationSetInitialized { pub fn create_journal ( self , journal_id: JournalId) -> CreationSetJournalCreated { CreationSetInventoryCreated { id: self .id, target_month: self .target_month, journal_id, } } } // 仕訳(Journal)生成後の状態 pub struct CreationSetJournalCreated { id: JournalCreationSetId, target_month: YearMonth, journal_id: JournalId, } impl CreationSetJournalCreated { pub fn create_report ( self , report_id: ReportId, ) -> CreationSetReportCreated { CreationSetReportCreated { id: self .id, journal_id: self .journal_id, report_id, } } } // Report生成後の状態 pub struct CreationSetReportCreated { id: JournalCreationSetId, target_month: YearMonth, journal_id: JournalId, report_id: ReportId, } 状態ごとに別々の型を定義しています。処理が進むに従って情報が追加されるので、フィールドが増えていくようにしています *2 。このような実装にすることで、以下のメリットがあります。 状態ごとに型が定義できるので可読性が高くなる Option を排除して分岐を少なく記述できる おわりに 今回は、会計システムのアーキテクチャと設計の進め方、Rustの実装サンプルを紹介しました。 会計システムでは、モノづくり産業のほんの一部である会計という世界をシステムに落とし込む難しさ、面白さに向き合うことができました。CADDiでは、「リアルな世界をシステムに落とし込む難しさ×面白さ」に向き合う開発エンジニアを募集しています。 エンジニア向け採用情報 *1 : 企業のお金の流れを記録するもの *2 : Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# という本を参考にしました
アバター
TL;DR エラーハンドリングを行う目的 エラーハンドリングが適切に行われているとどう嬉しいか 1. エラーの発生原因が分かる 2. レスポンスステータスを型安全に出し分けることが可能になる どうエラーハンドリングを行うのか 実装方法 エラー型の定義で気を付けるべきポイント なぜanyhowを利用しないのか エラーハンドリングを行う上で持っている課題感 Drawer Growth グループ バックエンドエンジニアの中野です。今回は、私が所属するチームで gRPC API を開発する際に実践している Rust でのエラーハンドリングについて紹介していきます。 TL;DR エラーの発生原因がわかるようにエラー型を定義することが大切。 anyhow は使わずに自前のエラー型を定義して利用する。 エラーハンドリングを行う目的 そもそもなぜエラーハンドリングを行う必要があるのでしょうか。私が所属するチームでは、以下目的を達成するためにエラーハンドリングを行っています。 発生したエラーに関する情報をログに含めて、調査しやすくするため。 API の利用者に適切なレスポンスを返すため。 エラーハンドリングが適切に行われているとどう嬉しいか エラーハンドリングが適切に行われている場合、我々は以下のような出力をログに埋め込むことができるようになります。 DrawingServiceError * DrawingPageUseCaseError * DrawingPageError * Failed to cast 0 to PageNumber. PageNumber should be greater than 0. * out of range integral type conversion attempted 実行可能なコードは こちら 。 エラーハンドリングを適切に行なった場合に嬉しいポイントは2つあります。 1. エラーの発生原因が分かる 調査の際に「該当エラーはどの経路を通ってなぜ発生したのか」がログからすぐにわからないと辛いです。例えばどの API が呼び出されて発生したエラーなのか、コードベースにおけるどのレイヤーで発生したエラーなのか、といった情報がログを見るだけでわかると調査がスムーズに進みます。 エラー発生元のメッセージを見てみます。 * Failed to cast 0 to PageNumber. PageNumber should be greater than 0. * out of range integral type conversion attempted この出力がポイントで、このメッセージを見るだけで、「PageNumber は 0 より大きい数字である必要があるが、0 を PageNumber にキャストしようとして失敗した」という情報を得ることができます。 これがもし、以下のようなメッセージだと、ログを見るだけでは何が問題だったのかわからなくなり、エラーハンドリングを行なう旨みが半減してしまいます。 // 悪い例1 // `std::num::TryFromIntError`に定義されたメッセージだけが出力される * out of range integral type conversion attempted // 悪い例2 // 0を何にキャストしようとして失敗したのか」がわからないメッセージが出力される * Failed to cast 0 * out of range integral type conversion attempted これらの例を見るとわかるように、エラーメッセージを定義する際にはアプリケーションにおける文脈をエラーメッセージに残すことが大切です。 (補足)stack trace を出力することでもエラーの発生経路はわかります。しかし、以下の技術的な理由から私たちのチームでは stack trace を出力することをやめました。 チーム開発としてどこでエラーログを出力するのかポリシーを決めるのが難しかった。 stack trace を出すにはエラーが発生した箇所でログを出力する必要があるが、ログ出力するコードを書くのを忘れる可能性がある。 そのため、stack trace が本来持っている役割の一部をエラーメッセージに持たせる設計としています。 2. レスポンスステータスを型安全に出し分けることが可能になる 何かエラーが発生し、API 呼び出しが失敗した場合には、発生したエラーによって ステータスコード を出し分ける必要があります。エラーを型で表現していると、このステータスコードの出し分けを型安全に行うことができて嬉しいです。 後ほど具体的に紹介する方法を使うと、エラー型を新しく定義するたびにエラーをステータスコードに変換する箇所でコンパイルエラーが発生し、エンジニアにステータスコードの定義を強制させることができます。そのためステータスコードへの変換漏れや意図しないステータスコードに変換されてしまう可能性をなくすことが可能です。これによって、問題に気づくのがランタイムからコンパイル時にシフトレフトでき、エラーを定義するする手間を考えてもトータルの開発スピードを向上させることができると考えています。 どうエラーハンドリングを行うのか 実装方法 必要なエラー型を Enum で定義していきます。この際、 実装を簡略化するために thiserror という crate を用いています。 thiserror::Error を derive すると、自分で実装しなくてもコンパイル時に std::error::Error trait が実装され楽をできます。 以下は DrawingUseCase で利用するためのエラー型の例です。 #[from] attribute をつけると From trait が実装されるので、 ? 演算子を使ってエラーハンドリングしていくことが可能になります。 このようなエラー型を我々のチームでは基本 trait 毎に定義するようしています。 use thiserror :: Error; #[derive( Debug , Error)] pub enum DrawingUseCaseError { #[error( "DrawingRepository" )] DrawingRepository ( #[from] DrawingRepositoryError), #[error( "CompanyRepository" )] CompanyRepository ( #[from] CompanyRepositoryError), } // DrawingUseCase trait pub trait DrawingUseCase { async fn create_drawing ( & self , command: CreateDrawingCommand, ) -> Result < Drawing, DrawingUseCaseError > ; } // create_drawingの実装例 fn create_drawing ( & self , command: CreateDrawingCommand, ) -> Result < Drawing, DrawingUseCaseError > { // この関数は Result<Company, CompanyRepositoryError>を返り値に持つ // CompanyRepositoryErrorに対して#[from] attributeがつけられているので、 // ?演算子でDrawingUseCaseErrorへの変換が可能になっている。 let company = get_company_by_name (command. company_name ()) ? ; ... drawing } エラーを伝播させていくと、最終的には API のエンドポイントとなる箇所において自分たちで定義したエラー型を tonic::Status に変換する必要があります。このマイクロサービスの実装では、gRPC サービスを実装する際のデファクトである tonic という crate を利用しています。別の crate を利用している場合には、 tonic::Status の箇所を適宜ステータスコードを表す別の型に読み替えてください。 この変換処理を行うために、定義したエラー型を tonic::Status に変換する ToErrorStatus trait と ErrorHandler struct を定義します。 trait ToErrorStatus { fn build ( self , error_message: String ) -> tonic :: Status; } struct ErrorHandler < 'a , Error: std :: error :: Error > ( & 'a Error); そして ToErrorStatus trait をこれまで定義してきたエラー型に対してそれぞれ実装していきます。ここでは DrawingUseCaseError に対する ToErrorStatus の実装だけを例に出していますが、同様にその他のエラー型に対しても ToErrorStatus を実装していく必要があります。例えば、 ErrorHandler(repository_error).build(error_message) が呼び出されているので RepositoryError 型にも ToErrorStatus を実装する必要があります。 impl ToErrorStatus for ErrorHandler < '_ , DrawingUseCaseError > { fn build ( self , error_message: String ) -> Status { use DrawingUseCaseError :: * ; match self . 0 { DrawingRepository ( DrawingRepositoryError :: Repository (repository_error)) => { ErrorHandler (repository_error). build (error_message) } DrawingRepository ( DrawingRepositoryError :: ParseDrawingId (_)) => { Status :: with_error_details ( Code :: Internal, error_message, ErrorDetails :: new ()) } CompanyRepository ( CompanyRepositoryError :: Repository (repository_error)) => { ErrorHandler (repository_error). build (error_message) } } } } 最後に、以下のような関数を定義して、gRPC API のエンドポイントとなる Result<tonic::Response<HogeResponse>, tonic::Status> を返り値とする関数内で呼び出せるようにします。あとはエンドポイントとなる関数内でエラーハンドリングを行う際に、必要に応じて to_error_status 関数を呼び出せば OK です。 pub ( crate ) fn to_error_status (error: impl Into < GrpcServiceError > ) -> Status { use GrpcServiceError :: * ; let error: GrpcServiceError = error. into (); let error_message = error. to_traverse_error_message (); let mut status = { let error_message = error_message. clone (); match & error { DrawingService (service_error) => { ErrorHandler (service_error). build (error_message) } CompanyService (service_error) => { ErrorHandler (service_error). build (error_message) } } }; status. set_source ( Arc :: new (error)); // 任意の方法でログを出力する // println!("{error_message}"); status } #[derive( Debug , Error)] pub ( crate ) enum GrpcServiceError { #[error( "DrawingService" )] DrawingService ( #[from] DrawingServiceError), #[error( "CompanyService" )] CompanyService ( #[from] CompanyServiceError), } このコードで利用している to_traversal_error_message メソッドはエラーの source を辿って全てを 1 つの String にまとめるための関数です。以下のエラーメッセージは to_traverse_error_message メソッドを利用して出力した例です。 DrawingServiceError * DrawingPageUseCaseError * DrawingPageError * Failed to cast 0 to PageNumber. PageNumber should be greater than 0. * out of range integral type conversion attempted to_traverse_error_message メソッドを利用しない場合、 DrawingServiceError だけが出力され、 DrawingServiceError の source を辿ったそれ以下の出力はなくなります。 to_traverse_error_message メソッドの実装は こちら にあるので、気になる方は確認してみてください。 このメソッドと同様のことは anyhow の debug 出力でも可能ですが、以下の理由から自前で関数を実装しています。 anyhow で wrap するのが面倒だった。 anyhow の他の機能は不要で debug 出力だけが欲しかった。 anyhow を使いたくなかったので、間違えて利用することが無いように crate から依存を外したかった。 エラー型の定義で気を付けるべきポイント 気を付けるべきポイントとして「エラー型を共通化しないこと」が挙げられます。我々のチームの場合、Infra 層で利用している crate である sea-orm が返すエラーをマッピングしている RepositoryError 型以外は基本的に共通化せず個別で定義するようにしています。そのため、例えば以下のように、2 つの別の UseCase のエラーの variants の中身がほぼ同じになることもあり得ます。 // 良い例 use thiserror :: Error; #[derive( Debug , Error)] pub enum DrawingUseCaseError { #[error( "DrawingRepository" )] DrawingRepository ( #[from] DrawingRepositoryError), #[error( "CompanyRepository" )] CompanyRepository ( #[from] CompanyRepositoryError), } #[derive( Debug , Error)] pub enum SalesUseCaseError { #[error( "DrawingRepository" )] DrawingRepository ( #[from] DrawingRepositoryError), #[error( "SalesRepository" )] SalesRepository ( #[from] SalesRepositoryError), } エラー型の variants に重複が増えてくると、つい「 DrawingUseCaseError と SalesUseCaseError を統合して UseCaseError にしてしまおう」という誘惑に駆られるのですが、それはあまりいいアイデアではありません。 // よくない例 use thiserror :: Error; #[derive( Debug , Error)] pub enum UseCaseError { #[error( "DrawingRepository" )] DrawingRepository ( #[from] DrawingRepositoryError), #[error( "CompanyRepository" )] CompanyRepository ( #[from] CompanyRepositoryError), #[error( "SalesRepository" )] SalesRepository ( #[from] SalesRepositoryError), } なぜなら、そうしてしまうと前述したエラーハンドリングを適切に行なった場合の嬉しいポイントである「何が原因でエラーが発生したのかが分かる」が失われてしまうからです。 UseCaseError 以外も全て共通化してしまった場合、ログの出力は以下のようになり、「0 を PageNumber にキャストしようとして発生したエラーである」ことしかわからなくなってしまいます。 ServiceError * UseCaseError * DomainError * Failed to cast 0 to PageNumber. PageNumber should be greater than 0. * out of range integral type conversion attempted なぜanyhowを利用しないのか Rust でエラーハンドリングを行う際によく名前が挙がる crate として anyhow がありますが、上記説明の通り我々は anyhow を利用していません。 anyhow の利用例はリポジトリのサンプルを見るとよくわかります。anyhow を利用すると、関数の返り値を anyhow::Result<i32> のように定義することで関数内では ? 演算子を使うだけでよくなり、自分でエラー型を定義する手間が省けます。一時的に利用するだけのスクリプトを書く際や利用者が限られる開発者用ツールを作る際など、エラー型を厳密に定義する必要がなく、anyhow を使うと楽に実装できるケースも多々あります。しかし、我々のケースのようにクライアントから利用される Web API を作る場合にはステータスコードの出し分けが必要であるはずですし、運用のために適切なログも必要になるはずです。この場合多少面倒でも、自分自身でエラー型を定義した方が型安全でデバッグに有益なコードを書くことができ、トータルの生産性は高くなると考えています。 実は以前、弊社で別のアプリケーションにおける Web API を作る際に anyhow を用いて実装したことがあったのですが、とても辛い結果になったという過去があります。具体的な辛いポイントとしては以下の要素などが挙げられます。 context を引き回すために常に with_context をつけて回らなければいけない。 ログを見てもエラーの発生箇所を示すだけで原因がわからない。 ステータスコードの出し分けがエラーメッセージの文字列に頼るしかない。 エラーハンドリングを行う上で持っている課題感 ステータスコードの出し分けをする箇所の実装がごちゃつくことを現状の課題感として持っています。コードベースの成長に伴い UseCase や Infra、Domain 層の種類も増え、 ToErrorStatus を実装するコードがどんどん肥大化して見通しが悪くなってきます。型で守ることができているとはいえ、ここはもう少し上手くやる方法がないか頭を悩ませているところです。
アバター
はじめに こんにちは。CADDiでバックエンドエンジニアとして働いている中山です。 今日は、プロダクト開発において大量Seedデータの管理基盤としてAirtableを使ったら開発体験が素晴らしかったのでご紹介しようと思います。 ※ 以下の内容はAirtableの契約プランによって機能が異なること、執筆時にはできないが今後機能が追加されてできるようになっている可能性があることはご了承ください。 はじめに 背景 Airtableとは Airtableでできること UI上で操作が完結し、データの追加/編集がサクサクできる 表計算ソフトでおなじみの便利機能がたくさんある Web APIでCRUD操作ができる IDの生成をAirtableにお任せできる RDBのようにテーブル間にリレーションを作成できる Airtable Automation & Airtable Scripting 細かく権限管理ができる Airtableでできないこと データベース間で同期できるテーブル数に上限がある。 RDBのようなカスケード削除の機能がない 実際に使ってみて おわりに 背景 私が開発に携わっているプロダクトではDBのテーブル数が80程度あり、そのうち約半数のテーブルにSeedデータ(※1)を投入する必要があります (このプロダクトの詳細については割愛させてください、それだけで記事になってしまいます)。開発当初はコード上でデータを定義していましたが、以下の課題がありました。 データの量が多く開発工数が膨らむ データ実装だけで1スプリント終わってしまうなんてことも、、、 実装ミスが多発。レビューでも気づかれずに不具合に データ間のリレーションや実装漏れなど いい感じの変数名を考えるのが面倒 少しのデータ変更を反映するだけでもリリースサイクルに合わせないといけない これらの課題を解決するために、我々のチームは SeedデータをAirtableで管理する ことを決めました。 ※1: ユーザーがシステムを使うために最初にDBに入れておく必要があるデータ (例: フォームで使う選択肢) Airtableとは Airtableは表計算ソフトとデータベースの機能を併せ持つ、Airtable社が提供しているクラウドベースのデータベースツールです。 airtable.com UIは以下のようになっていて、RDBでいうところのUser(左上のタブ)がテーブルを、行がレコードを、列がカラムを表しています。以下はサンプルでUserとCompanyテーブルを実装しています。 AirtableのUI Airtableでできること UI上で操作が完結し、データの追加/編集がサクサクできる AirtableはGUIベースで操作でき、コードで実装するよりも格段に早くデータを作成することができます。また、一般的な表計算ソフトと同じような感覚で使えるため、エンジニア以外でも簡単に操作することができます。 またコードだといい感じの変数名を考える必要があり面倒(似たような名前の表現を迷ったり、やたらと長い変数名になってしまったり)でしたが、Airtableであれば不要です。 表計算ソフトでおなじみの便利機能がたくさんある AirtableではSortやFilter、GroupBy、Lookupといった表計算ソフトでおなじみの機能が使えることでデータの視認性が格段に向上します。 また、カラム毎にデータ型の入力制限(テキスト、 数値、 選択肢、 .etc)や、関数による値の自動生成、テーブルやカラムに説明文の記載といった便利な機能がたくさんあります。以下の例だと、age列には数値以外入力できないようにし、Name列には関数でLastNameとFirstNameを結合させる、みたいなことができます。 カラムのカスタマイズ例 これらの機能を使いこなすことによって、ミスの予防や早期発見に繋がり安全に早く開発できるようになりました。 Web APIでCRUD操作ができる AirtableにはWeb APIが用意されており、基本的なCRUD操作が可能で、JavaScript(TypeScript)やRuby、 .NET、 Python等で書くことができます。 このAPI経由でAirtableからDBへデータを投入しています。 Airtable Web API IDの生成をAirtableにお任せできる AirtableのレコードにはデフォルトでRecord IDが付与されます(わかりやすいようにテーブルに表示させています)。 このIDもAPIで取得できるため、そのままDBのIDとして使うことができます。 Record ID RDBのようにテーブル間にリレーションを作成できる AirtableにはLinkedRecordというデータ種別があり、以下だとUserとCompany列がそれにあたります。 Linking Records in Airtable | Airtable Support UI上では”A株式会社”や”山田太郎”のような値が表示されていますが、セルに格納されている値はRecord IDです。 APIで取得できるのもRecord IDなので、そのままRDBの外部キー制約を満たす形で投入できます。(これが表計算ソフトとデータベースツールの機能を併せ持っていることの良さです) このLinkedRecordのリレーションは1:1、1:n、n:m全てに対応しており、設定で制限をかけることもできます。 Linked Record Airtable Automation & Airtable Scripting Airtable Automationsという機能があり、簡単なWorkflowを組むことができます。 Airtable Automations - Get More Work Done | Airtable 例えば、以下ではUserテーブルにレコードが作成されたら特定のSlackチャネルにメッセージを通知する、みたいなことができます。(もちろんもっと色々できます) Airtable Automationsの例 また、Airtable Scriptingを使えば、JavaScriptで書いたスクリプトをWorkflowに組み込むこともできます。 Airtable Scripting 我々のチームではデータの入力漏れがないかを定期的にチェックするスクリプトを実装してミスを早期発見できる仕組みを自動化していました。 細かく権限管理ができる Airtableでは、ユーザー毎に細かく権限管理ができます。 例えば、操作に慣れているエンジニアのみレコードの削除が可能、であったり管理者以外はテーブルの定義(カラムのデータ型やFilter条件など)を変更できないようにするなど、用途に合わせて自由度高く権限を設定することができます。 これによって操作に不慣れなメンバーの操作ミスによって環境が壊れてしまった、などのリスクを減らすことができます。もし環境が壊れてしまった場合でもバックアップされているのでSnapshotによって過去の状態に戻すことも可能です。 今回紹介した機能はごく一部で、Airtableにはまだまだ便利な機能があるので興味ある方は公式ドキュメントを御覧ください。 airtable.com Airtableでできないこと Airtableを活用することで様々なメリットがあることを紹介しましたが、実現できなかったこともあります。 データベース間で同期できるテーブル数に上限がある。 通常、開発環境毎にAirtableを用意して運用すると思いますが、最上位プランでもAirtable間で同期できるテーブル数に上限があり全てのテーブルを同期できませんでした。 Getting started with Airtable sync | Airtable Support 手動によるデータ同期は手間やミス予防の観点で許容できなかったため、全環境に対して共通のAirtable1つだけで運用しています。 不安はありましたが、データを反映する際はdevelopやstaging環境で、データ反映後に動作確認したうえで本番環境に反映させるため、半年以上運用してトラブルになったことはほとんどありません。 RDBのようなカスケード削除の機能がない 先述した通りLinkedRecordという仕組みでデータ間にリレーションを作成することができますが、RDBのカスケード削除のような依存関係のあるデータを一括で削除する仕組みがありません。(ネットには要望の声が多数あり、将来的には実装されるかもしれません) 手動で関連するデータを削除してまわる運用ではミスを防げないため、我々のチームではデータを削除したい場合はフラグを付けてFilterでデータ投入対象から弾くという工夫をして運用しています。 上記のようにできないことはあるものの、今のところ運用の工夫でカバーできています。 実際に使ってみて 結論としてAirtableをSeedデータの管理基盤にしたことは良い判断だったと思います。 一番良かったことはデータの更新サイクルを素早く回せるようになったことだと思います。 リリースサイクルとは別にAirtableを変更してデータ投入のジョブを実行するだけで反映できるため、ちょっとした文言の変更やフォームの選択肢を一つ追加してほしい、といった要望に対して素早く対応できるようになりました。 加えてデータの実装速度が向上し、かつミスも減少したことで開発効率が劇的に改善し、機能開発など本質的な開発に多くの時間を使えるようになったことも大きなメリットです。 おわりに 本稿では、Seedデータの管理基盤としてAirtableを活用することの利点やできないこと、それに対する運用上の工夫を紹介しました。もし大量のSeedデータを取り扱うことになったとき、Airtableを使う方法があるということを選択肢の一つとして検討してもらえれば幸いです。 CADDiでは現在、私たちと一緒に開発を推進してくださるメンバーを募集しています。 以下に採用情報を掲載しますので、興味のある方はぜひご連絡お待ちしています! recruit.caddi.tech
アバター
※本記事は、技術評論社 「Software Design」(2024年1月号) に寄稿した連載記事「Google Cloudを軸に実践するSREプラクティス」からの転載 1 です。発行元からの許可を得て掲載しております。 はじめに 前回はDatadogによるクラウド横断のモニタリング基盤について解説しました。 今回は Cloudflare とは何か、なぜ使っているのか、各サービスとポイント、キャディでの活用例を紹介します。 ▼図1 CADDiスタックにおける今回の位置付け Cloudflare とは 本記事では、Cloudflare社が提供しているプラットフォーム全体を「Cloudflare」とします。 Cloudflareは、ひと昔前までは数あるシンプルな CDN(Contents Delivery Network) サービスの1つでした。CDNとは、コンテンツの配信を最適化するためのネットワークです。コンテンツキャッシュを利用して、エンドユーザーにより早く効率的にコンテンツを配信できます。 近年、CDN事業者が提供するサービスは、単なるCachingやLoad Balancingだけではなくなってきています。Edge Cloud/Edge ComputingやSecurity領域など、Webアプリケーションのネットワーク上の“Edge”であることを活かしたさまざまなサービスを展開しています。その中でもCloudflareは、筆者の知る限り、最も先進的で幅広いスコープのサービスを提供するプラットフォームです。 執筆時点では、Cloudflare社は提供しているプラットフォーム全体を コネクティビティクラウド と呼んでいます。また、それを構成する要素として次の3つのサービスがあります(図2)。 アプリケーションとインフラストラクチャサービス 開発者サービス SASE(Secure Access Service Edge) と SSE(Security Service Edge) のサービス ▼図2 Edge ServerとOrigin Server なぜCloudflareか キャディでは2019年にコーポレートサイト(Origin Server)の負荷を下げるためのCDNとしてCloudflareを使い始めました。理由は単純で、非常に安い費用で利用開始できたからです。 費用が安いことはCloudflareの大きな魅力の1つで、無償から使えるさまざまなサービスを提供しているため、個人開発での利用にもお勧めです。きっかけこそCDNとしての費用が理由ではありましたが、現在はCDN以外のさまざまなサービスを利用しています。 筆者が過去に所属していた企業では、 Akamai や Fastly を利用していました。とくにFastlyは、Developer Friendlyで、柔軟かつ積極的なキャッシュ戦略をとれるのが魅力です。それが大量のコンテンツを配信するB2Cのサービスにはマッチしていて重宝していました。 一方、キャディはB2Bのサービスを提供しており、要件的にそれほど高度なキャッシュ戦略を必要としていません。そのため今となっては「先進的で幅広いサービスを提供しているプラットフォームで、それが事業にマッチしているから」という理由にとらえなおしています。 各サービスの紹介とポイント 誌面の都合上、Cloudflareのサービスを網羅的に紹介することは難しいので、いくつかピックアップしてポイントを解説します。 Cloudflare DNS Cloudflare DNS は一部機能を除いて無償で使えるDNSサービスです。 Cloudflare DNSを使ってDNS Recordを管理すると、図3のように設定画面上にProxy statusが表示され、このフラグをON(Proxied)にすることでEdge Serverへ向き先が変わります。nslookupすると、Proxy statusの状態によって返ってくる値が違うことを確認できます。DNSの設定は octoDNS やTerraformでIaCしておくことをお勧めします。 また、Business Plan以上であれば、 任意のDNSプロバイダでCNAMEを指定 してEdge Serverへプロキシできます。つまり、Cloudflare DNSの利用は、Cloudflare CDNを使うための必須条件はでありません。 ▼図3 DNS Recordの画面 Origin Serverの保護 前述のとおり、Cloudflare DNSのProxy statusを変更するだけで、リクエストを簡単にEdge Server経由に切り替えられます。とはいえ、より安全かつ効率的に運用していくための準備が必要です。その準備の1つとして「Origin Serverの保護」について触れておきます。 前提として、Edge Server側でさまざまな最適化をするためにEdge Serverを経由させたいので、Origin Serverへの直接アクセスされるとその最適化が効かなくなってしまいます。仮にOrigin Serverの場所がユーザーや攻撃者に漏れてしまっても、そのリスクを最小化するために「 Origin Serverの保護 」が必要です。具体的には、Origin Server側がEdge Serverを経由していないリクエストをブロックしたり、Cloudflareへの専用接続を作ったりします。方法には次のようなものがありますが、やり方によってセキュリティレベルや実装難易度、費用が変わってくるので、提供するプロダクトの要件しだいでどれを選択するか決めましょう。 アプリケーションレイヤ Cloudflare Tunnel (HTTP / WebSockets) HTTP Header Validation JSON Web Tokens (JWT) Validation トランスポートレイヤ Authenticated Origin Pulls Cloudflare Tunnel (SSH / RDP) ネットワークレイヤ Allowlist Cloudflare IP addresses Cloudflare Network Interconnect Cloudflare Aegis 「Cloudflare Tunnel」は、暗号化されたCloudflare専用の接続を構築することで、Origin Serverへの入口を非公開にできます。「HTTP Header Validation」は、Edge Serverで任意のヘッダを付与し、Origin Serverでそのヘッダがあるもののみ受け入れます。「JWT Validation」は、Cloudflare Access(後述)で付与された正しいJWTかどうかを検証して受け入れます。「Allowlist Cloudflare IP addresses」は、アクセス元が公開されているCloudflareのIPだったときのみ受け入れます。 手軽にやりたい場合は、「HTTP Header Validation」や「JWT Validation」がお勧めです。 Web Application Frameworkのミドルウェアで検証してもよいですが、Backend Applicationより前のレイヤで検証して関心事を分離するのもよいでしょう。GKE の場合、Service Mesh(Envoy)で検証できます。CloudRunの場合でも サイドカーが実装できるようになった ので、同様にEnvoyでの検証ができます。 Google Cloud Armor を使う場合、「Allowlist Cloudflare IP addresses」が選択肢の1つになるでしょう。しかし、Cloudflare WAF(後述)を使う場合、WAFが重複してしまうことや、最新のCloudflareのIPに追従する機能の費用が高いことに注意が必要です。 そのほかの準備や詳細は、 公式のGettingStarted を参照してください。 証明書 Edge Serverの証明書 は、デフォルトではCloudflareが発行したManagedな証明書を利用します。もちろんすでに別途発行済みの証明書をアップロードして使うこともできます。 また、「クライアントとEdge ServerとOriginServer間」の通信を常に暗号化しておくため、 encryption modes(暗号化モード) をFull以上にして、Origin Serverの証明書を設定しましょう。暗号化モードがFull未満の場合、「クライアントとEdge Server間」「Edge ServerとOriginServer間」のどちらか、または両方が暗号化なしで接続されます。 CloudflareにはOrigin CA(CertificateAuthority)証明書の発行機能があるので、それを利用するのがお勧めです。Kubernetesを使っている場合、cert-managerとOrigin CAIssuerを使って 証明書の発行を自動化 できます。 Cloudflare Cache Cloudflare Cache は、Cloudflare CDNのコア機能です。コンテンツをEdge Server側でキャッシュすることにより、Origin Serverの負荷を下げつつ、エンドユーザーにより早くコンテンツを配信します。 基本となるデフォルトのキャッシュ動作は次のとおりです(もちろんカスタマイズにより、ほかのルールや設定にオーバーライドされる可能性があります)。 キャッシュされないケース: ache-Control ヘ ッ ダ に「private」「no-store」「no-cache」「max-age=0」が設定されているとき Set-Cookieヘッダが存在するとき キャッシュされるケース: Cache-Controlヘッダがpublicに設定され、max-ageが0より大きいとき Expiresヘッダが未来の日付に設定されているとき デフォルトでは、HTMLはキャッシュされず定義されているファイルの拡張子をもとにキャッシュされます。たとえばJS/CSS/JPG/SVG/CSV/ICOなどが対象です。 実際のレスポンスがキャッシュされたものかどうかを確認するには、 CF-Cache-Status ヘッダを参照します。ヘッダの値が HIT であればEdge Serverにキャッシュされたコンテンツであり、 MISS であればOrigin Serverから返されたコンテンツです。 Edge Serverのキャッシュを削除(パージ)したいときは、Cloudflare DashboardやWeb APIのどちらからでも実行可能です。ただし、Enterprise Plan以外はURL単位でしかパージできません。たとえば、Webアプリケーションのデプロイ時に特定領域のキャッシュをまとめてパージできると便利なのですが、Enterprise Plan以外はそのオプションを利用できません。 また、キャッシュパージ以外にも契約しているプランごとに表1のような制限があります。 ▼表1 プラン別の制限内容 Free Pro Business Enterprise HTTP POST Requestサイズ上限 100MB 100MB 200MB デフォルト500MB(変更可能) キャッシュ可能なファイルサイズ上限 512MB 512MB 512MB デフォルト5GB(変更可能) キャッシュパージの単位 URL URL URL URL,Hostname,Tag,Prefix Web Application Security Cloudflare CDN を利用すると、自動的に Cloudflare WAF や Cloudflare DDoSProtection が有効になり、セキュリティを強化できます。どちらも無償から利用可能で、プランをアップグレードするとより高度な機能が利用できます。WebアプリケーションやOriginServerを守るため、これらを使うだけでもCloudflare CDNを導入する価値が十分あります。 ビジネス用途の場合、Cloudflare WAFの OWASP Core Ruleset を有効にしておきましょう。これは、 OWASPFoundation というセキュリティ向上に取り組むコミュニティが定義しているWAF用の攻撃検出ルールセットです。 プロダクトの運用開始後にWAFを設定するとき、ユーザー影響が心配であれば、適用範囲絞ったり、OWASP Anomaly Score Thresholdを低めに設定したりできます。 また、 ルールを検出したときのアクション を設定しておくことができ、次の中から選択できます。Enterprise Planを契約している場合は、ルールを厳しめにしてからアクションをLogに設定してしばらく様子を見るのもありでしょう。 Block: アクセスをブロックする Log: Cloudflare Logに書き込むだけ(Enterprise Planが必要) JS(JavaScript) Challenge: ボットやスパム対策。リクエスト元がブラウザかどうかを判定する Interactive Challenge: 人間が何らかの操作をすることで突破できる Managed Challenge: リクエストに応じてほかのチャレンジを自動選択する Cloudflare Access Cloudflare Access は、 Cloudflare ZeroTrust を構成するサービスの一部です。ZeroTrust(ゼロトラスト)とは、従来のネットワークによる境界防御ではなく、情報資産に対するアクセスを信頼せずに必ず検証することにより防御するという考え方です。 Cloudflare Accessを利用すると、WebアプリケーションやSaaSに簡単に認証認可を付与できます。Public InternetからアクセスできないPrivate Network Applicationにも適用できます。また、Synthetic Monitoring(外形監視)やシステム間API連携などのために、保護されたアプリケーションにアクセスするためのサービストークン発行機能もあります。 キャディでは、開発用のSaaSやツール、社内向けWebアプリケーションへのアクセスのために活用しています。認証プロバイダ(IdP)にはGoogle Workspaceを使い、Google GroupsとWebアプリケーションをひも付けることで認可を制御しています。たとえば、特定グループに属する社員だけが当該アプリケーションにアクセスできるといった制御です。一時的に業務委託の方へ社内向けアプリケーションを公開したいときは、One-time PIN login(OTP)によるアクセスを許可するなど柔軟な設定ができます。OTPとは、認証ページでE-mailを入力し、送られてきたパスワードを入力して認証するログイン方法です。概要図は図4のとおりで、表2のようなアクセスポリシーで制御します。 ちなみに、Google Cloudには Identity-AwareProxy (以降、IAP)というゼロトラストのアクセスモデルを実装できるサービスがあります。 要件によってはIAPを利用することで同等の認証認可を付与できますが、汎用性・柔軟性・メンテナンス性などの観点からCloudflare Accessをメインで使っています。たとえば、グローバルIPを持たないBastion Serverなど、Google Cloud内で完結させたほうがよいものはIAPを使って制御しています。 ▼図4 Cloudflare Accessの概要図 ▼表2 アクセスポリシー Application Policy Development Tool Staging Environment Sentry Allow Developer Group Internal Application Allow Internal App Group Allow outsourcing@example.com Cloudflare Gateway Cloudflare Gatewayは、 Secure WebGateway の実装の1つであり、Cloudflare Accessと同様にCloudflare Zero Trustを構成するサービスの一部です。Secure Web Gatewayとは、安全な外部通信をするために、URLフィルタリングやマルウェア検出/ブロック、アクセス制御などを行うゲートウェイ(プロキシ)のことです。 図5がCloudflare Gatewayを使用したときの概要図です。まず、エンドユーザーの端末とCloudflare Gateway間において暗号化された専用接続を構築します。具体的には、 WARP というクライアントをインストールします。 WARPを有効にすると、Webの通信がすべてCloudflare Gatewayにプロキシされます。 Cloudflare Gateway側では、DNS、HTTP、Networkのそれぞれのレイヤで フィルタリングルール(ポリシー)が適用 されます。企業の管理者は、ポリシーを設定しておくことで、各レイヤごとにマルウェアやフィッシングをブロックしたり、特定のグループのみに特定のアプリケーションへのアクセス権を与えたりできます。 また、アンチウイルススキャンの設定を有効にしておけば、ダウンロードしようとしているファイルにもマルウェア検知とブロックを実行できます。 ▼図5 Cloudflare Gatewayの概要図 Cloudflare Rules Cloudflare Rules を使うと、Edge Server側にて任意の条件でリクエストやレスポンスを変更できます。一例として、図6のようにリダイレクトルールのAPIを利用し、プロダクトごとに計画メンテナンス用の画面に切り替えができるように、共通の開発者向けアプリケーションを構築しています。 また、執筆時点でGAになっていませんが、 Cloudflare Snippets によりJavaScript でルールを書けるようになるため、さらに柔軟な変更が可能になりそうです。 ▼図6 計画メンテナンス管理の概要図 実行順序 これまで紹介したとおり、CloudflareではEdge Server側でさまざまな最適化ができます。 しかし、利用する機能が増えてきたときには注意が必要です。どの機能がどの順序で処理されるかを意識しておかないと、設定したときに想定外の挙動になりかねないためです。Cloudflareのダッシュボードから各機能の設定画面でTraffic Sequenceを確認できます(図7)。初めて利用する機能の設定をするときには、既存の設定との副作用や考慮漏れがないかをチェックしましょう。 執筆時点ではまだBetaの機能ではありますが、 Cloudflare Trace を利用すると便利です。 Cloudflareのダッシュボード上でリクエストをカスタマイズして実行し、そのリクエストにどの設定が適用されるかシミュレートできます。 ▼図7 実行順序 Developer Platform CloudflareにはWebアプリケーションを構築するためのコンポーネントが一通りそろっています。多くのサービスが無償から利用できます。 Cloudflare Workers は エッジコンピューティング の実装の1つであり、FaaS/Serverlessでもあります。次のような特徴があり、使い勝手が良いため、キャディの一部プロダクトでも利用しています。 非常に安価 高いスケーラビリティ 開発者体験がよい 0ms Cold Start(0ミリ秒コールドスタート) 少しだけしくみにも触れておきます。 Workers基盤上では、V8 EngineのIsolateが利用されており、リクエストごとに軽量かつ独立した環境でユーザーコードが実行されます。 さらに、 TLSハンドシェイク中にWarmupを終わらせる ことで、0ミリ秒コールドスタートを実現しています。これらの発明が従来のFaaSのコールドスタート問題を解決したおかげで、用途が格段に広がったのだと思います。 開発者体験の面では、デフォルトではWorkers専用ドメイン(workers.dev)で動くため独自ドメイン追加することなく始められますし、専用のCLIツールがシンプルで使いやすいです。CI/CDは 公式のGitHub Action で簡単にセットアップできます。また、 Hono というWebフレームワークを使うとより開発が楽になるのでお勧めです。 そのほかのサービスも一部概要を紹介します(すべてのサービスとその詳細は こちら を参照してください)。次のようなサービスをCloudflare Workersと組み合わせることで、さらに多様なアプリケーションが構築できるようになります。Edge Server上で処理を完結させられれば、より早くレスポンスを返すことができ、ユーザー体験の向上につながるでしょう。 Cloudflare Pages: Gitと統合されたJAMstackプラットフォーム Cloudflare D1: SQLiteベースのデータベース Cloudflare R2: S3互換でエグレス料金無料のオブジェクトストレージ Cloudflare Workers KV: key-valueデータストレージ Cloudflare Queues: メッセージキュー 運用上の課題としては、まだIAM(Identity and Access Management)に相当する機能がないため、最小の権限でのワークロード実行制御ができません。たとえば、「特定のCloudflare WorkersからはCloudflare R2の読み取りだけしかできない」といった制御です。一方「特定のCloudflare Workersから特定のCloudflare R2のすべての操作」はBindingによる紐づけを前提としており、制御できるようになっています。 ユーザーアカウントに関しては、RBAC機能を利用してある程度権限が管理できますが、リリース環境ごとに権限を分離するなどの制御はできません。とはいえ、IaCを前提としたGitリポジトリ側での統制により、ある程度担保できるでしょう。また、管理コストは上がりますが、本番環境とその他の環境をアカウント(テナント)やドメイン単位で分離することも有効な手段です。 Cloudflare Registrar Cloudflare Registrar は、Cloudflareが運営するレジストラです。ほかのレジストラと比較して特別な機能があるわけではないですが、レジストリやICANNの請求される費用(つまり原価)のみで利用できるのが魅力です。すでにCloudflareを使っていれば、集約による管理コストの低減が期待できるでしょう。 Google Domainsの売却の発表 に伴い、その移行先としても注目を集めています。Cloudflare DNSと同様にCloudflare CDNを使うための必須条件ではありません。 Cloudflare Waiting Room Cloudflare Waiting Roomは仮想待合室サービスです。想定したキャパシティを超えた急激なトラフィックの上昇からWebアプリケーションを守り、一定の可用性を維持できます。 数百の自治体の新型コロナワクチン予約サイトで導入 されていました。 利用するための前提条件は、Cloudflare CDNが設定済みでCookieが有効になっていることです。あとは対象のホストやパス、閾値などを設定するだけでWaiting Roomを適用できます。トラフィックが設定された閾値を超えたときユーザーは待合室に誘導されます。待合室入ったユーザーには待合室専用のページが表示され、20秒ごとに推定待ち時間が更新されます。その後、デフォルトではFirst In First Out(FIFO)でOrigin Serverへ到達できるようになります。 IaC キャディでは、Cloudflareに関してもともとIaC管理できていませんでしたが、履歴管理や変更容易性などの観点から徐々にIaC化を進めています。とくにCloudflare DNSやCloudflare Accessは、変更頻度が比較的高く、変更履歴や監査ログも重要ですので、早めにIaC化して良かったと感じています。まだIaC化はこれからという方は、 公式のTerraformベストプラクティス を参考にして進めるとよいでしょう。 Enterprise Plan 低コストでさまざまなサービスを利用できるのは、Cloudflareの大きな魅力です。しかし、組織としてさらに統制を効かせやすくしながらプロダクトの運用レベルを上げるためには、 Enterprise Plan への移行が必要になります。一例ですが次の機能はEnterprise Planでしか利用できません。 Cloudflare DashboardのSSO対応 ホスト単位のキャッシュパージ Request Log(Access Log)のExport Audit LogのExport また、Enterprise Planの顧客専用のチームがサポートチケットに対応してくれるため、回答の質やスピードが向上します。 2 ちなみに、RBAC機能は以前はEnterprise Plan限定でしたが、 2023年にほかのプランにも解放 されました。 連載のおわりに 今回が本連載の最終回となります。2021年7月に 2名で立ち上げたPlatform Team ですが、2年数ヵ月経過した現在、メンバーが大幅に増えPlatform Groupになりました。本連載では、筆者らがその間に取り組んできたことの一部を紹介してきました。 振り返ってみると、筆者らが本連載の企画を始めたとき、まだPlatform Engineeringという言葉はそれほど認知されていませんでした。そのため、議論の結果「Google Cloudで実践するSREプラクティス」というタイトルや内容に落ち着いたと記憶しています。ところが、 Gartnerのテクノロジートレンド に登場したり、 Platform Engineering Meetup が盛り上がっていたりと、現在はだいぶ認知が進んできたようです。今後も機会があれば、筆者らの取り組みをなんらかの形で共有したいと思います。 本連載が、みなさんにとって価値のあるDevOps、SREを実現するためのヒントになっていたら幸いです。最後までお読みいただきありがとうございました。 一部内容に誤りがあったため、訂正してあります。 ↩ キャディでは、Cloudflare Accessの不具合を報告し、修正してもらったことがあります。 ↩
アバター
はじめに AI Team MLOps エンジニアの西原です。2024 年 1 月にローカル環境で Kubeflow Pipelines を実行するドキュメントが公式から 公開 されました。今回はそのドキュメントを参考にローカル環境で Kubeflow Pipelines を実行する方法を紹介します。 はじめに Kubeflow Pipelines とは kfp を使った開発の課題 kfp を手元の開発環境で実行する ローカル環境でコンポーネント実行 アーティファクトを出力 任意のコンテナイメージを使ったコンポーネント GPU を使ったコンポーネント pipeline 実行 pipeline とは何か? pipeline 実行 まとめ 参考 Kubeflow Pipelines とは 今回取り扱う Kubeflow Pipelines とは何か?公式のドキュメントを引用します。 Kubeflow Pipelines(kfp)は、コンテナイメージを使ってポータブルでスケーラブルな機械学習(ML)ワークフローを構築し、デプロイするためのプラットフォームです。 CADDi AI Team では Google Cloud のマネージドなプラットフォームである Vertex AI Pipelines を使って機械学習パイプライン開発をしています。この裏で kfp が動いており、開発時に kfp の Python SDK を使ってパイプラインを定義しています。 kfp を使った開発の課題 機械学習用のコンテナイメージは比較的大きく、私たちのチームでは 1 つあたり 10~20GB になることが多いです。イメージサイズが大きくなる要因は GPU 環境でプログラムを動かすために必要なソフトウェアを setup するためです。これらの大きなコンテナイメージを push してリモートのパイプライン上で動作確認すると、Node の起動やコンテナの push と pull による待ち時間が長くなります。私たちのチームでは一番最初のコンポーネントが実行されるまでに 20 分弱かかることもありました。こういった状況では試行錯誤の回数が下がり開発効率が悪くなるため、コンテナイメージを 不必要に push せずにローカル環境で動作確認したいという話がありました。 この課題を解決するために、kfp の Python SDK を使ってローカル環境でパイプラインを実行する方法を調査し、検証したので紹介します。 kfp を手元の開発環境で実行する ローカル環境でコンポーネント実行 サンプルコードを使ってローカル環境でコンポーネント実行する方法を紹介します。シンプルな足し算の例が次のコードになります。 local.init がない状態だと実行できずエラーでプログラムが終わりますが、これを記述することでローカル環境で実行できます。 from kfp import local from kfp import dsl # 関数定義の後に実行しても良い # 実行にはdockerが必要 local.init(runner=local.DockerRunner()) @ dsl.component def add (a: int , b: int ) -> int : return a + b task = add(a= 1 , b= 2 ) assert task.output == 3 このプログラムを実行するとログから入力と出力が確認でき、問題なく動作していることがわかります。 ...省略 { "inputs": { "parameterValues": { "a": 1, "b": 2 } }, "outputs": { "parameters": { "Output": { "outputFile": "~/<PATH>/local_outputs/add-2024-01-15-18-45-51-383673/add/Output" } }, "outputFile": "~/<PATH>/local_outputs/add-2024-01-15-18-45-51-383673/add/executor_output.json" } } [KFP Executor 2024-01-15 18:45:55,665 INFO]: Wrote executor output file to ~/<PATH>/local_outputs/add-2024-01-15-18-45-51-383673/add/executor_output.json. 18:45:55.877 - INFO - Task 'add' finished with status SUCCESS 18:45:55.878 - INFO - Task 'add' outputs: Output: 3 アーティファクトを出力 kfp にはアーティファクトというものがあります。詳しい説明はここでは省略しますが、パイプラインと紐づくもので、データセットやモデルなどがそれになります。kfp でアーティファクトの扱いはコアな部分になるため、サンプルコードで動作を確認します。足し算の結果をアーティファクトとしてファイル出力する例を次に示します。 with 句でファイルを開いて、書き込みと読み込みをするプログラムです。 from kfp import local from kfp import dsl from kfp.dsl import Output, Artifact import json local.init(runner=local.DockerRunner()) @ dsl.component def add (a: int , b: int , out_artifact: Output[Artifact]): import json result = json.dumps(a + b) with open (out_artifact.path, 'w' ) as f: f.write(result) out_artifact.metadata[ 'operation' ] = 'addition' task = add(a= 1 , b= 2 ) with open (task.outputs[ 'out_artifact' ].path) as f: contents = f.read() assert json.loads(contents) == 3 assert task.outputs[ 'out_artifact' ].metadata[ 'operation' ] == 'addition' 実行した際のログからアーティファクトの出力先が確認できます。 .. [KFP Executor 2024-01-15 20:38:32,771 INFO]: Wrote executor output file to ~/<PATH>/local_outputs/add-2024-01-15-20-38-28-731045/add/executor_output.json. __import__(pkg_name) 20:38:32.975 - INFO - Task 'add' finished with status SUCCESS 20:38:32.975 - INFO - Task 'add' outputs: out_artifact: Artifact( name='out_artifact', uri='~/<PATH>/local_outputs/add-2024-01-15-20-38-28-731045/add/out_artifact', metadata={'operation': 'addition'} ) 出力先のファイルを確認すると、json 形式で 3 が書き込まれていることが確認できます。 任意のコンテナイメージを使ったコンポーネント ここまで Python の関数としてコンポーネントを実行してきましたが、 dsl.ContainerSpec を使うと任意のコンテナイメージをコンポーネントとして実行できます。 Hello World の文字列をファイルに書き込む例が次になります。 from kfp import dsl, local local.init(runner=local.DockerRunner()) @ dsl.container_component def say_hello (name: str , greeting: dsl.OutputPath( str )): """Log a greeting and return it as an output.""" return dsl.ContainerSpec( image= "alpine" , command=[ "sh" , "-c" , """RESPONSE="Hello, $0!" \ && echo $RESPONSE \ && mkdir -p $(dirname $1) \ && echo $RESPONSE > $1 """ , ], args=[name, greeting], ) task = say_hello(name= "World" ) print (task.outputs) 上記のプログラムを実行すると次のようなログが出力され、Hello World という文字列が見えます。実際に出力されたファイルを確認すると、Hello World という文字列が書き込まれていることが確認できます。 Found image 'alpine:latest' Hello, World! 06:39:37.953 - INFO - Task 'say-hello' finished with status SUCCESS 06:39:37.953 - INFO - Task 'say-hello' outputs: greeting: 'Hello, World! ' {'greeting': 'Hello, World!\n'} GPU を使ったコンポーネント 機械学習では GPU を使って学習や推論を行うことがあります。先述した通り、GPU を使ってプログラム実行するには依存するソフトウェアが増え、コンテナイメージのサイズが大きくなります。大きなコンテナイメージを使ってリモート環境で動作確認すると待機時間が長くなります。GPU を使ったコンポーネントがローカル環境で実行できると不必要にリモートのパイプライン上で動作確認することがなくなり、待機時間を減らすことができます。これにより開発効率が大きく改善できるため、今回のローカル環境の検証の核となる部分です。 結論として、今回紹介している kfp local で GPU を使ったコンポーネントをローカル環境で実行できます。GPU を使ったサンプルのプログラムが次になります。次のプログラムは、CUDA(GPU)がない環境で実行すると失敗しますが、CUDA がある環境では成功するようになっています。 from kfp import dsl, local local.init(runner=local.DockerRunner()) @ dsl.container_component def gpu_processing (): return dsl.ContainerSpec( image= "gcr.io/google_containers/cuda-vector-add:v0.1" , ) task = gpu_processing() print (task.outputs) 上記のコンポーネントを実行した結果が次になります。ログから GPU を使ったコンポーネントが問題なく実行できていることが確認できます。 16:21:16.599 - INFO - Executing task 'gpu-processing' 16:21:16.600 - INFO - Streamed logs: Found image 'gcr.io/google_containers/cuda-vector-add:v0.1' [Vector addition of 50000 elements] Copy input data from the host memory to the CUDA device CUDA kernel launch with 196 blocks of 256 threads Copy output data from the CUDA device to the host memory Test PASSED Done 16:21:18.040 - INFO - Task 'gpu-processing' finished with status SUCCESS 16:21:18.040 - INFO - Task 'gpu-processing' has no outputs pipeline 実行 これまでコンポーネントの実行について紹介してきましたが、パイプライン実行についても紹介します。 pipeline とは何か? kfp におけるパイプラインとは何か?公式のドキュメントを引用します。 パイプラインとは、1 つまたは複数のコンポーネントを組み合わせて計算有向 非循環グラフ(DAG)を形成するワークフローの定義です。実行時、各コンポーネント実行は 1 つのコンテナ実行に対応し、コンテナは ML のアーティファクトを作成します。パイプラインは制御フローを含むことがあります。 pipeline 実行 ローカル環境でのパイプライン実行を実際にやってみます。対象の関数に @dsl.pipeline をつけることでパイプラインとして定義できます。下記はコンポーネントを組み合わせて三平方の定理を計算するパイプラインの例です。 from kfp import dsl, local local.init(runner=local.DockerRunner()) @ dsl.component def square (x: float ) -> float : return x ** 2 @ dsl.component def add (x: float , y: float ) -> float : return x + y @ dsl.component def square_root (x: float ) -> float : return x ** .5 @ dsl.pipeline def pythagorean (a: float , b: float ) -> float : a_sq_task = square(x=a) b_sq_task = square(x=b) sum_task = add(x=a_sq_task.output, y=b_sq_task.output) return square_root(x=sum_task.output).output result = pythagorean(a= 3.0 , b= 4.0 ) print (result) これを実行すると次のようなログが出力され、ローカル環境だとパイプラインの実行はサポートされていないことが分かります。 (追記:v2.7.0でパイプライン実行がサポートされました。) ... raise NotImplementedError( NotImplementedError: Local pipeline execution is not currently supported. ローカル環境でパイプライン実行はサポートされてませんが、コンポーネントの実行はサポートされているのでコンポーネントを組み合わせてパイプラインっぽく実行することはできます。具体的にどうするのかというと、サンプルコードの @dsl.pipeline を消して実行するだけです。 パイプライン関数のデコレータを消して実行した結果が次になります。3 2 + 4 2 の平方根は 5 なので正しく動いていることが確認できます。 ... 06:59:08.912 - INFO - Task 'square-root' finished with status SUCCESS 06:59:08.912 - INFO - Task 'square-root' outputs: Output: 5.0 まとめ ここまで kfp のローカル環境での実行について紹介しました。 機械学習では GPU を使ったプログラムを実行することもありますが、その場合はコンテナイメージのサイズが大きくなります。大きなコンテナイメージを使ってリモート環境で検証すると、待機時間が長くなります。今回紹介したローカル環境での実行によって、リモート環境以外で動作確認できるようになり、不必要な待機時間を減らすことができます。今回紹介した kfp local によって開発業務の待機時間を減らせるため、うまく取り入れることで開発効率の改善が期待できます。 参考 Kubeflow Pipelines のローカル実行 kfp components kfp pipeline
アバター
※本記事は、 技術評論社 「Software Design」(2023年12月号) に寄稿した連載記事「 Google Cloudで実践するSREプ ラク ティス」からの転載です。発行元からの許可を得て掲載しております。 はじめに 前回 は、 Google Cloudが提供するAnthos Service Meshを導入して、GKEで動くアプリケーションに可観測性やセキュリティなどの機能を追加する方法について紹介しました。今回はDatadog 1 を利用したモニタリング基盤について、Datadogの採用理由や基本機能、キャディでの活用事例を紹介します(図1)。 ▼図1 CADDiスタックにおける今回の位置付け Datadogとは Datadogは クラウド ベースの運用監視 SaaS です。おもに クラウド プロバイダ( AWS 、Azure、 Google Cloudなど)やオンプレミス環境でのアプリケーションとインフラスト ラク チャの監視をサポートし、システムの状況をリアルタイムで追跡・可視化する機能を提供しています。 また、インフラスト ラク チャモニタリング、ログ管理などの用途でも利用でき、 Kubernetes 、NGINX、 MySQL などさまざまなサービスやアプケーションをサポートしているのもDatadogの特徴です。 なぜDatadogなのか 筆者らがDatadogを導入した2020年頃以前、キャディでは Google Cloudが提供するCloudMonitoringを利用していました。キャディでは複数の Google Cloudのプロジェクトでさまざまなプロダクトを運用しています。それぞれの状態を確認するためには、 Google Cloudの管理コンソール上で対象プロジェクトに移動する必要があり、操作が繁雑でした。このことから、モニタリングを一元化したいという要望が出てきました。 また、キャディはスタートアップ企業であるためプロダクト開発に注力する必要があり、独自の監視基盤を構築・運用する人的リソースを割くことができません。そのため、フルマネージドな監視基盤であるDatadogを採用することにしました。 Datadogはさまざまな特徴を備えますが、とくに筆者らの要件に合致したのは次のような点でした。 さまざまな対象からログやメトリクスを収集して一元管理できる WebUI上で ダッシュ ボードが簡単に作成できる クエリ定義による柔軟なアラート設定ができる Terraformが対応しており、設定をIaC化できる これらの特性から、自社で運用する多くのプロダクトの状態をプロダクト軸・時間軸の両方で分析・監視でき、状況把握や障害対応の効率が大きく向上しました。 Google Cloud連携のしくみ Datadogが クラウド サービスからログやメトリクスなどを収集するしくみを、 Google Cloudとの連携を例に紹介します(図2)。 ▼図2 Datadogと Google Cloudの連携方法 Google Cloudとの連携では、Datadog向けに用意したサービスアカウントを通じて認証します。Datadogはこのサービスアカウントを利用して Google Cloudの API をコールすることで、多くの情報を収集します。 API を通じて取得する主要な情報は各種メトリクスです。メトリクスにはCPU使用率、メモリ使用量、ネットワーク トラフィック などをはじめとするシステムやアプリケーションの状態、パフォーマンス、使用状況などに関する数値データがあります。これらのメトリクスは、 Google CloudのCloud Monitoringと呼ばれるモニタリングサービスの API から取得します。 Pod などの GKE 上のリソースは、CloudMonitoringでメトリクス収集できないため、GKEにDatadog Agentをインストールし、DataDog AgentがPodの状態を収集し、Datadogへメトリクスを送信します。 CloudRunやGKE上のコンテナなどをはじめとする各種ログは、 Google Cloudの標準サービスであるCloud Loggingに集められます。CloudLoggingにはログ ルーター という機能があり、ここでログのフィルタリングと転送ができます。 ログ ルーター の転送先としてCloud Pub/Sub( Google Cloudのキューイングサービス)を指定し、さらにPub/SubにDatadogのログ転送 API を設定することでDatadogにログが転送されます。 メトリクスやログ収集の詳しい設定方法はDatadogのドキュメント 2 にわかりやすく説明されています。これに従えば、それほど難しくはないでしょう。 Resource Managerを活用した一括設定 前述のように、Datadogと Google Cloudの連携作業はそれほど難しくありません。しかし、冒頭でも紹介したように、 Google Cloud上に多くのプロジェクトを抱える組織では、これらの設定がトイルになってしまいます。また、設定漏れや誤りといった作業ミスも発生します。 キャディではこれらの課題を解決するために、 Google CloudのResource Managerを活用しています。Resource Manager は、 Google Cloudアカウント内のリソースの整理、階層化する機能で、筆者らはアクセスコン トロール やコスト管理の向上に役立てています。 Resource Managerでは「組織」と「フォルダ」という単位でプロジェクトを管理できます。「組織」は Google Cloudアカウント全体を管理するトッ プレベ ルのエンティティです。組織には複数のプロジェクトやフォルダを含められます。 「フォルダ」は組織内でプロジェクトを管理するための階層的なエンティティです。フォルダ配下のプロジェクトに関して、アクセスコン トロール ポリシーや管理ポリシーを一元的に設定できます。 キャディでは、特定フォルダ配下のプロジェクトを自動検出するしくみを取り入れてDatadog導入の運用負荷を下げています。図3のようなフォルダ構成では、Enabling DD Integrationフォルダで連携設定するようにしており、このフォルダ配下に作成したプロジェクトではメトリクスが収集されるようにしています。一方、Disabling DD Integrationフォルダ配下のプロジェクトは対象外になります。 ▼図3 プロジェクトの自動検知を考慮したResource Managerの構成 ログをDatadogで管理する ログをDatadogに集約することで、分析、視覚化、アラートなどの機能が提供されます。これによって、システムやアプリケーションの監視が可能になり、 トラブルシューティング もしやすくなります。キャディではアプリケーションログ、 アクセスログ 、監査ログなどさまざまなログDatadogに集めており、分析や監視に役立てています。 Datadog logsをメインで利用する理由 Google CloudにはCloud Loggingという機能があり、こちらでもログ管理ができます。しかし、キャディでは次の理由でおもにDatadog logsを利用しています。 ログ、メトリクス合わせて普段運用で見るべき場所をDatadogだけに統一できる Google Cloud上のプロジェクトが増えても横断的にログを調べられる 使いやすい エクスプローラ によって、高度なフィルタや加工ができる ログの属性をインデックスする(ファセット化 3 )ことにより、条件によってはCloudLoggingより検索が速い ログの内容から、HTTPステータスの統計をメトリクスで可視化したり、処理時間に対してアラートの設定ができる ただ、すべてのログをDatadogで管理しているわけではありません。Datadogでは、ログの保持および、取り込み時・復元時に料金がかかります。このため、利用頻度が高いアプリケーションや アクセスログ などをDatadogで利用し、そのほかのログにはCloud Loggingを利用するといった使い分けをしています。 運用方針 キャディではいくつかの方針を定めてDatadogでログを運用しています。利用するアプリケーションによってログのフォーマットがさまざまであり、そのままDatadogに送るだけでは、 トラブルシューティング や監視の有効活用にはなりません。また、コストにも注意をはらう必要があります。 ログの標準属性を決める キャディでは、Datadog側でログを解析してもらうために、 JSON 形式で出力することを推奨しています。解析されたログは、各属性に割り振られ、ログのフィルタリングに利用できます。よく利用する属性(ユーザーIDやリク エス トIDなど)を統一させて標準化することで、直感的に検索が行えるようになります。また、ログの一覧画面(図4)で項目の追加や削除ができたり、各項目でのソートができたりして、分析や調査時にとても役立ちます。 ▼図4 ログ一覧のイメージ ただ、アプリケーションの仕様でフォーマット化しづらいケースなどもあります。その場合は、Datadogのパース機能 4 を使ってDatadog内で構造化できます。 たとえば、リスト2のような非構造化ログがDatadogに送られてくるとします。 ▼リスト2 非構造化ログの例 [2023-10-10 02:20:48][PID:158][INFO] method=GET path=/ping status=200 content_type=text/html; これに対してリスト3のようなパース規則を設定します。 ▼リスト3 パース規則の設定 SampleRule \[%{date("yyyy-MM-dd HH:mm:ss"):date}\]\[PID\:%{integer:pid}\]\[%{word:level}\]\s+%{data::keyvalue("=",";\\[\\]/")} 結果、リスト4のような JSON 形式に解釈され、ログインデックスに保存されます。 ▼リスト4 ログインデックスに保存される JSON { " date ": 1696904448000 , " path ": " /ping ", " method ": " GET ", " content_type ": " text/html; ", " level ": " INFO ", " pid ": 158 , " status ": 200 } パース規則の定義は、Grokと呼ばれるパターンマッチ構文を使って、ログ解析ルールを作る作業です。 正規表現 に似た部分もあるので、 正規表現 を知っていればドキュメントを見ながら規則を書けると思います。 また、DatadogのUI上で実際のログをサンプルとしてパース規則を作成でき、作成したパース規則の動作確認もしやすくなっています。 NGINXや PostgreSQL など、代表的なアプリケーションのログのパース規則もプリセットで用意されているので、これらを参考にするのも良いでしょう。 なお、推奨レベルではありますが、日時のフォーマットや必須項目(サービス名、リク エス トIDなど)を定義しており、ログフォーマットの標準化に努めています。 必要なものだけインデックスする Datadog logsでは、インデックス 5 という箱にログを格納することでLog Explorer からの検索が可能になります。どのインデックスにどんなログを入れるかフィルタを書くことができるので、アプリケーション側で選別して送信する必要がありません。Datadog側でフィルタすることで、より柔軟なログの運用ができます。一方で、インデックスするログの量が増えるほどコストがかかるので、なるべく不要なログは除外しておくようにしています。 ログを アーカイブ する Datadogの アーカイブ 6 は、収集したログを長期保存するためにログを クラウド ストレージ( Google Cloud Strageなど)へ転送する機能です。インデックスの保存期間を過ぎてしまったログを再度確認したくなった際に、リハイドレート 7 使って クラウド ストレージに保存されている アーカイブ から復元できます。 ダッシュ ボードを作成する Datadogの ダッシュ ボードは、複数のメトリクスやログから得られる情報を ウィジェット 8 と呼ばれるブロックで配置します。グラフ、テーブル、ヒートマップなどさまざまな ウィジェット が提供されており、自身のニーズに合わせてカスタマイズできます。 筆者らは次の3つを念頭に ダッシュ ボードを作成しています。 プロダクトの現在の状態を一目で把握できる 異常検知後の原因分析が速やかにできる 将来のための傾向分析ができる また、 ダッシュ ボードを切り替えながら監視や調査をするのは困難なため、1つのプロダクトに1つの ダッシュ ボードを作成することを推奨しています。キャディでは、 Grani 社の事例 9 を参考にして表1の3つのレイヤを定義しています。レイヤごとにその役割を解説していきます。 ▼表1 キャディで定義している ダッシュ ボードの3つのレイヤ レイヤー 概要 閲覧頻度 詳細度 1 Overview 常時 低 2 重要指標の詳細 障害時、最適化時 中-高 3 リソースやアプリケーションの詳細 障害時、最適化時 中-高 Overview プロダクトが正常に稼働できているかを一目で把握するためのレイヤです。ファーストビューに配置し、Query Value と呼ばれる ウィジェット を使用して現在の値を表示します。また、値に応じて背景色が変わるようになっており、正常時は緑、警告時は黄色、異常時は赤に切り替わります(図5)。 ▼図5 Overview正常時のイメージ また、プロダクト固有のメトリクスも含めると、開発者以外のメンバーも状況が把握しやすくなります。ショッピングサイトを例に固有のメトリクスの詳細を挙げてみます。 商品検索の成功率 ログイン成功率 決済の成功率 商品レビューの投稿成功率 このような指標は、障害が発生したときに、プロダクトにどのような影響が及んでいるか早期発見ができ、プロダクトマネージャーなどのシステムの詳細を把握していないメンバーとの連携もしやすくなります。このような情報は、基本メトリクスとして用意されていないので、カスタムメトリクス 10 としてアプリケーション側から送ります。また、ログからメトリクスへの変換もできます。 SLOの ウィジェット を利用して、パフォーマンスや信頼性を可視化するのもよいでしょう。 重要指標の詳細 このレイヤには、アプリケーションやビジネスとして重要なメトリクスや、可用性や性能面で ボトルネック になりやすいメトリクスをグラフでまとめておきます。重要指標の関連グラフを横断的に参照できるようにしておくことで、特定時刻に何が起こったかを分析しやすくなります。障害が起きたとき、問題の切り分けが迅速にでき、より早く復旧できるようになります。 たとえば、次のようなメトリクスが考えられます(図6は「ユーザーの同時接続数」「アプリケーションエラー数」を ダッシュ ボードにグラフ ウィジェット で可視化したときのイメージです。 ▼図6 アプリケーションのメトリクスイメージ ユーザーの同時接続数 トランザクション 数 アプリケーションエラー数 メッセージキューの状態 データベース(DB)やWorkloadの負荷 仮想マシン ( VM )/コンテナの再起動イベント DBのコネクション数 平均レスポンスタイム 定期実行ジョブの成功・失敗 リソースやアプリケーションの詳細 このレイヤには、重要指標ではないがプロダクトに関連するすべてのメトリクス(ApplicationやDBやWorkloadなど)をグラフでまとめておきます。「重要指標の詳細」レイヤで障害の特定ができないときや将来のための傾向・キャパシティ分析のために利用します。 アラート運用 ソフトウェアは複雑で、運用中にはさまざまな問題が発生します。とくに クラウド ネイティブな アーキテクチャ では、さまざまな要因で障害が発生します。ログやメトリクスを監視して問題が発生したとき、即座に通知するようアラート設定することで、迅速に対処し、システムのダウンタイムや障害の影響を最小限に抑えられます。 アラートというと、おもにリソース枯渇の検知というイメージが強いかもしれません。しかし、そのほかにもセキュリティやパフォーマンスの観点でアラートを設定すると、プロダクトの信頼性向上につなげられます。Datadogのアラート機能(Monitors)を運用するにあたり、筆者らが注意しているポイントを紹介します。 アラート設定基準 キャディではdevelopment、staging、productionの全環境でアラートを設定しています。本番環境以外でも作成しておくと、アラート自体の動作検証にもなります。通知先はSlackにしており、環境に応じてチャンネルを分けています。 また、4つのレベルでSeverity(重大度)を定義(表2)し、アラートの緊急度が一目でわかるようにしています。 ▼表2 キャディが定義する重大度の4つのレベル レベル 重大度 A なるべくはやく対応する B 4時間以内に対応する C 24時間以内に対応する D 一週間以内に対応する Runbookの整備 Runbookとは、 トラブルシューティング の手順や関連情報などをまとめた文書のことで、筆者らはそのURLをアラート本文に記載しています(図7)。Runbookはシステムの正常運用を維持するのに必要な情報を提供し、運用担当者が問題を迅速に特定し、解決するのに役立ちます。 ▼図7 Slackに通知されるアラートのイメージ 一般的な記載内容は次のとおりです。 システム概要:システムの構成、 アーキテクチャ 、技術スタックなどの概要情報 運用手順:システムの起動、停止、バックアップ、復元などの基本的な運用手順を詳細に示す 対応方法:システムの問題を特定し、解決するための具体的な手法を提供する。エラーコードに対する対処法や調査に使えるコマンドの実行方法が記載されている エス カレーション手順:複雑な問題や深刻な障害が発生した場合、適切なサポートまたは管理チームへの エス カレーション手順を示す 緊急対応手順:システムに深刻な障害が発生した場合の緊急対応手順を示し、迅速な復旧を目指す キャディではDevOpsを実践しています。運用専門のチームはおらず、各開発チームがアラート対応をしています。アラート対応は属人的になりがちで、それゆえに一部のメンバーに負荷が偏りがちです。筆者らは日替わりでアラート対応の当番を決め、アラート発生時はRunbookを参照することで対応しやすくなるよう、運用の改善に取り組んでいます。 セキュリティへのアラート活用 キャディでは、一歩進めてセキュリティ観点でもアラートを活用しています。本連載の第2~3回(本誌2023年5~6月号)でも紹介したように、キャディでは Google CloudのリソースをTerraformで管理しており、IAM Policyもその対象の1つです。IaC化したにもかかわらず、誰かがIAM Policyを手動で変更してしまったり、意図しない変更があった場合、とくに本番環境では セキュリティインシデント につながることもあります。これをただちに検知できるようにアラートを設定しています。 IAMの変更は Google Cloudの監査ログで確認できます。監査ログをDatadogに流したうえで、リスト5のようなqueryを設定することでアラートを通知できます。 Terraformによる正規の手順でIAM Policyが変更された場合もアラート発報しますが、めったに変更するものではないため、その都度それが意図した変更なのかどうかをアラート担当者が確認する運用としています。 ▼リスト5 alert-queryのサンプル logs("@evt.name:SetIamPolicy project_id:*-production -@usr.id:(*iam.gserviceaccount.com* OR service-agent-manager@system.gserviceaccount.com)").index("*").rollup("count").by("@usr.id").last("5m") > 0 Datadogを導入していない場合、このような検知は Google Cloudのプロジェクト単位で実現しなければならないでしょう。Datadogを導入するとログが集約されるので、1つのアラート設定で複数のプロジェクトを監視できます。 まとめ 今回はDatadogを利用したモニタリング基盤の構築や運用について紹介しました。要点は次のとおりです。 SaaS を使って運用負荷を下げ、リッチなモニタリング環境を利用する メトリクスやログを一元化することで、複数の Google Projectに対応した ダッシュ ボードやアラートが作成できる Runbookを作成し、誰でも トラブルシューティング できるような運用体制を目指す 本稿がみなさんのシステム運用のヒントになれば幸いです。次回はCloudflareを用いた CDN やゼロトラストセキュリティについて紹介します。 https://www.datadoghq.com/  ↩︎ https://docs.datadoghq.com/ja/integrations/google_cloud_platform/  ↩︎ https://docs.datadoghq.com/logs/explorer/facets/  ↩︎ https://docs.datadoghq.com/ja/logs/log_configuration/parsing/  ↩︎ https://docs.datadoghq.com/ja/logs/log_configuration/indexes/  ↩︎ https://docs.datadoghq.com/ja/logs/log_configuration/archives/  ↩︎ https://docs.datadoghq.com/ja/logs/log_configuration/rehydrating/  ↩︎ https://docs.datadoghq.com/ja/dashboards/widgets/  ↩︎ https://engineering.grani.jp/entry/2017/05/29/173141  ↩︎ https://docs.datadoghq.com/ja/metrics/custom_metrics/  ↩︎
アバター
※本記事は、 技術評論社 「Software Design」(2023年11月号) に寄稿した連載記事「 Google Cloudで実践するSREプ ラク ティス」からの転載です。発行元からの許可を得て掲載しております。 はじめに 前回 はArgo CDによる Kubernetes への継続的デリバリについて紹介しました。今回は、 Google Cloudが提供するAnthos Service Meshを導入して、GKEで動くアプリケーションに可観測性やセキュリティなどの機能を追加する方法を紹介します(図1)。また、本記事に関するサンプルコードについては GitHub 1 を参照してください。 ▼図1 CADDiスタックにおける今回の位置付け Anthos Service Meshとは Anthos Service Mesh 2 (以降、ASM)とは、サービスメッシュの OSS 製品であるIstio 3 をベースに機能を追加したフルマネージドのサービスメッシュのサービスです。GKEにアドオンとしてインストールし、 Google Cloudコンソールと連携されるほか、 Google Cloudの技術サポートを受けることもできます。 ASMはAnthos 4 というサービスの一部でもあります。Anthosはマルチ クラウド やオンプレミス環境にGKEを中核とした Google Cloudのサービスを構築し一元管理するためのサービスです。 Google CloudのGKEでのみサービスメッシュを使用したいのであれば、Anthos全体ではなくASMを単体で使用したほうが低コストで済みます 5 。キャディでもASMのみを使用しています。ASMを使用する際に誤ってAnthosの課金を有効にしないように注意してください。 サービスメッシュとIstio サービスメッシュは分散システムで動く複数のサービス間の通信を制御するためのインフラです。 信頼性の高いサービス間通信には、適切なリトライや タイムアウト の設定、ログやメトリクス、分散トレーシングなどの可観測性の向上、 TLS 通信などのセキュリティ向上など、さまざまな処理が必要です。これらの要素を各サービスへ個別実装するのは開発 工数 が増えるほか、設定変更のたびにサービスを再デプロイするといった運用負荷も増えます。サービスメッシュはこれらをインフラとして提供することで、サービスに対して高信頼で設定変更が容易な通信機能を透過的に提供します。 ASMのベースになっているIstioは代表的なサービスメッシュの製品であり、「 サイドカー 」と「コン トロール プレーン」という2つの コンポーネント を使用します。これらの コンポーネント 配置は図2を見てください。 ▼図2 サービスメッシュの アーキテクチャ サイドカー は各サービスのPodに通信プロキシとして挿入されます。その後、Podへの通信とPodから出ていく通信の両方が サイドカー 経由となります。すべての通信が サイドカー 経由となるので、 通信制 御やログ、メトリクスの出力が サイドカー に集約できます。 サイドカー への通信設定を行うのがコントールプレーンです。Istio用の マニフェスト ファイルを使うことでその設定内容をカスタマイズできます。 サイドカー はenvoy 6 という通信プロキシを使用します。envoyは自身の設定を API 経由で更新する機能を有しているため、Podを再起動することなく設定を変更できます。 Istioは、 サイドカー の挿入を透過的にできることが特徴です。つまり、 サイドカー の挿入はPodの起動時に自動で行われます。また、 サイドカー 導入のためにアプリケーションコードの変更や再起動は不要で、既存環境に対して低コストでサービスメッシュを導入できます。 サービスメッシュを導入する理由 Istioの導入は難しくありません。一方で、導入後にIstioを最新に保っていくためには、コン トロール プレーンとすべてのPodの サイドカー の更新が必要なため、簡単なことではありません。Istioは多機能であり、明確な目的を持たずに導入すると運用コストがあとから負債となるでしょう。 キャディでの導入目的は「可観測性の向上」です。キャディでは、多数のサービスが単一のGKE クラスタ 上で稼働しています。サービスの運用監視を効率的に行うには、各サービスが同じフォーマットでログやメトリクスを出力することが望ましいです。多数のサービスにこれらの処理を手作業で実装することは困難ですが、サービスメッシュの導入によって容易に実現できます。 図3はASMの ダッシュ ボードのキャプチャです。ASMを導入することで、GKE内のPodの通信グラフやリク エス ト統計が可視化されるほか、HTTPメトリクスも取得できるため、リク エス トエラーに基づく監視アラートも設定できます。 アクセスログ の出力については後述します。 また、キャディではサービスメッシュとOpenTelemetry 7 を組み合わせ、Cloud Traceによる分散トレーシングの収集も行っています(図4)。これら可観測性の向上も、最小限のアプリケーションコードの変更で実現しています。また、筆者の過去の経験では、非常に高いセキュリティを求めるシステムで、全サービス間通信の暗号化を要求されたことがあります。サービスメッシュを使えば サイドカー でmTLS通信を強制できるため、セキュリティ向上の手段としてもサービスメッシュは有効です。 サービスメッシュが何をできるかを知るには、Istioのドキュメント 8 を見たり、実際に動かして試してみたりするのが良いでしょう。 ▼図3 ASM ダッシュ ボード ▼図4 分散トレーシング コラム: Ambient Mesh まだ安定版にはなっていませんが、IstioではAmbient Meshという サイドカー を用いないサービスメッシュが開発中です 9 。Ambient Meshでは、各ノードに配置するセキュアな通信用のエージェント(ztunnel)と、L7レイヤの通信処理を集中して行うenvoy Podを使用して、 サイドカー と同等の機能を実現するようです。 サイドカー が不要になることで、リソース利用効率の向上や アーキテクチャ の簡素化につながることが期待できます。とくに、 サイドカー 更新時のPod再起動が不要になることは、大きな利点です。 ASMでAmbient Meshが提供されるかはまだわかりませんが、ぜひとも利用したい機能です。 コラム: 分散トレーシング OpenTelemetry Istioでは サイドカー での分散トレーシングをサポートしていますが、複数のサービス間通信を一連のトレーシングとしてまとめることはできません。Context Propagation 10 、という通信元から通信先へトレーシングに関する情報を引き継ぐ処理が必要で、現時点ではアプリケーションコードでの対応が必須となっています。 分散トレーシングのライブラリはOpenTelemetry 11 として標準化が進められていて、本記事のサンプルコードでも使用しています。また、対応する言語やカスタマイズ性は限られますが、OpenTelemetry Operator 12 を使えば、アプリケーションの対応が不要で Kubernetes でのデプロイ時にOpenTelemetryを自動で組み込むことも可能です。 ASMを試す それでは、実際にASMをインストールし、サービスメッシュの機能を試していきます。 ASMの種類とインストール ASMには次の2種類のオプションがあります。 ①マネージドAnthos Service Mesh(以降、マネージドASM) ② クラスタ 内コン トロール プレーン 両者の違いはコン トロール プレーンの管理方法です。 ①はコン トロール プレーンがGKE クラスタ の外にある Google Cloudのマネージドサービスから提供されます。コン トロール プレーンの運用やアップデートは自動で行われますが、バージョンの選択や使用できる機能に制限があります。 ②はGKE クラスタ 内に自身でコントールプレーンをインストールするものです。Istioを自前でインストールして運用する形式に近く、運用やアップデートは自分で行う必要がありますが、細かくカスタマイズできます。 詳細は公式ドキュメント 13 を参照してください。 筆者としては、マネージドASMを選択することをお勧めします。最大の理由は「マネージドASMを使うと、コン トロール プレーンの運用が大幅に簡単になるため」です。 通常、Istioのアップデートにはコン トロール プレーンと サイドカー 両方の更新が必要です。さらに、安全なアップデートのためには複数バージョンのコントールプレーンをインストールして段階的にアップデートする カナリア アップデートが必要です。 一方、マネージドASMではコントールプレーンのアップデートが自動で行われます。 サイドカー にも、コントールプレーンの変更を検知して自動再起動するマネージドデータプレーン 14 というしくみが提供されます。これによって、コン トロール プレーンと サイドカー が自動で安全に更新されるのが、大きなメリットです。 キャディでは、マネージドASMを使用していて、これまでに大きなトラブルなくコン トロール プレーンが更新され続けています。 ASMのインストール方法は、Istioが提供する方法ではなく Google Cloudから提供されているものを使用します。マネージドASMでは、「デフォルト設定のASMをfleet API でインストールする方法 15 」か「asmcliで細かくカスタマイズする方法 16 」を選択します。詳細はこれらのドキュメントとサンプルコードのインストール スクリプト を参照ください。 サービスメッシュ全体設定 マネージドASMでは、 アクセスログ や分散トレーシングなどのサービスメッシュ全般に関する設定を ConfigMapで行います。このConfigMapは、istio-systemネームスペースにistio-asm-managedという名前で作成します。 たとえば、 アクセスログ と分散トレーシングを有効化する場合の例はリスト1のとおりです。 ▼リスト1 manifests/3_controlplane_config. yaml apiVersion: v1 kind: ConfigMap metadata: name: istio-asm-managed namespace: istio-system data: mesh: |- # アクセスログの出力と形式 accessLogFile: /dev/stdout accessLogEncoding: JSON accessLogFormat: (..割愛) # デフォルトで使用する分散トレーシングの機能 defaultConfig: tracing: stackdriver: {} ConfigMapの名称の後半のasm-managedは、マネージドASMのバージョンを表すリリースチャネル 17 の値です。マネージドASMは更新頻度が異なる3種類のリリースチャネルがあります。asm-managedは最新バージョンから数世代前の安定稼働を確認したバージョンのIstioをベースとした使いやすいバージョンになっています。 dataに記述する内容はマネージドASMのドキュメント 18 を参照してください。また、ドキュメントにない設定もIstioのMeshConfig 19 を参考に独自に設定できます。 キャディでは、分散トレーシングのサンプリングレートを変更するなど、マネージドASMのドキュメントに記載のない機能も検証し使用しています。 Ingress Gateway Ingress Gateway はサービスメッシュへの通信の入り口となるサービスで、 サイドカー と同じくenvoyを使用しています。ロードバランサとの接続先となるサービスで、 Kubernetes の Ingress リソースの代わりとなるものです。Istioが提供する Gateway 20 リソース、Virtual Service 21 リソースを記述して、 ドメイン やパスに応じたサービスのルーティング、CORSやリク エス トヘッダ加工、 TLS 終端といった処理ができます。ASMではインストール用の マニフェスト が提供されていますので、必要に応じてインストールします。公式ドキュメント 22 かサンプルコードを参照してください。 Istioの マニフェスト コン トロール プレーンを通じて サイドカー の設定を変更するには、Istioが提供する マニフェスト を作成して クラスタ に適用します。数が多いので、よく使用する機能を中心に取り上げます。 最も利用頻度が高いのは、 通信制 御に関する設定でしょう。たとえば、リク エス トルーティング、リトライ、サーキットブレーカなどです。Istioの 通信制 御に関するガイド 23 に設定例がまとまっていますので参照してください。 Gateway 、VirtualService DestinationRule、ServiceEntryといったリソースを使用して通信をカスタマイズできます。 次に、キャディでは認証認可に関する設定をよく使います。サービスメッシュ上のPod間はmTLSで通信するので、各Podはサービスアカウントに基づくクライアント証明書を通信に付与します。クライアント証明書は通信元Podの 身元保証 に使用できるため、特定のPodからのみ通信を受け付けるといった制御ができます。 設定例はサンプルコードを参照してください。 そのほかにも、HTTPリク エス トを検証して、特定ヘッダや、認証 トーク ンがなければ サイドカー でアクセスを拒否するという振る舞いも実現できます。これについては、Istioの認証認可に関するガイド 24 を参照してください。 AuthorizationPolicy、RequestAuthenticationなどのリソースを使用してリク エス トの検証ができます。 アプリケーションをASMに対応する アプリケーションをサービスメッシュに組み込むには、アプリケーションの マニフェスト にも一部修正が必要です。 今回のサンプルコードでは、app というnamespaceに、frontおよびbackendという2つのPodをデプロイします。frontサービスはリスト2のように、backnedサービスの API を呼び出したあとに、その結果を加工してレスポンスを返します。 ▼リスト2 samples/front/index.js(一部抜粋) const BACKEND_SERVICE_URL = "http://backend:3100" app.get('/hello', (req, res) => { axios.get(`${BACKEND_SERVICE_URL}/api`).then(resp => {F res.send({result: resp.data.answer * 2}) })) app namespaceにデプロイするすべてのPodに サイドカー を挿入するようにするには、リスト3のとおり、app namespaceにistio.io/revラベルを追加します。ラベルの値は前述したリリースチャンネルの値です。このラベルが付与されたnamespaceに Podをデプロイすると、Podの マニフェスト に対して サイドカー のimageやサービスメッシュの設定を組み込むための各種設定が自動的に追加されます。 ▼リスト3 manifests/1_namespace. yaml apiVersion: v1 kind: Namespace metadata: name: app labels: istio.io/rev: asm-managed また、ServiceリソースにnameまたはappProtocolフィールドを追加し、サービスの 通信プロトコル を明示します(リスト4)。 ▼リスト4 manifests/5_front. yaml (一部抜粋) apiVersion: v1 kind: Service metadata: name: front spec: type: ClusterIP ports: - port: 3000 name: http-web # appProtocol: http protocol: TCP selector: app: front プロトコル を明示することにより、メトリクスの強化が行われるほか、gRPCを使用している場合はクライアントサイド負荷分散ができるといった利点があります。 プロトコル の種類や規則については、Istioのドキュメント 25 を参照してください。ここまでの内容を設定してアプリケーションをデプロイすると、アプリケーションPodは図5のように、istio-proxyというコンテナが追加された状態で動いていることがわかります。 ▼図5 サイドカー コンテナの挿入を確認する # Pod 一覧 $kubectl get pod -n app # pod内部コンテナを表示 $kubectl get pod backend-nnnn -n app -o jsonpath="{.spec.containers[*].name}" # istio-proxyと backend2つのコンテナがある。 istio-proxy backend istio-proxyが サイドカー です。このとき、Podへの通信はすべて サイドカー 経由となっています。前述のfrontサービスのサンプルコードでは、 http://backend:3100 のようにKubnetesのサービス名でほかのPodへ通信をしています。サービス名を使用した通信は Kubernetes ではよく使用しますが、これは サイドカー を導入したあとでもそのまま使用できます。そのため、アプリケーションコードはASMを導入しても変更する必要はありません。マネージドデータプレーンによりPodは定期的に再起動されることを考慮しておく必要があります。 サイドカー の起動オプションに EXIT_ON_ZERO_ACTIVE_CONNECTIONS というフラグを有効化すると、Pod終了時にPodへの接続がなくなることを待ってから終了するように指示できます。詳しい設定例はサンプルコードのDeploymentリソースを参照してください。 Google Cloudとの統合 デプロイしたアプリケーションにリク エス トを送って稼働確認を行うと、 サイドカー 経由でログやメトリクスが出力されます。これらの情報は Google Cloudの次の機能で利用できます。 Anthos Service Mesh ダッシュ ボード : サイドカー を導入したPodの可視化やリク エス ト統計を確認できる Cloud Logging : サイドカー の アクセスログ が収集される Cloud Monitoring : サイドカー のメトリクスを参照、監視できる Cloud Trace : 分散トレーシングを設定した場合のみ、トレーシングの確認ができる これで、GKEのPod単位での運用監視が Google Cloudの標準ツールでできるようになりました。ASMの導入を通して、GKE上のサービスの可観測性を向上できたことがわかると思います。 今回のまとめ サービスメッシュの導入は、 サイドカー を使うことから、 アーキテクチャ の複雑性やパフォーマンスへの影響を心配されることがあります。 確かに アーキテクチャ は複雑ですが、開発者から見れば、アプリケーションコードの変更なくデプロイできるように配慮されています。一方でASMの運用者は、 アーキテクチャ を理解し、 サイドカー の実装であるenvoyを理解しておくと、運用がしやすくなるでしょう。 クラウド インフラとアプリケーションの間にあるサービスメッシュは、通信エラーなどが起きたとき悪者にされがちです。筆者の経験から言えば、通信エラーの原因は クラウド かアプリケーションのどちらかに適切な通信設定をされていないことが大半であり、サービスメッシュによって強化されたログやメトリクスでエラーを検知できるようになったというだけでした。 通信エラーの調査をする際には、envoyの知識があると役に立ちます。 アクセスログ やメトリクスにあるResponse Flags 26 という値を見ると、通信断が起きたとき、どちら側からどのような理由で切断されたのかがわかります。 パフォーマンスの影響については、筆者はこれまでに3回、プロダクトにサービスメッシュを導入した経験がありますが、サービスメッシュによってパフォーマンスが極端に落ちたということはありません。結局はそのサービスの性能指標を満たせるかどうかを実際に計測してみるのが大事です。 また、 サイドカー によってもたらされる可観測性やセキュリティの機能を、 サイドカー なしで各サービスに実装するとなったら、その開発 工数 は膨大なものとなるでしょう。よって筆者は、サービスメッシュの導入コストは「各サービスに横断で必要となる機能を実装するコストの トレードオフ 」と考えています。 ◆ ◆ ◆ 今回は、サービスメッシュおよび、ASMのインストールとサンプル実行までを紹介しました。 キャディではGKEの可観測性向上を目的にサービスメッシュを導入し、その後も認証認可やセキュリティ強化のために利用する機能を増やしていく予定です。導入目的をはっきりしないといけないと述べましたが、それを明確にするためにも一度ASMを試してみて、みなさんが運用しているサービスの運用向上に役に立つ部分がないかを検証してみると良いと思います。 次回は、モニタリング基盤について紹介する予定です。お楽しみに。 https://github.com/caddijp/sd-asm-example/  ↩︎ https://cloud.google.com/anthos/service-mesh  ↩︎ https://istio.io/  ↩︎ https://cloud.google.com/anthos  ↩︎ AnthosとASMの料金比較 https://cloud.google.com/anthos/pricing https://cloud.google.com/service-mesh/pricing  ↩︎ https://www.envoyproxy.io/  ↩︎ https://opentelemetry.io/  ↩︎ https://istio.io/latest/docs/  ↩︎ https://istio.io/latest/blog/2022/introducing-ambient-mesh/  ↩︎ https://istio.io/latest/docs/tasks/observability/distributed-tracing/overview/  ↩︎ https://opentelemetry.io/  ↩︎ https://opentelemetry.io/docs/kubernetes/operator/  ↩︎ https://cloud.google.com/service-mesh/docs/managed/supported-features-mcp, https://cloud.google.com/service-mesh/docs/supported-features  ↩︎ https://cloud.google.com/service-mesh/docs/managed/provision-managed-anthos-service-mesh#managed-data-plane  ↩︎ https://cloud.google.com/service-mesh/docs/managed/provision-managed-anthos-service-mesh  ↩︎ https://cloud.google.com/service-mesh/docs/managed/provision-managed-anthos-service-mesh  ↩︎ https://cloud.google.com/service-mesh/docs/managed/select-a-release-channel  ↩︎ https://cloud.google.com/service-mesh/docs/managed/enable-managed-anthos-service-mesh-optional-  ↩︎ https://istio.io/latest/docs/reference/config/istio.mesh.v1alpha1/#MeshConfig  ↩︎ https://istio.io/latest/docs/reference/config/networking/gateway/  ↩︎ https://istio.io/latest/docs/reference/config/networking/virtual-service/  ↩︎ https://cloud.google.com/service-mesh/docs/gateways  ↩︎ https://istio.io/latest/docs/tasks/traffic-management/  ↩︎ https://istio.io/latest/docs/tasks/security/  ↩︎ https://istio.io/latest/docs/ops/configuration/traffic- management/protocol-selection/  ↩︎ https://www.envoyproxy.io/docs/envoy/latest/configuration/observability/access_log/usage  ↩︎
アバター
※本記事は、 技術評論社 「Software Design」(2023年10月号) に寄稿した連載記事「 Google Cloudで実践するSREプ ラク ティス」からの転載です。発行元からの許可を得て掲載しております。 はじめに 前回 はRenovateによる依存関係の更新について解説しました。今回はArgo CD 1 を利用した、 Kubernetes への継続的デリバリ(Continuous Delivery、CD)について紹介します。Argo CDとは何か、なぜ使うのか、基本的な機能やキャディでどのように活用しているかを紹介します(図1)。 ▼図1 CADDiスタックにおける今回の位置付け Argo CDとは Argo CDは Kubernetes への継続的デリバリを行うツールです。Git リポジトリ をソースとして継続的デリバリを行う手法をGitOpsと呼びます 2 。Argo CDは Kubernetes へのデプロイをGitOpsに沿って行います。 Kubernetes へのデプロイは、デプロイ内容を記述した マニフェスト ファイルを、 Kubernetes APIやkubectlコマンドに指定して実施します。 この作業は、ファイル数が増えると煩雑になるほか、ファイルの変更を追従して Kubernetes に反映することが困難になります。 Argo CDはGit リポジトリ にある マニフェスト ファイルを取得し、 Kubernetes への マニフェスト ファイルの適用状況を可視化します。また、差分検知や履歴管理、 ロールバック 、自動反映といった機能も備えています。権限制御可能なWeb UI があるため、Argo CDを通して Kubernetes にデプロイされているサービスの構成を把握する、管理者のみがArgo CD経由でデプロイ操作をするといった操作もできます。 なぜArgo CDか Argo CDは豊富な機能を提供していますが、その中でも筆者らがArgo CDを採用している最大の理由は、リッチなWeb UIがあるからです。たかがUIされどUIです。百聞は一見にしかずですので、まだ触ったことがなければぜひ公式のデモ環境 3 を体験してみてください。 DevOps実現のため、開発者がkubectlコマンド使いこなすことはすばらしいことです。しかし、チーム内すべての開発者がそれを習得する必要はないと考えています。Web UIでは、簡単にデプロイしたりリソースの状態を参照したりできます。それによって開発者が、プロダクト(サービス)の本質的な価値向上のためにより多くの時間を使えるようになります。 また、キャディのArgo CD導入以前(2020年ごろ)のCDは、Push型GitOps 4 を採用しており、セキュリティやデプロイ単位の柔軟性・属人性といった面で次のような課題がありました。これらの課題の解消にもArgo CDは役立っています。 Google Kubernetes Engine(GKE)のPrivate Cluster 5 に対して、デプロイごとに CD Server側のIPを承認済みネットワーク 6 に追加する必要がある CD Server側で、機密情報をDecryptして マニフェスト をデプロイする必要がある 特定のプロダクトの単位でデプロイができない(一括で複数のプロダクトリソースをClusterに対してすべてまとめてデプロイしていた) デプロイ スクリプト を作り込んであり、作成者以外が簡単に変更できない デプロイの流れ 図2は、Argo CDによるデプロイの流れを抽象化したものです。Git リポジトリ の変更を起点として、Argo CDがその変更を検知し、次の流れでデプロイを実行します。 ①Argo CDがPollingによりGit リポジトリ から Kubernetes マニフェスト を取得、差分検知する ②Argo CDが指定された差分をデプロイする ③開発者がWeb UI上でデプロイ結果を確認する また、これは自動同期の設定を有効にしている場合の例です。自動同期の設定を無効にしておくと、①と②のステップの間で、開発者がWebUI上で差分を確認しながら手動で同期処理をトリガーできます。 ▼図2 Argo CDによるデプロイの流れ Argo CD のProjectとApplication Argo CD を構成する重要な要素として、ProjectとApplicationがあります。 Kubernetes のCustom Resource Definition では、「AppProject」と「Application」という名前でそれぞれ定義されています。 図3は、ProjectとApplicationの構成例と簡単なデプロイの関係性を表したものです。 ▼図3 ProjectとApplication Applicationの マニフェスト には、デプロイ対象 Kubernetes マニフェスト 群(以降、 K8s マニフェスト )の場所を定義します(リスト1)。 このApplicationがArgo CDによるデプロイの最小単位となります。 より具体的には、次のような情報を指定します。 デプロイ先のClusterやNamespace 所属するProject デプロイ対象 K8s マニフェスト 群の場所やRevision Git リポジトリ とCluster間で差分が発生したときの同期ポリシーデプロイ対象 K8s マニフェスト 群の指定は、デフォルトでは次のものに対応しています。 Kustomize Helm chart YAML / JSON /Jsonnetの ディレクト リ プラグイン を別途入れることによって、そのほかのConfig管理ツールの利用も可能です。 また、Applicationは必ず1つのProjectにひも付きます。デフォルトでは、default Projectが用意されており、指定が可能となっていますが、特別な事情がない限り個別にProjectを作成することをお勧めします。 ▼リスト1 application-example. yaml apiVersion : argoproj.io/v1alpha1 kind : Application metadata : name : example namespace : argocd spec : destination : namespace : example-namespace server : https://kubernetes.default.svc project : example-project source : path : applications/example/overlays/dev repoURL : https://github.com/caddijp/example-cluster-config.git targetRevision : main syncPolicy : automated : {} Projectは、Applicationを束ねるオブジェクトです(リスト2)。この マニフェスト に、一定の制限を定義することで統制を効かせやすくなります。具体的には次のようなものです。 デプロイできるGit リポジトリ の制限 デプロイ先のClusterやNamespaceの制限 デプロイできる Kubernetes リソースの種類の制限 RBACで利用するProjectにひも付くロールの定義 RBAC(後述)の設定で、特定のProjectをそのオーナーとなる開発チームへ割り当てることで、誰が何を管理しているかを明確にしつつ、必要最小限の権限を付与できます。 ▼リスト2 project-example. yaml apiVersion : argoproj.io/v1alpha1 kind : AppProject metadata : name : example-project namespace : argocd finalizers : - resources-finalizer.argocd.argoproj.io spec : description : Admin Project sourceRepos : - '*' destinations : - namespace : example-namespace server : https://kubernetes.default.svc clusterResourceWhitelist : - group : '*' kind : '*' roles : [] キャディで利用している構成 図4は、キャディで利用している構成の概要図です。開発者を起点としたデプロイの流れは次のようになります。 ①:開発者が GitHub のPull requestをマージもしくはRelease Tagを作成する ②: GitHub Actionsの指定されたWorkflowが起動する ③:Imageを作成しArtifact Registryにプッシュする ④: K8s マニフェスト リポジトリ の対象のImage Tagを書き換える ⑤:Argo CDが Polling によりGit リポジトリ から K8s マニフェスト を取得、差分検知する ⑥:Argo CDが指定された差分をデプロイする ⑦:Argo CDが指定されたSlack Channelに同期状態の変更を通知する 図4では表現できていないところを含め、詳細を解説していきます。 ▼図4 キャディで利用している構成 Cluster構成 筆者らは、マルチテナント方式 7 でArgo CDを構築し、同じCluster上で複数のプロダクト(サービス)を運用しています。環境はCluster単位で分離し、Development/Staging/Productionの3つです。 また、Argo CDは仕様上1つのArgo CD環境で複数のClusterを管理できますが、筆者らClusterごとにArgo CDを構築するようにしています。おもな理由は3つです。 1つめは「単一障害点(SPOF)になるのを避ける」ためです。仮に1つのArgo CD環境ですべてのClusterを管理している場合、そのArgo CD環境が動かなくなったときにすべてのデリバリが止まってしまうリスクがあります。ClusterごとにArgo CDを構築しておくことで、依存関係のない独立したClusterとなり、そのリスクを最小化できます。 2つ目は「アップグレードがしやすい」からです。アップグレードの重要性は前回の連載で触れているため省略します。Argo CDは開発が活発で、リリースサイクルが早いです。仮に、アップグレード時に移行ミスがあった場合、デリバリが 止まってしまうリスクがあります。 Development環境のClusterからアップグレードを進め、適用後一定期間様子を見るなど、リスクを最小化するためのアップグレード戦略を立てやすくなります。 3つ目は「 Kubernetes API を外部に公開する必要がなくなる」からです。前述のとおり独立したClusterとなるため、外部に API を公開する必要がなく、Clusterをより安全に運用できます。 リポジトリ 構成 GitHub の リポジトリ は次のような構成となっています。 Clusterで管理する K8s マニフェスト を集約した リポジトリ が1つ アプリケーションごとの ソースコード リポジトリ が複数 K8s マニフェスト はアプリケーション側の リポジトリ でも管理できます。しかし筆者らは、それぞれの責務やライフサイクルが異なるため、Argo CDを採用する前から意図的に リポジトリ を分離しています。ポイントは、公式ドキュメントのベストプ ラク ティス 8 に記載されています。 K8s マニフェスト とアプリケーションコードの リポジトリ を分離する利点は次のとおりです。 それぞれのライフサイクルに依存しない 継続的インテグレーション やデリバリを構築できる 変更履歴(監査ログ)をきれいに保てる それぞれの リポジトリ でアクセス権や変更権限を分離できる また、 リポジトリ を分離しない場合は次のような課題が残ります。 アプリケーションコードの リポジトリ が複数あるとき、どこに K8s マニフェスト を配置するべきかを考える必要がある 自動化のトリガーとなる変更対象が何かを判定する必要があり、 継続的インテグレーション のパイプライン構築が複雑化する ブランチ戦略 図5はブランチ戦略を簡単に表現した図です。 アプリケーションコードの リポジトリ と K8s マニフェスト の リポジトリ 、どちらもmainブランチのみを利用しています。 K8s マニフェスト リポジトリ 上では、通常の マニフェスト の変更はPull requestを作成する運用になっています。アプリケーションのデプロイパイプラインではImage Tagのみを GitHub Actionsで自動的に書き換えています。 ▼図5 ブランチ戦略 Development環境への反映 Development環境へ反映の流れは次のようになります。 ①アプリケーションコードの リポジトリ でPull requestをマージする ② GitHub Actionsでテスト、Imageの作成後、 K8s マニフェスト リポジトリ のWorkflowをトリガーする ③ K8s マニフェスト リポジトリ のWorkflowでDevelopment環境用の K8s マニフェスト のImage Tagを書き換える Image Tagの書き換えは GitHub Actionsのrepository_dispatch 9 を利用して K8s マニフェスト リポジトリ 側で実行しています。アプリケーションコードの リポジトリ 側のWorkflowで書き換えると、コンフリクトが発生したり、余計な権限を持たせたりしないといけないからです。 Staging/Production環境への反映 Staging/Production環境へ反映の流れは次のようになります。基本的な流れはDevelopment環境の場合と同様ですが、起点と2環境ぶん同時にImage Tag書き換えをするところが異なります。 ①アプリケーションコードの リポジトリ でRelease Tagを作成する ② GitHub Actionsでテスト、Imageの作成後、 K8s マニフェスト リポジトリ のWorkflowをトリガーする ③ K8s マニフェスト リポジトリ のWorkflowでStaging/Production環境用の K8s マニフェスト のImage Tagを書き換える Production環境だけArgo CDの自動同期設定をOFFにしており、Staging環境での動作確認後、開発チームごとに任意のタイミングでWebUI上からデプロイや ロールバック をする運用となっています。 同じCommit HashでImageがすでに作成済みのときは、Image作成処理をSkipすることでリードタイムを短縮する工夫をしています。Development 環境で検証済みの ImageをStaging/Production環境でも使うことは、アプリケーションコードの同一性担保にも役立ちます。 Argo CDの設定管理 Argo CDは、 Kubernetes へデプロイするリソースを宣言的に管理します。開発者が追加する K8s マニフェスト だけでなく、Argo CD本体やその設定も宣言的に管理 10 できます。 Argo CDをClusterへインストール後、Argo CDのProjectやApplicationをWeb UIから追加できますが、筆者らはそれらの設定もコード化しています。Argo CDの本体や設定をコード化するおもな理由は、次のようなことを実現するためです。 再現性 再利用性 属人性の排除 静的解析による統制 K8s マニフェスト リポジトリ では、Kustomizeを利用し、 ディレクト リ構成は下記のようになっています。 ▼リスト3 K8s マニフェスト リポジトリ の ディレクト リ構成 applications/ ├── product1/ │ ├── base/ │ │ ├── ui/ │ │ │ └── ... │ │ ├── bff/ │ │ │ ├── deployment.yaml │ │ │ ├── secret.yaml │ │ │ ├── service.yaml │ │ │ └── config.yaml │ │ └── kustomization.yaml │ └── overlays/ │ ├── dev │ │ └── ... │ │ └── kustomization.yaml │ ├── stg │ └── prod ├── product2 └── ... argocd/ ├── base/ │ ├── argocd-cm.yaml │ ├── argocd-notifications-cm.yaml │ ├── ... │ └── kustomization.yaml └── overlays/ ├── dev/ │ ├── pj-admin/ │ │ ├── app-argocd.yaml │ │ ├── ... │ │ ├── helm-eso.yaml │ │ ├── helm-eso.values.yaml │ │ └── project.yaml │ ├── pj-sample1/ │ │ ├── app-product1.yaml │ │ └── app-product2.yaml │ ├── ... │ ├── argocd-rbac-cm.yaml │ └── kustomization.yaml ├── stg └── prod applications ディレクト リでは、Argo CD Applicationから指定する K8s マニフェスト を管理します。ここでは、プロダクト(サービス)ごと ディレクト リを作成し、デプロイしたい K8s マニフェスト 群の最小単位をまとめています。この K8s マニフェスト 群の最小単位が、どのArgo CD Application/Project や Namespaceに所属するかは関心事として切り離されているため、意図的にフラットな ディレクト リ構成としています。 argocd ディレクト リでは、Argo CDの本体や設定を管理します。初回インストールは、KustomizeでArgo CDのリモートリソース指定し 11 K8s マニフェスト を作成し、kubectlコマンドで反映します。その K8s マニフェスト 自体をapp-argocd. yaml (リスト4)で定義した1つのArgo CD Applicationとして、インストールされたArgo CDで管理します。 ▼リスト4 app-argocd. yaml apiVersion : argoproj.io/v1alpha1 kind : Application metadata : name : app-argocd namespace : argocd spec : destination : namespace : argocd server : https://kubernetes.default.svc project : pj-admin source : path : argocd/overlays/dev repoURL : https://github.com/caddijp/example-cluster-config.git targetRevision : main syncPolicy : RBAC Argo CDの認証にはさまざまな方法がとれますが、筆者らキャディでは GitHub 認証を使用しています。 GitHub アカウントにひも付いている GitHub Team 12 とArgo CDのRole 13 をひも付けて権限を管理しています。 リソースとアクションを組み合わせることで、要件に合わせて柔軟に権限を定義し、ユーザーグループ( GitHub Team)へのひも付けができます。Argo CD Applicationに対して個別に権限付与するより、Argo CD Project単位で権限付与たほうが圧倒的に楽ですので、基本的に開発チーム単位でArgo CD Projectを定義するのがお勧めです。 しかし、キャディはスタートアップという特性上、事業や開発チームの変更頻度が高く、その運用だと開発チームの実態と Argo CD Projectがすぐに一致しなくなります。そのため、執筆時点では、プロダクト(サービス)や類似プロダクト群ごとにArgo CD Projectを作成するケースが多くなっています。 Secret管理 GKE内で機密情報(Secret)を安全かつ簡単に管理するために、External Secrets 14 を利用しています。機密情報の実体は Google CloudのSecret Manager 15 で管理していますExternal Secretsを利用することで、各 Kubernetes リソースからは Kubernetes Secretを通して透過的に機密情報にアクセスできます。 また、Workload Identity 16 を利用し、External Secretsの Kubernetes サービスアカウントと Google Cloudのサービスアカウントをひも付けることができます。これによって、Secret Managerを参照するための鍵情報(サービスアカウントキー)をGKE内に持たせず運用できています。 ちなみに、 Google Cloudのサービスアカウントのベストプ ラク ティス 17 を参考にして、External Secret以外のリソースも基本的にサービスアカウントを分離しWorkload Identityを利用しています。 Google Cloudサービスアカウントの鍵情報を管理する必要がなくなることにより、両方のサービスアカウントの分離作業が楽になります。それは、サービスアカウントの権限を最小化し、トレーサビリティを向上させることも楽になるということです。 Slackへの通知 K8s マニフェスト の同期状態をSlackへ通知 18 させて、継続的デリバリの状態を把握できるようにしています。Argo CDのv2.3からArgo CD Notificationsが内包 19 されるようになり、より簡単に通知の設定ができます。通知先は、Argo CD ProjectやArgo CD Applicationのannotationsでイベントごとに定義します。 おわりに 今回はArgo CDの概要とキャディでの採用理由、また基本的な機能や継続的デリバリの構築事例を紹介しました。キャディでは、2021年の初めからArgo CDへ移行し、今ではプロダクト(サービス)を構築、運用していくための欠かせないツールになっています。筆者自身、執筆していく中で、Argo CDがさまざまな運用の課題を解決してくれるすばらしいツールだとあらためて感じました。 来月はサービスメッシュについて紹介する予定です。お楽しみに。 https://github.com/argoproj/argo-cd  ↩︎ https://www.weave.works/technologies/gitops/  ↩︎ https://cd.apps.argoproj.io/  ↩︎ https://caddi.tech/archives/2041  ↩︎ https://cloud.google.com/kubernetes-engine/docs/how-to/private-clusters  ↩︎ https://cloud.google.com/kubernetes-engine/docs/how-to/authorized-networks  ↩︎ https://argo-cd.readthedocs.io/en/stable/operator-manual/installation/#multi-tenant  ↩︎ https://argocd.readthedocs.io/en/stable/user-guide/best_practices/  ↩︎ https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#repository_dispatch  ↩︎ https://argo-cd.readthedocs.io/en/stable/operator-manual/declarative-setup/  ↩︎ https://argo-cd.readthedocs.io/en/stable/operator-manual/installation/#kustomize  ↩︎ https://docs.github.com/en/organizations/organizing-members-into-teams/about-teams  ↩︎ https://argo-cd.readthedocs.io/en/stable/operator-manual/rbac/  ↩︎ https://github.com/external-secrets/external-secrets  ↩︎ https://cloud.google.com/secret-manager  ↩︎ https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity?hl=ja  ↩︎ https://cloud.google.com/iam/docs/best-practices-service-accounts?hl=ja#using_service_accounts  ↩︎ https://argo-cd.readthedocs.io/en/stable/operator-manual/notifications/services/slack/  ↩︎ https://argo-cd.readthedocs.io/en/stable/operator-manual/upgrading/2.2-2.3/  ↩︎
アバター
※本記事は、技術評論社 「Software Design」(2023年9月号) に寄稿した連載記事「Google Cloudで実践するSREプラクティス」からの転載です。発行元からの許可を得て掲載しております。 はじめに 前回 はTerraformとGitHub Actionsで実践するインフラCI/CDについて解説しました。 今回はRenovate 1 を利用した、ツールやライブラリの依存関係更新について紹介します(図1)。 なぜ依存関係を更新する必要がある必要があるかという背景から、Renovateのしくみの解説と利用方法、更新の運用を手軽に行うためにキャディで取り組んでいることを紹介します。 ▼図1 CADDiスタックにおける今回の位置付け なぜ依存関係を更新するのか 現代のアプリケーション開発において、私たちエンジニアはさまざまなツールやライブラリの利用を通して、先人の知恵を借り、効率的な開発を進めています。また、前回までで紹介したTerraformやGitHub ActionsなどのインフラCI/CDの領域でも、なんらかの再利用のしくみを活用することで効率化しています。 しかし、ツールやライブラリは絶えずアップデートされています。機能の追加やバグの修正、脆弱性への対策など、その理由はさまざまです。その中でも筆者らが依存関係の更新を重視する理由は、セキュリティと対応コストの2点です。 セキュリティ観点では、ツールやライブラリの脆弱性やバグ修正の更新をいち早く検知・対応することが欠かせません。キャディでは、自社事業の基幹システムをフルクラウドで構築・運用しており、これらの放置は安定した価値提供を損ねることにつながるからです。 対応コスト観点では、頻繁な対応によって、バージョン間の差分が小さいうちに更新できることを重視しています。そのため、1回あたりの更新対応のコストを下げることが可能です。また、CHANGELOGにも常に目を通すことになるため、副次的に情報のキャッチアップにもつながります。 なぜRenovateを使うか 依存関係の更新をサポートしてくれる主要なツールとしては、RenovateのほかにGitHubで標準提供されているDependabot 2 があります。 キャディでは、2020年にRenovateを採用するまでは、Dependabotを一部で利用している程度でした。 本連載で紹介しているように、筆者の所属するPlatformグループでは、TerraformやGitHubActions を用いた IaC や CI/CDの高度化に取り組んでいます。これらにより依存するものが増えているため、前節のとおり依存関係の更新は必要です。一方で、事業の拡大を支えるための、本質的な価値提供にも集中する必要があります。 このような背景に対するトイル削減の一環で、高いカスタマイズ性を持つRenovateに魅力を感じ、利用を拡大しました。とくに、のちほど紹介するauto merge、Pull request(PR)のグループ化、正規表現を利用しながら更新ルールをカスタマイズできる点が効果的だったととらえています。 また、Dependabotは利用をやめているわけではありません。一部の開発チームではセキュリティアラートを活用するなどして、Renovateと共存しています。 Renovateでもセキュリティアラートを通知する設定はありますが、それぞれのツールでも得手不得手もあるため、開発者が最もメンテナンスしやすい方法を選択していく必要があると考えています。 Renovateのしくみ ここからは、Renovateのしくみと設定方法について簡単に解説します(図2)。さらに理解を深めたい方は公式ドキュメント 3 を参照してください。 Renovateは依存関係を一元管理し、新しいバージョンがリリースされたときに自動的にファイルを更新します。そしてGitHubやGitLabなどのサポートされているプラットフォームにて、RenovateによってPRやMerge requestが作成されます。 まず、Renovateは依存関係の現状を把握するために各バージョンを確認します。JavaScriptの場合は package.json、Terraform の場合はterraform blockのprovider定義など、各言語やツールに応じたファイルから取得します。 次に、そのバージョンが最新であるかどうかをRenovateのルールに従って判定し、最新でない場合はバージョンを更新するPRを作成します。 ▼図2 Renovateのしくみ Renovateの設定 Renovateの挙動を理解するために欠かせない概念として、設定ファイルとマネージャーがあります。 設定ファイル Renovateは設定ファイルや環境変数によって挙動をカスタマイズできます。どんな依存関係にあるものをどんな頻度で更新するか、レビュアーを指定するか、PRのラベルを指定するかなど、さまざまな設定が可能です。設定ファイルは、 renovate.json .github/renovate.json .renovaterc として配置できたり、コメントが記載できるようにも拡張されたjson5形式 4 でも記述できます。 リスト1の設定例をもとに、簡単に紹介します。より詳細を理解したい方はドキュメント 5 を参照ください。 ▼リスト1 renovate.json { "$schema": "https://docs.renovatebot.com/renovate-schema.json", // ① "extends": [ // ② "config:base", // ⑤ ":label(renovate)", // ⑥ ":timezone(Asia/Tokyo)", // ⑦ ], "schedule": ["after 1am and before 9am every weekday"], // ③ "reviewers": ["team:reviewer-team", "kei711"], // ④ } $schema(①)はJSON Schemaの指定です。この値により、エディタによっては設定名が補完されるようになります。extends(②)は設定値のプリセットを指定します。schedule(③)はcron形式で実行スケジュールを指定します。リスト1の例では、平日の午前1時から午前9時の間に実行されます。reviewers(④)はレビュアーを指定します。GitHubやGitLabなどの挙動に合わせて、グループや個人を指定できます。 また、リスト1の例ではRenovateで用意されているデフォルトプリセットの一部を指定しているため、こちらも紹介します。 config:base(⑤)はRenovateのデフォルト設定で、設定値はRenovate自体に組み込まれています 6 。:label(⑥)の設定により、作成されるPRに特定のラベルを指定します。ここでは、renovateというラベルを設定します。:timezone(⑦)の設定により、scheduleで指定された実行スケジュールのタイムゾーンを指定します。なお、デフォルトプリセットの詳細はドキュメント 7 を参照してください。 このように、設定ファイルによりRenovate自体の挙動を柔軟にカスタマイズできます。 マネージャー Renovateのマネージャーとは、各言語やツールに応じた処理が定義されたモジュールのことを指します。このマネージャーを通して、Renovateが依存関係の解析や更新をします。たとえば、JavaScriptであればnpm、Terraformであればterraform や terraform-version などのマネージャーがあります。 Renovateは初期設定でも多くのマネージャーを利用する設定となっています。詳細はドキュメント 8 を参照してください。各マネージャーもドキュメントにて紹介されています。 また、未設定だと利用されないマネージャーもあります。たとえば、Argo CDはファイル構成が利用者に委ねられており正確な検知が難しいため、リスト2のように明示が必要です。 ▼リスト2 Argo CD向け設定の抜粋 ... "argocd": { "fileMatch": [ "argocd/.+\\.ya?ml$", "applications/.+\\.ya?ml$" ] }, Argo CDは、本連載第1回(本誌2023年4月号)で紹介した、KubernetesマニフェストをGitOpsで管理するためのツールです。本連載でも以降の回で詳しく紹介する予定です。 最初から用意されているマネージャーのほかにも、正規表現を利用して振る舞いを定義できる、regex manager 9 が存在します。 こちらの詳細はキャディで利用している実例とともに、のちほど紹介します。 Renovateの組み込み方法 ここからは、実際の使用方法を説明します。 大きく分けて「GitHub Appの利用」「ローカルで実行」「GitHub Actionsなどの環境で実行」の3パターンがありますが、ここでは最も手軽なGitHub Appによる方法を紹介します。 RenovateはMend社により、無償のGitHub Appとしても提供されています。ソースコードの管理にGitHubを利用している場合には、GitHubのMarketplace 10 からGitHub Appを導入して利用することで、手軽にRenovateを利用できます。 MarketplaceからGitHub Appをインストールし、依存関係を自動更新させたいリポジトリを選択します。そうすると、Renovateの更新対象として選択したリポジトリにて、Renovateの設定ファイルであるrenovate.jsonを作成するPRが自動作成されます。設定ファイルをリポジトリに配置することで、Renovateの設定が完了し、自動で依存関係の更新が行われるようになります。 GitHub Appの各リポジトリにおけるGitHub Appの動作状況は、ポータルサイト 11 から確認できます。GitHub Organizationを選択すると、Installed Repositoriesとして、Renovateをインストールしたリポジトリの一覧と、それぞれのインストール日や最終実行時刻が表示されます。 次に、リポジトリの行をクリックするとRecentJobsのページが表示され、リポジトリ単位の実行状況が表示されます。 さらにJobごとの行をクリックすると、Renovateが実行された際のログを、ログレベルや詳細情報の表示切り替えをしながら確認できます。もしRenovateの設定変更がうまくPRに反映されていない場合は、このログから状況を確認できます。 応用的な使い方 ここからは、更新の運用を手軽に行うために取り組んでいることをピックアップして紹介します。 共通設定の定義と利用 Renovateの設定はrenovate.jsonに記述しますが、リポジトリそれぞれで定義すると管理コストが非常に高くなります。実際、キャディでは管理するリポジトリが多く、設定の共通化で管理コストを下げています。 共通設定の共有方法は複数ありますが、今回は手軽なGitHubで公開する方法を紹介します。 ほかの共有方法や、GitHubで公開する方法の詳細はドキュメント 12 を参照してください。 まず、renovateの設定ファイルを共有するためのリポジトリを作成します。キャディでは、renovate-configという名前で作成しています。 次に、後述するプリセット名を省略した場合のため、default.jsonにrenovateの設定を記述します。また、言語や開発チームごとに共通設定を用意したい場合はpreset name.jsonというような命名をします。たとえば、go.jsonやteam-platform.json5のような形です。 このように共通設定を用意したら、利用したいリポジトリのrenovate.jsonにて、リスト3のように記述します。GITHUB_ORGはcaddijpのような組織名やkei711のようなアカウント名に書き換えてください。 ▼リスト3 共通設定の利用例 { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "github>GITHUB_ORG/renovate-config", // ① "github>GITHUB_ORG/renovate-config:go", // ② "github>GITHUB_ORG/renovate-config:team-platform.json5", // ③ ] } リスト3の設定ファイルでは、次のように共通設定を参照します。 ①リポジトリ上のdefault.jsonを利用 ②プリセット名を指定し、go.jsonを利用 ③platform.json5を利用。JSON5形式の場合はプリセット名に拡張子を含める必要がある また、Gitのタグやファイルパスも指定できます。詳細な例はドキュメント 13 のGitHubの項目を参照してください。 なお、プライベートリポジトリでGitHub AppのRenovateを利用する場合には、renovate-configリポジトリもプライベートリポジトリにし、GitHub Appの導入も必要となる点に注意してください。 [Column] Renovate の GitHub Appを利用する際の注意点 GitHub Appは手軽に使える反面、注意すべき点が2点あります。 1点目は、セキュリティ観点です。GitHub Appを導入するということは、アプリケーションを作成する場合のサプライチェーン攻撃の攻撃面が増えることを意味します。RenovateのGitHub Appをインストールすると、自分たちが管理するソースコードへのアクセス権を与えることになります。そのため、このアクセス権が奪われた場合や、GitHub Appに不正なコードが含まれている場合、それが自分たちのソースコードにも影響を及ぼす可能性があることを理解する必要があります。 2点目は、作業コストの観点です。全リポジトリを対象にRenovateを導入するオプションがありますが、リポジトリに導入したぶん、Renovateにより依存関係の更新PRが作成されます。作成されるPRが多過ぎるとメンテナンスを行いにくくなります。そのため、前述のように導入するリポジトリを選択することをお勧めします。また、セキュリティリスクにおいても、リスクを取れるリポジトリと取れないリポジトリもあるため、適切に選択をする必要があります。 設定ファイルの検証 Renovateには設定ファイルの構文が正常かどうかを確認するコマンドが用意されています。 RENOVATE_CONFIG_FILE=renovate.jsonnpx renovate-config-validator のように実行することで確認できます。共通設定を管理するリポジトリのCIに設定しておくと安心して編集できるでしょう。 GitHubでオートマージを利用する Renovateのautomergeを活用する場合は、設定ファイルの変更と、GitHubやGitLabなどにて設定しているマージ条件が満たされている必要があります。 ・Allow auto-mergeが有効になっている ・ Branch Protection Rulesで設定されたマージ条件が満たされている たとえば、CODEOWNERSのレビューを必須にしている場合には、更新対象のファイルをCODEOWNERSから除外するか、ルールをバイパスする設定を追加する必要があります。また、Branch Protection RulesでPRのapproveを必須としている場合には、RenovateのPRを自動approveしてくれる GitHub App である「renovate-approve」を導入します。approveの数などの必要に応じて「renovate-approve2」のGitHub Appsも導入してください。 リスト4は、セマンティックバージョニングされたツールで、minorpatchの更新をオートマージする例です。設定ファイルでは、packageRulesブロックで依存関係ごとに上書きできます。matchPackageNamesでは対象を指定することで、特定の依存関係のみオートマージできます。また、必要に応じてignoreTestsでテストの実行を無視できます。 ▼リスト4 オートマージの設定例 { "platformAutomerge": true, "packageRules": [ { "automerge": true, "matchUpdateTypes": ["minor", "patch"], "matchPackageNames": [ "kubernetes-sigs/kustomize", "mikefarah/yq" ], "ignoreTests": true } ] } グループ化により、依存関係をまとめて更新する 同じ用途のバージョンは、一度に更新したいことが多いかと思います。キャディでは前回までの連載で紹介しているとおり、TerraformでGoogle Cloudの設定をIaC化しています。 Google Cloudの設定をするためのTerraformproviderには、googleとgoogle-betaの2種類があります。早く技術検証したい場合にはgoogle-beta providerを利用することがあります。 筆者らはこれらのproviderをまとめて更新するためのリスト5の設定を利用しています。 ▼リスト5 PRをグループ化する設定例 { "packageRules": [ { "matchManagers": ["terraform"], "matchPackageNames": ["google", "google-beta"], "groupName": "Google Terraform providers" } ] } matchManagersとmatchPackageNamesで対象となる依存関係を指定します。そしてgroupNameを指定することで、1つのPRの中で一度にバージョンが更新されるようになります。 regexManagersによる独自の更新ルール定義 筆者らは、TerraformによるGoogle Cloudの設定処理を共通化するため、GitHub ActionsのComposite Actionを作成しています。このとき、terraform_versionを渡す必要がありますが、このバージョンの指定方法ではRenovateが更新してくれません(リスト6)。 ▼リスト6 標準では更新対象外となる独自定義 ... - name: Setup Terraform and Auth Google Cloud uses: caddijp/gh-actions/terraform/setup_terraform@v0.21.0 with: workload_identity_provider: ${{ vars.GCP_WI_PROVIDER }} service_account: ${{ vars.GCP_WI_SERVICE_ACCOUNT }} working_directory: ./terraform terraform_version: 1.4.6 そこで登場するのがregex managerです。対象ファイルと正規表現をもとに更新すべき対象を絞り込みし、依存するバージョンの公開先を指定することで一緒に更新してくれるようになります。 リスト7の設定は、キャディで実際に利用している共通定義の一部を抜粋したものです。 ▼リスト7 独自の更新ルールを指示する設定 { "regexManagers": [ { "fileMatch": [ // ① "^\\.github/workflows/.*\\.ya?ml$", "^\\.circleci/config\\.ya?ml$" ], "matchStrings": [ // ② "terraform_version: +['\"]?(?<currentValue>[^'\" \\n]+?)['\"]?\\n" // ③ ], "depNameTemplate": "hashicorp/terraform", // ④ "datasourceTemplate": "github-releases", // ⑤ "extractVersionTemplate": "^v(?<version>.*)$" // ⑥ } ] } まず、Renovateの更新対象となるファイルを①fileMatchで指定します。キャディではGitHubActionsのほか、CircleCIも利用しているので、両方を指定しています。次の②matchStringsでは正規表現を指定し、fileMatchで指定したファイルの中からマッチするものを探します。③ がRenovate中で特殊に扱われているキャプチャグループ名です。terraform_version: 1.4.6という表記のほかにもterraform_version: '1.4.6'のような表記、terraform_version: 1.4.6 # comment のような表記のブレも吸収できるようにしています。 次の④⑤⑥は、データソースの設定です。④depNameTemplateと⑤datasourceTemplate により、TerraformのGitHub Release 14 の情報をもとに最新バージョンを取得します。最後の⑥extractVersionTemplateは、バージョンの表現を指定しています。Terraformのバージョンはv1.4.6のように先頭がvから始まるタグの命名ルールです。ですが、キャディではworkflow中のバージョンではvを除いているため、記述方法に合わせるように先頭のvを除外しています。ほかにも特殊なキャプチャグループ名がありますので、興味がある方はregex managerのドキュメント 15 を参照してください。 Renovateの更新運用の工夫 Renovateが自動的にPRを作成してくれるとはいえ、リポジトリ数が増えると差分を確認しながらマージするだけでも一苦労です。依存関係の更新が形骸化しないように、Platformグループ設立から2年間試行錯誤してきました。ここからは筆者らが現在行っている運用の一部を紹介します。 依存関係更新の運用 Renovateの更新PRが溜まってしまうことを防ぎつつも、依存関係を更新していくには、習慣化するのが一番です。そこで筆者らは毎週1回30分カレンダーにRenovate用の予定を登録しました。この時間内は必ず依存関係を更新するルールにしています。 また、作業開始前にRenovateによるPRがあるリポジトリのURLをSlackに通知しています。このしくみを作ることにより、対象PRを探しに行く手間をなくすようにしました。図3のようにリポジトリ単位でリポジトリのURLを投稿されるため、それぞれにリアクションができるようになります。筆者らは作業開始するリポジトリに対して「やります」のリアクションをしながら分担して作業を進めています。 ▼図3 Slackを利用した運用 CIのみで利用するツールのpatch、minorは極力automergeする CIのみで利用するテスト、Lint、静的解析に関連するツールを自動更新しても大きく壊れることがなかったため、極力automergeを利用するようにしています。 ただし、CDでも利用しているツールは自動でデプロイされると影響が大きく困るため、automergeの対象から外しています。 Renovate経由で作られたPRの通知を削減 筆者らはSlackのGitHub Appを経由して、担当するリポジトリのPRを定期的にSlackに通知し、PRマージまでのリードタイムを短くする取り組みをしています。この通知設定にて、renovateのラベルが付いているPRを除外することで、Renovateによる通知疲れを低減させています。 Platformグループは横断組織であることから、認知負荷が高くなりがちなため、日ごろから通知を減らす努力をしています。 おわりに 今回は依存関係の更新が必要な背景、Renovateの解説、キャディでの取り組みについて紹介しました。連載の流れから、TerraformとRenovateの組み合わせを中心に紹介をしてきましたが、今回紹介したものはアプリケーション開発でも同様に使えるものばかりです。みなさんの開発においても、依存関係の更新が楽になることを願っています。来月はArgo CDを利用したKubernetesのCDについて、キャディの事例をまじえながら紹介する予定です。 https://www.mend.io/renovate/  ↩︎ https://docs.github.com/ja/code-security/dependabot  ↩︎ https://docs.renovatebot.com/  ↩︎ https://json5.org/  ↩︎ https://docs.renovatebot.com/configuration-options/  ↩︎ https://github.com/renovatebot/renovate/blob/35.141.3/lib/config/presets/internal/config.ts  ↩︎ https://docs.renovatebot.com/presets-config/  ↩︎ https://docs.renovatebot.com/modules/manager/  ↩︎ https://docs.renovatebot.com/modules/manager/regex/  ↩︎ https://github.com/marketplace/renovate  ↩︎ https://developer.mend.io/  ↩︎ https://docs.renovatebot.com/config-presets/  ↩︎ https://docs.renovatebot.com/config-presets/#github  ↩︎ https://github.com/hashicorp/terraform/releases  ↩︎ https://docs.renovatebot.com/modules/manager/regex/  ↩︎
アバター
こんにちは。CADDi DRAWERでMLOpsチームのチームリードをしている中村遵介です。 チームリードは技術に関して多方面の意思決定を行ってチームの成果に貢献するテッ クリード と異なり、チームのメンバーや組織に関する意思決定を行ってチームの成長に貢献します。貢献したいです。頑張ります。 最近では、 機械学習 メンバー/MLOpsメンバーの採用を積極的に行っています。チームメンバーも採用に対してもっと関わっていきたい、と普段から活動してくれています。 私たちのチームでは採用に半構造化面接を用いています。どういう観点でどんな質問をするのか、を予め決めています。 しかし、メンバーの期待している人物像に関して聞いてみると、この質問内容に対して人物像が少しずつ乖離し始めているのはいないか、ということが気になりました。また、チーム全体で顔を合わせて議論すると「xxな人に来てほしい」という何となくのイメージは共有されているのですが、詳細を一人一人に ヒアリ ングすると微妙に想定している内容が異なることに気づきました。当然ですね。 そこで、チームメンバーで「我々はどういう仲間と働きたいのか」を 言語化 した後に、構造化面接の内容を見直すワークショップを開催しました。 ワークショップの準備 Values Card Values Cardとは、Wevoxさんの出している自己理解とチームの相互理解を深める取り組みです( https://wevox.io/valuescard/ )。 過去に部署の相互理解目的で利用したことがあり非常に良い体験だったため取り入れることにしました。 ただし、今回は チーミング 目的ではなく「どんな新しい仲間に来てほしいか?」という価値観を共有し 言語化 し合うために使用したいと思いました。 よってカードの内容はより私たちの目的に限定したものにするために自作することにしました。 カードの生成 まずはバリューが記載された多様なカードを用意する必要があります。 機械学習 /MLOpsの新しい仲間に望む要素を1つ1つ思い浮かべて大量に用意する...なかなかすぐに出来ることではありません。自分だけでやると偏りも生じます。 そうです。ChatGPTです。これなら100点の答えを出すことは難しいですが、60点の答えを一瞬で大量に用意することができます。 以下がChatGPTに送ったプロンプトです。 あなたはエンジニアの採用の最高責任者をやっています。 いま、あなたはスタートアップの機械学習/MLOpsエンジニアを採用しようとしています。そこで、今のチームにはどんな人がマッチするのかを調べるために、下記のワークを開催することにしました。 * カードが大量にあり、それぞれに「高度なエンジニアリングスキルを持っている」「他のチームメンバーへの質問を躊躇わない」(*注: 実際にここに書いた例は異なります)など、エンジニアとしてのスキルや指向といった採用観点での様々な要素が1枚につき1つ書かれている * プレーヤーは最初に5枚のカードが伏せた状態で配られる * プレーヤーは自分のターンになると山札もしくは川から1枚カードを引く * プレーヤーは5枚のカードと、引いた1枚のカードのうち、新しい仲間に求めるものとして大事だと思う要素を5つ手元に残し、1枚を川に捨てる * プレーヤーはターンを終了し、次の人がターンを開始する * 山札がなくなるまでこれを繰り返す これにより、メンバーがどういう仲間を探しているのかをシャープに掴もうと考えています。山札がN(*Nは十分大きな数)枚ほど必要なので、カードの中身を考えてみてください これに対して、ChatGPTは「ユニークで面白い」と言った上でN個の要素を出してくれました。いい時代です。 しかし、いまいちピンと来ない内容も入っています。そのまま使用するには粗すぎる印象です。 カードの精製 LLMに限らず、AIで100点の納得感を出せる回答を用意するには、やはり最後にはエキスパートによる修正を加える必要があります。 そこで、HR(Human Resource)で一緒にエンジニアの採用をしている はまDさん にお願いして、一緒にチェックをしてもらうことにしました。 はまDさん「まずはそれぞれの項目を分類して整理すると良いです」 タイピングが得意なわたし「任せてください。『10個くらいにカテゴリーで分けられますか?』」 ChatGPT「もちろん」 分類してもらった結果、「技術力」「学習・成長志向」など、確かに納得できるカテゴリーが作られました。「あ、このカテゴリーはもっと詳しく聞きたいんだよな」とか「この要素とこの要素はほとんど同じ内容だな」というのが分かりはじめます。 さらに、はまDさんから「このカテゴリーについては、HRではさらにこういう分類をすることがあります」などプロの ドメイン 知識を教えてもらいました。それにより要素がさらに磨かれていきました。もしかするとこのタイミングで自分のバイアスが入ってしまったかもしれません。ただ、ある程度の数を用意できたのでその点についてはカバーされているだろうと思います。 最後に「ワークショップの最後にただお互いの5つの要素を見せ合うだけでなく、それを文章にして説明することでより具体的な相互理解が深まる」というアド バイス も貰えたので、ワークショップに組み込むことにしました。 カードの準備 さて、最後は実際にカードを用意すればおしまいです。オフラインで顔を合わせて行いたかったので、100均で売ってるメッセージカードに油性ペンで書くことにしました。 一つだけポイントとして、裏面から内容が透けてしまわないように少し厚みのあるカードをお勧めします。 実際のワークショップ 実際のワークショップは4人で行いました。手元に残せるのは5つだけ、になるとどうしても「うーんこの要素も...この要素も重要だと思う...どれも捨てられない...」という状態になりますが当然です。今回カードに書いた要素は全て Better to have な要素です。あった方が良いに決まっていますが、全てを兼ね備えるのはほとんど無理な話です。「5つ」という制約を加えると自分の中で深く比較することになり、本当に譲れないものだけを残せます。 結果として4人×5枚で20の要素が残っていました。「あ、意外とこの要素はそこまで求められていないんだな」とか「やっぱりみんなこの要素は欠かせないと思っているんだね」がメンバー間でかなり具体化されたように感じます。 最後に、それらの要素に対して既存の構造化面接の内容を見直してみると「この要素は見極められていないんじゃないか?」ということが見えてきます。新しく質問を追加することにしました。 もちろん「あなたはこの要素を大事に思いますか?」という質問をしてもあまり効果はないでしょう。大抵の要素は大事です。 みんなでホワイトボードに様々な質問を列挙していくことで、この観点を見るためにこの質問を追加しよう、というのを全員で共通認識として持つことができました。 今後も定期的に自分たちの認識を見直していきたいと思います。 おわりに 私たちと一緒に開発を推進してくださるメンバーを募集しています。興味のある方、是非お気軽にご連絡ください!
アバター
はじめまして。CADDiでバックエンドエンジニアとして働いている中野です。 この記事では、Cloud Data Fusionを利用して作成したデータパイプラインについてご紹介します。 TL;DR Salesforce とBigQuery間のデータ連携にHeroku Connectをこれまで利用していたのですが、Cloud Data Fusionに乗り換えることでダウンタイムなしで約1/8までコストダウンができました。 モチベーション 弊社では、 Salesforce に溜まったデータをBigQueryに連携し、営業などのBizサイドの組織も含めアクセスできる状態にしております。これまでは連携に Heroku Connect 及び Heroku Postgres と Stitch というCloud Data Pipelineを用いていました。 しかし、Heroku Connect及びHeroku Postgresの利用料が高額でコストダウンしたいというモチベーションがありました。 乗り換え先として、Embulkなどの OSS を利用して自分たちで ホスティング を行う方法なども検討に上がりましたが、なるべくメンテナンスコストをかけたくないことから、要件を全て満たせそう且つフルマネージドなCloud Data Fusionを使うことに決定しました。 Cloud Data Fusionについて Cloud Data Fusion は、データ パイプラインを迅速に構築し管理するための、フルマネージドかつ クラウド ネイティブな エンタープライズ データ統合サービスです。Cloud Data Fusion は、データ パイプラインを迅速に構築し管理するための、フルマネージドかつ クラウド ネイティブな エンタープライズ データ統合サービスです。 引用: https://cloud.google.com/data-fusion/docs/concepts/overview?hl=ja UIからの操作も直感的に可能で、シンプルなパイプラインであればエンジニア以外でも簡単にデプロイすることができます。 構成 今回我々がやりたかったことは、「 Salesforce にあるデータをBigQueryに連携する」ということです。それを実現するために、Cloud Data Fusionのデプロイは以下の構成で行いました。 ▽図1:システム構成図 しかし、一度デプロイした後には不要になるリソースがいくつかあります。そのためデプロイが完了し、Dataproc クラスタ ーをCloud Data Fusionがプロビジョニング可能な状態になった後には、定期実行のスケジュールを設定し不要なリソースを削除した上で、以下の構成で運用しています。 Dataprocは バッチ処理 などを行うためのマネージドサービスです。Dataprocが実際に Salesforce と通信してデータを取得し、BigQueryにデータを貯める役割を担っています。Dataprocの詳細は最後に参考文献として載せています。 ▽図2:リソース削除後システム構成図 マイグレーション プラン 弊社では様々な部署がBigQueryに蓄積されたデータを元に業務を行っているため、できる限りダウンタイムを作らずに マイグレーション を行う必要がありました。そのため以下方針で マイグレーション を行い、ダウンタイムを発生させずに作業を完了させることができました。(前提として、BigQueryの利用者はこれまで Salesforce のデータが連携されていた dataset sf_heroku_connect にある各テーブルを直接参照せず、dataset sf にあるViewを経由してデータにアクセスしておりました。) Cloud Data Fusionのリソースを作成し、dataset sf_cloud_data_fusion の各テーブルに Salesforce から取得したデータを格納する。 dataset sf のデータソースを dataset sf_heroku_connect の各テーブルから、 dataset sf_cloud_data_fusion の各テーブルに置き換える。 しばらく稼働させ、問題が発生しないか確認する。 dataset sf_heroku_connect を削除する。 実装詳細 以下リソースの定義を行いました。 実際には、module化して管理しておりますが、ここではブログ用に基本的にresourceとして定義しています。また、BigQueryのリソースも実際には別プロジェクト内に配置してあるのですが、ここでは簡易化のために同一プロジェクト内に配置しております。 FILL_YOUR_XXX と記載がある箇所はご自身で適切なIPレンジに置き換えてください。 全体設定 provider "google" { project = "sample-project" region = "asia-northeast1" zone = "asia-northeast1-c" } data "google_client_config" "current" {} provider "cdap" { host = "${module.wait_healthy.service_endpoint}/api" token = data.google_client_config.current.access_token } terraform { required_providers { google = { source = "hashicorp/google" version = "4.78.0" } google-beta = { source = "hashicorp/google-beta" version = "4.73.2" } cdap = { source = "GoogleCloudPlatform/cdap" version = "~> 0.10" } } required_version = ">= 1.1" } Cloud Data Fusion関連リソース # Service Account resource "google_service_account" "sa_for_data_fusion" { project = "sample-project" account_id = "data-fusion-instance-sa" display_name = "For cloud data fusion" } resource "google_project_iam_member" "sa_for_data_fusion_role_bindings" { project = "sample-project" for_each = toset([ "roles/storage.admin", "roles/datafusion.runner", "roles/dataproc.worker", "roles/bigquery.jobUser", ]) role = each.key member = "serviceAccount:${google_service_account.sa_for_data_fusion.email}" } locals { data_fusion_service_account = "service-${data.google_project.data_fusion_project.number}@gcp-sa-datafusion.iam.gserviceaccount.com" } resource "google_service_account_iam_binding" "google_managed_sa_role_bindings" { service_account_id = "projects/sample-project/serviceAccounts/${google_service_account.sa_for_data_fusion.email}" role = "roles/iam.serviceAccountUser" members = [ "serviceAccount:${local.data_fusion_service_account}", ] } # Data Fusion resource "google_data_fusion_instance" "create_instance" { name = "data-fusion-instance-name" description = "data-fusion-instance-description" region = "asia-northeast1" type = "DEVELOPER" enable_stackdriver_logging = true enable_stackdriver_monitoring = true private_instance = true dataproc_service_account = google_service_account.sa_for_data_fusion.email network_config { network = "sample-private-network" ip_allocation = "FILL_YOUR_IP_RANGE_OF_DATAFUSION_INSTANCE" } version = "6.9.1" } # Source is from # https://cdfhub-asia-northeast1.storage.googleapis.com/hub/packages/plugin-salesforce/1.6.0/salesforce-plugins-1.6.0.json # https://cdfhub-asia-northeast1.storage.googleapis.com/hub/packages/plugin-salesforce/1.6.0/salesforce-plugins-1.6.0.jar resource "cdap_local_artifact" "salesforce-plugins" { name = "salesforce-plugins" version = "1.6.0" json_config_path = "path/to/file/salesforce-plugins-1.6.0.json" jar_binary_path = "path/to/file/salesforce-plugins-1.6.0.jar" depends_on = [google_data_fusion_instance.create_instance] } data "google_project" "data_fusion_project" { project_id = "sample-project" } resource "cdap_application" "sf-bq-sync-account" { name = "sf-bq-sync-account" spec = file("path/to/file/sf-bq-sync-account-cdap-data-pipeline.json") depends_on = [google_data_fusion_instance.create_instance, cdap_local_artifact.salesforce-plugins] } resource "cdap_application" "sf-bq-sync-user" { name = "sf-bq-sync-user" spec = file("path/to/file/sf-bq-sync-user-cdap-data-pipeline.json") depends_on = [google_data_fusion_instance.create_instance, cdap_local_artifact.salesforce-plugins] } # https://github.com/terraform-google-modules/terraform-google-data-fusion/tree/master/modules/wait_healthy module "wait_healthy" { source = "terraform-google-modules/data-fusion/google//modules/wait_healthy" version = "~> 0.1" service_endpoint = google_data_fusion_instance.create_instance.service_endpoint access_token = data.google_client_config.current.access_token } ネットワーク関連リソース # Gateway VM resource "google_service_account" "sa_for_gateway_vm" { project = "sample-project" account_id = "gateway-vm-instance-sa" display_name = "For cloud data fusion gateway" } resource "google_compute_instance" "sample_gateway_vm" { name = "sample-gateway-vm" machine_type = "e2-micro" zone = "asia-northeast1-b" tags = ["allow-http-for-data-fusion", "allow-https-for-data-fusion"] can_ip_forward = true boot_disk { initialize_params { image = "debian-cloud/debian-11" } } network_interface { network = google_compute_network.sample_private_network.self_link subnetwork = google_compute_subnetwork.sample_subnetwork.self_link } metadata_startup_script = "#! /bin/bash \n echo 1 > /proc/sys/net/ipv4/ip_forward \n iptables -t nat -A POSTROUTING -s FILL_YOUR_IP_RANGE_OF_DATAFUSION_INSTANCE -j MASQUERADE \n echo net.ipv4.ip_forward=1 > /etc/sysctl.d/11-gce-network-security.conf \n iptables-save" service_account { email = google_service_account.sa_for_gateway_vm.email scopes = ["cloud-platform"] } shielded_instance_config { enable_integrity_monitoring = true enable_vtpm = true } metadata = { block-project-ssh-keys = true } } # VPC resource "google_compute_network" "sample_private_network" { project = "sample-project" name = "sample-private-network" auto_create_subnetworks = "false" delete_default_routes_on_create = "false" routing_mode = "REGIONAL" } resource "google_compute_subnetwork" "sample_subnetwork" { project = "sample-project" region = "asia-northeast1" name = "sample-subnetwork" ip_cidr_range = "FILL_YOUR_IP_CIDR_RANGE" network = google_compute_network.sample_private_network.self_link private_ip_google_access = "true" } resource "google_compute_network_peering" "sample_peering" { name = "sample-peering" network = google_compute_network.sample_private_network.self_link peer_network = "https://www.googleapis.com/compute/v1/projects/${google_data_fusion_instance.create_instance.tenant_project_id}/global/networks/${google_data_fusion_instance.create_instance.region}-${google_data_fusion_instance.create_instance.name}" export_custom_routes = true } # NAT resource "google_compute_router" "router" { name = "sample-router" project = "sample-project" region = "asia-northeast1" network = google_compute_network.sample_private_network.self_link bgp { advertise_mode = "CUSTOM" advertised_groups = ["ALL_SUBNETS"] asn = "64512" } } resource "google_compute_address" "address" { name = "nat-ip" project = "sample-project" region = google_compute_router.router.region } resource "google_compute_router_nat" "cluster_router_nat" { name = "sample-router-nat" project = "sample-project" region = google_compute_router.router.region router = google_compute_router.router.name nat_ip_allocate_option = "MANUAL_ONLY" nat_ips = [google_compute_address.address.self_link] source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES" log_config { enable = true filter = "ERRORS_ONLY" } } # Firewall rule resource "google_compute_firewall" "gateway_vm_for_data_fusion_allow_http_fw" { project = "sample-project" name = "gateway-vm-for-data-fusion-allow-http" network = "sample-private-network" allow { ports = ["80"] protocol = "tcp" } direction = "INGRESS" disabled = "false" priority = "1000" source_ranges = ["FILL_YOUR_IP_RANGE_OF_DATAFUSION_INSTANCE"] target_tags = ["allow-http-for-data-fusion"] } resource "google_compute_firewall" "gateway_vm_for_data_fusion_allow_https_fw" { project = "sample-project" name = "gateway-vm-for-data-fusion-allow-https" network = "sample-private-network" allow { ports = ["443"] protocol = "tcp" } direction = "INGRESS" disabled = "false" priority = "1000" source_ranges = ["FILL_YOUR_IP_RANGE_OF_DATAFUSION_INSTANCE"] target_tags = ["allow-https-for-data-fusion"] } # Route resource "google_compute_route" "sf_bq_sync_route" { name = "sample-route" dest_range = "0.0.0.0/0" network = google_compute_network.sample_private_network.self_link next_hop_instance = google_compute_instance.sample_gateway_vm.self_link priority = 1001 } Secret Manager関連リソース FILL_YOUR_CIPHERTEXT と記載がある箇所は google_kms_secret に従って、Cloud SDK を用いて暗号化したsecretを入れます。 google_kms_secretの例 だと、 my-secret-password にpasswordなどのsecretを入れ、outputとして出てきた CiQAaCd+xX4SsOXziF10a8JYq4spf~~~ を FILL_YOUR_CIPHERTEXT に登録します。 $ echo -n my-secret-password | gcloud kms encrypt \ > --project my-project \ > --location us-central1 \ > --keyring my-key-ring \ > --key my-crypto-key \ > --plaintext-file - \ > --ciphertext-file - \ > | base64 CiQAqD+xX4SXOSziF4a8JYvq4spfAuWhhYSNul33H85HnVtNQW4SOgDu2UZ46dQCRFl5MF6ekabviN8xq+F+2035ZJ85B+xTYXqNf4mZs0RJitnWWuXlYQh6axnnJYu3kDU= (引用: google_kms_secret ) # secret manager resource "google_secret_manager_secret" "salesforce_username" { project = "sample-project" secret_id = "salesforce-consumer-secret" replication { automatic = true } } resource "google_secret_manager_secret" "salesforce_password" { project = "sample-project" secret_id = "salesforce-consumer-key" replication { automatic = true } } resource "google_secret_manager_secret" "salesforce_consumer_secret" { project = "sample-project" secret_id = "salesforce-consumer-secret" replication { automatic = true } } resource "google_secret_manager_secret" "salesforce_consumer_key" { project = "sample-project" secret_id = "salesforce-consumer-key" replication { automatic = true } } data "google_secret_manager_secret_version" "salesforce_username" { project = "sample-project" secret = google_secret_manager_secret.salesforce_username.id } data "google_secret_manager_secret_version" "salesforce_password" { project = "sample-project" secret = google_secret_manager_secret.salesforce_password.id } data "google_secret_manager_secret_version" "salesforce_consumer_secret" { project = "sample-project" secret = google_secret_manager_secret.salesforce_consumer_secret.id } data "google_secret_manager_secret_version" "salesforce_consumer_key" { project = "sample-project" secret = google_secret_manager_secret.salesforce_consumer_key.id } data "google_kms_secret" "salesforce_username" { crypto_key = var.crypto_key ciphertext = var.salesforce_username } data "google_kms_secret" "salesforce_password" { crypto_key = var.crypto_key ciphertext = var.salesforce_password } data "google_kms_secret" "salesforce_consumer_secret" { crypto_key = var.crypto_key ciphertext = var.salesforce_consumer_secret } data "google_kms_secret" "salesforce_consumer_key" { crypto_key = var.crypto_key ciphertext = var.salesforce_consumer_key } variable "crypto_key" { type = string default = "sample-project/global/sample/terraform" } variable "salesforce_password" { type = string sensitive = true default = "FILL_YOUR_CIPHERTEXT" } variable "salesforce_username" { type = string sensitive = true default = "FILL_YOUR_CIPHERTEXT" } variable "salesforce_consumer_key" { type = string sensitive = true default = "FILL_YOUR_CIPHERTEXT" } variable "salesforce_consumer_secret" { type = string sensitive = true default = "FILL_YOUR_CIPHERTEXT" } BigQuery関連リソース # BigQuery resource "google_bigquery_dataset" "sf_cloud_data_fusion" { project = "sample-project" dataset_id = "sf_cloud_data_fusion" location = "asia-northeast1" } resource "google_bigquery_dataset_iam_member" "sf_cloud_data_fusion_owner" { project = "sample-project" dataset_id = google_bigquery_dataset.sf_cloud_data_fusion.dataset_id role = "roles/bigquery.dataOwner" member = "user:john_doe@caddi.jp" } resource "google_bigquery_dataset_iam_member" "data_fusion_editor" { project = "sample-project" dataset_id = google_bigquery_dataset.sf_cloud_data_fusion.dataset_id role = "roles/bigquery.dataEditor" member = "serviceAccount:${google_service_account.sa_for_data_fusion.email}" } 説明 いくつかCloud Data Fusionを定義する上でのポイントをかいつまんで説明します。 Service Account 図2をみるとわかる通り、Pipelineの実行時にはCloud Data Fusionは Salesforce に接続しておらず、Dataprocが Salesforce に接続して必要なデータの取得を行なっています。 Cloud Data Fusionのリソース定義を行う際にDataprocで利用するService Accountは宣言することができるのですが、Cloud Data Fusion自体が利用するService Accountは宣言することができません。 resource "google_data_fusion_instance" "create_instance" { name = "data-fusion-instance-name" description = "data-fusion-instance-description" region = "asia-northeast1" type = "DEVELOPER" enable_stackdriver_logging = true enable_stackdriver_monitoring = true private_instance = true dataproc_service_account = google_service_account.sa_for_data_fusion.email network_config { network = "sample-private-network" ip_allocation = "FILL_YOUR_IP_RANGE_OF_DATAFUSION_INSTANCE" } version = "6.9.1" } Cloud Data Fusion自体が利用するService AccountはCloud Data Fusion API を有効化した際に作成される、 Google Managed Service Accountになるので、Cloud Data Fusion自体が行う操作に対して追加で権限を付与する必要がある場合には、この Google Managed Service Accountに対して権限を付与してやる必要があります。( 参考:Cloud Data Fusion でのサービス アカウント ) 例えば、Pipeline作成時に別プロジェクトにあるBigQueryテーブルを確認しに行くためには、自身で定義したSerivce Accountではなく、 Google Managed Service Accountに対して必要なロールを付与する必要があります。 プライベート インスタンス からインターネット上のリソースへの接続 Cloud Data Fusionの インスタンス を作成した後に、パイプラインの作成が行われるのですが、その際に Salesforce (インターネット上に存在するデータソース)に接続し、 Salesforce 上の スキーマ 情報を取得する必要があります。 プライベートインスタンスからパブリックソースへの接続 のドキュメントを読むと、プライベート インスタンス からインターネット上に存在するデータソースに接続するためには、Network Peeringを設定し、 Gateway VM や Firewall Ruleなども設定し、Cloud Data Fusion インスタンス が外部に接続することができる状態を作る必要があることがわかります。 しかし、一度パイプラインを作成した後、Dataprocのプロビジョニングを行う際には既にCloud Data Fusion インスタンス が Salesforce の スキーマ 情報など必要な情報を 保有 しているため、再度インターネット上に存在するデータソースに接続する必要がありません。そのためパイプラインの編集を頻繁には行わない場合などには、パイプラインのデプロイ後、Network Peering, Gateway VM , Firewall Rule, Routeなど、インターネット上に存在するデータソースにCloud Data Fusionプライベート インスタンス が接続するために必要なリソースは削除することが可能です。 ただしこれらのリソースの削除にはメリットデメリットが存在するので、用途に応じて削除するかどうかの判断が必要です。 メリット VPC 構成の複雑さを抑えて、ネットワークに問題が生じた際の デバッグ が容易になる。 リソース削除により定常コストを削減できる。 デメリット 外部サービス(弊社の例では Salesforce )の最新 スキーマ を取得できなくなる。取得するためには再度これらのリソースを構築し直す必要がある。 弊社の場合、 Salesforce の更新頻度が低い且つIaCでリソースを管理しており再構築が容易に可能という状況だったため、これらのリソースを削除するという選択を行いました。 Cloud Data Fusion インスタンス とパイプラインの作成タイミング Cloud Data Fusion インスタンス の作成には30分ほど時間がかかります。パイプラインの作成はCloud Data Fusion インスタンス が存在してはじめて可能になるため、Cloud Data Fusion インスタンス の作成が完了するまでパイプライン作成は待つ必要があります。そこで、 wait_healty module を利用することでCloud Data Fusion インスタンス の作成を待ってパイプラインの作成に移ることが可能になります。 パイプラインの定義方法 パイプラインを定義する際に path/to/file/sf-bq-sync-user-cdap-data-pipeline.json で参照しているファイルは、以下のような JSON ファイルを参照しています。 resource "cdap_application" "sf-bq-sync-user" { name = "sf-bq-sync-user" spec = templatefile("sf-bq-sync-user-cdap-data-pipeline.json", { consumer_key = data.google_kms_secret.salesforce_consumer_key.plaintext, consumer_secret = data.google_kms_secret.salesforce_consumer_secret.plaintext, username = data.google_kms_secret.salesforce_username.plaintext, password = data.google_kms_secret.salesforce_password.plaintext }) depends_on = [google_data_fusion_instance.create_instance, cdap_local_artifact.salesforce-plugins] } sf-bq-sync-user-cdap-data-pipeline.json { "name": "sf-bq-sync-user", "description": "Data Pipeline Application", "artifact": { "name": "cdap-data-pipeline", "version": "6.9.1", "scope": "SYSTEM" }, "config": { "resources": { "memoryMB": 2048, "virtualCores": 1 }, "driverResources": { "memoryMB": 2048, "virtualCores": 1 }, "connections": [ { "from": "Salesforce", "to": "BigQuery" } ], "comments": [], "postActions": [], "properties": {}, "processTimingEnabled": true, "stageLoggingEnabled": false, "stages": [ { "name": "Salesforce", "plugin": { "name": "Salesforce", "type": "batchsource", "label": "Salesforce", "artifact": { "name": "salesforce-plugins", "version": "1.6.0", "scope": "USER" }, "properties": { "referenceName": "user", "useConnection": "false", "username": "${username}", "password": "${password}", "consumerKey": "${consumer_key}, "consumerSecret": "${consumer_secret}", "loginUrl": "https://login.salesforce.com/services/oauth2/token", "connectTimeout": "30000", "query": "select\nlastname,\nid,\nname,\ndivision\nfrom user", "operation": "query", "enablePKChunk": "false", "schema": "{\"name\":\"etlSchemaBody\",\"type\":\"record\",\"fields\":[{\"name\":\"lastname\",\"type\":[\"string\",\"null\"]},{\"name\":\"id\",\"type\":[\"string\",\"null\"]},{\"name\":\"name\",\"type\":[\"string\",\"null\"]},{\"name\":\"division\",\"type\":[\"string\",\"null\"]}]}" } }, "outputSchema": "{\"name\":\"etlSchemaBody\",\"type\":\"record\",\"fields\":[{\"name\":\"lastname\",\"type\":[\"string\",\"null\"]},{\"name\":\"id\",\"type\":[\"string\",\"null\"]},{\"name\":\"name\",\"type\":[\"string\",\"null\"]},{\"name\":\"division\",\"type\":[\"string\",\"null\"]}]}", "id": "Salesforce" }, { "name": "BigQuery", "plugin": { "name": "BigQueryTable", "type": "batchsink", "label": "BigQuery", "artifact": { "name": "google-cloud", "version": "0.22.1", "scope": "SYSTEM" }, "properties": { "useConnection": "false", "project": "sample-project", "datasetProject": "sample-project", "serviceAccountType": "filePath", "serviceFilePath": "auto-detect", "dataset": "sf_cloud_data_fusion", "table": "user", "operation": "upsert", "relationTableKey": "id", "allowSchemaRelaxation": "false", "location": "asia-northeast1", "createPartitionedTable": "false", "partitioningType": "NONE", "schema": "{\"name\":\"etlSchemaBody\",\"type\":\"record\",\"fields\":[{\"name\":\"lastname\",\"type\":[\"string\",\"null\"]},{\"name\":\"id\",\"type\":[\"string\",\"null\"]},{\"name\":\"name\",\"type\":[\"string\",\"null\"]},{\"name\":\"division\",\"type\":[\"string\",\"null\"]}]}" } }, "outputSchema": "{\"name\":\"etlSchemaBody\",\"type\":\"record\",\"fields\":[{\"name\":\"lastname\",\"type\":[\"string\",\"null\"]},{\"name\":\"id\",\"type\":[\"string\",\"null\"]},{\"name\":\"name\",\"type\":[\"string\",\"null\"]},{\"name\":\"division\",\"type\":[\"string\",\"null\"]}]}", "inputSchema": [ { "name": "Salesforce", "schema": "{\"name\":\"etlSchemaBody\",\"type\":\"record\",\"fields\":[{\"name\":\"lastname\",\"type\":[\"string\",\"null\"]},{\"name\":\"id\",\"type\":[\"string\",\"null\"]},{\"name\":\"name\",\"type\":[\"string\",\"null\"]},{\"name\":\"division\",\"type\":[\"string\",\"null\"]}]}" } ], "id": "BigQuery" } ], "schedule": "0 */2 * * *", "engine": "spark", "numOfRecordsPreview": 100, "rangeRecordsPreview": { "min": 1, "max": "5000" }, "description": "Data Pipeline Application", "maxConcurrentRuns": 1 }, "version": "de67b401-29e2-11ee-9d6b-7ad3ba276e43" } この JSON ファイルを1から手で書くのは骨が折れますが、Cloud Data FusionではUIから定義したパイプラインの設定をパイプラインのページからExportし、利用することが可能です。 そのため、1番最初はUIからパイプラインの定義を行い、exportした JSON ファイルを雛形として利用し、必要に応じて編集しながら使うのが効率的かと思います。その際に、secretの扱いを気をつける必要があります。 設定をexportすると、 JSON ファイルの中に以下passwordやconsumerSecretなどの情報が直接入ってきます。これらを GitHub などにPushしてしまうとまずいため、templatefile function を利用して、Secret Managerなどから取得したsecretに置き換えてやる必要があります。 JSON ファイル内で "password": "${password}", と書いて変数を埋め込み、パイプラインの定義を行う際に以下のように templatefile function を利用してsecretに置き換えます。 resource "cdap_application" "sf-bq-sync-user" { name = "sf-bq-sync-user" spec = templatefile("sf-bq-sync-user-cdap-data-pipeline.json", { consumer_key = data.google_kms_secret.salesforce_consumer_key.plaintext, consumer_secret = data.google_kms_secret.salesforce_consumer_secret.plaintext, username = data.google_kms_secret.salesforce_username.plaintext, password = data.google_kms_secret.salesforce_password.plaintext }) depends_on = [google_data_fusion_instance.create_instance, cdap_local_artifact.salesforce-plugins] } スキーマ の更新 スキーマ の更新の際には JSON ファイルを編集する必要があります。変更内容が多い場合でも、置換をうまく使えば作業自体はそこまで大変ではないので、Heroku ConnectでUIから管理していた時よりも個人的には作業が楽になったように感じます。また、 JSON ファイルもGit管理下に置かれるので、変更前後のDiffが見られる安心感もメリットに感じています。 Salesforce 側の設定 Cloud Data Fusionを利用して Salesforce のデータを取得するためには、 Salesforce 側の設定も必要になります。 Salesforce の設定はClassmethodさんの記事「 Cloud Data FusionでSalesforceのデータをBigQueryに取り込んでみる 」を参考にさせていただきました。 困っている点 パイプラインの定期実行スケジュールのトリガー方法 パイプラインのデプロイまではIaCで自動化することができたのですが、パイプラインの定期実行スケジュールをデプロイと同時に開始することができず、スケジュールの開始だけはUIから操作する必要があります。UIから定期実行を開始したのちに、 google _data_fusion_instance に対してterraform import&terramform plan を実行しても差分が出ず、また、pipelineを作成しているcdap_applicationは terraform importをサポートしておらず、定期実行のスケジュールを開始する方法は見つけられておりません。 おわりに お決まりですが採用についてです。リアルな世界に向き合い複雑な ドメイン を取り扱うことに興味がある方、検証を回しつつ、スケールするための基盤作りに興味がある方を募集しています。カジュアル面談もやっていますのでぜひお気軽にご連絡ください。 エンジニア向け採用サイト https://recruit.caddi.tech/ 求人一覧 https://open.talentio.com/r/1/c/caddi-jp-recruit/homes/4139 参考文献 Cloud Data Fusion の概要 アーキテクチャとコンポーネント Cloud Data Fusion インスタンスを作成する プライベート インスタンスを作成する プライベート インスタンスからパブリック ソースへの接続 Cloud Data Fusion サービス アカウント Dataproc とは Secret Manager のコンセプトの概要 Data Fusion Wait Healthy Cloud Data FusionでSalesforceのデータをBigQueryに取り込んでみる
アバター
※本記事は、 技術評論社 「Software Design」(2023年8月号) に寄稿した連載記事「 Google Cloudで実践するSREプ ラク ティス」からの転載です。発行元からの許可を得て掲載しております。 はじめに 前回 はTerraformと GitHub Actionsで実践するインフラCI/CDのCI部分について解説しました。今回はその続きとなるCD部分、デプロイについて扱います。また、運用をよりスケールさせるために検討すべき観点やキャディでの事例についても紹介します。 terraform applyの実行 前回はPull request(PR)に対して terraform plan を実行し、どのようなリソース変更が予定されているのかチェックしました。今回は、PRがマージされたら terraform apply を実行し、リソース変更が適用されるようなパイプラインを構築してみましょう。 リスト1はmainブランチへのプッシュをトリガーに terraform apply を実行し、apply結果をPRコメントとして投稿するワークフロー定義です。サンプルを実行するとCompute インスタンス が作成されるので、費用を抑えたい方はサービスアカウントなど無料で作成できる別のリソースに置き換えてください。 ▼リスト1 . github /workflows/terraform_ci.yml name: Terraform Apply on: push: # ① branches: - main jobs: terraform_apply: runs-on: ubuntu-latest permissions: contents: read id-token: write pull-requests: write steps: - uses: actions/checkout@v3 - uses: hashicorp/setup-terraform@v2 - uses: google-github-actions/auth@v1 with: workload_identity_provider: projects/(…略…)/providers/github-provider service_account: my-github-actions@(…略…).com - run: terraform init working-directory: ./terraform/environments/dev - id: apply run: terraform apply -no-color -auto-approve working-directory: ./terraform/environments/dev continue-on-error: true - uses: actions/github-script@v6 # ② with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const output = `Terraform Apply: \`${{ steps.apply.outcome }}\` <details><summary>Show apply</summary> \`\`\` ${{ steps.apply.outputs.stdout }} \`\`\` </details>` const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: context.sha }); const pr_number = data?.[0]?.number; if (pr_number) { github.rest.issues.createComment({ issue_number: pr_number, owner: context.repo.owner, repo: context.repo.repo, body: output }) } ①でPRがマージされ、mainブランチに取り込まれたプッシュイベントをトリガーにワークフローが実行されます。 ②でこのワークフローはmainブランチ上で実行されますが、マージ元のPRにapply結果をコメントで投稿しています。何をしようとして(plan)、結果どうなったか(apply)が1つのPRにまとまり証跡の見通しが良くなります(図1)。 ▼図1 apply結果のコメント投稿 複数環境対応 プロダクトを運用するうえで、開発用(dev)・商用(prod)など目的ごとに環境を分離することは非常に重要です。環境を分離することでセキュリティリスクを軽減したり、厳格な権限管理ができたりします。また、開発中に誤って商用環境を操作してしまうといった人的ミスの予防にもつながります。 環境分離の境界は Google Cloudプロジェクトや VPC ネットワークなどさまざまですが、キャディでは環境ごとにプロジェクトを分離しています。1つのプロダクトを1つのIaC リポジトリ で複数環境へデプロイする方法について、キャディでの事例をもとに解説します。環境ごとの差分をどのようにTerraformで扱うか、各環境へのデプロイをどのように GitHub Actionsで実現するのか、それぞれ見ていきましょう。 tfファイルの構成 環境ごとにワーキング ディレクト リを作成し、ステートを分離する手法がプ ラク ティス 1 として知られています。前回の例をもとにしてprod環境用のリソース定義を作成すると、リスト2のようになります。 ▼リスト2 terraform/environments/prod/main.tf terraform { backend "gcs" { bucket = "my-tfstate-prod-<<SUFFIX>>" # ① } } provider "google" { project = "<<プロジェクトID>>" region = "asia-northeast1" zone = "asia-northeast1-b" } module "vm" { # ② source = "../../modules/vm" name = "my-vm-prod" } ①で、tfstateを保管するバックエンドの バケット についても、プロジェクトごとに分離すると構成がシンプルになります。一方で、tfstateを1つの バケット に集約して厳格に集中管理したいというケースも考えられますので、自分たちに合った方法を選択しましょう。 ②で、ワーキング ディレクト リをただ分割してしまうと、環境ごとに似たような内容のコードが増え冗長になります。共 通化 や再利用が可能なリソース定義はモジュール化し、各環境からはモジュールとして利用することで、コードの記述量が減りメンテナンスしやすくなります。 インスタンス 名やマシンタイプなど、環境ごとに異なるパラメータはモジュールの変数として定義し、外部から変更可能な余地を与えます。 ワークフロー定義 prod 環境へ terraform apply を実行するワークフローはリスト3のようになります。dev環境向けのワークフローから変更がない箇所は省略しています。 ▼リスト3 . github /workflows/terraform_apply_prod.yml name: Terraform Apply Prod on: push: branches: - production # ① jobs: terraform_apply: # 略 steps: # 略 - uses: google-github-actions/auth@v1 with: # ② workload_identity_provider: projects/(…略…)/providers/github-provider service_account: my-github-actions@(…略…).com - run: terraform init working-directory: ./terraform/environments/prod # ③ - id: apply run: terraform apply -no-color -auto-approve working-directory: ./terraform/environments/prod continue-on-error: true - uses: actions/github-script@v6 # 略 ①は適用するタイミングをdev環境とずらすために、ワークフローのトリガーはproductionブランチへのマージとしています。ブランチ戦略の詳細については後述します。 ②はdev環境とprod環境でプロジェクトが異なる場合、prod環境用の値に置き換えます。 Workload Identity連携、サービスアカウントの作成については前回を参照してください。 ③はterraformを実行する際にはprod用のワーキング ディレクト リを指定します。 これでmainブランチをマージするとdev環境向けの変更が、productionブランチをマージするとprod環境向けの変更がそれぞれ適用されるようになりました。 なお、 terraform plan を実行する terraform_ci.yml についても修正内容は同じです。 ここまでのファイル構成は図2のとおりです。 ▼図2 プロジェクトIaC リポジトリ の構成 . ├── .github │ └── workflows │ ├── terraform_apply.yml │ ├── terraform_apply_prod.yml │ ├── terraform_ci.yml │ └── terraform_ci_prod.yml └── terraform ├── environments │ ├── dev │ │ └── main.tf │ └── prod │ └── main.tf └── modules └── vm └── main.tf リポジトリ のブランチ戦略 プロダクト運用では、開発環境で検証したあとに商用など後続環境へのデプロイが行われます。ワーキング ディレクト リ分離により各環境を管理している場合、mainブランチ1本だけではデプロイサイクルの管理が難しくなります。 デプロイサイクルをずらすために、人間が環境ごとにPRを作成し、個別に適用するという手間が発生してしまいます。また、全環境から参照されているモジュールを変更した場合、dev環境だけ先に適用するといったタイミングの調整難度はより高くなります。 この解決策の1つとして、環境ごとにブランチを分離し、ワークフローの実行タイミングをずらす方法があります。mainブランチが変更されたらdev環境へデプロイし、productionブランチが変更されたらprod環境へデプロイするという具合です。具体的な運用サイクルは図3のようになります。 ▼図3 ブランチ戦略 この運用では、修正した環境( terraform/environments/* )に関わらず、mainブランチに対してPRを作成します。 PRをトリガーに terraform plan が実行され、マージするとdev環境に対して terraform apply が実行されます。 このとき、prod環境への適用はまだ行われていません。 dev環境で動作確認を行い問題ないことを確認したら、productionブランチに対してmainブランチの変更を含んだPRを作成します。単純な場合には「base: production, compare: main」としてPRを作成します。ここでもPRをトリガーに terraform plan が実行され、マージすると今度はprod環境に対して terraform apply が実行されます(図4)。 ▼図4 prod環境へ適用するPR [Column] Terraform Workspace 複数環境を管理する別の手段として、Terraform Workspace 2 があります。 Workspaceは、ステートを管理する1つのバックエンド上で複数の独立したステートを保持できる機能です。本稿で解説したワーキング ディレクト リ分割の方法と比べて、コードの記述量は少なくなります。 しかし、Workspaceは次の ユースケース を想定した機能となっています。 ・バックエンドや認証方法が変わらない環境での利用 ・環境を複製し、一時的な検証用途としての利用 環境ごとにtfstate用の バケット を分離している場合や、リソース定義に違いのある場合には、ワーキング ディレクト リによる分離のほうが管理は容易です。「開発環境は費用を抑えるためにデータベースは1 インスタンス だけだが、商用環境ではリードレプリカ用の インスタンス を追加で構築する」といった環境差分にも容易に対応できます。 一方でコードの記述量は増えてしまうので、 ユースケース 3 を確認し、自分たちに合った管理方法を選択しましょう。 ワークフローの統合 これまでは、簡単のためにCI/CDのワークフローを個別の環境ごとに作成してきました。ここではワークフロー terraform_apply.yml を例に、よりDRYに記述する方法について解説します。 terraform_apply.yml, terraform_apply_prod.yml を1つのワークフローに統合してみましょう。 環境ごとに変わる値は次のとおりです。 ・トリガーとなるベースブランチ ・ Google Cloud認証用のパラメータ ・Terraformのワーキング ディレクト リ これらのうち、 google-github-actions/auth の入力パラメータ、Terraformのワーキング ディレクト リについては、ベースブランチ名によって値を切り替えられれば良さそうです。 また今まで workload_identity_provider , service_account はハードコードしていましたが、 GitHub Actionsシークレットも活用してみましょう。 GitHub Actionsシークレットは、機密性の高いデータを管理するための機能です。 ワークフローからは 環境変数 として参照できます。 図5のように環境名の プレフィックス を付け、Workload Identityプロバイダとサービスアカウントをシークレットに登録します。 ▼図5 Actionsシークレット 環境差分を吸収した、統合後のワークフローはリスト4のようになります。 ▼リスト4 . github /workflows/terraform_apply.yml name: Terraform Apply on: push: # ① branches: - main - production jobs: terraform_apply: # 略 steps: - uses: actions/checkout@v3 - uses: hashicorp/setup-terraform@v2 - id: get_env # ② shell: bash run: | case ${{ github.ref_name }} in production ) echo 'env=prod' >> $GITHUB_OUTPUT echo 'upper_case_env=PROD' >> $GITHUB_OUTPUT ;; * ) echo 'env=dev' >> $GITHUB_OUTPUT echo 'upper_case_env=DEV' >> $GITHUB_OUTPUT ;; esac - uses: google-github-actions/auth@v1 with: # ③ workload_identity_provider: ${{ secrets[format('{0}_GCP_WI_PROVIDER', steps.get_env.outputs.upper_case_env)] }} service_account: ${{ secrets[format('{0}_GCP_WI_SERVICE_ACCOUNT', steps.get_env.outputs.upper_case_env)] }} - run: terraform init working-directory: ./terraform/environments/${{ steps.get_env.outputs.env }} # ④ - id: apply run: terraform apply -no-color -auto-approve working-directory: ./terraform/environments/${{ steps.get_env.outputs.env }} continue-on-error: true ①はmainまたはproductionブランチへのプッシュイベントをトリガーに、このワークフローが実行されます。 ②はブランチ名を参照し、対応する環境名をステップの出力パラメータとして設定します。 ③は環境名プレフィクスを追加した文字列( DEV_GCP_WI_PROVIDER )を作成し、シークレット( secrets.DEV_GCP_WI_PROVIDER )を参照します。 ④では環境名に対応したワーキング ディレクト リを指定します。 これで、CDのワークフローファイルを1つに統合できました。今後ステージング環境など環境が追加された場合にも数行の修正で対応できます。CIのワークフローを修正する際には {{ github.ref_name }} を ${{ github.base_ref }} に置き換えてください。 今回は GitHub Actionsシークレットを取り扱いましたが、 GitHub の契約プランによってはEnvironmentsのシークレット機能が利用できます。 GitHub のEnvironmentsはデプロイ先の環境ごとに、ブランチ保護ルールやシークレット、変数を管理できます。これによりmainブランチでは GCP_WI_PROVIDER=AAA 、productionブランチでは GCP_WI_PROVIDER=BBB といった値の切り替えが容易に実現できます。 [Column] Matrix strategyの活用 今回紹介したブランチ戦略では、各環境に対応するブランチへのPRやプッシュをトリガーにCI/CDが実行されます。この場合、prod環境に対するCI ( terraform plan ) を実行するには、一度mainブランチへマージしなければなりません。 しかし、より早く間違いを検知するために、mainブランチへのPR上でdev/prod両環境に対してCIを実行したくなります。この課題はMatrix strategy 4 を活用することで解決できます。 Matrix strategyは、dev/prodなどのバリエーションを変数で定義し、その値ごとにジョブを複数実行できる機能です。たとえば、前回紹介した terraform_ci.yml ではリストAのように修正します。 ▼リストA . github /workflows/terraform_ci.yml jobs: terraform_ci: # 略 strategy: # ① matrix: environment: [main, production] steps: # 略 - id: get_env shell: bash run: | case ${{ matrix.environment }} in # ② production ) # 略 ①で並列実行のための変数を定義します。ここでは各環境に対応するベースブランチ名を与えています。 ②でブランチ名から環境名を解決する get_env ステップ内で、 ${{ github.base_ref }} の代わりに マトリックス の値 ${{ matrix.environment }} を与えます。 これで、mainブランチに対してPRが作成された際に、dev/prod各環境に対する terraform plan を確認できます。 組織アカウントの横断管理 企業として Google Cloudを利用している場合、組織リソース配下で複数プロダクト、プロジェクトを管理することになります。しかし、管理下の全プロジェクトに対して前回の事前準備で触れた作業を実施するのは非常に手間がかかり、運用がスケールしません。 この課題に関するキャディでの取り組み事例を簡単に紹介します。 キャディでは組織リソースに対してもIaCを行い、プロジェクトの横断的な構成管理やガバナンス強化を実施しています。IaC用の GitHub リポジトリ は責務ごとに分離していますが、おもに2種の リポジトリ から構成されます。 一つは本稿で解説してきた、プロダクトにひも付くIaC リポジトリ です(以下product-repo)。 product-repoはプロダクトごとに作成され、対応する Google Cloudプロジェクトに関連するリソースを管理します。 そしてもう一つは組織全体を管理するIaC リポジトリ です(以下org-repo)。org-repoでは横断的に設定したい項目や、フォルダに対するIAM設定を管理しています。 具体的には、org-repoで次のようなリソースを管理しています。 ・tfstate用のStorage バケット 作成 ・ GitHub ActionsでTerraformを実行するためのセットアップ作業 ・Workload Identityプール、プロバイダ作成 ・サービスアカウント作成 ・ product-repoに GitHub Actionsシークレット登録 5 ・組織ポリシーの管理 ・セキュリティ基盤向けのLogging転送設定 新規プロダクトが作成された際には、product-repoとプロジェクトの対応関係をorg-repoに追加します。org-repo上のワークフローによって、 GitHub ActionsでTerraformを実行するための各種セットアップが行われます。セットアップを自動化することで、product-repoのCI/CD環境をすばやく開発者へ提供できます(図6)。 このような取り組みを通してキャディでは運用のスケーラビリティ向上を目指しています。 ▼図6 横断管理の アーキテクチャ おわりに 前回から2回にわたりTerraformと GitHub  Actionsを組み合わせたIaCのCI/CDパイプラインについて紹介しました。手作業によるミスをなくしつつ、安全にすばやくリリースするためにIaCとCI/CDは欠かせない要素です。本稿がみなさんの運用負荷を下げるヒントになれば幸いです。 次回はRenovateを用いたライブラリの自動更新について紹介します。 https://cloud.google.com/docs/terraform/best-practices-for-terraform  ↩︎ https://developer.hashicorp.com/terraform/language/state/workspaces  ↩︎ https://developer.hashicorp.com/terraform/cli/workspaces  ↩︎ https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs  ↩︎ https://github.com/integrations/terraform-provider-github  ↩︎
アバター
こんにちは、キャディでエンジニアやエンジニア リングマ ネージャをやっている高藤です。 久しぶりのTechブログへの投稿です。 今回はタイトルにあるようにCADDiで4年働く上で起きた事をエンジニア視点でまとめつつ、これから何をしようとしているのか私自身の思いを込めて書いてみようかなと思います。 4年前のあのころ 私は2019年2月に入社し、4年強の期間CADDiで働いてきました。私が当時CADDiに興味を持ったのは、単純に面白い経歴のCEOとCTOがなぜ日本で起業したのかという興味と私自身が成長できる環境で働きたいという2点でした。 正直な話、CADDiが対象とする、製造業 ドメイン への興味は全くありませんでした。 当時のCADDiではWebから 流入 するたくさんの顧客に対して見積を自動で行い、見積頂いた顧客からくる製造依頼に対して製品を納品していました。これらの業務は当時フォーカスをしていた3D CADからの自動見積を行う技術や頂いた図面から見積に必要な入力を人力で抜き出し、社内の見積ロジックを使ってコストを算出するオペレーションを通じて実現していました。 まだまだ、複雑なオペレーションを人力で行っている状況も多く、 流入 する顧客数の増加に伴い、複雑なオペレーションを支えるための仕組みが社内で必要とされているタイミングでもありました。 当時私達が採用していた技術をまとめると以下のものがありました。 3D CADを解析して見積に必要な入力値を解析する C++ で記述された アルゴリズム 入力値から製造コストを算出する C++ で記述されたロジック 製造コストを算出するための Excel で記述された計算モデル 3D CADをアップロードし見積/製造依頼ができるWebアプリケーション 受発注管理の課題から生まれたKleinというプロダクト 当時の受発注の仕組は社内で用意された Salesforce を軸にオペレーションを構築していました。しかし事業のスケールを見据え、より大量の トランザクション に耐えるオペレーションを実現するため、2つの大きなプロダクトを開発する決断をしました。 1つは顧客からの見積リードタイムを減らすため見積ロジックを担うQuipuと呼ばれるプロダクト。もう1つは受発注管理を行うKleinというプロダクトです。私は後者のKleinというプロダクトの開発に携わってきました。どんなプロダクトなのかは弊社白井の 記事 を見てもらったほうがわかりやすいかと思います。 また技術面としても開発言語をRustにするなどいくつかの大きな決定を行いました。当時Rustを選択した経緯などは 別の記事 でまとめてありますが、今ふりかえると自分でもよく決断したなと思ったりもします。Kleinの開発を通して私達が学んだことは大きく2点あると思います。 ドメイン 駆動設計 Kleinの開発を通じて最も学んだことは「 ドメイン 駆動設計」を採用した事です。当時の開発チームはプロダクトマネージャーの白井も含め製造業出身のメンバーがおらず、 ドメイン ナレッジが全くない状態からのスタートでした。 私達は「どのような業務プロセスにしたいのか」「どのような課題を解きたいのか」を明らかにしないと前にも進めない状態でした。またCADDiの成長に伴いプロダクトに求められることも変わると予測していたため、プロダクトの中核部分である ドメイン はその時に見えている範囲で正しく設計したいという判断を行いました。 一言に ドメイン 駆動設計と聞くと、エンジニア視点ではどのように実装するのかという部分に目が行きがちですが、その本質は開発対象となる ドメイン についてどれだけ理解している状態で開発できるかが重要です。そこで、 ドメイン エキスパートとチームで徹底的に議論を行い、プロダクトの設計にあたりました。 具体的には毎日昼食時にはチームと ドメイン エキスパートである業務担当者を招き、徹底的に ヒアリ ングとホワイトボード上での モデリング 繰り返しました。これらのプロセスを通じて ユビキタス 言語の構築したことで、エンジニア自身が現場で起きていることをクリアに理解でき、同じ言葉を使って議論できるようになったことが大きな学びでした。 新しい技術の採用 前述したRust以外にも 通信プロトコル としてのgRPC/GraphQLの採用を行いました。これらの新しい技術の採用過程には、解きたい課題に使う技術が合致しているかという問いだけでなく、新しい技術を使ってみたいという感情的な理由もあったかなとも思っています。それらの決断に対して「だめだったら考え直せばいいじゃん」「学べば良いでしょ」という開発組織の空気感があったことも支えになったと思っています。初めて利用する技術については我々の無知故にハマった数々の落とし穴など数え切れないような失敗も経験しましたが、ここで得た知見は現在でも資産になっています。 おや、CADDiのようすが 前述のKleinの開発と前後してCADDiが大きく方針を変えたのもこの頃でした。前述したとおり、当時のCADDiはWebからの受注を主としており、事業の成長としてもより多くの顧客からWebを通じて取引が発生する事を計画をしておりました。 しかしいくつかの理由からこの方針を変更することになりました。一言でまとめると以下のようなことだったかと思います。 「顧客の要求が顧客毎に異なりCADDiとして標準化した要求としてまとめることが難しかった」 これは品質などに対する要求が顧客毎に異なること、そのような品質を業界や用途向けに統一的に定義し、それを顧客に提示し受け入れてもらうことがが難しかったということになります。当たり前ではありますが、1スタートアップの小さい会社が標準化された仕様を作り上げたとしてもそれを受け入れてもらう交渉力はなかったと思います。 そこで、CADDiは顧客を徹底的に絞る決断をしました。今までは顧客が持つ装置の一部分の部品に対する調達依頼を受けていましたが、この方針転換により、装置一式などより大きい単位で依頼を受けることになりました。この変更によりプロダクト開発側としても様々な点で考慮が必要になりました。 プロダクトに求められる要件の変化 当初、顧客との取引においてCADDiが調達を行う製品数は20製品ほどが多く、プロダクトの設計においても20製品程度の納品を行う案件を大量に処理するオペレーションを想定していました。しかしこの方針転換の実施後、1回の取引で1,000製品を超える取引が発生するようになりました。 Kleinなどオペレーションを担うプロダクトは小さい案件を大量に処理することから、大きな案件を問題なく完遂するための大量の製品に対する操作が必要になるなど、当初想定していた機能ではオペレーションを支えきれないことがわかりました。 これらの問題を解決するためにプロダクトに対して一括で処理を行う機能の提供を行ったりなど、大きな案件を処理する上で必要な機能の追加や大量の処理を行った際のパフォーマンスを改善することを行ってきました。 より深い ドメイン ナレッジの獲得 大きな意思決定ではありましたが、より顧客にフォーカスし特定の業界における産業装置に対する知見を獲得することができました。また大きな調達プロジェクトにおいて、どんな事が発生するのかなど数え切れないほど学びを得ることができました。 これらの新しい ドメイン ナレッジは単純に既存プロダクトの改修だけには留まらず、新しいプロダクトの種にもなったと思っています。 CADDiにおける生産管理プロダクト 前述の大きな転換を行いながらも、CADDiは大きく成長してきました。私が入社した4年前を考えると比較にできないくらい大きい案件の調達を行っています。また、当初のCADDiでは基本的には受注生産を行ったオペレーションを行っていましたが、案件の規模が大きくなるにつれ、見込み生産を行い在庫を持つような取引も発生しています。 私が当初開発を行ったKleinでは受注生産モデルとして設計していたこともあり、こういった事業の成長に対してモデル自体を刷新する必要もでてきました。こちらは既存のKleinというプロダクトを改修する判断ではなく、根本からモデルを刷新するためプロダクトのリプレイスを実施しました。 顧客との取引を重ねることで製造業における図面管理の難しさに気づきました。現在CADDiが提供している SaaS プロダクト「CADDi DRAWER」の原型となる図面管理プロダクトの開発も行い図面の世代管理や図面を介したコミュニケーションの改善を行ったりしています。 また、単純に受発注だけの管理だけでなく、製造を引き受けていただく加工会社様とのコミュニケーションを円滑にするためのプロダクトや倉庫での在庫管理や倉庫内オペレーションを支援するためのプロダクトを開発してきました。このように事業拡大と共に必要な課題を様々なプロダクトを開発・運用することで解決してきました。 これらの開発を通じて得たナレッジはエンジニアだけでなくCADDiの資産になっています。他方でCADDiの生産管理プロセスにおいてCADDi独自のナレッジや解決させるためのHowになっている部分と多くの企業と同様のアプローチで課題解決を行っている部分が出てきていることも事実です。 サプライチェーン のデータを資産化する ここまで、今のCADDiのプロダクトの開発とその開発や課題を解決することで得たナレッジについての話をしてきました。私は現在「CADDi DRAWER」というCADDi初の SaaS プロダクトの開発を行っています。CADDi DRAWERについては以下の2つの記事をみてもらったほうが良いかなと思います。 CADDi DRAWERで何をしたいのか?/創業6年目からの新しい挑戦 プロダクトが何かを変える瞬間に立ち会うこと この新しいプロダクトはCADDiで私達が経験した課題やその解決方法から生み出されたプロダクトです。現在は主に「図面」という製造業における重要なデータを取り扱っています。しかし製造業においては、図面以外にも様々なプロセスから情報が発生しています。それらはデータとしては存在しているのですが、資産として扱える状態ではないと考えています。 今後CADDi DRAWERには製造の各プロセスに関するより多くの情報をが蓄積され、それら情報から課題発見や意思決定を促すプラットフォーム基盤になると考えています。これらは私達が受発注プラットフォーム事業を行ってきたからこそ実現できることだと思っています。もちろん解くべき課題も多く、技術面だけでなく大きくなってきたCADDiの開発組織がより生産的に活動できるようにするにはどうしたら良いのかなど多くのことに取り組まないといけない状況です。 さて、なぜ私は働いているのだろうか? 製造業という未知の領域にCADDiのエンジニアとして飛び込んで、4年と少し働いてきました。製造業における課題を少しづつではありますが見てきたつもりです。最後になぜCADDiにいるのかをまとめて終わりにしようと思います。 記事の冒頭で記載したとおり、当初、製造業という ドメイン 自体への興味はありませんでした。4年間濃い経験を過ごすことができた結果、製造業という産業自体の課題をエンジニアとして解決してみたいと思うようになりました。 私が今後CADDiを通してやりたいのは「製造プロセスの中で標準的な プロトコル を定める」ことです。CADDiへ飛び込む前は製造において図面さえあれば顧客が望むものは製造できると考えていました。その意味で図面は1つの標準 プロトコル だと捉えていました。ですが製造業の中で働く中で、図面だけでは顧客が望んでいるものを納品できないという現実が見えてきました。 顧客が望むものを納品するためには図面を元にした要求事項の確認が必要であったり、なかには図面で表現できていないことや過去の商習慣から生まれた 暗黙知 など、取引において図面以外のコンテキストが必要になります。このような標準でない プロトコル をCADDiが取引に参加することで標準化を促したり定義できると考えています。これは今まで行ってきた受発注プラットフォームや「CADDi DRAWER」どちらを通じても実現できるだろうと考えています。 こうした産業への インパク トを起こせる仕事というのもなかなか無いと思っています。このようなチャレンジをできるCADDiだからこそ面白いと改めて実感しています。 (なお、この4年間は、本当にきついと感じることは何度もありました。ですが良い仲間に出会えお互いを支えられる関係でここまで仕事ができました。本当に感謝しています。) 本記事を読んで、CADDiのミッションやプロダクト組織に少しでも興味を持ってくださった方。お気軽にカジュアル面談でお話ししませんか。 https://open.talentio.com/r/1/c/caddi-jp-recruit/pages/78398 プロダクトマネージャ、ソフトウェアエンジニア、セキュリティエンジニア等、様々なポジションを募集しています。 https://open.talentio.com/r/1/c/caddi-jp-recruit/homes/4139?group_ids=8633 ご連絡お待ちしています。
アバター
MLOps Team Tech Lead の西原です。以前の Tech Blog で Pants を使った Python モノレポ移行への取り組みについて紹介しました。日々の業務で得た知見を Python コミュニティに共有できるといいなと思い、 PyCon APAC 2023 に「Pants ではじめる Python モノレポ」というタイトルで CfP を提出し採択されました。この記事では、PyCon APAC 発表に向けての整理も兼ねて、Pants を使ったモノレポの管理・運用を効率的に行うための取り組みを一部紹介します。 TL;DR CI の待ち時間を短縮する リモートキャッシュの活用 テストの分散実行による効率化 モノレポ内の依存を集約管理 依存管理を集約する背景 依存関係の更新 poetry up pip-compile 依存関係の集約 pex による Python 環境のパッケージング pex とは Pants による pex の構築 pex を用いたコンテナイメージの構築 プロジェクトの依存関係を制御する:Pants の visibility 機能の活用 まとめ link TL;DR リモートキャッシュでテスト時間を 1/12 に短縮 テストの分割実行で CI の待ち時間短縮 依存の集約管理でビルドの堅牢性向上 pex を使った Python コードのパッケージング シンプルなビルドプロセス コンテナイメージの軽量化 依存禁止ルールを設け、意図しない依存関係形成の防止 CI の待ち時間を短縮する リポジトリ のサイズが大きくなると、依存が増え CI の待ち時間が長くなります。CI の待ち時間が長くなれば開発業務の ボトルネック になり、開発スピードが低下します。この状況は開発体験を損ないますが、リモートキャッシュの活用やテストの分割実行をすることで CI の待ち時間を短縮できます。 リモートキャッシュの活用 Pants は Remote Execution API (REAPI)による リモートキャッシュを サポート しています。これにより、個々の開発マシンのローカルキャッシュだけでなく、異なる開発マシン間でキャッシュを共有できます。リモートキャッシュを参照することで一度実行済みのコードやテストの結果を再利用できるため、CI の待ち時間を短縮できます。REAPI をサポートした self-hosted の OSS やマネージドサービスがいくつかありますが、私たちのモノレポでは bazel-remote-cache を使って検証を進めています。Bazel Remote Cache は、ローカル ディスク、S3、GCS、Azure Blob ストレージ をサポートしています。REAPI 用のサーバを建てる必要がなく、Docker コンテナ 1 つだけで動作するため簡単に導入できます。Pants の公式 リポジトリ には、S3 を用いて リモートキャッシュを有効にする 例 が存在します。これを参考に、GCS 用の setup を行い、検証を進めました。 リモートキャッシュを有効にするためには、 pants.toml または .pants.rc ファイルに以下のような設定を追加します。 [GLOBAL] remote_cache_read = true remote_cache_write = true remote_store_address = "grpc://localhost:9092" GCS を使った リモートキャッシュ setup の例が次になります。リモートキャッシュを格納する GCS の バケット を事前に作成し、作成済みの bucket を --gcs_proxy.bucket オプションで指定します。 mkdir -p ~/bazel-remote docker run -u 1000:1000 -v ~/bazel-remote:/data -p 9092:9092 buchgr/bazel-remote-cache -d --max_size 10 --gcs_proxy.bucket=foo_bar_remote_cache_example_bucket --gcs_proxy.use_default_credentials=true 上記の setup を行った状態で、2 万行のコードに対してテストを実行し、キャッシュの有無で処理時間に差が出るかを確認しました。結果として、キャッシュがない状態で 12分50秒 かかっていたテストが、リモートキャッシュに full hit すると 59秒 で終了することが確認できました。 私たちのモノレポでは pre-commit hook などを活用し、コードが GitHub に push される前に個々の開発環境でテストが実行されるように努めています。これらのテストは CI でも実行されているので、ほとんどの場合で同じテストが 2 度実行されることになります。リモートキャッシュを使うと開発環境でのテスト結果を CI での実行時に参照できるので、CI の待ち時間を大幅に短縮できます。 テストの分散実行による効率化 Pants では、テストを複数の shard(分割単位)に分けて実行できます。CI 環境でこの機能を活用すると複数のマシンで分散してテストを実行でき、CI の待ち時間を短縮できます。以下に、テストを 2 つの shard に分割して実行するコード例を示します。 pants test --shard=0/2 :: pants test --shard=1/2 :: Pants の公式 リポジトリ では、 GitHub Actions を使用して shard に分割したテストを複数のマシンで実行しています。 こちらの例 では、10 台のマシンでテストを並列実行し、1 つのジョブが 10 分程度で完了しています。 リモートキャッシュの実験でも使った 2 万行のコードに対して pants test --shard=1/10 を実行してみました。キャッシュヒットがない状態でも 58 秒でテストを終了することが確認できました。開発規模が拡大し、依存するテストが増えた場合でも shard 数を増やして複数のマシンで並列分散することで CI の待ち時間を短縮できます。 モノレポ内の依存を集約管理 依存管理を集約する背景 以前の Tech Blog では、私たちが リポジトリ 内の各プロジェクトごとで依存ライブラリを管理していることを紹介しました。しかし、そのアプローチを続けるうえで 2 つの問題がありました。これらの問題に対処するために、 リポジトリ 内の依存管理を集約することにしました。 最初の問題は Diamond Dependencies です。これは下の図のように liba と libb が libbase に依存している場合、 liba が使う libbase のバージョンと libb が使う libbase のバージョンが異なると、ビルドに失敗する場合があるというものです。私たちが使ってるライブラリの例だと PyTorch や pydantic 、 Kubeflow pipeline 、 pandas などで最近メジャーアップデートがありました。モノレポではコードの参照が容易にできますが、それぞれのコードで依存しているライブラリのバージョンが異なるとビルドに失敗することがあります。任意のライブラリに対して 1 つのバージョンを使用することでビルドの堅牢性を高め、失敗することを防ぐことができます。これは書籍『Software Engineering at Google 』の Build Systems and Build Philosophy の章で"One-Version Rule"として紹介されています。 図 : Diamond Dependencies の例。引用: Diamond Dependencies 2 つ目の問題は、依存関係のバージョン更新にかかる 工数 が増えてきたことです。モノレポを運用する上で、次の理由から依存関係の更新をなるべく高頻度で行うようにしています。 開発が落ち着いてるから何もいじらないという選択肢もあるが、そのまま塩漬けになり久しぶりに触ってみた時に動かない可能性がある 頻繁に更新することで CI やビルドが動くのである程度動作確認できる 依存してるライブラリが更新されると互換性がなくて動かないということが起こり得るが、どのバージョンまでなら動くのかを把握するために高頻度に更新したい 更新サイクルが長く、1 回の更新で多くを変更して問題が起きた場合に何が原因なのか問題の特定に時間がかかる セキュリティ的にハイリスクな問題は放置できない 通常新しいバージョンは新機能の追加やバグ修正がされてるので私たちにとってプラス これまで、各プロジェクトごとに poetry を使って依存関係を管理していました。poetry を使っている場合は、 poetry lock コマンドを実行すると依存が壊れない範囲で最新バージョンを poetry.lock ファイルに記述してくれます。しかし、私たちの環境では poetry.lock ファイルを使って依存管理をしないため別の方法で依存関係の管理をする必要があります(後述)。poetry で管理しているライブラリバージョンをまとめて更新するために poetry up の プラグイン を使っていました。poetry には poetry update <lib> コマンドで個別のライブラリを更新する機能がありますが poetry up を使うことでライブラリ個別にではなく、一括で依存関係を更新できます。依存関係の更新を行う際は、都度プロジェクトで使う Python バージョンに切り替えて poetry up コマンドを実行して依存関係の更新を行っていました。Diamond Dependencies の問題になりそうなところは個別にバージョンを揃えていました。 リポジトリ で管理するプロジェクトが増えてくると、それぞれのプロジェクトで依存しているライブラリのバージョンを揃えつつ更新する作業が大変になってきました。 これらの問題を解決し、依存管理の 工数 を下げるために リポジトリ 内の依存管理を集約することにしました。 依存関係の更新 依存関係を集約する上で考慮するした点は、集約した依存関係のバージョンをどう更新していくかです。Pants で Python ライブラリの依存関係を管理するにあたって、次の 4 つの形式 がサポートされています。 requirements.txt ファイル poetry 形式の pyproject.toml ファイル PEP621 形式の pyproject.toml ファイル pipenv 形式の Pipfile.lock ファイル Pants にも lock ファイル生成の機能があり、上記の形式に従って管理すれば、依存が壊れない範囲の最新バージョンで依存関係の管理をしてくれます。しかし、この lock ファイルは人が読んで理解しやすい形式ではありません。 デバッグ 時など、使用しているバージョンを特定する場面を考えると人から見ても分かりやすい形式で管理するのが望ましいと考えました。 そこで、人からも読める形式であり、"One-Version Rule"を実現できるように poetry up を使う方法と pip-compile を使う 2 つの方法を検討しました。 poetry up poetry up は先にも紹介した通り、poetry 管理の依存関係を一括で更新するための プラグイン です。poetry up を使って更新する手順は次のように考えました。 モノレポで使うライブラリをバージョン指定せずに pyproject.toml に記述。バージョンを指定しない理由は、新規追加した際と poetry up を実行した際に既存のライブラリとバージョンが競合することがあるため。依存が多いモノレポでは競合が起きやすく、都度解決するのは大変。 poetry up --no-install を実行して poetry.lock を更新。私たちのモノレポでは 3 桁の サードパーティ ライブラリに依存しており、毎回インストールを行うと時間がかかるため、 --no-install をつけることで依存関係の更新だけ行い、意図しないインストールは行わない。 pyproject.toml にはバージョンを記述していないため、 poetry export --without-hashes -f requirements.txt --output requirements.txt を実行してバージョンが記載された requirements.txt を生成 requirements.txt を Pants の依存関係に追加 pip-compile pip-compile は requirements.txt 形式、または PEP621 形式の pyproject.toml の依存関係を更新して requirements.txt に出力するツールです。 pip-compile を使って更新する手順は次のように考えました。 バージョンを指定せずに requirements.in にライブラリを記述。バージョンを指定しない理由は、poetry up の時と同様に新規追加時のバージョンの競合を避けるため。 pip-compile --output-file requirements.txt requirements.in を実行してライブラリのバージョンが記載された requirements.txt を生成 requirements.txt を Pants の依存関係に追加 これら 2 つの方法を検討した結果、依存関係のライブラリバージョンを管理するだけであれば pip-compile を使う方がシンプルにできそうだったためこちらを採用しました。 requirements.in にバージョンを指定せずにライブラリを記述することで、pip-compile 時に依存関係が衝突しない範囲で最新のバージョンに更新されます。一部、バージョンが更新されるとテストや静的解析が失敗する場合においては、明示的にバージョンを指定して固定するようにしています。 余談:依存関係を集約した当初だと rye がまだリリースされてませんでしたが、この記事を書きながら rye を使う方法も考えてみました。大まかな手順は poetry の時と同様になりますが、 --no-install のようなオプションをつけなくてもデフォルトの挙動として依存関係をインストールしないのが良い点だと思います。rye の裏側で pip-compile を使っており、 rye lock コマンドを使うと、他の手法と同様に requirements.txt 形式でバージョンが記載されたファイルが出力できます。このファイルを Pants の依存に加えれば依存の集約管理ができそうです。ただ、rye で pip-tools を消す 動き があるので rye の機能を直接使わずに、pip-compile などを間に入れるのが良いのではないかと思います。私たちのモノレポではすでに requirements.in と pip-compile でうまくいってるため リポジトリ 内で rye を使う場面がありませんが Python 環境構築を pyenv から rye に切り替えたりと他の場面で活用しています。 依存関係の集約 依存関係を集約した後の更新方法が決まったので、次は リポジトリ 内の依存関係を集約していきます。 pants peek --filter-target-type=python_requirement :: コマンドを実行すると リポジトリ 内の Python ライブラリの依存関係を確認できます。 json で出力されるので、次のように jq コマンドを使って整形し、 requirements.in に記述します。 pants peek --filter-target-type=python_requirement :: | jq ' [ .[].requirements ] | flatten | unique | .[] ' -r > requirements.in 集約した requirements.in にライブラリのバージョンが記載されていますが、先にも記載した通り新規追加時に依存のコンフリクトを避けるためにバージョンを削除します。その後、上記の pip-compile の手順を実行して依存関係の集約は完了です。 依存管理を集約する前は手作業も多く、依存関係の更新作業に数時間かかることもありました。集約後はコマンド 1 つ実行すると数分で依存関係の更新ができ、Diamond Dependencies の問題を解決するための"One-Version Rule"も実現できました。今後は原則として リポジトリ 内で使われているライブラリは集約管理していきます。 リポジトリ 全体で使ってるバージョンと異なるバージョンを使う必要がある場合は、これまでのように個別で管理もできるのでそのように対応する予定です。 pex による Python 環境のパッケージング pex とは Pants は、 pex 形式の Python 仮想環境を構築できます。pex ファイルは、 サードパーティ のパッケージを含めた Python 仮想環境を zip 形式でパッケージングした実行ファイルです。これにより、必要なライブラリや依存関係を含んだ環境を 1 つのファイルにまとめることが可能となります。 Python インタープリタ が存在する環境であれば、pex ファイルを実行することで、それぞれ独立した仮想環境上でコードが実行されます。また、pex の仮想環境内のパッケージのみを使用するか、システムの Python 環境に存在するパッケージも利用するかは pex の設定で選ぶことができます。 以下に pex の使用例を示します。まず、pex コマンドを実行する際に必要なパッケージを指定します。すると、指定したパッケージとその依存関係を含む pex ファイルが生成されます。生成された pex ファイルを実行すると、 Python インタープリタ が起動し、ファイルに含まれるパッケージを使ってコードを実行できます。 $ pip install pex # pex をインストール $ pex pydantic pip -o demo.pex # pydantic と pip を含む pex ファイルを作成 $ ./demo.pex -m pip list # pex ファイル内のパッケージを確認 Package Version ----------------- ------- annotated-types 0 . 5 . 0 pip 23 . 2 . 1 pydantic 2 . 3 . 0 pydantic_core 2 . 6 . 3 typing_extensions 4 . 7 . 1 $ ./demo.pex # pex ファイル内の Python インタプリタを実行 Python 3 . 11 . 3 ( main, Apr 7 2023 , 20:13:31 ) [ Clang 14 . 0 . 0 (clang-1400. 0 . 29 . 202 ) ] on darwin Type " help " , " copyright " , " credits " or " license " for more information. ( InteractiveConsole ) & gt ;& gt ;& gt ; import pydantic & gt ;& gt ;& gt ; pydantic.VERSION ' 2.3.0 ' $ unzip -l ./demo.pex # pex ファイルの中身を確認 # 省略 Pants による pex の構築 Pants を使って pex ファイルを構築すると、Pants が必要な サードパーティ のパッケージと自作のコードの依存関係を自動的に検知し pex ファイルを構築してくれます。どの サードパーティ パッケージと自作のコードを含めるかを、ほとんどの場合において明示的に指定する必要がありません。特定の場合には明示的な指定が必要になるかもしれませんが、基本的に Pants が最適な依存関係を推測してくれるので、開発者はシンプルで効率的なビルドプロセスを実現できます。 Pants を使って pex を構築する例が次になります。main.py を実行すると、pydantic のバージョンが表示されます。 # dir構成 . ├── BUILD ├── lib │ ├── BUILD │ └── foo.py ├── main.py └── pants.toml # lib/foo.py import pydantic def get_pydantic_version (): return pydantic.VERSION # main.py from lib.foo import get_pydantic_version if __name__ == '__main__' : print (f "pydantic {get_pydantic_version()}" ) # lib/BUILD python_requirement( name= "pydantic" , requirements=[ "pydantic" ] ) python_sources() # BUILD python_sources() pex_binary( name= "pex-demo" , entry_point= "main.py" , ) # pants.toml [GLOBAL] pants_version = "2.17.0" backend_packages = [ "pants.backend.python", ] $ pants run main.py # pex ファイルを作成せずに実行 pydantic 2 . 3 . 0 $ pants package :pex-demo # pex ファイルを作成 05:07:12. 53 [ INFO ] Completed: Building pex-demo.pex with 1 requirement: pydantic 05:07:12. 53 [ INFO ] Wrote dist/pex-demo.pex $ ./dist/pex-demo.pex # pex ファイルの実行 pydantic 2 . 3 . 0 このように Pants を使って pex ファイルを構築することで、Pants が依存関係を自動的に検知して pex ファイルにパッケージングしてくれます。パッケージングの際に開発者は自作のコードと サードパーティ のパッケージの依存を意識する必要がありません。上記の例では、main.py から lib/foo.py をインポートしています。lib/foo.py からインポートされている pydantic が自動的に検知され、これらが pex ファイルに含まれています。 pex を用いたコンテナイメージの構築 pex を使うとポータビリティが高まり、コンテナイメージの構築もシンプルになります。pex を使わない場合、コードを参照するために Dockerfile 内で各ファイルや ディレクト リを都度 COPY する必要があります。依存するファイルが増えるほど、Dockerfile の記述が大変になります。以下にその例を示します。 FROM python:3.11-slim COPY requirements.txt . RUN pip install pydantic==2.3.0 COPY lib/foo.py lib/foo.py # 依存するファイルが増えるほど COPY の記述が大変になる COPY main.py main.py CMD ["python", "main.py"] 一方、pex ファイルを使用する場合はそのファイルを COPY するだけでアプリケーションを実行できます。これにより、 リポジトリ 内の様々な場所から関連するコードを集める作業が不要となり、Dockerfile の記述をシンプルにできます。 FROM python:3.11-slim COPY pex-demo.pex pex-demo.pex CMD ["./pex-demo.pex"] pex をコンテナ環境で使用することで、ポータビリティを向上させるだけでなく、コンテナイメージのサイズの削減がしやすくなります。イメージサイズを小さくすることで、コールドスタートの時間を短縮したり、スケールアウトの速度を改善できます。コンテナイメージを構築する際にイメージサイズを小さくする tips は様々あります。 Python では、依存ライブラリのインストールのキャッシュを無効化・削除したり、multi-stage build で venv を COPY するなどしてイメージサイズを小さくできます。pex を使うと、これらの知識を必要とせずにイメージサイズを小さくできます。 こちらの記事 では venv を使った multi-stage build 時のイメージサイズと pex を使った時のイメージサイズが比較しており、pex の利用でコンテナイメージを小さく保てることがわかります。 image size multi-stage build なし・キャッシュ削除なし 1.29GB venv を使った multi-stage build 66MB pex 47.2MB 引用: Docker build for Python 実際にチームでは、pex を用いたコンテナイメージを Vertex AI Pipeline で構築した 機械学習 パイプラインや Vertex AI Endpoint のサービングの場面で積極的に活用しています。 プロジェクトの依存関係を制御する:Pants の visibility 機能の活用 コードベース内で不適切な依存関係が形成されるとコードの修正や追加が難しくなったり、バグの原因になります。この章では依存関係を適切に管理するために役立つ、Pants の visibility 機能についてご紹介します。 Pants の visibility は、 API やモジュールの依存許可や禁止をコン トロール する機能です。これを活用することで、特定の API やモジュールを他のコードから隠蔽したり、公開範囲を制限することが可能となります。これにより、意図しない依存関係の形成を防止できます。 開発プロセス の中には、一時的な実装のためのコード(以下「sandbox コード」と呼びます)を作成する場面があります。これらの sandbox コードが参照されると、そのコードの改変や廃止が困難になったり、バグの原因になることがあります。 このような問題を未然に防ぐため、私たちのモノレポでは「他からの依存禁止ルール」を適用した sandbox ディレクト リを設け、一時的なコードや試験的な実装をそこに配置しています。これにより、sandbox ディレクト リ内のコードは他のコードから切り離され、安全に共有・開発することが可能となります。 依存禁止ルールが適用された ディレクト リのコードを参照しようとすると、次のようなエラーメッセージが表示され、ルールが適用されてることが確認できます。 $ pants test projects/foo/:: 10:57:00. 07 [ INFO ] Initialization options changed: reinitializing scheduler... 10:57:15. 32 [ INFO ] Scheduler initialized. 10:57:19. 34 [ ERROR ] 1 Exception encountered: Engine traceback: in ` test ` goal DependencyRuleActionDeniedError: projects/foo/tests/test_main.py has 1 dependency violation: * BUILD [! projects/sandbox/** ] -> projects/sandbox : DENY python_tests projects/foo/tests/test_main.py -> python_sources projects/sandbox:src コードベースで何でもかんでも公開して参照できる状態すると、知らぬ間に複雑な依存関係が形成されてメンテナンスが大変になることが想定できます。『 Software Engineering at Google 』の書籍にも書かれているように公開するターゲットを最小限に止め、依存関係が複雑にならないように心がけています。 まとめ ここまでモノレポのメリットを活かしながら、開発効率を高めるための様々な取り組みについて紹介しました。Pants による依存関係管理、pex ファイルの活用、CI の待ち時間の短縮、依存関係の集約管理による効率化を行いました。これらの取り組みによって、モノレポでの開発効率を高めることができました。今後もモノレポの改善を続けながら、事業に素早く貢献できるようにしていきます。 link YOUTRUST (9/25 まで) CADDi Tech 機械学習エンジニア求人
アバター
※本記事は、 技術評論社 「Software Design」(2023年7月号) に寄稿した連載記事「 Google Cloudで実践するSREプ ラク ティス」からの転載です。発行元からの許可を得て掲載しております。 はじめに 前回 はTerraformの基本的な概念とステート管理について解説しました。 今回からは 2 回にわたり、Infrastructure as Code(IaC)のCI/CD( 継続的インテグレーション /継続的デリバリ)パイプラインについて紹介します(図1)。 ▼図1 CADDiのスタックにおける今回の位置付け Google Cloudプロジェクトのインフラ構成をTerraformで定義し、 GitHub Actionsでデプロイするまでを目標とし、前半となる今回は事前備とインフラCIについて焦点を当てていきます。後半となる次回では、インフラCDについて触れつつ、運用をよりスケールさせるためにキャディで取り組んでいる事例について紹介予定です。 IaCとCI/CD 本連載第2回(本誌2023年5月号)では、IaC化によってLinterによる自動チェックや再現性の担保など、さまざまなメリットが得られることを解説しました。今回はIaCの管理・デプロイについて考えてみましょう。 一般に、作業者のPCなどローカル環境でTerraformを実行するときには、次のような課題が生じます。 作業者以外が自動チェックの実行結果を確認できない 実行に必要なシークレットが作業者PCに保管されてしまう 誰がいつデプロイしたのか証跡を残しづらい Terraform plan/applyの実行ログが残らない 手動オペレーションのため作業者のリソースに依存してしまう アプリケーション開発の領域において、CI/CDはすでに一般的なプ ラク ティスとなっています。Pull request(PR)に対してLinterやテストを実行し、問題がなければmainブランチへマージ、アプリケーションコンテナのビルド、デプロイが自動実行されます。 インフラ領域においても、IaC化することで、このプロセスを実現できます。また、 GitHub Actionsなどを活用してCI/CDパイプラインを構築することで、先ほどの課題を解消・軽減できます。 GitHub Actions概要 GitHub Actionsは、タスク実行やワークフローを自動化するCI/CDサービスです。 GitHub でホストされている リポジトリ であれば設定ファイルを設置するだけで利用でき、セットアップも容易です。 ここでは、本連載を理解する上で必要となる知識について簡単に解説します。 理解をより深めたい方は、公式ドキュメント 1 や本誌のバックナンバー Software Design 2022年2月号 2 を参考にしてください。 ワークフローは GitHub リポジトリ 内の .github/workflows ディレクト リ配下に YAML 形式のファイルとして定義します。これらが、記述内容に従ってプッシュなど特定のイベントをトリガーに実行されます。 リスト1はPRをトリガーとしてTerraform組み込みのフォーマッタである terraform fmt を実行する例です。 以下はPRをトリガーとしてTerraform組み込みのフォーマッタである を実行する例です。 ▼リスト1 . github /workflows/terraform_ci.yml name: Terraform CI on: pull_request: branches: - main jobs: tf_version: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v3 - uses: hashicorp/setup-terraform@v2 - run: terraform fmt -check -no-color -recursive working-directory: ./terraform ワークフローは名前( name )、トリガー( on )、ジョブ( job )などから構成されます。 onではワークフローをトリガーするイベントを定義します。ここではmainブランチに対するPR関連のアクティビティをトリガー条件としています。 ジョブは、処理タスクを表現する複数のステップから構成されます。ステップでは、shellのコマンド実行や、「アクション」と呼ばれる再利用可能な コンポーネント が利用できます。 リスト1の例では actions/checkout 3 でPR元ブランチのコードをワークフローランナー上に展開し、 hashicorp/setup-terraform 4 でterraformコマンドを利用するためのセットアップを実施しています。 最後に terraform fmt コマンドを実行しフォーマットのチェックを行っています。 このように、 GitHub Actionsでは、アクションを活用しつつ、CI/CDパイプラインで実施すべき処理を YAML ファイルに記述します。雰囲気をつかんでいただけたでしょうか。 事前準備 ここからは Google Cloudも含めたCI/CDパイプラインの具体的な構築方法を解説していきます。まずはワークフロー実行に必要なリソースを事前に作成します。 はじめに、Terraformのtfstateを保管するためのStorage バケット を作成します(図2)。前回でも紹介したように、ステートが複数環境から参照されるときには、tfstateをローカルではなくリモートのオブジェクトストレージなどに保管する必要があります。 ▼図2 tfstate用 バケット 作成 $ gcloud storage buckets create \ gs://my-tfstate-dev-${RANDOM} \ --location=asia-northeast1 \ --uniform-bucket-level-access なお、 Google Cloud Storageの バケット 名は、グローバルに一意でなくてはならないため、 バケット 名にランダム値を追加しています。 次に、Terraformを実行するためのサービスアカウントを作成し、必要な権限を付与します(図3)。 ${PROJECT_ID} は対象の Google CloudプロジェクトIDに置き換えてください。今回は編集者ロールを付与しますが、実際は必要に応じた最小限の権限とするのが適切です。 ▼図3 サービスアカウント作成 $ gcloud iam service-accounts create my-github-actions $ gcloud projects add-iam-policy-binding ${PROJECT_ID} \ --member="serviceAccount:my-github-actions@${PROJECT_ID}.iam.gserviceaccount.com" \ --role="roles/editor" 最後に、サンプルのtfファイル(リスト2、3)を用意します。 VPC ネットワークを構築済みの既存プロジェクトに対してCompute インスタンス を作成します。のちの説明のために、対象のプロジェクトは便宜上開発(dev)環境として扱います。 ▼リスト2 terraform/modules/ vm /main.tf variable "name" { type = string } resource "google_compute_instance" "default" { name = var.name machine_type = "e2-micro" boot_disk { initialize_params { image = "debian-cloud/debian-11" size = 10 } } network_interface { network = "default" } } ▼リスト3 terraform/environments/dev/main.tf terraform { backend "gcs" { bucket = "my-tfstate-dev-<<SUFFIX>>" } } provider "google" { project = "<<プロジェクトID>>" region = "asia-northeast1" zone = "asia-northeast1-b" } module "vm" { source = "../../modules/vm" # ① name = "my-vm" } 再利用しやすくするためCompute インスタンス のリソース定義はモジュール化しています。 モジュールは複数のtfファイルを含んだ ディレクト リで、1のようにモジュール ディレクト リのパスを指定して利用します。 このtfファイルを利用して GitHub Actions上でTerraformを実行してみましょう。 [Column] IaC するもの/しないもの IaCは再現性や監査など多く点でメリットがあります。しかし、手動オペレーションをすべて禁止してしまうと、かえって作業が煩雑になったりセキュリティリスクが高まったりするケースもあります。そのような場合には、柔軟に手動オペレーションを許容することも選択肢の1つです。 キャディでは、実施頻度が低く強い権限を要する一部の作業は手動で行っています。 Google CloudではプロジェクトやCloud API について、IaCの可否を事前に検討しておけると後の管理がスムーズになります。 IaC化しているリソース、IaC化していないリソースはドキュメントで明文化しておくことをお勧めします。明文化しておくことで開発者が意図せずリソースを手動で編集してしまい、IaCで定義した状態から乖かい離してしまうといった事故の予防につながります。 認証方法 Terraformから Google Cloud上のリソースを操作するには、認証が必要です。 GitHub Actionsでは google-github-actions/auth 5 アクションが次の2種類の認証方法を提供しています。 ・サービスアカウントキーを利用する方法 ・Workload Identity連携を利用する方法 サービスアカウントキーを利用する方法 サービスアカウントのキーファイルを生成し、 GitHub Actionsのシークレットへ登録します(図4)。シークレットは機密性の高いデータを管理するための機能で、ワークフローからは 環境変数 として参照できます(図5)。 ▼図4 サービスアカウントキー作成 $ gcloud iam service-accounts keys create gsa-key.json \ --iam-account=my-github-actions@${PROJECT_ID}.iam.gserviceaccount.com ▼図5 Actionsシークレット ワークフローでは、 credentials_json でアクションに対してサービスアカウントキーを与え、認証します(リスト4)。 ${{ secrets.DEV_GCP_SA_KEY }} が、 DEV_GCP_SA_KEY という名前で登録したシークレットの内容を参照する部分です。 ▼リスト4 サービスアカウントキーでの認証 - uses: google-github-actions/auth@v1 with: credentials_json: '${{ secrets.DEV_GCP_SA_KEY }}' この方法はシンプルですが、セキュリティ面で好ましい方法ではありません。このキーは有効期限がなく、漏洩した際にはキーを使用している全環境でローテーション作業が発生します。 そこで、よりセキュアな手法として、Workload Identity連携を利用した方式が推奨されています。 Workload Identity連携 を利用する方法 Google Cloud の Workload Identity 連携は、 GitHub など外部IDプロバイダ(IdP)の認証情報をもとに、 Google Cloudリソースへのアクセス制御を行うサービスです。外部IdPで発行された トーク ンを検証し、対象サービスアカウントの権限を借用することで、サービスアカウントキーを使用することなく認証ができます(図6)。 ▼図6 Workload Identity連携イメージ GitHub Actions は OpenID Connect(OIDC) トーク ンを利用した認証をサポートしているので、ワークフローで短命なOIDC トーク ンを生成し、ID連携に利用できます。 GitHub Actionsは OpenID Connect(OIDC) トーク ンを利用した認証をサポートしているので、ワークフローで短命なOIDC トーク ンを生成し、ID連携に利用できます。 6 先ほどのサービスアカウントキーと異なりOIDC トーク ンは数時間程度で失効するため、セキュリティリスクを軽減できます。 Workload Identity連携はプールとプロバイダから構成されます。プールは外部IdPにより発行されたIDを管理し、プロバイダは Google Cloudと外部IdPにおける属性情報の対応を管理します。 まず、 GitHub Actionsで利用するプールとプロバイダを作成します(図7、8)。 ▼図7 プールの作成 $ gcloud iam workload-identity-pools \ create my-github-actions \ --location=global ▼図8 プロバイダの作成 $ gcloud iam workload-identity-pools providers create-oidc github-provider \ --location=global \ --workload-identity-pool=my-github-actions \ --issuer-uri=https://token.actions.githubusercontent.com \ --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.aud=assertion.aud,attribute.repository=assertion.repository" これでOIDC トーク ンを検証する準備ができました。 次に、 my-github-actions Workload Identityユーザーロールをmy- github -actionsアカウントに付与し、外部からのアカウント権限借用を許可します(図9)。 ▼図9 サービスアカウントの権限借用許可 $ gcloud iam service-accounts add-iam-policy-binding my-github-actions@${PROJECT_ID}.iam.gserviceaccount.com \ --role=roles/iam.workloadIdentityUser \ --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUM}/locations/global/workloadIdentityPools/my-github-actions/*" これで、ワークフローから my-github-actions サービスアカウントを使用して Google Cloudリソースを操作できます。 ワークフローではリスト5のように記述します。 ▼リスト5 Workload Identity連携での認証 - uses: google-github-actions/auth@v1 with: workload_identity_provider: projects/(…略…)/providers/github-provider service_account: my-github-actions@(…略…).com Terraform planの実行 GitHub ActionsでTerraformを実行するための準備が整いました。PRに対して terraform plan を実行し、どのようなリソース変更が予定されているのかチェックしてみましょう。 plan結果はActionsの実行ログから参照できますが、PRコメントとして投稿されるとレビュー体験がより良くなります。リスト6はPRに対して terraform plan を実行し、plan結果をPRコメントとして投稿するワークフロー定義です。 ▼リスト6 . github /workflows/terraform_ci.yml name: Terraform CI on: pull_request: branches: - main jobs: terraform_ci: runs-on: ubuntu-latest permissions: # ① contents: read id-token: write pull-requests: write steps: - uses: actions/checkout@v3 - uses: hashicorp/setup-terraform@v2 - uses: google-github-actions/auth@v1 with: workload_identity_provider: projects/(…略…)/providers/github-provider service_account: my-github-actions@(…略…).com - run: terraform init working-directory: ./terraform/environments/dev - id: plan run: terraform plan -no-color working-directory: ./terraform/environments/dev continue-on-error: true - uses: actions/github-script@v6 # ② with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const output = `Terraform Plan: \`${{ steps.plan.outcome }}\` <details><summary>Show plan</summary> \`\`\` ${{ steps.plan.outputs.stdout }} \`\`\` </details>` github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: output }) ①:このワークフローではOIDC トーク ンの生成やコメント投稿を行うため、 permission で対応する権限を付与します。 ②:plan 結果のコメント投稿には actions/github-script 7 を利用します。これは GitHub API を用いた処理を JavaScript で記述できるアクションです。 hashicorp/setup-terraform 8 アクションのREADMEには github -scriptのサンプルも記載されていますので、参考にしてください。 サンプルのtfファイルを含め、現状 GitHub リポジトリ は図10のファイル構成となっています。 ▼図10 プロジェクトIaC リポジトリ の構成 |-- .github | `-- workflows | `-- terraform_ci.yml `-- terraform |-- environments | `-- dev | `-- main.tf `-- modules `-- vm `-- maint.tf これらのファイルをコミットし、PRを作成するとワークフローが実行され、 terraform plan の結果がコメントに投稿されます。 ▼図11 plan結果のコメント投稿 今回は terraform plan のみですが、コードの品質を高めるためにバリデーション( terraform validate )やフォーマット( terraform fmt )も実行すると良いでしょうtfsec 9 などtfファイルを静的解析し、セキュリティリスクのあるインフラ構成を検知できる OSS もあります。 なお、コメント投稿は actions/github-script を利用しましたが、より見やすい形でコメントするアクションも OSS で公開されています 10 。 おわりに 今回はTerraformと GitHub Actionsを組み合わせたIaCのCIパイプラインについて紹介しました。実運用する際には開発用・商用など複数環境対応や複数プロジェクト管理についても考慮が必要です。 次回はIaCのCDパイプラインに触れつつ、運用をスケールさせるキャディでの取り組みを紹介します。 https://docs.github.com/ja/actions/using-workflows/workflow-syntax-for-github-actions  ↩︎ 本誌2022年2月号第2特集「 GitHub Actionsで簡単・快適 CI/CD」  ↩︎ https://github.com/marketplace/actions/checkout  ↩︎ https://github.com/marketplace/actions/hashicorp-setup-terraform  ↩︎ https://github.com/marketplace/actions/authenticate-to-google-cloud  ↩︎ https://cloud.google.com/blog/ja/products/identity-security/enabling-keyless-authentication-from-github-actions  ↩︎ https://github.com/marketplace/actions/github-script  ↩︎ https://github.com/hashicorp/setup-terraform  ↩︎ https://github.com/aquasecurity/tfsec  ↩︎ https://github.com/suzuki-shunsuke/tfcmt  ↩︎
アバター