TECH PLAY

キャディ株式会社

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

61

こんにちは MLエンジニアとMLOpsエンジニアを兼任している押条です。 CADDi の機械学習プロジェクトでは Python がよく使われています。 本番環境にリリースするコードのlinter や formatter、test に関しては、 CI で自動的に適用し運用できていました。 一方、型注釈については開発担当者に一任されており、プロジェクトごとにあるものとないものと混在していました。 採用を強化してチームを拡大していこうという動きもあり、開発に携わる人が増える見込みがあります。 そのような背景から、型注釈による既存コードの可読性の向上の重要性が増してきました。 そこで現行の mypy の設定の読み合わせと mypy 設定の修正を実施しました。 その時の議論の内容と修正後の設定をご紹介しようと思います。 目次 現行ルールがどのようなものか認識合わせする 現在のプロダクションコードを参照しながらルールのスコープを決める 修正後の設定ファイルの紹介 おわりに 現行ルールがどのようなものか認識合わせする まずは現在使用している mypy の設定ファイルの読み合わせをしました。 この設定は MLOps チームのテックリードが厳しめに設定してくれたものでした。 ChatGPT に入力して、説明してもらったのが下記です。 この設定は、`mypy`に関する多くのオプションを含んでいます。各オプションについて簡単に説明し、必要に応じて改善の提案を行います。 1. `allow_redefinition = False`: 同じスコープ内での変数の再定義を禁止します。通常、このような再定義はエラーの原因となります。 2. `allow_untyped_globals = False`: 型注釈がないグローバル変数の使用を禁止します。型情報が不足している場合、予期しないエラーが生じる可能性があります。 3. `check_untyped_defs = True`: 型注釈がない関数やメソッドに対して型チェックを行います。 4. `color_output = True`: エラーメッセージに色を付けて表示します。 5. `disallow_incomplete_defs = True`: 型注釈の一部が欠けている関数やメソッドの定義を禁止します。 6. `disallow_untyped_defs = True`: 型注釈がない関数やメソッドの定義を禁止します。 7. `disallow_untyped_calls = True`: 型注釈がない関数やメソッドの呼び出しを禁止します。 8. `disallow_untyped_decorators = True`: 型注釈がないデコレータの使用を禁止します。 9. `error_summary = True`: エラーメッセージの概要を表示します。 10. `ignore_missing_imports = True`: 型情報がないモジュールのインポートを無視します。多くのサードパーティライブラリは型情報を持っていないため、このオプションが有効になっています。 11. `implicit_reexport = False`: 型注釈がない関数やメソッドの再エクスポートを禁止します。 12. `no_implicit_optional = True`: Optionalの型を明示的に宣言する必要があります。 13. `pretty = True`: エラーメッセージを見やすくフォーマットします。 14. `show_column_numbers = True`: エラーの発生した列番号を表示します。 15. `show_error_codes = True`: エラーコードを表示します。これは、特定のエラーを無視するための設定を行う際に便利です。 16. `show_error_context = True`: エラーメッセージのコンテキストを表示します。 17. `show_traceback = True`: mypyがクラッシュした場合にトレースバックを表示します。 18. `strict = True`: すべてのstrictモード関連のオプションを有効にします。このオプションが有効になっている場合、以下のオプションも自動的に有効になります。 19. `strict_equality = True`: 異なる型同士の等価性の比較を禁止します。 20. `strict_optional = True`: Noneと他の型の混在を許容しないようにします。 21. `warn_no_return = True`: 戻り値がない関数やメソッドでのreturn文の省略に警告します。 22. `warn_redundant_casts = True`: 不要な型変換に警告します。 23. `warn_return_any = True`: `Any`型を返す関数やメソッドに警告します。 24. `warn_unreachable = True`: 到達不能なコードに警告します。 25. `warn_unused_configs = True`: 使用されていないconfigオプションに警告します。 26. `warn_unused_ignores = True`: 使用されていない`# type: ignore`コメントに警告します。 **改善の提案**: 1. `ignore_missing_imports = True`: これはグローバルに適用されるため、可能ならば特定のライブラリにのみこのオプションを適用することをおすすめします。全体的な型の厳格性が低下する可能性があります。 2. `strict = True`: このオプションは多くの他のstrictモード関連のオプションを有効にするため、個別に指定したオプション(例: `strict_equality`, `strict_optional` など)は削除しても良いです。 全体的に、この設定は非常に厳格な型チェックを行うように設定されており、コードの品質を向上させるのに役立ちます。ただし、非常に厳格であるため、既存のコードベースでこれらの設定を有効にすると、多くのエラーが発生する可能性があります。新しいプロジェクトや、既存のプロジェクトでの大規模なリファクタリングを行う際には、これらの設定を検討すると良いでしょう。 warn_redundant_casts = True warn_return_any = True warn_unreachable = True warn_unused_configs = True warn_unused_ignores = True 各オプションの役割が何となくわかるようになりました。 prefix が show_ のオプションや warning_ のオプションのほとんどについては、True で問題なさそうです。 それ以外のオプションについて、1行ずつ確認して必要があれば議論も実施しました。 現在のプロダクションコードを参照しながらルールのスコープを決める オプションの確認を行うにあたって、現在のプロダクションコードに対して無理なく適用できそうかどうかを意識して取り組みました。 それでは順番に見ていきましょう。 allow_redefinition こちらは変数の再定義に関するオプションです。 画像解析の Deep Learning モデル開発の現場では、このようなコードが頻繁に見られると思います。 image : PIL.Image = PIL.Image.open('path_to_image.png') image : np.array = np.array(image) image : torch.Tensor = torch.from_array(image) 再定義が許容されない場合以下のようなコードになりそうです。 image : PIL.Image = PIL.Image.open('path_to_image.png') image_arr : np.array = np.array(image) image_tensor : torch.Tensor = torch.from_array(image_arr) リーダブルコードにも記述があるように変数には適切な名前をつけるのが望ましいですが、幾分冗長に思えます。 後述する設定項目によって、私たちはできるだけ型注釈を省略せずコードを書こうとしています。 その場合、変数に型情報が紐づいているため、変数名に型の情報を持たせなくて良くなります。 redefinition を許可しても可読性が損なわれることはないと判断し、 allow_redefinition = True としました。 allow_untyped_globals こちらは 型注釈のないグローバル変数を許容するかどうかに関するオプションです。 そもそも グローバル変数は使わない方が良いが、定数の場合 Final 型 を使用して意図しない変更を検知するようにしていこうという議論になりました。結論は False です。 check_untyped_defs 型注釈がない関数やメソッドに対して型チェックを行うかどうかに関するオプションです。 型チェックを自動化するために型チェッカを利用しているのでこちらは True です。 disallow_incomplete_defs 我々が記述するコードについては、型注釈を欠損なく書いていきたいです。こちらも True です。 disallow_untyped_defs 型注釈がない関数やメソッドの定義に関するオプションです。こちらも True です。 disallow_untyped_calls 型注釈がない関数やメソッドの呼び出しに関するオプションです。 前述の disallow_untyped_defs = True、disallow_incomplete_defs = True により我々が定義したコードには型注釈がついている状態になっているはずです。 call まで True にしてしまうとサードパーティライブラリにもルールが適用されてしまいます。 厳しすぎるため、我々のルールでは False にしました。 disallow_untyped_decorators 型注釈がないデコレータに関するオプションです。 True にしてしまうとサードパーティライブラリにもルールが適用されてしまいます。 サードパーティライブラリにはデコレータに型がついていない場合があります。 厳しすぎるため、我々のルールでは False にしました。 ignore_missing_imports = True : 型情報がないモジュールのインポートを無視します。多くのサードパーティライブラリは型情報を持っていないため、このオプションが有効になっています。 True で良さそうです。 implicit_reexport = False : 型注釈がない関数やメソッドの再エクスポートを禁止します。 我々は型注釈のない関数を定義しないので False です。 no_implicit_optional = True : Optionalの型を明示的に宣言する必要があります。 True で良さそうです。 strict = True : すべてのstrictモード関連のオプションを有効にします。このオプションが有効になっている場合、以下のオプションも自動的に有効になります。 strict_equality と strict_optional を明示的に True にしているため strict オプションは削除で良さそうです。 warn_no_return 戻り値がない関数やメソッドでのreturn文の省略に対する警告についてのオプションです。 ↓ このようなコードはエラーにしてほしいため True に設定します。 def example_function(value: int) -> int: if value > 0: return value # ここに戻り値の記述がない warn_unused_ignores 使用されていない # type: ignore コメントへの警告に関するオプションです。 基本的に True にすべきですが、我々が採用しているビルドシステムとの相性が悪く、一時的に False としています。 修正後の設定ファイルの紹介 議論の結果出来上がった設定ファイルは次のようなものになりました。 [mypy] allow_redefinition = True allow_untyped_globals = False check_untyped_defs = True color_output = True disallow_incomplete_defs = True disallow_untyped_calls = False disallow_untyped_decorators = False disallow_untyped_defs = True error_summary = True ignore_missing_imports = True implicit_reexport = True namespace_packages = True no_implicit_optional = True pretty = True show_column_numbers = True show_error_codes = True show_error_context = True show_traceback = True strict = True warn_no_return = True warn_redundant_casts = True warn_return_any = True warn_unreachable = True warn_unused_configs = True warn_unused_ignores = False おわりに チームで mypy 設定ファイルの読み合わせを実施しました。 今回ご紹介した取り組み以外にも、python typing や pydantic などの型注釈に関するドキュメントの読み合わせなど、チーム全体で知識を底上げしたり知見を共有したりする取り組みを進めています。このような取り組みによって、より安全で高速な開発を推進していきます。 我々と一緒に開発を推進してくださるメンバーを募集しています。興味のある方、是非気軽にご連絡ください! エンジニア向けサイト カジュアル面談 機械学習エンジニアの求人 The post mypy 設定ファイルの読み合わせと修正を実施しました appeared first on CADDi Tech Blog .
アバター
※本記事は、技術評論社 「Software Design」(2023年6月号) に寄稿した連載記事「Google Cloudで実践するSREプラクティス」からの転載です。発行元からの許可を得て掲載しております。 はじめに 前回はIaCの考え方や必要性と、筆者らが採用しているTerraformの特徴について紹介しました。今回は今後紹介するプラクティスの前提となるTerraformに触れたことのない方のために、その基本を簡単に紹介します 1 。 ここで紹介できない事項やTerraformのインストール方法については、HashiCorp 社やGoogleCloudのチュートリアル 2 を参考にしてください。ぜひそちらも併せてご覧ください。 Terraformの基本コンセプト Terraformの基本コンセプトは図1のようになっています。 ▼図1 Terraformのコンセプト コードとして表現するインフラの状態は、Terraform Languageと呼ばれる独自の設定言語で記述し、拡張子 *.tf のファイルとして保存します。 なおドキュメント 3 によると、「Terraform Language は、HCL(HashCorp Configuration Language)と呼ばれるHashiCorp社の独自の言語がベースになっている」と説明されています。 しかし、Terraformの設定言語を一般化してHashiCorp社のほかの製品でも使えるようにしたものがHCLですので、「Terraform Language≒HCL」ととらえても差し支えはないでしょう。 Terraformの実体は単一のバイナリファイルで、 terraform plan 、 terraform apply などのさまざまな機能がサブコマンドとして提供されています。前回も触れたように、クラウドサービスごとの処理はプロバイダ(Provider)と呼ばれるプラグインに分離されています。どのプロバイダを利用するかは、tfファイル内に記述します 4 。 Terraformがtfファイルの記述に従ってプロバイダを自動ダウンロードするので、利用者がプロバイダのインストールを意識する必要はありません。 ステート Terraformを理解するうえで重要なのが、ステート(State)です。tfファイルには、Terraformで管理したいクラウドサービス上のリソースたとえばVPC(Virtual Private Cloud)やVM(Virtual Machine)インスタンスなどを記述します。これらリソースの実際の状態と、tfファイル上の記述との対応を保持しているのがステートです。 ステートの実体は *.tfstate という拡張子のJSONファイルで、Terraformが処理を実行するたびに更新されます。本連載では、このファイルを「tfstateファイル」と呼びます。 バックエンド Terraformにおけるバックエンドとは、tfstateファイルの保存先のことです。バックエンドを切り替えることで、さまざまな方法でtfstateファイルを管理できます。 デフォルトのバックエンドは local で、tfstateファイルをローカルストレージに保存します。 Terraformの学習時や個人で使用するときは、これで十分でしょう。 チームでインフラを管理するときは、tfstateファイルを共有しなければなりません。このためgcs やs3といったバックエンド 5 を使って、オブジェクトストレージ上に配置することが一般的です。筆者らもgcsバックエンドを使ってGCS(Google Cloud Storage)のバケット上でtfstateファイルを管理しています。 Terraform Languageの基本 ブロック Terraform Languageでは、クラウド上のリソースをブロックと呼ばれる固まりで記述します。ブロックの文法はリスト1のようになっています。たとえば、Google Cloud上で my-network という名前のVPCネットワークを作成するにはリスト2のように記述します。 ▼リスト1 ブロックの文法 resource "google_compute_network" "my_network" { name = "my-network" } ▼リスト2 my-networkというVPCネットワークを作成 resource "google_compute_network" "my_network" { name = "my-network" } ブロックタイプには、表1に示すようなものがあります。 今回は、主要なブロックタイプについてTerraformの使い方の流れに沿って紹介します 6 。 ▼表1 おもなブロックタイプ ブロックタイプ 説明 provider プロバイダの設定を記述する resource クラウドサービス上のリソースを定義する locals リソース内で使用する変数を定義する variable 外部から設定可能な変数を定義する output リソースやモジュールの出力を定義する module 他のモジュールの読み込みを指示する data Terraformが管理しないリソースの参照を定義する resource ブロック resourceブロックはTerraformの主役とも言えるブロックで、クラウド上のリソース定義を表します。resourceブロックでは2つのブロックラベルを指定し、1つめがリソースの種類(リソースタイプ)、2つめがTerraform内でのリソースの識別名を表します。リソースタイプは、プロバイダによってあらかじめ定義されているもので、プロバイダのドキュメントに記載されています 7 。 リスト3の例は、GoogleCloudのプロバイダが提供するgoogle_compute_networkというリソースを使ってVPCネットワークを定義しています。 ▼リスト3 google_compute_networkを使ってVPCネットワークを定義 resource "google_compute_network" "vpc_network" { name = "terraform-network" } vpc_network がTerraform上の識別名で、互いのリソースを参照するときなどはこの名前を使用します。識別名は開発者が自由に決めてかまいません。 ブロック内の引数は、プロバイダが提供するリソースの仕様にしたがって指定します。google_compute_networkでは、nameが必須となっており、これでGoogle Cloud上における実際のVPCの名前を指定します。 ドキュメント 8 を参照するとほかにもオプショナルな引数があり、たとえばルーティング・モードや、MTUといったパラメータも指定できることがわかります。 TerraformによるIaCの流れ Terraformを利用するときの流れは図2のとおりです。 init などはそれぞれTerraformコマンドのサブコマンドで、 terraform init のように実行します。 ▼図2 Terraform利用の流れ ここからは、簡単なサンプルを使って操作の流れを説明しましょう。 ここで作成するインフラ構成を図3に示します。Google CloudのVPC(Virtual Private Cloud)の中にサブネットを1つ作成するというものです。 ▼図3 サンプルのインフラ構成 まずサンプルコードの内容を解説してから、Terraform実行の流れを説明します。 サンプルコードの解説 図3のインフラを構築するTerraformのコード( main.tf 9 )をリスト4に示します。 ▼リスト4 main.tf provider "google" { ❶ project = "<<プロジェクトID>>" region = "asia-northeast1" } resource "google_compute_network" "my_network" { ❷ name = "my-network" auto_create_subnetworks = false } resource "google_compute_subnetwork" "my_subnetwork" { ❸ name = "my-subnetwork" ip_cidr_range = "10.2.0.0/16" network = google_compute_network.my_network.id ❹ } tfファイルの構成 リスト4は次の3つのブロックで構成されています。 Google Providerへの指示を記述する provider ブロック (❶) VPCを作る resource ブロック (❷) サブネットを作る resource ブロック (❸) ❶のproviderブロックは、表1で紹介した7つのブロックタイプの1つで、プロバイダ共通の情報を定義しています。 たとえば、Google Cloudのプロバイダでは、Terraformの適用先プロジェクトIDなどを指定します。なお、手元で実行してみたい方は、 プロジェクトID の部分をご自分のGoogleCloudのプロジェクトIDに変更してください。 リソース間の参照 リスト4❹では、ほかのリソースの属性を参照しています。たとえば、リスト4❸のサブネットワークは、❷で定義したVPC内に作成します。 google_compute_subnetwork リソースでは、 network 引数でサブネットの所属するVPCを指定します。VPCはリスト4❷の my_network リソースで定義しているので、これを参照します。 Terraformでは、 リソースタイプ.識別子.リソースの属性 という形式で参照できるので google_compute_network.my_network.id と記述しています。なお、ここで id という属性は my_network に記述されていませんが、これはプロバイダが内部で自動生成する属性です。 ブロックの記述順は自由 Terraform Languageは宣言的ですので、各ブロックの記述順は自由です。リスト4におけるVPCとサブネットのようにリソース間の依存関係がある場合、Terraformは依存関係を自動解析して適用順を決定します。 また、ここでは説明を割愛しますが depends_on 10 で依存関係の明示もできます。 init Terraformの初回実行時には、tfファイルのあるディレクトリ 11 上で terraform init を実行して初期化します。Terraformはtfファイルの内容をチェックし、必要なプロバイダやモジュールのダウンロード、バックエンドの初期化などを行います。 「モジュール」の詳細は今回割愛しますが、Terraformにおけるコードのカプセル化や再利用の単位です。関連するリソースをまとめてモジュール化し、variableやoutputブロックで入出を定義できます。自身のコードをモジュールで分割したり、インターネットに公開されたモジュールを利用したりできます。 なお、Google Cloudのプロバイダを使用する際 、Terraform は Application Default Credential(ADC) 12 を使って認証をするので、次のように gcloud コマンドで事前にログインしておく必要があります。 $ gcloud auth application-default login $ terraform init TerraformがダウンロードしたProviderやモジュールは、ワーキングディレクトリの .terraform という隠しディレクトリに保存されます。 plan Terraformを使ううえで最も重要なのが plan と次項で解説する apply です。 plan では、 apply でtfファイルの記述内容をターゲットに適用するときの実行計画を表示します。追加、変更、削除されるリソースや、具体的な変更内容が表示されるので、 apply の実行時に、インフラが受ける影響を事前に確認できます。 リソース追加の例 具体例で確認してみましょう。たとえば、新規リソースを作成するときは、図4のように新しく作成される項目が + 記号で示されます(図4は、先ほどのサンプルで新しいVPCが作られるときのplan出力例です)。 ▼図4 新しいVPCが作られるときのplan出力例 Terraform will perform the following actions: # google_compute_network.my_network will be created + resource "google_compute_network" "my_network" { (…省略…) + name = "my-network" + project = (known after apply) (…省略…) Plan: 2 to add, 0 to change, 0 to destroy. リソース変更の例 次に、一度作成したサブネットのオプションを変更するケースを考えます。main.tfにリスト5の❶の部分を追加してから、planを実行してみます(出力例は図5)。 ▼図5 変更後のplan出力例 # google_compute_subnetwork.my_subnetwork will be updated in-place ~ resource "google_compute_subnetwork" "my_subnetwork" { ❶ id = "projects/xxxx/regions/asia-northeast1/subnetworks/my-subnetwork" name = "my-subnetwork" ~ private_ip_google_access = false -> true ❷ # (11 unchanged attributes hidden) } Plan: 0 to add, 1 to change, 0 to destroy. 今回はすでに存在するリソース( my_subnetwork というサブネット)に対して変更が加わるため、2のように変更箇所 ~ 記号で示されます。 ▼リスト5 main.tfへの追加部分 resource "google_compute_subnetwork" "my_subnetwork" { name = "my-subnetwork" (…省略…) private_ip_google_access = true ❶ } ここでの例示は割愛しますが、リソースの削除や作りなおしを伴う変更の場合も、同様にplanで表示されます。 また、 plan はクラウド上のリソースの状態とステートの比較も行います。このため、Terraform以外の手段でインフラの状態を変更した結果、ステートとの食い違いが発生したときも、 plan によって検知できます。 さらに、 plan はTerraformのコードをリファクタリングしたときや、プロバイダのバージョンを更新したときなど、既存のインフラに影響が出ないことを確認する手段としても重要です。 リファクタリングの内容によっては、クラウド上のリソースが一度削除されてから再作成されたり、プロバイダの仕様変更によって既存のリソースが影響を受けたりすることもあります。 このようなことでインフラに意図しない影響を与えないためにも、 plan による差分チェックはとても重要です。 apply apply は、tfファイルの記述に従って、クラウド上のリソースを実際に変更します。前述の plan を実行しなくても apply は可能ですが、運用中のシステムに対して apply をかける前には、planによる影響のチェックがほぼ必須となるでしょう。キャディでは、GitHub上でPull requestが作られたときに plan を自動実行して差分を確認できるようにしています(次回詳しく紹介します)。 destroy destroy は、Terraformが管理するすべてのリソースを実際のインフラから削除します。tfファイル上で一部のリソースを削除した場合は apply で削除されますので、運用中のインフラに対して destroy を使用することはほとんどありません。検証時や開発環境などで「Terraformから作成したリソースをすべて削除したい」といった場面で使用することがほとんどでしょう。 ステートの裏側 最後に、Terraformを利用するうえで注意すべきステートについて解説します。ステートはTerraformの特徴的な概念であり、実運用中のクラウドインフラをTerafformで管理する際には、そのしくみをしっかり理解しておく必要があります。そうしないと、apply時に予期せぬ影響を与える危険があり、インフラの安定性を損ねるリスクがあるためです。 冒頭でも説明したとおり、ステートはTerraformの管理対象リソースの状態を保持するJSON形式のファイルです。terraformコマンドには、ステートを参照する機能もあります。 たとえば、前述のmain.cfをapplyしたあとのステートを表示するには、 state list コマンドを実行します 13 。 図6のように main.tf に記述した3つのリソースが表示されました。 ▼図6 state listの実行結果 $ terraform state list google_compute_instance.terraform_test google_compute_network.my_network google_compute_subnetwork.my_subnetwork リソースの詳細を表示するには、 state show コマンドを実行します(図7)。出力を見ると、purposeなどtfファイルには書かれていない情報もありますね。これは、Terraformがクラウド上のリソースから読み取った状態です。 ▼図7 satate showの実行結果 $ terraform state show google_compute_network.my_network # google_compute_subnetwork.my_subnetwork: resource "google_compute_subnetwork" "my_subnetwork" { name = "my-subnetwork" gateway_address = "10.2.0.1" ip_cidr_range = "10.2.0.0/16" purpose = "PRIVATE" (…省略…) } importによるドリフトの調整 ここで、ステートを意識すべき運用の実例を紹介します。 何らかの原因で発生するtfファイルとステート、クラウド上のリソース実体にズレが発生してしまうことをドリフトと呼びます。たとえば、tfファイル上の変更がapplyされていない状態もドリフトですが、このようなケースはapplyで解消できます。 一方で、Terraformで自動解決できないドリフトもあり、これはステートを意識した手作業の修正が必要です。その一例を紹介しましょう。たとえば、何らかの事情でクラウド上のリソースを先に手作業で作成してしまい、後追いでTerraformで定義したいようなケースです(図8)。 ▼図8 ドリフトの発生例 手作業で作ったリソースはTerraformの管理外であるため、このままapplyすると名前が衝突して作成できず、実体を削除しなければapplyできません。実体を削除せずに、コードと一致させるにはどうしたらよいでしょうか。 具体例で考えてみましょう。たとえば、図3とリスト4の状態に対して、手作業で新しいサブネット my-subnetwork2 を作成し、その後tfファイルにリスト6のような定義を追加したとします。 ▼リスト6 新しいサブネット作成後にtfファイルへ追加 resource "google_compute_subnetwork" "my_subnetwork2" { name = "my-subnetwork2" ip_cidr_range = "10.3.0.0/16" network = google_compute_network.my_network.id } この状態でplanを実行すると、createの差分が表示されます。tfファイル上のリソース記述はステートに反映されておらず、クラウド上に作成したサブネットはTerraform管理外であるためです(図8)。このままapplyすると、同じ名前のサブネットがすでに存在しているのでエラーになります(図9)。 ▼図9 applyでエラーになった google_compute_subnetwork.my_subnetwork2: Creating... ╷ │ Error: Error creating Subnetwork: googleapi: Error 409: The resource 'projects/xxxx/regions/asia-northeast1/subnetworks/my-subnetwork2' already exists, alreadyExists (…省略…) この状態を解消するために、 terraform import コマンドを使用します。図10の例では、クラウド上の my-subnetwork2 の状態を、ステート上の google_compute_subnetwork.my_subnetwork2 として取りこみます。 ▼図10 terraform importを実行 $ terraform import google_compute_subnetwork.my_subnetwork2 my-subnetwork2 これでtfファイル、ステート、実体がすべて一致するためplanを実行しても差分が発生しなくなります。手作業で作成したサブネットは、無事Terraformの管理下となりました。 Terraformには、これ以外にもステートの状態を編集するコマンドがいくつかあり、それらを活用することで柔軟な運用が可能です 14 。 セキュリティ上の注意 tfstateファイルには、リソースの設定値が含まれるので、取り扱いに注意が必要です。たとえば、TerraformでCloudSQL(GoogleCloudにおけるリレーショナルデータベース)のアカウントを設定するようなケースでは、アカウントのパスワードがtfstateファイルに保存されます。 リモートバックエンドでステート管理する場合は、関係者以外が参照できないようにアクセス権限を適切に設定してください。 また、Terraformにはtfstateファイルを暗号化する機能もあり、これを利用するのが最も確実です。筆者らも暗号化を進めているところです。 まとめ 今回は、Terraformの概念と基本的な使い方、また実運用時に注意すべきステートの扱い方法を中心に紹介しました。次回はGitHub ctions上でTerraformを実行し、Google Cloud上のインフラを自動デプロイするCI/CDパイプラインの構築事例を紹介予定です。 Terraformについては、本誌2022年1月号「TerraformではじめるAWS構成管理」や、『WEB+DBPRESS』Vol.128の「ゼロから学ぶTerraformでも詳しく紹介されています。  ↩︎ HashiCorp 社 https://developer.hashicorp.com/terraform/tutorials Google Cloud https://cloud.google.com/docs/terraform?hl=ja  ↩︎ https://developer.hashicorp.com/terraform/language  ↩︎ 明示しなくてもTerraformが自動判別してくれますが、プロバイダのバージョンを指定する場合は記述する必要があります。  ↩︎ Terraformがサポートするバックエンドは次のページで紹介されています。 https://developer.hashicorp.com/terraform/language/settings/backends/configuration#available-backends  ↩︎ 各ブロックタイプの説明は次のページで紹介されています。 https://developer.hashicorp.com/terraform/language  ↩︎ GoogleCloudPlatformProviderであれば次のページに記載されています。 https://registry.terraform.io/providers/hashicorp/google/latest/docs  ↩︎ https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_network  ↩︎ ここでは main.tf という名前にしていますが、拡張子を .tf とする以外の決まりはありません。ファイルの分割も自由で、実行時のカレントディレクトリ配下のtfファイルがすべて処理対象になります。  ↩︎ https://developer.hashicorp.com/terraform/language/meta-arguments/depends_on  ↩︎ ワーキングディレクトリと呼びます。  ↩︎ ADCは、アプリケーションがGoogleCloudへアクセスするときに利用する標準的な認証のしくみです。 https://cloud.google.com/docs/authentication/application-default-credentials?hl=ja  ↩︎ リスト4ではバックエンドを指定していないので、一度applyを実行するとローカルに terraform.tfstate という名前でtfstateファイルができているはずです。  ↩︎ statemv 、 staterm など。詳細は次のページで解説されています。 https://developer.hashicorp.com/terraform/cli/state  ↩︎
アバター
こんにちは、DRAWER Enabling QAチームの猿渡です。 この記事ではDRAWER QAチームで進めているE2Eテスト自動化についてご紹介します。 課題 CADDi DRAWERにはQAチームがあります。品質保証業務は、開発エンジニアや外部パートナーなど様々な方と連携し行っています。 現在QAが行っているテストは、システム全体をスコープにしたエンドツーエンド(E2E)テストです。。 CADDi DRAWERでは、DRAWER Product Testing Guidelineにより、以下のテストカテゴリを定義しており、E2Eテストでは、Test Size: Largeの「Story Tests」と「Scenario Tests」のCategoryに対して ソフトウェアテスト ライフサイクル(STLC)を行っています。 [DRAWER Product Testing Guideline] Category Test Size Description Run Timing Quality Unit Tests Small 依存はStubする。そのDomainやDomain Service、Use Caseの振る舞いが正常なのか検証する。 Local, CI 内部品質 Integration Tests Medium コンポーネント が外部に依存するDBや外部サービスといった別 コンポーネント との結合をテストする。 Local, CI 内部品質 Component Tests Small 外部依存はStubする。そのService単体として振る舞いが正常に行われているかどうか検証する。 Local, CI 内部品質 Scenario-Based Integration Tests Medium システム全体としてシナリオテストが満たせるかどうか API のみで検証する。つまり、Scenario Test から UI 操作を除いたもの。Stubは使わない。 CI 内部品質 Story Tests Large 開発機能の受入条件が満たされているのか検証する。 Before Release 外部品質 Scenario Tests Large ユーザーの代表的な操作シナリオを定義して リグレッション が発生していないかUIから操作して検証する。 Before Release 外部品質 今のE2Eテストは全て手動テストで実行していることで、今後「E2Eテストがリリースの ボトルネック になることによる、顧客に対する価値提供が遅れ」が顕在化することが想定されます。 CADDi DRAWERの開発チームは、事業の急成長に合わせて拡大し、開発生産性も向上しています。ソフトウェア開発ライフサイクルの高速化に合わせて、 ソフトウェアテスト ライフサイクルも同期させる必要があります。 早くソフトウェアが価値を生み出すために、一定のQuality Gateとして機能しているE2Eテストを高速化させ、顧客への価値提供のリードタイムを短くすることが重要です。 さらに、将来的には、マイクロサービスでのE2Eテストによる品質保証にも課題があると考えます。今まで通りにシステム全体に対するE2Eテストでは、テストがフェールすると、その原因となった問題が解決されるまで、すべてのマイクロサービスのリリースがブロックされてしまいます。マイクロサービスが ユーザーインターフェース を持っている場合のE2Eテストの扱いについては今後のテーマと捉えていますが、独立的なE2Eテストが必要になるのではと考えています。 目指すは『テストピラミッド』のようなShift Leftされた状態を思い描きながら、まずは、ピラミッドを登りながら改善を進めるのではなく、ピラミッドの頂上から改善を進め、「リードタイムの短縮」「マイクロサービスでの独立したE2Eテスト」をGoalとし、E2Eテストをできるだけ自動化することへの取り組みを始めました。 E2Eテスト自動化に向けて QAチームが所属するEnablingチームには、ArchitectureチームとSREチームがいます。特にE2E自動化のCI/CDなど環境構築はSREチームと協力しながら進めています。 E2Eテスト導入のROIとテスト戦略 E2Eテスト導入のROIとして、ローコード系有償ツールを導入した場合の試算をしました。ただし、自動化によって得られる継続的な価値(Delivery高速化の価値など)を数値化することは難しかったので、金銭コスト、時間コストで効果測定しました。 To-BeのE2Eテスト戦略として、手動テストと自動テストの役割を決めました。自動テストの担当はテスト自動化アーキテクトをメインとしたエンジニア(SDET)が担当、手動テストはテストエンジニア(TE)が担当することでハイブリッドなSTLCとし、品質面で品質保証エンジニア(QA)が伴走する形を考えています。 Category テスト担当 品質担当 手動テスト Story Tests: Functional Tests Test Engineer(TE) Quality Assurance Engineer(QA) 自動テスト Scenario Tests: Functional Regression Test Software Development Engineer in Test(SDET) Quality Assurance Engineer(QA) 自動テスト Scenario Tests: Visual Regression Test Software Development Engineer in Test(SDET) Quality Assurance Engineer(QA) 自動化ツール選定 と実行環境 選定はローコード系と オープンソース 系で行いました。費用、学習コスト、汎用性、拡張性、コーディングスキルの観点でPros/Consを整理した結果、 オープンソース のPlaywrightを採用しています。詳細は、次項に記載します。 上記のテスト戦略から、まずは「Visual Regression Test」の自動化から取り掛かることにしました。 Playwright + reg-suitで実践するVRT E2Eテストの自動化を、CI/CDのパイプラインを利用して開発のサイクルに組み込む方針で実装を検討してみました。 上述の通りツールはPlaywrightを採用しており、Playwright単体でもVRTは実現できますが、reg-suitというツールを組み合わせるとより高機能なレポートを生成することができます。 (インストールやセットアップについては今回の記事では扱いません) なぜreg-suitを採用したのか? VRTをするためにシンプルかつ十分なUIが用意されている シンプルにファイル名での比較をするので利用に際しての難易度が低く、合わせて利用するツールの自由度が高い 外部ストレージへの保存がデフォルトで搭載されており、データのポータビリティ性が高い 実装方針 CADDi DRAWERの開発チームではgit tagを利用したリリースフローを採用しており、tag間の差分でVRTを実行する方針とします。 reg-suitには reg-simple-keygen-plugin という プラグイン があり、任意のKeyを利用して比較を行うことができます。この プラグイン のKeyをtagにすることによりtag間の比較を行います。 処理の流れとしては以下のようになります。 1. regconfig. json で設定されているactualDirに画像ファイルを出力する(playwrightのscreenshot機能を利用) 2. regconfig. json に直前のtagと現在のtagを比較するように設定する 3. reg-suitを実行してレポートを作成する git tagベースのVRTの実装 regconfig. json は以下のようになります。 { "core": { "workingDir": ".reg", "actualDir": "directory_contains_actual_images", "thresholdRate": 0, "ximgdiff": { "invocationType": "client" } }, "plugins": { "reg-simple-keygen-plugin": { "expectedKey": "EXPECTED", "actualKey": "ACTUAL" }, "reg-notify-slack-plugin": { "webhookUrl": "<slack incoming webhook url>" }, "reg-publish-gcs-plugin": { "bucketName": "<your bucker name>" } } } expectedKeyとactualKeyは GitHub Actionsの中で直前のtagと現在のtagで置換します。 GitHub Actionsのworkflowは以下のようになります。 name: VRT on: push: tags: - 'v*' jobs: tag_push: runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - id: 'auth' name: 'Authenticate to Google Cloud' uses: 'google-github-actions/auth@v0.4.0' with: project_id: ${{ secrets.GCP_PROJECT_ID }} service_account: ${{ secrets.GCP_WIF_SERVICE_ACCOUNT }} workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} - name: Checkout main branch uses: actions/checkout@v3 with: ref: main fetch-depth: 0 - name: Use Node.js uses: actions/setup-node@v1 with: node-version: "18.x" - name: Run npm install run: | npm ci - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Run playwright run: | npx playwright test - name: Replace reg-suit tag run: | sed -i "s/EXPECTED/$(git tag | tail -2 | head -1)/g" regconfig.json sed -i "s/ACTUAL/$(git tag | tail -1)/g" regconfig.json - name: Run reg-suit run: | npx reg-suit run git tagがpushされるタイミングで実行されます。 reg-suitを実行する際にGCSへの読み取りと書き込みが発生するため、 GitHub Actions内でGCSにアクセスするためにWorkload Identity連携を利用してアクセスできるように設定してあります。 実装方針に則りVRT関連の処理は以下の3 stepになります 1. playwrightを実行してscreenshotを取得する( Run Playwright ) 2. regconfig. json の書き換え、expectedKeyとactualKeyをgit tagで置換する( Replace reg-suit key ) 3. reg-suitを実行してレポートを出力する( Run reg-suit ) 実行が完了すると以下のようなslack通知とVRTのレポートが作成されます(サンプルの スクリーンショット )。 ! 今後の課題 git tagを利用してのVRTを構築しましたが、実際のところアプリケーションのデプロイはArgoCDでのimage tagの書き換えを起点として実行されます。 なので GitHub ActionsのTriggerを利用した実行ではなく、GKEにある該当Deploymentに対するArgoCDでのSyncオペレーションを起点として実行される仕組みが必要になります。 もう1点レポートはGCSに保存されるようになっていますが、レポートを閲覧するためには Bucket を公開設定にする必要がありセキュリティの懸念があります。キャディではCloudflareを利用しているため、Cloudflare Access を利用することで特定のユーザーのみのアクセスに制限するなどの対策を今後実施していく必要があります。 今後のE2Eテスト自動化 Visual Regression TestのSTLCへの組込み後、Functional Regression Testの自動化を試みる予定にしています。CADDi DRAWERでは機能拡張が継続的に行われて、合わせて リグレッション テストもスケールしています。この増加に対処できるように機能テストの自動化が必須であると考えています。複雑度が高く、探索的テストが効果的なものは手動テスト、それに対して複雑度の低いテストは自動化を進める方針です。 E2Eテストでは手動と自動のハイブリッドなテストでプロダクト品質の管理を行いたいと考えています。 QAはチーム立ち上げ期で、不確実性の多い環境の中でQAエンジニアとして組織やプロダクト・サービス品質の向上に取り組んでいます。ソフトウェア品質保証、テストエンジニアリング、テスト自動化のご経験のある方、カジュアル面談もやっていますのでぜひお気軽にご連絡ください。 エンジニア向け採用サイト https://recruit.caddi.tech/ 求人一覧 https://open.talentio.com/r/1/c/caddi-jp-recruit/homes/4139 参考文献 https://www.oreilly.com/library/view/software-engineering-at/9781492082781/ https://www.infoq.com/jp/news/2021/02/end-to-end-testing-microservices/ reg-viz/reg-suit: Visual Regression Testing tool
アバター
 みなさんはじめまして。CADDiで図面解析チームのテックリードをしている稲葉です。今日は、我々のチームがどういった図面解析の機械学習モデルをどのように開発しているのか、それをどのように改善しようとしているかを紹介したいと思います。 目次 どういう図面解析が必要なのか CADDiの機械学習モデル開発の流れ 継続的な機械学習モデルの改善に向けて おわりに どういう図面解析が必要なのか  CADDiでは図面活用SaaSであるCADDi DRAWERを提供しています(DRAWERの詳細に関しては こちら )。図面はどういうものが作りたいかを示した設計図なわけですが、PNG画像やPDFなど2次元図面画像で保管されており、構造化されていないデータである事が多いです。作りたいものが何を素材としているか、どのように加工すべきかなどが画像になっているため、人の目では分かってもコンピュータ上では管理し易い状態になっていません。そのため特定の図面を自動で探し出すのは難しく、ベテランの記憶や勘に頼っていたりします。そこで、DRAWERでは検索・活用し易くするため図面画像から重要な項目を認識して構造化する解析処理が行われています。また、認識する対象範囲もさらに広げることが望まれています。  現状では、どういう認識モデルを開発しているかというと、 類似特徴抽出 形状など OCR 全文字列 表題欄(図面番号や名称など) 寸法 記号検出 溶接記号など などといったものです(図1)。図面解析だけでもかなり多いですね。さらに、図1に示した例のような図面ばかりではなく、一度紙に印刷されたものをスキャンして画像として取り込まれた図面もありますし、手書きの図面もあります。また、図面の描き方やフォーマットはお客様に依っても様々です。もちろん画像処理・アルゴリズムで解ける部分もあるのですが、機械学習の方が向いているタスクや機械学習に頼らざるを得ないタスクも多いです。 図1. 図面解析の例 CADDiの機械学習モデル開発の流れ  CADDiでの機械学習モデル開発の流れの話を紹介したいと思います。みなさんにとっては釈迦に説法の部分もあると思いますし、これが一番良い開発フローだ!と思っているわけでもないのですが、CADDiならではの部分もあると思いますのでお付き合いください。  世の中で機械学習モデル開発の流れは色々なところで語られているのですが、自分はUberさんの機械学習開発プロセスの概念図(図2)がしっくり来ているのでこれをベースに各工程でどういうアウトプットを定義しているかを紹介していきます。全体の大きな改善ループに加え、PoCフェーズの試行錯誤が表されています。 図2. 機械学習モデル開発の流れの例 ( ML Engineering Lessons Uber Learned from Running ML at Scale より引用) 1. Define 1-1. 問題設定  このステップでのアウトプットはPRD: Product Requirements DocumentとDesign Docです。プロジェクトを始める際は、主にプロダクトマネージャらが下記に示す項目を含んだPRDを書いてくれています。 背景 スコープ(やること、やらないこと) ターゲットユーザ ユースケース 機能要件 非機能要件 リリーススケジュール  そして、このPRDを叩き台として関係する開発チームが合同で読み合わせを行い、詳細を具体化したり、懸念点を洗い出したり、機械学習でやるべきなのかどうかも含めて議論します。また、機械学習に限らないですが認識モデルを作る際は、入出力の定義・評価方法・目標値も大事になります。図面特有の記号などはJIS規格で種類や補助記号(図3に記号例を示します)が定められているのですが、いきなり全ての記号に対応しようとするとアノテーション工数が増大しリリースに時間がかかってしまうため、今必要とされているスコープに合わせて調節することもあります。機械学習はやってみないと・実際にデータを見てみないと性能がどれくらい出るかを見積もるのは難しいですが、評価方法や目標値もユースケースに合わせて仮置きではありますが決めています。  ここで話し合った内容を基に、 こちらのml-design-docフォーマット から必要な部分を抜粋し、Design Docを書いています。後のステップを進める中で、ここで決定した内容に追加・修正をすることはもちろんあります。 図3. 表面粗さJIS記号の例( 表面粗さと溶接を図面で指示するJIS記号 より引用) 1-2. アノテーション定義  このステップでのアウトプットはアノテーション定義書・作業マニュアルです。図面という特殊ドメインの画像タスクですので、オープンデータセットをそのまま活用できることは少なく(もちろん事前学習に使うことはできます)、人間による正解値(アノテーション)が必要なことがほとんどです。実際に図面を見つつ決まったスコープに従ってアノテーションの実例や例外ケースなどをまとめていきます。このようなアノテーション定義はドメイン知識がかなり必要なところなので、実際に図面からモノを作る CADDi MANUFACTURING事業 で培った経験を持つ方のお力を借りています。最近では、ドメイン知識をカバーし、アノテーションのQuality, Cost, and Delivery: QCDを高める役割を持ってくれるAnnotation Opsチームが発足したため、とても進め易くなりました。 2. Prototype 2-1. データセット作成  このステップでのアウトプットは入力データと出力したいアノテーションのセットから成るデータセットです。お客様から預かっている大量の図面データをサンプリングして、アノテーション定義に従ってアノテーションしていきます。Annotation OpsにアノテーションのQCDを管理してもらっていると先ほど述べましたが、それぞれで実施している取り組みを紹介します。  まず、Qualityに関してはオンボーディング、Q&A、二段階承認フロー、抜き取り検査を実施しています。オンボーディングでは、実際にアノテータさんにアノテーションタスクの例題をいくつか実施してもらい、想定するアノテーションができるようになるまで練習していただいています。Q&Aでは、アノテータさんからアノテーションの判断に困った際の質問・回答をアノテーション定義書・作業マニュアルとして更新し、できる限りアノテータさん毎にブレが生じないように、アノテーションに迷いが生じないようにしています。質問は基本的にAnnotation Opsのメンバーが対応していますが、認識モデルの学習で問題になりそうなところは機械学習エンジニアと相談して決めています。二段階承認フローは、最初にアノテーションするアノテータさんとは別のアノテータさんがアノテーションをチェックし、修正が必要であればコメントを残し再度アノテーションプロセスに戻すようにしています。抜き取り検査は全て承認が通ったデータをいくつかサンプリングし、Annotation Opsのメンバーが評価しています。この際の良品率をKPIとして追っています。  次に、Costに関してですが、基本的にアノテーション速度を上げるために、プレアノテーションとショートカットキーの利用促進を実施しています。プレアノテーションは、皆さんご存じの通り、既に認識モデルがある場合はその認識モデルの結果を初期アノテーションとして登録することです。ショートカットキーの利用促進は、アノテーションツールにデフォルトで”クラスの切替”や”アノテーション削除”などのショートカットキーが設定されているため、それらをアノテータさんに共有しています。キーボードだけでなく、マウスのボタンにもキーを設定することもできるため試行錯誤しています。  最後に、Deliveryの部分ですが、予実管理を毎日行っています。データセット作成の期日に間に合いそうに無ければ、期日を調整するかアノテータさんのアノテーションと他の作業の工数割合を調整しています。  これらの取り組みはアノテーションツールの選定も重要になります。以前はオープンソースを活用していたこともありましたが、セキュリティ面や上記で示したようなことがプロセスとして組めるかどうかを考慮した結果、 FastLabel さんのツールを利用させていただいています。 2-2. 学習・評価  このステップでのアウトプットは学習・評価コードと評価レポートです。図面解析チームにはkaggle含め経験豊富な機械学習エンジニアが居ますので、バリバリ開発してくれています。実際どういう技術を使っているかや技術スタックは、また別の機会にメンバーから紹介する予定ですのでここでは割愛します。 3. Production 3-1. デプロイ  このステップでのアウトプットはAPIサーバとドキュメント類(モデルカード、テスト結果)です。 ML API基盤 に関しては既にTech Blogに書かれていますので、ぜひ読んでみてください!テストは、PoCの性能が再現できているかの性能テストと想定リクエストでどれくらいのレイテンシ・スループットで処理できるかを見積もる負荷テストを実施しています。PRD作成時に要件を決めていますので、満たせているかどうかを確認しています。 4. Measure 4-1. 監視  デプロイしたWeb APIのレイテンシ・スループットなどは日々監視・ロギングしており、サービス提供に問題が無いかを確認しています。次項で示す機械学習モデルの改善にも関連するのですが、性能面での監視やフィードバックの貰い方の仕組み化も進めているところです。 継続的な機械学習モデルの改善に向けて  ご存じの通り、機械学習モデルは一度作って終わりではありません。新しいお客様に契約いただくことで図面のバリエーションも増えますし、中々現れないレアケースの認識対象もあります。なので、継続的な改善が必要なのですが、そのために”重要なデータを集める仕組み”と”機械学習パイプライン”の構築を考えています。改善ループのイメージを図4に示します。 図4. 機械学習モデルの改善ループ  重要なデータというのは、性能向上に寄与するデータや顧客価値を毀損しているデータのことを指しています。集め方としては下記が考えられます。 能動学習: 不確実性が高いデータを現状のモデルを使ってマイニングする お客様とやりとりがあるCustomer Successチームや実際にデータを処理するOpsチームなど社内全員で課題データを挙げて収集する ユーザであるお客様にDRAWERアプリ内から直接課題データを挙げてもらう これらの内、まずは2の仕組みを作り社内の人間であれば誰でもアノテーションタスクとして登録できるようにして試験運用を開始しました(図5)。DRAWERで登録してある図面画像は一意に定まるIDで管理されていますので、ID群とタスク名と登録した理由を入力して実行するだけでアノテーションツールに登録され、リンク先から実際にアノテーションすることができます。アノテーションツールは FastLabel さんのツールを利用していますが、アノテーションタスクを管理するWebAPIや FastLabel Python SDK が揃っているため、容易にこのようなフローを作ることができました。多くの人間でアノテーション登録をするとノイズになるようなデータが含まれるのでは?という懸念もありますが、データセット化されるためには他のアノテータの承認が必要となるため、アノテーションの品質は担保できると考えています。 図5. アノテーション登録UI  機械学習パイプラインは上記のようにして収集したデータを活用し、定期的に”前処理-学習-評価-デプロイ”を自動実行する仕組みですが、絶賛開発中です。MLOpsチームのメンバーがまた紹介してくれますのでここでは詳細を割愛しますが、機械学習モデルの更新サイクルを高速化するため、また機械学習エンジニアが新しいモデルの開発など得意な領域に注力できるようにするため、開発を進めています。  これらの仕組みを組み合わせることで、お客様などからいただいた課題に迅速に対応してサービス品質を向上させ、より良い顧客体験が産み出せるようにしていきたいと思っています。この辺りの話は今井が Data-centric AI勉強会で話した資料 もありますので、ぜひ目を通していただけると嬉しいです。 おわりに  CADDiの機械学習モデル開発の流れと継続的な機械学習モデルの改善に向けてどのようなことに取り組んでいるかを紹介しました。図面解析は新機能の開発も進んでおり、DRAWERの機能としても増えつつあるのですが、まだまだ必要な機能がありますし、各認識モデルの改善も必要です。また、今回は2次元図面画像解析の話をしたのですが、2次元図面だけでなくもちろん3次元図面(3DCAD)も今後扱えるようにしていきたいと思っています。やりたいことは沢山あるので、ぜひ一緒に開発を推進してくださるメンバーを募集しています。興味のある方、是非気軽にご連絡ください! エンジニア向けサイト https://recruit.caddi.tech カジュアル面談 https://youtrust.jp/recruitment_posts/53ac0bf30d7855e9d45f0ea7fc2be3d3 機械学習エンジニアの求人 https://open.talentio.com/r/1/c/caddi-jp-recruit/pages/79797 The post CADDiの機械学習モデル開発の流れと継続的な改善 appeared first on CADDi Tech Blog .
アバター
こんにちは、DRAWER Enabling Architectureチームの刈部です。 この度、弊社はシリーズCの資金調達を実施しました。これを受けTech Blogを盛り上げようというPRの施策に乗っかり本稿に繋がるのですが、なかなか筆が乗らず気づいたら調達の発表から1ヶ月近く経ってしまいました。計画的に生きたい。 content.caddi-inc.com さて、この記事ではDRAWER開発チームにEventStormingを導入した件について、導入時の課題や良かった効果について紹介しようと思います。 EventStormingとは? 本題に入る前にEventStormingに関する簡単な紹介です。 EventStormingとは、ドメインモデリング手法のひとつです。ドメインエキスパートとステークホルダーがビジネスプロセスを協働して整理することを通じて、サブドメインや境界付けられたコンテキストを見つけ出します。 EventStormingでは3つのアクティビティを順に行います。 Big Picture: ビジネスプロセス上の意味のあるイベントを思いつくだけ付箋に書き出して時系列に並べます。 Process Modeling: 書き出されたイベントの発火条件やデータフローを整理します。 Software Design: Aggregateを抽出します。 EventStormingのアクティビティを進め方については 本家の記事 でも抽象的な説明が多いです。私自身正しく理解できている自信がありませんが、個人的にはBPM(Business Process Modeling)とDDD(Domain-Driven Design)を組み合わせたようなものだと簡単に理解しています。 EventStormingを導入した経緯 現在CADDi DRAWERの開発チームでは、事業の急拡大に対しシステムと開発組織をスケーラブルにするためリアーキテクチャを行っています。リアーキテクチャではサービス導入当初から運用されているモノリシックなシステムの分割やコアドメインとなるドメインモデルやデータモデルの整理が含まれています。 まずそれらに仕掛かるために現状のシステムについて整理する必要がありました。下図は同僚が作成した資料になります。 この資料は画面から呼び出されるバックエンドのAPIと図面解析パイプラインなどが、それぞれどのテーブルを参照・更新しているのか整理したものになります。 これにより現状の把握は劇的にしやすくなりました。一方で「これらの操作は誰がどのようなユースケースで行っており、ビジネスプロセス上どのような意味を持つのか」という文脈は理解できません。ビジネスプロセスを整理した上でリアーキテクチャを進めるためにEventStormingを採用しました。 苦戦したこと 実際にEventStormingのアクティビティに取り組んでみると下記の課題にあたりました。 解釈がメンバー間で揃わない EventStormingの各アクティビティについて明確な作法はありません。チームのメンバー同士でCommandやEventの粒度や書き方が異なることが度々ありました。共通言語がより少ないエンジニアと非エンジニアの間では「何を書くか」ではなく「どう書くか」について擦り合わせる時間が多く必要となりました。 例えば「xボタンがクリックされた」や「xが確認された」のようなイベントです。それらはたしかに”発生する出来事”ではありますが、ビジネスプロセスにおける”重要な出来事”ではありません。 既存仕様に引っ張られる EventStormingに限らないですが、ビジネスプロセスにおける”重要な出来事”ではなく今現在のUIやシステムの制約において”発生する出来事”にどうしても引きづられてしまいました。EventStormingのアクティビティに明確な作法がないため、行き詰まった時に既存仕様や既存設計から発想を拡げやすいからです。 異常系の扱い 設計が進んでいくと様々な考慮事項が見えてきました。特に異常系をEventStormingで表現するのは難しく、異常の内容に応じて細かく分岐を書こうとすると本来描きたいビジネスプロセスにノイズが多く発生するほか、矢印だらけの図になり非常に見づらくなってしまいます。 難しいのは異常系のハンドリングそれ自体ではなく(技術的にはもちろん難しいわけですが)、EventStormingによって解像度が上がった時、我々エンジニアは未考慮のものを未考慮のままにして前に進みづらい性格をしているということです。そうするとビジネスプロセスよりも異常系の場合分けに時間を浪費してしまいます。 工夫したこと 身も蓋もないですが、まずはとにかく慣れることに集中しました。EventStormingもDDDも誰がやっても同じ結果になるものではなく、解像度を上げながら対話によってドメインモデルを深化するしかないと割り切りました。正しくEventStormingを行うことよりも、我々の扱っているビジネスプロセスの関心事に集中すると、自然と枝葉が削がれていきました。 また、小さな成功体験を生むためにもまずは正常系のプロセスを1本通すことを優先し、細かい分岐は後回しとしました。 EventStormingは後のアクティビティになるにつれて点と点が結ばれていきます。すると前半に出した付箋にどのような意味を持つのか、または持たないのか答え合わせされていきます。これによりビジネスプロセスの中で本質的に何に関心を持つべきなのか学習が進みます。 下図が実際にチームで行った成果物になります。 これによってチーム内外で同じものを指差してコミュニケーションが取れるようになりました。システム間のI/Fについても貼られたDomainEventを参考に設計しやすいため、開発者間で認識がズレにくいという効果もありました。 そして全体へ... 前述の通りDRAWERは事業の急拡大に応じてチーム数が急増しました。しかし、システム境界やチーム境界が曖昧な部分があり、チーム間に歪みを生み、ディスコミュニケーションが起きていました。 今回のリアーキテクチャを期にビジネスプロセス全体をEventStormingによって整理して、見つけた境界付けられたコンテキストによってシステム設計や組織設計もしようという流れになりました。 良かった点 大まかに境界付けられたコンテキストが見えてきました。それによりチームやシステムの分割の材料になりました。 現状のチームの隙間に存在するEventや認識の違いが表出しました。またシステムに関係する箇所だけではなく、人力のオペレーションの複雑さも表出しました。 全体へ導入する進め方について、結果論ではありますがトップダウンに進めたことは功を奏しました。リソースや開発の優先度の調整が可能なポジションにある人が全体の旗振りをするのはとても効果的でした。 今後の展望 EventStormingによってビジネスプロセスやドメインモデルの解像度が非常に上がりました。チームを越えて認識を揃えられる点や実装に落とし込みやすい点においても非常に強力なフレームワークでした。 一方で、EventStormingの本当の難しさはやって終わりではなく育てていくことにあると思います。 全体最適の共通言語として 組織が大きくなりチームが分割されていくと徐々に意思決定がサイロ化していきます。チーム間で情報が非対称であったりコミュニケーションコストが大きいと、自分達がコントロールしやすい選択をしてしまいます。 そんな時、各チームでの最適な意思決定を行うのではなく今回のEventStormingの成果物に立ち戻れるようにしていきたいです。プロダクトの機能はシステムだけでなくオペレーションも含めて価値であるということを強く意識し、局所最適の積み重ねではなく全体最適を志向し続ける必要があります。 ビジネスプロセスの洗練 また、EventStormingの成果物を定期的に見直して不要なドメインイベントを減らしていきたいと考えています。不要な分岐が減ることはデータやプロセスの標準化に繋がるだけではなく、システムの運用容易性にも繋がり、システムの拡張性や高いアジリティの獲得に繋がると考えています。 定型句になりますが採用についてです。複雑で絡み合ったドメインモデルをソフトウェアに落とし込むことに興味がある方、良い機能を生み出す組織や文化を作ることに興味がある方を募集しています。カジュアル面談もやっていますのでぜひお気軽にご連絡ください。 recruit.caddi.tech open.talentio.com 参考文献 Resources - EventStorming Domain Modeling Made Functional [Book] Practical Process Automation [Book] イベントストーミング入門【ノーカット版】 - YouTube EventStormingでモデリングしてみた | KINTO Tech Blog | キントテックブログ 新しいモデリング手法: EventStorming (イベントストーミング) をはじめるための準備 - yoskhdia’s diary イベントストーミング導入
アバター
注意! 2023年8月時点の内容となりますので、参考情報としてご覧ください。現在、 アーキテクチャ を見直し、同等の機能をより効率的に実現できる構成にして随時開発中です。機会が来たら新しい アーキテクチャ の構成を紹介します CADDi Platformグループの前多です。 私たちはCADDiのプロダクト横断の技術課題を解消するための活動をしています。 これまでの活動の詳細は 信頼性を高めるサービス基盤と技術選定 を見てください。 これまでの活動は クラウド インフラや開発環境の整備などが大半でしたが、今後のCADDiのプロダクト開発を発展させるために、プロダクト共通で必要となるサービス基盤の開発にも着手しています。 現在私たちが開発しているのは、CADDiプロダクト全体で利用する想定の認証認可基盤です。 認証認可に関する製品は、 Auth0 などの SaaS をはじめ、他にもさまざまな製品があります。 私たちが開発している認証認可基盤は、これらの製品をただ導入するだけではなく、CADDiの事業形態に合わせて複数の製品や自作のサービスを組み合わせて構築したものです。 この記事では、CADDiのプロダクトの変遷および、独自の認証認可基盤を開発する理由と設計について記します。 なお余談ですが、私たちの認証認可基盤には Notcher というコードネームが付いています。 Notcher というのは切り込みを入れるためのハサミのことです。 かつての日本の鉄道では、紙の切符に駅員の方がハサミで切り込みを入れて、駅のホームに入ることができました。 つまり、切り込みを入れた切符は トーク ンであり、私たちの認証認可基盤も認証の結果として トーク ンを発行することから、コードネームの由来となっています。 CADDiプロダクトの変遷 CADDiの事業は製造業の部品の発注から納品までを 一気通貫 で行う CADDi MANUFACTURING から始まっています。 CADDi が企業から発注を受け、部品の製造をパートナーに依頼し検品との納品までを行うため、製造過程を管理するためのプロダクトを内製しました。 少々古い内容ですが、内製プロダクトについては こちらの記事 などが参考になるでしょう。 その後も私たちは事業の内容や拡大に伴い、いくつかのプロダクトを作っていきました。 そこで、大量に発生する紙の図面と製品を効率よく管理するために、図面にIDを付与し、紙のデータをデジタル化して管理するプロダクトが生まれました。 そのプロダクトを開発した経験から、図面をデジタル化して有効活用するニーズは製造業に広くあるのではという仮説が生まれ、 AIによる類似図面の判定機能を追加して SaaS 化したものが、 CADDi DRAWER です。 ここに来て私たちは、内製プロダクトを SaaS 化し、かつCADDi社内での利用から社外への展開をはじめました。 もちろん上記で触れた以外にもCADDiでは多くのプロダクトがあります。 そして、将来これらのプロダクト群を連携させ、社外にも展開していく構想があります。 そのためには次のような考慮が必要です。 プロダクト群を一貫したユーザー体験で提供する API を統一された方式で公開し、CADDiプロダクト群や サードパーティ のツールとも連携する 社内のプロダクト開発やプロダクト間連携の標準化・効率化・品質担保を推進する この構想を実現するための下地として、認証認可基盤の開発を始めました。 認証認可基盤が必要な理由 CADDi のプロダクトは社内プロダクトから始まったため、社員が使えれば良いということで認証認可は最低限の実装となっていました。 CADDi では社員アカウントは Google Workspaceで管理しています。内製プロダクトの認証は Auth0のSocial Login を使って、 Google Workspaceのアカウントでログインしていました。 社内利用ということもあり、当時は社員のログインだけできればよく、認可の要件はありませんでした。 CADDi DRAWER の認証についても、当時は使い慣れたAuth0で利用者を管理することにしました。 CADDi Drawer専用のAuth0テナントでアカウント管理をしていて、利用者の所属企業などはユーザーの属性として保持しています。 この頃から、あるプロダクトから別プロダクトのデータを取得したいといった要望が出てきます。 しかし人による認証を念頭に置いていたため、 API 間の認証についての検討が十分ではありませんでした。 また、他にも社外提供を前提としたプロダクトの開発が始まったり、 CADDi Drawerでも会社ごとに独立した2FAやパスワードポリシーを設定したり、認可制御をしたいといった要望も出てきました。 拡大し続けるプロダクトについていけるように、今まで劣後してきた認証認可について考え直す必要性が出てきました。 認証認可基盤の設計 新しく認証認可基盤を設計するにあたり次のことを重視して設計しました。 また、OIDC/OAuth2 という認証認可の標準に則ることを前提としています。 マルチテナントのユーザ管理 認証と認可の分離 セキュアな API 間通信 マルチテナントのユーザー管理 これまでのプロダクトの認証は、プロダクトの性質に合わせて複数のAuth0テナントに分散していました。 また、CADDi Drawer ではユーザー属性に所属企業の情報を持たせることでユーザーの所属を設定していました。 この状態のままプロダクトを拡大していくと以下の点で運用しづらくなります。 まず、SSO( シングルサインオン )のような認証機能の使いやすさを大きく損ねます。 プロダクトごとにAuth0テナントのような認証サービスが分かれていると、複数のプロダクトにわたって共通でログインすることができません。 同じIDであってもプロダクトごとにログインをしたりパスワードが分かれてしまったりします。 従来では、認証サービスから Google workspace によるSocial Login によって社員は同じIDが使用できましたが、社外利用のユーザーにとってはそうでありません。 将来のプロダクト増加と利用企業増加に伴う、単一の認証サービスが必要です。 そして、単一の認証サービスであっても利用する企業ごとにユーザーを管理できるマルチテナントの機能も必要です。 現在のCADDi Drawerでは利用企業のユーザー管理はCADDiで実施しています。 将来的には、CADDiのプロダクトを利用する企業自身でユーザー管理を行うことを理想としています。 また、利用企業のセキュリティ基準に合わせた セキュリティポリシー や認証機能を実現することも想定しています。 現在でも企業ごとに、MFAやIP制限といった追加ルールを提供していますが、 さらに SAML や SocialLogin、 パスワードポリシーの設定といった内容を企業ごとに独立して設定したいという要望に対応したいと考えています。 このような背景から、単一システムでマルチテナントに対応するユーザー認証サービスの実現を根幹として、認証認可基盤の設計を進めました。 当初は独自開発を考えていませんでしたが、 OSS 、有償製品、 クラウド のサービスなど広く調査した結果、この要件を完全に満たしコスト的にも満足できるものはありませんでした。 ここでネックとなったのが、 「CADDiのプロダクトを利用するユーザーは、企業の中の一部門の方に限られる」 という背景です。 つまり、利用テナント数は多いがテナント内のユーザー数は決して多くないという想定です。 多くの製品では、テナント数の制約に上限があるか追加コストが必要でした。 また、ユーザー数が増えるほどコストが下がるような恩恵もこの形態では得られません。 そこで、認証サービスについては単一の製品で機能を実現することは諦め、低コストのID管理サービスに自分達で必要な機能を追加するという方針をとっています。 認証と認可の分離 マルチテナントのユーザー認証と同時に検討していたのが、認証と認可の分離です。 一般的にマルチテナントというと、認証認可に関する機能すべてが独立したものとして扱われます。 例えば、署名なOIDC/OAuth2の OSS である Keycloak のマルチテナント機能は、 テナントごとにユーザー管理だけでなく、OIDC/OAuth2クライアントの設定が全て独立するというものです。 CADDiのプロダクトにおけるマルチテナントの要件を検討した結果、すべての設定が独立していてはまずいケースがあることがわかりました。 CADDiにおけるテナントとはプロダクトを利用する企業(顧客やパートナー)です。 マルチテナントとして全ての設定が独立している場合、あるプロダクトはその企業専用のデータを持つことになります。 私たちのプロダクトも各テナントごとに専用の設定でデプロイする必要があります。 (もっと正確に言えば、プロダクトごとに発行したOAuth2クライアントを識別して取り回す必要があります。) また、プロダクトによっては企業間で問い合わや質疑応答をするといったような、単一のプロダクトを複数の企業で使うような形式もありえます。 つまり私たちのプロダクトは利用者がマルチテナントである必要はあるが、プロダクトは複数テナントのユーザーを扱える必要があります。 ここで思い至ったのは、ユーザーの認証とプロダクトの認可を分離するというア イデア です。 プロダクトのログインは単一だが、ログインのプロセス中で利用者が所属テナントを入力してログインします。 プロダクトは認証の結果として、テナントと利用者IDの2つの属性でユーザーを特定します。 似た仕組みとしては Auth0 Organizations がありますが、 私たちの認証認可基盤では企業ごとの独立性を、より高めています。 このような認可・認証の分離を実現するために、 Ory Hydra を認可サービスとして採用しました。 Hydra は OpenID Foundationによって認証された OIDC/OAuth2 の OSS のライブラリです。 特徴的なポイントは OAuth2の機能に特化していて、ログインに関する機能は Pluggableになっていることです。 詳細は Hydraのログインフローのドキュメント を参照してください。 Hydraはログインプロセスの中で設定に記載されたログインエンドポイントを呼び出し、ログインエンドポイントは認証結果をHydraに返します。 Hydraはその結果から、IdToken,AccessTokenを発行し、そのあとはHydraがログインセッションや トーク ンを管理します。 この仕様を守っていれば任意のログイン処理が利用できるため、前述のマルチテナント認証サービスをHydraと組み合わせることで要件を実現しています。 余談ですが、Oryは Kratos というID管理の OSS も提供していて、これらを組み合わせた Ory というIDaaSを展開しています。 残念ながら、このサービスも私たちの想定するマルチテナントの機能はありませんでしたので、今回はHydraの OSS 版を使用しています。 セキュアな API 間通信 最後に API 間通信です。 私たちがプロダクトに実装してきた認証処理は、主に人がログインする前提で設計していたため、プロダクト間で安全に API を呼ぶための仕組みやルールが不足していました。 過去にはブラウザでログインした後に、開発者ツールでアクセス トーク ンを抜き出して curl でAuthorization Header に トーク ンを設定するといったようなことまでしていました。 そのほかに、内部利用を想定していたため、そもそも認証がかかっていない API もあります。 私たちが主に利用している Auth0 にも Mchine to Machine Token (M2M Token) という仕組みがあります。 ただし、M2M Tokenは月間の発行上限が決まっているため、内部利用ならともかく外部公開を前提とすると、想定外の使い方をするクライアントがいた場合、発行上限を迎える可能性が常にあるため、少々使い勝手が悪いものでした。 また、 Auth0は複数のAPIの呼び出しを複数のAudienceとして設定できないという仕様 があるため、 API 連携を拡大していく方針と相性が良くありませんでした。 そこで、まずは API 用のアクセス トーク ンを発行する機能として、前述の Hydraを使用します。 Hydraは Client Credentials Grantによるアクセス トーク ンの発行ができます。 また最近のアップデートによってWebHookでアクセス トーク ンをカスタマイズできるようになったため、任意の属性をクライアントごとに付与できます。 次に、リソースサーバー( API )を管理する仕組みを自作しました。 リソースサーバーにはOAuth2スコープや、WebHookで トーク ンをカスタマイズするための スクリプト を設定できるようにしました。 この仕組みとHydraを連携して、クライアントがアクセス可能なリソースサーバーとスコープを厳密に管理できるようにしています。 最後に、 API 側の実装を簡略化するプ ラク ティスの提供です。 私たちの認証認可基盤はあくまでプロダクトの認証の結果として トーク ンを発行し、それをカスタマイズすることだけです。 プロダクトで行う トーク ンの検証などはプロダクト側の責務ですが、なるべく実装を省力化することも視野に入れています。 私たちはプロダクトを GKEや Cloud Run上で稼働させていて、 GKE ではサービスメッシュを導入し、Cloud Runでは サイドカー コンテナの設定ができるようになりました。 サイドカー コンテナ上で、アクセス トーク ンの検証を実施したり、 API を呼び出した時に サイドカー コンテナで透過的にアクセス トーク ンを発行してリク エス トに付与するといった仕組みを検討・実装しています。 まとめ これまでに解説した内容を図にまとめると次のようになります。 複数テナントのユーザーアカウントを認証する認証サービスと、プロダクトからのOAuth2リク エス トを処理する認可サービス(hydra)があり、 これらを設定するためのAdmin UIがあります。 認証認可基盤を利用するプロダクト(クライアントとリソースサーバー)は、Amdin UI上からクライアントとリソースサーバーの情報を設定しておきます。 認可サービスにOAuth2に従った認証リク エス トを行うことで、その結果としてアクセス トーク ンを得るので、アクセス トーク ンをリソースサーバーへのリク エス トに付与します。 その際、人によるログインであれば認証サービスのログイン処理が行われテナントを特定してログインを実施します。またその場合、ID トーク ンも同時に発行されます。 リソースサーバーでは サイドカー 自身でアクセス トーク ンの検証を行い、問題なければ処理を続行します。 以上が現時点での認証認可基盤の内容です。 認証認可基盤はまだ完成しておらず、今後も次のような機能を実装予定です。 ユーザー管理 API とUIの作成 パスワードポリシーなどのセキュリティ機能 運用監視の向上 また、認証認可基盤の開発がひと段落したら、他にもプロダクト横断の共通機能の開発を継続的に行っていく予定です。 私たちのグループでは以下のような方をお待ちしています。 拡大していくプロダクトの開発を支えたり共 通化 することに興味がある方 認証認可、OIDC/Oauth2に興味がある方 Platform Engineeringに興味がある方 SREに興味がある方 SREに興味がありつつ、開発もしたい方 興味がある方はぜひ一度お話しましょう。 CADDi エンジニア向け採用情報 カジュアル面談お申し込みフォーム
アバター
※本記事は、 技術評論社 「Software Design」(2023年5月号) に寄稿した連載記事「 Google Cloudで実践するSREプ ラク ティス」からの転載です。発行元からの許可を得て掲載しております。 はじめに 第1回(本誌2023年4月号)では、キャディにおける Google Cloudを中心としたサービス基盤の全体像を紹介し、信頼性向上のために筆者らが心掛けている技術選定基準について触れました。 今回からは数回にわたって、 Terraform を中心としたIaC(Infrastructure as Code)の実践例を紹介予定です(図1)。 ▼図1 CADDiスタックにおけるTerraformの位置付け 今回は「インフラの信頼性」という側面で考えるIaCの重要性と、 Google Cloudを中心とした クラウド ベースのシステムのIaCにおいて、なぜTerraformとの相性が良いのかを説明します。 クラウド ネイティブにおけるIaCの必要性 システムのインフラをコードとして記述すること、すなわちInfrastructure as Code(IaC)は、すでに当たり前のプ ラク ティスとなりつつあります。たとえば、オンプレミス上のシステムや、 パブリッククラウド 上で Amazon Web Services ( AWS )のEC2など 仮想マシン ( VM )ベースのシステムを構築する際は、Ansibleを使ってIaCを実現する事例が増えてきました。また、 AWS CloudFormationを利用して AWS 上のリソースを作成している方も多いでしょう。 手作業によるインフラ管理の課題とIaCによる解決 AWS 、 Google Cloud、 Microsoft Azureなどの パブリッククラウド は、いずれもWeb上のUIから VM インスタンス を起動するなどの操作が可能で、手軽なことがメリットの1つです。その一方、 クラウド ネイティブな アーキテクチャ で実運用可能なシステムを構築するには、さまざまなサービスを組み合わせる必要があり、膨大な設定が必要となります。 これらをWebUIからいわゆる「ポチポチ」で設定するのは非現実的で、言うまでもなく次のような問題が生じます。 構築作業に時間がかかる 設定内容を記録しにくい 全体の見通しが悪く、レビューしにくい WebUIによるインフラ構築は手軽であるため、学習やコンセプト検証など、試行錯誤が主体の作業とは相性が抜群です。しかし、実運用するシステムを構築・維持するうえでWebUIだけが設定手段となっている場合は、従来のサーバ構築よりも非効率になってしまいます。 また、WebUIでは設定内容の記録が困難なことも大きな問題です。ここから、さまざまな問題が派生します。 通常、システム運用時には、開発環境、テスト環境、本番環境など、複数の環境が必要になります。これらを手作業で同じように構築するのは手間がかかりますし、ミスも発生します。 記録が困難であることからノウハウを残しにくく、インフラの品質も属人的スキルに依存しがちになります。変更管理ができないことから、インフラの変更意図がわかりにくく、障害発生時に元の構成に戻すことも困難でしょう。 設定内容のレビューをするにしても、Web上の管理画面から確認が必要で、大規模なシステムでは確認箇所が広範囲にわたります。このように見通しが悪い状況ではレビュー効率が悪く、問題を適切に見つけることも困難です。 IaCによる解決 そこで必要となるのが、IaCの考え方です。 旧来なら、 シェルスクリプト などでOSの状態を変更したり、設定ファイルを書き換えたりといった手続き的な処理の自動化も、広義のIaCと捉えることもできました。すなわち、「サーバ構築手順書」をそのままプログラム化するような考え方です。 また、主要な クラウド サービスでは、インフラ操作のための CLI コマンドが提供されています。 AWS であれば aws 、 Google Cloudでは gcloud 、 Microsoft Azureなら az といったコマンドです。 WebUIの代わりにこれらの CLI を利用することで、効率や記録の問題はある程度解決できるでしょう。 しかし、 CLI コマンドによる構築は手続き的です。このため、見通しの悪さやレビューのしにくさの改善に対しては、あまり寄与しません。 現代の理想的なIaCとしては、インフラの「状態」を宣言的に記述することが考えられます。ツールによって、記述されたコードの状態と等しくなるように、インフラを自動設定するのです。 インフラの信頼性に寄与するIaC さて、IaCというと「自動化による効率改善」というイメージが強いかもしれません。もちろんこれは大きなメリットであり、「開発」「テスト」「本番」など、複数環境の構築も容易になります。 しかし、筆者らがそれ以上に大きなメリットと捉えているのが、次に挙げるような信頼性向上への寄与です。 レビューしやすくなる ツールによるチェックができる 変更管理ができる 再現性・自動デプロイに繋げられる 再利用化で知見が共有できる プルリク エス トによる相互レビュー IaC化されたコードは GitHub などの ソースコード リポジトリ で管理でき、アプリケーションコードと同じようにプルリク エス トによるレビューが可能になります。これによって、構築や変更前にチェックができ、品質向上につながります。 ツールによる自動チェック さらに、チェックツールを導入することでレビュー自体を半自動化できます。 たとえば、筆者らがIaCツールとして採用しているTerraformでは、 tflint や tfsec といったチェックツールがあります。tflintでは、設定内容の妥当性、 命名規則 、ベストプ ラク ティスに従っているかなど、 プログラミング言語 の静的チェックツールと同じようなチェックが可能です。tfsecでは、セキュリティ上問題となる設定がないかの確認ができます。 このような自動チェックを事前に実施することで、人間によるレビューはより本質的な点に絞ることができ、作業効率と精度の両面に寄与できます。 変更管理が可能 コード リポジトリ で変更管理が可能になることで、構成変更に起因する障害が発生したとき、 ロールバック もしやすくなります。 クラウド ネイティブなシステムでは、アプリケーションとインフラ構成が密接に絡みます。たとえば、アプリケーションが パブリッククラウド の提供するキューイングサービス( AWS ならSQS、 Google CloudならCloud Pub/Subなど)を利用するとします。その場合、アプリケーションコードと、キューを構成するインフラコードのバージョンには整合性が求められるでしょう。 インフラがIaC化されて リポジトリ 上で管理されていれば、アプリケーションとインフラの整合性を取るのが容易になります。 再現性 IaC化の大きなメリットは再現性が得られることですが、これには信頼性向上という側面もあります。たとえば、誤って環境を壊してしまっても元に戻すことができるので、ダウンタイムを最小化できます。 筆者らは過去に、あるプロダクトの開発環境において不注意で Kubernetes ( K8s ) クラスタ を削除してしまったことがありました。幸いにもこれらはIaC化されていたため、比較的短時間で復旧できました。 また、 ディザスタリカバリ 観点でも事業の継続性に寄与します。 再利用化による知見の共有 手作業によるインフラ構築は、ノウハウが担当者の中に閉じがちで 暗黙知 となりやすいです。 TerraformやAnsibleなど、たいていの構成管理ツールでは、コードをモジュール化して再利用する機能が提供されています。ノウハウをモジュール化して再利用することで、インフラの品質向上が望めます。 たとえば、Terraformの Google Cloud向けモジュールでは、管理者がシステムにアクセスするための「踏み台サーバ(Bastion server)」を構築するためのモジュール bastion-host が公開されています。 このモジュールでは、ネットワークや認証のしくみもセットで構築してくれるので、踏み台サーバ構築のノウハウが再利用できる状態と言えます。 IaCのデメリット 一方で、次のような点はIaCのデメリットと捉えられることもあります。 初期構築が大変 変更コストがかかる 初期構築の大変さ 初期構築について、とくに慣れない段階では時間がかかります。筆者らも開発チームからIaCについて相談を受ける際は、開発初期段階では無理にIaC化しなくても良いとアド バイス しています。 IaC化を進めるには、手作業で構築してから、ツールでコード生成するのが1つの方法です。 たとえばTerraformの場合、主要な パブリッククラウド に対応している Terraformer や、 Google Cloudであれば、 gcloud resource-configコマンド といった選択肢があります。 Terrformerは、利用する クラウド サービスに対応するTerraformプロバイダの手動インストールが必要という手間はありますが、さまざまな クラウド からコード生成できる点が魅力的です。 gcloud resource-configは、普段からgcloudコマンドを利用していれば、追加インストールが不要なので、手軽に利用できます。 筆者も現段階では両者を軽く試用した程度ですが、Terraformerの方がTerraformコードをきちんとファイルに分けて出力してくれるため、見通しが良さそうと感じています。 いずれにせよ、自動生成されたコードは土台と割り切ってしまい、それらを参考にしながらコードをきれいにしていく必要はあるでしょう。 また、組織として知見が溜まってくれば、ベースとなるコードを共有することで、イニシャルコストを下げられそうです。筆者らもこの点に関してはまだ手探りの段階です。 変更コスト IaC化されたインフラの変更コストは、信頼性向上との裏返しではあります。インフラのコードをGit リポジトリ で管理し、プルリク エス トをレビューしてから反映するというプロセスは、WebUIから変更するのに比べると手間がかかる印象を持たれるでしょう。 実際のところ、次回以降で説明するようにCI/CDパイプラインを構築することで多くの作業は自動化でき、それほどの手間はかからなくなります。 しかし、とくに慣れないうちは 心理的 ハードルが高くなるのは否めません。たとえば「パフォーマンス調整のために K8s クラスタ 上でノードプールの インスタンス 数を変更する」といったケースでも、プルリク エス トとレビューが必要になります。 一方で、プルリク エス トに変更理由を記載して妥当性をレビューできるのは、インフラの信頼性を保つうえで大きなメリットです。このため、信頼性とアジリティ(機敏性)のバランスが重要です。 キャディのプロダクトにおけるインフラについては、IaCによる管理を基本としつつも、「検証のための一時的な変更などは、開発環境に限ってWebUIからの変更をOKとする」というルールを導入しました。このようにして、信頼性と変更コストのバランスをとっています。 Terraformを採用する理由 筆者らがIaCツールとしてTerraformを採用する大きな理由は、次の2点です。 クラウド サービス上のリソースが扱える さまざまな クラウド サービスに対応している クラウド サービス上のリソースが扱える IaCツールというとAnsibleが有名であり、本誌でもたびたび取り上げられています。しかし、AnsibleとTerraformでは、図2のように位置付けが少し異なります。 ▼図2 AnsibleとTerraformの想定ターゲット Ansibleの想定している主な役割はサーバ設定であり、OSよりも上のレイヤがターゲットです。一方Terraformは、 クラウド 上のリソース作成や設定変更を主な役割と想定しており、Ansibleがカバーするレイヤをメインターゲットとしていません。 AnsibleとTerraformはそれぞれ基本的なしくみも異なります。Ansibleは設定対象のサーバ(マネージドノードと呼ばれる)に SSH でログインして「モジュール」と呼ばれる小さなプログラムを送り込み、そのモジュールがマネージドノードを設定します(図3上)。 一方、Terraformではターゲットとなる クラウド サービスのWeb API を呼び出すことで、リソース作成や変更を実現します(図3下)。 ▼図3 Ansible(上)とTerraform(下)のしくみの違い このような設計思想の違いはありますが、互いの領域が重なっていないわけではありません。 Ansibleでは、 AWS や Google Cloudのリソースを構築するためのモジュールが提供されており、 クラウド サービスのリソース管理もできます。 一方、TerraformでもProvisionersという機能でAnsibleのようなサーバの内部の設定変更も実現できます。 ただし、Terraformに関しては Provisioners のドキュメントによると、あくまでも「最後の手段」と位置付けられていることから、主要な使い方でないことがわかります。 なお、各 クラウド サービスへの対応状況については注意が必要です。 筆者らが利用している Google Cloudについては、Terraformでは Google とHashiCorpのTerraformチームがメンテナンスする公式の Provider が提供されており、頻繁にメンテナンスされています。 一方Ansibleの Google Cloud向けコレクション の開発状況は、 GitHub の履歴を見る限りそれほど開発が活発ではなかったものの、2022年12月からリリース頻度が早くなっており、今後に注目です。 なお、Ansibleにおける「コレクション」とは、一連の機能に関するモジュールの集まりのことです。 幅広い クラウド サービスへの対応 IaCツールを選定する際、まず クラウド サービス固有のツールが選択肢に挙がるでしょう。 「AWS CloudFormation」 や、 「Google Cloud Deployment Manager」 などです。 これらのツールは、当該 クラウド サービスとの高い親和性が利点である一方、幅広い クラウド サービスには対応できないという弱点があります。 Terraformには「Provider」という プラグイン 機構があり、 クラウド サービスの対応はそれぞれの Provider によって実現されています。 Providerは、 Terraform Registry で公開されており、コード内で宣言するだけでTerraformが自動的にProviderをダウンロードしてくれます。このようなしくみから、Providerがあれば、さまざまな クラウド サービスに対応できます。 もちろん、必要に応じてProviderの自作もできます。 また、 AWS 、 Google Cloud、Azureなど主要な パブリッククラウド については、HashiCorp社によるオフィシャルProviderが提供されているため、安心して活用できます。 Google Cloudでは、Terraformの利用に関する 公式ドキュメント も充実しています。 キャディのインフラでは、 Google Cloudを中心としつつも、さまざまなXaaSを組み合わせているため、インフラをコード化するツールとしてTerraformが最適といえるのです。 [Column] Ansibleと比較してTerraformが良い点 筆者自身、キャディへ入社する前はAnsibleを長く利用していました。いささか主観的ですが、双方を使った経験から、Ansibleと比べてTerraformのほうが良いと感じる点を紹介します。なお、筆者が最後にAnsibleを触ったのは2021年秋ごろなので、その後改善されている点があればご了承ください。 純粋に宣言的な書き方ができる Ansibleでは手続き的な書き方ができてしまうので、 YAML でプログラミングをするようなイメージになりがちです。また、コードを書くときにも冪等性を意識した書き方をする必要があり、習熟に時間がかかりました。コードを読み解くのにも慣れが必要です。 一方、Terraformのコードは純粋に宣言的なので、「あるべき姿を記述する」ことに集中でき、コードの可読性も高いです。冪等性は、 クラウド サービスまたは TerraformのProvider内部で担保されるためです。 エラーメッセージが読みやすい Ansibleのエラーは、メッセージを含む JSON が改行なしに出力されるため、読み解くのが非常に困難です。Terraform のエラーは整形されてわかりやすく表示されます(図A)。これはTerraformに限らず、HashiCorp製品全般に言えます。 ▼図A Terraformのエラー表示例 環境の影響を受けない 設計上仕方のないことですが、Ansible自体が Python で動作するため、ホストで実行される Python の影響を受けます。 古いホストでAnsibleを動かす際、Ansibleが要求するバージョンの Python がインストールされていないことがありました。 このため、venv 1 でAnsible用の Python 環境を隔離するといった対応が必要になりました。 TerraformはGo言語で実装されており、ランタイムが不要なバイナリとして提供されるため、このような影響を受けることがありません。 特性の差に対する理解が必要 一方で、TerraformにはState 2 というAnsibleには無い概念があるため、その考え方を理解しないと混乱してしまいます。Stateとは管理対象リソースの状態を保存したファイルのことで、TerraformはStateを中心にコードと実環境の差分をチェックする考え方です。AnsibleはPlaybookの記述内容を正とすることで、冪等性を実現します。 また前述のように、TerraformはOSよりも上のレイヤに対しては使えません。たとえば、 クラウド 上に構築した 仮想マシン の内部を設定したいといった場合は、依然としてAnsibleが有力な選択肢です。このようなケースで クラウド 自体のリソース管理もしたい場合は、すべてをAnsibleで統一するというのも良いかもしれません。 キャディではすべてのアプリケーションをDockerコンテナ化しており、 仮想マシン を使用するケースは踏み台サーバなど限られています。 AnsibleがカバーしているOSよりも上のレイヤはDockerfile として記述できるため、すべてをTerraformでカバーできています。 まとめ 今回はまず、信頼性の高いインフラを運用する基盤となるIaCの考え方を紹介しました。そして、 Google Cloudを中心とした クラウド ネイティブなインフラを構築している弊社にとって、IaCツールとしてTerraformが最適であると判断した理由を解説しました。 今後は GitHub ActionsとTerraformの組合せでインフラのCI/CD実践例を紹介する予定です。次回はまず、Terraformに触れたことがない方向けにTerraformの基本を解説します。お楽しみに。 仮想環境を分離する Python の標準機能です。  ↩︎ Stateについては次回に詳しく解説します。  ↩︎
アバター
こんにちは。DRAWER SRE の廣岡です。最近は開発チーム内の権限付与方針の整備や、他チームのインフラ構築のサポートなどに取り組んでいます。 さて、キャディではサービス構築のために Google Cloud のマネージドサービスを多く利用しており、そのご縁で先日 Google Cloud 様主催の「Digital Native Leader’s Meetup」という企画に招待いただきました。本稿はこのイベントの参加レポートとなります。少しでもイベントの雰囲気を感じていただけると幸いです。 Digital Native Leader’s Meetup とは 本イベントは、Google Cloud 様が主催するエンジニア向けのネットワーキングイベントです。今回はキャディの窓口担当の方にご紹介いただき、参加いたしました。 第3回となる今回ではデータベースをテーマに、Google Cloud 製品のアップデート情報や、他のユーザー企業様によるサービス利用事例のライトニングトーク、参加者同士のアンカンファレンスが実施されました。これらを通して Google Cloud のサービス利用や、利用シーンにおける知見を交換し、プロダクト開発を加速するのが目的となっています。 会場の様子 会場は Google 渋谷オフィスでのオフライン開催でした。ユーザー企業からは40人程度が参加していたかと思います。軽食やお酒も用意いただいており、カジュアルにお話を聞くことができました。 イベントの内容 イベントのコンテンツとしては以下の通りでした。 – Google Cloud サービスアップデート – ユーザー企業様による Lightning Talk – 参加者によるアンカンファレンス Google Cloud のサービスアップデートについては非公開情報のため、本記事では掲載できないのですが、Google の技術力を感じさせる内容であり、今後のアップデートが楽しみになりました。 ユーザー企業様による Lightning Talk LT では、AlloyDB へのデータベース移行事例や、Cloud Spanner の入門・導入事例の紹介がありました。 AlloyDB はパフォーマンスやスケーラビリティ、可用性に優れたフルマネージドデータベースサービスです。事例紹介ではデータベースの移行サービスである Database Migration Service と合わせた取り組みを紹介いただきました。 データベースの移行といえばかなり慎重を要する作業ですが、これらのマネージドサービスを用いてうまく移行を進められたとのことでした。また実際使ってみたからこそわかるような AlloyDB の良い点や、将来への改善要望なども伺うことができ、非常に参考になりました。 PostgreSQL 向け AlloyDB Database Migration Service | Google Cloud Cloud Spanner は強整合性とグローバルなスケーラビリティ、そして高い可用性を持つフルマネージドデータベースサービスです。LT では Spanner の入門的な内容と、導入事例が紹介されていました。実際に導入した感想としては、やはりスケーラビリティが非常に優れているとのことでした。 個人的に Spanner は大規模サービス向けだと思っていたのですが、小規模にも柔軟にスケールできるというお話があり、意外に感じると共に見識を深めることができました。 Cloud Spanner PostgreSQL 向け AlloyDB – AlloyDB と Google Cloud のそれ以外の PostgreSQL オプションを比較する 参加者によるアンカンファレンス アンカンファレンスでは、各テーブルの参加者でお互いの業務で抱えている悩みや、データベースサービス活用における洞察などを共有、ディスカッションしました。 私は SRE という業務の性質上、データベースを扱うシーンが多いわけではありません。一方で図面活用 SaaS である DRAWER では、大量の図面処理に応じてデータベースへの負荷が高くなるシーンもあり、そういった場合の対策や AlloyDB、Spanner などとの適性についてもディスカッションさせていただきました。 本イベントはデータベースがメインテーマでしたが、私のテーブルでは私と同じ SRE として勤めている方のほかに、プロダクトマネージャーや CTO に近いような立場で開発に関わっている方もおり、さまざまな観点から議論ができました。また Google Cloud の担当者の方も同席いただいていたため、各サービスの疑問点や改善要望などもカジュアルに話すことができ非常に有意義でした。 他にもいろいろな内容がディスカッションされていました。以下に一部を記載します(サービスや事業の特定を避けるために一部修正しています)。 実際 Spanner は使ってみてどうか?冗長性はどのように設定しているか? Spanner はトラフィックの変動にはどれくらい追従できるか? データベースのバックアップ、復旧訓練はどのように実施しているか? データベースのコスト適正化のために実施していることは?、など お互いが関わっているサービスは別々なのですが、データの特性や運用上の悩みなどは共通しているものもあり、純粋にエンジニアのミートアップとしても楽しい時間を過ごすことができました。 終わりに Google Cloud 様主催のネットワーキングイベントである、Digital Native Leader’s Meetup に参加させていただきました。 特にアンカンファレンスでは、実際に使ったからこそ分かるような勘所など、貴重なお話を伺うことができました。データベースのように慎重な選定や運用が求められる領域において、このような先進的な事例を共有いただけるのは非常にありがたいと感じました。 また Google Cloud の皆様の配慮により、快適に聴講やディスカッションに参加できました。ご招待いただき改めてお礼を申し上げます。 本イベントで得られた知識や洞察をもとに、キャディでは引き続き製造業の変革に貢献するようなプロダクト開発を進めていきます。ご興味のある方はぜひお気軽にご連絡いただけると幸いです。 CADDi Tech The post 第3回 Digital Native Leader’s Meetup に参加しました appeared first on CADDi Tech Blog .
アバター
※本記事は、 技術評論社 「Software Design」(2023年4月号) に寄稿した連載記事「 Google Cloudで実践するSREプ ラク ティス」からの転載です。発行元からの許可を得て掲載しております。 はじめに キャディ株式会社の前多です。筆者はPlatformグループという部署で、 クラウド インフラの整備や開発組織横断の技術課題の解消に携わっています。 キャディでは製造業向けのビジネスを展開しており、社内外向けに SaaS を含む多くサービスを運用しています。また、事業展開にあわせて常に新たなプロダクトが開発されています。 各サービスには担当の開発チームが組織されていて、開発・運用に責任を負っています。筆者らPlatformグループは、開発チームが自律的にユーザーへの価値提供に集中できることを目標に、SREプ ラク ティスの導入、信頼性の高いサービス基盤やサービス横断の機能開発といった活動をしています。 サービスの開発・運用主体は開発チームであるため、筆者らは個々のサービスに対するインフラ構築やサービス運用、 アーキテクチャ 設計や言語・ライブラリ等の技術選定といった作業を行いません。開発チームがこれらを主体的に進められるよう、サービスの基盤や監視基盤を整えたり、ガイドの作成や啓蒙、SREプ ラク ティスの実践サポートなどが主な役割です。 筆者らPlatform グループと開発チームの関係は次の図のようになります。 詳しくは 弊社のブログ で紹介していますので、興味ある方はぜひ ご覧ください。 現在のキャディは事業成長フェーズにあり、開発組織の拡大に伴い、さまざまな課題が発生しています。このような状況に対応するため、Platformグループでは、少人数でも レバレッジ の効くような戦略的・技術的な解決手法の提供も目指しています。 その取り組みの1つが、高い信頼性と開発組織のスケールへ追従できるサービス基盤の提供です。 この連載では、筆者らが提供するGoogleCloudを中心とした組織横断の基盤について、技術選定の方針と、採用している各種技術要素について解説していきます。 信頼性とは何か 信頼性とは、サービスが一定の条件下で要求された機能を果たす性質であり、サービス利用者が遅延や障害などにより機会損失する度合いを管理していくことです。 ソフトウェアエンジニアリングによってサービスの信頼性を向上させる役割を果たすのが、 Google によって提唱されたSRE(Site Reliability Engineering)です。 書籍『SRE サイトリライアビリティエンジニアリング ― Google の信頼性を支えるエンジニアリングチーム』や、 Google Cloud の SRE ページ では、SREの マインドセット やツールセットについてべられています。 高い信頼性を示すには、信頼性を数値化して計測することが必要です。以前から信頼性の尺度として MTBF ( 平均故障間隔 )、 MTTR (平均復旧時間)といったものがあり、最近ではSREプ ラク ティスの1つとして紹介されたSLI(サービスレベル指標)、SLO(サービスレベル目標)を使うこともあります注4。筆者らもサービスごとにSLI、SLOを定義して監視・運用することを標準化し、開発チームへの導入を始めたところです。 信頼性を高く保つためには、信頼性の可視化だけではなく、サービスの品質を底上げしていくための取り組みも必要です。そのために筆者らが導入している技術要素を次に見ていきます。 コラム : 信頼性の尺度 MTBF ( 平均故障間隔 )はサービスが故障せずに稼働できる平均時間で、長いほど故障がしづらいと言える尺度です。一方 MTTR (平均復旧時間)はサービスが故障から復旧までにかかる平均時間で、短いほど故障から復旧が早いと言える尺度です。 この2つの尺度から MTBF ÷( MTBF + MTTR )を計算すると 稼働率 が得られます。たとえば 稼働率 が99% なら、年間でサービスが停止しているのは約87時間、月間では約7時間です。信頼性を計測する方法の1つとして、目標とする 稼働率 を設定して実際の 稼働率 を計測します。 SLI(サービスレベル指標)とSLO(サービスレベル目標)は、サービスの利用者がサービスを安定して使えているかという観点で設定します。SLIは、サービスの状態を計測して 定量 化した値です。サービスの特性によって独自に決めます。汎用的なものとして、サービスへのリク エス トの遅延時間やエラー率などがあります。 一方でSLOは、サービスが安定して稼働しているかを判断するためのSLIの目標値です。 たとえば「月間のエラー率(SLI)を1%以下にする」といったものです。SLOを満たしていないようであれば、SLOを満たすために改善作業を実施します。 SLIとSLOについて詳しくは、 Google が公開している The Art of SLOs を参照してください。 技術選定の観点 サービス基盤の技術選定に際して筆者らは次の4点を重視しています。 IaC(Infrastructure as Code) 自動化 可観測性(Observability) セキュリティ IaC(Infrastructure as Code) 筆者らが使用する パブリッククラウド や SaaS の構築作業は、可能な限りコード化(IaC)し、CI/CDパイプラインに載せて作業を自動化しています。 API が提供されているツールを選択し、UIが提供されていたとしても手動による変更は原則として行いません。 こうすることで、複数環境の構築や複製が簡単になったり、環境に加えた変更の差分が明確になるといった利点があります。また、コード化によってインフラ構築のノウハウが 形式知 化されるので、属人性の排除や手順書に基づくインフラ構築といった煩雑な作業からの解放につながります。 IaCと次に説明する自動化により、サービス拡大に伴うインフラ構築の負荷を最低限に減らすことができます。 自動化 信頼性を高く保つためには、サービスの品質を上げることが重要です。 そのためには「テストをしてバグを減らす」「最新のライブラリを使う」「新機能や改善を取り込んだサービスを早くリリースする」などの行動が必要です。これらの行動を繰り返すことで品質は向上します。 繰り返しの速度を上げるためには、自動化が欠かせません。 自動化の方法として CI(継続的イングレーション)とCD(継続的デプロイメント)が知られています。 キャディでもこの2つを合わせたCI/CDパイプラインを整備して、サービスのテスト、ビルド、デプロイを繰り返し実行できるようにしています。 また、自動化の推進によって、特定の人しかデプロイができないといったような属人化を減らすことにもつながります。 可観測性(Observability) 信頼性を計測するためには、稼働しているサービスから指標となるデータを取得する必要があります。また、パフォーマンスの劣化やサービス障害に対する調査も、勘に頼ったり場当たり的に行ったりするのではなく、実測値に基づいて行うことが重要です。 そのために、サービスや利用するツールからログやメトリクスなどのデータが取得できること、それらのデータを一元的に収集して分析可能であることを重視します。 セキュリティ キャディでは創業当初からシステムのすべてが パブリッククラウド や SaaS にあるため、社内ネットワークのような閉じたネットワークはありません。また、多くの社員がリモートワークをしています。 そこで、筆者らはゼロトラストネットワークの考え方の基づいてサービスを構築しています。 システムへのリク エス トは、原則として正当性の検証が必要であり、そのための認証認可や監査のしくみの標準化を進めています。 また、 DDoS攻撃 のような脅威からサービスを守るための方法も検討しています。 安定したサービス基盤に使う技術 前述した技術選定の観点をふまえて、キャディのサービスが稼働している環境で実際に使っている技術を端的に紹介します。詳しい内容は今後の連載で掘り下げていきます。 なお、これらのツールは現時点のキャディにマッチしていると考えているものであり、唯一の正解だとは考えていません。必要に応じて見直し、ときにはツールを入れ替えるなどの判断もしていきます。 これから紹介する技術の全体像は次の図を見てださい。必要な要素以外は割愛してあります。 Google Cloud キャディのサービスは、ほぼすべてがGoogleCloudのインフラ上で動いています。 筆者らがおもに使っている Google Cloudのサービスは次のものです。 Google Kubernetes Engine (GKE / マネージド Kubernetes ) Cloud Run (コンテナベースのPaaS) BigQuery (データ分析基盤) Cloud SQL ( RDBMS ) Vertex AI ( 機械学習 プラットフォーム) Anthos Service Mesh (マネージド サービスメッシュ) Cloud Logging (ログ収集) Cloud Trace(分散トレーシング) 創業間もない2018年当時、サービスをすばやく開発・提供するには パブリッククラウド を使うことが必然でした。 創業当時の社員にGoogleCloudの選定理由を聞いたところ、実は明確な理由があったわけではなく、社員のアカウント管理で Google Workspaceを使っていたから、とりあえず Google Cloudを選択したとのことでた。 また、2018年当時はアプリケーションをコンテナ化してデプロイすることが注目された時期であり、当時の開発メンバーもコンテナ化を検討していました。そのときに Google Cloudの東京リージョンでGKEが開始されたのは、大きな後押しになりました。 もちろん、 Google Cloud以外の パブリッククラウド にも類似のサービスはありますがGoogleCloudを使っていて良かった点をいくつか紹介します。 まずは BigQuery です。キャディでは BigQueryに社内のデータを集約し、開発組織以外の社員もデータを閲覧・分析しています。 データ容量がスケールでき、BigQueryにデータを投入する方法が豊富であるため、データ分析基盤として初期投資が不要で使いやすいことが利点です。 次にCloud Runです。コンテナを基本としたアプリケーションの基盤としてキャディではGKEを使っていますが、小規模のサービスではCloud Runを使う機会も増えています。コンテナ化の知見がそのまま利用できるのに加え、運用監視に必要な可観測性を最初から備えているためです。 さらにAnthos Service Meshも良かった点ですが、これついては後述します。 また、筆者が個人的に気に入っている点は次の2点です。 プロジェクト単位で Google Cloud のサービスをまとめられる IAMによる権限制御ができることです。 多数のキャディのサービスを Google Cloudのプロジェクト単位でまとめることで、効率よく管理できています。 IaCに関する技術 IaCに関しては次の2つを使用しています。 Terraform Argo CD Terraform Terraform は OSS のインフラ構築ツールです。イン フラリ ソースの構成をコードとして記述し、その内容を現在のインフラ構成と比較・検証して差分反映できるため、インフラ構築作業や設定変更を自動化できます。 同様のツールはAnsibleや AWS Cloud Formationなどほかにも存在しますが、Terraformは「Provider」というしくみで拡張できるようになっており、 AWS や Google Cloudなどの パブリッククラウド だけでなく、キャディで採用している SaaS についてもProviderが提供されています。そのため、Terraformのコードでインフラの大半を制御できます。 Argo CD Argo CD は Kubernetes への継続的デリバリーを通じて行うツールです。 Git リポジトリ をソースとしてを継続的デリバリーを行う手法を「GitOps」と呼びます。Argo CDは Kubernetes へのデプロイをGitOpsに沿って行います。 通常、 Kubernetes へのデプロイは、デプロイ内容を記述した マニフェスト ファイル Kubernetes API やkubectlコマンドに指定して実施します。 この作業は、ファイル数が増えると煩雑になるほか、ファイルの変更を追従して Kubernetes に反映することが困難になります。 Argo CDは、git リポジトリ にある マニフェスト ファイルを取得し、 Kubernetes への マニフェスト ファイルの適用状況を可視化します。また、差分検知、履歴管理、 ロールバック 、自動反映といった機能も備えています。 権限制御可能なWeb UIがあるため、Argo CDを通して Kubernetes にデプロイされているサービスの構成を把握したり、管理者のみがArgo CD経由でデプロイ操作をしたりするといった操作もできます。 自動化に関する技術 自動化に関する技術は次の2つを使用します。 GitHub Actions Renovate GitHub Actions GitHub Actions は、 GitHub 上で提供されるCI/CDツールです。 GitHub へのプッシュやプルリク エス トなどのイベントをトリガーとして、任意の処理を実行できます。 キャディでは当初CI/CD基盤としてCircleCIを採用していましたが、現在では GitHub Actionsへの一本化を進めています。その理由は次の3点です。 GitHub との親和性に優れていて ソースコード を外部に渡す必要がない ナレッジや マーケットプレイス による共通処理の豊富 OpenID Connect連携が可能 OpenID Connect連携によって Google Cloudのリソースをクレデンシャルを介することなく扱えます。 これによって、CI/CDパイプラインから安全にTerraformのコマンドが実行できるようになり、インフラ構築の自動化に役立ちます。 筆者らは、プルリク エス トのマージをトリガーとしてTerraformを実行し、インフラ構築作業をCI/CDで行うことを徹底しています。 Renovate Renovate は、依存性の更新を自動化するツールです。 現在のソフトウェア開発では、さまざまなツールやライブラリに依存していますが、それらは絶えずアップデートされています。なかには 脆弱性 の対策によるアップデートもあるため、そのような更新は早めに気づき対応する必要があります。 Renovateは、 GitHub リポジトリ の内容から依存性を抽出し、最新版があればその内容や更新をプルリク エス トとして作成します。 キャディでは100を超える GitHub リポジトリ があり、多くの リポジトリ の依存性の更新チェックを自動化するためにRenovateを導入しています。 可観測性に関する技術 可観測性に関しては次の4つの技術を使います。 Anthos Service Mesh Datadog Cloud Logging Cloud Trace Anthos Service Mesh Anthos Service Meshは Google Cloudが提供するマネージドのサービスメッシュです。 サービスメッシュは、 Kubernetes 上のサービスに アクセスログ やメトリクスといった可観測性を与えたり、ネットワークのセキュリティ向上や 通信制 御といったさまざまな機能を追加したりします。サービスメッシュを導入することで、開発者の作ったサービスに対して、一定品質の可観測性やセキュリティを一律に付与できます。 オープンソース のサービスメッシュとしてはIstioが有名ですが、多くの コンポーネント を連携させる必要があるため、導入や運用の負荷が非常に高いのが難点です。 Anthos Service Meshは、Istioベースでありながら、 Google Cloudによるフルマネージドサービスであるため導入が簡単です。自動バージョンアップなども備えているため、運用負荷が低減できます。 Datadog Datadog は複数の クラウド に対応した運用監視 SaaS です。キャディで実行する大半のサービスのログやメトリクスを収集し運用監視を行っています。SLI/SLOをはじめとした負荷状況・稼働状況の可視化、サービス異常を検知と通知、外形監視によるサービスの死活監視、証明書期限チェックなどに活用しています。 運用監視サービスは多くの製品がありますが、次の点から選定しました。 複数の パブリッククラウド や SaaS に対応して運用監視を一元化できる ログとメトリクスどちらも収集してアラートの対象にできる ダッシュ ボードの可視性や調査時の検索性が良い Cloud LoggingとCloud Trace Cloud Loggingは Google Cloudが提供するログ収集サービスで、Cloud Traceは分散トレーシングのサービスです。 どちらも Google Cloud内部のアプリケーションやインフラの可観測性に関するデータを収集します。 Cloud LoggingのデータはDatadogでも収集しており、Datadogと役割が重複していますが、次のように使い分けています。 Datadog : 検索や可視化に優れるため、リアルタイムログ検索や ダッシュ ボード、アラートの一元化に使用 Cloud Logging : Datadogに収集していない一部のログの参照や、過去のログの検索に使用 (Detadogにすべてのログを集約するとコストがかかり、ログの保存期間にも制約があるため) Cloud Traceは、複数のサービスのパフォーマンスデータを収集して可視化できるため、どのサービスが遅延や障害を起こしているかを調査するのに役立ちます。Datadogにも同様のサービス、 DatadogAPM がありますが、 Cloud Traceのほうが低コストであるためCloudTraceを使っています。 また、監視サービスの Cloud Monitoring もありますが、Datadogと 重複するので積極的には利用していません。 ですが、 Google Cloud Managed Service for Prometheus が登場したことで、より扱いや すくなりメトリクス収集の範囲が広がるという期待があり注目しています。 セキュリティに関する技術 セキュリティに関しては Cloudflare を利用しています。Cloudflareは、インターネット上で提供するサービスに対して、 CDN 、 TLS 、ネットワークセキュリティ、エッジコンピューティングなど、さまざまな機能を提供します。 当初はキャディのWebサイトを動かしていた WordPress の負荷軽減のために CDN を導入する目的で利用を開始しました。 しかし、現在ではセキュリティに関する機能を有効活用するために、すべてのサービスをCloudflareの CDN 経由で公開しています。 利用している機能の一部は次の通りです。 Cloudflare DNS : DNS レコードと TLS 証明書を管理する Cloudflare Access : Cloudflare にホストしているサービスに認証プロキシを設定できる。社内システムへのアクセスを Google Workspaceのアカウントで認証できるようになる Cloudflare WAF : DDos攻撃 や 不正アクセス を検知しアクセスの遮断や通知を行う Cloudflare Workers : Cloudflareのネットワーク内でリク エス トに応じて任意のプログラムを実行できる仕組。重要なデータへのアクセスに対して高度な認証を適用したり、監査ログを取得するために使用したりする まとめ 今回は、信頼性を高めるための技術選定の4つの観点(IaC、自動化、可観測性、セキュリティ)について解説し、それらの観点から現在筆者らが使用している技術について紹介しました。 最初からこれらの観点があったわけではなく、試行錯誤を積み重ねた結果として今の形に型化できました。注力する技術を型化したことにより、技術トレンドに応じて柔軟に使用する技術を組み替えていけると考えています。 次回からは、各技術トピックについて詳しく解説していきます。どれか1つでも興味のある技術があれば幸いです。お楽しみに。
アバター
こんにちは、キャディでMLOpsをやっている志水です。機械学習の推論基盤にregression testを追加したところ依存パッケージのアップデート等が楽になり開発者体験がすごくよくなったので、その詳細について書きます。 推論基盤の運用 MLOpsチームでは機械学習モデルの推論API基盤を開発運用していています。こちらに関しての詳細は 以前のTechブログ をご参照ください。 チームで Googleのソフトウェアエンジニアリング本を読んだこと をきっかけに、現在のプロダクトで改良できる部分を議論しました。 図1. 現状のデプロイフローと、起きえるエラーについて議論した図 現状のデプロイフローでは機械学習エンジニアが以下を手動で行っています。 実験時に作成したデータとモデルファイル(.ptファイル)を用意 TorchServe でサーブするためにAPI用のDockerコンテナを作成 dev環境へのデプロイと疎通確認 stg環境へのデプロイと負荷試験 prod環境へのデプロイ ホワイトボードでデプロイフローと既存のテストを整理した結果、機械学習エンジニアが開発時に出した結果と最終的なAPIが同じ結果を出すことを保証するために、手動のテストでカバーしている範囲が多いことがわかりました。 (既存のCIのテストは前処理の違いによる不具合が起きたことで追加したUnit testが大半でした。) 機械学習エンジニアとも相談し、自動化するテストの構成や内容についていくつかのパターンを出しました。 「これからのパッケージアップデートに対して現在と推論が変わらないことを保証る」を目的に、サンプル図面に対して推論を行い、過去の推論結果と比較するregression test 「実験当時の推論結果と、デプロイされたものの精度の一致」を目的に、実験時のデータと推論結果が一致することを確認するテスト 実装の工数やそこから得られるbenefitを天秤にかけ、「これからのパッケージアップデートに対して現在と推論が変わらないことを保証する」ことを目的に、サンプル図面に対して推論を行い、そこで今までの推論結果と同じ結果を返していることを保証するregression test をCIに追加することにしました。 regression testの追加 regression test の構成は サンプル図面とその推論結果を用意し docker-compose.yml で APIを立てて推論リクエストを送り(下記batchプロセス) 返ってきた現在の推論結果が用意しておいた推論結果と一致することを確認する ようにしました。 図2. サンプル図面 このようなサンプル図面に対して、以下のような推論結果を予め用意しています。 図面_id,pred,name WA-20220616-ABC-01/RT-1,9.607873916625977,thickness これをCI内で実行するための docker-compose.yml は以下のようになります。 # docker-compose.yml services: api: build: context: . dockerfile: Dockerfile ports: - "7080:7080" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:7080/ping"] interval: 30s timeout: 10s retries: 5 batch: build: context: . dockerfile: tools/Dockerfile # 中でテストを呼び出す depends_on: api: condition: service_healthy environment: API_PORT: 7080 API_HOST: api CI CIでテストが走ってくれれば、エンジニアが人手で確認することなくデプロイされている推論APIが以前のバージョンと乖離していないことを保証でき、安心できます。 キャディでは複数の機械学習APIを運用しているため paths-filter を活用し、変更があったAPIだけテストが発火するようにしています。 github actions 内では以下のようにシンプルにテストを実行しています。 docker compose build docker compose up api -d docker compose run batch この docker-compose.yml を使ったテストは1つのマシンで複数のプロセスが走るという、Googleのソフトウェアエンジニアリングに登場するmedium size のテストになります。関数ごとのテストはunit testなどのsmall testでカバーします。 small test がたくさんあり、medium(やlarge)のテストがそれより少ない状態がピラミッド型のバランスのいいtest suiteとされています。それを実現するためにsmall testはカバレッジ高くたくさん書いており、regression testのようなmedium sizeのテストはクリティカルな部分のみに書いています。 依存パッケージの更新 我々のチームでは renovate というツールによって依存パッケージのバージョンアップデートを管理しています regression test を導入するまでは、例えばPyTorchにセキュリティパッチがあたりバージョンが上がった場合に、APIを作成した各機械学習エンジニアに想定した結果と精度が変わっていないかを確認してもらう必要があり、手間も時間もかかっていました。 regression test を導入してからはrenovateでパッケージバージョンが上がるPRが上がってきた場合、CI上のregression test通っていれば推論APIの挙動の一貫性についてある程度自信が持てるため、依存関係の更新が高速かつ安全になりました。 当初想定してた以上に開発者としての体験はよく、今後も積極的にテストを開発していくモメンタムがチームに生まれています。 終わりに 今回は推論API基盤に対するregression testの導入に至った経緯と、その具体的な方法について紹介しました。 キャディでは一緒に働く仲間を探しているので、 募集要項 から気になる求人があればご気軽にご連絡いただければ幸いです。 The post 機械学習API基盤にregression test を追加する appeared first on CADDi Tech Blog .
アバター
AI 組織のモノレポ紹介 はじめに こんにちは、西原です。AI Lab の MLOps チームでエンジニアとプロダクトオーナーを兼任しています。私たちは、日々 機械学習 (ML)の成果を素早くシステムに取り入れ、安定した運用を実現するための仕組み作りに取り組んでいます。この一環として 2022 年秋からはモノレポ構成での開発に移行しました。モノレポの採用背景やモノレポでの取り組みについて紹介します。 TL;DR 車輪の再発明 を防ぎ、開発効率を向上することを目的にモノレポへ移行 モノレポのビルドシステム Pants を使って、異なる Python バージョンのプロジェクトを管理 モノレポ移行によって開発効率の向上を実感しており、今後もモノレポの運用と改善を継続していく AI 組織のモノレポ紹介 はじめに TL;DR モノレポの概要 モノレポに移行するまで Pants とは モノレポへの移行 移行方針 アプリケーションコードの移行 インフラコードの移行 モノレポの静的解析設定 トランクベース開発とデプロイ トランクベース開発 デプロイ main 以外のブランチからの実行をブロック run-name を使った概要表示 同時実行制限 モノレポにおける CI 差分テスト テストカバレッジの計測 今後の展望 まとめ 参考 モノレポの概要 モノレポは複数のプロジェクトやアプリケーションの ソースコード 、リソースを分割せずに、全てを 1 つの リポジトリ で管理する開発手法です。プロジェクトやアプリケーションを個別の リポジトリ で管理する方法(poly repo/multi repo)に対して、モノレポを採用することで次のようなことが期待できます。 コード共有が容易になるため、同じ機能を複数のプロジェクトで実装する必要がなくなる コードの品質を統一的に管理できるため、バグやセキュリティの問題を早期に発見できる ビルドやテスト、デプロイなどの自動化が容易になる 開発者が 1 つの リポジトリ に集まることで開発者同士のコミュニケーションが促進され、開発効率やコード品質の向上につながる 対照的に、モノレポを採用するにあたって気をつける点として同じコードベースを複数人で編集することによるコンフリクトへの注意や依存関係の管理、ビルドシステムを使った効率的な設計等があります。 モノレポに移行するまで AI Lab では 2022 年秋まで poly repo で開発していました。poly repo では リポジトリ に関わるメンバーの得意・不得意によって、リンター、フォーマッター、テスト、CI/CD の作り込みに差がありました。 リポジトリ を横断して最低限の仕組みを構築するようにしましたが、当時は Organization 横断の workflow もまだなく、似たような作業の繰り返しで生産的な作業とは思えませんでした。一方で、これらの作業を怠るとメンテナンス性が低下し、継続的な改善・運用が難しくなるため放置もできません。 GitHub のテンプレー トリポジ トリや base となるコンテナイメージの利用による Docker イメージの共 通化 を試みましたが問題を解決できず、モノレポへの移行を検討しました。モノレポに移行することで開発に必要なリソースが全て 1 つの場所に集約され、 リポジトリ に関わる全員が共通の 開発プロセス やツールを使用できます。これによりリンター、フォーマッター、テスト、CI/CD などの開発ツールの統一が容易になり、メンテナンス性や開発効率の向上が期待できます。 モノレポに移行するにあたって依存関係の管理やビルド効率に関する懸念があったため、これらを解決できそうなツール(ビルドシステム)を調査したところ Bazel と Pants が選択肢として挙がりました。最終的に AI Lab の中心的な技術スタックである Python をネイティブでサポートしている Pants を採用することに決めました。 Pants とは Pants(pantsbuild/pants) はあらゆる規模のコードベースに対応できるスケーラブルなビルドシステムで、特にモノレポとの相性が良いです。依存関係解決ツール、テストランナー、リンター、フォーマッター、パッケージャーなど数十の基本ツールをとりまとめ、扱えるようにします。記事執筆時点の Pants v2.15.0 では Python 、Go、 Java 、 Scala 、Shell、Docker をサポートしています。Pants の特徴として、静的解析による依存関係 モデリング 、実行結果のキャッシング、並列実行やリモート実行等があります。 モノレポへの移行 移行方針 モノレポへの移行は Pants のキャッチアップと並行して進めることになりました。AI Lab が抱えている問題を モノレポと Pants が本当に解決できるのか不安があったため、モノレポ移行が失敗した場合に切り戻せるように AI Lab が所有するコードの一部を対象に移行を始めました。AI Lab には主にアプリケーション用、ML モデル作成用、インフラ用のコードがあります。このうち、アプリケーションとインフラのコードは CI/CD が整っており、失敗した場合でも元に戻すことが容易だったためこれらのコードを対象にモノレポ移行を進めました。 アプリケーションコードの移行 移行するアプリケーションは Poetry と Docker を使用して構成されていましたが、アプリケーションごとに異なる Python バージョンを使用していました。Pants の事前調査で Poetry をサポートしていることや、異なる Python バージョンを管理できることがわかっていたため、移行の際は事前の作業なしに ディレクト リ構成含む全てをそのままモノレポに取り込みました。Pants は requirements.txt を使った管理、 pex を使ったコード実行もできますが移行のハードルを下げるために Poetry を使った構成をそのまま引き継ぎました。コード例のように pants.toml の記述を行い、モノレポ移行した各アプリケーションに BUILD ファイルを追加して Pants が動くようにしていきます。 ./pants tailor :: コマンドを使うと BUILD ファイルの追加を補助してくれます。 ./pants test コマンドを実行してテストが通過すればアプリケーションの移行は終わりです。 [GLOBAL] pants_version = "2.15.0" backend_packages = [ "pants.backend.python", ] インフラコードの移行 次にインフラ用コードの移行について紹介します。もともと Terraform で書かれたコードはインフラ用のモノレポで管理されていましたが、今回の移行を機 にアプリケーションと同じ リポジトリ で管理することにしました。 移行前は開発用、検証用、本番用それぞれの tfstate が 1 つあり、その tfstate に複数のアプリケーションが含まれている状態でした。この状態では管理する state の数が増えるにつれて、 plan や apply の実行時間が長くなるだけでなく、追加・変更・削除の際の tfstate の lock により、コンフリクトする恐れがあります。今後アプリケーションが増えると、この状況が開発の ボトルネック になると考え、 リポジトリ 移行のタイミングで 1 つの tfstate で 、環境ごとの 1 アプリケーションを管理するように tfstate を分割をすることにしました。 コードを新しい リポジトリ に集約した後、 terraform state コマンドを使用して tfstate をアプリケーションごとに分割して リポジトリ 移行を終えました。移行後の ディレクト リ構成を簡略化した例が以下になります。 . ├── .github ├── pants ├── pants.ci.toml ├── pants.toml ├── projects │ ├── app_1 │ │ ├── src │ │ │ ├── BUILD │ │ │ └── main.py │ │ ├── BUILD │ │ ├── docker-compose.yaml │ │ ├── Dockerfile │ │ ├── poetry.lock │ │ ├── pyproject.toml │ │ └── tests │ │ ├── BUILD │ │ └── test_main.py │ └── app_N │ ├── src │ │ ├── BUILD │ │ └── main.py │ ├── BUILD │ ├── docker-compose.yaml │ ├── Dockerfile │ ├── poetry.lock │ ├── pyproject.toml │ └── tests │ ├── BUILD │ └── test_main.py └── terraform ├── app_1 │ ├── environments │ │ ├── development │ │ │ └── main.tf │ │ ├── staging │ │ └── ... │ └── modules │ ├── cloud_run.tf │ ├── iam.tf │ └── ... └── app_N ├── environments │ ├── development │ │ └── main.tf │ ├── staging │ └── ... └── modules ├── cloud_run.tf ├── iam.tf └── ... モノレポの静的解析設定 モノレポ移行の当初の目的であったリンター、フォーマッター、テスト、CI/CD の作り込みと全体への適用を行いました。 pants.toml にモノレポで有効にするリンターやフォーマッターの設定を記述します。以下は、Pants で使う静的解析の設定例です。 [GLOBAL] pants_version = "2.15.0" backend_packages = [ "pants.backend.python", "pants.backend.python.lint.black", "pants.backend.python.lint.flake8", "pants.backend.python.lint.isort", "pants.backend.python.lint.pylint", "pants.backend.python.lint.docformatter", "pants.backend.python.lint.bandit", "pants.backend.python.lint.autoflake", "pants.backend.python.lint.pyupgrade", "pants.backend.python.typecheck.mypy", "pants.backend.docker", "pants.backend.docker.lint.hadolint", ] Pants では、 Python 開発で使うリンターやフォーマッターを一通りサポートしており、flake8 extension も利用できます。Pants でサポートしていないツールも、plugin 機能を使って自前でルールを書くことでモノレポに適用できます。Pants の fmt 、 fix 、 lint 、 check コマンドを実行して動作確認できたら静的解析の設定は終わりです。 余談:Pants v2.16 では Ruff の導入が予定されています。Ruff は Rust で書かれた高速な Python リンターであり、Pants の リンター比較実験 からも高速に動作することが期待できます。リンターの実行時間が長いと CI の実行が長くなったり、手元でリンターを実行するハードルも高くなりますが、高速に動作する Ruff によって改善されることを期待しています。 トランクベース開発とデプロイ トランクベース開発 モノレポでは トランクベースの開発 スタイルを採用しており、main ブランチ以外の永続的なブランチを作成しないようにしています。一般に、寿命の長いブランチを作成し、デプロイするタイミングで多数の変更をリリース用ブランチに取り込む手法もあります。この手法ではデプロイの影響範囲が大きくなり、デプロイの失敗率が高くなります。デプロイが失敗すると多数の変更の中から問題を特定することになり、調査にかかる負担が大きくなると考えました。そこでトランクベースの開発スタイルを採用し、小さなパッチを基本とした開発を目指しています。小さなパッチの場合、コードレビューの負荷も低減され、フロー効率も向上すると考えています。トランクベースの開発スタイルを採用することで、小さな単位のパッチを意識することと、main ブランチに積極的に変更を取り込むうえで本番環境に影響を出さない仕組み(feature flag や versioning など)を活用していくことを期待しています。 デプロイ アプリケーションのデプロイや terraform apply は GitHub Actions を使って行うようにしました。 GitHub Actions の workflow ファイルは .github/workflows 配下に置く必要があります( 公式ドキュメント )。1 つの workflow ファイルで 1 つのアプリケーションのみデプロイできるようにするとファイル数が増加し、見通しが悪くなります。ファイルの増加を防ぐために、 workflow_dispatch を使用して単一のファイルから複数のアプリケーションをデプロイできるようにしています。デプロイする際は、実行ブランチ、デプロイする環境、および対象のアプリケーションを選択して実行します。これにより、新しいアプリケーションが増えても、1 つの workflow ファイルで管理できるようになります。 ただし、1 つのファイルに複数のアプリケーションのデプロイ設定を書くとファイルが肥大化し、メンテナンスが難しくなります。メンテナンス性を保つために各アプリケーションごとにデプロイ用の設定ファイルを用意し、workflow 内からそれらのファイルを参照するようにしています。 main 以外のブランチからの実行をブロック 検証環境と本番環境では main ブランチからのみ実行できるようになっており、その他のブランチを指定して実行した場合はブロックされるようにしています。main ブランチは、コードレビューを通過したコードのみを取り込むようになっており、誤操作によって未レビューのコードが本番環境に反映されることを防ぎます。 run-name を使った概要表示 1 つの workflow ファイルから複数のアプリケーションをデプロイする場合、ジョブ実行一覧の画面でどのアプリケーションが誰によって、どの環境に、そしていつデプロイされたかの履歴を確認することが難しくなります。 GitHub Actions の run-name を使用して、実行の概要を表示することで該当するジョブを見つけやすくしました。 同時実行制限 デプロイ時に同じ動作が連続して実行されないように 同時実行を制限 しています。下記のように inputs の値を使って group を設定してみたのですがうまくできず、 こちらのディスカッション にあるように github.event.inputs を使うとうまく group の設定ができました。 name : Deploy Workflow concurrency : # group: ${{ github.workflow }}---${{ inputs.appName }} これだと ${{ inputs.appName }} の値が常に空文字になる group : ${{ github.workflow }}---${{ github.event.inputs.appName }} # こっちだと入力した値が反映される cancel-in-progress : true on : workflow_dispatch : inputs : appName : description : "Name of the App you want to deploy." required : true # 省略 これまで紹介した取り組みをまとめた workflow の例が次のコードになります。 run-name を使った概要表示 同時実行制限 実行ブランチ、デプロイする環境、対象のアプリケーションを選択 main 以外のブランチからの実行をブロック name : Deploy Workflow run-name : Deploy to ${{ inputs.application }} by @${{ github.actor }} # ① concurrency : group : ${{ github.workflow }}---${{ github.event.inputs.application }} # ② cancel-in-progress : true on : workflow_dispatch : inputs : # ③ environment : description : environment type : environment required : true application : type : choice description : application options : - app1 - app2 jobs : deploy : runs-on : ubuntu-latest if : ${{ inputs.environment == 'development' || github.ref_name == 'main' }} # ④ development環境はどのブランチからでも実行できるが、それ以外はmainブランチからのみ実行できる steps : - run : echo "Deploy to ${{ inputs.application }} by @${{ github.actor }}" モノレポにおける CI 差分テスト モノレポ CI の構築において差分検知がポイントだと考えました。モノレポには複数のプロジェクトが存在し、毎回の CI 実行で変更と関係ないプロジェクトまでテストすると時間とお金がかかります。一方で、変更によって影響を受けるコードがテストされなかった場合、コードが壊れていることを検知できず広範囲に影響を及ぼすことがあります 。そして、デプロイと同様にプロジェクトが増えた際に何の工夫もしなければ CI 用の workflow ファイル数が増加していきます。 Pants には差分検知機能があるため、基本的に Pants を使って差分検知します。Docker Compose を使用した ML 推論の regression test や、Terraform など Pants で管理されていないものは paths-filter を使用して差分検知しています。 Pants の差分検知は実行時にオプションを追加するだけで実行できるため導入負荷が低く、Pants を初めて使う人でも簡単に差分実行できます。AI Lab モノレポで全体テストをすると 30 分かかっていましたが、差分実行を導入すると CI の時間が p90 で 3 分になりました。Pants 採用後はできるだけ早く差分実行を導入することをお勧めします。 # origin/mainとの差分を計算するオプション # --changed-since=origin/main --changed-dependees=transitive # origin/mainとの差分をテストする例 ./pants test --changed-since=origin/main --changed-dependees=transitive CI の実行時間を短縮するためにキャッシュの活用にも取り組んでいます。Pants でキャッシュを使って CI を高速化する方法が公式の ドキュメント と サンプルコード で紹介されており、こちらを参考にして AI Lab モノレポでもキャッシュを活用して CI の時間が短くなるように努めています。 先述したとおり CI では差分実行を採用していますが、差分実行だけだと リポジトリ 全体が正常に動作しているか不安もあります。そこで、 リポジトリ 全体のテストを日次で実行して リポジトリ 全体が壊れてないか確認することで、不具合を発見できるようにしました。このように差分実行によって時間と費用を節約し、全体テストによって リポジトリ 全体が正常か確認することで QCD のバランスが取れた CI システムを実現しました。 テスト カバレッジ の計測 Pants には テストカバレッジを計測する機能 があり、 MishaKav/pytest-coverage-comment と組み合わせて カバレッジ をレポートしています。ML 推論の regression test は Pants で管理していないため、この カバレッジ に反映されていませんがモノレポ全体のテスト カバレッジ を計測したところ 86%でした。組織として数値目標は設定していませんが、テストを書いて当たり前の文化が数字として表れていると思います。次の yaml は差分テストを実施した後テスト カバレッジ を Pull Request にコメントする例です。 # 省略 steps : - uses : actions/checkout@v3 - uses : pantsbuild/actions/init-pants@v2 - name : Setup Python uses : actions/setup-python@v4 with : python-version : 3.9 - name : Test run : ./pants --changed-since=origin/main --changed-dependees=transitive test - name : Pytest coverage comment uses : MishaKav/pytest-coverage-comment@<sha> with : pytest-xml-coverage-path : dist/coverage/python/coverage.xml ML モデルの推論は前処理によって結果が変わります。前処理で使っているライブラリに変更があった場合、推論に影響があるかどうかを判断できるように Pants を使ったテストや regression test をしています。モノレポでは renovate を活用して外部ライブラリのバージョン更新をしており、これらのテストが通れば安心して main ブランチへマージできる仕組みになっています。 今後の展望 ここまでモノレポ移行の背景やモノレポでの取り組みについて紹介してきました。モノレポ移行によって開発効率が向上したことを実感していますが、まだまだやりたいことが残っています。今後、検討・検証したいことの一部を紹介します。 ML モデル作成用コードをモノレポで管理 現在はモデル作成用コードは別 リポジトリ で管理している。モデル作成用コードもモノレポで管理することで品質を統一的に管理し、デプロイまでの繋ぎこみをスムーズにしたい 各プロジェクトごとに管理している外部ライブラリをまとめて管理 各プロジェクトごとにやっている外部ライブラリ管理を辞め、 リポジトリ 内で使用している外部ライブラリを一箇所に集約して管理することでメンテナンス性が向上する見込み Pants との相性から Poetry を継続するか、Poetry を辞めて requirements.txt で外部ライブラリを管理するかの判断 (余談: 最近 Poetry 1.4 がリリースされて install が高速になりましたね) コードの依存関係整備 コードがプロジェクト内で完結しているため、レポジトリ内に似たようなコードが存在する 同じことをやっているコードをまとめ、プロジェクト横断で使えるようにする ML の前処理も、一つのコードを使いまわすことで前処理で差分がでることを防ぎ、推論結果を安定させられる見込み custom plugin の作成 モノレポの中で共通の処理を plugin 化して統一のインターフェースで実行できるようにする pex を使った Python 実行 まとめ AI Lab では 車輪の再発明 を避け、開発効率を向上するためにモノレポへ移行しました。モノレポ移行により移行前に抱えていた課題を解消でき、開発効率の向上を実感しています。Pants を使用して Python バージョンの異なるプロジェクトを管理しています。モノレポでは、何か 1 つのプロジェクトが終了しても リポジトリ のメンテナンスが止まることはなく、永続的にメンテナンスすることが前提となります。そのため、開発効率を向上させるための投資がしやすくなりました。ML パイプラインの改善などの他の取り組みと併せて、今以上に事業への価値提供ができるように今後も改善に取り組んでいきます。 参考 Pants monorepo.tools https://www.pantsbuild.org/docs/media https://developer.hashicorp.com/terraform/language/state/locking https://developer.hashicorp.com/terraform/language/settings/backends/gcs
アバター
はじめに こんにちは、キャディAILab MLOpsエンジニアの廣岡です。MLOpsエンジニアの業務では、機械学習エンジニア(MLE)の開発したモデルのデプロイ面の協働や、それらを含む機械学習基盤の開発・運用などを担当しています。最近は特にモデルデプロイに伴うチェック内容の自動化や、各ライブラリのアップデートを安全に実施するためのCI/CDの整備などに取り組んでいます。 本稿では、AILabが運用する図面解析ETL基盤、および開発に際して得られた知見や悩みを記載します。読者の方の参考になれば幸いです。 TL;DR 社内オペレーションにおいて、膨大な図面に対する解析とその結果に対するアクセスが必要となっていた 社内のアプリケーションにアップロードされた図面に対して、機械学習を用いた画像解析を実施し、解析結果をデータベースに格納するETL(Extract、Transform、Loadからなるデータ処理)基盤を構築した 結果として、アップロードされた図面群に対して当初目標としていた「30分以内に図面10000枚の解析完了」を達成することができた :tada: 背景 キャディでは業務の中で膨大な量の図面を扱っています。こうした膨大な量の図面を全て人手でチェックするのは大変なため、図面の取り扱いに際するオペレーションを自動化することで、業務効率化や情報抽出の高度化・均一化が期待できます。今回紹介するETL基盤もそうした自動化の一端を担っており、機械学習によって図面からの情報抽出を行います。 本稿で紹介するETL基盤の構築以前から、キャディでは社内向けのML APIをホストするAPI基盤が構築されていました(詳しくは 以前のブログ投稿 をご参照ください)。このAPIを用いることで、社内のアプリケーションから図面に対する機械学習の解析結果を取得することができていました。 一方でこうしたAPIの利用者はエンジニアを想定していたため、非エンジニアからは利用しづらいと言う課題がありました。これに対して、例えばBigQueryのようなストレージに解析結果を格納しておくことで、多くの社員が図面の解析結果にアクセスでき、機械学習の価値を享受できるようになります。こういった背景から、図面情報のETL基盤の開発が検討されました。 ETL基盤の開発にあたっては、「 10000枚の図面データ入力に対して30分以内に解析結果をアクセス可能な状態にする 」という、処理性能の数値目標を定義していました。詳細は割愛しますが、これは図面情報を用いる業務オペレーションから逆算した数値となっています。 プロダクトの開発に際してはDesgin Docという資料をまとめており、開発の基礎としています。上記の開発背景や数値目標の他にも、ユーザーストーリーや機能要件、プロダクトのスコープが意図する要素などを整理して記載しています。これらをチームとして考え合意しておくことで、必要なことにフォーカスし、不要なものを作らないことが期待できます。 ETL基盤のアーキテクチャ ETL基盤の開発前には、インポートされた図面に対して採番(ID付与)を行う図面管理基盤と、MLモデルをホストするAPI基盤がありました。これらを踏まえて以下のようなアーキテクチャのETL基盤を構築しました。 AILabでは基本的にGoogle Cloudのサービスを利用しています。今回のETL基盤の核となっているのは中央のCloud Runにデプロイされるサービスであり、下記の流れで、図面の情報抽出を実施します。 図面管理基盤に図面がインポートされると、Pub/Subトピックにメッセージが発行される Pub/Subからのプッシュサブスクリプションによって、Cloud Runにリクエストが送られる Cloud RunからVertex Endpoint(ML API基盤)にリクエストを送り、図面に対する解析結果を得る 解析結果をCloud SQLに格納する (BigQueryを介してCloud SQLにクエリすることで、解析結果が閲覧できる) またPub/Subメッセージの状態や、Cloud Runのログや各種リソースの動作状況はCloud Monitoringに取り込まれ、処理が問題なく進んでいるかをチェックできるようになっています。 開発時の議論 開発を進める際には、各リソースの動作確認や構成の議論が必要でした。ここではその一部を紹介します。 スケーラビリティ 図面管理基盤への図面のインポートはまとめて行われるため、リクエストの変動が激しくなることが予想されました。Cloud Runを用いることで迅速なスケールアウトが期待でき、リクエスト数に応じたスケーラビリティを獲得できると感じています。 一方でVertex Endpointで提供されるML API基盤は機械学習モデルを搭載しているためか比較的スケールアウトが遅い場合がありました。これをETL基盤の問題と捉えるかは微妙なところですが、「30分以内に10000枚の図面を解析」と言う目標に対しては致命的なほどではありませんでした。しかし今後さらに大量の図面を扱う場合や、高速な処理が求められる場合は改善が必要と感じています。 処理のリトライ 一度に大量の図面に対してETLを実行する場合、ML APIのスケールアウトが間に合わないなどの理由で、一部の図面に対して処理が失敗することが考えられました。 これに対するリトライ処理はPub/Subのリトライ機能を利用しており、処理が成功するまで一定時間リクエストを送り続けます。時間経過に応じてインスタンスのスケールアウトなどが間に合うと処理が成功し、結果がデータベースに格納されます。 解析結果へのアクセス権管理 解析結果はCloud SQLに格納され、ユーザーからはBigQueryを介してクエリを実行し、各図面に対応した解析結果を得られます。 BigQueryへのアクセス権はTerraformを介してIAMによって付与しています。アクセスが必要な際にはMLOpsチームに連絡してもらう運用にしており、これによって解析結果にアクセスできるユーザーやグループを管理しています。 リリースに伴う動作チェック ETL基盤のリリースに際しては、開発環境での疎通確認や、ステージング環境での負荷テストなどを実施しています。特に負荷テストでは、「30分以内に10000枚の図面を解析」の性能指標が達成できているかを確認するため、実際に10000件の図面を投入し、処理性能が問題ない範囲で収まっているかを確認しています。 ML APIの増加に伴う失敗率の増加 本稿のETL基盤では、一つの図面に対して複数のML APIによる解析を実行しており、全て完了した後に結果をデータベースに格納しています。この時Vertex Endpointのスケールアウトなどの要因でAPIのどれか一つでも失敗すると、ETLの実装上一図面に対する解析がまとめて失敗となっていました。リトライ時には、元々成功していたAPIも再度リクエストを送る必要があり、処理時間の増加につながってしまいます。 この課題は暫定的にVertex Endpointの最小インスタンスを増やすことで対応しましたが、APIごとに解析結果を随時保存するように、アーキテクチャと実装を鋭意改善しています。 ユースケースの拡大と社内の認知向上 今回紹介した図面ETL基盤は、社内の非エンジニアの特定オペレーションに際して、大量の図面を効率的に扱うために開発されました。今後実際にオペレーションに組み込まれていく中で、精度や処理性能面のさらなるブラッシュアップができると良いと感じています。 また元々想定していたオペレーション以外にも、ETL基盤による図面情報の提供は、図面を扱う多くのオペレーションに有用であるとも考えています。これに対しては、社内のSlackで宣伝したり、必要に応じて抽出できるデータやデータベースのアクセス方法を紹介する機会を作ったりしています。一方で、さまざまなユースケースが混在するとシステムの要件が複雑になる可能性もあるため、今回のETL基盤が本当に適しているのかはよく見極めて進めたいと考えています。 まとめ 本稿では、キャディ社内でAILabが開発・運用している図面に対するETL基盤を紹介しました。他にもMLOpsチームとしては下記の課題や機能候補なども検討しており、今後さらにブラッシュアップしていきたいと感じています。 API単位でのリトライ Pub/Subなど外部サービスに依存している箇所のテスト バッチ推論への対応 本稿で紹介したETL基盤では、Google Cloudのマネージドサービスを使いながら「30分以内に10000枚の図面を解析」という目標を達成できました。これを用いてキャディのビジネスをさらに加速していきたいと考えています。 The post 機械学習APIを用いた図面解析ETL基盤 appeared first on CADDi Tech Blog .
アバター
TL;DR Chrome Extension経由で独自に学習したMLモデルを社内配布できるようにしました モデルはユーザのブラウザ上で実行するので余計な通信も発生せず クラウド 代も不要です 背景 こんにちは。CADDi AI Lab MLOpsチームの中村遵介です。普段は 機械学習 エンジニアチームの作るモデルを Vertex Endpointsを使用してAPIとして提供 したり、パイプラインに組み込んで推論結果をデータ提供したりするお仕事をしています。モデルは様々な種類がありますが、一番多いのは図面画像から特定の値を推論したり、何らかのクラスに分類するようなモデルです。 そのような中で「 API 提供するとサーバ代かかるし、ユーザに API 使ってもらうのもちょっと手間があるしなぁ」と考えることがあり、ふと「 Chrome extensionでMLモデルを提供しちゃえば、ユーザはextensionを入れるだけでモデルを使えるようになるし、実行コストもユーザのローカル環境にお任せできるしで Win-Win では?」と思ったので実際にやってみました。 ユーザ体験設計 Chrome extensionは非常に強力で、様々な体験を作ることができます。今回はお試しとして以下のように設計しました。 提供するモデルは、図面画像に書かれている物体の最大寸法値を推論するもの※1 社内で使用している図面画像管理 Webサービス に対して Chrome extensionを提供する 図面画像管理 Webサービス 上に出てくる図面を右クリックすると、「AIで推論する」のようなメニューが出てくる 「AIで推論する」というメニューを選択すると、その場で対象の画像に対してMLモデルで最大寸法値を推論する 推論した最大寸法値は、ブラウザのコンソールに出力する 技術検証の段階なので、提供するMLモデルが1つだけだったり、推論の出力先がブラウ ザコン ソールだったりします。 こちらが実際にできたものの動画です。動画内の Webサービス が社内向け図面管理サービスで、対象図面は社内サンプル図面です ※1 … 最大寸法値推論モデルは、図面に記述された寸法値から、物体の大体の最大サイズを推論します。最大寸法値が直接図面に書かれていないような図面であっても推論可能です。 技術選定 ざっくり以下の技術を選択しました。MLモデルをONNX形式に変換し、それをTypeScriptでロードして実行します。 途中までは、MLモデルはONNXエクスポートしたものをRustでラップしWASMを使ってTypeScript側に関数提供する方法も考えたのですが、TypeScript側にONNXのランタイムが出ていたのでよりシンプルな構成にしました。 MLモデル 学習: PyTorch 推論: ONNX Chrome Extension MLモデルのランタイム: onnxruntime-web 開発自体の言語・ フレームワーク : typescript + webpack 実装 Chrome extensionの マニフェスト Chrome extensionでは マニフェスト と呼ばれる定義ファイルを作成するのですが、今推奨されている書式は Manifest V3 と呼ばれる形式なので、それに則ります。Manifest V2は現在の予定だと2024年でdeprecatedになります( Manifest V2 support timeline ) V2からV3の移行は比較的簡単にできるようです( Migrating to Manifest V3 )。 まず、画像を右クリックした際に出てくるメニューに「AIで推論する」という項目を追加するため、 Chrome のバックグランドで実行する スクリプト ファイルが必要になります。 さらに、今回は図面画像管理 Webサービス の中の画像を取得するため、特定のページの要素( <image> )にアクセスする権限が必要です。そのため、バックグランド実行とは別のコンテンツ スクリプト を作成して、それぞれを マニフェスト に登録する必要があります。 また、コンテンツ スクリプト はMLモデルであるONNXファイルを実行時にロードします。図面管理画像 Webサービス からこのONNXファイルにアクセスしても良いよ、という許可を与える必要があります。 manifest. json は以下のようになります { "name": "Some cool name", "description": "very clear instructions.", "version": "1.0.0", "manifest_version": 3, "action": {}, "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": [ "https://example.com/*" ], "js": [ "content.js" ] } ], "permissions": [ "activeTab", "tabs", "contextMenus", "scripting" ], "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }, "web_accessible_resources": [ { "matches": [ "https://example.com/*" ], "resources": [ "model.onnx" ] } ] } バックグラウンドでのメニュー画面追加 実装はTypeScriptで行います。 バックグラウンドで実行して欲しい内容としては、 Chrome で画像を右クリックした際に「AIで推論する」というような内容をメニューに挿入することです。 また、メニューを選択した際は、コンテンツ スクリプト 側に何からの手段で該当の画像を送信する必要があります。このバックグラウンドとコンテンツの間のデータ送信はメッセージ機能で簡単に実現することができます。 chrome.runtime.onInstalled.addListener((): void => { chrome.contextMenus.create({ id: "inference-cnn", title: `AIの結果を見る`, contexts: ["image"] }); }); chrome.contextMenus.onClicked.addListener((info, tab): void => { if (tab?.id && info.srcUrl) { const message = { type: "inference-cnn", imageUrl: info.srcUrl }; chrome.tabs.sendMessage(tab.id, message); } }); 推論処理 これもTypeScriptで記述します。この スクリプト 内でやることは以下の通りです。 バックグラウンド スクリプト から推論開始メッセージを受信する メッセージから画像のURLを取り出す 画像のURLから画像をロードする 画像をロードし終えたら、MLモデルに入力できるように前処理を行い、 Tensor 型にする MLモデルをロードする MLモデルのロードを終えたら、推論する 推論を終えたら、結果を出力する XXXを終えたら、と書いてあるところは実際に処理の終了を待ってあげる必要があります。普段 Python を書いていると非同期処理が出てきた際に一瞬ビクッとしますが(私だけかも)、直列に実行していくだけなのでシンプルにawaitすれば十分です。実際に記述するのはそこまで大変ではありません。 必要な画像の前処理は、グレースケール化とリサイズでした。どちらも画像ライブラリによって差異が大きく、その差分を吸収するのは大変なので結構雑に実装してしまいました。きちんとやるならば、実験時と同等の挙動を示すように前処理を実装する必要があります。 import { InferenceSession, Tensor } from "onnxruntime-web"; const asTensor = (image: HTMLImageElement): Tensor => { const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; const canvasContext = canvas.getContext('2d'); if (canvasContext != null) { canvasContext.imageSmoothingEnabled = true; canvasContext.imageSmoothingQuality = "low"; } // 雑にリサイズして雑にグレースケールにする canvasContext?.drawImage(image, 0, 0, 1024, 1024); const imageData = canvasContext?.getImageData(0, 0, 1024, 1024); const input = new Float32Array(1024 * 1024); for (let i = 0; i < 1024 * 1024 * 4; i += 4) { const r = imageData?.data[i] ?? 0; const g = imageData?.data[i + 1] ?? 0; const b = imageData?.data[i + 2] ?? 0; input[i / 4] = (255 - (r + g + b) / 3) / 255.0; } const tensor = new Tensor('float32', input, [1, 1, 1024, 1024]); return tensor; } const inference = async (tensor: Tensor): Promise<string> => { // モデルの読み込み const modelFilePath = chrome.runtime.getURL('model.onnx') const session = await InferenceSession.create(modelFilePath, { executionProviders: ["webgl"] }) // 推論の実行 const feeds = { 'modelInput': tensor } const outputMap = await session.run(feeds) const outputData = outputMap.modelOutput.data; return `${outputData[0]}` } const run = async (imageUrl: string) => { const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => { const tensor = asTensor(image); console.log("Start to inference...") inference(tensor).then((inferenced) => { console.log(`Max size is ${inferenced}.`) }); }; image.src = imageUrl; } chrome.runtime.onMessage.addListener((message, sender, sendResponse): string => { if (message.type == "inference-cnn") { run(message.imageUrl).then(sendResponse); return "OK"; } return "" }) ONNX 図面画像から最大寸法値を推論するMLモデルはPyTorch Lightningを使用して学習されています。そこで、モデル定義ファイルと重みファイルをダウンロードし、そこからONNXファイルへと変換します。変換にはPyTorch Lightningの onnx export関数 を使用しました。 実体はtorchのonnx export関数 なので使い方は難しくありません。 ここで、 Chrome extensionではONNXファイルのランタイムとしてonnxruntime-web を使用していますが、onnxruntime-webで動かせるONNXには制限がある、ということに気をつける必要があります。比較的新しめのモデルだと未対応の関数を使用していたり、またONNX変換時に最適化をしすぎたりするとonnxruntime-webで動かないことがあります。 また、PyTorchからONNXファイルを変換する際、重みの整数型がint64で出力されますが、onnxruntime-webで動かすためにはint32に変換しておく必要があります。変換にはこちらの onnx-typecast を参照させてもらいました。 誤差 画像の前処理を雑に書いたり、ONNX変換をしていたりするので、元の実験時とだいぶ違う挙動になるのかと思いましたが、意外にも誤差は十分に使用可能な範囲で小さく収まってくれました。 まとめ onnxruntime-webやWASMのおかげでちょっとした開発をすればMLモデルを Chrome extensionとして配布できることがわかりました。 とはいえ、 API による提供に対して モデル更新のタイミングがユーザ依存になる ユーザのリソースで計算するため実行が安定しない どのモデルがどの程度ユーザに使用されているかが分からない などの問題はあります。ぱっとお試しで価値検証をしてもらうには良いかもしれませんが、 API をなくせるほどではないなと思いました。 銀の弾丸 は存在しないので、適切なタイミングで適切な価値を提供できるよう精進していきたいものです。
アバター
こんにちは。CADDiのAI LabでMLOpsエンジニアをやっている中村遵介です。 MLOpsチームは今から3ヶ月前に立ち上がったばかりの新しいチームなのですが、その前身としてAPI基盤を作っていた時期があったので、そこで得られた知見を書いていこうと思います。 背景 CADDiのAI Labは2021年の12月に立ち上がった今月1才になったばかりの組織です。その若さにも関わらず、日々有用なMLモデルが作成されていっています。 そのような中で、「新しく作ったMLモデルを素早くユーザにデリバリーしたい」という話が上がるようになりました。ここでいうユーザとはCADDi社員や社内システム、公開アプリケーションなどを指します。 そのため、AI Lab内で簡単に使用できるAPI基盤を作成することにしました。具体的には以下の体験を作ることを目指しました。 開発者に提供するAPIデプロイ体験 推論コード部分だけを記述すればAPIサーバのためのDockerイメージを完成できる APIに必要なテストやドキュメントは一定のテンプレートに沿って記述できる CIの中でコードから簡単にAPIをデプロイできる MLモデルのデプロイに必要なテスト類は簡単なCLIを通して実行することができる ユーザに提供するAPI使用体験 APIの使い方やスキーマをドキュメント上で確認できる 特定のエンドポイントに対してHTTPSリクエストを送ることで期待のレスポンスを得ることができる API呼び出しには認可を必要とする 開発したもの 開発したものは大きく次の5つです API基盤の中心であるAPIサーバ(エンドポイント) APIサーバにAPIをデプロイするためのデプロイツール APIの仕様を公開するためのドキュメントツール APIの品質を確認するためのテストツール APIの状態を監視するためのモニタリングツール それぞれについて選定した技術に関してご紹介していこうと思います。 APIサーバ APIサーバ自体はGoogle CloudのVertex AI Endpointsをそのまま使用しました。Vertex AI Endpointsを簡単に説明すると「特定の条件を満たしたDockerコンテナをAPIサーバとしてホスティングするフルマネージドサービス」です。 一般的な使い方としては、 特定の条件を満たしたDockerコンテナをArtifact Registry か Container Registry にアップロードする アップロードしたDockerコンテナを、Vertex AI Model Registryにモデルとしてインポートする インポートしたモデルを、Vertex AI Endpointsにエンドポイントとしてデプロイする と言う流れになります。 我々のチームでVertex AI Endpointsを採択した理由は、大きく2つあります。 1つ目の理由は、GPUを簡単に扱えることです。現在利用しているMLモデルは特に軽量化をしておらず、高速な推論のためにGPUを必要とするものがあります。しかし、CloudRunではGPUを扱うことができず、GKEでは比較的大きめの運用コストを見込む必要があります。 一方で、Vertex AI EndpointsはGPUをアタッチするかどうかを選択するだけで簡単にGPU環境にデプロイ可能です。ただし、デプロイするDockerイメージの中でGPUを使用するコードを書く必要はあり、アタッチできるGPUの種類はデプロイするregionによって異なるため注意が必要です。 2つ目の理由は、Vertex AI Endpointsの要求が少なかったことです。Vertex AI Endpointsは、Vertex AI Model Registryというコンテナ置き場に置いたDockerイメージをデプロイすることでサービングを開始できます。そして、Dockerイメージは以下の要件さえ満たせば自由に実装することができます。(参考 コンテナイメージの要件 ) HTTPサーバが実行されていること ヘルスチェック用のエンドポイントが用意されていること 予測用のエンドポイントが用意されていること リクエストサイズは最大1.5MB以下であること 入出力のスキーマに一定の制約を設けること Vertex AIのカスタムトレーニングを使用してDockerイメージを作成する場合は、自動的に上の要件を満たしたDockerイメージを作成することができます。 また、独自の実験環境でモデルをトレーニングした場合でも、上記の要件を満たすようなDockerイメージを作成すれば、APIサーバとして公開できます。この手軽さからVertex AI EndpointsをAPI基盤に選択しました。 一方で、要求が少ないということは実装の自由度が高いことを意味します。Dockerイメージを作るためには、自分達自身で上記の条件を満たしたAPIサーバを正しく実装する必要があります。 要求される知識や実装コストは決して低くありません。MLモデルをデプロイする際に、ML以外の関心ごとに多くの時間を取られるのは避けたいところです。 そこで、Dockerイメージの作成には敢えて制約をかけ、TorchServeを使用することに決めました。TorchServeはPyTorchでトレーニングしたモデルをHTTPサーバから簡単に提供できるようにするツールです。 モデルの実際の推論処理をHandlerと呼ばれるクラスの中に実装する必要がありますが、それ以外はほとんど手を加える必要がありません。 TorchServeでAPIサーバを起動させるには、前準備として、Torch model archiverというツールを使ってHandlerの定義ファイル(handler.py)とパラメータ(model.pt)を1つのmarと呼ばれるファイルに固める必要があります。 torch-model-archiver \ --model-name ${model-name} \ --version 1.0 \ --serialized-file model.pt \ --handler handler.py \ --extra-files src \ --export-path model-store 固めたmarファイルをTorchServeに渡してあげることでAPIサーバが立ち上がります。APIサーバの細かな挙動は設定ファイル(ts_config.properties)を記述することで制御できます。 torchserve \ --start \ --ts-config=ts_config.properties \ --models ${model-name}.mar \ --model-store model-store 先ほどのVertex AI Model Registryの要件と非常に相性が良く、ヘルスチェックと予測用のエンドポイントが自動で作成されます。入出力のスキーマに関しても一致しています。さらにロギングや動的なバッチ処理など多くの重要な機能を自動で提供してくれます。Vertex AI EndpointsでもHTTPサーバの作成方法の1つとしてTorchServeが挙げられていました(参考リンク: Google Cloud 上の PyTorch: Vertex AI に PyTorch モデルをデプロイする方法 )。 TorchServeは、予め環境が構築されたDocker imageがCPU環境/GPU環境の両方で 公開 されています。 ただし、TorchServeとVertex AI Endpointsを組み合わせるためには以下に気をつける必要があります。 CPU環境/GPU環境の両方で動かすために、それぞれの環境用にptファイル(モデルパラメータ)が必要である APIに設定ファイルや他のPythonスクリプト等の追加ファイルが必要な場合、mar作成時に渡す必要があるが、1つのディレクトリにフラットに再配置されるためファイルパスを2種類記述して切り替えるなどで対応する必要がある 特に2つ目の方は注意が必要です。 torch-model-archiverは渡されたファイルを1つのmarファイルに固め、TorchServeはそれを受け取ってテンポラリディレクトリの中に展開します。この際、元のディレクトリ構成は無視されて全てのファイルがテンポラリディレクトリの直下に展開されます( 参考イシュー )。 複雑な構成の場合は予めディレクトリごとzipに固めるなどの回避策を取るそうですが、我々は数ファイル程度の依存しかなかったため、スクリプトの中でインポート先を動的に切り替えて対応しました。 デプロイツール CI/CD デプロイに関しては初期開発段階は手動で行っていました。開発 、検証、本番の3環境を用意して、それぞれについて権限を持っている人がGoogle Cloudコンソール上もしくはgcloud CLIを通してデプロイを行なっていました。 しかし、多くの先例が示す通り手動デプロイはミスやデプロイサイクルの速度低下に繋がったため、一連の操作を1つのスクリプトにまとめ、CIからデプロイするように変更しました。 デプロイ手段として、スクリプト以外にもterraformがよく選択されます。しかし、Vertex AI Endpoints自体はterraform管理下に置くこともできても、参照先となるVertex AI Model Registryは 2022年12月1日時点 だとterraform記述はできませんでした。 そのため、Vertex AI Endpoints自体もterraform管理下とせず、1つのスクリプトを実行することでDockerイメージビルドからデプロイまでの全ての流れが完了するようにしました。 注意点として、初回のデプロイ時と2回目移行のデプロイではコマンドの中身が異なる箇所があります。Vertex AI Model RegistryとVertex AI Endpointsについては、初回実行時はリソースを作る必要があり、2回目移行は作ったリソースに紐づける形でコンテナやモデルをデプロイしていく必要があります。 # モデル名でVertex AI Model Registry内を検索。存在すればモデルIDが入り、存在しなければ空文字となる model_id= (gcloud ai models list \ --project {project_id} \ --region {region} \ --filter=displayName= {model_name} \ --format="value(name)") # 新規にモデルを作成 if [ -z " {model_id}" ]; then gcloud ai models upload ……. # 既存のモデルに紐づけて新バージョンとして作成 else parent_model= (gcloud ai models describe {model_id} \ --project {project_id} \ --region {region} \ --format="value(name)") gcloud ai models upload ……. –parent-model= {parent_model} fi また、別の注意点としてトラフィック分割の問題が挙げられます。Vertex AI Endpointsでは新しくモデルをデプロイすると、既存モデルへのトラフィックが自動的に0%になり、新規モデルへのトラフィックが100%になります。開発環境や検証環境はデプロイと同時に新規モデルに切り替わっても問題ないのですが、本番環境に関しては安全に倒して、新規モデルへのトラフィックは0%にした状態でデプロイされるようにしました。 # 指定のエンドポイントのトラフィックを取得し、mode_1=割合,model_2=割合のように並べる current_traffic= (gcloud ai endpoints describe {endpoint_id} \ --project= {project_id} \ --region= {region} \ --format=json | jq '.trafficSplit’) formatted_current_traffic= (echo current_traffic | jq 'to_entries[] | .result = (.key|tostring)+ "=" + (.value|tostring)' | jq '.result' -r | tr '\n' ',' | sed -e 's/, /\n/g' ) # 新しいモデルのトラフィック割合を0にして、既存のモデルの割合を変更せずにデプロイ gcloud ai endpoints deploy-model …… –traffic-split=0=0, {formatted_current_traffic} さらなる注意点として、トラフィックが0%になった既存モデルのアンデプロイの自動化が挙げられます。トラフィックを完全に切り替えても、既存モデルは待機状態のままであるため、使用しているリソース分の料金が請求されます。 最初は定期的にモデルを監視して不要なモデルを手動でアンデプロイしていました。現在は使用していないモデルをアンデプロイするワークフローを作成してその作業を自動化しています。 追跡可能性 CIの中でモデルをデプロイしているため、CIのログ上には、いつどのコードを何にデプロイしているかの記録は残っています。しかし、それを目で辿っていくのは困難を極めます。 そこで、Artifact Registryへのpush時にgitのcommit hashをDockerのタグ情報として記録するようにしました。さらに、このcommit hashをVertex AI Model Registry上のモデルのエイリアスにも登録し、現在デプロイされているAPIから、作成したソースコードまでを簡単に辿れるようにしました。 一点気をつけることとして、Vertex AI Model Registryのエイリアスは英小文字始まりで指定する必要があります。ドキュメントに理由は見当たりませんでしたが、commit hashは数字で始まることもあるため、commit-というprefixを付けて回避しました。 Google Cloud コンソールからエイリアスを新規作成しようとした画面。英小文字指定が求められている。 デプロイ戦略 AI Labではモノレポを採用しており、APIのソースコードは全て該当のレポジトリに入っています。レポジトリはmainブランチを1つだけ持っており、開発時はmainから派生しています。そしてmainブランチへのマージで開発環境にデプロイが走るようになっており、mainの最新を反映し続けるようにしています。 また、それ以外にも手元から特定の命名規則に従ったタグをpushすることでもデプロイ可能にしました。これによって開発時に手元からさっと開発環境にデプロイすることが可能です。複雑なブランチルールを持たず、タグpushだけで気軽にデプロイできるためデプロイのハードルはかなり低くなっていると感じています。 検証環境や本番環境へのデプロイも現在はタグpushを採用していますが、mainブランチ以外からのデプロイを避けるため今後はWorkflow dispatchでデプロイするように変更予定です。 ドキュメントツール API基盤が作成されMLモデルをサービングできるようになりましたが、Vertex AI EndpointsはAPIの仕様に関しては面倒を見てくれません。入出力のスキーマはどのようなものなのか、どのカラムにどんな型のどんな値を期待しているのか、返してくれるのか、などは最小限のスキーマ情報を除いてほとんど未知の状態です。 そこで、APIの仕様を手で記述してドキュメントサーバ上に公開することにしました。幸いなことに、特定のGCSバケット上に静的ファイルを置くと簡単に社内ドキュメントサーバにデプロイできる仕組みがCADDi内にありました。我々のAPIドキュメントも同じように、GCS上に仕様を記述したHTMLファイルを置くことで社内公開することにしました。 APIの仕様の記述にはOpenAPIを使用しました。YAMLにAPI情報を記述するのですが、APIの仕様記述に関して一般的なフォーマットであり、HTMLへのコンバートも簡単にできること、複数のYAMLファイルに分割して記述しても簡単にまとめることができること、が大きな選定理由です。OpenAPIに則ったYAMLファイルの管理ディレクトリの構造は以下のように決めました。 これらを swaggerを通して1つの大きなHTMLファイルへと変換しています。 APIの仕様にはVertex AI EndpointsのURL、入出力のスキーマ情報に加えて、入出力の各値の具体的な意味や実際の入出力例を記述するようにしました。というのも、単純なスキーマ情報では文字列型を期待していることはわかっても、実際にそこには画像のbase64エンコードテキストを入力することまでは分からないからです。 テストツール ここまでで「APIを公開する」ということは可能になりましたが、すぐに「公開したAPIが本当に期待したものなのかのテストができていない」という問題に当たりました。 APIが本当に接続できる状態なのか、期待する出力が期待する時間内に返ってくるのか、等は不明なままです。具体的には、以下の内容を保証する必要が出てきました。 ドキュメントに書かれたAPIの型定義と、リクエスト・レスポンスの例に矛盾がない ドキュメントに書かれたAPIのリクエスト例が、実際にリクエスト可能なものである API基盤にデプロイしたモデルと、デプロイしようとした実験時のモデルの挙動が等しい ある程度の高負荷時にも妥当なレスポンス時間で返ってくる そこで、テストツールとして以下の内容をテストするツールを作成しました。 APIのドキュメントとして記述したOpenAPIが正しいフォーマットに沿っているかどうか ドキュメントに書かれたリクエストの例を用いてAPIが疎通できるか APIが、デプロイ前のMLモデルと同じ値を返せているか APIに高負荷をかけてもリクエストが妥当な時間内に返ってくるか OpenAPIのフォーマットテスト OpenAPIとしての記述の正しさの確認にはswaggerを使用しました。しかし、このツールは型の中身についてチェックまではしてくれません。つまり、矛盾するような型定義を書いてもYAMLとして問題がなければチェックを通過してしまいます。そこで、さらに型定義のテストを行うようにしました。具体的には リクエスト・レスポンスの型定義に矛盾がないか リクエスト・レスポンスの具体例が型定義と一致しているか 型の定義はJSON schemaで記述されています。そこで、OpenAPIの型定義部分だけ抜き出して以下の内容をチェックするCLIを用意しました。チェックにはPythonモジュールの jsonschema を使用しました。 これにより、ドキュメントに書かれたAPIの型定義と、リクエスト・レスポンスの例の間に矛盾がないことが保証されました。 APIの疎通テスト Vertex AI Endpointsにデプロイ後、サービングが確かに始まっていることを確認するため、サンプルリクエストを使用した疎通テストを追加しました。これによって、「デプロイしたと思っていたが実は途中で失敗したいた」という問題や、「ドキュメントのサンプルリクエストが実は間違っていた」という問題を消すことができました。 APIの性能テスト MLエンジニアがモデルを作成した時の実行環境・ソースコード・ライブラリバージョンと、APIサーバのそれらを完全一致させるのは非常に難しいです。そのため、「作成したモデルとAPIの出力が一致しない」ということはしばしば起こり得ます。大切なのは、それらの誤差が許容できるレベルに十分小さいのか、もしくは何らかの問題により大きな誤差が発生しているのかを検知することです。 そこで、数十〜数百枚程度の小規模なデータセットに対して、モデル作成時にそれらの推論値をGCSに保存しておき、APIサーバがほぼ同じ出力を返すことを確認する性能テストを追加しました。APIサーバはローカル環境に立ち上げることで、デプロイ前にテストをすることが可能です。 これによって、API上でも期待する精度を出せることが保証できるようになりました。 APIの負荷テスト これまでのテストで、APIが正しくデプロイされたことは保証されましたが、実際に使用に耐えうるレイテンシかどうかまでは確認できていません。そこで、APIドキュメントから使用したサンプルリクエストを使用した負荷テストを追加しました。 負荷テストのツールにはlocustを用いました。テスト実行後にHTMLでレポートが生成されるため、そのレポートを共有することでAPIが高負荷時にも許容できるレイテンシになっていることを保証しています。 API基盤のモニタリングツール 上記の複数のテストを経て、APIをデプロイすることができるようになりました。しかし、本当に大事なのはここからで、デプロイしたAPIを運用していく必要があります。具体的には、API基盤が問題を起こしていないかを監視し続ける必要があります。 監視にはCloud Monitoringを使用しました。Google Cloudで完結すること、Vertex AI Endpointsに対する基本的な監視がデフォルトで用意されていることが理由です。監視項目は以下の内容です。これらをダッシュボード上に用意し、業務時間中は常にサブディスプレイに表示し続けることで「Vertex AI Endpointsが正常に稼働している際にこれらの値がどのような傾向を示すか」というのを追い続けています。 監視をする中で、いくつか分かったことがあります。ここでは4つほどあげたいと思います。 GPUを使用しているAPIは、インスタンスのスケールアップが遅い 現在、CPU使用率が60%を上回ったAPIに関しては、インスタンス数を増やすように設定しています。しかし、実際にCPU使用率が60%を上回ってから、追加のインスタンスが確保されるまで、5-15分ほどがかかることがわかりました。図では5:43頃に閾値を超えていますが、実際にスケールアップしたのは5:52頃になっており、9分ほどかかっています。理由は分かっていませんが、TorchServeを含むDockerイメージのpullに時間がかかっているのかもしれません。 インスタンスのスケールアップ中はAPIのレスポンス速度が急激に低下する CPU使用率が60%を超えてからインスタンスが増加するまで、APIのレスポンス速度が急激に低下します。デプロイしているAPIの多くの普段のレイテンシが大体0.1秒から1.0秒ですが、インスタンススケールアップ中は10-60秒ほどに低下します。また、この間に多くのリクエストに対して503エラーが返るようになります。 現時点では、5XX系のエラーに関しては利用者側にリトライ処理をお願いしています。 GPUのメモリ使用量はほとんど一定である 高負荷時も低負荷時も、GPUのメモリ使用量に大きな変化はなく、リクエストのボトルネックになることはなさそうでした。一方GPU使用率の方は負荷に応じた変動を示しています。 問題発生時にできることはほとんどない Vertex AI Endpointsはフルマネージドサービスであるため、Vertex Modelとそれを動かすインスタンス条件だけを決めれば、あとはよしなにサービングしてくれます。逆に言えば、API基盤として問題が起きたとしてもやれることはほとんどなく、Google Cloud全体の障害かどうかを判断するかくらいです。もちろん精度の問題を検知した場合は、過去バージョンに巻き戻すのような対応がありますが、これも簡単に行うことができます。 とはいえ、まだ運用しはじめて3ヶ月ほどですが、基盤として問題が生じたことはなく安定したサービスだなと感じます。 Vertex EndpointのHuman readableな識別子がない これは地味に辛い問題です。Vertex Endpointは識別子として数値列を使用しています。ユーザ側が指定することもできますし、特に指定しなければ適当な値を割り振られます。しかし、どちらの場合であっても数値列だけを見てどのAPIのものかを判断することは難しいです。そのため、APIには displayName という表示名を与えることができますが、これをCloud Monitoringから確認することができません。現時点ではダッシュボード上のテキストカードに、識別子とdisplayNameの対応を記述することで凌いでいます。 結果 半年前に3ヶ月ほどで作られたAPI基盤ですが、現在も開発や運用が続けられています。 まだ荒削りなとことはありますが、チームが増えた今でもシステム自体は容易に把握することができ、いくつものAPIがMLエンジニア主導でデプロイされていたり、新たにチームにジョインしたMLOpsメンバーが1週間で機能改善の本格的なPRを作成したりしてくれています。 一方、まだテストを自動化しきれていなかったり、スケールアップ時にAPI基盤が不安定になったりする問題があります。 今後は、テストやデプロイメントを改良しつつ、よりユーザに使ってもらいやすいAPI基盤を目指していこうと思います。 最後に MLOPsチームではAPI基盤を皮切りに、ビジネスサイドに提供するためのバッチ推論基盤やデータ管理基盤にも取り組んでいます。今後は再学習基盤やデータ基盤など、より早く広く安全にAIの価値をビジネスに提供するための仕組みを作って行くので、一緒に作っていける仲間を募集しています! CADDi Tech カジュアル面談はこちら MLOpsエンジニアの募集はこちら The post Vertexで3ヶ月で作る運用可能なML API基盤 appeared first on CADDi Tech Blog .
アバター
私(寺田 @u_1roh )が携わっているプロジェクトについて。 ここでは「金属加工品の多品種少量生産」という文脈の話をします。具体的には、例えば板金加工や旋盤やフライス盤による機械加工などの受注生産をイメージして下さい。 CAD/CAMの理想と現実 製造業では、CADやCAMといったソフトウェアが使われています。この2つは次の役割分担をしています。 CADは、製品の形状や仕様を定義する。 CAMは、製品仕様を満たす加工プログラムを生成する。 この役割分担の理想を突き詰めると、次のようになるでしょう。 CADは、どんな複雑な形状でも表現できる高い自由度を持ち、実際に用いられる加工法を仮定することなく、製品の仕様を表現する。 CAMは、どんな複雑な形状でも受け入れることができ、寸法公差や幾何公差を反映した加工プログラムを生成できる。 CAD界隈は、この理想に向かって進化を推し進めようとしているようです。しかしこの理想は、実現には相当なハードルがあるのではないかと私は予想しています。実際、3DCADに付与されたPMI(Product Manufacturing Information; 寸法公差や幾何公差などのこと)を下流工程に自動連携するというのは、語られてはいるもののまだ普及しているとはいい難いでしょう。 事実、弊社キャディでは、見積もりの段階で3DCADのデータを受け取ることはほとんどありません。多くのお客様は2D図面(PDF)で見積もり依頼や発注をされます。CAD業界が喧伝する高邁な理想をよそに、 多品種少量生産の現実は「図面」で回っています。 Worse Is Better で現実に向き合う しかし一方で、図面で設計情報を流通させている限り、この業界にブレークスルーをもたらすことは難しいことも事実です。PDFの図面は machine readable ではなく、「画像」という非構造化データです。これを読み取るには人の目が必要になる場面が多く、DXを阻む大きな壁として立ちはだかっています。 この状況を、Worse Is Better の哲学で打開したい、と私は考えています。 「正しいこと」は時として、描かれた理想像は美しくとも、それを実際に実装するコストが途方もなく高く付くことがあります。CAD業界が目指している理想は、まさにこの隘路にはまり込んでいるように私には思えます。少なくとも、彼らの描く理想が多品種少量生産の業界まで「降りて」くるまで長い年月がかかるでしょう。 こういった「正しいこと」の追求に対するアンチテーゼが Worse Is Better の考え方です。t_wada さんのツイートが非常に端的でわかりやすいので引用致します。 Worse Is Better に関する自分の解釈は「設計の正しさ/美しさと実装の単純さが対立する(両立できない)ときは、実装の単純さを選択した方が、たとえそれが漏れのある抽象になったとしても現実の問題を解決し、実装の単純さによって開発参加のハードルが下がり、進化的な強さを獲得できる」というもの — Takuto Wada (@t_wada) April 6, 2018 MO CAD 構想 現在、私のチームでは独自のCADフォーマットを作るプロジェクトに取り組んでいます。 私たちのCADは、数学的な美しさは犠牲にして、形状表現を加工ドメインの特性に合わせて実用上十分な範囲に制限し、代わりに寸法公差や幾何公差といった加工指示を重視したものにする。 下流工程のシステムは、上記のように制限された形状表現のみを受け取るものとすることで、形状認識のアルゴリズム開発の実装コストを低減させる。 このコンセプトを私たちは、Manufacturing Oriented CAD、略して MO CAD と呼んでいます。 MO CADの価値の中核を成すのは、3Dグラフィックスでもモデリング機能でもなく、データモデルです。純粋なCADでもなく、純粋なCAMでもなく、「製造に歩み寄ったCAD」という何とも中途半端な立ち位置のデータモデルを設計する必要があります。数学的な美しさや完全さは犠牲にして、代わりに下流システムの実装のしやすさに配慮し、妥協にまみれたデータモデルを設計しています。お世辞にも美しいとはいい難いものですが、「しかしこのほうが better なんだ」という信念のもとに実装を進めています。 これはつまり、 Domain Specific なデータモデル です。フライス加工品のためには、フライス加工という加工ドメインに特化したデータモデルを作ります。加工ドメインごとにデータモデルが必要になるので一見ハイカロリーに思えますが、実際にはこの割り切りによって下流システムの実装コストが低減されるので、総じて見れば低コストで済むというのが私が持っている見立てです。 製造を指向すると、形状の自由度は制限できる 製造を指向すると、自然と形状は制限されます。これが MO CAD 構想の根拠になっています。 冒頭で述べたCADとCAMの分担は、顧客企業(発注側)と加工会社(受注側)の役割分担とも似ています。 顧客企業は、製品の形状や仕様を定義する 加工会社は、顧客が定義した製品仕様を満たすものを製造する しかし、コトはそれほど単純ではありません。 加工が困難な形状を設計してしまうと、現実的なコストで製造できないことになります。ですから、顧客企業は製造のしやすさに配慮した設計をします。機能要件を満たしつつ、加工にも配慮してコストダウンを図るのが設計者の腕の見せ所です。 つまり、CADシステムの思想がどうであれ、実際には製品は設計の段階から加工方法がある程度想定されています。 設計という行為は本質的に Manufacturing Oriented である といえるかもしれません。 その結果、実際に設計される製品形状は、加工のしやすいシンプルな形状に収斂します。フライス加工品であれば、直方体のブロックから削り出しやすい形状になります。それらは、平面と円筒面のみから構成され、角の多くは直角です。 つまり、データモデルとして表現可能な形状が上記のように制限されたとしても、実用上は問題ありません。それよりも、下流工程を自動化するために必要になる演算を実装しやすくすることが重要です。 見積もりを自動化する MO CAD データの活用先として、見積もりの自動化という課題を挙げます。 フライス加工の加工コストは、例えば切削によって除去される体積であったり、段替えの回数であったり、様々なパラメータによって決まります。見積もりを自動計算するためには、CADデータからこれらのパラメータを自動で推論しなくてはなりません。 一般的な3DCADデータは B-rep と呼ばれるデータ構造で表現されており、非常に柔軟で高い表現力を持っています。これはCAD側にとっては自由度が高い一方で、そのデータを受け取る側は負担を強いられます。B-rep データから段替え回数を推論するのは、おそらく相当に困難でしょう。対処しきれないほどの例外ケースを回避することに追われて、例外処理だらけの読むに耐えないプログラムになってしまうと予想されます。 こういった事態を回避するために、見積もりの自動化で必要になる形状演算が比較的容易になるようなデータモデルを考えていきます。 ※ B-rep については例えば私のQiitaをご参照下さい。 3D CAD の内側をちょっと覗いてみませんか – Qiita 俺は B-rep をやめるぞーーッ! 具体的なデータモデルをここで詳細に述べることはできませんが、アイディアの一端を軽くご紹介します。 フライス加工というのは除去加工です。まず直方体の「母材」があり、そこから切削によって体積を除去していくことによって製品形状を得ます。この加工の実態をそのまま模したようなデータモデル、つまり引き算しかないCSG表現のようなモデルが、アイディアの核となっています。 これはつまり、3DCAD の「定石」である B-rep を捨てることを意味します。これは勇気の要る判断ですし、この判断が正しいかどうかはこれから検証されていくことになるでしょう。 しかし、3DCADが誕生して40年以上の年月が経過にしているにも関わらず、この業界は未だに図面で回っています。この事実がまさに、「3DCADの定石」が業界のニーズにマッチしていないことの証左ではないでしょうか。 常識に囚われない、大胆な判断が必要だ、と考えています。 まずはドッグフーディング 少なくとも最初の想定ユーザーは、キャディ自身です。 お客様から図面を頂いたらまず最初に、MO CAD データに変換するオペレーションを社内で回す想定です。これにより、後段のあらゆる処理に対して自動化への道が拓けるので、トータルで見るとオペレーションコストが大幅に下げられることが期待できます。 もちろん、将来はお客様に直接使って頂けたら嬉しいですね! ですが、やはり最初はドッグフーディングから始めるべきでしょう。 見込める効果は、いわゆるフロントローディングです。 フロントローディングというのは、雑にいうと「前工程で頑張っておくと後工程がめっちゃ楽になるよ理論」ですね。前工程でリッチな情報入力を頑張っておくと、後工程が自動化されたり非常にスムーズになったりして嬉しいというやつです。 これの最大の嬉しさは、「問題がより早い段階で顕在化すること」です。 従来は後工程になってみないと発覚しなかった問題が、フロントローディングをするともっと早い段階で顕在化するようになります。早い段階で顕在化すれば、早い段階でお客様にフィードバックしたり擦り合わせたりできます。「納期直前で問題発覚、緊急電話と突貫工事のドタバタ劇」みたいなハレーションを事前に防ぐことが出来ます。 まずはキャディ社内でフロントローディングを実現させることで、QCD (Quality, Cost, Delivery) を更に向上し、受発注事業を通じてお客様に価値を届けることを目指します。 技術スタック ブラウザで動く Web アプリケーションとして作っています。 front-end TypeScript React, Vite, Nest.js(BFF) Three.js back-end Rust C++, Open CASCADE (フリーの3DCADカーネル)  F#, ODA (2DCADデータのライブラリ) データモデルのスキーマ定義 F# 弊社はかなり Rust を推しており、本プロジェクトでもご多分に洩れず Rust を利用しています。私も大好きな言語です。 この中では F# が異彩を放っているでしょう。F# は Microsoft の .NET で動く関数型言語です。しかし Windows を使っているわけではなく、.NET Core を使って Linux の上で動かしています。 MO CAD データのスキーマは F# を用いて定義されています。F# は、 Domain Modeling Made Functional というDDDの書籍でも使われているように、データモデルを簡潔に記述できる言語です。独自開発中のライブラリにより、F# で定義された型をリフレクションを使って動的に読み取り、Rust と TypeScript と Python の型をコード生成できるようにしています。これにより、F# で定義されたスキーマに沿って複数の言語で JSON のシリアライズ/デシリアライズが可能になっています。(これについても、頃合いを見計らってご紹介できたらいいなと考えています。) エンジニアを募集しています 一緒にこの夢を追って頂ける仲間を募集しています。以上の技術スタックを見て、すべてについて自信を持って「自分はこの開発で活躍できる」といえる方は、かなり限られることでしょう。多くのWebエンジニアはCADというものには馴染みがないかと思いますし、逆にCADの業界にいらっしゃる方はWebの開発に馴染みが薄いと思います。ですのでもちろん、すべての分野に明るいスーパーマンを募集しているわけではありません。各々のエキスパートが、互いをリスペクトしあい、協力しあって開発を進めていければ良いと考えています。 MO CAD に関わるエンジニアは、大きく次の2つに分けられそうです。 MO CAD そのものを作っていくエンジニア MO CAD データを利用するアプリケーションやアルゴリズムを開発するエンジニア 「MO CAD そのものを作っていくエンジニア」として、この場で特に募集したいのは、次のような方です。 フロントエンド開発において、例えば Three.js や WebGL など、グラフィックスのご経験がある方。特に、まさにCADソフトのように、マウス操作を伴ったインタラクティブなUXを開発した経験がある方。逆にいうと、複雑なシェーダーを駆使した美しいグラフィックスとか、大容量データのレンダリングといった技術は、現時点では必要としておりません。CADアプリケーションのUX開発に情熱を燃やせる方だと素晴らしいです。 バックエンド側で、RustやC++を使って2D/3Dの幾何アルゴリズムを書ける方。もちろん、ズバリこの通りの経験をお持ちである必要はなく、そういったポテンシャルがある方。空間幾何を扱う基礎的な数学力や、グラフ構造を扱う基礎的なアルゴリズムなどが必要になります。 MO CAD や関連するデータモデルのスキーマを設計できる方。加工ドメインや下流工程のオペレーションといったドメイン知識を積極的に獲得し、製品の形状表現と下流工程の処理をバランスさせたドメインモデリングをする必要があります。 同時に、「MO CAD データを利用するアプリケーションやアルゴリズムを開発するエンジニア」も必要になっていきます。次のような関連アプリケーションが考えられます。 MO CAD データを管理するシステム。BOM や PDM/PLM といったドメインに明るい方が加わって頂けると非常に心強いです。 MO CAD データから加工プロセスを推論するアルゴリズム。プロセスが推論できれば、加工時間も予測しやすくなり、原価計算にもつながっていきます。幾何的な計算処理が書けると同時に、加工プロセスへの理解が必要になります。 難加工を自動検出するアルゴリズム。加工が非常に難しい箇所、あるいは不可能な箇所があれば、これを自動で検出するというものです。こういった難加工が受注後になって見つかると、大きなトラブルに繋がりかねません。受注前の早い段階で素早く自動的に検出できれば、お客様と擦り合わせをしてトラブルを事前に防ぐことが出来ます。この開発もやはり、幾何計算アルゴリズムのスキルと加工への理解が必要になるでしょう。 本プロジェクトは、当然ながら、決して成功が約束されたプロジェクトではありません。まだまだ多くの不確実性が横たわっており、それらを一つ一つ潰していく必要があります。 そういった不確実性を含めて、大きなチャレンジに立ち向かうことを楽しめる方のご応募をお待ちしております! 「まずはカジュアルに話を聞いてみたい」という方は、こちらより面談をお申し込み下さい。 – 応募フォーム | JP-TECH-000.エンジニア・デザイナー:カジュアル面談 – キャディ株式会社 選考へのエントリーは下記サイトよりお願いします。 応募フォーム | Rust、F#を利用したCAD開発を行うMO-CAD Engineer – キャディ株式会社 応募フォーム | WebGL、Typescriptを利用したWebCAD開発を行うMO-CAD FrontEnd Engineer – キャディ株式会社 The post Worse Is Better の精神で Domain Specific なCADを作っている話 appeared first on CADDi Tech Blog .
アバター
はじめに こんにちは、Platformチームの小森です。 eBPF (extended Berkley Packet Filter) について、2022年8月2日に開催された社内勉強会で発表しました。 eBPF はここ数年で注目が集まっている技術で、2021年には eBPF Foundationが設立 され、Facebook、Google、Isovalent、Microsoft、Netflixなどの大手IT企業が参画を進めています。 筆者は概要程度しか把握していなかったので、遅ればせながらキャッチアップのために情報収集しました。すでに多くの情報が出回っているので新規性は少ないですが、引用元を示しつつ、短時間で理解できるようにまとめてみました。 特に次のような方は、eBPFの概要を押さえておくと良いのではないかと思います。 Kubernetesに興味がある、または使っている Linuxの中でもネットワーク周りの基盤技術に興味がある 日々障害調査に追われている iptablesが大好き eBPF とはなにか ざっくり概要 eBPF(extended Berkley Packet Filter)は、Linuxカーネル内でのイベント発生時に動作する処理を、安全・手軽に組み込むための仕組みで、現在ではLinuxカーネルの機能として提供されています。 eBPFは、カーネル空間で動作する仮想マシンです。仮想マシンというと、VMWare、VirtualBox、KVMなど、ハードウェアを含めてエミュレートするハイパーバイザを思い浮かべるかもしれませんが、eBPFは専用の命令セットを持った仮想的なCPUのようなものです。小型のJavaVMのようなものがカーネル内で動作するとイメージすれば良さそうです。 JavaVMと同じように、eBPF専用のバイトコードを渡すと、カーネル内部で検証され、実際に動作するマシンコードにコンパイルされたのちに、カーネルに組み込まれます。 「Packet Filter」なのに「Virtual Machine」? なぜ、eBPFは「Packet Filter」という名称なのに、実体は Virtual Machine なのでしょうか。理由は、その発展の歴史にあります。 eBPFの歴史は意外に古く、約30年前の1992年に、ローレンス・バークレー国立研究所のSteven McCanneとVan Jacobsonが公開した論文「 The BSD Packet Filter: A New Architecture for User-level Packet Capture 」にさかのぼります。 当初はその名のとおり、パケットキャプチャやフィルタリングを効率化するための技術でした。 現在でも、障害調査などで特定の宛先やポートで通信されるパケットを取得したいことはよくあります。 ネットワークでやりとりされるパケットをキャプチャして必要なものだけを抽出(フィルタ)するには、カーネル空間で動作するネットワークドライバはキャプチャしたパケットを、ユーザー空間で動作するアプリケーションに渡し、アプリケーション側でフィルタ処理を行う必要がありました。 [出典] : BPF Overview The BSD Packet Filter: A New Architecture for User-level Packet Capture このような作りでは、カーネル空間とユーザー空間の切替が多く発生し、効率がよくありません。 そこで、カーネル空間で動作する仮想マシンを用意し、その上でパケットをフィルタリングするアプリケーションを実行できるようにすれば、多くの処理がカーネル空間で完結するのでパフォーマンスが向上するというのが基本的なアイデアです。 [出典] : BPF Overview The BSD Packet Filter: A New Architecture for User-level Packet Capture BPFは当初BSDに実装されたのち、 1997年にLinuxカーネル2.1.75に移植されました 。 BPFを利用したパケットキャプチャとフィルタリング機能はlibpcapというライブラリとして実装され、現在でもよく使われているパケットキャプチャツール、 tcpdump で利用されています。 そしてしばらく時がたち、2013年、対象をネットワークパケットだけに限らず、より汎用化した仕組みとして「eBPF」が提案されました。2014年、eBPFがLinuxカーネル3.17に組み込まれて拡張が続き、今にいたります。 eBPFと対比して、当初のBPFを「cBPF (classic BPF)」と呼ぶこともあります。なお、現在ではeBPFは 「 which is no longer an acronym for anything (何の略称でもない)」とされています。 [出典] eBPF – 入門概要 編 – BPFの歴史 BPF – in-kernel virtual machine eBPFでなにができるか? カーネルイベントのフック eBPFでは、ネットワークだけではなく、さまざまなカーネル内のイベントをフックし、さまざまな処理を実行することができます。 フックできるカーネルイベントは「 Program Types 」として定義されています。 いくつかの記事を参考にして大別すると、以下のようになります。Program Typeの説明は、原文のほうが分かりやすいので、翻訳せずに出典からそのまま引用しました。 ソケット操作 – パケットフィルタリングや、コネクション確立/タイムアウトなどのソケット属性変更、パケットのリダイレクトなどのイベント BPF_PROG_TYPE_SOCKET_FILTER : a network packet filter BPF_PROG_TYPE_SOCK_OPS : a program for setting socket parameters BPF_PROG_TYPE_SK_SKB : a network packet filter for forwarding packets between sockets トンネリング – ネットワークスタック内のパケットカプセル化フレームワークに関するイベント BPF_PROG_TYPE_LWT_* : a network packet filter for lightweight tunnels 帯域制御 – 帯域制御を実現するためのイベント BPF_PROG_TYPE_SCHED_CLS : a network traffic-control classifier BPF_PROG_TYPE_SCHED_ACT : a network traffic-control action XDP(Xpress Data Path) – NICから受け取ったパケットデータを直接操作するためのイベント BPF_PROG_TYPE_XDP : a network packet filter run from the device-driver receive path トレーシング – カーネル関数呼び出し、カーネル関数内のイベント発生、パフォーマンスカウンタなどのイベント BPF_PROG_TYPE_PERF_EVENT : determine whether a perf event handler should fire or not BPF_PROG_TYPE_KPROBE : determine whether a kprobe should fire or not BPF_PROG_TYPE_TRACEPOINT : determine whether a tracepoint should fire or not Cgroups – Cgroup(プロセスをグループ化してリソース割り当てを制御する機構)におけるイベント BPF_PROG_TYPE_CGROUP_SKB : a network packet filter for control groups BPF_PROG_TYPE_CGROUP_SOCK : a network packet filter for control groups that is allowed to modify socket options BPF_PROG_CGROUP_DEVICE : determine if a device operation should be permitted or not 当初のBPF(いわゆるcBPF)で実現されていたのは、ネットワーク処理の部分だけでしたが、eBPFではさまざまなフックポイントが追加されていることがわかります。 [出典] A thorough introduction to eBPF (上記Program Typeの説明は、本記事より引用) BPF: A Tour of Program Types @IT – Berkeley Packet Filter(BPF)入門(4) – LinuxのBPFで何ができるのか? BPFの「プログラムタイプ」とは eBPF - 入門概要 編 「おいしくてつよくなる」eBPFのはじめかた ユーザーランドアプリケーションとのやりとり eBPFプログラムは、Program Typesでフックしたイベントによって動作し、処理結果をユーザー空間で実行するアプリケーションと受け渡しすることができます。 具体的には、 Maps という機構が提供されており、ハッシュテーブルや配列、LRU、リングバッファなどの各種データ構造が使えます。 eBPFの主な用途 eBPFが使われているプロダクトは、 こちら のサイトで紹介されていますが、いくつかを簡単に紹介します。 ネットワーク制御 Cilium コンテナ間通信に対してパケット処理に可観測性・セキュリティ・高度な通信制御を付与するソフトウェア。主にKubernetesでの利用を想定 Katran Facebookが開発する、XDPを活用した高性能L4ロードバランサ Pixie Kubernetesアプリケーションから自動でテレメトリデータを取得してObservability(可観測性)を向上させるツール Cloudflare Magic Firewall プロダクトではないが、Cloudflare のDDoS対策機能として利用されている セキュリティ Falco アプリケーションの異常な動作を検出するアクティビティモニタ Tetragon 透過的にセキュリティや可観測性を適用するためのツール トレーシング bpftrace BPFをラップするDSL(Domain Specific Language)を提供する、汎用的なトレーシングツール このように、eBPFが提供するさまざまなフックポイントを活用したプロダクトが作られています。 eBPFが注目される背景 近年eBPFが注目されている背景には、Kubernetes(以下、k8s)の普及によるアプリケーションのコンテナ化やマイクロサービス化が進んでいることがあると思われます。 k8sでコンテナ間の通信を実現するには、Linuxに古くからある netfilter/iptables が使われています。 netfilter は、BPFと同様にカーネル内のネットワークスタックのさまざまな場所に、コールバック関数を追加できるしくみで、iptablesはそのフロントエンドツールです。 iptablesはnetfilterを使ってパケットのフィルタリングや転送を実現し、Linuxによるファイアウォールやネットワークルータの実現には、古くからiptablesが利用されていました。 さきほど述べたように、k8sにおけるコンテナ間通信にも、netfilter で実現されています。 出典:Netfilter - Wikipedia しかし、k8sクラスタで管理されるコンテナが増えるにしたがって、iptablesの問題点が浮き上がってきました。 iptablesは、ルール順番に処理してマッチしたパケットを処理する仕組みなので、ルールの量(つまり通信するコンテナの数)に比例して遅くなってしまいます。 たとえば、eBPFの機能の1つであるXDP(Express Data Path)を活用することで、NICに近いところでパケットを受け取り、独自に処理して高速なコンテナ間通信を実現しようとしているのが、eBPFを活用したプロダクトでも特に注目を集めている Cilium です。 (Ciliumについては、次回前多さんが紹介予定です) また、コンテナ環境を活用してシステムのマイクロサービス化が進むと、安定運用のために通信や各種メトリクス、パフォーマンスを把握する必要性が高まり、可観測性やセキュリティもより重要視されます。 このようなニーズにも、冒頭で紹介したeBPFの多彩なフックポイントと、カーネル空間内で高速処理できるという特性がマッチしていると思われます。 [出典] iptablesの後に来るものは何か?: nftables - 赤帽エンジニアブログ eBPFの仕組み アーキテクチャと処理フロー eBPFの概要や背景がわかったところで、その仕組みをもう少し追ってみましょう。 eBPF - 入門概要 編 – eBPFアーキテクチャ概要 で説明されている図が分かりやすかったので、これを参考にアーキテクチャの説明図を書きました。 この図に使って、eBPFの大まかな処理フローを説明します。 eBPFを扱うユーザープログラムは、eBPFソースコードをコンパイルしてバイトコードに変換する システムコールを使用してeBPFバイトコードをカーネルへロードする eBPF Verifierがバイトコードを検証。問題なければコンパイラによって機械語に変換する ユーザープログラムはeBPFプログラムを目的のイベントにアタッチする イベントが発生すると、ExecutorがeBPFプログラムを実行 処理結果を map や ring buffer へ格納 ユーザープログラムが結果を参照 カーネルモジュールとeBPFの違い もともと、Linuxには「 カーネルモジュール 」という仕組みがあり、カーネルモジュールを作成することでカーネル空間で動く処理を追加で組み込めるようになっており、これを使ってカーネルの機能を拡張できるようになってる。 カーネルモジュールの代表例としては、ファイルシステムや、デバイスドライバなどハードウェアを制御するプログラムが挙げられます。 一方で、カーネルモジュールは自由度が高すぎるため、作りが悪いとカーネルをクラッシュさせる危険性があります。また、 modprobe コマンドで明示的にロード/アンロードする必要があります。もともとデバイスドライバやファイルシステムなどの使い方が想定されていたので、頻繁にロード/アンロードすることは想定されておらず、特定のアプリケーション実行時だけカーネルに処理を組み込むといった使い方がしにくいです。 eBPFでは、ユーザー空間で動作するアプリケーションからシステムコールを呼び出すことで、カーネル空間で動作するeBPFプログラムを動的に組み込むことができます。 また、eBPFでは実行されるプログラムに一定の制約をつけ、Verifierによる検証をパスしなければカーネルに組み込めないような仕組みとして安全性を高めています。 Linuxのリポジトリに、検証器ののコード( verifier.c )があります。こちらのコメントを読むと、たとえば最初の段階では、次のようなチェックが行われるようです。 ( 参考1 , 参考2 ) 命令数が一定数以下であること(大きなプログラムはダメ) ループがないこと 到達不可能な命令がないこと 境界の外に出る不正なジャンプがないこと 特に命令数やループなどの制約は、カーネル空間で実行される処理であることから、厳しめになっていることがわかります。 このような仕組みによって、カーネル空間での処理を手軽かつ安全にアドオンできるようになったことがeBPFとカーネルモジュールの違いです。eBPFによってカーネル空間での処理を手軽に作成できるようになったため、従来のようにカーネル空間とユーザー空間の切替を頻繁に行う必要もなくなり、パフォーマンス向上にも寄与できるようになりました。 [参考] Linux カーネルハックを始める前に知っておきたいこと - かーねるさんとか 【図解】初心者向けユーザー空間とカーネル空間,システムコール,MMU/メモリ保護,の仕組み | SEの道標 eBPFプログラムの作り方 eBPFプログラムの実際の作り方を見てみましょう。 eBPFは最終的に専用のバイトコードにしてカーネルに渡す必要があります。いきなりバイトコードを書くわけにもいかない(書くこともできますが)ので、現実的にはなんらかのプログラミング言語を使うことになります。 eBPFのプログラムはC言語で作成します。 BCC(BPF Compiler Collection) というツールチェーンが公開されているので、これを使用してコンパイルします。 一方、eBPFプログラムをカーネルに組み込み、eBPFの処理結果を受け取ってユーザーに機能を提供するアプリケーション)を、eBPFのフロントエンドと呼びます。 eBPFのフロントエンドは、BCCの公式サポート範囲では、C++、Python、Luaで記述することができます。(世の中のサンプルを見渡すと、簡単なツールはPythonで書かれているものが多いようです) また、BCC本体のサポート範囲ではありませんが、RustやGoといった新しい言語でもフロントエンドが作成できるようです。 また、さきほど紹介したbpftraceは、独自の言語(DSL:Domain Specific Language)を提供しており、トレース用途中心ではありますが、C言語を知らなくてもeBPFとして動作する処理を作成することができます。 bpftraceのGitHubリポジトリ には、多数のサンプルが公開されています。 たとえば、次のようなワンライナーでプロセスが開くファイルを表示させることができます。 # Files opened by process bpftrace -e 'tracepoint:syscalls:sys_enter_open { printf("%s %s\n", comm, str(args->filename)); }' このように、コマンドラインからアドホックにeBPF処理を実行できるのがbpftraceの魅力です。 eBPFプログラムを作ってみる ここからは、実際にeBPFのプログラムを作成して理解を深めます。 環境の準備 今回は、 GCP の Computing Engine に CentOS8 の仮想マシンを作り、そこで試しました。 先ほど紹介したbpftraceが標準のパッケージマネージャで提供されているので、コマンド一発でインストールできます。 sudo dnf -y install bpftrace bpftraceをインストールすると、bccも同時にインストールされて使えるようになります。 Hello world BCCの公式リポジトリ にあるサンプルから、もっとも簡単なものを選んで実行してみます。 hello_world.py を少しだけ分かりやすく書き直したものが、以下のコードです。 このサンプルは、Pythonをフロントエンドとして書かれています。 from bcc import BPF bpf_src=""" int kprobe__sys_clone(void *ctx) { bpf_trace_printk("Hello, World!\\n"); return 0; } """ b = BPF(text=bpf_src) b.trace_print() 上のプログラムの最終行で BPF 関数に渡しているテキストのC言語部分が、eBPFのコードです。 抜き出したものが以下のコードです。 int kprobe__sys_clone(void *ctx) { bpf_trace_printk("Hello, World!\\n"); return 0; } イベント名__関数名 という命名ルールで、カーネル内の関数にフックできます。(ここでは、 kprobe__sys_clone ) kprobe は、カーネル内の関数呼び出し前のイベントであることを示します。 sys_clone は、Linuxでプロセスがforkされるときに呼び出されるシステムコールです。(この関数はカーネルのバージョンやCPUアーキテクチャによって異なることがあるので注意 (※)) eBPFプログラムの3行目では、 bpf_trace_printk という関数を呼び出しています。これは主にeBPFのデバッグ用途でカーネル空間からユーザー空間へ文字列を渡すためのものです。 Pythonで書いたフロントエンドコードの11行目で BPF 関数を呼び出すことにより、eBPFプログラムをコンパイルされ、カーネルへ組み込まれます。 そして、12行目の trace_print 関数で、 bpf_trace_printk が出力した文字列を受け取って表示しています。 (このあたりの仕組みは eBPF の紹介 - Qiita で詳しく解説されています) このサンプルを実行し、別のターミナルでコマンドを実行すると、そのたびに「Hello, World!」が表示されます。 実用性はありませんが、eBPFプログラムの書き方と組み込み方、結果の受け取りかたがわかりました。 (※) 「この関数はカーネルのバージョンやCPUアーキテクチャによって異なることがあるので注意」・・・BPFのプログラムは、その特性上CPUアーキテクチャやカーネルのバージョンに依存してしまいます。このためBPFを実際に実行するマシン上でコンパイルするのが原則のようです。このあたりのポータビリティを高めるしくみとして、 BPF CO-RE(Compile Once - Run Everywhere) が考案されており、ある環境でコンパイルしたBPFコードを他の環境でも実行できるようにしています。 もう少し複雑なサンプル eBPF側からフロントエンド側に情報を渡す、もう少し実用的なサンプルを動かしてみます。 第690回 BCCでeBPFのコードを書いてみる | gihyo.jp で紹介されているコードを写経しました。 eBPF部分には、少しコメントを追加しました。 #!/usr/bin/python3 from bcc import BPF bpf_text=""" #include <linux/sched.h> /* Information passed from eBPF to frontend */ struct data_t { u32 pid; u32 ppid; char comm[TASK_COMM_LEN]; char fname[128]; }; /* Make ring buffer named `events` */ BPF_PERF_OUTPUT(events); int syscall__execve(struct pt_regs *ctx, const char __user *filename) { struct data_t data = {}; struct task_struct *task; /* Get PID */ data.pid = bpf_get_current_pid_tgid() >> 32; /* Get Parent PID */ task = (struct task_struct *)bpf_get_current_task(); data.ppid = task->real_parent->tgid; /* Get current task program name */ bpf_get_current_comm(&data.comm, sizeof(data.comm)); /* Get execve argument from user space */ bpf_probe_read_user(data.fname, sizeof(data.fname), (void *)filename); /* Stores data in a ring buffer */ events.perf_submit(ctx, &data, sizeof(struct data_t)); return 0; } """ b = BPF(text=bpf_text) b.attach_kprobe(event=b.get_syscall_fnname("execve"), fn_name="syscall__execve") print("PID PPID COMM FNAME") def print_event(cpu, data, size): # Get eBPF data from `event` ring buffer event = b["events"].event(data) print("{:<8} {:<8} {:16} {}".format(event.pid, event.ppid, event.comm.decode(), event.fname.decode())) b["events"].open_perf_buffer(print_event) while True: try: b.perf_buffer_poll() except KeyboardInterrupt: exit() 今度は、eBPF部分が長くなっていますが、関数は syscall__execve の1つです。 execve は、指定したファイルを実行する時に使用するシステムコールです。システムコールの引数をeBPFから取得する場合は、 syscall イベントでアタッチします。 execve システムコールが実行されるときに、その引数をeBPFで取得して data_t 構造体に格納し、リングバッファ経由でユーザー空間で動くフロントエンドへ送ります。 フロントエンド側では、リングバッファからデータを取得して整形して出力しています。 実行例は以下のようになります。プログラムを実行して他のターミナルで ls や cat コマンドを実行すると、そのログが表示されますし、バックグラウンドで実行されているプログラムも検知できています。 その他のサンプル 最初に紹介した、BCCのリポジトリには豊富なサンプルが公開されており、サンプルに留まらず、実用性のあるツールも多数公開されています。多くは数百行程度なので、勉強がてら自分の用途に改造して使うこともできそうです。 ツールとサンプルの一覧はこちらです。 https://github.com/iovisor/bcc#contents 最後に、このなかから実用性がありそうなものを、いくつか選んで紹介します。 HTTPリクエストのダンプ https://github.com/iovisor/bcc/tree/master/examples/networking/http_filter TCP接続先の調査 tcpv4connect_example.txt tcpv4connect.py PID COMM SADDR DADDR DPORT 1479 telnet 127.0.0.1 127.0.0.1 23 1469 curl 10.201.219.236 54.245.105.25 80 1469 curl 10.201.219.236 54.67.101.145 80 tcplife 実行中に開始/終了されたTCPセッションを記録します。 tcplife_example.txt tcplife.py PID COMM LADDR LPORT RADDR RPORT TX_KB RX_KB MS 22597 recordProg 127.0.0.1 46644 127.0.0.1 28527 0 0 0.23 3277 redis-serv 127.0.0.1 28527 127.0.0.1 46644 0 0 0.28 22598 curl 100.66.3.172 61620 52.205.89.26 80 0 1 91.79 22604 curl 100.66.3.172 44400 52.204.43.121 80 0 1 121.38 22624 recordProg 127.0.0.1 46648 127.0.0.1 28527 0 0 0.22 3277 redis-serv 127.0.0.1 28527 127.0.0.1 46648 0 0 0.27 22647 recordProg 127.0.0.1 46650 127.0.0.1 28527 0 0 0.21 3277 redis-serv 127.0.0.1 28527 127.0.0.1 46650 0 0 0.26 [...] dirtop ディレクトリ単位でのread/writeの記録。 dirtop_example.txt dirtop.py # ./dirtop.py -d '/hdfs/uuid/*/yarn' Tracing... Output every 1 secs. Hit Ctrl-C to end 14:28:12 loadavg: 25.00 22.85 21.22 31/2921 66450 READS WRITES R_Kb W_Kb PATH 1030 2852 8 147341 /hdfs/uuid/c11da291-28de-4a77-873e-44bb452d238b/yarn 3308 2459 10980 24893 /hdfs/uuid/bf829d08-1455-45b8-81fa-05c3303e8c45/yarn 2227 7165 6484 11157 /hdfs/uuid/76dc0b77-e2fd-4476-818f-2b5c3c452396/yarn 1985 9576 6431 6616 /hdfs/uuid/99c178d5-a209-4af2-8467-7382c7f03c1b/yarn 1986 398 6474 6486 /hdfs/uuid/7d512fe7-b20d-464c-a75a-dbf8b687ee1c/yarn 764 3685 5 7069 /hdfs/uuid/250b21c8-1714-45fe-8c08-d45d0271c6bd/yarn [...] filetop ファイル単位でのread/writeしたプロセスのトレース filetop_example.txt filetop.py # ./filetop -C Tracing... Output every 1 secs. Hit Ctrl-C to end 08:00:23 loadavg: 0.91 0.33 0.23 3/286 26635 PID COMM READS WRITES R_Kb W_Kb T FILE 26628 ld 161 186 643 152 R built-in.o 26634 cc1 1 0 200 0 R autoconf.h 26618 cc1 1 0 200 0 R autoconf.h 26634 cc1 12 0 192 0 R tracepoint.h 26584 cc1 2 0 143 0 R mm.h 26634 cc1 2 0 143 0 R mm.h 26631 make 34 0 136 0 R auto.conf [...] oomkill OOM killerによるプロセスkillイベントのトレース。 oomkill_example.txt oomkill.py # ./oomkill Tracing oom_kill_process()... Ctrl-C to end. 21:03:39 Triggered by PID 3297 ("ntpd"), OOM kill of PID 22516 ("perl"), 3850642 pages, loadavg: 0.99 0.39 0.30 3/282 22724 21:03:48 Triggered by PID 22517 ("perl"), OOM kill of PID 22517 ("perl"), 3850642 pages, loadavg: 0.99 0.41 0.30 2/282 22932 まとめ eBPF登場の背景と位置づけを確認し、大まかな仕組みとサンプルの実行を通してeBPFプログラムの作り方を確認しました。 発展中の技術であるため、体系的な情報はまだ少ない印象ですが、サンプルが豊富なので試行錯誤しながら習得していくこともできそうです。 一方で使いこなすにはC言語やカーネルのシステムコールなど、比較的低レイヤーの知識が必要になるため、習得には一定の時間がかかりそうです。(おそらく、bpftraceでこのハードルが下がりそうです) eBPFはなにに使えるか 全体としては、採用プロダクト例で挙げたように、特にネットワーク周りの基盤技術や、可観測性(Observability)の強化が主な用途になると思いました。 一般のエンジニアがeBPFのコードをバリバリ書けるようになる必要は、あまり無さそうですが、eBPFの仕組みを理解しておくと、これから登場するeBPFを活用したプロダクトを使いこなす時に理解が進むと思います。 また、bpftraceのようなツールを使いこなせるようになると、障害調査に使える武器として大いに役立ちそうです。 個人的には、システム運用の場面で監査やセキュリティチェックなど、プロダクト独自のニーズが生じたときに、eBPFを活用できるのではないかと期待しています。 ここまでお読みいただきありがとうございました。 CADDiでは、現在積極的に採用を行っています。 まずはカジュアルにお話を聞いてみたい!という方は、ぜひ こちら より面談をお申し込みください。 また、Tech Blogや勉強会等のイベントについてはSNSで随時発信しておりますので、 Twitter のフォローや、 connpass のメンバー登録をぜひよろしくお願いします。 参考サイト 記事中でも断片的に示しましたが、eBPFについて体系的に紹介されていて参考になったサイトを紹介します。 @IT連載 > Berkeley Packet Filter(BPF)入門 全10回の連載でかなりボリュームがありますが、Web上の日本語での説明が一番よくまとまっています。著者はbpftraceのコミッタでもあります。 (1) : パケットフィルターでトレーシング? Linuxで活用が進む「Berkeley Packet Filter(BPF)」とは何か (2) : BPFのアーキテクチャ、命令セット、cBPFとeBPFの違い (3) : BPFプログラムの作成方法、BPFの検証器、JITコンパイル機能 (4) : LinuxのBPFで何ができるのか? BPFの「プログラムタイプ」とは (5) : BPFによるパケットトレース――C言語によるBPFプログラムの作り方、使い方 (6) : BCC(BPF Compiler Collection)によるBPFプログラムの作成 (7) : Linux 5.5におけるBPF(Berkeley Packet Filter)の新機能 (8) : BPFを使ったLinuxにおけるトレーシングの基礎知識 (9) : BPFによるトレーシングが簡単にできる「bpftrace」の使い方 (10) : 単なるデバッグ情報だけではない「BPF Type Format」(BTF)の使い道 「eBPF – 入門」 必要な要素がコンパクトにまとまっています。私もこの記事を取っ掛かりとしました。 eBPF – 入門概要 編 eBPF – 仮想マシン 編 eBPF – BCCチュートリアル 編 eBPF – bpftraceチュートリアル 編 eBPF – XDP概要 編 The post eBPFに3日で入門した話 appeared first on CADDi Tech Blog .
アバター
はじめに 前編 では、 setter メソッドによる値の設定や build メソッドによる構造体の生成などの基本的な機能を持った手続き的マクロを実装しました。後編では以下の機能を実装していきます。 Optional な値を構造体のフィールドとして持てるようにする 以下の 2 つの方法で Vec 型のフィールドを更新できるようにする ベクタを与えて一括で更新する ベクタの要素を与えて 1 つずつフィールドに要素を追加する コンパイルエラーが発生した際にわかりやすいメッセージを表示する builder マクロを作る(続き) 06-optional-field 目標 構造体のフィールドとして Optional な値を持てるようにする Optional なフィールドには値が入っていなくても build メソッドで構造体を生成できる Optional でないフィールドは値が入っていないと build メソッドで構造体を生成できない Optional なフィールドは Some でラップせずに中身の値をそのまま使って初期化できる 最後の項目についてですが、たとえば Command 構造体が以下のようになっている場合、 pub struct Command { executable: String, args: Vec<String>, env: Vec<String>, current_dir: Option<String>, } current_dir は以下のように String を渡すだけでよいということです。 let command = Command::builder() .executable("cargo".to_owned()) .args(vec!["build".to_owned(), "--release".to_owned()]) .env(vec![]) .current_dir("..".to_owned()) .build() .unwrap(); 実装方針 目標とする機能を実現するために実装する必要があるのは以下の項目です。まずはこれらの機能を実装していきましょう。 ガード節で Optional でない型のみエラーを出すようにする Option でラップされた型はアンラップして CommandBuilder 構造体のフィールドで保持する 今は元の型が何であっても Option でラップするようになっています。そのため、Optional な型は Option<Option<_>> のようになります。このままだと扱いづらいので、Optional な型はいったんアンラップして、すべてのフィールドの型が Option<_> になるようにします Optional な型の setter メソッドはラップされた中身の型を引数として受け付けるようにする 実装 実装方針で説明した機能を実装していきます。 ガード節で Optional でない型のみエラーを出すようにする ガード節を生成する部分の実装は以下のようになっています。今はすべてのフィールドに対して None であるかのチェックを生成しています。これを Optional でない型のみガード節を生成するように変更します。 let checks = idents.iter().map(|ident| { let err = format!("Required field '{}' is missing", ident.to_string()); quote! { if self.#ident.is_none() { return Err(#err.into()) } } }); Optional でないフィールドのみガード節を生成するためには、各フィールドの型を見て Optional でないフィールドのみをフィルタすれば良さそうです。 types に各フィールドの型が格納されているので、以下のように filter を用いて Optional でないフィールドの識別子についてだけガード節を生成します。 is_option は与えられた型が Optional であるかどうかを判定する何らかの関数です。のちほど実装します。 let checks = idents .iter() .zip(&types) .filter(|(_, ty)| !is_option(ty)) .map(|(ident, _)| { let err = format!("Required field '{}' is missing", ident.to_string()); quote! { if self.#ident.is_none() { return Err(#err.into()) } } }); Option でラップされた型はアンラップして CommandBuilder 構造体のフィールドで保持する 今の Builder 構造体の定義は以下のようになっています。すべてのフィールドを Option でラップしています。Optional なフィールドについては、あらかじめ Option でラップされた型を取り出しておけば、あとの処理は今までと同じ内容になります。 #vis struct #builder_name { #(#idents: Option<#types>),* } 実際に実装していきます。 Option の中身の型を取り出すなどの処理が追加されるので、生成された Builder 構造体のフィールドを builder_fields にいったん保持してあとで展開しましょう。 builder_fields の実装は以下のようになります。 unwrap_option 関数で Option にラップされている型を取り出している以外は今までと同じです。 unwrap_option は is_option と同じくのちほど実装します。 let builder_fields = idents.iter().zip(&types).map(|(ident, ty)| { let t = unwrap_option(ty).unwrap_or(ty); quote! { #ident: Option<#t> } }); builder_fields は Builder 構造体の定義を生成する部分で展開します。 #vis struct #builder_name { #(#builder_fields),* } Builder 構造体の定義が変わったため build 関数も変える必要があります。今は以下のように目的の構造体を生成する際にすべてのフィールドを unwrap していますが、Optional なフィールドは unwrap する必要がないのでそのまま返すようにしましょう。 pub fn build(&mut self) -> Result<#ident, Box<dyn std::error::Error>> { #(#checks)* Ok(#ident { #(#idents: self.#idents.clone().unwrap()),* }) } Optional なフィールドかどうかを判定する処理が追加されるため、 builder_fields と同様に別の変数に格納してのちほど展開します。具体的な実装は以下の通りです。 is_option で Optional なフィールドかどうかを判定して、Optional であれば値をそのまま使用し、Optional でなければ unwrap して得られた値を使用します。 let struct_fields = idents.iter().zip(&types).map(|(ident, ty)| { if is_option(ty) { quote! { #ident: self.#ident.clone() } } else { quote! { #ident: self.#ident.clone().unwrap() } } }); struct_fields は build 関数の中で展開します。 pub fn build(&mut self) -> Result<#ident, Box<dyn std::error::Error>> { #(#checks)* Ok(#ident { #(#struct_fields),* }) } Optional な型の setter メソッドはラップされた中身の型を引数として受け付けるようにする 今の setter の実装は以下のようになっています。Optional なフィールドかどうかは関係なく、すべてのフィールドについてそのフィールドの型をそのまま受け付けるようになっています。 impl #builder_name { #(pub fn #idents(&mut self, #idents: #types) -> &mut Self { self.#idents = Some(#idents); self })* ... つまり Optional なフィールドについては以下のような setter が生成されます。これでは Builder 構造体のフィールド定義と矛盾します。そのため、Optional なフィールドの setter 関数は引数として Option の中身の型を受け取るようにします。 pub fn current_dir(&mut self, current_dir: Option<String>) -> &mut Self { self.current_dir = Some(current_dir); self } 具体的には以下のような setter を作って展開するように書き換えます。Builder 構造体のフィールドと同様、 unwrap_option 関数を使って Option でラップされた中身の型を取り出します。 let setters = idents.iter().zip(&types).map(|(ident, ty)| { let t = unwrap_option(ty).unwrap_or(ty); quote! { pub fn #ident(&mut self, #ident: #t) -> &mut Self { self.#ident = Some(#ident); self } } }); ... impl #builder_name { #(#setters)* ... is_option と unwrap_option の実装 is_option と unwrap_option を実装していきます。まずは is_option 関数から実装していきましょう。 is_option 関数 is_option は Type 型を受け取ってそれが Option かどうかを判定する関数なのでシグネチャは以下のようにすれば良さそうです。 fn is_option(ty: &Type) -> bool ty が Option かどうか判定するのに使えそうな Type のメソッドがあるか確認してみましょう。 syn クレートのドキュメント を確認したところ Type は以下の enum のようです。 pub enum Type { Array(TypeArray), BareFn(TypeBareFn), Group(TypeGroup), ImplTrait(TypeImplTrait), Infer(TypeInfer), Macro(TypeMacro), Never(TypeNever), Paren(TypeParen), Path(TypePath), Ptr(TypePtr), Reference(TypeReference), Slice(TypeSlice), TraitObject(TypeTraitObject), Tuple(TypeTuple), Verbatim(TokenStream), // some variants omitted } Option がどのバリアントに分類されるかはまだわかりませんが、とりあえずパターンマッチで処理すれば良さそうです。 fn is_option(ty: &Type) -> bool { match ty { todo!() } } パターンマッチで分類できそうだというところまで方針を立てられましたが、 Option はどのバリアントに分類されるのでしょうか。ドキュメントを一見してもそれらしいものは見当たりませんが、 Option は Type::Path(syn::TypePath) に分類されます。 TypePath は std::iter::Iter のような Path をパースして得られる構造体です。 Type::Path にマッチしないバリアントについてはこの時点で false を返してしまって問題ないでしょう。 fn is_option(ty: &Type) -> bool { match ty { Type::Path(path) => todo!(), _ => false } } TypePath には Option 以外にも上述の std::iter::Iter のようなものも含まれます。どのように Option かそれ以外かを判定すれば良いでしょうか。 先ほども説明したように、 TypePath は std::iter::Iter のようにコロン 2 つで分割されたセグメントの集合でした。つまり、セグメントの集合の最後の要素が Option であるかどうかを判断すれば良さそうです。 では、どのようにしてセグメントの集合の最後の要素を取得すれば良いのでしょうか。 syn::TypePath は以下のような構造体で、 path に Path の情報を保持しています。 syn::Path は以下のように segments にセグメントの集合を保持しており、 segments.last() で最後の要素にアクセスできます。 pub struct TypePath { pub qself: Option<QSelf>, pub path: Path } pub struct Path { pub leading_colon: Option<Colon2>, pub segments: Punctuated<PathSegment, Colon2>, } 上記を踏まえると、現時点での実装は以下のようになります。 fn is_option(ty: &Type) -> bool { match ty { Type::Path(path) => path.path.segments.last(), _ => false } } 最後に、セグメントの最後の要素の識別子が Option と一致するか比較する必要があります。 segments.last() の戻り値は PathSegment 型 であり、 ident フィールドから識別子にアクセスできます。この識別子が Option かどうかを比較すれば良さそうです。 最終的な is_option 関数の実装は以下のようになります。 fn is_option(ty: &Type) -> bool { match ty { Type::Path(path) => match path.path.segments.last().unwrap() { Some(seg) => seg.ident == "Option", None => false }, _ => false } } unwrap_option 関数 次は unwrap_option 関数を実装してきます。 unwrap_option 関数は与えられた型が Option であれば Some(型) を返し、そうでなければ None を返す関数なので、シグネチャは以下のようにすれば良いでしょう。 fn unwrap_option(ty: &Type) -> Option<&Type> 引数が Option でない場合は None を返します。引数が Option かどうかは先ほど実装した is_option 関数が使えます。 fn unwrap_option(ty: &Type) -> Option<&Type> { if !is_option(ty) { return None; } todo!() } 次に Option にラップされた中身の型を取り出す処理を実装します。中身の型はどうやって取り出せば良いでしょうか。 is_option の実装ででてきた PathSegment のフィールドには ident 以外にもう 1 つ arguments というのがありました。これが関係ありそうなので、 PathArguments の定義を見て見ましょう。 pub enum PathArguments { None, AngleBracketed(AngleBracketedGenericArguments), Parenthesized(ParenthesizedGenericArguments), } PathArguments は enum のようです。バリアント名を眺めてみると AngleBracketed というものがあります。これが関係ありそうです。実際、 ドキュメント の AngleBracketed の項目には以下のように記載されており、このバリアントがジェネリック引数の情報を持っていることがわかります。 AngleBracketed(AngleBracketedGenericArguments) The <'a, T> in std::slice::iter<'a, T> . syn::AngleBracketedGenericArgument 型の定義を見てみましょう。 pub struct AngleBracketedGenericArguments { pub colon2_token: Option<Colon2>, pub lt_token: Lt, pub args: Punctuated<GenericArgument, Comma>, pub gt_token: Gt, } フィールド名を眺めてみるとジェネリック引数の型は args に入っていそうです。 syn::Punctuated なので複数の要素がありそうですが、 Option は引数を 1 つしか取らないので first() で取得すれば良いでしょう。 fn unwrap_option(ty: &Type) -> Option<&Type> { if !is_option(ty) { return None; } match ty { Type::Path(path) => path.path.segments.last().map(|seg| { match seg.arguments { PathArguments::AngleBracketed(ref args) => args.args.first(), _ => None } }), _ => None } } args.first() の戻り値は Option<GenericArgument> です。 GenericArgument は以下のような enum で、型が含まれる場合は GenericArgument::Type バリアントが使用されるので、 Type バリアントにマッチさせれば良いでしょう。 pub enum GenericArgument { Lifetime(Lifetime), Type(Type), Binding(Binding), Constraint(Constraint), Const(Expr), } 上記を踏まえると、 unwrap_option の最終的な実装は以下のようになります。 fn unwrap_option(ty: &Type) -> Option<&Type> { if !is_option(ty) { return None; } match ty { Type::Path(path) => path.path.segments.last().map(|seg| { match seg.arguments { PathArguments::AngleBracketed(ref args) => { args.args.first().and_then(|arg| match arg { &GenericArgument::Type(ref ty) => Some(ty), _ => None, }) } _ => None } }), _ => None } } Path の最後の要素を持ってくる処理はまとめられるので別の関数として外に切り出します。これを用いて is_option と unwrap_option を書き直すと、最終的には以下のようになります。 fn get_last_path_segment(ty: &Type) -> Option<&PathSegment> { match ty { Type::Path(path) => path.path.segments.last(), _ => None, } } fn is_option(ty: &Type) -> bool { match get_last_path_segment(ty) { Some(seg) => seg.ident == "Option", _ => false, } } fn unwrap_option(ty: &Type) -> Option<&Type> { if !is_option(ty) { return None; } match get_last_path_segment(ty) { Some(seg) => match seg.arguments { PathArguments::AngleBracketed(ref args) => { args.args.first().and_then(|arg| match arg { &GenericArgument::Type(ref ty) => Some(ty), _ => None, }) } _ => None, }, None => None, } } 最終的な実装は以下のようになります。 use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{ parse_macro_input, Data, DeriveInput, Fields, GenericArgument, Ident, PathArguments, PathSegment, Type, }; #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let ident = input.ident; let vis = input.vis; let builder_name = format_ident!("{}Builder", ident); let (idents, types): (Vec<Ident>, Vec<Type>) = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields .named .into_iter() .map(|field| { let ident = field.ident; let ty = field.ty; (ident.unwrap(), ty) }) .unzip(), _ => panic!("no unnamed fields are allowed"), }, _ => panic!("expects struct"), }; let builder_fields = idents.iter().zip(&types).map(|(ident, ty)| { let t = unwrap_option(ty).unwrap_or(ty); quote! { #ident: Option<#t> } }); let checks = idents .iter() .zip(&types) .filter(|(_, ty)| !is_option(ty)) .map(|(ident, _)| { let err = format!("Required field '{}' is missing", ident.to_string()); quote! { if self.#ident.is_none() { return Err(#err.into()) } } }); let setters = idents.iter().zip(&types).map(|(ident, ty)| { let t = unwrap_option(ty).unwrap_or(ty); quote! { pub fn #ident(&mut self, #ident: #t) -> &mut Self { self.#ident = Some(#ident); self } } }); let struct_fields = idents.iter().zip(&types).map(|(ident, ty)| { if is_option(ty) { quote! { #ident: self.#ident.clone() } } else { quote! { #ident: self.#ident.clone().unwrap() } } }); let expand = quote! { #vis struct #builder_name { #(#builder_fields),* } impl #builder_name { #(#setters)* pub fn build(&mut self) -> Result<#ident, Box<dyn std::error::Error>> { #(#checks)* Ok(#ident { #(#struct_fields),* }) } } impl #ident { pub fn builder() -> #builder_name { #builder_name { #(#idents: None),* } } } }; proc_macro::TokenStream::from(expand) } fn is_option(ty: &Type) -> bool { match get_last_path_segment(ty) { Some(seg) => seg.ident == "Option", _ => false, } } fn unwrap_option(ty: &Type) -> Option<&Type> { if !is_option(ty) { return None; } match get_last_path_segment(ty) { Some(seg) => match seg.arguments { PathArguments::AngleBracketed(ref args) => { args.args.first().and_then(|arg| match arg { &GenericArgument::Type(ref ty) => Some(ty), _ => None, }) } _ => None, }, None => None, } } fn get_last_path_segment(ty: &Type) -> Option<&PathSegment> { match ty { Type::Path(path) => path.path.segments.last(), _ => None, } } リファクタリング derive 関数が肥大化してきたので内部の処理を関数として切り出しました。主な変更点は以下の通りです。 Builder 構造体の定義を生成する部分を関数化( build_builder_struct ) Builder 構造体の実装(setter 関数、 build 関数)を生成する部分を関数化( build_builder_impl ) builder 関数を生成する部分を関数化( build_struct_impl ) これらの関数はすべて戻り値として proc_macro2::TokenStream を返していますが、これは quote マクロが proc_macro2::TokenStream を返すためです。 これらの処理を関数化したのに伴い、もともと以下のようにフィールド名と型を最初に取得していたのを、 let (idents, types): (Vec<Ident>, Vec<Type>) = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields .named .into_iter() .map(|field| { let ident = field.ident; let ty = field.ty; (ident.unwrap(), ty) }) .unzip(), _ => panic!("no unnamed fields are allowed"), }, _ => panic!("expects struct"), }; 以下のように NamedFields を取得して各関数にわたすように変更しています。 let fields = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields, _ => panic!("no unnamed fields are allowed"), }, _ => panic!("this macro can be applied only to structaa"), }; リファクタリング後の実装は以下の通りです。 use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::{ parse_macro_input, Data, DeriveInput, Fields, FieldsNamed, GenericArgument, Ident, PathArguments, PathSegment, Type, Visibility, }; #[proc_macro_derive(Builder, attributes(builder))] pub fn derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input = parse_macro_input!(input as DeriveInput); let ident = input.ident; let vis = input.vis; let builder_name = format_ident!("{}Builder", ident); let fields = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields, _ => panic!("no unnamed fields are allowed"), }, _ => panic!("this macro can be applied only to structaa"), }; let builder_struct = build_builder_struct(&fields, &builder_name, &vis); let builder_impl = build_builder_impl(&fields, &builder_name, &ident); let struct_impl = build_struct_impl(&fields, &builder_name, &ident); let expand = quote! { #builder_struct #builder_impl #struct_impl }; proc_macro::TokenStream::from(expand) } fn build_builder_struct( fields: &FieldsNamed, builder_name: &Ident, visibility: &Visibility, ) -> TokenStream { let (idents, types): (Vec<&Ident>, Vec<&Type>) = fields .named .iter() .map(|field| { let ident = field.ident.as_ref(); let ty = unwrap_option(&field.ty).unwrap_or(&field.ty); (ident.unwrap(), ty) }) .unzip(); quote! { #visibility struct #builder_name { #(#idents: Option<#types>),* } } } fn build_builder_impl( fields: &FieldsNamed, builder_name: &Ident, struct_name: &Ident, ) -> TokenStream { let checks = fields .named .iter() .filter(|field| !is_option(&field.ty)) .map(|field| { let ident = field.ident.as_ref(); let err = format!("Required field '{}' is missing", ident.unwrap().to_string()); quote! { if self.#ident.is_none() { return Err(#err.into()); } } }); let setters = fields.named.iter().map(|field| { let ident = &field.ident; let ty = unwrap_option(&field.ty).unwrap_or(&field.ty); quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = Some(#ident); self } } }); let struct_fields = fields.named.iter().map(|field| { let ident = field.ident.as_ref(); if is_option(&field.ty) { quote! { #ident: self.#ident.clone() } } else { quote! { #ident: self.#ident.clone().unwrap() } } }); quote! { impl #builder_name { #(#setters)* pub fn build(&mut self) -> Result<#struct_name, Box<dyn std::error::Error>> { #(#checks)* Ok(#struct_name { #(#struct_fields),* }) } } } } fn build_struct_impl( fields: &FieldsNamed, builder_name: &Ident, struct_name: &Ident, ) -> TokenStream { let field_defaults = fields.named.iter().map(|field| { let ident = field.ident.as_ref(); quote! { #ident: None } }); quote! { impl #struct_name { pub fn builder() -> #builder_name { #builder_name { #(#field_defaults),* } } } } } fn is_option(ty: &Type) -> bool { match get_last_path_segment(ty) { Some(seg) => seg.ident == "Option", _ => false, } } fn unwrap_option(ty: &Type) -> Option<&Type> { if !is_option(ty) { return None; } match get_last_path_segment(ty) { Some(seg) => match seg.arguments { PathArguments::AngleBracketed(ref args) => { args.args.first().and_then(|arg| match arg { &GenericArgument::Type(ref ty) => Some(ty), _ => None, }) } _ => None, }, None => None, } } fn get_last_path_segment(ty: &Type) -> Option<&PathSegment> { match ty { Type::Path(path) => path.path.segments.last(), _ => None, } } 07-repeated-field 目標 以下の 2 つの方法でベクタを値としてもつフィールドを更新できるようにする ベクタを与えて一括で更新する ベクタの要素を 1 つずつ追加する 一括で更新するための関数名はフィールド名と同じにする ベクタの要素を 1 つずつ追加する関数の名前は以下のようにアトリビュートを用いて指定する 1 つずつ追加するための関数名として一括で更新するための関数名と同じ名前が指定された場合は 1 つずつ追加するための関数を優先する #[derive(Builder)] pub struct Command { executable: String, #[builder(each = "arg")] args: Vec<String>, #[builder(each = "env")] env: Vec<String>, current_dir: Option<String>, } 実装方針 目標とする機能を実現するために実装する必要があるのは以下の項目です。 フィールドに付与されたアトリビュートを取得する builder アトリビュートを付与できるようにする 要素を 1 つずつ追加する関数名は build アトリビュートの each キーに指定する アトリビュートのキー名( each )のバリデーションは次のステップで実装する Vec 型のフィールドの setter を一括更新用と要素追加用の 2 種類生成する また、要素を 1 つずつ追加できるようにするためには以下の機能の実装も必要です。 Builder 構造体のフィールドでは Vec 型の変数は Option でラップしない フィールドの型が Vec かどうか判定できるようにする Vec をアンラップして中身の型を取得できるようにする 実装 Builder 構造体のフィールドでは Vec 型の変数は Option でラップしない Builder 構造体の定義を生成しているのは build_builder_struct 関数です。今の実装では入力の型に関わらず Option でラップしています。これを Vec のみラップしないように変更すれば良さそうです。 fn build_builder_struct( fields: &FieldsNamed, builder_name: &Ident, visibility: &Visibility, ) -> TokenStream { let (idents, types): (Vec<&Ident>, Vec<&Type>) = fields .named .iter() .map(|field| { let ident = field.ident.as_ref(); let ty = unwrap_option(&field.ty).unwrap_or(&field.ty); (ident.unwrap(), ty) }) .unzip(); quote! { #visibility struct #builder_name { #(#idents: Option<#types>),* } } } is_vector 関数は is_option と似た関数で、与えられた型が Vec 型かどうかを判定する関数です。実装は後述します。 fn build_builder_struct( fields: &FieldsNamed, builder_name: &Ident, visibility: &Visibility, ) -> TokenStream { let struct_fields = fields .named .iter() .map(|field| { let ident = field.ident.as_ref(); let ty = unwrap_option(&field.ty).unwrap_or(&field.ty); (ident.unwrap(), ty) }) .map(|(ident, ty)| { if is_vector(&ty) { quote! { #ident: #ty } } else { quote! { #ident: Option<#ty> } } }); quote! { #visibility struct #builder_name { #(#struct_fields),* } } } Builder 構造体の定義が変わったので、Builder 構造体を返す builder 関数の実装を生成する build_struct_impl 関数も修正が必要です。 Vec 型のフィールドのみ Vec::new() を返すようにすれば良さそうです。 fn build_struct_impl( fields: &FieldsNamed, builder_name: &Ident, struct_name: &Ident, ) -> TokenStream { let field_defaults = fields.named.iter().map(|field| { let ident = field.ident.as_ref(); quote! { #ident: None } }); quote! { impl #struct_name { pub fn builder() -> #builder_name { #builder_name { #(#field_defaults),* } } } } } こちらも build_builder_struct 関数と同様、 is_vector 関数を使って条件分岐を記述しています。 fn build_struct_impl( fields: &FieldsNamed, builder_name: &Ident, struct_name: &Ident, ) -> TokenStream { let field_defaults = fields.named.iter().map(|field| { let ident = field.ident.as_ref(); let ty = &field.ty; if is_vector(&ty) { quote! { #ident: Vec::new() } } else { quote! { #ident: None } } }); quote! { impl #struct_name { pub fn builder() -> #builder_name { #builder_name { #(#field_defaults),* } } } } } また、 Vec 型のフィールドは要素を含まなくても問題ないので、 Vec 型のフィールドについてもガード節を生成しないように変更します。今は Option のみをフィルタしていますが、追加で Vec もフィルタするように変更します。 let checks = fields .named .iter() .filter(|field| !is_option(&field.ty)) .filter(|field| !is_vector(&field.ty)) .map(|field| { let ident = field.ident.as_ref(); let err = format!("Required field '{}' is missing", ident.unwrap().to_string()); quote! { if self.#ident.is_none() { return Err(#err.into()); } } }); is_vector と unwrap_vector の実装 ここからは is_vector と unwrap_vector を実装していきます。ここまでの実装では unwrap_vector はでてきませんが、今後使うのでここで実装しておきます。 Vec も Option と同様 Type::Path に分類されるので、以下の項目は 06 で実装した is_option や unwrap_option を流用できます。 fn is_vector(ty: &Type) -> bool { match get_last_path_segment(ty) { Some(seg) => seg.ident == "Vec", _ => false, } } fn unwrap_vector(ty: &Type) -> Option<&Type> { if !is_vector(ty) { return None; } match get_last_path_segment(ty) { Some(seg) => match seg.arguments { PathArguments::AngleBracketed(ref args) => { args.args.first().and_then(|arg| match arg { &GenericArgument::Type(ref ty) => Some(ty), _ => None, }) } _ => None, }, None => None, } } unwrap_vector のパターンマッチの部分は unwrap_option と同様の処理をしているので関数化できそうです。これを unwrap_generic_type という関数にくくり出すと以下のようになります。 fn unwrap_option(ty: &Type) -> Option<&Type> { if !is_option(ty) { return None; } unwrap_generic_type(ty) } fn unwrap_vector(ty: &Type) -> Option<&Type> { if !is_vector(ty) { return None; } unwrap_generic_type(ty) } fn unwrap_generic_type(ty: &Type) -> Option<&Type> { match get_last_path_segment(ty) { Some(seg) => match seg.arguments { PathArguments::AngleBracketed(ref args) => { args.args.first().and_then(|arg| match arg { &GenericArgument::Type(ref ty) => Some(ty), _ => None, }) } _ => None, }, None => None, } } フィールドに付与されたアトリビュートを取得する フィールドに付与されたアトリビュートを取得する処理を実装する前に、まずアトリビュートを付与できるようにする必要があります。 アトリビュートを付与できるようにするためには、以下のように derive 関数に attributes(builder) というアトリビュートを追加します( 参考 )。これでフィールドに #[builder(...)] のようなアトリビュートを付与できます。 #[proc_macro_derive(Builder, attributes(builder))] pub fn derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream { アトリビュートを付与できるようになったので、それを取得する処理を実装します。 アトリビュートを取得するにはどのデータを処理すれば良いでしょうか。まずは DeriveInput をみてみましょう。 pub struct DeriveInput { pub attrs: Vec<Attribute>, pub vis: Visibility, pub ident: Ident, pub generics: Generics, pub data: Data, } DeriveInput は attrs フィールドを持っていますが、以下の DeriveInput の attrs の説明にあるように、これは構造体自体に付与されたアトリビュートです。( 引用元 ) Attributes tagged on the whole struct or enum. 構造体のフィールドは data フィールドに格納されているので Data の定義をみてみましょう。 Data の構造を下っていくと最終的に構造体の各フィールドの情報を保持している Field 構造体が得られます。 Data の構造の詳細については 前編 を参考にしてください。この中にある attrs がフィールドに付与されたアトリビュートです。 pub struct Field { pub attrs: Vec<Attribute>, pub vis: Visibility, pub ident: Option<Ident>, pub colon_token: Option<Colon>, pub ty: Type, } 構造体のフィールドは derive 関数の最初の方で取得しているので、これを処理してアトリビュートを取得していきます。 attrs は Attribute のベクタになっていますが、今回は 1 つのアトリビュートしか使わないので first で先頭のアトリビュートだけ取得すれば良いでしょう。 let ident_each_name = field .attrs .first() .map(|attr| todo!()); Attribute の parse_meta 関数 でアトリビュートをパースした結果が得られます。 let ident_each_name = field .attrs .first() .map(|attr| attr.parse_meta()); parse_meta() 関数の戻り値は Result<Meta> です。 Meta 型は以下のような enum です。 pub enum Meta { Path(Path), List(MetaList), NameValue(MetaNameValue), } Meta の List バリアントの 説明 に List A meta list is like the derive(Copy) in #[derive(Copy)] . とあるように、今回取得したいのは Meta::List なのでパターンマッチで処理します。 let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(_)) => todo!(), _ => None, }); MetaList は以下のような構造体です。 pub struct MetaList { pub path: Path, pub paren_token: Paren, pub nested: Punctuated<NestedMeta, Comma>, } MetaList 型の path はアトリビュート名( #[builder(each="foo")] の builder の部分)を、 nested アトリビュートの値( #[builder(each="foo")] の each="foo" の部分)を保持しています。今必要なのはアトリビュートの値を保持する nested の部分です。 nested はアトリビュート値を複数保持していますが、今回は複数の値をもつことは想定していないので first で先頭を取得すれば良さそうです。 let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => list.nested.first(), _ => None, }); nested.first() は Option<NestedMeta> を返します。 NestedMeta は以下のような enum です。 pub enum NestedMeta { Meta(Meta), Lit(Lit), } NestedMeta のフィールドに関する 以下の記述 からわかるように、 Lit は Rust のリテラルを保持します。この段階では nested.first() から Some(each="foo") のような形式が返ってくることを期待しているので Lit ではなく Meta にマッチするようにします。 Meta(Meta) A structured meta item, like the Copy in #[derive(Copy)] which would be a nested Meta::Path. Lit(Lit) A Rust literal, like the “new_name” in #[rename(“new_name”)]. let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(_)) => todo!(), _ => None, }, _ => None, }); 先ほどはパターンマッチを用いて Meta::List を取得しましたが、上述のように今度は each="foo" のようなキーとバリューのペアが取得されることを期待しているので、 Meta::NameValue(MetaNameValue) をマッチして処理します。 let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(Meta::NameValue(MetaNameValue{}))) => todo!(), _ => None, }, _ => None, }); MetaNameValue は以下のような構造体です。 pub struct MetaNameValue { pub path: Path, pub eq_token: Eq, pub lit: Lit, } each = "foo" を例にとると、 MetaNameValue は path に each を、 lit に "foo" を格納します。今回欲しいのは "foo" の方なので lit だけを取得すれば良さそうです。 let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(Meta::NameValue(MetaNameValue{ path: _, eq_token: _, lit }))) => todo!(), _ => None, }, _ => None, }); Lit は以下のような enum です。 each = "foo" のような形式からもわかるように、文字列リテラル( Lit::Str )が得られることを期待しています。 pub enum Lit { Str(LitStr), ByteStr(LitByteStr), Byte(LitByte), Char(LitChar), Int(LitInt), Float(LitFloat), Bool(LitBool), Verbatim(Literal), } リテラルが表す値は value メソッドで取得できるので、 let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(Meta::NameValue(MetaNameValue{ path: _, eq_token: _, lit: Lit::Str(ref s) }))) => { Some(s.value()) }, _ => None, }, _ => None, }) .flatten(); 以上で各フィールドの要素追加用のメソッド名を取得できました。 Vec 型のフィールドの setter を一括更新用と要素追加用の 2 種類生成する 今まではフィールドの型によらず、単純に以下のように setter を生成していました。 quote! { pub fn #ident(&mut self, #ident: #t) -> &mut Self { self.#ident = Some(#ident); self } } まずアトリビュートが付与されているかどうかで分岐が発生します。アトリビュートが付与されていると要素追加用のメソッドが必要になります。 match ident_each_name { Some(name) => todo!(), None => todo!(), } まずは要素追加用のメソッドがいらない方を実装します。 Vec 型のフィールドは Option でラップされないので Vec 型かどうかで setter の実装が変わります。 match ident_each_name { Some(name) => todo!(), None => { if is_vector(&ty) { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = #ident; self } } } else { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = Some(#ident); self } } } }, } 次に要素追加用のメソッドが必要なパターンを実装します。こっちは以下の 2 つのパターンで処理が分岐します。 要素追加用のメソッド名がフィールド名と 同じ 要素追加用のメソッドのみ生成する 要素追加用のメソッド名がフィールド名と 異なる 要素追加用のメソッドと一括更新用のメソッドを両方生成する 実装は以下のようになります。実装のポイントは以下の通りです。 要素追加用の関数の引数の型として使用するために unwrap_vector で中身の型を取り出す 要素追加用の関数の名前( name )は String なので Ident::new で Ident を生成する Ident::new の第二引数には Span 構造体を指定する必要がある。 Span 構造体はマクロの展開先で識別子が誤って捕捉されないようにするために必要( 参考 ) match ident_each_name { Some(name) => { let ty_each = unwrap_vector(ty).unwrap(); let ident_each = Ident::new(name.as_str(), Span::call_site()); if ident.unwrap().to_string() == name { quote! { pub fn #ident_each(&mut self, #ident_each:#ty_each) -> &mut Self { self.#ident.push(#ident_each); self } } } else { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = #ident; self } pub fn #ident_each(&mut self, #ident_each: #ty_each) -> &mut Self { self.#ident.push(#ident_each); self } } } } None => { (略) }, } 最終的な実装は以下のようになります。 use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote}; use syn::{ parse_macro_input, Data, DeriveInput, Fields, FieldsNamed, GenericArgument, Ident, Lit, Meta, MetaList, MetaNameValue, NestedMeta, PathArguments, PathSegment, Type, Visibility, }; #[proc_macro_derive(Builder, attributes(builder))] pub fn derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input = parse_macro_input!(input as DeriveInput); let ident = input.ident; let vis = input.vis; let builder_name = format_ident!("{}Builder", ident); let fields = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields, _ => panic!("no unnamed fields are allowed"), }, _ => panic!("this macro can be applied only to struct"), }; let builder_struct = build_builder_struct(&fields, &builder_name, &vis); let builder_impl = build_builder_impl(&fields, &builder_name, &ident); let struct_impl = build_struct_impl(&fields, &builder_name, &ident); let expand = quote! { #builder_struct #builder_impl #struct_impl }; proc_macro::TokenStream::from(expand) } fn build_builder_struct( fields: &FieldsNamed, builder_name: &Ident, visibility: &Visibility, ) -> TokenStream { let struct_fields = fields .named .iter() .map(|field| { let ident = field.ident.as_ref(); let ty = unwrap_option(&field.ty).unwrap_or(&field.ty); (ident.unwrap(), ty) }) .map(|(ident, ty)| { if is_vector(&ty) { quote! { #ident: #ty } } else { quote! { #ident: Option<#ty> } } }); quote! { #visibility struct #builder_name { #(#struct_fields),* } } } fn build_builder_impl( fields: &FieldsNamed, builder_name: &Ident, struct_name: &Ident, ) -> TokenStream { let checks = fields .named .iter() .filter(|field| !is_option(&field.ty)) .filter(|field| !is_vector(&field.ty)) .map(|field| { let ident = field.ident.as_ref(); let err = format!("Required field '{}' is missing", ident.unwrap().to_string()); quote! { if self.#ident.is_none() { return Err(#err.into()); } } }); let setters = fields.named.iter().map(|field| { let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(Meta::NameValue(MetaNameValue { path: _, eq_token: _, lit: Lit::Str(ref str), }))) => Some(str.value()), _ => None, }, _ => None, }) .flatten(); let ident = field.ident.as_ref(); let ty = unwrap_option(&field.ty).unwrap_or(&field.ty); match ident_each_name { Some(name) => { let ty_each = unwrap_vector(ty).unwrap(); let ident_each = Ident::new(name.as_str(), Span::call_site()); if ident.unwrap().to_string() == name { quote! { pub fn #ident_each(&mut self, #ident_each:#ty_each) -> &mut Self { self.#ident.push(#ident_each); self } } } else { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = #ident; self } pub fn #ident_each(&mut self, #ident_each: #ty_each) -> &mut Self { self.#ident.push(#ident_each); self } } } } None => { if is_vector(&ty) { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = #ident; self } } } else { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = Some(#ident); self } } } } } }); let struct_fields = fields.named.iter().map(|field| { let ident = field.ident.as_ref(); if is_option(&field.ty) || is_vector(&field.ty) { quote! { #ident: self.#ident.clone() } } else { quote! { #ident: self.#ident.clone().unwrap() } } }); quote! { impl #builder_name { #(#setters)* pub fn build(&mut self) -> Result<#struct_name, Box<dyn std::error::Error>> { #(#checks)* Ok(#struct_name { #(#struct_fields),* }) } } } } fn build_struct_impl( fields: &FieldsNamed, builder_name: &Ident, struct_name: &Ident, ) -> TokenStream { let field_defaults = fields.named.iter().map(|field| { let ident = field.ident.as_ref(); let ty = &field.ty; if is_vector(&ty) { quote! { #ident: Vec::new() } } else { quote! { #ident: None } } }); quote! { impl #struct_name { pub fn builder() -> #builder_name { #builder_name { #(#field_defaults),* } } } } } fn is_option(ty: &Type) -> bool { match get_last_path_segment(ty) { Some(seg) => seg.ident == "Option", _ => false, } } fn is_vector(ty: &Type) -> bool { match get_last_path_segment(ty) { Some(seg) => seg.ident == "Vec", _ => false, } } fn unwrap_option(ty: &Type) -> Option<&Type> { if !is_option(ty) { return None; } unwrap_generic_type(ty) } fn unwrap_vector(ty: &Type) -> Option<&Type> { if !is_vector(ty) { return None; } unwrap_generic_type(ty) } fn unwrap_generic_type(ty: &Type) -> Option<&Type> { match get_last_path_segment(ty) { Some(seg) => match seg.arguments { PathArguments::AngleBracketed(ref args) => { args.args.first().and_then(|arg| match arg { &GenericArgument::Type(ref ty) => Some(ty), _ => None, }) } _ => None, }, None => None, } } fn get_last_path_segment(ty: &Type) -> Option<&PathSegment> { match ty { Type::Path(path) => path.path.segments.last(), _ => None, } } 08-unrecognized-attribute 目標 attribute に間違った識別子が与えられた際に適切なコンパイルエラーを表示する 実装方針 アトリビュートのキーとして each 以外のものが与えられた場合にエラーを表示する エラーを発生させたい箇所で syn::Error の to_compile_error メソッドで TokenStream を返すようにする 単純に panic! させるだけよりも詳細なエラーメッセージを表示させられる 実装 アトリビュートのキーが正しいか判定する必要があるので、アトリビュートを処理している以下の箇所を変更します。 let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(Meta::NameValue(MetaNameValue { path: _, eq_token: _, lit: Lit::Str(ref str), }))) => Some(str.value()), _ => None, }, _ => None, }) .flatten(); アトリビュートのキーは MetadataNameValue の path に格納されています。 path は Path 型なので 06-optional-field で処理したのと同様にして識別子を取得します。 Ident の to_string メソッドで識別子の名前を取得できるので、これが each と一致しなければエラーを返せば良さそうです。 let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(Meta::NameValue(MetaNameValue { ref path, eq_token: _, lit: Lit::Str(ref str), }))) => { if let Some(name) = path.segments.first() { if name.ident.to_string() != "each" { todo!() } } Some(str.value()) } _ => None, }, _ => None, }) .flatten(); syn::Error は syn::Error::new_spanned() を使って生成します。エラーメッセージ(”expected …”)はテストケースに記載されたメッセージをそのまま使います。以下のようにしたいところですが、このままでは型が合わないのでコンパイルできません。 let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(Meta::NameValue(MetaNameValue { ref path, eq_token: _, lit: Lit::Str(ref str), }))) => { if let Some(name) = path.segments.first() { if name.ident.to_string() != "each" { return Some(syn::Error::new_spanned( list, "expected `builder(each = \"...\")`", )); } } Some(str.value()) } _ => None, }, _ => None, }) .flatten(); そこで、以下のような enum を返すようにして、後でパターンマッチで処理しましょう。 enum LitOrError { Lit(String), Error(syn::Error), } LitOrError を使って先ほどの箇所を次のように書き換えます。 let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(Meta::NameValue(MetaNameValue { ref path, eq_token: _, lit: Lit::Str(ref str), }))) => { if let Some(name) = path.segments.first() { if name.ident.to_string() != "each" { return Some(LitOrError::Error(syn::Error::new_spanned( list, "expected `builder(each = \"...\")`", ))); } } Some(LitOrError::Lit(str.value())) } _ => None, }, _ => None, }) .flatten(); パターンマッチで処理していた部分も enum に合わせて変更します。 LitOrError::Error にマッチする場合はコンパイルエラーを生じさせる必要があるので、 to_compile_error().into() でエラーを返します。 match ident_each_name { Some(LitOrError::Lit(name)) => { (略) } Some(LitOrError::Error(err)) => err.to_compile_error().into(), None => { (略) } } 今まで panic! していた場所も syn::Error の to_compile_error メソッドを使って TokenStream を返すように変更しておきます。 let fields = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields, _ => { return syn::Error::new(ident.span(), "expects named fields") .to_compile_error() .into() } }, _ => { return syn::Error::new(ident.span(), "expects struct") .to_compile_error() .into() } }; 最終的な実装は以下のようになります。 use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote}; use syn::{ parse_macro_input, Data, DeriveInput, Fields, FieldsNamed, GenericArgument, Ident, Lit, Meta, MetaNameValue, NestedMeta, PathArguments, PathSegment, Type, Visibility, }; enum LitOrError { Lit(String), Error(syn::Error), } #[proc_macro_derive(Builder, attributes(builder))] pub fn derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input = parse_macro_input!(input as DeriveInput); let ident = input.ident; let vis = input.vis; let builder_name = format_ident!("{}Builder", ident); let fields = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields, _ => { return syn::Error::new(ident.span(), "expects named fields") .to_compile_error() .into() } }, _ => { return syn::Error::new(ident.span(), "expects struct") .to_compile_error() .into() } }; let builder_struct = build_builder_struct(&fields, &builder_name, &vis); let builder_impl = build_builder_impl(&fields, &builder_name, &ident); let struct_impl = build_struct_impl(&fields, &builder_name, &ident); let expand = quote! { #builder_struct #builder_impl #struct_impl }; proc_macro::TokenStream::from(expand) } fn build_builder_struct( fields: &FieldsNamed, builder_name: &Ident, visibility: &Visibility, ) -> TokenStream { let struct_fields = fields .named .iter() .map(|field| { let ident = field.ident.as_ref(); let ty = unwrap_option(&field.ty).unwrap_or(&field.ty); (ident.unwrap(), ty) }) .map(|(ident, ty)| { if is_vector(&ty) { quote! { #ident: #ty } } else { quote! { #ident: Option<#ty> } } }); quote! { #visibility struct #builder_name { #(#struct_fields),* } } } fn build_builder_impl( fields: &FieldsNamed, builder_name: &Ident, struct_name: &Ident, ) -> TokenStream { let checks = fields .named .iter() .filter(|field| !is_option(&field.ty)) .filter(|field| !is_vector(&field.ty)) .map(|field| { let ident = field.ident.as_ref(); let err = format!("Required field '{}' is missing", ident.unwrap().to_string()); quote! { if self.#ident.is_none() { return Err(#err.into()); } } }); let setters = fields.named.iter().map(|field| { let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(Meta::NameValue(MetaNameValue { ref path, eq_token: _, lit: Lit::Str(ref str), }))) => { if let Some(name) = path.segments.first() { if name.ident.to_string() != "each" { return Some(LitOrError::Error(syn::Error::new_spanned( list, "expected `builder(each = \"...\")`", ))); } } Some(LitOrError::Lit(str.value())) } _ => None, }, _ => None, }) .flatten(); let ident = field.ident.as_ref(); let ty = unwrap_option(&field.ty).unwrap_or(&field.ty); match ident_each_name { Some(LitOrError::Lit(name)) => { let ty_each = unwrap_vector(ty).unwrap(); let ident_each = Ident::new(name.as_str(), Span::call_site()); if ident.unwrap().to_string() == name { quote! { pub fn #ident_each(&mut self, #ident_each:#ty_each) -> &mut Self { self.#ident.push(#ident_each); self } } } else { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = #ident; self } pub fn #ident_each(&mut self, #ident_each: #ty_each) -> &mut Self { self.#ident.push(#ident_each); self } } } } Some(LitOrError::Error(err)) => err.to_compile_error().into(), None => { if is_vector(&ty) { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = #ident; self } } } else { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = Some(#ident); self } } } } } }); let struct_fields = fields.named.iter().map(|field| { let ident = field.ident.as_ref(); if is_option(&field.ty) || is_vector(&field.ty) { quote! { #ident: self.#ident.clone() } } else { quote! { #ident: self.#ident.clone().unwrap() } } }); quote! { impl #builder_name { #(#setters)* pub fn build(&mut self) -> Result<#struct_name, Box<dyn std::error::Error>> { #(#checks)* Ok(#struct_name { #(#struct_fields),* }) } } } } fn build_struct_impl( fields: &FieldsNamed, builder_name: &Ident, struct_name: &Ident, ) -> TokenStream { let field_defaults = fields.named.iter().map(|field| { let ident = field.ident.as_ref(); let ty = &field.ty; if is_vector(&ty) { quote! { #ident: Vec::new() } } else { quote! { #ident: None } } }); quote! { impl #struct_name { pub fn builder() -> #builder_name { #builder_name { #(#field_defaults),* } } } } } fn is_option(ty: &Type) -> bool { match get_last_path_segment(ty) { Some(seg) => seg.ident == "Option", _ => false, } } fn is_vector(ty: &Type) -> bool { match get_last_path_segment(ty) { Some(seg) => seg.ident == "Vec", _ => false, } } fn unwrap_option(ty: &Type) -> Option<&Type> { if !is_option(ty) { return None; } unwrap_generic_type(ty) } fn unwrap_vector(ty: &Type) -> Option<&Type> { if !is_vector(ty) { return None; } unwrap_generic_type(ty) } fn unwrap_generic_type(ty: &Type) -> Option<&Type> { match get_last_path_segment(ty) { Some(seg) => match seg.arguments { PathArguments::AngleBracketed(ref args) => { args.args.first().and_then(|arg| match arg { &GenericArgument::Type(ref ty) => Some(ty), _ => None, }) } _ => None, }, None => None, } } fn get_last_path_segment(ty: &Type) -> Option<&PathSegment> { match ty { Type::Path(path) => path.path.segments.last(), _ => None, } } 09-redefined-prelude-types 目標 std::prelude でインポートされる型( Option や Vector )などがユーザーによって再定義されても正しく使えるようにする 実装方針 Option や Vec などを名前空間を指定して使うようにすればよいです。テストコードでチェックされているのは以下の 5 つなので、今回はこれらの適切な名前空間を指定するように変更します。 変更前 変更後 Option std::option::Option Some std::option::Option::Some None std::option::Option::None Result std::result::Result Box std::boxed::Box 再定義の影響を受けるのはマクロが呼び出されて展開される部分のみなので、直すのは quote マクロの中にある部分だけで十分です。 実装 上述の 5 つの名前空間を正しく指定するだけなので、今回は最終的な結果のみを記載します。最終的な実装は以下のようになります。 use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote}; use syn::{ parse_macro_input, Data, DeriveInput, Fields, FieldsNamed, GenericArgument, Ident, Lit, Meta, MetaNameValue, NestedMeta, PathArguments, PathSegment, Type, Visibility, }; enum LitOrError { Lit(String), Error(syn::Error), } #[proc_macro_derive(Builder, attributes(builder))] pub fn derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input = parse_macro_input!(input as DeriveInput); let ident = input.ident; let vis = input.vis; let builder_name = format_ident!("{}Builder", ident); let fields = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields, _ => { return syn::Error::new(ident.span(), "expects named fields") .to_compile_error() .into() } }, _ => { return syn::Error::new(ident.span(), "expects struct") .to_compile_error() .into() } }; let builder_struct = build_builder_struct(&fields, &builder_name, &vis); let builder_impl = build_builder_impl(&fields, &builder_name, &ident); let struct_impl = build_struct_impl(&fields, &builder_name, &ident); let expand = quote! { #builder_struct #builder_impl #struct_impl }; proc_macro::TokenStream::from(expand) } fn build_builder_struct( fields: &FieldsNamed, builder_name: &Ident, visibility: &Visibility, ) -> TokenStream { let struct_fields = fields .named .iter() .map(|field| { let ident = field.ident.as_ref(); let ty = unwrap_option(&field.ty).unwrap_or(&field.ty); (ident.unwrap(), ty) }) .map(|(ident, ty)| { if is_vector(&ty) { quote! { #ident: #ty } } else { quote! { #ident: std::option::Option<#ty> } } }); quote! { #visibility struct #builder_name { #(#struct_fields),* } } } fn build_builder_impl( fields: &FieldsNamed, builder_name: &Ident, struct_name: &Ident, ) -> TokenStream { let checks = fields .named .iter() .filter(|field| !is_option(&field.ty)) .filter(|field| !is_vector(&field.ty)) .map(|field| { let ident = field.ident.as_ref(); let err = format!("Required field '{}' is missing", ident.unwrap().to_string()); quote! { if self.#ident.is_none() { return Err(#err.into()); } } }); let setters = fields.named.iter().map(|field| { let ident_each_name = field .attrs .first() .map(|attr| match attr.parse_meta() { Ok(Meta::List(list)) => match list.nested.first() { Some(NestedMeta::Meta(Meta::NameValue(MetaNameValue { ref path, eq_token: _, lit: Lit::Str(ref str), }))) => { if let Some(name) = path.segments.first() { if name.ident.to_string() != "each" { return Some(LitOrError::Error(syn::Error::new_spanned( list, "expected `builder(each = \"...\")`", ))); } } Some(LitOrError::Lit(str.value())) } _ => None, }, _ => None, }) .flatten(); let ident = field.ident.as_ref(); let ty = unwrap_option(&field.ty).unwrap_or(&field.ty); match ident_each_name { Some(LitOrError::Lit(name)) => { let ty_each = unwrap_vector(ty).unwrap(); let ident_each = Ident::new(name.as_str(), Span::call_site()); if ident.unwrap().to_string() == name { quote! { pub fn #ident_each(&mut self, #ident_each:#ty_each) -> &mut Self { self.#ident.push(#ident_each); self } } } else { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = #ident; self } pub fn #ident_each(&mut self, #ident_each: #ty_each) -> &mut Self { self.#ident.push(#ident_each); self } } } } Some(LitOrError::Error(err)) => err.to_compile_error().into(), None => { if is_vector(&ty) { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = #ident; self } } } else { quote! { pub fn #ident(&mut self, #ident: #ty) -> &mut Self { self.#ident = std::option::Option::Some(#ident); self } } } } } }); let struct_fields = fields.named.iter().map(|field| { let ident = field.ident.as_ref(); if is_option(&field.ty) || is_vector(&field.ty) { quote! { #ident: self.#ident.clone() } } else { quote! { #ident: self.#ident.clone().unwrap() } } }); quote! { impl #builder_name { #(#setters)* pub fn build(&mut self) -> std::result::Result<#struct_name, std::boxed::Box<dyn std::error::Error>> { #(#checks)* Ok(#struct_name { #(#struct_fields),* }) } } } } fn build_struct_impl( fields: &FieldsNamed, builder_name: &Ident, struct_name: &Ident, ) -> TokenStream { let field_defaults = fields.named.iter().map(|field| { let ident = field.ident.as_ref(); let ty = &field.ty; if is_vector(&ty) { quote! { #ident: std::vec::Vec::new() } } else { quote! { #ident: std::option::Option::None } } }); quote! { impl #struct_name { pub fn builder() -> #builder_name { #builder_name { #(#field_defaults),* } } } } } fn is_option(ty: &Type) -> bool { match get_last_path_segment(ty) { Some(seg) => seg.ident == "Option", _ => false, } } fn is_vector(ty: &Type) -> bool { match get_last_path_segment(ty) { Some(seg) => seg.ident == "Vec", _ => false, } } fn unwrap_option(ty: &Type) -> Option<&Type> { if !is_option(ty) { return None; } unwrap_generic_type(ty) } fn unwrap_vector(ty: &Type) -> Option<&Type> { if !is_vector(ty) { return None; } unwrap_generic_type(ty) } fn unwrap_generic_type(ty: &Type) -> Option<&Type> { match get_last_path_segment(ty) { Some(seg) => match seg.arguments { PathArguments::AngleBracketed(ref args) => { args.args.first().and_then(|arg| match arg { &GenericArgument::Type(ref ty) => Some(ty), _ => None, }) } _ => None, }, None => None, } } fn get_last_path_segment(ty: &Type) -> Option<&PathSegment> { match ty { Type::Path(path) => path.path.segments.last(), _ => None, } } まとめ builder マクロを題材にして前編と後編に分けて手続き的マクロの実装方法を説明してきました。 今回実装したマクロはフィールドの型が Option<Vec<_>> であるケースや、 Vec 型のフィールド以外に each を付与した場合などを考慮しておらず、実装した機能は十分ではありません。テストも十分なケースを網羅しているとは言えません。 しかし今回の記事で手続き的マクロをどのようにして作るかは一通り理解でき、今後やる時もどこから手をつければよいかだいたい感覚がつかめたかと思います。この記事が今後みなさんがマクロを実装するときの助けになれば幸いです。 参考文献 syn – Docs.rs The post proc_macro_workshopでRustの手続き的マクロに入門する 後編 appeared first on CADDi Tech Blog .
アバター
注意!! 記事を書いた時点からの更新があります この記事の内容は古くなっています。当時の課題は 2022年12月の Github ActionsのUpdateにより、同一オーナーの private repository のActionsを参照可能になったため、同一オーナー間であればこの記事の手順を実施する必要はなくなりました。 詳しくは以下の記事を参照を参照してください。 GitHub Actions – Sharing actions and reusable workflows from private repositories is now GA ただし、オーナーが異なるprivate repository のactionを再利用したいというケースではこの記事の方法は利用可能です。 Platformチームの前多( @kencharos )です。 私たちのプロダクトは GCP 上に構築されていて、アプリケーションのテストやビルド、デプロイに加えて、 GCP のインフラ構築もほとんどがIaC化されています。 Pull Requestによるコードレビューを経て、CI/CD パイプラインによりアプリケーションのデプロイや GCP リソースの構築が自動化されています。 CI/CDパイプラインは、以前はCircleCIに注力していましたが、現在は GitHub Actionsに注力しています。 新規サービスの開発では GitHub Actions を最初から使用し、既存サービスでも徐々にCircleCIから GitHub Actionsへの移行を進めています。 GitHub Actionsへの移行理由としては以下の2点があります。 GitHub を使用しているため、CI/CDが統合されていると使い勝手が良い OIDCに対応しているため、 GCP のWorkload Identityを使うことによって GCP Service AccountのKey fileをCI/CD側に保存しなくても GCP リソースを扱うことができる 特に 2 のService Account Key fileをCI/CD側に保存する必要がなくなるのは重要でした。 CADDiでは GCP プロジェクトの数が多く、複数のKey fileを扱うのはメンテナンスコストや漏洩リスクが高かったためです。 なお、現在ではCircleCIもOIDCに対応していますが、検討開始当初では GitHub Actionsのみが対応していました。 actionを共 通化 するための課題 GitHub Actionsでは 再利用可能な処理をactionとして定義して、workflowから呼び出すことができます。 例えば、次のような .github/actions/sample/action.yaml があるとします。 #.github/actions/sample/action.yaml name: sample description: sample runs: using: "composite" steps: - name: echo id: echo shell: bash run: | echo "sample" 同一 リポジトリ 内のactionは次のように uses を使ってworkflowから呼び出すことができます。 #.github/workflows/sample_workflow.yaml name: workflow sample on: pull_request: jobs: sample_job: runs-on: ubuntu-latest steps: # 同一リポジトリの場合 ./ を先頭につける - uses: ./.github/actions/sample このようにaction 共通処理をまとめることができると便利です。 CircleCIでも私たちは Orb という仕組みを使って共通処理をまとめていました。 しかし、検討を進めていくと、別のprivate actionにある共通actionを利用できないという問題に気づきました。 注意! 2022年7月時点の内容です。2022年12月からは同一オーナーのprivate repository も参照可能になりました。 Github Actionsのusesの説明 を見ると、 uses に指定できるのは次のものだけでした。 publicな GitHub リポジトリ にあるaction 同一 リポジトリ 内のaction publicなdockerイメージ 私たちはサービスごとに GitHub の リポジトリ を分けていてその数は20を超えています。 それぞれの リポジトリ の中には GCP SDK の初期化、terraformの実行などの共通処理が多数ありますので、これらの処理を共通actionとして別 リポジトリ に管理できないと、コピペの嵐となってしまいます。 しかし、これらの処理をpublic リポジトリ に置くこともできません。 private リポジトリ を checkout するア イデア 同様の悩みを抱えている人は多数いるようで、その中で出てきていたのは別private リポジトリ をcheckoutして使うというものでした。 #.github/workflows/sample_workflow.yaml name: PAT checkout sample on: pull_request: jobs: sample_job: runs-on: ubuntu-latest steps: # PAT で sampleorg/private-actions の内容を .github/actions/common にチェックアウト - id: checkout-private-repo-by-PAT uses: actions/checkout@v3 with: repository: sampleorg/private-actions path: ./.github/actions/common ref: "main" token: ${{ secrets.CHECKOUT_PAT }} # チェックアウトした action を使う - uses: ./.github/actions/common/sample actions/checkout actionは別 リポジトリ の内容をパスを変えてチェックアウトできます。 チェックアウトした後であれば同一 リポジトリ 内のファイルと見做せるので、 uses: ./.github/actions/common/sample のようにして使うことができます。 ですが、別の リポジトリ をチェックアウトするにはデフォルトの GITHUB_TOKEN では権限が足りないため、 PAT(Personal Access Token)を使う必要があります。 PAT は個人に紐づく トーク ンですし期限もあるため、退職リスクや期限切れによって突然CI/CDが動かなるリスクもあるので、 組織活動としてなるべく使いたくありません。 そこで考えたのが GitHub Appsを使うというものです。 GitHub Apps GitHub Apps は、 アプリケーションが適切な権限で GitHub API にアクセスする仕組みを提供するものです。 GitHub Apps であれば、Organization 内の リポジトリ 単位でアクセスを制御できますし、チェックアウトに利用するアクセス トーク ンを安全に発行できます。 一方で、 GitHub Appsを使ってアクセス トーク ンを発行するのはそこそこ複雑です。 詳しくは 公式ドキュメント などを見ていただきたいですが、簡単に説明すると以下の作業が必要です。 事前準備として、 GitHub Appsを作成して 秘密鍵 を取得した後、 GitHub Appsを organization にインストールしておきます。 アクセス トーク ンを取得するには次の手順を実施します。 GitHub Appsの 秘密鍵 からJWTを生成する installations API をJWTを使用して呼び出し、 GitHub AppsがインストールされているOrganization一覧を取得し、アクセス対象のOrganization の ID (installation ID) を特定する installation IDを使用して installations/:installation_id/access_tokens エンドポイントを呼び出してアクセス トーク ンを取得する アクセス トーク ンを使用して リポジトリ をチェックアウトする このようにPATを使用する場合と比べて GitHub Appsを使う手順は複雑です。 そこで、上記の Github Appsを使ってprivate リポジトリ をチェックアウトするpublic actionを作成しました。 checkout-private-action https://github.com/caddijp/checkout-private-action は、前述の GitHub Appsを使って アクセス トーク ンを発行して、private リポジトリ をチェックアウトするactionです。 このactionだけはpublic actionとして定義します。 次のように使用します。 name: sample workflow on: pull_request jobs: sample-job: name: Sample job runs-on: ubuntu-latest steps: # リポジトリのチェックアウト - uses: actions/checkout@v3 # Github appsを使用して caddijp/common-github-actions private リポジトリを .github/actions/common にチェックアウト - uses: caddijp/checkout-private-action@v0.1.0 with: app_id: ${{secrets.CHECKOUT_PRIVATE_ACTION_APPS_ID}} secret_key: ${{secrets.CHECKOUT_PRIVATE_ACTION_APPS_SECRET}} org: "caddijp" repo: "common-github-actions" ref: "main" dist: "./.github/actions/common" # チェックアウトした action を呼び出す - uses: ./.github/actions/common/common_action 事前に GitHub Appsを作成して、 GitHub AppsのIDと 秘密鍵 をsecretに入れておく必要があります。 これで、別 リポジトリ にある共通actionを各repositoryで使うことができるようになりました。 具体的な処理内容を知りたい方は https://github.com/caddijp/checkout-private-action を見てみてください。 これまでに説明した内容の処理が書かれています。 共通actionを使用する際の注意点 action. yaml の内部にも uses を使用して、別のactionを呼び出すことができます。 そのため、action の中からさらに別の共通actionを呼び出したいと思うかもしれませんが、 これには注意が必要です。 Github Actionsのusesの説明 で説明したとおり、同じ リポジトリ 内のaction指定は、 uses: ./.github/actions/sample のように リポジトリ のルートからの指定しかできないためです。 相対パス や、自分のactionのパスを示す変数である ${{ github.action_path }} などを uses 節で使うことはできません。 そのため、actionから別の共通actionを呼び出すには、必ずチェックアウトするパスを固定しておく必要があります。 この制約を満たすことが難しい場合は、そもそもactionから共通actionを呼ぶような構成を見直して、workflow側で制御すると良いでしょう。 まとめ CircleCIからの移行を検討したときに Orb 相当の機能がなくて困りましたが、 GitHub Appsを使う工夫をすることで対応できました。 将来 GitHub Actionsの機能追加で、private リポジトリ のactionも利用可能になると嬉しいですが、それが待てないという方は参考にしてください。 https://github.com/caddijp/checkout-private-action は図らずともCADDi初の OSS ライブラリとなりました。 今後、違う形で OSS のライブラリが出せると個人的には嬉しいなと思っています。 私たちは他にも CI/CDパイプラインの品質を高く保つための工夫を取り入れています。 Workload Identityによるシークレットの排除や Renovate による依存ライブラリの定期更新など様々な取り組みをおこなっています。 これらの取り組みもまた別の記事で紹介できればと思っています。 興味がある方はぜひ、 採用ページ をご覧ください。
アバター
はじめに ご無沙汰しております。キャディでCTO務めております小橋です。 先ほど製造業のモノづくりに直接関わっていたキャディならではの製造業向け SaaS プロダクト 「CADDi DRAWER」のプレスリリース を出しました。この数年間、物理的な製造・検査・納品をしながら培った ドメイン 知見とソフトウェア技術を レバレッジ して、ソフトウェアを通じて産業に直接的な価値を提供出来たことは非常に嬉しく思っています。 今回は長い歴史のある製造業に寄り添ったプロダクト作りの体験を皆さんに共有させて下さい。 背景 製造業は物理的な物を作る産業です。バーチャルな構想を物に変換する工程を「製造」と呼び、その想定アウトプットを描いた「設計図面」が設計者から製造担当者に渡ります。図面には、製造する物(部品)の材質や形状や寸法が記載されています。下記の図は社内で書いたサンプル図面です。ここから、SUS304(ステンレス)で幅500ミリの金具で、といった加工の現場で必要な情報を多く読み取る事ができます。 私達の生活を支えている家電は、この図面で製造されるような個別部品の集合体から組み立てられ、我々の手元に届いています。極端な例かもしれませんが自動車には30,000点以上の部品が搭載され、部品ごとにこのような図面が存在しているのです。 さて、この図面のデータや管理には製造業独特の難しさがあります。先述の通り、現場で読み取るためのツールであるため、フォーマットも多く、人間が読むために注記等が記載されていたりします。また、 SVG に似たベクトル画像フォーマットを利用する場面が多く、バージョン管理がしにくいためsuffixに v1, v2, v3 と連番を追加したり、図面内に記載の図番(XCVF-118)を変更する事もあります。(製造業ではよく図番という図面固有の番号をふります)ソフトウェアの世界だと簡単にコードを GitHub 上で検索したり、過去書いたコードと比較出来ますが、設計図面を扱っていると実現しにくいのが現状です。もちろん、数多くの設計CADソフトとセットで専用 バージョン管理システム が販売されていますが、オープンな業界スタンダードはありません。 また、製造業の特徴として、産業 バリューチェーン が長いということが挙げられます。複数の会社が物や情報を受け渡しながら数多くの部材・部品を製造し、組み立てることで製品をつくる産業です。大手メーカーでは、部品の多くを自社工場ではなく外の加工会社に製造をお願いしています。この、社外に部品の製造を依頼したり購買・調達する業務や役割を「調達」といいます。調達担当者は、設計図面をもとに加工会社に部品の製造を依頼しますが、この図面が殆どの場合、紙や2D画像(pdfや tiff のようなデータ)でやりとりされているため、過去の担当者の知見や発注履歴などの情報を活用することができません。調達部門は会社の財務状況に直接影響する製造原価を握る重要な役割を持っているからこそ、調達に関わるデータの課題を解くことは製造業においても非常に重要であるといえます。 実際、キャディが受発注事業を拡大する上で取引社数も、社内で取り扱う図面数も急増し、まさに自社でこの図面管理の難しさを実感し苦しめられていました。社内のオペレーションを清流化するためのシステムを作り改善を続ける中で、図面の問題にはもっと大きなポテンシャルがあると考えるようになりました。図面を管理するだけではなく、情報を自動的に抽出するといったデータ活用法へのニーズがあり、図面特有の画像解析技術の研究も進めてきました。解析 アルゴリズム が成長したタイミングでこの技術の業界への汎用性にも気づき、ソフトウェアの技術を通じて産業全体に価値提供が出来るのではないかと思ったのが本製品の始まりです。 始まり この気付きをもとに業界調査を始めたかったものの、アンケート資料も無い状態でした。専属の開発チームも マーケティング 担当もいない、ア イデア と夢だけの創業期に戻った感覚でした。 既に受発注の事業を提供していたため、キャディには沢山の製造業のお取引先様があります。1日でも早く ヒアリ ングを始めようと、コンセプトを伝えて反応をつかむためのデモを突貫工事で作る事になりました。システムの設計としても状態を持たず、図面データの検索性や活用シーンといった使い勝手を想像しやすいUXを起点に、裏側はフルマネージドのデータベース(Firestore, Algolia)を利用しました。検証段階でデータ構造が揺らぐことも想定し、schema-lessに寄せてバリデーション緩く。リリース時にドタバタしたく無かったので最低限の開発と本番環境までCI/CDを設けましたが、一方で 結合テスト などは省略し、人力 テスト駆動開発 でデモの品質を担保することにしました。 突貫工事を数週間続けてお客様の意見を回収した結果、我々が社内で当たり前のように使っている図面管理の仕組みが驚くほど数多くの企業様・製造業で働く方々に求められていると実感できました。早速、本格的にプロダクト開発の投資を始める判断に至りました。あまりにも商談が順調で正直焦りましたが、これは喜びの悲鳴とも言えるでしょう。お客様に「いいね」「欲しいです」と言われる事で価値検証の確度が上がり、プロダクトの価値が固まっていきました。 事業としては素敵な事ですが、開発者としては冷や汗をかき始めます。今までは運用や安定性などを考慮せずでデモを作ってきたところで、突然複数のお客様が触れるシステムが求められている訳です。「技術負債が…」と言いたい気持ちもありますが、検証したい仮説を絞った上で全力で走ろうと決めました。社内でサンプル図面を作成する事でセキュリティ要件も抑え、ブラウザも指定、テストも最低限とする戦略を取りました。 走りながらも未来に投資 スタートアップはよく“崖から飛び降りながら飛行機を作る”事に例えられますが、我々もまさに目の前の仕事で必死なのにも関わらず、同時に将来に投資し続ける事が求められました。翌週のリリースに向けて開発しながらも、並行して新規プロダクトのために顧客理解を深めるリサーチも続け、さらに半年後の技術的不確実性を潰す挑戦も必要でした。新たな検証結果や情報が手に入る度に軌道修正する事も求められました。仮説の上にまた仮説を重ねていたので、初期仮説が崩れれば全部ひっくり返る。そんな状況でしたが、これこそが新規事業に求められるアジリティだと今では思います。 組織や体制づくりについても同様で、まだプロダクト価値の検証中でも、成功する前提で将来を見据えて投資する必要もありました。設計図面は製造業にとって最重要データといわれており、大切な 知財 に当たります。そうした機密性の高い情報を扱うプロダクトになる事が想像出来ていたため、セキュリティ対策の優先度は当然高めました。技術的なプロダクト自体のセキュリティだけでなく、組織として情報取扱を含む幅広い情報管理体制を整える必要があると考え、社内の関係者10人前後の段階から ISMS (Information security management system) の構築を決断しました。詳細に営業や開発部門の取り扱うデータを整理し、強弱を付けて施策に落とすだけではなく、制度と実態が合っているのか継続的に確認、改善できる体制を作りました。もちろん、 クラウド 世代のエンジニアらしくIaC (Infrastructure as Code) 管理で運用負荷を下げるために特定情報取り扱うSlackチャネルの参加者もIaC管理しました。 この段階で情報セキュリティに投資した事が、正式ローンチを迎えた今、 組織力 に変換され根付いています。開発のスピードが重要なフェーズにも関わらずセキュリティ対策に時間をとられ目の前の進捗を犠牲にすることもありましたが、お客様の大切な情報を取り扱うプロダクトだからこそ過剰といえるぐらいの対策を行う必要がありますし、非常に重要な施策だったなと今振り返ってみて思います。事業が拡大してから、組織の価値観を変える事はとても難しいからです。 突貫工事を卒業しベータ版へ お客様からの強いニーズを確認できたため、ベータ版として提供し使っていただくことにしました。突貫工事のデモを卒業するにあたっては、事業的にも技術的にも多くの仕事がありました。特にエンジニアとして「ソフトウェアを作る人」ではなくて「ソフトウェアの専門性を活かして事業を作る人」として振る舞う必要がありました。お客様が直接ソフトウェアを触る事で初めてプロダクト価値が分かるので、それに向けて少しでも早く商談が始められるように開発の順番等を調整し続けていました。営業の場に同席して、自分達で ヒアリ ングもする事で情報を 仕入 れ続けていました。IT分野を超える 多能工 化が求められるわけですが、これこそが新規事業の楽しみだなとも振り返って思います。 数ヶ月かかりましたが徐々にデモの状態 からし っかりと組まれたソフトウェアに成長し、ドキュメントやタスクも整理出来ていきました。弊社の設計図面の解析技術も進化して、当初の想定以上のパフォーマンスを出せるようになっていました。ソフトウェアのスタックも社内標準に合わせ、インフラも Kubernetes クラスタ ーに移し、監視も出来るようになり、定期的に ブラックボックステスト も追加されていきました。今では、メトリクスを通じて提供したい価値がユーザにお届けできているかの測定も可能になっています。 エンジニア個人の腕力と気合に依存していた部分もありますが、この四年間で蓄積してきた B2B のシステム運用の知見が社外に発揮出来たとも思っています。社内用に大量の図面を処理する仕組みを構築した経験もありましたし、複雑なデータ構造を扱う非同期処理が多い基幹システムの落とし穴も事前に分かっていました。 ドメイン 駆動設計(DDD)に レバレッジ をかけた開発の難しさも経験していたお陰で、なるべく事業の目指す方向に適した開発手法が選定出来たのかなと思います。 新規目玉機能の開発 ベータ版を通じてお客様の調達の課題をひとつ解決出来ることは検証出来ましたが、これはいわゆるProblem Solution Fitでしか無く、まだ1つの大きなプロダクトと言える状態ではありませんでした。我々は痒いところに手が届く仕組みだけを作りたいわけではなく、大きく業界を次の世代に進化させる事を目的としています。もちろん業務簡略化機能を届ける事も大切にしていましたが、他の誰にも実現出来ないキャディならではの圧倒的なWOW要素を込めた1つの”プロダクト”にするために試行錯誤を繰り返しました。 プロダクトマネージャの ヒアリ ングやリサーチから出てきた案が、設計図面の類似検索でした。 Google で検索キーワードの代わりに写真をキーにした画像検索が出来るかと思いますが、それを調達というコンテキストで設計図に適用し、類似品の比較を通じて調達の最適化を行うというア イデア です。製造業に関わっていないと分からない「類似」に関する強い ドメイン 知識もあり、まさに受発注に日々関わっているキャディにしか出来ない事です。 素早く弊社のAI Labを巻き込み、社内の ドメイン エキスパートや図面に纏わるデータを参考にした探索を始めました。また、「類似」というほぼ定義の無い要件を 言語化 して評価指標を用意出来るまで、数多くの方々にお世話になりました。受発注に日々関わっているからこそ仮説を立てフィードバック頂ける時間が短縮出来たのかなと思います。見積・調達・製造・検査と幅広いオペレーションを社内で持っているため、 アルゴリズム の評価指標を設計する上で重要な要素を現場に ヒアリ ングする事も出来、まさにキャディの総力で挑んだプロダクトになっています。 アルゴリズム だけではなく、アプリケーション全体の設計も一気に難易度が上がりました。類似判定をする特殊な アルゴリズム の開発を始め、その解析技術を活用する分散処理の仕組みや類似データを格納する特殊なデータベースも必要になりました。データ保管をするともちろんバックアップや マイグレーション も求められ、開発の関係者数も増えるため情報の交通整備の難易度も上がっていきました。 色々組織的にも技術的にも苦労して負債も積んでいますが、お客様に調達業務を進化させるプロダクトがついに出来たと思っています。デモを作り始めてから一年間で業界特化の バーティカル SaaS を立ち上げ、プレスリリース出せた事は嬉しい限りです。ですが、これから事業成長を続けるには正直足りていない要素ばかりです。この勢いで急速立ち上げしているので、改善の余地しか無いです。 現状パフォーマンス改善を省略しマシンスペックで殴っていたり、CI/CDの関係で新規機能がお客様の手元に届くまでの時間が長かったり、開発者体験や運用コストを犠牲にして走ってきた部分もあります。また、設計変更を繰り返した結果チーム構造とシステム構造にねじれがあって連携コストが高くなっている事実もあり、チームとシステム両方の最適化が必要です。 プロダクトとしてもこれがまだスタート地点にやっと立てた状況です。公開は出来ないワクワクするロードマップが沢山待ち構えておりますので、また皆さんに紹介出来るのを楽しみにしております。 これからの挑戦 ユーザ数及び図面数が急増する中での継続安定運用 今後事業伸ばしていく上で桁違いの負荷に耐えて安定運用を継続出来る組織体制や技術挑戦が必要とされてます。組織的にはカスタマーサクセスに向けたUX改善やユーザより先に問題を検知出来る品質の作り込み、そして障害が起きても速やかに リカバリ ー出来る体制の強化が重要かなと思います。これを実現出来る組織作りに挑戦したい エンジニアリングマネージャー を探しております。 技術的には既に クラウド のスケーラビリティ力に頼っておりますが、甘えすぎずにパフォーマンスを常に意識し続ける事で、新たな機能開発の弊害を避けられると信じています。非同期処理も多い複雑なプロダクトに関わりたい アプリケーションよりの方々 、そして縁の下の力持ちとして組織とプロダクトを支えたい プラットフォームエンジニア の仲間も絶賛募集しております。 開発者体験改善を通じて組織のアジリティ向上 最初の一年は不確実性が多く、そもそもプロダクトとして価値を出せるのかも分からずに走って来たため、人数絞ってハイコンテキストで仕事を進められる体制をとっていました。しかし、これからさらなる拡大をしていく上では内部の API やインタフェースを安定化してドキュメント化し、毎週 breaking change が起きない状態を目指したいです。複数チーム横断したパッケージの共有やリリースの清流化、細かいけど重要なログフォーマットの共 通化 等も今後は開発者体験及び生産性に効いてくると思います。 キャディの プラットフォームエンジニアリングチーム は開発者組織のアジリティ向上と イノベーション 推進をミッションに掲げており、直近ではサービスメッシュの検討等も進めており、深い知見をお持ちの方に協力して頂きたいです。 種からプロダクトまでの イノベーション パイプライン短縮化 今後、R&Dから生まれた新規画像解析 アルゴリズム や新たなUXの検証実験等、数々の施策を打っていくことを想定しています。最速で新規ア イデア をユーザの手元に届けられ、プロダクション提供しているものを継続改善出来る世界に持っていきたいんですが、まだ苦労しているのが現状です。 データ領域に関しては半年前にAI Lab設立したばかりで、更地どころか無の状況で始まり、やっと更地になったくらいです。今回のプロダクトリリースで初めてend-to-endのデリバリーが実現出来た良い事例かなと思いますが、今後はこの価値提供を加速化する領域にも投資したくて MLOps や Data Engineering の領域を強化して行こうとおもっています。上流のデータ取得や ディスカバリー は現状 人海戦術 な部分も多いのは多少仕方がないとしても、運用中のモデルのパフォーマンス評価を元に PDCA 回したり、更新に伴うバージョン管理やロギング周りも劣後して来たので、今後は力入れていきたい。データが好きだったり、データとソフトウェア開発の境界線を歩みたい方と一緒に イノベーション パイプライン作っていきたいです。 プロダクト成長を支える新規施策 最初の課題を解くプロダクトが出来上がったと感じていますが、これからデータ基盤になるのか、新しいWOWの提供に特化したサービスになるのか、 ヒアリ ングと試行錯誤を続ける難しいフェーズになるなと考えています。プロダクトとして立ち上がった今、1から100に成長させられる力強い プロダクトマネージャー と プロダクトデザイナー も募集しています。 製造業の品質管理とソフトウェアの比較記事を以前書きましたが、キャディでは現状テスターのみの組織はもっていません。四六時中 単体テスト 書く職人もいません。現状は各開発者にお任せしている状況ですが、これではキャディ全体がスケールしないと考えており、DevOps系の施策が好きな方や自動化に興味がある Quality Assuranceエンジニア とも一緒に働きたいと考えています。
アバター
はじめに この記事では proc_macro_workshop というリポジトリを使って Rust の手続き的マクロの作り方を学んでいきます。想定している読者は以下のような方です。 Rust の基本的な文法や概念(トレイトや所有権、ライフタイムなど)を知っている 手続き的マクロの作り方について知りたい この記事では以下のことを説明します。 Rust のマクロの概要 手続き的マクロ( derive マクロ)の作り方 proc_macro_workshop の進め方 また、この記事では以下のことは説明 しません 。 Rust の基本的な文法や概念 宣言的マクロの作り方 Rust のマクロ Rust のマクロには宣言的マクロと手続き的マクロの 2 種類があります。 宣言的マクロ 宣言的マクロは macro_rules! 構文を使用して定義されるマクロで、match 式に似た形で処理内容を定義します。 vec! や println! など普段良く使うマクロも宣言的マクロです。 宣言的マクロについては The Rust Programming Language 日本語版 に vec! マクロを例にした解説があるので、そちらをご覧ください。 手続き的マクロ 手続き的マクロはトレイトのデフォルト実装の導出に用いる derive マクロなどに代表されるマクロです。宣言的マクロよりも複雑な処理を記述できることが特徴で、以下の 3 種類があります。 derive マクロ 構造体やenumに付与することでその構造体やenumに追加の処理を実装できます 関数風マクロ 関数風マクロは宣言的マクロのように関数呼び出しと似た形で使用できるマクロです。宣言的マクロと比較するとより複雑な処理を記述できます 属性風マクロ deriveマクロと同様に付与した対象に対して追加の処理を実装できますが、こちらは構造体やenumだけでなく関数などにも適用できます。テストを書くときに用いる #[test] アトリビュートなどがこれにあたります 手続き的マクロについては The Rust Programming Language 日本語版 により詳しい説明があるのですが、これだけで実際に使えるような手続き的マクロを作り始めるのは難しいかと思います。この記事では proc_macro_workshop というリポジトリを使って実際に手を動かしながら手続的マクロの作り方を学んでいきます。 proc_macro_workshop proc_macro_workshop について proc_macro_workshop には作成するマクロの種類によって 5 つのプロジェクトがあります。 derive マクロ derive(Builder) derive マクロ derive(CustomDebug) 関数風マクロ seq! 属性風マクロ #[sorted] 属性風マクロ #[bitfield] これらのマクロの内容については proc_macro_workshop リポジトリ をご覧ください。 この記事では、Builder パターンに必要な実装を導出する derive(Builder) プロジェクトを進めつつ手続き的マクロの作成方法を学んでいきます。 derive(Builder) プロジェクトを進めると最終的には以下のような使い方のできるマクロが完成します。 use derive_builder::Builder; #[derive(Builder)] pub struct Command { executable: String, #[builder(each = "arg")] args: Vec<String>, #[builder(each = "env")] env: Vec<String>, current_dir: Option<String>, } fn main() { let command = Command::builder() .executable("cargo".to_owned()) .arg("build".to_owned()) .arg("--release".to_owned()) .build() .unwrap(); } proc_macro_workshop の進め方 proc_macro_workshop の各プロジェクトは以下の流れで進めていきます。 テストケースを追加する tests/progress.rs に記載されているテストケースのコメントアウトを解除する 追加したテストケースをパスするようなマクロを実装する src/lib.rs に実装します テストをパスしたら 1.に戻る proc_macro_workshop の各プロジェクトにはそれぞれのマクロがパスすべきテストが記載されており、これらのテストに通過するようなマクロを実装することが目標となります。 derive(Builder) プロジェクトの場合は builder/tests ディレクトリにテストケースが記載されたファイルが格納されています。各ステップでテストケースを 1 つずつ増やしていき、これに対応した機能を実装してきます。 プロジェクトの進捗は tests/progress.rs を用いて管理します。 tests/progress.rs は以下のようになっており、各行がそれぞれのテストケースに対応しています。各ステップでテストケースのコメントアウトを解除し、それぞれのテストを通過するように実装を進めていきます。 #[test] fn tests() { let t = trybuild::TestCases::new(); t.pass("tests/01-parse.rs"); //t.pass("tests/02-create-builder.rs"); //t.pass("tests/03-call-setters.rs"); //t.pass("tests/04-call-build.rs"); //t.pass("tests/05-method-chaining.rs"); //t.pass("tests/06-optional-field.rs"); //t.pass("tests/07-repeated-field.rs"); //t.compile_fail("tests/08-unrecognized-attribute.rs"); //t.pass("tests/09-redefined-prelude-types.rs"); } 各テストケースにおける目標はテストケースが記載されているファイルのコメントに書いてあります。実際に作業する際はこれらのコメントを参考にしつつ実装していくことになります。 テストは builder ディレクトリで cargo test コマンドを実行することで実行できます。 derive(Builder) マクロを作る derive(Builder) プロジェクトでは大まかに以下のような流れでマクロを作成していきます。 マクロのひな型を用意する 01-parse で作業します builder パターンを実現するために必要な機能(setter メソッドなど)を実装する 02-create-builder ~ 05-method-chaining で作業します エラーハンドリングなどを実装する 06-optional-field ~ 09-redefined-prelude-types で作業します それでは実際にマクロを書いていきましょう。以降の各節の名前はテストファイルの名称と対応しています。以降の各節では以下の流れで進めていきます。 ステップの目標を確認する 実際には具体的なテストケースがありますが、すべて記載すると煩雑なので各ステップのゴールのみ記載します 実装方針の説明 実装 各ステップの最後にテストケースをパスする実装例を提示します マクロの処理の流れ Rust の手続き型マクロの処理は基本的に以下のようになっています。本記事ではこの内容を具体的に実装していきます。 トークン 列を入力として受け取る 受け取ったトークン列を構文木に変換する 変換した構文木をもとに処理を行い所望の構文木を得る 得られた構文木をトークン列に変換して返す トークン列と構文木の相互変換には syn クレート と quote クレート がよく使われます。この記事でもこれらのクレートを用いてマクロを実装します。 syn クレートはトークン列から構文木への変換に使用します。また、 syn クレートには構文木に関する構造体が定義されており、パースして得られた構文木はこれらの構造体のインスタンスとして保持されます。 quote クレートは構文木からトークン列への変換に使用します。 マクロを開発する上での Tips dbg! マクロの使用 マクロのデバッグをする際などには構文木の中身が実際にどうなっているかを知りたくなることがあるかもしれません。 syn クレートの extra-traits フィーチャー を有効にするとこれらの構造体に Debug トレイトが実装されて dbg! マクロを使ったデバッグができます。開発する上で便利なので開発中は有効にしておくと良いでしょう。 syn = { version = "1.0.86", features = ["extra-traits"] } cargo-expand cargo expand を使うとマクロによって生成されたコードを出力できます。これもデバッグに便利なので詰まった時は使うと良いでしょう。 proc-macro-workshop の GitHub リポジトリ と cargo-expand の GitHub リポジトリ に使い方の説明があるので、使う場合はこちらを参照してください。 01-parse 目標 空の derive マクロを作る 実装方針 テストが実行されるのは tests/01-parse.rs の main 関数ですが、このステップでは何も記載がありません。ですので、特に何もしなくてもテストをパスする…かというとそうではなく、 #[derive(Builder)] が使われた際に呼ばれる derive マクロが存在している必要があります。 なぜかというと、テストコード中で以下のように構造体に対して derive マクロが呼ばれているためです。 #[derive(Builder)] pub struct Command { executable: String, args: Vec<String>, env: Vec<String>, current_dir: String, } マクロの基本的な処理内容についてはすでに説明しましたが、より具体的には以下のような流れになっています。 トークン列 proc_macro::TokenStream (以降では基本的に TokenStream と記載します)を引数として受け取る syn::parse_macro_input! マクロでパースして構文木にする パースした構文木を元に所望の構文木を生成する 生成した構文木を TokenStream に変換して返す このステップではマクロが存在していればよいので、1.と 4.の処理を実装すればテストをパスします。 実装 それでは実際に実装をしていきます。 まずは derive マクロの定義から始めます。関数に proc_macro_derive アトリビュートをつけることでその関数内に記載された処理が derive マクロの実装になります。 derive マクロのシグネチャは (TokenStream) -> TokenStream になっており、実態は Rust のトークンを TokenStream という構造体で受け取り、内部で処理した後に TokenStream という構造体として返す関数です。 #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream {} では関数の中身を実装してきましょう。最低限テストが通るためにはこの関数から TokenStream を返せば良いので、以下のように TokenStream を新しく作って返してやれば OK です。 #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream { proc_macro::TokenStream::new() } このままでも良いのですが、次のステップへの準備として入力をパースできるようにもしておきましょう。入力のパースには syn::parse_macro_input! マクロを使います。derive マクロの入力は syn::DeriveInput で定義される構造をしているので DeriveInput としてパースします。入力をパースする処理を追加すると最終的なマクロの実装は以下のようになります。 use proc_macro::TokenStream; use syn::{parse_macro_input, DeriveInput}; #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream { let _input = parse_macro_input!(input as DeriveInput); proc_macro::TokenStream::new() } 02-create-builder 目標 CommandBuilder 構造体のインスタンスを返す builder 関数を Command 構造体に実装する 実装方針 最終的には以下のような CommandBuilder 構造体のインスタンスを返す builder 関数を Command 構造体に実装するのが目的です。 pub struct CommandBuilder { executable: Option<String>, args: Option<Vec<String>>, env: Option<Vec<String>>, current_dir: Option<Vec<String>>, } 必要な作業は大きく分けて以下の 2 つです。 builder 関数を Command 構造体に実装する CommandBuilder 構造体の定義を実装する 汎用的に使えるようにするには Builder 構造体の構造体名やフィールドの名前、型は derive マクロが適用される構造体に応じたものにする必要がある これを同時に全部進めるのはたいへんですので、以下の順に進めていきます。 空の builder 関数を Command 構造体に実装する CommandBuilder を返すように builder 関数の実装を変更する 構造体名、フィールド名に応じた Builder 構造体を生成する 実装 空の builder 関数を Command 構造体に実装する 前のステップで説明したように、手続き的マクロの処理の概要は以下の通りです。1.と 2.は前のステップで実装しました。このステップでは 3.と 4.の処理を実装していきます。 まずは空の builder 関数を実装する処理を書いていきましょう。この段階では入力について気にする必要はありません。 proc_macro::TokenStream (以降では基本的に TokenStream と記載します)を引数として受け取る syn::parse_macro_input! マクロでパースして構文木にする パースした構文木を元に所望の構文木を生成する 生成した構文木を TokenStream に変換して返す トークン列を生成するためには quote クレートの quote! マクロを使います。以下のように Rust のコードと(ほぼ)同じ構文で記述でき、この内容がトークン列に変換されます。 #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream { let _input = parse_macro_input!(input as DeriveInput); let expand = quote! { impl Command { pub fn builder() {} } }; proc_macro::TokenStream::new() } このままだと proc_macro::TokenStream::new() によって生成された空のトークン列が戻り値として返るので、生成したトークン列を返すようにしましょう。 quote! マクロを使うと proc_macro::TokenStream ではなく proc_macro2::TokenStream が生成されます。 proc_macro2::TokenStream は proc_macro2 クレートによって提供されるトークン列です。 proc_macro::TokenStream が Rust コンパイラの使用するトークン列です。 proc_macro2::TokenStream は proc_macro::TokenStream::from で proc_macro::TokenStream に変換できるので、以下のようにして生成したトークン列を返します。 #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream { let _input = parse_macro_input!(input as DeriveInput); let expand = quote! { impl Command { pub fn builder() {} } }; proc_macro::TokenStream::from(expand) } この段階でのマクロの実装は以下のようになります。 use proc_macro::TokenStream; use quote::quote; use syn::{parse_macro_input, DeriveInput}; #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream { let _input = parse_macro_input!(input as DeriveInput); let expand = quote! { impl Command { pub fn builder() {} } }; proc_macro::TokenStream::from(expand) } CommandBuilder を返すように builder 関数の実装を変更する 前のステップで実装した builder 関数は空ですので、このままでは何もしてくれません。次の段階として、ハードコードされた CommandBuilder 構造体を返すように builder 関数の実装を変更してみましょう。 まずは CommandBuilder 構造体の定義を quote! マクロ内に追加します。 let expand = quote! { pub struct CommandBuilder { executable: Option<String>, args: Option<Vec<String>>, env: Option<Vec<String>>, current_dir: Option<Vec<String>>, } impl Command { pub fn builder() {} } }; 次に builder 関数内からデフォルト値でフィールドを埋めた CommandBuilder を返すように変更します。 CommandBuilder のフィールドはすべて Optional なので None で埋めておきます。 let expand = quote! { ...略... impl Command { pub fn builder() -> CommandBuilder { CommandBuilder { executable: None, args: None, env: None, current_dir: None, } } } }; 最終的な実装は以下のようになります。 use proc_macro::TokenStream; use quote::quote; use syn::{parse_macro_input, DeriveInput}; #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream { let _input = parse_macro_input!(input as DeriveInput); let expand = quote! { pub struct CommandBuilder { executable: Option<String>, args: Option<Vec<String>>, env: Option<Vec<String>>, current_dir: Option<Vec<String>>, } impl Command { pub fn builder() -> CommandBuilder { CommandBuilder { executable: None, args: None, env: None, current_dir: None, } } } }; proc_macro::TokenStream::from(expand) } 構造体名、フィールド名に応じた Builder 構造体を生成する さて、ここまでで Command 構造体用の Builder 構造体を返すマクロができました。ただ、このままだとほかの構造体に対して使用できません。derive マクロが適用された構造体の名前やフィールドに応じて適した実装を与えるようにしたいです。Builder 構造体の要件をまとめると以下のようになります。 構造体の名前は derive マクロの適用された構造体名の末尾に Builder を付けたものとする フィールド名は derive マクロの適用された構造体のフィールド名と同じものを使う フィールドの型は derive マクロの適用された構造体のフィールドの型を Optional にしたものとする このような要件を満たすためには derive マクロが適用された構造体の情報を取得する必要があります。では、どこから取得すれば良いのでしょうか。derive マクロの入力をパースすると以下のような syn::DeriveInput 構造体が得られるのですが、実はこの構造体の中に必要な情報が入っています。 pub struct DeriveInput { pub attrs: Vec<Attribute>, pub vis: Visibility, pub ident: Ident, pub generics: Generics, pub data: Data, } このステップに関係する部分は以下の 3 つです。 vis は構造体の可視性の情報を保持しています ident は identifier の略で、変数名などの Rust コード中の識別子の情報を保持しています。ここでは derive マクロが付与された構造体/enum の名前を保持しています data は構造体の保持するフィールドの情報を持っています 構造体名は ident を使えば取得できそうです。derive マクロの使われた構造体名の末尾に Builder を付けたものを Builder 構造体の名前にしたいので、 format_ident! マクロを使って末尾に Builder を結合します。 format_ident! マクロはこのように新たな識別子を作るときに使用します。詳しくは quote クレートの GitHub リポジトリ をご覧ください。 let ident = input.ident; let builder_name = format_ident!("{}Builder", ident); 構造体名についてはこれで良さそうです。次はフィールド名と型を取得しましょう。フィールドの情報は data フィールドに入っているのでした。 syn::Data は以下のような enum です。今回扱うのは構造体なので DataStruct 構造体の中身を見てみましょう。 pub enum Data { Struct(DataStruct), Enum(DataEnum), Union(DataUnion), } syn::DataStruct はこうなっています。 fields がフィールドの情報を持っています。 pub struct DataStruct { pub struct_token: Struct, pub fields: Fields, pub semi_token: Option<Semi>, } Fields は以下のような enum です。 Named が名前のついたフィールドです。今回は Unit 構造体やタプル構造体はサポートしないので Named だけ見てみましょう。 pub enum Fields { Named(FieldsNamed), Unnamed(FieldsUnnamed), Unit, } FieldsNamed はフィールド情報を持っている Field 構造体を複数保持しており、 named.iter() で Field 構造体のインスタンスをイテレートできます。 pub struct FieldsNamed { pub brace_token: Brace, pub named: Punctuated<Field, Comma>, } syn::Field 構造体はこうなっています。 ident がフィールド名で ty が型の情報を持っています。ようやく必要な情報までたどり着きました。 pub struct Field { pub attrs: Vec<Attribute>, pub vis: Visibility, pub ident: Option<Ident>, pub colon_token: Option<Colon>, pub ty: Type, } パターンマッチを使って必要な情報だけを取り出します。サポートしないケースについては今は panic! させておきましょう(後のステップでちゃんとコンパイルエラーが出るようにします)。この後で使いやすいように ident と ty は Vec に格納しておきます。 let (idents, types): (Vec<Ident>, Vec<Type>) = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields .named .into_iter() .map(|field| { let ident = field.ident; let ty = field.ty; (ident.unwrap(), ty) }) .unzip(), _ => panic!("no unnamed fields are allowed"), }, _ => panic!("expects struct"), }; さて、必要なものはすべて準備できました。これらを使ってマクロの出力として返すトークン列を生成しましょう。 この前のステップで quote! マクロについて Rust のコードと(ほぼ)同じ構文で記述することができ と説明しましたが、 quote! マクロの内部では特殊な記法を使うことができます。たとえば、 quote::ToTokens トレイトを実装する変数に quote! マクロの中で # をつけるとその変数の内容が挿入されます。これを使って適切な名前や型のフィールドを持った構造体を動的に生成します。今までに出てきた syn::Ident などの構造体は quote::ToTokens トレイトを実装しているので特に気にせず使うことができます。 これを使って CommandBuilder 構造体の構造体名を書き直すと以下のようになります。 quote!{ #vis struct #builder_name { executable: Option<String>, args: Option<Vec<String>>, env: Option<Vec<String>>, current_dir: Option<Vec<String>>, } } また、 quote! マクロの中では #(...)* のような形で IntoIterator を実装した型の変数を繰り返して挿入できます。これを使って CommandBuilder 構造体のフィールド名も書き換えると以下のようになります。 idents と types はそれぞれフィールド名と型を格納したベクタですので、以下のようにしてベクタの各値について繰り返し展開できます。 ) と * の間に文字を記述するとその文字で区切って展開してくれるので、以下では #(...),* のようにして構造体のフィールドをベクタから生成しています。 quote!{ #vis struct #builder_name { #(#idents: Option<#types>),* } } さて、これでハードコードされた構造体名やフィールド名などをすべて取り除くことができました。 同じように builder 関数も書き換えると以下のようになります。 quote! { #vis struct #builder_name { #(#idents: Option<#types>),* } impl #ident { pub fn builder() -> #builder_name { #builder_name { #(#idents: None),* } } } } 以上をまとめると最終的なマクロの実装は以下のようになります。 use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{parse_macro_input, Data, DeriveInput, Fields, Ident, Type}; #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let ident = input.ident; let vis = input.vis; let builder_name = format_ident!("{}Builder", ident); let (idents, types): (Vec<Ident>, Vec<Type>) = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields .named .into_iter() .map(|field| { let ident = field.ident; let ty = field.ty; (ident.unwrap(), ty) }) .unzip(), _ => panic!("no unnamed fields are allowed"), }, _ => panic!("expects struct"), }; let expand = quote! { #vis struct #builder_name { #(#idents: Option<#types>),* } impl #ident { pub fn builder() -> #builder_name { #builder_name { #(#idents: None),* } } } }; proc_macro::TokenStream::from(expand) } 03-call-setters 目標 CommandBuilder 構造体の各フィールドに対する setter メソッドを作る 実装方針 具体的な実装から考えるとやりやすいです。 Command 関数の executable フィールドを例にとると setter は以下のようになるので、これを前のステップと同様、変数の展開や繰り返しを使って書き直しましょう。 pub fn executable(&mut self, executable: String) -> &mut Self { self.executable = executable self } 実装 実装方針で書いた具体的なフィールドの実装を書き直しましょう。変数の展開や繰り返しの書き方はすでに説明した通りです。 #(...)* の括弧内には変数以外を入れることもできます。今回は関数の実装自体を #(...)* で繰り返してやれば良いでしょう。 impl #builder_name { #(pub fn #idents(&mut self, #idents: #types) -> &mut Self { self.#idents = Some(#idents); self })* } 最終的な実装は以下の通りです。 use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{parse_macro_input, Data, DeriveInput, Fields, Ident, Type}; #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let ident = input.ident; let vis = input.vis; let builder_name = format_ident!("{}Builder", ident); let (idents, types): (Vec<Ident>, Vec<Type>) = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields .named .into_iter() .map(|field| { let ident = field.ident; let ty = field.ty; (ident.unwrap(), ty) }) .unzip(), _ => panic!("no unnamed fields are allowed"), }, _ => panic!("expects struct"), }; let expand = quote! { #vis struct #builder_name { #(#idents: Option<#types>),* } impl #builder_name { #(pub fn #idents(&mut self, #idents: #types) -> &mut Self { self.#idents = Some(#idents); self })* } impl #ident { pub fn builder() -> #builder_name { #builder_name { #(#idents: None),* } } } }; proc_macro::TokenStream::from(expand) } 04-call-build 目標 Command 構造体のインスタンスを返す build メソッドを CommandBuilder 構造体に実装する CommandBuilder の各フィールドが None の場合はエラーを返すようにする 実装方針 これも具体的な実装から考えるとわかりやすいです。 Command 構造体の場合は以下のようになるので、これを変数の展開や繰り返しを使って書き直しましょう。 impl CommandBuilder { ...略... pub fn build() -> Result<Command, Box<dyn Error>> { if self.executable.is_none() { return Err(...略...) } ...略... if self.current_dir.is_none() { return Err(...略...) } Command { executable: self.executable.clone().unwrap(), args: self.args.clone().unwrap(), env: self.env.clone().unwrap(), current_dir: self.current_dir.clone().unwrap(), } } } 実装 ガード節をまず作ります。このように quote! を使って部分的にトークン列を作って後で組み合わせることもできます。 let checks = idents.iter().map(|ident| { let err = format!("Required field '{}' is missing", ident.to_string()); quote! { if self.#ident.is_none() { return Err(#err.into()) } } }); 前のステップと同じようにして build 関数を作ります。上で作ったガード節を #(#checks)* で展開しています。 let expand = quote! { ...略... pub fn build(&mut self) -> Result<#ident, Box<dyn std::error::Error>> { #(#checks)* Ok(#ident { #(#idents: self.#idents.clone().unwrap()),* }) } ...略... } 最終的な実装は以下の通りです。 use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{parse_macro_input, Data, DeriveInput, Fields, Ident, Type}; #[proc_macro_derive(Builder)] pub fn derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let ident = input.ident; let vis = input.vis; let builder_name = format_ident!("{}Builder", ident); let (idents, types): (Vec<Ident>, Vec<Type>) = match input.data { Data::Struct(data) => match data.fields { Fields::Named(fields) => fields .named .into_iter() .map(|field| { let ident = field.ident; let ty = field.ty; (ident.unwrap(), ty) }) .unzip(), _ => panic!("no unnamed fields are allowed"), }, _ => panic!("expects struct"), }; let checks = idents.iter().map(|ident| { let err = format!("Required field '{}' is missing", ident.to_string()); quote! { if self.#ident.is_none() { return Err(#err.into()) } } }); let expand = quote! { #vis struct #builder_name { #(#idents: Option<#types>),* } impl #builder_name { #(pub fn #idents(&mut self, #idents: #types) -> &mut Self { self.#idents = Some(#idents); self })* pub fn build(&mut self) -> Result<#ident, Box<dyn std::error::Error>> { #(#checks)* Ok(#ident { #(#idents: self.#idents.clone().unwrap()),* }) } } impl #ident { pub fn builder() -> #builder_name { #builder_name { #(#idents: None),* } } } }; proc_macro::TokenStream::from(expand) } 05-method-chaining 目標 CommandBuilder 構造体でメソッドチェーンを使えるようにする 実装方針 03-call-setters ステップで実装した setter は &mut Self を返すようになっているので、実はすでにメソッドチェーンを使えるようになっています。そのため、このステップでは追加の実装をする必要はありません。 実装 このステップでは追加の実装は必要ありません。このステップ用のテストを実行して変更なしでテストをパスすることをチェックしてみましょう。 まとめ 前編では Builder パターンを実現するのに最低限必要な機能を持ったマクロを作りました。 しかし、まだ Optional なフィールドの取り扱いに不十分な点があったり、ベクタ型のフィールドの取り扱いに改善の余地があったりします。後編ではそのあたりの機能を実装していきます。 後編へ続く。 参考文献 proc-macro-workshop/builder に取り組む Rust の procedural macro を操って黒魔術師になろう〜proc-macro-workshop の紹介 実践 Rust プログラミング入門 syn – Docs.rs quote – Docs.rs The post proc_macro_workshopでRustの手続き的マクロに入門する 前編 appeared first on CADDi Tech Blog .
アバター