キャディ株式会社のブログ - TECH PLAY

TECH PLAY

キャディ株式会社

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

239

はじめに LLM-as-a-judge 対象のタスク 実験概要 実験のプロセス Generative AI on Vertex AIでの実装 評価結果の確認 追加実験 まとめ はじめに こんにちは、Analysisチームの宇佐見です。 こちらはCADDi プロダクトチーム Advent Calendar 2024 6日目の記事になります。 adventar.org 皆さん、機械学習モデルの精度を追ってますか?PoCの話ではなく、デプロイした後の話です。 CADDiが展開するCADDi Drawerの一機能の図面解析も機械学習を用いて行われており、デプロイしたモデルの精度が期待していたよりも低ければ図面から適切な情報を得られない可能性があります。なのでどうにかしてデプロイ後のモデルの精度は監視したいものです。 とはいえ直接的な精度評価は難しいです。従って、例えば間接的に特徴量の推移を追うことで精度変化が起きている可能性を考えると思います。 データドリフトのように特徴量の分布が変わってしまったり、コンセプトドリフトのように全く異なるコンテキストのデータに適用してしまったりすると精度低下は容易に起こりえます。 CADDiが扱う領域である図面で考えれば、例えば学習画像に含まれていないような書き方の図面だったり、はたまた全く扱っていない業界の図面だったりを推論すると精度低下は発生し得ます。 ただ、ここで精度が低下したと本当にわかるのは人間が確認した時であるというところが問題になります。 つまり定期的に推論にかけられたデータを確認し、アノテーションをして初めて本当に精度低下が起きているかが確定するわけです。 また、図面の特徴量の分布ってどうやって測るの?というところも難しいです。 定期的にバナーでポップアップを出してユーザーに体験を聞くことでフィードバックを得ることで、ユーザー体験を測ることもできますが、直接的な精度計算はできません。この場合フィードバックが悪くなってから気づくことになるので、後手に回る感も否めません。 つまり、理想的には ユーザーが悪い印象を得るよりも早いタイミングでアノテーション作業なしに精度を測りたい ということになります。 めっちゃすごい仕組み 究極的にはこんな感じで推論に使われた画像に自動的にアノテーションができていれば、デプロイされたモデルの推論結果の評価ができるなあというのをずっと昔から考えていました。 この「めっちゃすごい何か」を埋めてくれるピースとしてついにLLMが来たのではと思い、最近話題のLLM-as-a-judgeの考え方を取り入れてこれを実現できないか?ということを実験してみた、というのがこの記事の主旨になります。 LLM-as-a-judge 昨今注目を集めるLLMですが、実用するとなれば他の機械学習モデルと同様性能評価は必須です。 LLMを評価する方法はいくつか挙げられますが、いずれの方法にしても人間が関わってきて、どうしてもコストや時間がかかります。 そういった課題を解決するために、LLM-as-a-judgeという方法が注目されています。 これは文字通りあるLLMの性能を評価するためにLLMを使うという方法です。 評価したいLLMに対して評価用のLLMを用意し(Claude, ChatGPTなどの汎用LLMや、ファインチューニングされたLLMなど)、特定のスコア(1~10の離散値、Yes/No、2つの選択肢を比較するペアワイズ比較など)を返すようなプロンプトを用意します。後処理でそのスコアをうまく抽出します(正解している割合など)。 評価用のLLMから返されたスコアが十分高ければ被評価LLMの性能は良い、と言えるわけです。 ものすごくざっくりした説明ですが、本記事はその詳細よりも実際に試したいことに注力して書いていきたいと思います。 上記の前提に則って考えると、図面解析で使っているモデルが返却する値を評価用LLMに評価してもらうことでLLM-as-a-judgeを適用できそうです。 CADDiでは図面解析にはいくつかのモデルを使っていますが、ここでは最大寸法推定というタスクを例に考えてみたいと思います。 対象のタスク 先述の最大寸法推定とはどのようなタスクかというと、文字通り図面内に存在する寸法のうち、最大値を推定するというタスクになります。 なぜ最大の寸法を知りたいのかというと、製品の最大寸法がわかることにより、大方使う材料の量が決まってきて、製造のコストに大きく影響するからです。例えば、新しく作った図面の最大寸法がわかり、それと同じような形状の図面かつ最大寸法が同じような図面が過去にあったとします。そうすると、その図面が持つ当時の見積もり額を確認すれば、新しくつくった図面の妥当な見積額がわかるわけです。 実験概要 タスクがわかったところで、どのような実験をするか説明します。 現在、CADDiでは最大寸法を推定する機械学習モデル(以下、最大寸法推定モデル)を運用しています。それ自体は図面を与えると、最大寸法らしき値を抽出するというモデルになります。 とすると、以下のような評価方法が考えられます。 ピースが埋まった 素晴らしい。先ほどのピースが埋まりました。 もちろんLLMが最大寸法をある程度精度高く推定できるという前提がありますが、過去の実験からGPT-4oを使えばある程度高い精度で抽出できることがわかっているので、評価用のLLMとして十分実用に耐えそうです。 ところで、そこまで精度がいいのならばLLMを実運用して使えば良いのでは?という疑問があるかと思いますが、これはレスポンスタイムとコストの面から不採用となった経緯があります。 現行の最大寸法推定モデルと比べ、GPT-4oでは1リクエストあたりのコストが数十倍になることがわかっており、そのコストは飲めませんでした。また、1リクエストあたりのレスポンスタイムがかかりすぎて、SLOを満たすことができませんでした。 しかし評価用としていくつかのサンプルに実行してやるとなれば、コストを抑えつつLLMの恩恵を享受できそうです。 実際には人がアノテーションする金銭的なコストと比較する必要はあると思いますが、少なくとも結果を得られるまでの速度の面では圧倒的に早くなるでしょう。 実験のプロセス それでは実際に実験をやってみます。 本実験では、CADDiがGoogle Cloudを使っているため、Gemini 1.5 Proを使って実験します。 対象の図面は、CADDiが持っているサンプル図面を使ってみます。 サンプル図面 ここで図面上の最大寸法は、図の中央下部にある214になります。 では、Gemini 1.5 Proにこの図面をリクエストした場合、どのような結果になるかを見ていきます。 プロンプトは以下のようにしてみました。 Extract the maximum dimension of the image. とすると、レスポンスは以下の通りでした。 {'max_dimension': 214.0} 正解できているようです。 こうやって得られた結果と、元々の最大寸法推定モデルの回答とを同時に比べていけば、常に精度の監視ができそうです。 次の章でもう少し、実運用の方法について考えていきたいと思います。 Generative AI on Vertex AIでの実装 Gemini 1.5 Proへのリクエストの送信ももちろん、LLM-as-a-judgeの評価も、Vertex AIを使って行うことができます。 以降のコードは、Google Cloudが公開している Notebook を参考に進めていきます。 さて、シンプルにGemini 1.5 Proにリクエストを送る方法は以下の通りです。 vertexai.init(project=PROJECT_ID, location=LOCATION) model = GenerativeModel( "gemini-1.5-pro" , generation_config=GenerationConfig( response_mime_type= "application/json" , response_schema=response_schema, ) ) question = 'Extract the maximum dimension of the image.' image= "gs://path/to/image.png" image_file = Part.from_uri(image, "image/png" ) prompt = [ "QUESTION: " , question, "IMAGE: " , image_file, ] response = model.generate_content(prompt) 上記のPROJECD_ID, LOCATION, imageのパスは適宜変更してください。 responseの一部に先述のmax_dimensionが含まれており、それを抽出すれば最大寸法がわかります。 これを踏まえ、最大寸法推定モデルの推論結果を評価する関数を用意してみます。 def get_autorater_response (metric_prompt: list ) -> dict : metric_response_schema = { "type" : "OBJECT" , "properties" : { "score" : { "type" : "NUMBER" }, "max_dimension" : { "type" : "NUMBER" }, "explanation" : { "type" : "STRING" }, }, "required" : [ "score" , "max_dimension" , "explanation" ], } autorater = GenerativeModel( "gemini-1.5-pro" , generation_config=GenerationConfig( response_mime_type= "application/json" , response_schema=metric_response_schema, ) ) response = autorater.generate_content(metric_prompt) response_json = {} if response.candidates and len (response.candidates) > 0 : candidate = response.candidates[ 0 ] if ( candidate.content and candidate.content.parts and len (candidate.content.parts) > 0 ): part = candidate.content.parts[ 0 ] if part.text: response_json = json.loads(part.text) return response_json この関数では、Geminiのレスポンスにscore, max_dimension, explanationを必ずキーとして持つように設定しています。 そして、関数に与えられた metric_prompt をGeminiに与えます。 metric_prompt は以下のように作成します。 question = 'Extract the maximum dimension of the image.' context = [ { "question" : question, "image" : "gs:path/to/image" , }, ] eval_instruction_template = """ # Instruction You are a specialist of reading manufacture drawings. You will be provided with a question and an image of a manufacture drawing. Your task is to extract the maximum dimension of the image. Please leave why you think the maximum dimension is the correct answer in the explanation section. """ # read image from uri image_file = Part.from_uri(image, "image/png" ) evaluation_prompt = [ eval_instruction_template, "QUESTION: " , question, "IMAGE: " , image_file, ] evaluation_response = get_autorater_response(evaluation_prompt) ここで、Instructionとして情報を与えています。後段の評価のステップで評価時の説明を追加してもらうことができるので、説明に推定の根拠を書いてもらうように情報を与えています。 さて、どのようなプロンプトで最大寸法を得るのかを決めたので、どのような指標で最大寸法推定モデルの性能を評価できるかを考えていきます。 簡単に考えれば、Geminiが出力した最大寸法と、元々の最大寸法推定モデルが出力した最大寸法が一致しているかどうかで評価できそうです。ただ少しマージンを持たせて、どれくらいずれているか、という割合を評価指標として使うことにします。 具体的には、以下の計算式で評価します。 score = round ( 1 - ( abs ( float (max_dimension) - float (ground_truth)) / float (ground_truth)), 3 ) max_dimensionがGeminiが出力した最大寸法、ground_truthが元々の最大寸法推定モデルが出力した最大寸法です。 このスコアを評価指標に使う方法として、VertexAIの CustomMetric を利用します。 custom_metric = CustomMetric( name= "score" , metric_function=custom_score, ) custom_score_simple は先ほどの処理を踏まえ以下の通りに定義します。 def custom_score (instance): question = instance[ "context" ][ "question" ] image = instance[ "context" ][ "image" ] ground_truth = instance[ "ground_truth_dimension" ] eval_instruction_template = """ # Instruction You are a specialist of reading manufacture drawings. You will be provided with a question and an image of a manufacture drawing. Your task is to extract the maximum dimension of the image. Please leave why you think the maximum dimension is the correct answer in the explanation section. """ # read image from uri image_file = Part.from_uri(image, "image/png" ) evaluation_prompt = [ eval_instruction_template, "QUESTION: " , question, "IMAGE: " , image_file, ] # get_autorater_responseは上部で定義 evaluation_response = get_autorater_response(evaluation_prompt) max_dimension = evaluation_response.get( "max_dimension" , "" ) score = round ( 1 - ( abs ( float (max_dimension) - float (ground_truth)) / float (ground_truth)), 3 ) return { "score" : score, "max_dimension" : evaluation_response.get( "max_dimension" , "" ), "explanation" : evaluation_response.get( "explanation" , "" ), } ここで返却するDictの形で最終的なレスポンスのを定義できます。他にも返したいものがあるときは、 get_autorer_response の metric_response_schema を適切な形に変更して、ここで返却するDictの形も変えると良いです。引数のcontextは後段の処理で与えられることになります。 続いて EvalTask を使って評価します。 ground_truth_dimension = [ "214" ] eval_dataset = pd.DataFrame( { "prompt" : question, "context" : context, "ground_truth_dimension" : ground_truth_dimension, } ) metrics = [custom_metric] experiment_name = "eval-multimodal-metric" eval_task = EvalTask( dataset=eval_dataset, metrics=metrics, experiment=experiment_name, ) eval_result = eval_task.evaluate() eval_dataset が custom_metric の引数として与えられます。 これで結果を得るための準備は整いましたので、評価結果を見てみましょう。 評価結果の確認 得られた eval_result を整形すると、以下のようにまとめられます。 score max dimension explanation 1.0 214.0 The maximum dimension is 214, which represents the total length of the part, measured horizontally. max dimension が214を返しているため、ground truthと完全一致し、scoreは1を返しています。 explanation を見ると、ある部分の水平方向の全長を取ったと説明していて、適切なところから寸法を推定したことがわかります。この説明があるおかげで、実際に最大寸法推定モデルと推定値がずれてしまった場合に、結果が妥当かどうかが全く図面を読めない初心者でも評価する助けにもなりそうです。 追加実験 更に他のタスクや評価指標も追加して複数のタスクの評価をやってみたいと思います。 追加のタスクは、与えられた画像が図面なのか、全く関係ない画像なのかを分類するタスクを考えてみます。 例として、以下の見積書のような画像を推論対象に追加してみます。 見積書 今度はそれも考慮してコードを少し変更してみます。 def custom_score (instance): question = instance[ "context" ][ "question" ] image = instance[ "context" ][ "image" ] ground_truth = instance[ "ground_truth_dimension" ] eval_instruction_template = """ # Instruction You are a specialist of reading manufacture drawings. You will be provided with a question and an image of a manufacture drawing. Your task is to extract the maximum dimension of the image. Please leave why you think the maximum dimension is the correct answer in the explanation section. If target image is not a manufacture drawing, please return 1.0 as the maximum dimension and write "Not a drawing" in the drawing_or_not section. If you think the image is a manufacture drawing, please write "Drawing" in the drawing_or_not section. A manufacture drawing is a type of technical drawing that provides information about shape, dimensions, and materials of a part. It should have some of the figures like lines, circles, arcs, and text. """ # read image from uri image_file = Part.from_uri(image, "image/png" ) evaluation_prompt = [ eval_instruction_template, "QUESTION: " , question, "IMAGE: " , image_file, ] evaluation_response = get_autorater_response(evaluation_prompt) max_dimension = evaluation_response.get( "max_dimension" , "" ) score = round ( 1 - ( abs ( float (max_dimension) - float (ground_truth)) / float (ground_truth)), 3 ) return { "score" : score, "max_dimension" : evaluation_response.get( "max_dimension" , "" ), "drawing_or_not" : evaluation_response.get( "drawing_or_not" , "" ), "explanation" : evaluation_response.get( "explanation" , "" ), } Instructionの内容に、図面かどうかを分類するように指示を追加し、返却するDictのキーにその結果が入るようにしました。また、図面でない場合は仮の値で1.0を返すようにも指示しています。 評価の実行部分に関しては、以下のように更新します。 context = [ { "question" : question, "image" : "gs://path/to/image1" , }, { "question" : question, "image" : "gs://path/to/image2" , }, ] ground_truth_dimension = [ "214" , "1.0" ] ground_truth_drawing = [ "Drawing" , "Not a Drawing" ] reference = [ "Drawing" , "Not a drawing" ] eval_dataset = pd.DataFrame( { "prompt" : question, "context" : context, "response" : ground_truth_drawing, "reference" : reference, "ground_truth_dimension" : ground_truth_dimension, } ) custom_metric = CustomMetric( name= "score" , metric_function=custom_score_simple, ) metrics = [ "exact_match" , custom_metric] experiment_name = "eval-multimodal-metric" eval_task = EvalTask( dataset=eval_dataset, metrics=metrics, experiment=experiment_name, ) eval_result = eval_task.evaluate() metricsに exact_match を追加しました。指標の例としては こちら にありますが、ここでは Drawing or Not a Drawing の2択のうち、出力した結果が完全一致しているかを指標として追加しています。 また、 exact_match を利用する際には reference がcontextのcolumnが必要なので追加しています。 同じように、この時の結果の表を見てみましょう。 response score score/max_dimension score/drawing_or_not score/explanation exact_match/score Not a Drawing 1.0 1.0 Not a Drawing There are no dimensions, lines, arcs, or circles in this image. 1.0 Drawing 1.0 214.0 Drawing The maximum dimension is 214 which is the length of the part. 1.0 図面かそうでないのかの回答についてはしっかり正解しています。説明にも図面でない理由を説明してくれています。exact_match/scoreに関しても、いずれも正解と一致しているため1.0となっています。 まとめ ここまでの実験により、LLMを使うことでデプロイ後のモデルの精度を監視できそうな予感がしました。 もちろん、実際には評価用のLLMを用意するコストや、評価用のプロンプトを作成するコスト、評価用のデータを用意するコストなどがかかります。それでも人がアノテーションするコストよりは安く、かつ早く評価することができるのではないかと思いますので、実際に運用してみる価値はありそうです。 ただ外部のLLMを使っているので、モデル自体をコントロールできないというリスクはあります。なので、できるならば自社でLLMを学習させ、そのモデルを評価用LLMとして使うことが望ましいかもしれません。図面解析LLM、ちょっと夢があります。 そんなCADDiは機械学習エンジニア、MLOpsエンジニアを始め、様々なポジションでのメンバーを募集しています。 少しでも興味をお持ちいただけましたら、以下の採用ページよりお問い合わせください。 カジュアル面談もお待ちしております! ML Engineer Machine Learning Engineer / キャディ株式会社 MLOps Engineer MLOps Engineer / キャディ株式会社 Data Engineer Data Engineer / キャディ株式会社 カジュアル面談 カジュアル面談申し込み_エンジニア・プロダクトデザイナー / キャディ株式会社
この記事は CADDi プロダクトチーム Advent Calendar 2024 の4日目の記事です。 Data Management チームの森岡です。要らなくなったものをすぐに捨てられるデータ基盤を意識して日々開発しています。 今回は、クエリコストを下げるための方針を体系的に整理するのと、BigQueryのクエリコスト削減施策の中でとくに効果が大きかった事例について紹介しようと思います BigQueryのクエリコストを削減する3つの基本方針 BigQuryの課金体系には、On-demandとCapacityの2つがありますが、CADDi ではOn-demandで利用しています。そのため、以降の話はOn-demand を前提としています。 On-demandの場合、クエリ実行時にスキャンされたデータ量に対して課金されます。このスキャンデータ量を削減するための方針は、大きく3つに分類できます。 行を減らす 列を減らす 回数を減らす 1. 行を減らす 1-1. パーティショニング BigQueryのコスト削減でまず初めにやることではないでしょうか。WHERE句にパーティション列を指定することで、読み込まれるデータを特定のパーティションのみに絞ることができます。 1-2. クラスタリング パーティショニングと似ている機能ですが、クラスタリングの方が制限が緩いため適用できるケースは多いです。しかし、制限が緩い分、効果が弱かったり扱いが難しかったりします。例えば、クラスタ化列は最大で4つまで設定できるのですが、その順番がパフォーマンスに影響を与えます。つまり、 CLUSTER BY column1, column2; と  CLUSTER BY column2, column1; でパフォーマンスが変わります。その他にも気にすべき点がいくつかあるので、公式ドキュメントの ベストプラクティス は一読することをお勧めします。 2. 列を減らす 2-1. 必要な列だけを SELECT する これは単純で、SELECT する列を減らせば、スキャン量は減ります。 2-2. 検索インデックス 非構造化テキストおよび半構造化JSONのカラムは巨大になりがちですが、検索インデックスを貼ることで、特定のデータを検索・抽出する際のスキャン量を減らすことができます。 3. 回数を減らす 3-1. サマリテーブルを利用する 頻繁に実行されるクエリあるいはビューは、集計結果をサマリテーブルとして保存することで効率化できます。たとえば、データ量の多い元テーブルを集計するビューが高頻度で参照されている場合、そのビューを実体化することで元テーブルが参照される回数を削減できます。 3-2. 要件の調整 当たり前ですが、高頻度で定期実行されているクエリなどは、頻度を落とすことができないかステークホルダーと調整することも大事です。 巨大JSONカラムをもつテーブルを最適化し、サービスレベルを下げずにクエリコストを90%カットした話 次に、CADDiで実際に取り組んだ施策を紹介します リアルタイムでFirestoreと連携しているテーブルがあったのですが、このテーブルを参照するクエリのコストが月額約2,600ドルにも達しており、問題となっていました。 詳細は省きますが、このテーブルは、 Stream Firestore to BigQuery という拡張機能を使ってBigQueryに連携されており、Firestoreのdocument作成や更新などの変更履歴をBigQueryの送信するという仕組みでした。送信されるデータには、更新があったdocumentのidとtimestamp、そして更新後の全データがdataというカラムにJSON形式で格納されています。そのため、documet_idに対して最新のtimestampを抽出すれば、Firestoreのデータを再現できるというものでした。 送信される変更履歴データの簡易イメージ このテーブルはtimestampカラムでパーティションが切られていましたが、実際の利用ケースの多くではcreated_atを基準にフィルタリングされていました。そのため、スキャンする行を効率的に減らすにはcreated_atでパーティションを分割する必要がありますが、created_atはdataカラムのJSON内に含まれているため設定することができません。 また、すべてのデータがdataカラムのJSONに押し込まれているため、JSONの一部だけを利用しようとしても膨大な量のデータがスキャンされてしまいます。 そのため、実際に参照したいデータは少量にもかかわらず、毎回ほぼ全件スキャンされてました。 これだけでも十分に曲者なのですが、さらにユーザからの要求でニアリアルタイムでの連携が必要でした。 課題を整理すると以下になります。 有効なパーティションを設定できない 巨大なJSONカラム ニアリアルタイム連携 このような巨大なJSONカラムをもつテーブルはFirestoreのようなNoSQLや監査ログのデータでよく見かけるので、似たような問題に直面している方も多いのではないでしょうか。 やったこと 最終的にやったことはこんな感じです。上の3つの方針を全部盛です。 行った施策のイメージ ポイントは2つです 前日までのデータに関しては、JSONのパースやパーティションニングの設定を行った最適化テーブルを作成することで、パーティション分割やカラムでの絞り込みよるスキャン量削減を可能にしました。 直近のデータのビューと union することにより、ニアリアルタイムの要件に対応しました。 dbt で表現するとこんな感じです。 dbt model の Linage Raw table version : 2 sources : - name : your_dataset tables : - name : raw_table columns : - name : timestamp description : "Partition Key" - name : document_id description : "" - name : data description : "" Current View {{ config( materialized= " view " , ) }} select json_value(data, " $.job_id " ) as job_id, json_value(data, " $.status " ) as status, json_value(data, " $.created_at " ) as created_at, json_value(data, " $.updated_at " ) as updated_at, from {{ source( " your_dataset " , " raw_table " ) }} where timestamp >= timestamp_sub( current_timestamp (), interval 1 day) Optimized Table {{ config( materialized= " incremental " , incremental_strategy= " merge " , unique_key=[ " job_id " ], partition_by={ " field " : " created_at " , " data_type " : " timestamp " , " granularity " : " day " , }, cluster_by=[ " created_at " ], ) }} select json_value(data, " $.job_id " ) as job_id, json_value(data, " $.status " ) as status, json_value(data, " $.created_at " ) as created_at, json_value(data, " $.updated_at " ) as updated_at, from {{ source( " your_dataset " , " raw_table " ) }} where true {% if (is_incremental()) %} and timestamp >= timestamp_sub( current_timestamp (), interval 7 day) {% endif %} union_view with union_all as ( select job_id, status, created_at, updated_at from {{ ref( " optimized_table " ) }} union all select job_id, status, created_at, updated_at from {{ ref( " current_view " ) }} ), latest_data as ( select * from union_all qualify row_number() over ( partition by job_id, created_at -- パーティションニングやクラスタリングを効かせるために created_at も必要 order by updated_at desc ) = 1 ) select job_id, status, created_at, updated_at from latest_data 実際には、optimized_tableとcurrent_viewの加工ロジックを共通化するための中間ビューをなどがあったりしますが、おおむねこんな感じです。 この改修により、サービスレベルは落とさず、毎月2,600ドルかかっていたものを240ドルまで削減できました。 まとめ この記事では、BigQueryのクエリコスト削減に関する3つの基本方針と、効果が高かった具体的な施策事例をご紹介しました。 dbtなどのツールの普及により、テーブル作成のハードルは大幅に下がり、現在ではさまざまなバックグラウンドを持つ方々がdbtモデルの開発に積極的に携わってくださっています。これは非常に良いことですが、その一方で、非効率なテーブルやクエリが増えることでコストがかさむリスクも生じています。 こうした背景のなかで、クエリコスト削減のための体系的な知見が十分に共有されていないと感じたことが、この記事を書いたきっかけです。 みなさまの参考なれば幸いです。 おまけ あとから知ったのですが、こういうデータモデリングを lambda architecture と呼ぶそうです。
この記事は CADDi プロダクトチーム Advent Calendar 2024 の2日目の記事です。 Tech チームの前多です。個人的なことですが この一年で10kgほど減量しました。体重や食事量、活動量の記録と可視化を行って調整してきた結果だと思います。 人生もシステムもオブザーバビリティが大事ですね。 CADDi の技術スタックは 採用サイト でも紹介されている通り、TypeScript, Python, Rustの比率が高めです。 一方でプロダクトや組織の拡大に合わせて、生産性の高い技術スタックの検討も行っています。 JavaおよびJVM関連技術も検討候補の一つで、私も3,4年ぶりくらいにJavaやQuarkusを書いたりしています。 今回はその中でも私の最近のお気に入りである Apache Camel で、マイクロサービス間のデータ連携の信頼性を高める方法について書きます。 この記事を読むにあたり、以下についてはある程度知っていることを前提としています。 Java Quarkus Saga Patternなどマイクロサービスのデータ整合性を担保する設計 最後の項については、 以下の記事がとても参考になると思います。 blog.smartbank.co.jp Apache Camel について Apache Camel は多数のサービスとの連携を行うライブラリで、 HTTP, FTP, RDBMS, メッセージングプロトコル(JMS, Kafka, ActiveMQ, SQS, Cloud Pub/Sub,ほか)などの多数の対応プロトコルを相互変換して入出力できます。 例えば Cloud Pub/Subのメッセージを受け取って、何かの処理をして、別のCloud Pub/Subへ送る、といったような処理が DSLで記載できます。 また、送信相手が Cloud Pub/Sub から Kafkaに変更になった場合でもDSLの修正だけで対応できるので、 連携先の通信方式に合わせてデータを変換するといったような処理をほとんど書く必要がなくなります。 私は今まで Apache Camelをあまり使ってきませんでたが、知れば知るほど便利なやつだなと思うようになりました。 今まで使ってこなかった理由は、抽象度が高いこと(連携先が固定なら直接通信ライブラリを使った方が実装が早いし、様々な技術を下敷きに構成されている技術は学習コストが高い)や、日本語情報の少なさ、ドキュメントが少々見づらいことなどがあるのかなと思いました。 また、Apache Camelが ESB(Enterprise Service Bus)のような中央集権型のメッセージサーバーと思っていたことも理由かも知れません。 ですが、Apache Camelはあくまでライブラリでありサーバー用途で使えるような永続化機能は持っていません。 むしろ、各プロセスにプロセス固有のサービス間連携処理を組み込む方が適しています。 Apache Camel の Saga EIPについて 元々、Apache Camel を試してみようと思ったのは、マイクロサービス間のデータ連携の信頼性を上げる方法を探していからでした。 その方法の一つとして Saga Patternが有用なのはわかるが、 Saga Pattern をちゃんとサポートしているライブラリやフレームワークあるんだろうか、 というところに Apache Camel の Saga EIP を見つけました。 見つけたのはいいのですが、私たちが普段使っている Cloud Pub/Subと連携したサンプルがなかったので、ちょっと試してみようというのが今回の記事です。 Saga EIP を試すにあたり、前提知識として必要となるのが MicroProfile LRA(Long Running Action です。 MicroProfile LRA について MicroProfile LRA は、Saga トランザクションを実現するための仕様で、詳細についてはこちらの記事を見てもらうのが良いでしょう。 speakerdeck.com 資料にもある通り、 LRA Coordinator と呼ばれるトランザクションの管理サーバーと、Participants と呼ばれるトランザクションに参加する各サービスが協調してSaga トランザクションを構成します。 最初のParticipants は Coordinator にトランザクションを開始します。その際に、自身のCompensation(補償トランザクション)のURL も登録します。 Coordinatorは トランザクションIDになるヘッダーを発行するので、その値を 次のParticipantsに引き継ぐことで分散トランザクションを構築していきます。 どこかの Participants がエラーを報告した場合、Coordinator はそのトランザクションに紐づく全ての Participantsの補償トランザクションのURLを呼び出します。 Participantsが適切に補償トランザクションを実装することで、そのトランザクションの処理は取り消されることにになります。 MicroProfile LRA は Coordinator の仕様のほか、Participants が実装するべきアノテーションを定めています。 ただし、このアノテーションはJakarta RESTful Web Services(JAX-RS) に基づくので、その他のフレームワークやJava以外の言語では使用しづらいです。 そこで Apache Camel の Saga EIP の出番です。 Saga EIP は Camel 側で MicroProfile LRA に関する処理を実行してくれます。 そのため、任意の処理に対して Saga トランザクションを後付けで追加できます。 いろんな言語で書かれたサービスを Saga トランザクションに対応させるのにもってこいではないでしょうか。 LRA Coordinator の実装について 解説した通り、 LRA Coordinator が Camel Saga EIPを実現する上で欠かせない要素になります。 LRA Coordinator の選択肢としては次のものがあります。 Narayana LRA https://www.narayana.io/lra/ www.narayana.io Narayana 自体は JTAの参照実装を提供しているプロジェクトですが、 LRA Coordinator の参照実装(おそらく)も提供しています。 Quarkus に組み込んで実行できる Extension や Docker イメージ もあります。 今回のサンプルコードではDocker イメージを使用しています。 MicroTx oracle-japan.github.io Oracle提供で、有償版の提供もある Coordinator 実装。XAやTCCといった異なる種類の分散トランザクションも扱えるほか、 有償版の場合可用性サポートがある。無償版の場合、時間あたりのトランザクション実行数の上限がある。 その他 テスト用にオンメモリで動く実装が、Camelや Helidonで提供されているようです。 他にも何かあれば教えてください。 所感 現状、Coordinator の選択肢が少ないことが懸念だと感じています。 参照実装のNarayana LRAでも申し分ないのですが、こちらはトランザクションのデータをオンメモリで管理しているので、可用性での不安が残ります。 もし、Saga EIP を全面的に採用するとなったら、独自実装や拡張で可用性を確保しにいく必要があると感じています。 サンプル実行 ここまでくれば、実装の前提知識は整いました。 簡単なサンプルで、試してみましょう。 今回解説するソースコードの全量は https://github.com/kencharos/welcome-camel-saga にあります。 今回のサンプルでは、2つのマイクロサービスと LRA Coordinatorがあり、2つのサービスは Cloud Pub/Subを通してデータ連携をします。 Cloud Pub/Subとの連携、LRA Coordinator とのやり取り、補償トランザクションの実行制御は Apache Camel で行います。 実装を簡単にするため、二つのサービスは同一の Quakrus アプリケーションとしていますが、仕組み的にプロセスを分離しても動くはずです。 いくつか実装上のポイントを解説していきます。 Camel Sagaの設定 Camel Contextに Saga EIPで使用する Sagaサービスの実装を設定します。 Quarkus camel では設定ファイル経由の設定ができないので、以下のような初期化コードを作成します。 LRASagaService はLRA Coordinator とやり取りするクラスで、CoordinatorのURL(今回は Docker コンテナで Narayana RLAを用いる)と、ParticipantのURL(自身のサービスのURL, Coordinator が補償トランザクションを呼び出すために用いるので、Coordinatorから解決可能なもの) を設定します。 @ApplicationScoped public class CamelSagaConfig { public void onComponentAdd( @Observes ComponentAddEvent event) throws Exception { if (event.getContext().hasService(LRASagaService. class ) == null ) { LRASagaService service = new LRASagaService(); // LRA Coordinator(トランザクションマネージャ)のURL service.setCoordinatorUrl( "http://localhost:8081" ); /* LRA Coordinator に伝える、自身のアプリケーションのURL。 Saga トランザクション参加の際にこのURLを伝え、 何らかのエラーで補償トランザクションを実行する場合にCoordinatorがこのURLで呼び出す。 Coordinatorは今回はDockerで実行しているので、Dockerから解決可能なホストアドレス(host.docker.internal)にしている。 */ service.setLocalParticipantUrl( "http://host.docker.internal:8080" ); event.getContext().addService(service); } } } サービスの実装 各サービスで実行する処理は今回は QuarkusアプリケーションのCDI Beanで、Camelから呼び出されます。 最初のサービスでは乱数を設定して次のサービスへの入力値にします。 CDI Beanではメッセージング処理などは実装せず、Camel に任せる形です。 transactionContext は LRA Coordinator が発行したトランザクションのIDに相当するもので、これでどのトランザクションの処理であるかを識別できますので、 実際にデータを処理する場合はこのIDと紐付け可能にしておくと良いでしょう。 cancel メソッドは 補償トランザクションとして実行する処理にあたります。今回はサンプルのためログ出力するだけにしておきますが、実際には何かしら登録されたデータを消すなどの処理を書きます。 @ApplicationScoped @Named ( "ServiceA" ) public class ServiceA { private final SecureRandom rnd = new SecureRandom(); public InputB perform(String transactionContext, InputA body) { var price = rnd.nextInt( 1000 ); System.out.printf( "ServiceA: LRA-transaction(%s), %s issue price %d \n " , transactionContext, body.title(), price); // do persist process return new InputB(body.title(), price); } public void cancel(String transactionContext) { System.out.printf( " \" ServiceA: LRA-transaction(%s) do cancel.. \n " , transactionContext); // do cancel process } } 続いて、後続のサービスの処理です。補償トランザクションを発生させるために、前のサービスで発行した乱数の値によってエラーを発生させるようにしています。 @ApplicationScoped @Named ( "ServiceB" ) public class ServiceB { public void perform(String transactionContext, InputB body) { System.out.printf( "ServiceB: LRA-transaction(%s), %s price %d \n " , transactionContext, body.title(), body.price()); if (body.price() < 500 ) { throw new IllegalStateException( "price must greater 500 transaction(" + transactionContext + ")" ); } // do persist } public void cancel(String transactionContext) { System.out.printf( " \" ServiceB: LRA-transaction(%s) do cancel.. \n " , transactionContext); // do cancel process } } Camel Route 定義 ここからが、今回の中核の Camel のRoute 定義です。 まずは最初のサービスのRoute定義です。 @ApplicationScoped public class RouteA extends RouteBuilder { @Inject private ObjectMapper mapper; @Override public void configure() throws Exception { //@formatter:off // PubSub経由でメッセージを受け取り、Saga transactionを開始する from( "google-pubsub:{{app.pubsub_project}}:service-a-subscription" ) .routeId( "service-a-start-saga" ) .log( "saga start with ${headers} | ${body}" ) .unmarshal( new JacksonDataFormat(InputA. class )) .saga() // Saga transactionの設定 /* トランザクション制御をマニュアルに設定する。 https://camel.apache.org/components/4.4.x/eips/saga-eip.html#_using_manual_completion_advanced AUTOの場合、このルートが終了するとtransactionが自動で完了する。 PubSubなど非同期通信で別のルートが起動する場合だとAUTOは使用できない。 一方で、https://github.com/apache/camel-spring-boot-examples/blob/main/saga/readme.adoc のサンプルの場合、JMSを使っているがJMSの応答を同期処理的に待ち合わせる replyの仕組みが使えるので利用する技術が同期応答可能ならAUTOが利用できる。 */ .completionMode(SagaCompletionMode.MANUAL) // 補償トランザクションが行われた場合のルート。どこかでエラーが起きてsaga:compensationが実行されたら、LRA Coordinator 経由で呼び出される .compensation( "direct:cancel-a" ) // variableにLRA の値を保管しておく .setVariable( "transaction_context" , header(SagaConstants.SAGA_LONG_RUNNING_ACTION)) .doTry() // サービスA処理の実行後、後続サービスをPubSub経由で呼び出す .bean(ServiceA. class , "perform(${variable.transaction_context},${body})" ) // pubsub連携する場合は、Long-Running-Actionヘッダの値を自前で送信・復元する必要がある。 // 現時点でDSLでうまく設定できないため、processで記述する .process(exchange -> { exchange.getIn().setHeader(GooglePubsubConstants.ATTRIBUTES, Map.of(SagaConstants.SAGA_LONG_RUNNING_ACTION, exchange.getVariable( "transaction_context" ))); }) .to( "google-pubsub:{{app.pubsub_project}}:service-b-topic?exchangePattern=InOnly" ) .endDoTry() .doCatch(Exception. class ) // 処理中の例外は saga:compensate を呼び出して補償トランザクションの実行を指示する。 .log( "error on serviceA ${exception}" ) .to( "saga:compensate" ) .end() .end(); //@formatter:on from( "direct:cancel-a" ) .routeId( "service-a-cancel" ) .log( "cancel service a with body=${body}, headers=${headers}" ) .bean(ServiceA. class , "cancel(${header." + SagaConstants.SAGA_LONG_RUNNING_ACTION + "})" ); } } from で Cloud PubSubのsubscriptionからメッセージを受け取って処理を開始します。 その後 Saga トランザクションの設定に入っていきます。 Pub/Sub, Kafkaなどの非同期処理と Saga を組み合わせる場合 .completionMode(SagaCompletionMode.MANUAL) の設定が重要です。 これは Sagaトランザクションの補償・完了の制御を手動で行う必要があり、正常終了する場合には saga:completion ルートを指示します。 逆に SagaCompletionMode.AUTO の場合は現在のルートが終了したら自動で トランザクションの完了が呼び出されます。 これが使用できるケースは、Camel routeから実行する処理が全て同期で完結する場合です。 例えば、Javaの任意の処理やHTTP通信の完了を待ち合わせる場合や、メッセージングであっても応答の待機ができる場合です。 私が参考にしていた こちらのサンプル でも、 JMSの replyTo の仕組みを使って応答の待機をしていました(参考になったと同時に、大いに混乱した点でもあります) 次に大事なのは補償トランザクションが行われる際のルートを .compensation("direct:cancel-a") のように指定しておく点です。 このルートは、エラー発生時に .to("saga:compensate") のように補償トランザクションの指示が行われた時に、 LRA Coodinator 経由で呼び出されます。 もう一つ大事な点は、 LRA トランザクションIDを後続のサービスに伝播させることです。 この値は、 SagaConstants.SAGA_LONG_RUNNING_ACTION という名前のヘッダに入っています。 この値を取得して、 メッセージ連携の属性に乗せる必要があります。 以下のように、 Cloud Pub/Sub では Attribute にこの値を設定して次のサービス用のトピックにメッセージを送っています。 // LRA トランザクションID の取得 .setVariable( "transaction_context" , header(SagaConstants.SAGA_LONG_RUNNING_ACTION)) .doTry() // サービスA処理の実行後、後続サービスをPubSub経由で呼び出す .bean(ServiceA. class , "perform(${variable.transaction_context},${body})" ) // LRA トランザクションID を Cloud Pub/SubのAttributeに埋め込む。 .process(exchange -> { exchange.getIn().setHeader(GooglePubsubConstants.ATTRIBUTES, Map.of(SagaConstants.SAGA_LONG_RUNNING_ACTION, exchange.getVariable( "transaction_context" ))); }) .to( "google-pubsub:{{app.pubsub_project}}:service-b-topic?exchangePattern=InOnly" ) 次に、後続のサービスのRoute 定義を見てみます。 @ApplicationScoped public class RouteB extends RouteBuilder { @Inject private ObjectMapper mapper; @Override public void configure() throws Exception { // pubsubの開始。 // saga transactionを復元するため、PubSub Attributeから LRA 値を取得して所定のヘッダに設定してから Saga を開始する from( "google-pubsub:{{app.pubsub_project}}:service-b-subscription" ) .routeId( "service-b-continue-saga" ) .setHeader(SagaConstants.SAGA_LONG_RUNNING_ACTION, simple( "${headers." + GooglePubsubConstants.ATTRIBUTES + "[" + SagaConstants.SAGA_LONG_RUNNING_ACTION + "]}" )) .to( "direct:service-b" ); //@formatter:off from( "direct:service-b" ) .log( "saga start with ${headers} | ${body}" ) .unmarshal( new JacksonDataFormat(InputB. class )) .saga() // Saga transactionの設定 // 開始済みのSaga transactionに合流することを必須にする .propagation(SagaPropagation.MANDATORY) .completionMode(SagaCompletionMode.MANUAL) .compensation( "direct:cancel-b" ) // LRA Transactionの値を控える .setVariable( "transaction_context" , header(SagaConstants.SAGA_LONG_RUNNING_ACTION)) .doTry() .bean(ServiceB. class , "perform(${variable.transaction_context},${body})" ) // 処理が終わったら、Sagaトランザクションを完了させる .to( "saga:complete" ) .log( "saga transaction complete success" ) .endDoTry() .doCatch(Exception. class ) // ルート内のエラーで、トランザクション失敗通知 .log( "error on serviceB ${exception}" ) .to( "saga:compensate" ) .end() .end(); //@formatter:on // RLA Coordinator の補償処理から実行されるキャンセル処理 from( "direct:cancel-b" ) .routeId( "service-b-cancel" ) .log( "cancel service b with body=${body}, headers=${headers}" ) .bean(ServiceB. class , "cancel(${header." + SagaConstants.SAGA_LONG_RUNNING_ACTION + "})" ); } } Sagaトランザクションを復元するには、 SagaConstants.SAGA_LONG_RUNNING_ACTION の名前のヘッダにトランザクションIDを設定します。 前回とは逆に Cloud Pub/Sub の AttributeからトランザクションID を取り出してヘッダに設定します。 後続のサービスは 既存のSagaトランザクションに合流することを必須とするため、 .propagation(SagaPropagation.MANDATORY) を設定します。 これは有効な LRA トランザクションIDがない場合は処理が失敗します。 CDI Beanの処理が終わったら .to("saga:complete") で トランザクションを完了させることを忘れないようにします。 これで、解説は終了です。 次にサンプルを動作させます。 サンプルコードの実行 docker compose, quarkus app を起動し、次のようなコマンドでCloud Pub/Sub エミュレータにメッセージを送信します。 DATA=$(echo '{"title":"test"}' | base64) curl -i -XPOST \ -H "Content-Type:application/json" \ -d "{\"messages\":[{\"data\": \"${DATA}\"}]}" \ http://localhost:8681/v1/projects/pj-local/topics/service-a-topic:publish 以下はトランザクションが成功する場合のログです。 # Camel Route Aのログ 2024-11-26 10:29:57,783 INFO [RouteA:29] (ForkJoinPool.commonPool-worker-4) saga start with {CamelGooglePubsubAttributes={}, CamelGooglePubsubMessageId=1, CamelGooglePubsubPublishTime=seconds: 1732584597 , Long-Running-Action=http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_2} | {"title":"test"} # サービスAでの処理 ServiceA: LRA-transaction(http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_2), test issue price 689 # Camel Route Bのログ 2024-11-26 10:29:57,932 INFO [RouteB:37] (ForkJoinPool.commonPool-worker-4) saga start with {CamelGooglePubsubAttributes={Long-Running-Action=http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_2}, CamelGooglePubsubMessageId=2, CamelGooglePubsubPublishTime=seconds: 1732584597 , Long-Running-Action=http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_2} | {"title":"test","price":689} # サービスBでの処理 ServiceB: LRA-transaction(http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_2), test price 689 # Saga Transaction完了ログ 2024-11-26 10:29:58,178 INFO [RouteB:50] (ForkJoinPool.commonPool-worker-8) saga transaction complete success http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_2 が LRA トランザクションIDで同じ値が各サービスに渡り、正常にトランザクションが完了しています。 次にエラー時のログです。 # Camel route A 2024-11-26 10:32:49,907 INFO [RouteA:29] (ForkJoinPool.commonPool-worker-2) saga start with {CamelGooglePubsubAttributes={}, CamelGooglePubsubMessageId=7, CamelGooglePubsubPublishTime=seconds: 1732584769 , Long-Running-Action=http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_1b} | {"title":"test"} # サービスAの処理。エラーとなる値が生成された。 ServiceA: LRA-transaction(http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_1b), test issue price 406 # Camel route B 2024-11-26 10:32:49,959 INFO [RouteB:37] (ForkJoinPool.commonPool-worker-2) saga start with {CamelGooglePubsubAttributes={Long-Running-Action=http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_1b}, CamelGooglePubsubMessageId=8, CamelGooglePubsubPublishTime=seconds: 1732584769 , Long-Running-Action=http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_1b} | {"title":"test","price":406} # サービスBの処理 ServiceB: LRA-transaction(http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_1b), test price 406 # Camel Route Bのエラーハンドラ, saga:compensate の呼び出し。 2024-11-26 10:32:49,963 INFO [RouteB:54] (ForkJoinPool.commonPool-worker-2) error on serviceB java.lang.IllegalStateException: price must greater 500 transaction(http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_1b) # Camel Route A cancel route 2024-11-26 10:32:49,979 INFO [RouteA:65] (vert.x-worker-thread-1) cancel service a with body=, headers={Camel-Saga-Compensate=direct://cancel-a, CamelHttpMethod=PUT, CamelHttpPath=/lra-participant/compensate, CamelHttpQuery=Camel-Saga-Compensate=direct://cancel-a, CamelHttpRawQuery=Camel-Saga-Compensate=direct://cancel-a, CamelHttpUri=/lra-participant/compensate?Camel-Saga-Compensate=direct://cancel-a, CamelHttpUrl=http://host.docker.internal:8080/lra-participant/compensate?Camel-Saga-Compensate=direct://cancel-a, CamelVertxPlatformHttpLocalAddress=127.0.0.1:8080, CamelVertxPlatformHttpRemoteAddress=127.0.0.1:61640, content-length=0, Content-Type=text/plain, host=host.docker.internal:8080, Long-Running-Action=http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_1b, Long-Running-Action-Recovery=http://localhost:8081/lra-coordinator/recoveryhttp%3A%2F%2Flocalhost%3A8081%2Flra-coordinator%2F0_ffffac160002_8191_67452495_1b/0_ffffac160002_8191_67452495_1d, Narayana-LRA-Participant-Data=, User-Agent=Quarkus REST Client} # サービス A cancel "ServiceA: LRA-transaction(http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_1b) do cancel.. # Camel Route B cancel route 2024-11-26 10:32:49,989 INFO [RouteB:63] (vert.x-worker-thread-1) cancel service b with body=, headers={Camel-Saga-Compensate=direct://cancel-b, CamelHttpMethod=PUT, CamelHttpPath=/lra-participant/compensate, CamelHttpQuery=Camel-Saga-Compensate=direct://cancel-b, CamelHttpRawQuery=Camel-Saga-Compensate=direct://cancel-b, CamelHttpUri=/lra-participant/compensate?Camel-Saga-Compensate=direct://cancel-b, CamelHttpUrl=http://host.docker.internal:8080/lra-participant/compensate?Camel-Saga-Compensate=direct://cancel-b, CamelVertxPlatformHttpLocalAddress=127.0.0.1:8080, CamelVertxPlatformHttpRemoteAddress=127.0.0.1:61641, content-length=0, Content-Type=text/plain, host=host.docker.internal:8080, Long-Running-Action=http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_1b, Long-Running-Action-Recovery=http://localhost:8081/lra-coordinator/recoveryhttp%3A%2F%2Flocalhost%3A8081%2Flra-coordinator%2F0_ffffac160002_8191_67452495_1b/0_ffffac160002_8191_67452495_1f, Narayana-LRA-Participant-Data=, User-Agent=Quarkus REST Client} # サービスB cancel "ServiceB: LRA-transaction(http://localhost:8081/lra-coordinator/0_ffffac160002_8191_67452495_1b) do cancel.. 後続のサービスでエラーが起きて、 saga:compensate が指定された後、両方のサービスの canel 呼び出しが行われています。 この通り、複数のマイクロサービスを横断する処理でどこかでエラーが起きたら、横断して各サービスの取り消し処理を呼び出せるのが Saga トランザクションの強みです。 まとめ いかがだったでしょうか。 Camel を用いることで、制御が難しい Saga トランザクションがそこそこ実現できそうだなと思ってもらえたら幸いです。 同僚やまだ見知らぬ誰かにSaga やりたいと思った時に思い出してもらえれば嬉しいです。 Camel Routeの呼び出し先を、HTTPなどにすることで Java以外の言語で書かれた処理でも Saga トランザクションに参加できます。 Camel Routeの修正だけでメッセージ送信の実装を切り替えたり、ログを保存しておくといったことも柔軟にできます。 Camel Route をサイドカー的に差し込むようなことが基盤側で提供するなどの Saga トランザクションプラットフォームみたいな未来が描けたらワクワクしますね。
こんにちは、CADDiの図面解析チームで機械学習エンジニアをしている宇佐見です。 このブログでは先日行われたチーム合宿について書かせて頂きます。 はじめに 全体の流れ D&A戦略理解 & チーム説明 マシュマロチャレンジ ハッカソン 最後に はじめに CADDiは「製造業AIデータプラットフォームCADDi」を提供しています。 私が所属するData&Analysis部(以下、D&A)は文字通り集約されたデータを分析するのがメインの業務です。 Drawerに集まるあらゆるデータを対象とはしていますが、私が所属するチームは図面データ活用クラウドであるCADDi Drawerというプロダクトに集まった図面解析を主な業務としていて、図面の形状解析や記載されている情報の抽出等を機械学習を使って行っています。 自分は8月に入社したばかりではありますが、早速大量の図面データを扱う機会を得て、その奥深さと必要性をひしひしと感じています。 これまでメーカー→AIスタートアップと働いてきて、一貫して画像処理、データ分析等を行ってきましたが、CADDiに来て感じたのは図面データの重要性のみならず、CADDi内に図面に対してのドメイン知識が豊富にある点です。 Drawerに統合されたManufacturing事業にて、図面を書いたり読んだりしていたメンバーがPdMとして活動していて、かなりのドメイン知識がエンジニアにも共有されています。それにより、開発の方向性に自信を持って開発ができていると感じています。 またPdM自体もチームとしてしっかり存在していて、エンジニアが開発に集中できる環境を作ってもらえています。 良し悪しはあると思いますが、これまでは自分が前に立ってお客様と折衝したり、セールス向けに資料を作ったりしていたりという活動に時間を使っている部分はあったので、そういう点では恵まれていると思います。 とはいえ、あまりに受け身だと限られた情報しかもらえないので、気になったことは積極的にPdMとコミュニケーションを取る必要はあります。コミュニケーションに関しては、皆さん気兼ねなく話せて、ちゃんと返してくれる方々ばかりなので、こちらからコミュニケーションを取ることにも心理的障壁を感じたこともありませんでした。 またMLOpsチームが管理、運営している解析基盤もしっかりと作られていて、慣れが必要ではありますが、モデル開発した後にどういうデプロイをすれば良いのかも明確なのはありがたいです。 さて、そんなD&Aはミッションとしては以下のようなものを挙げています。 D&Aのミッション 私は結構このミッションが好きで、「非連続」というCADDiっぽいストレッチした目標を表しつつ、1年後にはその非連続的な変化を日常的に使われる「スタンダード」にするという部分が、高い技術を追いつつもお客様に価値を提供しようというスタートアップらしさをよく表していると思っています。 さて、そんなD&Aが開発したデータ解析技術が使われているCADDi Drawerはありがたいことに使っていただくお客様がどんどん増加しています。それに伴いデータの量や、扱いたいデータの種類が増え、D&Aが行うべき業務も重要性が増していっています。 これを受け、D&Aは新規採用や部署異動も含め、直近で大きく人員が増加しています。また正確にはD&Aではないものの、機械学習を使いうるサービスを開発しているチームや、今後取り入れる技術探索するチームもあったりして、D&Aが関わる人たちはどんどん増えていっています。 人員の増加は喜ばしいことではある一方、今までお互いにコミュニケーションを取ったことが少ないメンバーも増えてきました。私自信、8月入社ということもありまだまだコミュニケーションを取ったことのないメンバーも多いです。 そこで、D&A全体でお互いのことを知り合うための合宿を行うこととなりました。CADDiは10月が期初のため、ちょうど2025年度のスタートとしてもぴったりです。 このブログでは、合宿でどのようなことを行ったかについて紹介します。 全体の流れ 合宿全体としては以下のような流れでした 合宿のタイムライン D&A戦略理解 & チーム説明 集中力があるうちに初めは真面目なプログラムから。 まずはD&A全体の戦略理解です。CADDi全体としては10月期初にKickoffのイベントがあり、そこで25年度のD&Aとしてどういうことをやっていくのかという説明はありましたが、全体向けのため抽象的な部分が多めでした。今回はもう少し細かいところを掘っての共有になりました。 書いてる内容はconfidentialなので詳細は載せられないのが辛いのですが、端的にいえばCADDiはデータでこんなことができるはずで、こんな業界にも価値が届けられるはずだ、というお話です。 自分はまだ入社3ヶ月経ってないくらいなのでまだ事業理解が浅かったためどういうデータを扱えばお客様に価値を届けられるのかが提案しづらいところがあったのですが、 説明を受けて我々が扱いうるデータがどんなものがあり、こんなことで価値が提供できるはずだという未来が想像でき、非常にワクワクしました。 その後、各々のチームリーダーよりチームの説明がありました。 今D&Aは大きくAnalysis, Analysis Platform, Data Managementの3グループに分かれています。 簡単に各チームのやっていることをまとめると、 データ分析(機械学習モデル作ったり、デプロイしたり) 機械学習モデルをデプロイする基盤作り データ基盤づくり(プロダクト活用状況ダッシュボード作成、BQ使用の仕組みづくりなど) です。 ものすごく端的にいえば、機械学習エンジニアはAnalysis、MLOpsエンジニアはAnalysis Platform、データエンジニアはData Managementに所属しています。ただお互いに業務が完全に分断されているわけではなく、協力し合うところは協力してやっています。 マシュマロチャレンジ 次にチーム間の連携を高めるアクティビティとして、 マシュマロチャレンジ を行いました。 マシュマロチャレンジとは、 パスタ テープ 紐 ハサミ を使って、頂点にマシュマロが刺さった塔をなるべく高く作るというアクティビティです。 パスタ、テープ、紐は無限に使っていいものではなく、個数、長さに制限があります。 とりあえず立ててみたタワー どのチームもなるべく高いタワーを作ろうと苦心していましたが、結局8チーム中3チームしか立てられないという結果でした。 さて、これだけだとただのアクティビティなのですが、このマシュマロチャレンジが選ばれたのは1つ理由があります。 それは、このアクティビティを通じてチームで活動することでいくつかのことを学べるためです。 例えば、 考えすぎず、まずはやってみること 実際のプロジェクトでも、小さな試行を重ねることが成功の鍵である 柔軟な発想で考えること 普段の業務も「コスト」「リソース」「時間」などの制約がある中で、どうやって最大限の成果を出すかが問われている 役割を分担し協力すること PdMとEngineer などの普段の業務でも役職はあるが、オープンなコミュニケーションが重要である など、実際の業務につながる重要なヒントが隠されていました。 単純にアクティビティとしても面白かったですが、振り返ってみると高さに固執して完成品ができずに最終的に高さが0cmになってしまったり、テープが足りなくなってしまったりなど、自分の取りやすい行動がどのようなものなのかを見つめ直す良い機会だったと思います。 実際、この後のプログラムにも効いてくる部分がありました。 ハッカソン 続いてはメインイベントのハッカソンです。 テーマは明確に決まっていたのではなかったのでチームごとに色々特色があり、CADDi Drawerで扱いうるデータの活用方法であったり、LLMをローカルで複数実行してみる基盤を作ってみたり、はたまた社内Slackでの任意のユーザーのその日のメッセージのまとめを作ってくれるアプリを作ってみたりなど様々でした。 自分のチームはカスタマーサクセスの方がデータ整理をしやすくなるツールを作ることになったのですが、開発過程でたくさんの発見がありました。 まず出来上がりの方向性がずれないよう、各メンバーに絵を描いてもらいました。元々文字だけで持っていたイメージが、皆の間でズレがなかったことを確認できてよかったです。 意外とみんなでズレがなかったUI案 またチームメンバーにマネージャーの役割を持っているメンバーがいたため、その方主導でユーザーストーリーマップを作って開発を進めてみました。 ユーザーストーリーマップ 自分はこれをちゃんとやったことがなかったのですが方向性を決める手段として使いやすく、業務にも活かせそうだなと感じました。 また先ほどのマシュマロチャレンジを経て、チームのキーワードとして「マシュマロを立てよう」という言葉が自然発生していました。これにより、必ず動くものを短い単位で作るという意識が生まれ、最後の細かい修正をしている中でも、「最悪この修正が入らなくても動くものがあるからいいか」という安心感の中で開発できていました。 約1日かけて開発を終了し、各チームの発表が完了しました。 内容は前述した通り多岐に渡り非常に面白いものをみなさん開発していました。 発表中の風景 タイトルから内容は推察ください 1つ1つ説明すると長くなってしまうので割愛します。 最後に全体の総括がありましたが、そこで2年ほど前に開催されたハッカソンからは大きく変わったことがあったという話がありました。 それは全てのチームが動くものを作成するところにまで到達した、という点です。 そこには2つのポイントがあったようです。 まず、マシュマロチャレンジを行ったこと。上述の通り「マシュマロを立てよう」というワードが自分のチームで発生しましたが、他のチームも同様なようで、動くものを短い単位で作っていこうという意識づけが自然と行われていたようです。 また開発環境的な要因として、LLMの台頭とUIを容易に作成できる方法が充実してきたというのがあります。LLMによって学習なしで機械学習の恩恵を受けることができますし、チームメンバーがほぼPythonを使っているという関係で、StreamlitのようなPythonでUIを作成できるパッケージのおかげで目にみえるアプリケーションとして完成させることが容易になっています。実は自分のチームには技術リサーチがメインで、プログラミング未経験のメンバーが一人いたのですが、そのメンバーも1日のうちにStreamlitを使ったコンポーネントを開発できるようになっていて、その威力を強く感じました。 決して2年前のチームが今のチームと比べて技術的に劣っていたわけではありませんが、環境要因によりアウトプットが大きく変わるということを実感できました。 最後に 多くのアクティビティを通し、今まで喋ったことのないメンバーと喋ってお互いの理解が深まったと思います。業務上においても、コミュケーションを取ることがよりスムーズに行けるようになったと思います。また、実際の業務にも役立つような体験もあり、非常に実のある合宿でした。 実際アンケートをとると5段階評価で4以上の評価がほとんどを占めるという結果となり、参加者皆の体験も良好だったようです。 満足度アンケート さてこの合宿は総勢28名のメンバーが参加するほど、チームは大きくなってきています。しかし、それでもプロダクトの成長を考えるとまだまだメンバーが足りません。D&Aに所属するMLエンジニア、MLOpsエンジニア、データエンジニアはもちろん、その他エンジニアやデザイナーなど、様々な職種で採用を超強化中です! 「はじめに」、でも書いた通りCADDiにはインターネット上に存在しない図面というデータをお客様から預かっており、世の中に解決策が転がっていないような課題がたくさんあります。それを素早く、価値のあるものとして届けるというのは非常に面白いです。 少しでも興味を持っていただけましたら、↓の採用ページをご覧ください。ご応募もカジュアル面談も、お待ちしております! ML Engineer Machine Learning Engineer / キャディ株式会社 MLOps Engineer MLOps Engineer / キャディ株式会社 Data Engineer Data Engineer / キャディ株式会社 カジュアル面談 カジュアル面談申し込み_エンジニア・プロダクトデザイナー / キャディ株式会社
はじめまして、CADDiの図面解析チームで機械学習エンジニアをしている藤田です。 CADDiでは、CADDi Drawerという図面データ活用クラウドサービスを提供しています。私の所属するチームでは、Drawer上にある図面画像から様々な情報を抽出する、機械学習モデルを作ることに取り組んでいます。その取り組みの1つとして、Multimodal Large Language Model (MLLM)というモデルを図面画像に適用し図面解析をするということを、PoCとして実施しています。今回、MLLMの取り組みについて紹介します。 Multimodal Large Language Modelとは? openなMLLMとclosedなMLLM openなMLLMを画像に適用 画像認識AIの開発への影響 まとめ Multimodal Large Language Modelとは? 昨今、Large Language Model (LLM) の研究・開発が急激に進んでいます。例えば、OpenAI社のGPT-4oが2024年3月、Google社のGemini 1.5 Proが2024年5月、Meta社のLLaMA 3.1が2024年7月にリリースされており、各社日進月歩で開発を進めています。 そのようなLLMですが、最近のモデルは、入力・出力にテキスト以外のものを指定することができるようになっています。上記であげた、GPT-4oをはじめ、Gemini 1.5などがそれにあたります。このように、入出力を複数モーダル(text、画像、音声、動画など)にすることができるLLMのことを、Multimodal Large Language Model (MLLM)と言います。 MLLMに、画像とテキストを入力すると、画像内容をテキストで出力してくれます。例えば、キャディのロゴ画像と「この画像は何を表していますか?」のテキストを入力します。 そうすると、 この画像は、CADDi(キャディ)のロゴです。CADDiは、日本の製造業向けにデジタル化や自動化を支援する企業で、製造業の調達プロセスを効率化するためのサービスを提供しています。ロゴには、3Dのキューブが描かれており、技術とデジタル化を象徴しています。 が出力されます。このようにMLLMでは、テキストだけでなく、画像、音声、動画を解析してくれるLLMです。 openなMLLMとclosedなMLLM MLLMには、openなものとclosedなものがあります。それぞれの定義・特徴は以下の通りです。 openなMLLM closedなMLLM ソースコードが公開されているか 公開(open) 非公開(closed) 利用方法 ユーザは計算リソースを準備しMLLMのソースコードを実行し利用 ユーザはWEB APIを呼び出しMLLMを利用 例 LLaVA GPT-4o、Gemini 2024年8月現在では、openなMLLMより、closedなMLLMの方が精度高い印象で、それもあって様々な企業・サービスで使われています。ただ、近い将来、openなMLLMも精度が上がってきて、今よりも使われてくると私は予想しています。そこで本ブログでは、openなMLLMを紹介します。 closedなMLLMに対する、openなMLLMのメリット モデルのカスタム closedなMLLMでは、対象データを簡易的に学習(例:fine-tuning等)することで、対象データでの精度を向上させることは可能です。しかしながら、簡易的な学習をしても適用対象のデータで思うような結果が出ないことはあります。そのような状況でも、ソースコードが公開されていないため、コード変更をする、学習をし直すなどをして精度改良することができないです。一方で、openなMLLMではソースコード・モデルファイルが公開されているため、精度改善することが可能です。 データをサービスプロバイダに渡す必要がない 企業によっては、機密情報をMLLMに入力して活用したい場合があると思います。しかしながら、GPT-4oなどのサービスプロバイダが提供するAPI(closedなMLLM)を利用するためには、サービスプロバイダが管理しているシステムに機密情報を送信する必要があります。サービスによっては、一時的にデータを保存するようなものもあります。 一方で、openなMLLMは、自社管理のサーバ上でMLLMの推論を実行できるため、機密情報を外部管理システムにに送る必要がないです。機密情報を取り扱うような会社ではopenなMLLMの利用が進むかもしれません。 openなMLLMを画像に適用 実際にopenなMLLMを画像に適用しましょう。transformersというライブラリを使うことで簡単にMLLMを使えます。以下が サンプルコード です。以下では、LLaVA1.5というモデルを使用しています。 from PIL import Image import torch from transformers import AutoProcessor, LlavaForConditionalGeneration model_id = "llava-hf/llava-1.5-7b-hf" model = LlavaForConditionalGeneration.from_pretrained( model_id, torch_dtype=torch.float16, low_cpu_mem_usage= True , ).to( 0 ) processor = AutoProcessor.from_pretrained(model_id) conversation = [ { "role" : "user" , "content" : [ { "type" : "text" , "text" : "ここにプロンプトを書く" }, { "type" : "image" }, ], }, ] prompt = processor.apply_chat_template(conversation, add_generation_prompt= True ) raw_image = Image.open( "image.png" ) inputs = processor(images=raw_image, text=prompt, return_tensors= 'pt' ).to( 0 , torch.float16) output = model.generate(**inputs, max_new_tokens= 200 , do_sample= False ) print (processor.decode(output[ 0 ][ 2 :], skip_special_tokens= True )) 利用手順は以下の通りです。 "ここにプロンプトを書く"という部分を任意のプロンプトに変更 入力画像を準備して、"image.png"部分を準備した画像ファイルパスに変更 簡単にMLLMが使えることがわかると思います。 例として、以下のプロンプトと図面画像を使って、上記コードを実行してみました。 プロンプト:Is this a technical drawing? 入力画像 出力結果は以下になります。 Yes, this is a technical drawing, which is a detailed representation of a piece of furniture, such as a cabinet or a chair. 画像が、図面かどうかの推論を正しくできていることがわかると思います。 画像認識AIの開発への影響 前節では、MLLMを使い、画像が図面かどうかの推論する例を示しました。例のようにMLLMが正しく推論できる、画像認識系のタスクは存在します。このようなタスクに、従来のモデルではなく、MLLMを適用するメリット・デメリットがどのような点なのかを整理しました。 メリット MLLMは汎用性が高いため、追加での学習なしで正確に推論できるタスクがある。それにより、モデルの開発工程をスキップすることが可能。 従来モデルと異なり、1つのMLLMで複数のタスクを対応することができる。それにより、コストメリットが得られる場合がある。 デメリット openなMLLMを使う場合、計算リソースを使用するコストがそれなりにかかる。closedなMLLM場合でも利用料がそれなりに発生する。 従来モデルよりもMLLMは、モデルサイズが大きいため、学習するのにたくさんの計算リソースが必要。さらには学習に時間がかかる。 上記にメリット・デメリットを記載しましたが、私が特に重要だと思うメリットは、MLLMで対応できるタスクにおいては、モデル開発の工程をスキップでき、それにより専門性がなくともモデル開発をすることができることです。下図にモデル開発の流れを示しました。従来のモデル開発だと、データ収集、アノテーション、学習の工程が必要なのですが、MLLMの場合、データ収集〜学習までの工数をスキップできます。 このように、専門性が必要な工程をスキップできるので、専門性がなくともモデル開発が可能となります。これにより、機械学習エンジニアでなくともモデル開発ができますし、さらには、エンドユーザがモデル開発をするということもあり得ると考えています。そうなれば、画像認識AIの開発方法は今と大きく変わることになるでしょう。 まとめ ここまでMLLMの紹介や、MLLMを使ったモデル開発について説明してきました。もし「このタスクもMLLMで対応できるのか?」と思ったら、ぜひ上記のサンプルコードを使い試してみてください。その結果MLLMで対応できる場合、効率的にAIのモデル開発をできる可能性がありますので、積極的にMLLMを検討するのはいかがでしょうか。
SREチームの前多です。以前、Google Cloudが提供するサービスメッシュのAnthos Service Meshの入門記事を書きました。 caddi.tech この記事のまとめで私は、Istio (Anthos Service MeshのベースのOSS) を詳しく知るには、envoyのことをもっと知る必要があると書きました。 そしてサービスメッシュで何かエラーが起きているとき、それはサービスメッシュ自体ではなく インフラやアプリケーションのバグや設定ミスがサービスメッシュによってあぶり出されるということも述べました。 先日、サービスメッシュ上でPod間のgRPC通信が特定条件で失敗し、サイドカーがない場合のみ通信が成功するという事象が起きていました。 gRPCのライブラリのアップデートやIssueの調査しましたが、原因がわからずサイドカーを外すしかないかと思っていました。 最終手段として送信元PodのOutbound通信のサイドカー経由を外してみたところ、送信先のPodのサイドカーで、HTTP2のヘッダサイズが大きすぎるというエラーが出ていることを見つけました。 わかってみればなんということはなく、アプリケーションのミスで特定条件下で想定外に大きいgPRC Metadataを作っていて、OutboundのサイドカーのHTTP2クライアントでヘッダ長超過のエラーを出していたというのが原因でした。 アプリケーションでエラーハンドリングを詳細に行うか、送信元サイドカーの送信ログを出していれば、もっと早く原因がわかったかもしれません。 結局のところ、サイドカーが異常な通信をブロックしていたので、サービスメッシュ自体がバグを出してはいませんでした。 そして、サービスメッシュでも異常であることを記録しないと原因判明に時間がかかってしまうという教訓を得ました。 というわけでこの記事では、サービスメッシュ・サイドカーがおかしい場合にどのようなデバッグができるかを紹介します。 なお、弊社ではKubernetesの実行環境としてGKE, IstioについてはAnthos Service Meshを使用しています。 今回紹介する内容も弊社環境にて確認した内容となりますが、他の環境でも利用できるでしょう。 envoy サイドカー のおさらい アプリケーションコンテナとサイドカーの関係は以下の図のようになります。 envoy sidecarの構成 Istioは、各Podにenvoyをサイドカーコンテナとして挿入します。 envoyはPodの通信の入(Inbound)と出(Outbound)のどちらもサイドカー経由にします。 つまり、サービスメッシュ内の2つのPodの通信は、 PodAコンテナ -> Pod Aのサイドカー(Outboundポート) -> Pod Bのサイドカー(Inboundポート) -> Pod B コンテナ という経路を辿ります。 もう少し踏み込むと、 Inbound通信は、L4とL7のInbound Listenerが構成され、アプリケーションコンテナに通信が行きます。 (L4向けのListenerはTCP用、L7はHTTPやgRPCなどのプロトコルごとのListenerとなっていてほとんどの場合でL7が使われる) Outbound通信では、通信先ごと(サービスメッシュ内のk8sのサービスごと、および外部通信先)にClusterが構成されます。 そしてClusterごと(サービスごとに)にL4(TCP)/L7(HTTPなど)のOutbound Listenerが作られます。 最終的に、アプリケーションコンテナからの外部通信アドレスに応じてOutbount Listnerが振り分けられます。 Listenerはenvoyの待ち受けアドレスやポート、プロトコルの設定で、Clusterはenvoyの通信先に関するアドレス、ポート、プロトコル、分散方法などの設定です。詳細は envoyのアーキテクチャ をみると良いでしょう。 SaaSサービスなどサービスメッシュ外の通信は、何も設定しない場合はPassthrough Clusterというデフォルトの外部通信用クラスタとListenerが使用されます。 外部通信は通常HTTPSなので、ほとんどの場合でL4(TCP)が使われます。 ( Service Entryや Egress Gateway を使用すると、 通信先ごとにClusterを分けられるので外部通信先ごとのログやメトリクスを識別できる) このようにサイドカー内部の構成を知っておくと、これから紹介するデバッグ手法やログ、メトリクスの分類などで役に立つでしょう。 envoy admin コンソールを開く envoyは各種設定の参照や変更ができる admin コンソール があります。 Istioで挿入されるサイドカーやIngress gatewayも、adminコンソールにアクセスすれば有用な情報が得られます。 Istio envoyコンテナのadminポートは15000です( istioの使用ポート )。 このポートにport-forwardします。 例えば次のコマンドで、あるラベルのサービスのpod1つのサイドカーのadminコンソールにlocalhost:15000でアクセスできるようにします。 app=<your app> namespace=<your namesapcae> kubectl port-forward -n ${namespace} $(kubectl get pod -n ${namespace} -l app=${app} -o jsonpath="{.items[0].metadata.name}") 15000:15000 以下のようなUIが表示されると思います。 いくつかデバッグ時に役にたつメニューを紹介します。 Config Dump envoyのconfigはprotobufをスキーマとするJSON/YAMLで設定します。config dumpはその内容を全てJSONで表示するので、 この内容を読み解ければ、Istioの各種設定ファイルからどのようにenvoyが構成されるのかがわかります。 ただし、読み解くには時間がかかるでしょう。一助として envoyのhigh level アーキテクチャ が参考になると思います。 この設定により、自分で設定したIstioのマニフェストや、EnvoyFilterなどが想定通りにサイドカーに反映されているかがわかるほか、 通信プロトコルごとのタイムアウトや上限値などを把握したりします。 メトリクス statsエンドポイントは、各種メトリクスを表示します。OpenMetrics形式にも対応しています。 Istioはenvoyのメトリクスを一部カスタマイズしていますので、Istioが提供するメトリクスを使うと良いでしょう。 istio.io 様々なメトリクスがありますが、私の経験ではoutbound通信のエラー調査で使うことが多いです。 例えば、istio_requests_totalメトリクスをみると通信先、通信エラーごとのカウントがわかります。 通信先は destination_service_name などのラベルで表現されます。前述したenvoyのクラスターに相当します。 通信エラーは response_flags というラベルでわかります。 response_flagsの詳細は envoy access log のページに記載されています。 通信が切断された理由がこれで判明するので、自分か相手かどちらに原因があったのかが判断できます。 また、メトリクスについてはadminコンソールを開かなくてもPodの15020ポートでデフォルトで提供されるので、 PrometheusやDatadogなどで収集できます。 以下の図は、サイドカーの外部通信(passthrough cluster)のメトリクスをDatadogで収集して可視化している例です。 ほとんどの通信は成功しているのに加え、 response flags がDC(DownstreamConnectionTermination)およびUC(UpstreamConnectionTermination)の記録もあります。 そのためごくわずかの通信がタイムアウトで切断されているようです。 datadog ログレベルの変更 loggingエンドポイントで、envoyサイドカーのログレベルをコンポーネント単位で変更できます。デフォルトのログレベルはwarningですが、 debugに設定すると通信ごとに詳細な内容をログに出せます。 冒頭で触れた特定条件での通信エラーは、ログレベルを一時変更して再現させることで何が起きたのかが詳細に調査できるでしょう。 ログレベルの変更はコンソール上からでもできますが、port-fowardしている状態なら、curlなどでもできます。 次の例は、主に通信関連のコンポーネントについてログレベルをdebugにする例です。 curl -XPOST http://localhost:15000/logging \ -d "paths=connection:debug,grpc:debug,http:debug,http2:debug,ext_authz:debug" この状態でpodのistio-proxyコンテナ(サイドカーのコンテナ名) のログを見ると、通信ごとに次のような内容が表示されます。 kubectl logs <pod_name> istio-proxy 2024-05-13T09:04:00.103315Z debug envoy http external/envoy/source/common/http/conn_manager_impl.cc:1118 [C340][S8971455977285060032] request headers complete (end_stream=true): ':authority', '10.12.0.240:15021' ':path', '/healthz/ready' ':method', 'GET' 'user-agent', 'kube-probe/1.27' 'accept', '*/*' 'connection', 'close' thread=47 2024-05-13T09:04:00.103329Z debug envoy http external/envoy/source/common/http/conn_manager_impl.cc:1101 [C340][S8971455977285060032] request end stream thread=47 2024-05-13T09:04:00.103359Z debug envoy connection external/envoy/source/common/network/connection_impl.h:98 [C340] current connecting state: false thread=47 2024-05-13T09:04:00.104007Z debug envoy http external/envoy/source/common/http/conn_manager_impl.cc:1708 [C340][S8971455977285060032] closing connection due to connection close header thread=47 調査が終われば、ログレベルを戻します。 また、Podが再起動したりすると設定が元に戻るので、長時間の調査は注意してください。 サイドカーのアクセスログを出力する 弊社では、調査用にL7 Inbound Listenerのアクセスログを常に収集しています。 ですが、状況に応じてOutbound Listenerのログも収集できると役に立ちます。 例えば次のような EnvoyFilter リソース を使用すると、特定のPodのOutbound通信のみアクセスログを出力できます。 EnvoyFilterは、Istioが提供するマニフェストでは設定できないような細かいカスタマイズを直接envoyサイドカーに行う機能です。 利用するにあたってはenvoyの知識も必要なので、config dumpと睨めっこしながら試行錯誤すると良いでしょう。 また、アクセスログのフォーマットは envoy log format を参照してください。 --- apiVersion : networking.istio.io/v1alpha3 kind : EnvoyFilter metadata : name : sidecar-outbound-accesslog namespace : <Outboundログを出力したいPodのnamespace> # target pod workspace spec : workloadSelector : labels : app : <Outboundログを出力したいPodのlabel> # target pod label configPatches : # sample 1 L4 outbound log( 例えば 外部 HTTPアクセス) - applyTo : NETWORK_FILTER match : context : SIDECAR_OUTBOUND # outbound通信の指定 listener : filterChain : filter : name : "envoy.filters.network.tcp_proxy" #L4 Listenerは tcp filterをターゲットにする patch : operation : MERGE value : typed_config : "@type" : "type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy" access_log : - name : envoy.file_access_log typed_config : "@type" : "type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog" path : /dev/stdout typed_json_format : "time" : "%START_TIME%" "direction" : "out_l4" "response_flags" : "%RESPONSE_FLAGS%" "response_code_details" : "%RESPONSE_CODE_DETAILS%" "connection_termination_details" : "%CONNECTION_TERMINATION_DETAILS%" "bytes_received" : "%BYTES_RECEIVED%" "bytes_sent" : "%BYTES_SENT%" "duration" : "%DURATION%" "upstream_host" : "%UPSTREAM_HOST%" "upstream_cluster" : "%UPSTREAM_CLUSTER%" "upstream_local_address" : "%UPSTREAM_LOCAL_ADDRESS%" "upstream_transport_failure_reason" : "%UPSTREAM_TRANSPORT_FAILURE_REASON%" # sample 2 outbound L7通信のログ (Pod間通信) - applyTo : NETWORK_FILTER match : context : SIDECAR_OUTBOUND listener : filterChain : filter : name : "envoy.filters.network.http_connection_manager" #L7 Listenerは http connection managerをターゲットにする patch : operation : MERGE value : typed_config : "@type" : "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager" access_log : - name : envoy.file_access_log typed_config : "@type" : "type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog" path : /dev/stdout typed_json_format : # envoy default + caddi log standard "direction" : "out_l7" "time" : "%START_TIME%" "route_name" : "%ROUTE_NAME%" "status_code" : "%RESPONSE_CODE%" "response_flags" : "%RESPONSE_FLAGS%" "response_code_details" : "%RESPONSE_CODE_DETAILS%" "connection_termination_details" : "%CONNECTION_TERMINATION_DETAILS%" "bytes_received" : "%BYTES_RECEIVED%" "bytes_sent" : "%BYTES_SENT%" "duration" : "%DURATION%" "upstream_service_time" : "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%" "user_agent" : "%REQ(USER-AGENT)%" "request_id" : "%REQ(X-REQUEST-ID)%" "client_ip" : "%REQ(True-Client-IP)%" "authority" : "%REQ(:AUTHORITY)%" "upstream_host" : "%UPSTREAM_HOST%" "upstream_cluster" : "%UPSTREAM_CLUSTER%" "upstream_local_address" : "%UPSTREAM_LOCAL_ADDRESS%" "upstream_transport_failure_reason" : "%UPSTREAM_TRANSPORT_FAILURE_REASON%" "grpc_status_details" : "%GRPC_STATUS%" "grpc_status_code" : "%GRPC_STATUS_NUMBER%" "request" : "type" : "%PROTOCOL%" "method" : "%REQ(:METHOD)%" "path" : "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%" 前述した通り、Outbound通信は通信先によってL4(TCP), L7(HTTP)の区別があリます。 網羅的にログを出したい場合は、上記のように TcpFilter にも設定が必要です。 また、L4(TCP)の場合は、HTTPに関する詳細情報をログに出すことはできません。 (参考)Telemetry APIによるログ出力の設定 なお、現在ではアクセスログの出力は、 Telemetry APIのAccess Logging でも行うことができます。 筆者らがAnthos Service Meshを使用したときにはまだアルファ版だったので採用していませんでした。 今後は検証した上で、 Telemetry APIに定義を移行するかもしれません。 まとめ Istioのenvoyサイドカーのデバッグ、可視化について解説しました。 これでなんとなくサービスメッシュが悪者扱いされることがなくなることを祈っています。
こんにちは、Platform チームの @akitok_ です。 CADDi Platform チームでは、チームトポロジーの定義に基づいてストリームアラインドチームが自律的に仕事を届けられるようにするため、様々なアセットとそれに付随するドキュメントなどを提供しています。 Platform チームのミッションやその活動などについては、以下の記事などを読んでいただけますと幸いです。 なんでもやるがなんでもはやらない?CADDi の Platform チームは、何をするチームなのか? - CADDi Tech Blog あれから 1 年、Platform チームのその後 - CADDi Tech Blog 今回、Platform Engineering Kaigi 2024 というイベントで、この Platform チームを取り巻く開発者向けドキュメント改善について登壇してきました。 この記事では、イベントの雰囲気や当日資料の補足、また当日 sli.do でいただいた質問への回答などを書いていきます。 Platform Engineering Kaigi 2024 登壇セッション 感想 sli.do でいただいた質問への回答 終わりに Platform Engineering Kaigi 2024 Platform Engineering Kaigi は、現在注目を浴びている Platform Engineering をテーマにした日本初のテクノロジーカンファレンスです。その初回である今回は、docomo R&D OPEN LAB ODAIBA で開催されました。 www.cnia.io #PEK2024 現地企画紹介:フォトパネル Platform Engineering Kaigiのフォトパネルを入口横に設置しております! カンファレンス参加の記念に一枚いかがでしょうか! 📷✨ https://t.co/4KK23vEQk9 pic.twitter.com/6dQhUSKNwS — Platform Engineering Kaigi / クラウドネイティブイノベーターズ協会 (@cnia_pfem) 2024年7月8日 最終的な参加者登録者数は996名だったそうで、実際に会場は非常に活気があり、注目度の高さを感じました。 登壇セッション 当日は13:45 - 14:15 に Track A で登壇させていただきました。 www.cnia.io アーカイブはこちらから見ることができます。 www.youtube.com 資料はこちらです。 speakerdeck.com 登壇の様子1 登壇の様子2 感想 ここからは登壇者としての感想です。 私個人としてオフラインかつ大規模イベントでの登壇はなかなか久しぶりだったため、内心かなり緊張していました。 ですが、運営の方はもちろん、イベント全体としてどことなくアットホームで、開発者フレンドリーな雰囲気が作られていて、 非常に落ち着いて発表させていただくことが出来ました。主催者・運営の方はもちろん、イベントに関わったすべての皆さんに感謝です。 また、登壇直後に質問や感想を伝えに声をかけていただいたり、懇親会でドキュメントや Platform Engineering について、 それぞれ異なる会社に所属し、異なる背景を持つ中で、意見交換出来たことは本当に有意義でした。 社内で言えば Platform チームは現在4名しかおりませんが、視野を広げると国内にこんなに仲間がいるんだと思えました。ありがとうございます。 sli.do でいただいた質問への回答 ここからは、sli.do でいただいた質問に回答します。 ドキュメントを捨てるのが難しい気がしています。捨てる基準など設けてGarbage Collectしていますか? ドキュメントの廃棄、勇気がいりますよね。 私たちのチームでは、現在は Confluence のアーカイブ機能を用いて廃棄しています。 Confluence では記事のアーカイブをすると、デフォルトでは検索にヒットしなくなり、ドキュメントのサイドバーツリーにも表示されなくなりますが、URL は残ります。 このアーカイブ機能を使って、認知負荷を下げつつ、情報自体は保持するようにしています。 捨てる基準について、シンプルなルールなどは今はありません。 登壇でも説明したように、鮮度や有効性の低下傾向をトリガーに Platform チームで判断し、主に以下のようなドキュメントをアーカイブしました。 明らかに、既に非推奨・無効になっているもの 同じ目的のドキュメントが新しく誕生していて、過去に廃棄すべきタイミングで廃棄し損ねていたもの 各ドキュメントにバイネームでメンテナーを設定しているのでしょうか? 資料ではメンテナーという言葉を使いましたが、Confluence の機能でいえばオーナーです。ドキュメントを最初に作成した人が自動でオーナーになります。 Confluence ではオーナーを変更する機能があるので、鮮度チェックによるメンテナンスの中で、既にオーナーが不在であればオーナーを現在の Platform チームメンバーに変更しています。 ドキュメントシステムごとにドキュメントのオーナーや最終更新者など様々なメタデータがあると思いますので、お使いのドキュメントシステムに合わせて読み替えて頂ければと思います。 レビューポリシーについて、例えばどのようなものを設定していますか? 入社いただければ閲覧できます! というのは冗談で、今回説明時間の都合で細かく読み上げなかったのですが、資料の27スライド右側に機能品質のレビューポリシーを一部抜粋したものを表示しています。 レビューポリシーの構成としては以下のようになっています。 機能品質に対するチェックリスト 構造品質に対するチェックリスト ドキュメントタイプごとのチェックリスト 機能品質・構造品質のチェック内容としては、資料でも紹介した「ユーザーの問題解決とプロダクトの成功を導くエンジニアのためのドキュメントライティング」の Chapter 9 の内容を参考にしています。 ドキュメントタイプごとのチェック内容としては、 Documentation topic types (CTRT) | GitLab Docs を参考にしています。 ドキュメントの新規作成・レビュー・メンテナンスの各フェーズの方針には触れられていましたが、Platfrom Enginneringを効果的に提供するために「どのようなドキュメントが足りていないか」をシステマチックに検討し続ける仕組みも何か検討をされていたら共有頂けるとありがたいです。 ドキュメントが網羅的にカバー出来ているかどうか、非常に重要な観点だと考えています。 ただ、現時点で具体的なアプローチは出来ていないのが現状です。 私たちのプロダクトは急成長しながら変化も非常に多いフェーズにあります。 過剰な網羅性を求めすぎると、開発者にとっては認知負荷を増やすことにも繋がりますので、網羅性についてどこまで投資するかは悩みながら進めています。 ドキュメンテーションは重要ではありますが、活動を始めてから人を拡大していくのが難しいと考えてます。どのようにして、この活動を継続して続けてくれる人を探してますか? 今回は、Platform チームが開発者に提供するドキュメントをスコープにお話させていただきました。そのため今回のスコープに関して言えば、Platform チームメンバーの業務の中で継続的に活動しています。 実際に週30分のドキュメント改修がプロダクトのアジリティ向上にどれだけ寄与できているか計測していたりしますか? ドキュメント改善による得られる効果の計測・定量化は、現在は出来ておりません。 開発者の閲覧行動の変化(閲覧数の増減など)はアジリティ向上とはイコールではありませんし、four Keys や SPACE などいわゆる開発生産性を示す指標もドキュメント改善と結びつけて判断するのは非常に困難で、過剰な数値化によりミスリードが生まれる可能性もあると考えています。 そのため、まずは開発者とのインタビュー、アンケートなど定性的な評価を集めて、フィードバックを繰り返しいきたいと考えています。 また、週30分の活動量捻出に対する費用対効果という観点で言えば、最初はドキュメント量が多いので時間がかかってしまいますが、改善が進むにつれ毎回の作業量が減っていきますので、実施頻度や時間数を減らすことで作業コストは減らしていけると考えています。 この手のものの次にChatGPT + RAG とかもあるかなと思っているのですが、そういったものも検討されたりしていますか?(うちでも膨大な社内ドキュメントをRAGで、、みたいな話があり) 具体的に検討までは至っていないですが、開発者が情報を取り出す際に ChatBot のようなインタフェースで必要な情報を提供出来る仕組みは、チーム内のアイデアとしては話題に上がっています。 Confluence のドキュメント品質を計るための各数値はどのように取得していますか? これは Confluence API をコールして、TSV を出力するツールを実装して、対応しています。 具体的には、大きく以下3点実施しています。 開発者向けドキュメントすべてに同じラベルを付与する 私たちのチームではドキュメントのポータルページを親として、その配下にツリー構造ですべての開発者向けドキュメントが配置されています。 この構造を利用し、以下の API で、ポータルページの配下に位置するページの content id を再帰的にすべて取得します。 https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-children/#api-group-children その content id リストを基に、以下の API を用いて、取得した content すべてに特定のラベルを付与しています。 https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-labels/#api-wiki-rest-api-content-id-label-post 鮮度低下が見られるドキュメントをリストアップする 以下の API を利用して、CQL で最終更新日が6ヶ月以上前で、かつ特定のラベルがついているものという条件で、ドキュメント一覧を取り出しています。 https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-search/#api-wiki-rest-api-search-get 有効性低下が示唆されるドキュメントをリストアップする これは以下の 2 step で実施しています。 (1) 特定のラベルがついているドキュメントを以下の API を用いて、すべて取り出して、content id のリストを作る https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/#api-labels-id-pages-get (2) 以下の API に対して、contend id のリストを基に、1件1件リクエストして、Page view を取得する https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-analytics/#api-wiki-rest-api-analytics-content-contentid-views-get 終わりに 今回、Platform Engineering にまつわるドキュメントをテーマに取り扱いました。登壇の中で話した機能品質に立ち返って言えば、想定読者とゴールは以下のようなものでした。 想定読者 Platform Engineering を実践している、あるいはこれから実践しようと検討している人 ゴール 想定読者が、職場に戻ってからドキュメントの重要性を再確認し、チームで意見交換し、継続的なドキュメント改善を行う仕組み作り、きっかけ作りを始められること このような機能品質を達成するために、資料や説明上は Platform Engineering を切り口にしていますが、ドキュメント自体はどこにも溢れていて、他の分野でも社内外の情報流通を助ける非常に重要なパスで、ある程度普遍性のあるテーマでもあったとも考えています。 社内でもシェアしてくださる方もいて、データマネジメントチームやコーポレートチームからもポジティブな反応がありました。嬉しいですね。 社内の声1 社内の声2 今後も製造業の変革を進めるために、CADDi のバリューでもある「一丸」となって、みんなでより良い Platform を作っていきたいと考えると同時に、 開発者コミュニティの一員としても、より良い Platform をみんなで考えていけるように今後も関わっていきたいと考えています。
はじめまして! 機械学習チームでプロダクトマネジメントを担当している、井上といいます。 今回はCADDiにおけるアノテーションの組織づくりについて紹介します。 アノテーションについて調べると、データ生成や品質改善のノウハウはよく目にするものの、アノテーションを行う組織体制づくりに関する情報は中々見つかりません。弊社のアノテーション組織づくりがその一事例として、参考材料になればいいなと思います。 「紹介します」なんて言うとさもよくできた事例のように聞こえそうですが、実際は試行錯誤の日々です.....💦 アノテーションってなんなの? アノテーションの組織づくりが必要になる背景 CADDiのアノテーション体制 CADDiのアノテーション業務サイクル まとめ アノテーションってなんなの? アノテーションとは一体何なのか。まずは簡単に説明しようと思います。 「それくらい知っておるわい!」という方は生暖かく読み飛ばしてくださってOKです👍 アノテーションとは、データにラベルを付けてその意味を明確にする作業のことです。アノテーションされたデータは、おもに機械学習・AIをつくるための教師データとして活用されます。とくに画像・動画認識の分野において欠かせないタスクです。 ……という言葉だけ見ても分かりづらいですし、具体例をみていきましょう! ↑ここに金魚の絵があります。 これ自体はただの絵でしかなく、それが何を意味するかという情報を持っていません。このときAIにとって、この絵は一体なんであるかを理解することができません。 画像に対して “金魚” というタグを付与し、様々なパターンを学習することによって、「これは金魚の画像なんだな」とAIが判断できるようになるという仕組みです。 アノテーションは、AIの教師データを作るために欠かせない作業です。教師データがあれば、AIはデータのパターンを学び、未知のデータに対して予測や分類を行うことができます。ただし、そのためには正確なアノテーションが必要です。間違ったタグをつけてしまうと、AIは誤った学習をしてしまいます。 機械学習コンペなどでは大抵、ラベルがつけられたデータが用意されています。しかし、現場で機械学習を利用するときは多くの場合、ラベル付けから始めるのが実情です。 アノテーションの組織づくりが必要になる背景 自社や業界に特有のノウハウをとりいれた機械学習モデル/AIを作ろうとすると、避けて通れないのがアノテーション。 アノテーションの質はモデルの精度に大きく影響します。質の良いデータを作るためには、データ解析とドメイン知識の両面からデータを眺める必要があります。ときにはエンジニア自身がアノテーションを行うことがあったり、ドメイン知識を習得しにいくことがあったりして、手間もかかるし要求される専門性も高い。ゆえにアノテーションがAI開発のボトルネックになることは珍しくありません。 アノテーションのボトルネック化を解決する手段として、定番の一手は外部に委託すること。しかしアノテーションを委託できない場合も多いのではないでしょうか? たとえば元となるデータの表現パターンが多様なため判断基準を網羅した要件を定めるのが困難であったり、要求されるドメイン知識の専門度が高くてインストールが大変だったり、第三者に渡せないくらいにセキュリティ要件の厳しいデータがあったり。。。 そうした場合に「自社でアノテーション組織をつくろう!」となるわけですが、困ったことに、アノテーションの組織づくりについては参考となる事例がとても少ない。ドメインに深く根ざすバーティカルSaaSでは利用できるオープンデータが少なく、より一層悩みやすい問題のはずです。 体系的にまとまったものと言えば、昨年末に翻訳本が出版された『Human-in-the-Loop機械学習』くらいではないでしょうか。 結構いいお値段しますが、 希少な良著です。 www.kyoritsu-pub.co.jp CADDiのアノテーション体制 弊社のアノテーション体制は以下の構成となっています。 機械学習エンジニア プロダクトマネージャー オペレーションマネージャー アノテーター アノテーター以外の登場人物が多く、少し役割や専門性が分かりづらいかもしれませんね。これから詳しく説明していきます。まずそれぞれのスキルセットは以下の表のとおりです。各々の強いところを持ち寄ると、開発・ドメイン・オペレーションの3領域をカバーできる形になります。 機械学習エンジニア アノテーションデータを用いた機械学習モデルの開発 初期フェーズでのアノテーションルール設計(with プロダクトマネージャー) とくにデータ解析観点で、望ましいアノテーションデータの型や必要なデータ量の目安を提示する。 テクニカルな手法でアノテーションの効率改善:プリアノテーション・ツール提供等 プロダクトマネージャー 機械学習が関わるプロダクトの企画 アノテーションの優先順位や、モデルの目標精度の設定 初期フェーズでのアノテーションルール設計(with エンジニア) とくにドメイン観点で、望ましいモデルの精度やアノテーションの取得方法を提示する。 アノテーションのイレギュラーパターンへの対応や、オペレーション状況に応じたルールの微修正(with オペレーションマネージャー) オペレーションマネージャー アノテーターチームのマネジメント:評価・育成・相談受け アノテーションの業務設計・品質と生産性の改善 アノテーションのイレギュラーパターンへの対応や、オペレーション状況に応じたルールの微修正(with プロダクトマネージャー) アノテーター アノテーションの実行・報告 不明点やお困りごとの問題提起 現場視点での改善ポイント・企画の提案 連携を図に示すとこんな感じ もちろん登場人物が増えている分、コミュニケーションコストが発生します。 理想としては、機械学習の開発・ドメインに紐づく要件定義・オペレーション設計・ピープルマネジメントを満遍なくできる人がいれば最速最良のアノテーションが実現できるのですが、そんなパーフェクトヒューマンは滅多にいません。ある程度分業したほうが、再現性が高く、業務負荷的にも持続可能なアノテーション体制になると考えています。 CADDiのアノテーション業務サイクル アノテーションからモデルができるまでのサイクルは、ざっくりと下図のとおりです。 意識しているポイントは、アノテーションルール・定義の見直しを、作業を進めながら頻繁に行っている点です。望ましくは事前にヌケモレなくアノテーションルールを定義できたら良いのですが、それは製造業×図面というドメインにおいては非現実的です。我々がとりあつかう図面というのは、兎にも角にも自由な書き方がなされています。どれだけ製造業に詳しい人であっても、すべての表記パターンを前もって洗い出すことができないほどです。 そこで弊社では、表記の多様性を後工程でキャッチして軌道修正する方法を採択しています。もちろん事前になるべく表記パターンを挙げてアノテーションの方法を定めるのですが、代表的な表記やイレギュラーパターンを洗い出した時点でアノテーションを開始しています。 ただ後工程で表記の多様性をキャッチする方法は、注意して運用しなくてはなりません。 アノテーターの質問にスピーディーに解消しなければ作業が停滞する ルール改善と周知を徹底的にやらないとデータ品質が落ちる アノテーターとの信頼関係ができていないとサイクルが回らなくなる 質問回答のスピード・ルール改善の強度・疑問が解消される安心感、いずれを損ねても途端にうまくいかなくなります。こうした課題に対して、弊社では以下のような対策を打っています。 質問回答のスピードUP アノテーションツールとSlackを連携する。 質疑応答をSlack上で回答でき、管理しやすくなる。 プロダクトマネージャーやオペレーションマネージャーは、アノテーターからの質問を歓迎する。かつ、なるべくレスポンシブに回答する。(対策とも言いがたい泥臭いやり方ですが.....) ルール改善と周知の徹底 アノテーターの中で役割分担をして、一定ルールの取りまとめる人・工数を確保する。 変更点を管理するスライドを用意して日次でアノテーターに展開 + 週次で今週のハイライトを展開する。かつ、読了しているかのチェックをとって追跡する。 日次で15分程度の疑問解消会をもうける 信頼醸成 プロダクトマネージャーやオペレーションマネージャーは、アノテーターからの質問を歓迎する。かつ、なるべくレスポンシブに回答する。(本日2回目) 地道ながら、コツコツとした積み重ねが信頼貯金をつくります。 評価で報いる。人事評価基準の明示と、きちんとした報酬。 アノテーションデータを用いた成果の共有と、感謝の言葉かけ。 アノテーションは作業だけ切り取ると自己効力感を得にくいです。なるべく目に見える形で、事業成果だったり、モデルのデモだったりを提示しています。 これを見てどう感じたでしょうか? ウェットなチーム運営だなぁとか、人のケアってそんな必要なのかなぁとか、 そう感じた方もいらっしゃるのではないでしょうか? 私もそう思ってた時期がありました しかし人手によるアノテーションを行う限りは、必ずピープルマネジメントに向き合うことになります。もしアノテーターとの連携や信頼関係が崩れると、現場からのフィードバックが途絶え、離職率が上がって専門性の高い人材が育たず、作業効率も落ちます。結果として、良質なアノテーションデータを手に入れることができなくなってしまいます。 今時点ではこういった仕組みでアノテーションを回していますが、もちろんこれがベストだとは思っていません。まだまだ試行錯誤の道半ば。これからもっと良いアノテーション体制を作ってまいります! まとめ CADDiにおけるアノテーション業務について、どのような体制で取り組んでいるのかをご紹介いたしました。私達は製造業を対象としていますが、他の業界に尖ったモデル開発でも、きっと同じようにドメインとデータの交差点で悩むことが多かろうと思います。 アノテーションの組織づくりや運用体制はまだまだベストプラクティスのない分野です。必要なデータの量と質の担保のために様々な人の協力が必要でありながら、中々認知を獲得しにくい分野だとも思います。この投稿を見たどなたかがアノテーションの組織づくりに興味を持って、各々の会社で役立てていただけると嬉しい限りです! さて、最後に宣伝です。 CADDiではモデルの開発もアノテーションの改善も、やりたいことはもりだくさん!機械学習を推進してくださるエンジニアも、アノテーションを進化させていただけるオペレーション企画も募集しています。機械学習自体の開発サイクルにも興味のある方は、こちらの記事に詳しく書かれておりますので、ぜひご一読いただけますと幸いです。 これまでは2Dの図面が中心だったのですが、これからは図面以外の文書データや3Dデータも解析していきます。いろんなデータに触れられるのは、きっと面白いと思いますよ! 興味本位でお話するだけでもウェルカムなので、ぜひ気軽にご連絡ください 🙌 CADDi の ML/MLOpsエンジニアの技術やチームの紹介です! エンジニア向け採用情報 CADDi 採用情報 for Engineer/Designer 機械学習エンジニア募集要項 MLOpsエンジニア募集要項 オペレーションマネージャー採用情報 オペレーション企画(OPS generalist)募集要項
こんにちは、MLOpsチームです。先日OCRモデルを学習するためのアノテーションにおいて、作業効率を検証するためのPoCとしてアノテーションUIを開発しました。本記事ではこのアノテーションUIにおける工夫について、試用によって得られた知見をまじえつつ紹介します。 はじめに アノテーションUIを開発することとなった背景について説明します。 アノテーションUIとは アノテーションUIは機械学習の学習データを作成するためのUIです。アノテーションUIはアノテーション作業の効率に強く影響し、アノテーション作業によって得られる学習データの量は機械学習の精度に大きく寄与します。したがって、アノテーションUIは機械学習において最も重要なコンポーネントのひとつといえます。 UIを開発した背景 キャディではOSSツールなどのUIを用いてアノテーションが行われていましたが、ここに独自の工夫を導入すれば入力効率が改善できるのではないか、という仮説がありました。たとえば画像中のテキストに関するアノテーションを実施する際、すでにあるOCRモデルを利用してデータ入力を効率化できるかもしれません(詳細は後述の「テキストの自動入力」にて説明します)。 そこで、これらの工夫が実用的であるかを確かめるべく、独自のアノテーションUIのPoCを実際に作成することにしました。今回は図面中のテキストを読み出すOCRモデルの学習データを作成するためのアノテーションUIを開発しました。 OCRモデルのアノテーションUIでは次の作業ができる必要があります。 図面中に矩形領域(バウンディングボックス)を作成する バウンディングボックスに対してテキストを入力するGoogle Cloud バウンディングボックスに対してラベル(テキストの種別となるカテゴリ値)を付与する 具体的には、次のスクリーンショットのUIです。 アノテーションUIのスクリーンショット 効率的な操作を実現する工夫 アノテーション作業において効率的にデータを作成できることは至上命題です。効率的なデータ作成のための工夫について2点紹介します。 テキストの自動入力 バウンディングボックスを作成したときにOCRモデルによってテキストを推論し、自動入力する工夫です。テキスト入力の負担を大幅に減らせます。OCRモデルの学習データを作るためにOCRモデルの推論を利用することになり、ある意味でHuman-in-the-Loopのような形になります。 この機能を提供するための適切なUIはOCRモデルの推論時間によって変わります。たとえばOCRモデルの推論に長い時間がかかる場合、複数のテキスト推論をまとめてバッチ的に推論する必要があるかもしれません。その場合、内部的にキューを用意したり、まとめて推論するためのUIを作ったりすることになります。そこで、筆者らは推論にかかる時間を任意に設定できるOCRのfakeモデルを作成し、いろいろな遅延を試しながらUIの使用感を確認しました。最終的に、我々が使いうるモデルではバッチ推論ではなく、バウンディングボックス作成時に逐次推論する方式のほうが使い勝手が優れていました。 OCRモデルも間違いうる、という点にも注意が必要です。 OCRモデルが間違えやすいものは人間も間違えやすく、たとえば「O」「0」や「I」「l」などは人間も気付きづらい間違いです。フォントの選択や文字サイズなどを工夫し、より正確性を向上できそうです。 バウンディングボックスの自動スナップ バウンディングボックスをテキストにフィットする機能を実装しました。大雑把な操作で作業できるので非常に便利です。 スナップ機能:マウスでドラッグ&ドロップした範囲が破線枠で、実際にバウンディングが作成される範囲が薄紫で表示されています この機能は白黒の図面画像ならではのヒューリスティックな、かつシンプルなアルゴリズムによって実現しています。したがって、うまくいかない場面も多々あります。たとえばテキストと重なる図形があったり、スキャン時のノイズが少しでもあるとうまくいきません。しかし多くの場合で入力効率が高くなり、スナップ機能がうまくいかないケースでもスナップ機能をオフにして通常の操作で正しいバウンディングボックスを付与できます。 使いやすさを向上する工夫 作りの悪いUIは操作に不快感があります。快適な操作性を提供し、ユーザがデータの正確さに注力できることが重要です。 アクセシビリティを考慮した配色 多くの人が効率的に作業するためには、配色に注意する必要があります。開発者にとって問題ない配色がユーザにとっても同様に問題ないとは限りません。ディスプレイ・照明などの利用環境やユーザ自身の色覚特性によって、色の見分けやすさが異なるためです。さまざまな色覚特性を持つユーザが快適に作業できるために、アクセシビリティに配慮した配色パターンを利用する必要があります。 今回のUIではバウンディングボックスに割り当てられるラベルが4種類あり、バウンディングボックスにどのラベルが割り当てられているかを一目でわかるように表示する必要がありました。これらを色ではない要素(文字や罫線など)で表現できればアクセシビリティ上も問題ありません。しかし、色以外の要素では図面に記載されている図形やテキストと重なって判別が難しくなってしまったため、ここでは色による表現を採用しました。 今回はOkabe-itoパレットと呼ばれる配色パターンを参考にUI上の配色を決めました。実際に見分けやすいかどうかはChrome DevToolsなどの色覚シミュレーション機能によって確認できます。 キーボード操作・ショートカット 基本的な操作にキーボード操作が割り当てられているのは効率化の上で重要です。しかし、ユーザビリティとしても重要な点があります。 たとえば、キーバインドの選択によって使いやすさが大幅に変わる場合があります。UIを試用してもらった際、以前が使っていたOSSのデータ入力ツールとキーバインドが異なっており使いづらい、というフィードバックが多くありました。 単に機能が揃っているだけでは不十分な場合があり、利用者の文脈に沿ったデザインも一考の余地があります。 戻るボタンの挙動・ホイールの挙動などの作り込み キーボード操作と同じく利用者に寄り添った作り込みが必要で、かつ失念しがちな部分があります。 1つはウェブブラウザの「戻る」ボタンの処理です。「戻る」ボタンでどのようにページが遷移するのかを考慮する必要があります。今回アノテーションUIをReactで実装しており、単一ページでアノテーションUIを構成しました。利用者がページ遷移と認識したものが実装上はページ遷移ではない場合があり、「戻る」ボタンが利用者の意図せぬ挙動を生みました。たとえばアノテーション対象となる図面を切り替えたときユーザは「戻る」ボタンで前の図面に戻ることを期待していましたが、実際は別のページへの遷移となっていました。 もう1つはマウスホイールの処理です。マウスホイールによって図面の拡大・縮小ができる機能を実装していました。利用者のOSや利用しているデバイス(マウス、タッチパッドなど)の違いにより、ホイールの使用感やによるスクロール量の大きさが異なります。利用者が使っているデバイスを用いて動作検証が必要です。 まとめ 今回のアノテーションUIを試用したユーザからはおおむね好評で、特にテキストの自動入力とスナップ機能は作業効率に寄与したとの(定性的ではありますが)評価でした。現在は機械学習のためのアノテーションだけでなく、図面上にデータを入力するUIとしての活用を探求しています。今後もさまざまな角度からデータについて取り組み、サービスの改善に努めてまいります。
こんにちは。Platformチームの飯迫 ( @minato128 )です。 今回は、Tech Blogの移行について簡単に紹介したいと思います。 背景 キャディのTech Blogでは、これまで KistaのManaged WordPress を利用してきました。 主な採用理由は、「カスタマイズ性の高さ、マネージドで安全に変更を反映できる仕組みがあること」でした。 実際、KinstaとWordPressはカスタマイズ性が高く、他社と差別化されたデザインを採用できたことはよかったのですが、下記のような課題がありました。 記事公開までの手順がシンプルではない *1 Production環境へのデプロイ(記事公開)に5~15分程度かかる 定額利用料とは別に、訪問数、ディスク容量、通信量でも追加課金が発生するため維持コストが高い また、運用する中でWordPressほどのカスタマイズ性は必要ないこともわかってきました。 そこで上記課題の解消のため、「より簡単に速く記事が投稿できるかつ定額制」のはてなブログに移行することにしました。 移行作業 移行作業のおおまかな流れは以下の通りです。 技術的な不確実性の調査 移行計画の作成 はてなブログ作成とデータ移行 はてなブログのテーマやCSS変更(デザイン調整) Techメンバーをブログメンバーに追加して、元の記事のAuthorに、移行データの内容チェックを依頼 はてなブログの設定変更(ドメイン切り替え準備など) はてなブログの運用資料整備 caddi.tech ドメインの切り替え しばらく運用後、Kinsta/WordPressを削除 細部は実際とは異なりますが、おおまかなタイムラインはこのようになります。 記事データのインポート後は、新しい記事データの重複管理が発生するのを避けるため、なるべく早く切り替えることを意識しました。 *2 一方で、退職者の記事の一部が正しく表示できていないので、それは徐々に修正していければと考えています。 gantt title 移行タイムライン dateFormat YYYY-MM-DD section 移行担当(筆者) 調査〜データ移行 :a1 ,2024-2-20 ,1d 移行データの内容チェック依頼、その他準備作業 :a2 ,after a1 ,1d ドメイン切り替え :a3 ,2024-3-1 ,1h section デザイナー テーマやCSS変更 :b1 ,after a1 ,5d section Techメンバー 移行データの内容チェックや修正 :c1 ,after a2 ,2w はてなブログのデータインポート機能 が優秀なので、大きな苦労はなかったのですが、一部自動インポートできない画像 *3 がありました。ちょっとした工夫として、画像移行用の Cloudflare Workers を作成し、WordPressから公開用の R2 Bucket に画像を取り込めるようにしました。記事中で移行に失敗した画像 *4 のURLのHostをCloudflare Workersに書き換えると移行が終わるという仕組みです。はてなブログのAPIを使って一括書き換えもできたかもしれませんが、手動で直せる件数だったので各記事のAuthorに依頼して直していただきました。 *5 help.hatenablog.com また、移行後のちょっとした改善として、次のような取り組みをしました。 まず、Google Analyticsで収集した情報をLookerで参照できるようにして展開しました。Authorが記事を公開後、どれくらい反響があったか継続的にみられるようになりました。 ブログメンバーは、当初Google Spread Sheetで台帳管理 *6 していました。ありがたいことに4月2日にTerraform Providerが公開されたため、早速GitHubでの管理に移行しました。 *7 staff.hatenablog.com 今後は、GitHubで記事を管理したり、CIで textlint を実行したりすることで、さらに記事の質やブログの運用効率を上げたいと考えています。 移行して変わったこと 維持費が下がっただけでなく、開発者がより気軽に「記事を書いて、社内レビューを通して、公開する」ことができるようになりました。 移行前の数ヶ月は1~2件記事が公開されればいい方でしたが、移行後の3月はひさびさの4件になりました。 また、ホットエントリーや 企業技術ブログ: エンジニアの技術ブログコミュニティ への掲載効果もあってか、以前よりはてなブックマーク数が伸び、より多くの方に記事を届けることができました。 CADDi Tech Blog[B!]新着記事・評価 - はてなブックマーク 移行後の社内の声を一部紹介します(フィードバックコメントをほぼそのまま転載) 「(切り替わって)やる気出てきたのでTech Blog書くぞ!!!」 「ひとつのUI内で執筆から公開まで完了するので、自分は投稿はめちゃ楽になったなと思いました!(前はWordPressで書いてKinstaのパッと見で分からないUI上でprodにポチポチデプロイする必要があったはず…あと、プレビューページを共有してレビューもらうのもやりやすかったです)」 「公開フローのドキュメントもあって、つまずくことなく公開できました。体感kinstaの時より簡単になった気がします!」 「公開後にちょっと直したい箇所とかもすぐに直して公開できるのがよい」 「今回初めて書いてみて詰まったり不便だと感じたところは特になく、リリースってこんなに簡単にできるんだ、と率直に思いました!」 「プレビューページの共有が簡単にできるのが良かったです」 「VSCodeで執筆したmarkdown(mermaidで書いた図も)がそのまま使えるのがよい」 「編集ページのpreview windowが優秀で実際の見た目を見ながら微調整できる(reloadもボタン1つ)のがよい」 編集長の hamada さんによると、まだたくさん公開予定の記事が控えているようです。 今後のCADDi Tech Blogの更新にご期待ください!(ぜひSubscribeをお願いします!) *1 : WordPressだけでなく、KinstaのStagingとProduction環境構成や使い方を理解する必要がある *2 : 移行データに多少の表示上の違いはあれど、情報の欠損がないとことがわかったため *3 : Pathの書き方やFile Format,Sizeなどが原因 *4 : WordPressを参照している画像 *5 : 移行データの内容チェックを含め、協力いただいた社内のAuthorのみなさんに改めて感謝です *6 : Hatena IdとGoogle Workspace Id *7 : 余談ですが、PlatformチームではSaaS IaC用のモノレポを管理しているので、あたらしいSaaSを簡単に追加できるようになっています
 みなさんこんにちは。キャディ(CADDi)でML/MLOpsチームのグループリーダをしている稲葉です。今日は、エルピクセル(LPIXEL)さんと一緒にオフラインイベントを開催しましたので、そのイベントレポートをお伝えしたいと思います。 はじめに  イベントの詳細は、 connpassのページ をご確認いただけると幸いです。このイベントを開催するにあたってエルピクセルさんとも色々と議論したのですが、AIを製品として市場にリリースしているエルピクセル株式会社、キャディ株式会社からどういう点を意識してプロダクト開発しているかをお話すると実際の開発現場がイメージできるのではないかという話になりました。また、Machine Learning Engineert・Engineering Managerそれぞれの立場からお伝えすることで、AI製品化プロセス全体の話ができるのではないかと思い、このような内容で開催することにしました。  以下、発表の様子や内容を独断と偏見でまとめた個人的なハイライトと感想を紹介します。各発表資料は公開していますので、詳細はそちらをご確認ください。 【CADDi】実例で示すKaggleコンペと開発実務の差 発表者: キャディ株式会社 機械学習エンジニア 押条さん 資料: 20240315_LPIXEL×CADDi_kaerururu | ドクセル  kaeru-sanこと押条さんからのKaggleコンペと開発実務の差のお話です。  キャディのMLモデル開発の流れが紹介されています。Kaggleではモデリング評価と推論コード作成が主ですが、実務ではタスク設計、データセット作成、デプロイ、運用がさらに必要になってきます。  以降の資料では各ステップでの実務の課題とKaggleの知見が活かせるところを説明されていました。データセット作成において、どういうアノテーションのコンペが良コンペだったかが分かっているのが役立つのは確かにその通りだと感じました。どういう仕様のアノテーションがあると解きやすいか、所望の性能が出しやすいかの勘所もあると思います。加えて、どういうデータ分布で収集する必要があるか、どうバリデーションデータを区切って評価すべきかといった部分でも活きてくるのではないかと思います。  モデリング・評価において、違いが分かり易く整理されています。もちろん精度を追及する部分はKaggleが活きるところですが、非機能要件として速度も重要なのでシンプルで軽いモデルが適していることもあります。また、チームでの開発・運用のために誰でも再現できる状態にしておくことも大事です。  モデルを作った後の運用も重要です。こちらに示しているような指標の監視含め、エラー率が上がった場合の通知の仕組みを作ったり既知の不具合かどうかのログ監視を行っています。  MLチームでの学びのページでも言及されているように、データ収集の工夫・精度だけではないモデル開発・運用とMLを活用したサービスの開発全体を経験できる環境です。 【LPIXEL】実例で示すKaggleコンペと開発実務の差 発表者: エルピクセル株式会社 シニアエンジニア 髙木さん 資料: LPIXEL_CADDi_イベント資料 - Speaker Deck  続いて、高木さん視点のKaggleコンペと開発実務の差のお話です。Software Design誌で「画像解析AIの作り方」も連載されていたのでぜひそちらもご覧ください。  エルピクセルさんの仕事の流れとKaggleの比較です。コンペ内容の確認からプロジェクト内容の確認に範囲が広がっているとはいえ、ほぼKaggleの流れと変わらないのが印象的でした。スタンドアロンなソフトウェアで機能提供されている点もKaggleの流れと変わらないことと関係しているのだろうと想像していました。  ほぼ同様の流れという話の中でもKaggleと実務の違いとして、プロジェクト企画・データセットやアノテーションの自由度・製品化する上での制約が語られていました。押条さんからもありましたが、データセットやアノテーション上の工夫によって課題をクリアする手段もある点は大きな違いです。モデルや学習の工夫で改善を図るべきなのか、データの工夫で改善を図るべきなのかを判断することも機械学習エンジニアに求められる責務だと考えています。  製品化する上で、速度面とリソース面の制約のお話がありました。実務では「CPUのみで1分以内」であったり、この機能は「リアルタイム推論(10枚/s)が必要」であったりと様々です。スタンドアロンの提供形態では、計算リソースやメモリを柔軟に確保するのがより難しいので、強い制約がある中で高性能なモデルを作成するのは高度な専門性が求められるでしょう。  医療AIは機械学習プロダクトとして性能を求められ、それが製品価値になるケースがほとんどであり、 強いモデルを作ることができるとそれだけでアドバンテージとなるのは、機械学習エンジニアにとって嬉しい環境だと思います。確かに、Kaggleでモデル開発を競った経験が活かせる環境だと感じました。 【CADDi】CADDi AI Labの進化 R&Dから実用プロダクトへの旅路 発表者: キャディ株式会社 Engineering Manager 今井さん 資料: ML組織のこれまでとこれから  キャディのAI Labの立ち上げに携わった今井さんからの発表です。  元々、図面解析チームが活動していたのですが、従来からある画像処理アルゴリズムだけでは解けない課題もありAI Labが発足しました。その活動の中で、類似図面検索を開発しDrawerに組み込まれリリースされました。その後も継続して図面解析の深化や3D解析にも取り組んでいました。当時AI Labは全社横断組織でしたが、価値提供先との距離が遠くフォーカスがブレたりスピード感がないなどの課題を感じていました。それらの課題を解決するため、AI LabからDrawer所属とした経緯があります。  キャディの事業全体への直接的な貢献を考慮していましたが、DrawerにフォーカスしDrawerで開発した資産と知見をManuに活かしていく体制をとりました。Drawerのユーザに求められるプロダクト・要件定義・データ作成にフォーカスすることでML図面解析機能をリリースすることができました。と言ってもまだ一部ですので、さらに図面解析機能の強化を予定しています。必要なのはプロダクトマネジメントでしたという言葉に重みを感じます。  今、Drawerは資産の一つとして図面の活用にフォーカスしています。それはものづくりのバリューチェーンの中で図面を使わない部署は無く、全体を繋ぐコアデータの一つになっているためです。ただ、このバリューチェーンの中で生まれるデータは図面だけでなく、3D CADもありますし、デザインレビューのログや過去品質トラブルといったドキュメント類もあります。ML/LLMの技術を用いてこれらのデータを活用し、より前工程(設計など)にフィードバックすることでより高い価値を創造して行きたいと考えています。 【LPIXEL】製品化を実現し続ける医療AIアルゴリズム開発のプロセス 発表者: エルピクセル株式会社 Engineering Manager 竹内さん 資料: 製品化を実現し続ける医療AIアルゴリズム開発のプロセス | ドクセル  エルピクセルの研究開発本部グループリーダーの竹内さんからの発表です。  最初にアルゴリズムが製品化されない理由を話されていましたが、とても耳が痛い内容でした。技術的に優れているだけでなく、ユーザに望まれる機能なのか、リターンが見込めるのかの調査や検証はとても大事です。また、やってみないと分からないことも多い機械学習の実現不確実性とうまく付き合う必要もあります。「製品化可能な状況を開発前から作っておく!極論これに尽きる」は強いメッセージでした。  そこで、製品可能な状況を開発前から作っていくために何が必要かのお話がありました。まずは、プロダクトマネジメントとしてユーザヒアリングや「真の需要」の考察が必要です。表面的にユーザが欲しいと言っている機能ではなく、本当にユーザが実現したいことは何なのかの仮説構築が大事です。その後、仮説検証や実現性の検証を実施し、その仮説が正しいのか技術的に実現可能なのかを確かめます。この際、現実的に達成できそうな性能と提供できる機能や体験をデモを通じて期待をすり合わせることも重要です。  この製品価値の明確化をやり切るのはプロダクトマネージャー、エンジニアともに苦労するのですが、ここがはっきりしないまま進めると使われるか分からないものを作ることになりお互いに不幸になります。ソフトウェア開発全般に言えることではありますが、特に機械学習モデル開発はデータセットの準備も含め大きな開発コストを払うため、より重要だと感じています。  もう一つ話されていたのが、エンジニアが頑張りやすい状況整備です。モデル開発が得意なエンジニアが活躍できるように、開発に必要なデータ要件や評価尺度を早く決めておくことが大事です。問題設定がクリアになっていて、データセットと評価方法が決まっていれば、Kaggleと同様にアルゴリズム改善に注力することができます。また、PoCを実施しているとはいえ本番データを集めてやってみないとどうなるか分からない部分もあるため、どういう形でプロダクト提供するかを複数プランを考えておくことが重要です。プロジェクト開始時に成功の定義を考えますが、多段階に想定されているのはさすがだなと思いました。  せっかく頑張ってくれたエンジニアの成果を無駄にしないように、我々もより成果が出やすい環境を作るため身が引き締まる思いでした。 質疑応答&パネルディスカッション  全体の発表の後、質疑応答&パネルディスカッションの時間を設けましたので、いただいた質問と回答を紹介します。 Q. Kaggleと実務ではリリースした後に継続してモデル精度を監視する点が一つ異なると思いますが、モデル精度のトラッキングはどのように実施していますか? 今井 : 定性的なものと定量的なものがあります。定性的には、カスタマーサクセスを通してお客様からのフィードバックをもらっています。定量的には、全てのモデルで実施しているわけではないですが、入力されたデータの一部に対してアノテーションし、実際の認識結果と突き合わせて評価しています。 竹内: エルピクセルの場合は薬機法に関わる製品であるため開発タイムラインが長く、容易にはモデルの更新ができません。そのため、よい製品であることの条件としてクリアすべき基準とデータセットを事前に突き詰めることが多いです。 Q. ユーザにもアノテーションに参加してもらうことを考えていますか? 竹内: ユーザにアノテーションに参加してもらうという意味ではプロダクトには直接入ってはいません。ただ、導入施設の医師から正解不正解のフィードバックを貰っていますし、アノテータとして契約することもあります。 押条: キャディも現在プロダクトに直接入ってはいませんが、もちろん考えてはいます。ただ、現状はカスタマーサクセスとお客様から課題図面のフィードバックを貰い改善を進めています。 Q. カスタマーサクセスからの問い合わせの中には、技術的に難しいものがあり精度が向上しない場合もあると思います。どのように説明していますか?また、対応フローがありますか? 竹内: 実際、難しい要望はかなり多いです。ただ、典型的なパターンのものは対応や説明例をまとめているので、現地のシステムエンジニアが一定対応してくれています。また、クレームというよりも純粋に開発者の気持ちを知りたい場合がかなり多い印象があります。実際にエンジニアがユーザに対して説明することで納得してもらえることも多いですし、それによって製品開発により協力的になってくれる方も居ます。 今井: キャディでもエルピクセルさんと同様に開発者の気持ちが知りたい場合が多いですね。そこは同様なので違う部分で言うと、Drawerでこういう検索結果が欲しいとしても方法は一つではないことが多いです。ですので、別の方法としてこういうやり方ができますよという話をカスタマーサクセスを通して伝えることで納得いただけることもあります。また、機械学習がどういうものなのかをお客様に正しくお伝えすることも大事です。そのために全社の理解底上げのためのオンボーディングや説明資料を用意しています。 Q. AIプロダクト開発におけるKaggleの役立ち度合は今後変わっていくと思いますか?また、どういう点が今後大事だと思いますか? 高木: 補助機能としてではなく、AIがメインとなるようなプロダクトはまだ少ないですが、今後増えるだろうと考えています。その際には競合が増えて認識性能勝負になるでしょう。強い認識モデルを素早く作る力は依然として必要なので、Kaggleに参加して鍛えるのが大事だと思います。 押条: チームで機械学習コードを開発する経験を意識してKaggleに参加するのが大事だと思います。実務では一人でモデル開発するのではなくチームで再現性を持って改善することが求められます。Kaggleではチームマージして別々のパイプラインを持ち寄って推論コードをアンサンブルした方が精度は上がりますが、チーム開発を意識して実験やコードの管理に取り組んだりツールを選ぶなど、自分で課題意識を持ってチャレンジの場としてKaggle を活用すると良いと思っています。 懇親会  登壇者と参加者、遊びに来ていたそれぞれのエンジニアで懇親会をしました!想像以上に盛り上がり、時間ギリギリまで質問や情報交換がされていたのが印象的でした。色々なところで名刺交換が行われたり、カジュアル面談の予定が組まれていたので、このイベントをきっかけによいコラボレーションが産まれるととても嬉しいです。 おわりに  参加者の皆さまのおかげでイベントは大成功で終われたと思います。オフラインでお話や情報交換ができるのは、やはり良い体験だなと再確認出来ました。  このイベントの開催に向けて会場提供含め協力いただいたエルピクセルさんにこの場をお借りして感謝申し上げます。特に広報の方に当日流れも含め綿密な準備をしていただきました。ありがとうございました!今後もこういったイベントは開催したいので、エルピクセルさん含め一緒に企画していただける企業様もぜひお声がけいただけると幸いです。 We're hiring! キャディでは一緒に開発を推進してくださるメンバーを募集しています。興味のある方、是非気軽にご連絡ください! エンジニア向けサイト CADDi 採用情報 for Engineer/Designer Information for ML/MLOps Engineer ML/MLOps募集要項 Machine Learning Engineer / キャディ株式会社 MLOps Engineer / キャディ株式会社
はじめに こんにちは。 バックエンドエンジニアの松本です。今回は、会計システムの開発を通じて、 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
はじめに AI Team MLOps Engineer の西原です。 前回 は kubeflow pipeline(kfp)のローカル環境での実行について Tech Blog を書きました。kfp は 2024 年に入ってからローカル環境の実行以外にも嬉しいアップデートがあったのでそれに少し絡めて今回の取り組みを紹介しようと思います。 今回の取り組みは、モノレポで使っている Python の最低バージョンを 3.9 から 3.11 に上げるというものです。なぜ、バージョンを上げたのか、上げる際の障壁とその対応を紹介しようと思います。 はじめに なぜ Python バージョンを上げたのか パッケージ更新を頻繁にする理由 パッケージの更新ができなくなった torchserve と各ソフトウェアのバージョン Python のバージョンをどこまで上げるか torchserve のコンテナイメージを自分たちで構築する torchserve メトリクスのエラー対応 pyupgrade で Python コードをアップデートする まとめ 参考 なぜ Python バージョンを上げたのか パッケージ更新を頻繁にする理由 Python のバージョンを上げるにあたって、依存するパッケージの更新の話が関係してきます。そのため、パッケージの更新について先に触れます。 以前、モノレポに置いて依存するパッケージは高頻度でアップデートしているということを Tech Blog で紹介しました。その Tech Blog のおさらいになりますが、新しいバージョンはバグ修正や新規機能追加が含まれていることが多いので、バージョン更新することでバグを回避したり、新しい機能が使えるようになります。また、頻繁にアップデートすることで一回あたりの変更量が少なくなるため、何か問題があった時も少ない変更量から原因を特定することになり、デバッグも簡単になります。こういった背景から、私たちのモノレポでは依存するパッケージのバージョンを頻繁に上げています。 パッケージの更新ができなくなった 上記の通り、モノレポでは依存するパッケージのバージョンを頻繁に上げています。しかし、パッケージの更新ができないものが出てきました。更新できないパッケージというのは torchserve です。我々 AI Team では ML WebAPI のフレームワークとして torchserve を利用しています。torchserve 公式が提供しているコンテナイメージをベースイメージとして WebAPI を構築しています。この公式が提供しているコンテナイメージの環境が、私達に合わなくなりました。具体的には、torchserve v0.9.0 から CUDA 12 系のコンテナイメージになり、これを使って Vertex AI Endpoints にデプロイすると、CUDA の認識ができずエラーになります。そのため、Vertex AI Endpoints を使うには CUDA 11 系の v0.8 のコンテナイメージに依存することになりパッケージの更新ができなくなりました。とはいえ torchserve には信頼性に関する issue がずっと立っていたりと、これからのアップデートに期待してる部分があるため torchserve 自体のアップデートを諦めるわけにはいきません。そのため、公式が提供するコンテナイメージを使うのを辞め、自分たちに適したコンテナイメージを構築することにしました。 torchserve と各ソフトウェアのバージョン 自分達に適した torchserve のコンテナイメージを構築することでコントールできるようになったものが次になります。 CUDA のバージョン torch と torchserve のバージョン Python のバージョン 前置きが長くなりましたが、ここで Python バージョンの話になります。torchserve 公式が提供するコンテナイメージの Python バージョンは 3.9 でした。この公式のコンテナイメージへの依存を止めることで、Python のバージョンをコントロールできます。v3.10 以降の Python では型表現の強化や match 構文の追加、パフォーマンス改善などが含まれており、よりよい Python コードを書くことができるようになります。加えて Python には EOL があり、古いバージョンのサポートが終わります。3.9 の EOL はまだ先ですが、直前になって慌ててバージョンアップするよりも、早めにバージョンアップしておいた方が良いと考え、Python のバージョンを上げることにしました。 余談:CUDA のバージョンをコントールするのは、torchserve と Vertex AI Endpoints の相性が悪いというのも理由の一つですが、他にも理由があります。それは、 GitHub Actions の GPU runner に向けた対応です。GitHub Actions で GPU を使えるようになるという話があり、現在はクローズドベータとして提供されているようです。このサービスが一般提供された時の利用に備えて CUDA のバージョンをコントロールすることで、導入負荷を下げることも目的の一つです。 Python のバージョンをどこまで上げるか torchserve のコンテナイメージを自分たちで構築することで Python のバージョンを自由に選べるようになります。タイトルにもあるように Python 3.9 から 3.11 に上げたのですが、なぜ 3.11 なのかの意思決定の過程を紹介します。このタスクは 2024 年の新年最初のタスクとして取り組みました。私たちは Kubeflow Pipelines(kfp)を使って ML パイプライン開発をしていますが、2024 年に入った時点で kfp は protobuf 3 系に依存していました。モノレポで使っている 3rd party package の中には Python 3.11 以上から protobuf 4 系を求めるものがあるため、protobuf の依存の関係で 1 月はじめの時点では Python 3.10 までしか上げることができませんでした。そこで一度 Python 3.10 に上げたのですが、その数日後に kfp が protobuf 4 系に対応したため Python を 3.11 まで上げることができました。stable release として Python 3.12 が出ていますが、開発で使っているパッケージが 3.11 までしか対応していないものがあるため 3.11 までの更新にとどめました。 torchserve のコンテナイメージを自分たちで構築する 先述の通り、torchserve のコンテナイメージを自分たちで管理します。この章ではコンテナイメージの管理について紹介します。まず、torchserve 公式の Dockerfile を見ると build 時の引数として Python、 CUDA のバージョンを指定できることがわかります。公式の Dockerfile を参考に、build 時に各ソフトウェアのバージョンを指定することで自分たちに適したコンテナイメージを構築することができます。 Pants を使ってコンテナビルドの設定を記述すると次のようになります。 docker_image( name= "caddi-torchserve" , source= "Dockerfile" , extra_build_args=[ "PYTHON_VERSION=3.11" , "CUDA_VERSION=cu118" , ], ... ) Pants を使わずともコンテナイメージをビルドして registry に push できますが、Pants で管理することでベースイメージを使った build/push が効率的に行えます。Pants には差分実行機能があり、あるコンテナイメージに変更があった場合に、それに依存するコンテナイメージを検知して一括で build/push できます。今回構築した torchserve のベースイメージに依存しているコンテナイメージは二桁に及び、今後も増えると予想しています。今後、開発規模が大きくなったとしても Pants を使ってコンテナイメージを管理することで一括で build/push できるため、運用負荷を下げることができそうです。 torchserve メトリクスのエラー対応 Vertex AI Endpoints では torchserve 公式の CUDA 12 系ではうまく動きませんでしたが、今回構築した CUDA 11 系のコンテナイメージで動作することが確認できました。しかし、動作確認をする中で GPU のメトリクスを計測するプログラムでエラーが発生するようになりました。推論処理自体は問題なく動作しているため、このエラーを無視して利用することもできます。しかし、エラーによって狼少年アラートが発砲すると良くないため、エラーを解消することにしました。 原因を調査すると pynvml というパッケージでエラーが発生していました。 pynvml の v11.5 では CUDA 11 との相性が悪いため、v11.4 にダウングレードすることでエラーを解消することができました。 pyupgrade で Python コードをアップデートする Python のバージョンを上げるにあたり、コードの構文も新しいものにアップデートしました。これまで使っていた Python 3.9 から 3.11 までの間で新しい構文が追加されています。例えば、 match 構文や | を使った型表現です。これらの新しい構文によって古い構文を置き換えることができます。機械的に置き換えできるものもあり、 pyupgrade というツールを使って Python コードをアップデートしました。 pyupgrade は Pants 公式がサポートしているため、特別な設定をすることなく使うことができます。 Ruff でも pyupgrade のように Python の古い構文を新しい構文に置き換える機能があります。Ruff も Pants 公式がサポートしているため、簡単に利用できます。 まとめ モノレポで使っている Python のバージョンを 3.9 から 3.11 に更新する取り組みについて紹介しました。AI Team では開発で使っているソフトウェアのバージョンを更新できる仕組み作りをしています。Python のパッケージにおいても kfp や torch、pydantic などがここ一年でメジャーアップデートをしています。チームでこれらのアップデートに追従し、更新可能な古い機能で新規開発をしなくていいように努めています。また、Python に限らず Terraform や GitHub Actions などのツールについても同様の取り組みをしています。 古いバージョンのソフトウェアに依存していて生産性を低下させているが、更新が難しいというのを技術負債あるあるとしてよく耳にします。このような状況を回避するために、短いサイクルでソフトウェアを更新できる仕組みと体制が大事だと考え、チームで取り組んでいます。 参考 pants docker
※本記事は、技術評論社 「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  ↩︎
本投稿は SRE Advent Calendar 2023 の19日目の記事になります。 こんにちは。SREチームの矢野( @yymm )です。 今年の4月からCADDi DRAWERのサービス信頼性向上のためSREチームが活動を始めています。チーム立ち上げから3Q経過して方向性も見えてきたため改めて立ち上がりから今までのことを紹介します。 CADDi DRAWERについて 図面データ活用クラウドのCADDi DRAWER は、2022年6月に正式にローンチされました。 ローンチから1年半経過し取り扱うデータ数のオーダーやユーザー数が拡大してきており、機能面の拡充はもちろんのことですが、非機能要件の重要性も高まってきています。 私は2021年12月のDRAWER立ち上げ期から開発に関わるSWEだったのですが、私も含め当時関わっていたメンバーがアプリケーション開発メインだったこともありインフラ周りや非機能要件周りに課題があるのを感じていました。 課題があるなら解決やったるぞということでSREへの社内ジョブチェンジを打診してチームの立ち上げをしました。1エンジニアのチャレンジを認めてもらえたことに感謝をしているのと、会社としての懐の広さを感じています。 SREチーム立ち上げから今までの道のり ローンチから9ヶ月してCADDi DRAWER開発チームの拡大に伴い、Team Topologyをベースにしたチーム構成が採用されました。SREチームは、横断的な活動をするEnablingチームのサブチームとして位置づけられています。 キャディでは3ヶ月を1クォーター(Q)として、その都度目標を設定しています。立ち上げから3Q経過したので、それぞれのQでの活動を振り返っていきます。 立ち上げ初期(2023-04~): SRE未経験からのスタート SREチームの初期メンバーは2人で実は2人ともSRE未経験、当時MLOps領域を担当していたメンバーと共に始まりました。モチベーションはあるもののどうしようか...というところからのスタートでした。 とはいえ目先の課題はあったので、Engineering Managerと一緒に課題を整理して目標を設定しました。 経験もなくSREチームとしても人数少ないですが、 Platformチームに協力を仰ぎ適宜支援いただきながら 以下のようなトピックに取り組みました。 インフラコストの削減と可視化 セキュリティ向上のためのIAMやサービスアカウント周り整備 IaCの整備 GKEの保守運用(クラスタアップグレードなど) もう一つ大事なことは、我々はそもそもSREとは何かを学ぶ必要があったので、輪読会を開催してSRE本やSRE Workbookを読みSREへの理解を深めていきました。 SRE サイトリライアビリティエンジニアリング―Googleの信頼性を支えるエンジニアリングチーム サイトリライアビリティワークブック―SREの実践方法 安心安定の定番書籍SRE本です、SRE未経験の私にとってはSREの役割や考え方を学ぶにはとても良い本でした。今でも作業の傍らにおいて読み返したりすることも多くバイブルと化しています。 自律的な目標設定(2023-07~): プロダクトロードマップとの連携 前の四半期ではSREとは何かをよく分からず取り組みましたが、今後はSREらしいチーム目標を立てて自律的に活動していきたいと考えていました。 自分もSREという言葉を知っていましたが具体的なSite Reliability Engneeringというエンジニアリングアプローチを知らない方も一定いると思い、そもそもSREについての組織全体の理解度を上げることも大事と思い、Slackの雑談チャンネルなどでの布教活動もしていました。 これからSREチームの取り組みを効果的に発揮していくためには、開発チームに閉じないでPdMなどのプロダクトの意思決定ができるステークホルダーと一緒に取り組むのが大事と思い、そのレイヤーの方々を巻き込んでの目標設定に取り組みました。 今まで取り組んできたトピックでもPdMやTPM(Technical Product Manager)などのプロダクトサイドとの接点があったことや前述の布教活動が功を奏してSREの認知が広まっていたこともありスムーズに議論は進み、最終的にプロダクトロードマップの1項目としてReliabilityを設定することになりました。 これはプロダクトロードマップの説明スライドの1枚なのですが、大枠は Googleが提唱しているSREのベストプラクティス に則る形で策定しています。 プロダクトサイドとの連携は今後も重要になってくるため、このアプローチを活かしてSREチームはSREらしく活動することができ、その活動がビジネスの目標と連携していき組織的な成長を促進していけるように取り組んで行こうと思っています。 本格活動開始(2023-10~): Enablingチーム拡大でSREにフォーカスできるように 順調に方向性も定まりやっていくぞという雰囲気なのですが1つ問題がありました。 横断的なチームという特性と、私が落ちがちなボールを見つけると拾う習性があったため、一般的にはSREチームでは扱わないようなトピックも扱うようになっておりSREのフォーカスに集中しきれないという課題がありました。 2023-10のタイミングでちょうど全社的な組織変更がありEnablingチームメンバーの拡充され、以下のようなチーム体制への変化がありました。 エンジニア向け会社紹介資料 から抜粋 紫色のEnablingチームは元々SRE/QA/アーキテクチャチームで構成されていたのですが、データマネジメントチームが組成され各チームの人員も増えました。 この組織変更と人事異動が功を奏して関わっていたSRE以外のトピックを、適切なチームに責務とタスクを委譲することができました。担当の余力が少なく、SREで巻き取っていたデータマネジメント関連のタスクを委譲できたのは個人的にインパクトが大きかったです。 Enablingチームの人数が増えたことでチームに活気も生まれ、連携強化のためにサブチーム全員で1つのスクラムを回してみたり、スクラムのトレーニングが実施されるなどチームの強度が上がってきているのを感じています。 実際の活動についてはPdMとTPMと協力して定めたプロダクトロードマップに沿って進めており、以下のような内容に取り組んでいます。 Metrics & Monitoring PdM, TPMと相談してクリティカルユーザージャーニーの再設定、SLIの計測に向けた基盤構築 Emergency Response インシデント対応フロー、On-call体制の整備 Capacity Planning キャパシティ評価の基盤構築 Change Management QAチームとArchチームでアーキテクチャやリリースフローの整理 PlatformチームでFour Keys測定の基盤構築 プロダクトロードマップの大部分はSREチームがOnwerとして取り組んでいますが、Change Managementの部分は状況的にSREチーム以外のチームに協力してもらいつつ連携を取りながら進めています。横断チーム間で連携しつつプロダクトロードマップにアラインした活動を行うことができており、今後も適宜コラボレーションモードを切り替えながら効果的に施策を進めていきたいと思っています。 今後の展望 定めたロードマップを推進して、信頼性の高いサービス提供ができるように組織全体のSRE力を上げていきます。今回の記事では具体的な取り組み内容に関しては言及できてないので、今後はSREチームの成果も発信していきたいと思っています。 SREというロールは初めてまだ1年満たないので馬力不足なところもありますが、一人前のSREを名のれるようにどんどん成長していく所存です。 昨今はSREに関連する書籍も結構増えてきているので、輪読会も継続していきます。輪読会はSREチーム以外の方も自由参加でわいわいやっています。 実はPlatformチームからインフラに強いメンバーのジョインもあり現在3人チームになりました。とはいえまだまだやらないといけないことがたくさんあり、SREポジションを絶賛募集中です。 製造業のポテンシャルを解放する成長中のSaaSを支えるSREに興味のある方は、カジュアル面談も行っていますのでぜひお気軽にお声掛けください。最後まで読んでいただきありがとうございました。 SRE(Site Reliability Engineer)採用情報・応募ページ Tech向けカジュアル面談申し込みページ