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

TECH PLAY

キャディ株式会社

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

239

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枚の図面を解析」という目標を達成できました。これを用いてキャディのビジネスをさらに加速していきたいと考えています。
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 チームの飯迫 ( @minato128 ) です。 2021 年 7 月 1 日に CADDi で初めての Tech 組織横断チームとして、山田( @kei711_ ) と一緒に Platform チームを立ち上げ、約 1 年 3 ヶ月が経過しました。今回は、我々が立ち上げからこれまで取り組んできたことや Platform チームのアップデートなどを紹介したいと思います。 CADDi の Platform チームとは 立ち上げからこれまで 課題と今後 CADDi の Platform チームとは 前提として 前回紹介した組織体制 に大きな変更はありませんが、現在は、各開発チームにひとり以上 Embedded SRE Role をアサインしています。Role の定義は下記のようになっています。 Embedded SRE はシステムの信頼性を高めるために、インフラや非機能要件、可観測性の向上に責任を持つ役割です。 システムの信頼性を高めることで、開発チームのアウトプットの質を高めます。 - 開発チームが非機能要件や可観測性の向上に取り組むことに対して責任を持ちます - システムの信頼性向上に関する課題に率先して取り組みます - 課題解決が困難な場合はプラットフォームチームと共同で問題解決を行います - プラットフォームチームから提供される部門共通のポリシーやガイドラインを理解し、開発チーム内の理解を高めます ※ Embedded SRE が全ての実現に取り組むのではなく、プロダクトの運用責任を持つ 「開発チーム」 で取り組むことが前提です。 ※抽象化した図なのでチーム数やプロダクト数は実際とは異なります 開発チーム それぞれのプロダクトの開発運用に責任をもつ Embedded SRE が、チーム内のプロダクトの Reliability の責務を持つ Embedded SRE は、 Role のひとつであり、専任の SRE ではない Platform チーム 全社共通プロダクトの開発運用に責任をもつ 開発チームと協調して、プロダクトに閉じない課題を解決する CADDi の Platform チームの役割 現時点でのチームの役割は下記のようになっています。また、こちらには記載できませんが、社内向けにはより細かい責務分割ラインを定義して運用しています。 Platform チームの基本的な役割の考え方は、 チームトポロジー の定義と同じです Stream aligned team が自律的に仕事を届けられるようにするのが目的です Platform チームは、開発チームが管理しているプロダクトの Reliability の責務は持ちません Embeded SRE が、プロダクトごとの Reliability の責務を持ち、特定ドメインの運用課題は開発チーム内で解決します Platform チームは、 横断的な技術課題の解決に重点を置いています 課題を解決するためなら、手段は問いません インフラという領域にとらわれず、 既存機能開発や新規アプリケーション開発も必要なら実施します SRE も Software Engineer ですが、Platform チームでは課題解決のためにより深い実装力を求められます 具体的な How のひとつとして、 SRE プラクティスを組織横断で適用する場面は多々あります CADDi のミッションは「モノづくり産業のポテンシャルを解放する」ことです。製造業のバリューチェーンは長く、データだけでなく実際のモノを扱っています。課題を解決するためなら、クラウドインフラだけでなく IoT デバイスやオンプレミスを扱わないといけない場面も出てくるかもしれません。そのあたりも Web 完結型ではない事業をやっている CADDi の Platform エンジニアならではのおもしろさだと考えています。 CADDi の Platform チームの MVV チーム立ち上げ時点 では行動指針を定義していましたが、チームで話し合って決めた MVV はこのようになりました。 Mission 開発組織のポテンシャルを解放する Vision 開発チームが自律的にユーザーへの価値提供に集中できる状態 Value More proactive 不確実性を先回りして解決し、開発体験を最大化する Observability driven 観測可能にしておくことを大前提とし、観測結果にもとづいて施策決定と効果判定を繰り返す Leveraging standardization for further growth 仕組み化でレバレッジを効かせて、よりスケールさせる Learning and Unlearning 必要ならなんでも学ぶ 過去の成功体験、固定観念に縛られない 状況は常に変化するため、再現性があるか、今やることに価値があるかを見極める Have Backbone; Disagree and Commit 信念を持ち、正しいと考えていることを言い続ける 決まったことにはコミットする 立ち上げからこれまで サマリー 時系列でチームの変化の概要をまとめるとこんな感じです。 やってきたことの紹介 すべては書けないのでかなり端折っての紹介になりますが、雰囲気だけでも伝わればと幸いです。 2021/07~09 2 人で課題を洗い出し優先度を決めて進めていましたが、同時に大きな新プロダクトの立ち上げも始まり、なかなか大きなタスクに着手できず、とにかく手が足りないという状況でした。最初にチームの方向性をしっかり定義・認識合わせできていたことは今振り返ってもよかったと感じています。 チームとして 目的を整理して社内に共有 今後スケールしていくための言語化 優先順位を下げていた目前の課題に着手 各種申請フロー整備 共通で管理したほうがよいものを巻取り ArgoCD への CD 移行 新しいプロダクトでうまくいっていたので、既存プロダクトに横展開 コスト管理 GCP など 社内ドキュメントサーバー構築、標準化 チーム/プロダクト間で API 連携が増えてきていたため CADDi DRAWER の立ち上げ インフラ構築 ISMS 対応 2021/10~12 Tech 組織の大幅なスケールに備え、開発に必要なツールやクラウドインフラ・セキュリティのガイドラインを明文化しました。また、それまで性善説で運用されていた開発者の Google Cloud IAM を見直し、あるべき姿を設計・移行しました。 標準化、ドキュメント整備 開発に使うツールや運用資料を明文化、Indexing Google Cloud の運用ガイドライン整備 開発者の Google Cloud IAM 移行 セキュリティガイドラインの定義 インシデント対応フロー整備 その他 社内向けプロダクト用 GKE のキャパシティ見直し Redash を SaaS から Self-Host へ移行 認可基盤の管理画面構築 2022/01~03 Tech 組織や新規プロダクトがさらに増え、積み上げてきた Platform の運用コストも上がってきていたので、再度チーム内で取り組み方について話し合い明文化しました。また、AI Lab 発案で Embedded SRE Role の運用が始まりました。定例会では、Platform チームから Embedded SRE にナレッジシェアをしたり、Embedded SRE に Platform チームの施策をレビューしてもらったり、Embedded SRE から Platform チームに開発チーム内の課題共有をすることで、より全体最適化を進めやすくなりました。 標準化、ドキュメント整備 すべての Terraform Version を Upgrade し、 tflint / tfsec / tfcmt を導入 Terraform や Provider の最新に追従できるようになり、定期的に最新化を実施 Production Readiness Checklist を整備 Embedded SRE Role の誕生 各開発チームにひとり以上アサイン 隔週で Embedded SRE 定例を実施し、施策や課題の共有 Platform チーム内運用のルール化 Platform チームへの相談依頼を日次当番化 新規プロダクトが多く、レビュー・壁打ちが多かったため 相談依頼によるコンテクストスイッチでベロシティが下がっていた 通常タスクと運用タスクの割合を 8:2 とすることを明文化 運用タスクが劣後しがちになっていた CADDi DRAWER 一部機能の設計・実装・運用整備 2022/04~06 人数が 6 人に増えチーム運営が安定してきたので、これまでよりも大きな技術的投資ができるようになりました。また、メンバーの多様性が増したことで、技術的課題を解くための How もさらに広がってきました。 技術的投資 社外向けプロダクト用 GKE への ServiceMesh/OpenTelemetry 導入 Sentry の運用設計と展開 プロダクトの安定運用のための土台作り SLI/SLO の導入 監視基盤のベストプラクティス明文化と啓蒙 Log Format, Tracing, バックアップ方式の標準化 2022/07~10 引き続き技術的投資や標準化を推進しつつ、中期的に今後どうなっていくべきかを自分たちで定義しました。 チーム 中期チーム構成や施策の検討 技術的投資 新認証認可基盤の設計 社内向けプロダクト用 GKE への ServiceMesh/OpenTelemetry 導入 標準化、自動化 全 SaaS 開発者アカウントの IaC 化・自動化 Google Cloud サービスアカウントの IAM Policy 標準化 Google Cloud への一時的な権限付与のためのアプリケーション構築 常にやっていること 構築した(提供中の)プラットフォームの運用 ドキュメント 標準化、運用ガイドラインなど ミドルウェアやツールのアップグレード(Renovate) ArgoCD, ESO, Datadog, ASM など 提供しているインフラやアプリケーション、SaaS、共通モジュール チーム内の働き方の明文化やそのブラッシュアップ Working Agreement オンボーディング資料 運用系の定期イベント CADDi DRAWER の支援 非機能要件や技術的な支援 将来的には開発チーム内で完結する想定だが、事業優先度が高いかつ人が足りていないため 課題と今後 現状の課題 現状このような課題があります。 Platform チームでカバーしている範囲が広いため、認知負荷がそれなりに高い 求められるスキルの範囲もそれなりに広い 強制力がない標準化施策に関しては、開発チームへの導入速度は緩やか Embedded SRE Role を持つ人も開発チームメンバーも基本的に協力的なので徐々には浸透している 専任で取り組む人はいないため、スピードは出ない これらを解決するため、また、中期チーム構成の過程として、次の四半期から Platform チームを分割し、Enabling と Platform の 2 つのチーム体制にする予定です。 Enabling チームは、 チームトポロジー の定義通り、特定のテクニカルドメインスペシャリストで構成され、複数の Stream aligned team の能力ギャップを埋めるのを助けます Enabling チームは、開発チームへの標準化施策の導入責務を持ち、その速度を加速させます 現在の Platform チームは、課題の解決に重点を置いているため、場合によっては開発チームへの標準化施策の導入サポートを行っていますが、それをスコープアウトします 分割後の Platform チームは、開発チームへの標準化施策の導入サポートを Enabling チームに任せることで、高度化や技術的投資に集中します 3 年後の妄想 現在、開発組織は約 70 名に対して、Platform チームは 6 名です。3 年後、開発組織が今の何倍になっていたとしても Platform チームの人数比率的には今と同じくらいが(個人的には)理想だと考えています。ただし、その過程ではもっと人を増やして、よりレバレッジの効く施策を積み上げていくことが前提です。そして最終的には、Platform チームから開発チームへ Embedded SRE や開発メンバーとして異動して、また必要最低限の人数でさらに成長して増えたプロダクト群を Platform で支えていくことができればよいと思います。同じことばかりやっていると飽きてしまうし、成長機会も減ってしまうので、本人の Will が一致すれば、再び Platform チームにも戻れたり、全く違う目的をもった新しいチームを作ったりできるとより楽しそうです。 3 年後の Platform チームは、Platform グループになり、仮に 30 名だったとしましょう。グループの中には、複数のチームが存在し、チームのミッションもそれぞれ異なります。役割もチームごとに分割され、認知負荷が減り、特定分野に特化したスペシャリストも活躍しやすくなっています。モノを扱いやすくするための IoT デバイスや物流拠点で利用するオンプレミスのシステムが必要になっていて、それを設計したり維持管理したりするチームもあるかもしれません。グループ全体では、より安定的で強固な Platform を提供しながら、さらに大きな課題や技術投資にチャレンジします。 また、現在の CADDi では組織拡大だけでなくグローバル化も進んでいます。開発組織向けの資料は、すでに英語を併記し始めていますが、まだ Platform チームに英語話者はいません。3 年以内なのか以降なのかはわかりませんが、Platform チームも英語話者を受け入れていくことになるでしょう。英語話者だけのチームやタイムゾーンの異なる国外のチームとも一緒に働くことになり、コミュニケーションの課題が全くなくなるということはないかもしれませんが、対チームや対開発組織でのプロセスは型化され、大きな問題はなく業務が遂行できるようになっていることでしょう。 この妄想を現実に変えるために、例えばこんな人に来てほしいです。 会社のグローバル化とともに、徐々に英語を習得しながら働きたい人 プロダクトの Platform を支え、技術的課題を解決し続けることで大きなミッションを達成したい人 人や組織面を中心にグループを支える Engineering Manager 一緒に未来を描ける Technical Product Manager ソフトウェア開発ライフサイクルを改善し続けて、グループのアウトカムを最大化できる Scrum Master より強固な Platform を設計したい Security Engineer より安定的な Platform を設計したい Site Reliability Engineer 標準化や自動化や高度化が好きな Cloud Platform Engineer 手段にこだわらないが軸がぶれない Software Architect 認証認可基盤などの組織横断プロダクトを開発したい Software Engineer おわりに 最後まで読んでいただきありがとうございました。少しでも CADDi の Platform チームが、どんなチームで何をやってきたか伝わっていたら嬉しいです。もし興味を持っていただけたら、気軽にお声がけいただければと思います! CADDiでは、現在積極的に採用を行っています。 まずはカジュアルにお話を聞いてみたい!という方は、ぜひ こちら より面談をお申し込みください。 また、Tech Blogや勉強会等のイベントについてはSNSで随時発信しておりますので、 Twitter のフォローや、 connpass のメンバー登録をぜひよろしくお願いします。
OpenSearch で実現する画像検索とテスト追加で目指す安定運用 こんにちは、CADDi AI Lab MLEの志水です。 8/19に10X,M3の両社と検索運用の勉強会 #Search_C10Xm3 を開催いたしました。 おかげさまで当日までの 登録者が254名 、当日の参加者は最大137名までお越しいただき大盛況でした。 勉強会中何度か紹介された ペンギン本 が Amazonで売り切れる ような反響もあったようです。 その中から、 キャディ発表分 を抜粋したイベントレポートをいたします! - 10Xさんの発表資料はこちらから - M3さんの発表資料はこちらから AI Labでは図面管理 SaaS CADDi DRAWER の検索サービスを開発/運用してきており、その経験から OpenSearch で実現する画像検索とテスト追加で目指す安定運用についてお話ししました。 目次 opensearch で knn を用いた画像検索 前半では、 OpenSearch でkNNを用いた画像検索がなぜ必要だったかと、それを実現した方法について説明していきます。 CADDi DRAWER CADDi DRAWER は今年7月にリリースした図面管理 SaaS です。 AI Labではこの中で類似画面の検索に用いられている 検索エンジン を開発しています。 サービスの詳細や開発に至った経緯については リリース時のブログ や Tech Lead や PDM によるイベントレポート をご覧ください。 図面って何? 図面は製造業において作る対象のレシピのようなものです。 図面内の情報の読み取りは、 アルゴリズム 、 OCR 、オペレーションの合わせ技になっています。 特に右下の枠内のテーブル情報には、図面のkeyとなる図番や部品名などが入っており、読み取ることがかなり重要です。 図面にまつわる pain なぜあえてこれを管理する SaaS を作る必要があるか、そこには図面特有のペインや辛さがあるからです。 業務都合上、縮尺違いや勝手違い(左右反転)した図面を扱うことは多く、「この形に似た図面を探したい」と思う場面が多くあります。そのため図面内の情報の検索や形状をベースにした類似が求められますが、ここにもう1つ難しさがあります。 図面は元々は設計の部署でCADなどで書かれますが、部署を跨いだり(設計→調達)会社を跨いだり(発注会社→加工会社) するときにPDFに変換されます。なので多くの情報をPDFから読み取ることも必要になります。 検索システムの アーキテクチャ 一般的な検索システム ペンギン本 の説明が的をえていてとてもおすすめの本です。 こちらの17ページから引用したもので、一般的な検索システムの構成としてUIと 検索エンジン の間にあるクエリプロセッサが検索のやりとりを行い、ドキュメントDBとインデクサが 検索エンジン へのデータの挿入を行っていることがわかります。 DRAWER の検索システム (Kaleido) DRAWERの検索システム (Kaleido) も同じような構成になっています。 UIがクエリを発行し、 Python で書かれたクエリ プリプロセッサ が OpenSearch が理解できる形にクエリを変換し、検索します。 検索結果を Python のクエリポストプロセッサがフロントエンドが理解できる形に変換して返します。 データのインデックスに関しては、図面に紐づく メタデータ 等は直接 OpenSearch にインデックスされます。 画像特徴のベクトルは、PyTorchのモデルを通して計算され、 OpenSearch にインデキシングされます。 DRAWER チームと AI Lab チームの 責任分界点 DRAWERチーム フロントエンド、バックエンド、インフラ含めた全体を開発/運用している 検索システムの中ではUI、データ、インデクサの部分 AI Lab AI LabはKaleidoという水色の部分を開発/運用している Query Processor OpenSearch PyTorchのモデル Kaleidoとの接点は API コールで行われる /search : 検索 /preprocess : 図面の素材等のデータのインデキシング /feature_extract_and_save : Deepモデルへの推論と、 OpenSearch へのインデクシング OpenSearch ではこの 検索エンジン の OpenSearch について少し説明していきます。 OpenSearch とは OpenSearch はElasticsearchからforkしたプロジェクトです。 詳細はリンク先に譲りますが、ライセンス戦略等に発した AWS とElastic社の仲違いから発生したものです。 https://www.elastic.co/blog/why-license-change-aws So why the change? AWS and Amazon Elasticsearch Service. They have been doing things that we think are just NOT OK since 2015 and it has only gotten worse. If we don’t stand up to them now, as a successful company and leader in the market, who will? https://aws.amazon.com/OpenSearch-service/the-elk-stack/what-is-OpenSearch/ On January 21, 2021, Elastic NV announced that they would change their software licensing strategy not release new versions of Elasticsearch and Kibana under the permissive Apache License, Version 2.0 (ALv2). https://aws.amazon.com/blogs/opensource/stepping-up-for-a-truly-open-source-elasticsearch/ Choosing to fork a project is not a decision to be taken lightly, but can be the right path forward when the needs of a community diverge—as they have here. https://www.elastic.co/blog/elastic-and-amazon-reach-agreement-on-trademark-infringement-lawsuit We’re pleased to share that Elastic and Amazon have resolved the trademark infringement lawsuit related to the term Elasticsearch. Tipsとして、 OpenSearch はElasticsearchに比べて歴史が浅いため検索でヒットしずらいです. エラーメッセージ等もElasticsearchで検索し、その結果をそのまま OpenSearch に適用することが多いです。 なぜ OpenSearch を採用したか 技術選定時点(2021年)ではkNN+filterを実現できる、ANNが使える、ということから OpenSearch を採用しました。 ANNの アルゴリズム の1つである HNSWの解説動画 は面白くておすすめです 現在ではどちらもElasticsearchに導入されているため、今から画像検索を始める場合はElasticsearchでも十分可能です! OpenSearch の運用 当初はmanagedを採用せず、現在は k8s 上で運用していますが、少しずつつらみも出てきているため、ElasticCloud on GCP をはじめとしてmanagedの採用も検討しています。 モデル 画像検索が可能なのは、 Deep Learning モデルが優秀な特徴量を作成してくれるからです。 モデルの詳細については 竹原さんの発表 をご参照ください 1280次元の特徴量を使っていますが、現実的な時間内で検索が可能です テスト追加で目指す安定運用 後半では開発した検索システムを安定して運用するために、どのようにテストを追加したかや、監視の具体について説明していきます。 全体像 CI/CD内でカバーするunit test、Integration Testの話と、アプリケーションがデプロイされた後のUIテストや監視の全てが大事な要素です。 unit test Functionやモジュールごとのテストのをするのがunit testになります。 類似検索パッケージの中ではcode coverageは100%近くあり、PRのたびにテストがあるかを互いにチェックしています。 工夫ポイント クエリプロセッサは複雑なので、複数パターンの入力→複数パターンの出力をテストしています Deep推論部分は変数が多いため、Configをクラス化するなど型づけを厳密にしています endpointはOpenSearchClientを使っているので、テストではclientをmockしています # PostProcessor コードの一部 class PostProcessor: """map the opensearch results to format in kvs (snake_case to camelCase) """ @staticmethod @postprocessor_exception def search_postprocess(hits) -> list[SearchOut]: def _reformat(data) -> SearchOut: return SearchOut( id=data["_source"]["source_id"], score=data["_score"], index=data["_index"], foo=data["_source"]["foo"], bar=data["_source"]["bar"], ) return [_reformat(x) for x in hits] # PostProcessorのテストコードの一部 class TestPostprocessor(unittest.TestCase): def test_search_postprocess(self): hits = [ { "_score": 0.1, "_index": "index_1", "_source": { "source_id": "id_1", "foo": "foo_1", "bar": "bar_1", }, } ] expected = [ SearchOut( id="id_1", score=0.1, index="index_1", foo="foo_1", bar="bar_1", ) ] actual = PostProcessor.search_postprocess(hits) self.assertEqual(expected, actual) Integration test unit testだけではカバーしきれない部分はIntegration testを書いていきます。(実際に OpenSearch と通信する部分など) CI上ではdocker-compose.ymlを用いて検索の API と OpenSearch を起動しています。 # docker-compose.yml services: opensearch: build: context: ./ dockerfile: ./docker/OpensearchForLocalDockerfile ports: - ... api: build: context: ./ dockerfile: ./docker/ApiDockerfile ports: - ... batch: build: context: ./ dockerfile: ./docker/BatchDockerfile Makefile にテストコマンドを用意することで、ローカルでもCI上でもテストをキックしやすくしています。 # Makefile ## run test search test-search: docker compose -f ../docker-compose.yml exec batch poetry run python -m src.pipeline.test_search_pipeline IO indexの作成→データ挿入→検索までをintegration testを書くことで、DRAWERという外部サービスに対してのcontractに なっています コード例 データ挿入 from itertools import cycle, islice def post_bulk_preprocess_endpoint(data_length=10, index="example-tenant"): """apiのbulk preprocess endpointに対しsampleデータが投入出来ることを確認する""" # create test data foos = list(islice(cycle(['foo_1', 'foo_2']), data_length)) bars = list(islice(cycle(['bar_1', 'bar_2', 'bar_3']), data_length)) data = [] for i in range(data_length): data.append( { "id": f"{index}-{i}", "index": index, "foo": foos[i], "bar": bars[i], } ) response = requests.post("http://api:80/preprocess/bulk", data=json.dumps(data)) response = response.json() assert response["message"] == "success bulk index", f"{response}" assert response["indexedDataCount"] == data_length, f"{response}" assert response["data"]["bulk_count"] == 1, f"{response}" requests.get("http://opensearch:9200/_refresh") logger.info("bulk preprocess endpoint [OK]") 検索して結果を確認する def _post_can_find(source_id: str, index: str, polling_time: int, sleep_time: int): # filter is successful in finding the correct item data = { "source_id": source_id, "index": index, "limit": 2, "filters": [{"field": "foo", "boolean_operator": "AND", "keywords": ["1"]}], } response = post_with_timeout( "http://api:80/search", data=json.dumps(data), timeout=polling_time, sleep_time=sleep_time ).json() assert type(response) == list, f"{response}" assert len(response) > 0 actual = {res["foo"] for res in response} expected = {"foo_1"} assert actual == expected, f"{expected=}, {actual=}" snapshot 上記のテストだけでは意図せず壊すことがあります。暫定的な対応をした後に、今後同じようなミスを起こさないように対策をします。 この例では、 dict の要素に対して [] でアクセスしたため、データ マイグレーション をしていなかった環境では検索が落ちました. 暫定対応ののち、再発防止のためにSnapshotをとっておき、Snapshotで検索ができることをCIで保証しました。 コード例 snapshotを復元する def restore_opensearch_snapshot(): """opensearchのsnapshotからデータを復元する""" # add settings headers = {"content-type": "application/json"} data = {"type": "fs", "settings": {"location": "/snapshot"}} requests.put("http://opensearch:9200/_snapshot/snapshot", data=json.dumps(data), headers=headers) # restore index "snapshot", version "1" response = requests.post("http://opensearch:9200/_snapshot/snapshot/1/_restore") logger.info(f"restore snapshot: {response.json()}") response = requests.get("http://opensearch:9200/snapshot-tenant/_refresh") logger.info(f"refresh: {response.json()}") logger.info("restore opensearch snapshot [OK]") 検索が動くことを保証する def post_search_with_snapshot(polling_time: int, sleep_time: int): """ index mappingsが変更された場合等、text diffでは確認出来ない破壊的変更のための回帰テスト """ index = "snapshot-tenant" source_id = "snapshot-0" data = {"source_id": source_id, "index": index, "limit": 1} response = post_with_timeout( "http://api:80/search", data=json.dumps(data), timeout=polling_time, sleep_time=sleep_time ).json() assert type(response) == list, f"{response}" assert len(response) == 1, f"{response}" assert response[0]["source_id"] == "snapshot-1", f"{response}" assert response[0]["score"] > 0.0, f"{response}" logger.info(f"search snapshot tenant={tenant}, item_id={item_id} [OK]") checkerboard features - UIテストでのバグ発生から、テストを追加して再発を防止した例です - kNN search + filterのあと、kNNの結果が反映されていないとUIテストからフィードバックがありました - 事象が判明した時点でPdMから全体に周知されています - 正しくは score=replace をクエリに追加する必要がありました - 直した後、ポストモーテムで全員に根本原因と対策が記録、周知されます - テストデータのkNNベクトルを乱数ではなく、検索の結果に意味があるものに変更してテスト可能にしました コード例 checkerboard(互い違い)の特徴量を作成する def checkerboard_array(i: int, length: int = 1280) -> list[float]: """A checkerboard feature to test similarities e.g., 0: 0, 1, 0, 1, ... 1: 1, 0, 1, 0, ... 2: 0, 1, 0, 1, ... Args: i: the index of the data length: length of the feature Returns: a list of `length` floats """ np.random.seed(i) a = np.random.normal(loc=0.9, size=length) a = np.clip(a, 0.5, 1) b = np.zeros(length) # [num, 0, num, ...] for even, [0, num, 0, ...] for odd if i % 2 == 0: ab = (a, b) else: ab = (b, a) c = [] for element in zip(*ab): c.extend(element) c = c[0:length] return c 奇数番目のデータは奇数番目のデータが類似することを確認し( odd_comes_first ),かつfilterが機能していることを確認する def _find_one_on_amount(data_length: int, index: str, polling_time: int, sleep_time: int): data = { "source_id": f"{index}-3", "index": index, "limit": data_length, "filters": [{"field": "bar", "value": "3"}], } response = post_with_timeout( "http://api:80/search", data=json.dumps(data), timeout=polling_time, sleep_time=sleep_time ).json() assert type(response) == list, f"{type(response)=}" assert len(response) > 0, f"{len(response)=}" assert {r['bar'] for r in response} == {"bar_3"} # check that even comes first (because of checkerboard features) assert odd_comes_first(response) def odd_comes_first(response) -> bool: ids = [int(r["id"].split("-")[-1]) for r in response] # [2, 4, 6, 3, 1, ...] still_true = True for num in ids: if num % 2 == 1: if still_true: continue else: return False else: still_true = False continue return True pruning tests 基本的にテストはどんどん増えていきます 不要なテストを削除したり、依存するものを解消したりすることも時に必要です(diffが大きくなりますが! ) 適切なものに絞ることで開発者経験をよく保ちます 監視 上記のようにテストを追加していても、UIテストでバグが発覚したりアプリケーション上ではエラーが起きたりします。 監視をして、適切に対応したりチーム全体にアラートしたりすることが重要です。 KaleidoではDatadogにログをためて、 ダッシュ ボードでレスポンスタイムやリク エス ト数を見ています. またエラーはSentryで記録していて、監視用のSlackチャネル( #tech-kaleido-alerts )に通知来るようにしています。 エラーが上がったら開発しているチームで障害対応をしています。 精度問題 精度問題は必ずどこかで出てきます 仕組みで対応するために、改善要望リストを作成し、 定量 評価デー タセット を用意しています Kaleidoでは 定量 評価デー タセット をPdM2名MLE2名で作成しています 運用の移譲 - DRAWERもリリースし顧客からの改善要望が増えるにつれ、AI LabではなくDRAWERチームで運用できる体制に移行しようとしています - 運用の移譲のために、技術の整理やロードマップの整理をしています もっと他のお話が聞きたい方へ 9/15にAI Labの中村さんがMLOpsのお話をしてくれます! (Datadogの運用やSLOの設定をしてくれたのは中村さんでした。) CADDi Tech Chat 〜MLOpsエンジニアの働き方紹介〜 中村さんは最近はMLモデルをデプロイするための基盤を作ってくれていたり、MLモデルのデモの基盤を作ってくれていたりと、MLエンジニアがより成果を出しやすくするための取り組みを色々やってくれています。面白い話になると思いますのでみなさんぜひ! We are hiring! キャディでは一緒に働いてくれる 仲間を募集 しています。 最近のAI Labでは2D/3Dのデータに対するモデルや、 API 基盤を作ったり、デモ基盤を作ったり、 アノテーション 基盤を作ったり、モデルだけでなくそれらの周辺技術を自分達で整備していく仕事もしています。 今後もDRAWERの進化のための機能開発や、より広く製造業全体の進化のための製造拠点のデータを活用したプロダクトなどを作っていきたいと思っています。 AI Labやキャディでの仕事に少しでも興味をもたれた場合は、ぜひ面接や カジュアル面談 を申し込んでいただければ幸いです。 また、 Tech Blogや勉強会等のイベントについては SNS で随時発信しておりますので、 Twitter のフォローや connpass のメンバー登録をぜひよろしくお願いします。
はじめに 河合セッション AIプロジェクトでありがちな技術的負債負債 CADDi AI Labにおける技術的負債 CADDi AI Labにおける対策 パネルディスカッション おわりに はじめに こんにちは、最近オーディオミキサーを買い換えたら「リモート MTG の音質が良くなった」と褒められ MTG したがりなAI Labの河合です。 先日、YUMEMI様が主催するSELECK LIVE!にて、「スタートアップと技術的負債」というイベントに登壇させて頂きました。 登壇者は、株式会社カミナシCTOのトリさん( @toricls )、atama plus株式会社の深澤さん( @qluto )。 モデレータは、株式会社ゆめみ取締役の工藤元気さん( @Genki910biz )でした。 非常に豪華なメンバーでエンジニアリングにおける負債と事業についてディスカッションする事ができました。ありがたい限りです。 本記事は、イベントの中でCADDiから発表した内容を補足しつつ、パネルディスカッションを振り返る形で、イベントレポートとさせて頂ければと思います。 イベントURL: https://yumemi.connpass.com/event/255925/ 河合セッション 他2人の登壇者のテーマと少し違いを出すため、私のセッションでは主に「 機械学習 」をテーマにした技術的負債のお話をしました。 AIプロジェクトでありがちな技術的負債 そもそも、AI、 機械学習 の分野には「 機械学習 は高利のクレジットカードである」という言説があります。 これは、2014年のNIPS(現在のNeuralIPS、 機械学習 業界における著名な学会)にて Google より発表された論文から来ています。 タイトルはそのまま『Machine Learning: The High-Interest Credit Card of Technical Debt(*1)』です。 2022年においても、 機械学習 や統計モデルの技術活用はさらに進む裏側で激しい技術的負債化と戦っているのがAI業界であるとも言えます。 私のセッションの入りとして、私がよくあると感じる 機械学習 の負債についていくつか例をお話しました。 時間が経つと再現不可能になるモデル、データ、バージョン管理 機械学習 のライブラリは扱っている問題が複雑故にwrapにwrapを重ね難度化しています 機械学習 モデルの進捗も早く、画像認識分野などでは1年前のモデルは古いと言われたりします "厳密"にあらゆるバージョンを管理する事が本来求められますが、非常に早い業界スピードの中では劣後されがちです モデル推論、学習に関連するテストコードが書きにくい事をいいことに前処理や API 実装に書かれないテスト 複数の機能がwrapされた便利な 機械学習 ライブラリを使う事が多い反面、method単位のtestが書きにくくなっています そういった背景から、モデルの周辺コードのテストがサボられるケースがあります テストのないコードはそれだけで負債と言えます データドリフトが考慮されない、監視されないデータ分布 機械学習 プロジェクトの最も難しい点の1つに、入力データの分布への対応があります 分かりやすくTo Cで言えば、数ヶ月前とユーザの購買行動が変わってしまうシーン等がそれに当たります これらに対応するには本来分布を監視すべきなのですが、一定以上のシステムが必要故劣後されがちです 最初は精度が良いモデルでも半年後に負債となって襲いかかってくる事がよくあります PDCA を回さない プロダクトマネジメント から来る長期間のメンテナンス放置 機械学習 モデルの良い点として、データを集めて再学習する事で精度を上げられる点があります PdMが 機械学習 に疎い場合など、より目先の別の価値のために PDCA を放置される事があります 固く成果が出せる部分でもありますし、先のデータ分布の変化にも対応出来るので本来は進んでやるべきです トリさん、深澤さんが、より広い技術的負債のお話をしてくれると考え、AI文脈に絞っています。そうあって欲しくないのですが、結構身近なあるあるの課題です。 *1 At: SE4ML: Software Engineering for Machine Learning (NIPS 2014 Workshop) D. Sculley, Gary Holt, Daniel Golovin, Eugene Davydov, Todd Phillips, Dietmar Ebner, Vinay Chaudhary, Michael Young (Google, Inc) https://research.google/pubs/pub43146/ CADDi AI Labにおける技術的負債 CADDi AI Labは、昨年12月に設立し9ヶ月のチームですが、素早い開発速度を求める反面でいくつかの負債も抱えています。 これらは、「プロダクトの変化」や「データ分布の変化」に 機械学習 のシステムが追従出来なかった結果として起こった後発的な負債と言えます。 イベント中、トリさんが良い事を仰っていたのですが、「プロダクトに導入された技術は時間と共に必ず負債化」します。 機械学習 は効力も大きい分、その反動も多くなるので時間の経過を考慮した開発が必要になってくるなと私も痛感しています。 スライドにもある通り、技術的負債はまさに「負債」です。 ネガティブな印象はなく、金銭感覚があれば借りても良いし返却もできます。 本質的には、負債額の把握、返却への 皮算用 、未来に向けた負債対策、返却の体制作りなどの継続が大事になると認識しています。 CADDi AI Labにおける対策 CADDi AI Labでは、技術的な負債を借りるため、後々負債にならないよう、いくつかの施策を打っています。 小チーム制とチーム内PdM、Tech Lead PJが大きくなり人が増えれば複雑性が増し負債が発生する可能性は高まるのでチームは小さく保っています 小チーム内の事はメンバー同士の手が届く状態にしています チーム間での「負債を許容するか」「返却するか」はTech PdM / Tech Leadが判断しています 判断にはビジネス上の意義や他チームの状態など様々な要因が絡むためです ドキュメントの記載 機械学習 エンジニアもDesign Docや実験ドキュメント、Model Cardを書いています Design Docには以下の内容を必ず記載しています Goals Non-goals User Stories Success Metrics Biggest Hypothesis Functional Requirements Results 実験ドキュメントは GitHub 上で管理し、lexicoを使って社内から閲覧できる状態にしています lexicoは全社共通のドキュメントサーバとして利用しており Google アカウントによる認証が付いています 基本的にフリーフォーマットですが実験を他者に引き継げる状態を期待しています Model Cardはproductionにdeployされるモデルについて以下のような内容を必ず記載しています 概要 開発者 入力 出力 学習デー タセット 、方法 評価方法、評価結果 想定 ユースケース 性能に関する要素 これらはレビューにより負債化を防止したりもしています プロジェクトが進んでも方向がブレにくくなるだけでなく、チームとして技術的負債をそもそも生みにくくする体制、知識共有の役割を果たしています Working Agreement ローカル実験から本番まで「成果」となるラインをAgreementとして共有 Test群においてもチーム内で定義 必須の項目は「時間を掛けてやる」ことを明言しています イベント内では実際のWorking Agreementの スクリーンショット 等も共有してます。 「バージョン管理の行われていない」「Productionに出るにも関わらずTestのない」状態は成果と呼ばない事を明言しています。 また、それらを守るために「改修を2回劣後させたら報告すること」をルールとし、時間を作る事もWorking Agreementの取り組みの一環として実施しています。 イベント中には言っていませんでしたが、今もまさにWorking Agreement追従のためにスプリントを丸々使って改修を行っているメンバーが居ます。 パネルディスカッション イベント後半では、登壇者によるパネルディスカッションを実施しました。 ビジネス職であるCEO等に技術的負債の返却をどう説明するかという議論から始まり、事業貢献として金額に換算する話や良いエンジニアを採用し確保し続けるために必要であるという話をしました。 トリさんから「(立場上言いづらいと明言した上で)やっぱり汚い環境で仕事したくないじゃないですか」といった本音 トーク も出てきて、非常にエモーショナルな会になったなと思います。 私も基本的には「事業が成功するために技術的な負債を借りるべき」と考えていますが、それによって起こるやり辛い環境を解消したいというエンジニアの性のような部分も大事にすべきだといつも思っています。 また、「ドキュメント化は一種の筋力」という言葉も非常に刺さりました。 CADDiにおいてもまだまだ未熟ではありますが、未来の技術的負債を減らすためにベースを強くしていきたい所です。 既にある技術的負債の解消を進める取り組みとして、CADDi AI Labが実施しているSmall Winシートも共有しました。 技術的負債というものは、返しても目先は事業的には前に進んでいないため会社から評価されにくいものですが、技術者からは確実に大きく評価される事だと思いますし、その事を忘れずしっかり評価者に伝えるためにもお互い書くようにしています。 こういった仕事の評価をどう上げていくか、登壇者皆さん頭を回している事が良く分かり非常に良いディスカッションとなりました。 おわりに 本記事では、「スタートアップと技術的負債」というイベントのレポートとその補足を行いました。 ここまでお読みいただきありがとうございました。 CADDiでは、現在積極的に採用を行っています。 まずはカジュアルにお話を聞いてみたい!という方は、ぜひ こちら より面談をお申し込みください。 また、Tech Blogや勉強会等のイベントについては SNS で随時発信しておりますので、 Twitter のフォローや、 connpass のメンバー登録をぜひよろしくお願いします。
こんにちは。 Platformチームの前多( @kencharos )です。 2022年8月9日に開催した社内勉強会で、eBPFベースのネットワークミドルウェア、 Cilium(スリィアム) について発表しました。 この記事は発表の内容をベースに内容を補足したものです。 この記事を読むにあたり、Kubernetesをある程度触ったことがないと用語などが分かりづらいかもしれません。 サービスメッシュや Istio については、構成や導入目的について簡単に次節に記載しています。 より詳しく知りたい方は Istioのサービスメッシュの説明 を見てもらえると、Ciliumとの対比がわかりやすくなるでしょう。 またサービスメッシュを触ったことがある、あるいは運用している方であれば、Ciliumの魅力がより伝わると思います。 eBPFの知識は必要ありません。またeBPFについて同僚も社内勉強会で発表していますので、eBPFについて詳しく知りたい方は以下の記事を見てください。 eBPFに3日で入門した話 はじめに 私はこれまでに3度サービスメッシュをプロダクトに導入した経験があります。 サービスメッシュを導入する目的はプロダクトに応じて様々ですが、私の中では観測性(Observability)を基盤側で提供することを主な目的としています。 例えば、Kubernetesクラスタにサービスメッシュを導入すると、クラスタ内の各PodのHTTPレベルのメトリクスやアクセスログの取得をアプリケーションの改修なしで実現できます。 GCPのCloud RunやApp EngineといったPaaSでも自分でデプロイしたアプリケーションの外側で、アクセスログやメトリクスの取得ができ、ダッシュボードなどの可視化の仕組みが提供されています。 それと同様の仕組みをKubernetesでもサービスメッシュを用いて実現できます。 こういった機能は従来のサービスメッシュでは各アプリケーションにプロキシとして挿入されるサイドカーと、サイドカーの管理を行うコントロールプレーンといったコンポーネントで実現されるのが普通です。 代表的なものが Istio ですね。また、サイドカーとして挿入されるプロキシは Envoy が使われることが大半です。 実際私がこれまでに携わってた3度の経験でも、全て同様の構成でした。 サイドカーによるサービスメッシュは強力な方法ではあるものの、やはり結構複雑です。 透過的にサイドカーが挿入されるのはソースコードを変えることなく導入できて便利ですが、一方でその原理を理解しておかないと、なんとなく不安に思われたり通信障害の原因として真っ先に疑われがちだったりします。 また、サービスメッシュを導入しない場合と比較してパフォーマンスへの影響も気になるところですが、それはアプリケーションの性能要件と比較して妥協可能かどうかを判断すると良いでしょう。よほどクリティカルな状況でなければ問題になるほどの影響はないはずです。 といったようなサービスメッシュ導入あるあるをこなしていた時に、たまたま Cilium のことを見かけました。 eBPFがベースのネットワークライブラリであり、2022年8月時点の最新版の 1.12 では サイドカーフリーのサービスメッシュ機能がGAになったとの内容 を見て、Istioなどと比較してどこまでの機能が実現できるのかがとても気になりました。 なお、私は eBPFやネットワークレイヤーに関する知識はあまりありません。そのため知識不足により見当違いのことを書いている場合があるかもしれませんので、その場合はコメントいただけると幸いです。 Ciliumとは Cilium は主にIsovalent社によってメンテナンスされているOSSのネットワークライブラリです。 OSSとして無償利用するほか、同社による有償サポートもあります。 Ciliumの主な機能は、eBPFを使用してカーネル内で行われるパケット処理に観測性・セキュリティ・高度な通信制御を付与することです。 オンプレミスやDockerネットワークに対して動かすことも可能なようですが、主な対象はKubernetesだと思います。 KubernetesにCiliumを導入すると、透過的にPod間の通信制御やHTTPメトリクスの取得といったサービスメッシュで実現してきた機能の一部がCiliumで実現できます。 また、Ingress Controllerも1.12から実装されました。 eBPFでKubernetesのネットワーク課題を解決する Kubernetes ネットワークの課題とeBPFによる解消方法を簡単にまとめます。 KubernetesのPodネットワークの制御は主にiptableによって行われていますが、iptableはL3/L4に対するネットワーク制御のツールであり、多数のプロセスが動的・短期的にIP・Portを変えていくような Podネットワークでは大量のルール変更が必要で、そのオーバヘッドはPodが増えるほど無視できないものになります。 eBPFによりiptableを置換し、より効率的なパケット処理であったり、ユーザ空間からKubernetesのPodの情報を持ち込むことで、IP・PortしかないパケットにどのPod宛の通信であるかやL7レベルの情報を与えることができ、観測性の付与やセキュリティの向上を果たすことができます。 今回はCiliumを実際に試すことが目標だったので、あまり深掘りはできていません。より詳細な内容は 以下の資料を見ていただくのが良いでしょう。 CNI Benchmark: Understanding Cilium Network Performance Replacing iptables with eBPF in Kubernetes with Cilium Ciliumをインストールする さて実際にCiliumをインストールして動作を試していきます。 Ciliumのinstall手順 によれば、主要なパブリッククラウドのKubernetesサービスのほか、ローカルの環境でも試すことができます。 今回私がCiliumを試した環境は以下の通りです。 Cilium 1.12 Minikube 1.26.0 on M1 macbook pro Ciliumのコンポーネントの概要は 公式ドキュメント を参照してください。 主要なコンポーネントは次の通りです。 Cilium Agent: DaemonSet として動く Ciliumの基本機能を提供するコンポーネント CNI-plugin: Pod ネットワークの情報と連携する機能。通常KubernetesクラスタにはデフォルトのCNI pluginがあるためそれを無効化して インストールします Hubble: Ciliumネットワークを可視化し、Prometheusメトリクスを提供するツール これらのコンポーネントがインストーラによってクラスタに導入されます。 インストーラはCilium CLI を使用するかHelm chartを使用します。 実際には CLIからのインストールもHelm経由です。 今回はHubbleのHTTPメトリクスを サービスごとにグループ分けをしたかったので、以下のような helm オプションを設定して Cilium, Hubble をインストールしました。 # Prometheusメトリクスを有効化してインストール cilium install --helm-set prometheus.enabled=true --helm-set operator.prometheus.enabled=true # Hubble のメトリクスに pod, namespace をタグ付けするように設定してインストール cilium hubble enable --ui --helm-set hubble.metrics.enabled="{dns,drop:destinationContext=pod-short|namespace,tcp:destinationContext=pod-short|namespace,flow:destinationContext=pod-short|namespace,icmp:destinationContext=pod-short|namespace,http:destinationContext=pod-short|namespace}" サンプルアプリケーションの実行 今回は front, backend, backend2という 3つのnginxコンテナを用意してCilium上でアプリケーションを実行します。 proxy_passで後続のサービスを指定することで、 front->backend1->backend2 というように数珠繋ぎでpodを接続します。 # nginx.conf 抜粋 server { listen 80; # front -> backend location /api/ { proxy_pass http://backend:80; } # front->backend->backend2 location /api2/ { proxy_pass http://backend:80; } Ciliumはネットワークレイヤに対して動くツールなので、単にKubernetesにPodをデプロイするだけなら特にマニフェストを修正する必要はありません。 この時点で何度か pod にリクエストを送信し、可視化コンポーネントの hubble の UI を開くと次のような接続状態の可視化が得られます。 何もしなくても Pod間の接続状態が可視化ができてしまいました。 また、UIの下部にはパケットキャプチャのようなTCP接続のステータスが並んでいます。 どれか一つに注目してみると、パケットのIP・Portの他に宛先podやラベル情報が見て取れます。 これが eBPFによって持ち込まれたPodのContextで、これらの情報をパケット処理で扱えるようになります。 L7 Protocol visibility を設定する サイドカーがなくてもPod間接続が可視化できるだけでも私としてはだいぶ驚いたのですが、どうせならもっとHubbleで可視化できる情報を増やしたいところです。 eBPFでL7 Protocolを動的に判定するような高度な処理ができるわけではありませんが、Kubernetesのマニフェストでヒントを与えることができます。 L7 Protocol Visibilityのドキュメント によれば、PodのアノテーションやCiliumNetworkPolicyといったリソースでPodの通信プロトコルを明示することができます。 ただし現時点では、HTTP,DNS,Kafka(beta)のみが指定可能です。 まずは、各Podのmanifestに次のように io.cilium.proxy-visibility アノテーションを設定してみます。 通信の入出力のポートで、TCP/UDPおよびL7プロトコル名を設定します。 apiVersion: apps/v1 kind: Deployment metadata: name: front spec: replicas: 1 selector: matchLabels: app: front template: metadata: labels: app: front annotations: "io.cilium.proxy-visibility": "<Egress/53/UDP/DNS>,<Egress/80/TCP/HTTP>,<Ingress/80/TCP/HTTP>" これらのマニフェストを適用して再びHubble UIを表示すると、可視化される内容にHTTPのURLなどの情報が付加されていることがわかります。素晴らしい。 CiliumNetworkPolicy 続いて、CiliumNetworkPolicyリソースを設定してみます。 例えば次の例は front->backend の通信でPOSTメソッドしか受け付けないという定義です。 apiVersion: "cilium.io/v2" kind: CiliumNetworkPolicy metadata: name: "front2backend" spec: description: "Only POST" endpointSelector: matchLabels: app: backend ingress: - fromEndpoints: - matchLabels: app: front toPorts: - ports: - port: "80" protocol: TCP rules: http: - method: POST このリソースを適用すると今までのGETリクエストは通らなくなります。 % curl -i http://localhost:8083/api2/hello HTTP/1.1 403 Forbidden Server: nginx/1.23.1 Date: Mon, 29 Aug 2022 10:38:07 GMT Content-Type: text/plain Content-Length: 15 Connection: keep-alive x-envoy-upstream-service-time: 0 Access denied Hubble UIでもパケットのドロップが記録されます。 メトリクスを取得する HubbleにはPrometheus用のメトリクスを提供する機能もあります。 こちら にある通り、メトリクスのタグにはPodの情報を付与できます。 ただし前述した通り、この内容はインストール時に明示的に設定する必要がありました。 コンテキストオプションを指定した場合、次のようにpod, http ステータスごとのメトリクスを得ることができます。 # HELP hubble_http_responses_total Count of HTTP responses # TYPE hubble_http_responses_total counter hubble_http_responses_total{destination="default/backend",method="GET",status="200"} 13 hubble_http_responses_total{destination="default/backend",method="GET",status="500"} 12 hubble_http_responses_total{destination="default/front",method="GET",status="200"} 13 hubble_http_responses_total{destination="default/front",method="GET",status="500"} 7 個人的にはサービスメッシュ導入の主目的であるメトリクスの取得が、これだけで実現できてしまって驚きました。 弊社ではモニタリングでDatadogを使用していて、CiliumとのIntegrationもありました。 Monitor Cilium-managed infrastructure with Datadog Datadog Cilium Integration 現時点ではCilium AgentのメトリクスのみのようでHubbleのメトリクスはまだサポートされていないようですが、将来に期待して良いのではと思っています。 sidecar free service mesh Cilium 1.12の目玉であるサービスメッシュの機能については、 Cilium 1.12のリリース記事 や サービスメッシュのブログ に内容がまとまっています。 Ciliumはネットワークコンポーネントなので、Istioのようなサイドカーベースのサービスメッシュと組み合わせて動かすこともできます。 1.12 ではそれに加えて、Ingress ControllerおよびCilium Agent と協調して動いているEnvoyへ、eBPFだけではできないネットワーク制御を適用する方法を、sidecar free のサービスメッシュというふうに表現しています。 Cilium AgentはサブプロセスとしてEnvoyもノード単位で動かしています。 eBPF (IngressController, TLS終端, L7 load balancing, リトライなど)をEnvoyに移譲するようになっていて、Envoyへの設定を適用するCRDが追加されています。 IstioなどではPodごとにサイドカーとしてEnvoyが必要ですが、 Ciliumの場合ノード単位で一つEnvoyがあれば良いので通信パフォーマンスが大幅に向上すると サービスメッシュのブログ には書かれています。 また、 ロードマップ でも今後さらなる機能追加が予定されています。 では、実際にいくつかのサービスメッシュのサンプルを試してみます。 サービスメッシュのサンプル1-サービス振り分け 最初は複数サービスを割合に応じて接続先を振り分けるというものです。 これまでのサンプルプリケーションにもう一つ、 backend3 というPodを追加し、次のような CiliumEnvoyConfig リソースを適用します。 apiVersion: cilium.io/v2 kind: CiliumEnvoyConfig metadata: name: envoy-backend-listener spec: services: # backend2,および backend3への接続がまとめられる。 - name: backend2 namespace: default - name: backend3 namespace: default resources: - "@type": type.googleapis.com/envoy.config.listener.v3.Listener name: envoy-backend-listener filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: envoy-backend-listener rds: route_config_name: lb_route http_filters: - name: envoy.filters.http.router - "@type": type.googleapis.com/envoy.config.route.v3.RouteConfiguration name: lb_route virtual_hosts: - name: "lb_route" domains: [ "*" ] routes: - match: prefix: "/" route: #接続を 50:50 で振り分ける weighted_clusters: clusters: - name: "default/backend2" weight: 50 - name: "default/backend3" weight: 50 retry_policy: retry_on: 5xx num_retries: 3 per_try_timeout: 1s - "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster name: "default/backend2" connect_timeout: 5s lb_policy: ROUND_ROBIN type: EDS outlier_detection: split_external_local_origin_errors: true consecutive_local_origin_failure: 2 - "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster name: "default/backend3" connect_timeout: 3s lb_policy: ROUND_ROBIN type: EDS outlier_detection: split_external_local_origin_errors: true consecutive_local_origin_failure: 2 CiliumEnvoyConfigリソースはCilium Agentの Envoyに対して設定をするためのリソースで、ここでは backend2, backend3への接続に対してEnvoy Listenerを起動して接続をプロキシするようになります。 その後、weighted_clustersの設定で接続を割合で振り分けるようになります。 backend Podのnginxにはproxy_passとして backend2 しか記述がないにもかかわらず、暗黙的に接続がenvoy経由になり、50:50で backend3にも繋がるようになります。 % curl localhost:8083/api2/hello {"message":"fom-backend2"} % curl localhost:8083/api2/hello {"message":"fom-backend3"} % curl localhost:8083/api2/hello {"message":"fom-backend3"} % curl localhost:8083/api2/hello {"message":"fom-backend2"} サービスメッシュのサンプル2-外部認証リクエスト(ExtAuthz) 前述のCiliumEnvoyConfigリソースのサンプルを見て、わかる人にはわかると思いますがリソースの記述内容は、ほとんどEnvoyのListenerとClusterの設定そのままです。 とはいえ、 CiliumEnvoyConfig の説明 をみると、 記載が可能なフィルターの内容はある程度限定されています。 Istioでは可能なアクセスログ出力,JWT検証, Fault Injectionなどは現時点では設定できないようです。 ですが、外部リクエスト認証を行うための ext_authz フィルターは設定可能なようなので、これを使用して 特定のリクエストヘッダがなければアクセスを拒否するように設定してみます。 外部リクエスト認証を行うサービスとして、 Istioの認証サンプル を使用します。 これは リクエストヘッダに x-ext-authz: allow という値があるかどうかをチェックするものです。 このサンプルをext-authz Podとして デプロイし、次のCiliumEnvoyConfigリソース を適用します。 apiVersion: cilium.io/v2 kind: CiliumEnvoyConfig metadata: name: envoy-front-authz-listener spec: #front podへの接続を envoyでプロキシする services: - name: front namespace: default # 接続をproxyしないが、envoyから接続するサービスは backendServiceとして記述する backendServices: - name: ext-authz namespace: default number: ["8000", "9000"] resources: - "@type": type.googleapis.com/envoy.config.listener.v3.Listener name: envoy-front-authz-listener filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: envoy-front-authz-listener rds: route_config_name: front_lb_route http_filters: # ext_authzフィルターを追加。 全てのリクエストを ext_authz podへ転送して認証チェック - name: envoy.filters.http.ext_authz typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz stat_prefix: ext_authz transport_api_version: V3 http_service: server_uri: uri: "http://ext-authz.default.svc.cluster.local:8000" cluster: "default/ext-authz:8000" timeout: 1s path_prefix: / authorization_request: allowed_headers: patterns: - contains: authz failure_mode_allow: false include_peer_certificate: true - name: envoy.filters.http.router - "@type": type.googleapis.com/envoy.config.route.v3.RouteConfiguration name: front_lb_route virtual_hosts: - name: "front_lb_route" domains: [ "*" ] routes: - match: prefix: "/" route: cluster: "default/front" - "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster name: "default/front" connect_timeout: 5s lb_policy: ROUND_ROBIN type: EDS outlier_detection: split_external_local_origin_errors: true consecutive_local_origin_failure: 2 - "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster name: "default/ext-authz:8000" connect_timeout: 3s lb_policy: ROUND_ROBIN type: EDS outlier_detection: split_external_local_origin_errors: true consecutive_local_origin_failure: 2 - "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster name: "default/ext-authz:9000" connect_timeout: 3s lb_policy: ROUND_ROBIN type: EDS typed_extension_protocol_options: envoy.extensions.upstreams.http.v3.HttpProtocolOptions: "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions explicit_http_config: http2_protocol_options: {} outlier_detection: split_external_local_origin_errors: true consecutive_local_origin_failure: 2 front Podへの接続について、ext_authzフィルタを適用して認証チェックを追加します。 ここでのポイントは Envoyでプロキシはしないが、envoyから接続するバックエンドのサービスを backendServices に記載することです。 現時点ではbackendServicesについてはドキュメント化されておらず、ソースを見てやっとその存在がわかりました。 次のように front Podへの通信はまず ext_authz Podへ流れ、そこでOKだと backendへ、NGだと 403ステータスコードがクライアントへ返ります。 リクエストヘッダなしのリクエストは拒否されるようになります。 # curl http://front/api/hello -i HTTP/1.1 403 Forbidden x-ext-authz-additional-header-override: x-ext-authz-check-received: GET front/api/hello, headers: map[Content-Length:[0] X-Envoy-Expected-Rq-Timeout-Ms:[3600000] X-Envoy-Internal:[true] X-Forwarded-For:[172.17.0.1] X-Forwarded-Proto:[http] X-Request-Id:[3200cb72-9786-49d6-a5b5-102907202116]], body: [] x-ext-authz-check-result: denied date: Mon, 29 Aug 2022 11:51:54 GMT content-length: 76 content-type: text/plain; charset=utf-8 x-envoy-upstream-service-time: 3 server: envoy denied by ext_authz for not found header `x-ext-authz: allow` in the request 認証が通るリクエストヘッダ付きのリクエストはOKになります。 # curl http://front/api/hello -i -H "x-ext-authz: allow" HTTP/1.1 200 OK server: envoy date: Mon, 29 Aug 2022 11:53:57 GMT content-type: text/plain content-length: 19 x-envoy-upstream-service-time: 2 {"message":"hello"} これで、sidecar free service meshの機能の一部を試すことができました。 まとめ Ciliumの機能をある程度試すことができました。 Istioなどサイドカー型のサービスメッシュはL4より上のレイヤでサイドカーによって通信をコントロールすることでサービスメッシュを構成するのに対し、 CiliumはeBPFによりセキュアなネットワークを導入することで結果としてシンプルなサービスメッシュを実現しているという印象を受けました。 Ciliumは他にも色々な機能があります。以下は興味はあるものの時間の都合上試せなかったものです。 OpenTelemetry Integration gRPCの複数Podの分散 Multi Cluster mTLS( 参考のブログ記事 ) 個人的な感想では、相当の魅力があるものの現時点ではプロダクトへの導入は以下の内容から、様子を見たいという感じです。 Podレベルのメトリクスがサイドカーなしで取得できるのは非常に良い、というよりもびっくりした。 Datadogなど、外部の監視サービスのサポート状況を注視したい。 Istioでパフォーマンス的に困っていない。 サービスメッシュの機能としてはIstioの方が揃っている アクセスログの取得は現時点でも使用しているが Ciliumにはない Istioの通信機能 と比較すると、FaultInjectionやCircuit Breaker,JWT検証などは現時点ではCiliumでは使用できないか工夫が必要 CiliumEnvoyConfig リソースの書き方があまりにもEnvoyに寄りすぎていて、Envoyの知識が必須になっている とはいえ 2,3年後にはこの状況が変わっていてもおかしくないのではと期待できるプロダクトであることは間違いないです。 これからもCiliumのアップデートには注目していきたいと思います。 また、Ciliumが流行ったとしてもEnvoyはそのまま使われていくと思います。サービスメッシュの主要コンポーネントやプロキシサーバーとしてEnvoyは至るところで使われています。みなさんEnvoy力を鍛えましょう。 ここまでお読みいただきありがとうございました。 CADDiでは、現在積極的に採用を行っています。 まずはカジュアルにお話を聞いてみたい!という方は、ぜひ こちら より面談をお申し込みください。 また、Tech Blogや勉強会等のイベントについてはSNSで随時発信しておりますので、 Twitter のフォローや、 connpass のメンバー登録をぜひよろしくお願いします。
はじめに eBPF とはなにか ざっくり概要 「Packet Filter」なのに「Virtual Machine」? eBPFでなにができるか? カーネルイベントのフック ユーザーランドアプリケーションとのやりとり eBPFの主な用途 eBPFが注目される背景 eBPFの仕組み アーキテクチャと処理フロー カーネルモジュールとeBPFの違い eBPFプログラムの作り方 eBPFプログラムを作ってみる 環境の準備 Hello world もう少し複雑なサンプル その他のサンプル HTTPリクエストのダンプ TCP接続先の調査 tcplife dirtop filetop oomkill まとめ eBPFはなにに使えるか 参考サイト はじめに こんにちは、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概要 編
エンジニア採用担当の浜田です。 キャディでは note 、 Wantedly 、 Tech blog 、 twitter 、 podcast 、 オンラインイベント など様々なメディアを通じて情報発信をしています。 多種多様なキャディのコンテンツに対し、エンジニアのみなさんが効率よく情報を獲得できるよう、キュレーション記事があると良いと考え、本記事を執筆しています。 以下のような方に読んでいただけると嬉しいです! ・キャディの Tech 組織に興味がある方 ・キャディのビジネスに興味があるエンジニアの方 ・キャディのエンジニアになってみたい方 [toc] さくっとライトに知りたい方へ エンジニア職、ビジネス職も一丸で課題解決に向き合っているキャディにおいて、ぜひ目を通して頂きたいのが下記3点です。 キャディを取り巻く環境をテキストだけでなく、画像や動画からも感じ取って頂けると思います。 会社概要資料 カルチャーブック ブランドムービー https://www.youtube.com/watch?v=9IogFRU9AkE Tech組織やプロダクトを知りたい方へ エンジニア向けのCompany Overview(採用ピッチ資料)です。会社概要資料と似ていますが、プロダクトや技術スタック、エンジニアが働く環境について記載しています。 2021年末にはAI-Labを立ち上げ、業界トップクラスのエンジニアが続々と集結しています。 キャディのエンジニアは何を開発しているの?を知りたい方にはこちらがおすすめです。主に受発注プラットフォーム事業について記載しています。 [blogcard url=” https://note.com/ksato9700/n/n366ee2989321 ″] また、キャディは受発注プラットフォーム事業で得たノウハウや知見をもとに、図面データ活用クラウド「CADDi DRAWER」をローンチしました。CADDi DRAWERの開発秘話や、CTO小橋がプロダクトに込めた思いはこちらよりご覧いただけます。 [blogcard url=” https://caddi.tech/archives/3719 ″] エンジニア組織の歩みと今後について綴ったCTO小橋のブログです。 [blogcard url=” https://caddi.tech/archives/2443 ″] 2022年にはグローバル化の第一歩として、ベトナムに拠点を設立しました。日本でも海外出身のエンジニア採用が本格化しており、最近は毎月のように海外出身のエンジニアが入社しています。 そんなTech組織のグローバル化について記載したブログです。 [blogcard url=” https://caddi.tech/archives/3759 ″] 最後に、グローバル1兆円のプラットフォームを目指すキャディが取り組む課題をまとめた特設サイトです。ワクワクする課題が見つかるかもしれません。 [blogcard url=” https://recruiting.caddi.jp/recruit/tech/8challenges ″] しっとりと個人を知りたい方へ エンジニア1人1人の事を見てみると、キャディの事がより立体的に見えてくると思います。 ここではエンジニア一人一人のインタビュー記事やnoteなどを集めてきました。 ご紹介したいエンジニアが沢山いるので、ざっくり役割別に分けてみました。 アプリケーション開発(フロントエンド、バックエンド) [blogcard url=” https://kuwana-kb.hatenablog.com/entry/2019/09/27/194935 ″] [blogcard url=” https://vn.wantedly.com/companies/caddi/post_articles/169058 ″] [blogcard url=” https://vn.wantedly.com/companies/caddi/post_articles/169198 ″] [blogcard url=” https://vn.wantedly.com/companies/caddi/post_articles/192402 ″] [blogcard url=” https://vn.wantedly.com/companies/caddi/post_articles/202357 ″] [blogcard url=” https://note.com/hasssiii_caddi/n/nacf1a88775be?magazine_key=me62552818b14/ ″] アルゴリズムエンジニア [blogcard url=” https://vn.wantedly.com/companies/caddi/post_articles/222595 ″] [blogcard url=” https://ngtkana.hatenablog.com/entry/2021/03/17/210830 ″] 機械学習エンジニア [blogcard url=” https://note.com/vaaaaanquish/n/n17b375e70562/ ″] テクニカルプロダクトマネージャー [blogcard url=” https://note.com/srt_taka/n/ncb6dff973779?magazine_key=me62552818b14/ ″] [blogcard url=” https://note.com/imaimai/n/n28e3100a1de7/ ″] エンジニアリングマネージャー [blogcard url=” https://vn.wantedly.com/companies/caddi/post_articles/309875 ″] [blogcard url=” https://vn.wantedly.com/companies/caddi/post_articles/324651 ″] CTO [blogcard url=” https://www.wantedly.com/hiringeek/interview/rc_ttb10/ ″] [blogcard url=” https://type.jp/et/feature/18514/ ″] キャディのエンジニアメンバーは、個々が専門分野において卓越した技術を持っており、それぞれの専門性を活かして日々業務に取り組んでいます。 また、週次で勉強会をするなどしており、技術や考え方の多様性と向き合う機会を作り合う文化があります(勉強会はCADDiをモジッてSTUDDiと呼ばれています。一部、 Tech Blogでも勉強会の内容を掲載しています ので、よかったらのぞいてみてください!)。 じっくりどっぷり知りたい方へ ここまでの記事を読み終えてしまい、更に深く知りたいという方向けにそっと参考記事を置いておきます。 [blogcard url=” https://note.com/sasaguchisan/n/n65ee9266bc25 ″] [blogcard url=” https://corp.caddi.jp/recruit/issues/ ″] [blogcard url=” https://note.com/ideafruits/n/ndb62b147fc78 ″] [blogcard url=” https://note.com/yushirodesu/n/n412ac5846d2c ″] 最後に この他、不定期で情報発信している twitter もよければフォローしてみてください。なんらかの Give を届けられるように日々試行錯誤しています。 イベントに関する情報は、 キャディのconnpass がありますので、こちらも登録いただけると嬉しいです。 ここまでお読みいただき、キャディのことが気になってきた、と言う方は募集中ポジションについても是非ご覧ください。 [blogcard url=” https://note.com/jirohiraiwa/n/nd24788c801bf ″] まずはカジュアルにもっと話を聞いてみたい!という方は、 こちら より申し込んでいただけますと幸いです。 最後までお読みいただき、ありがとうございました。
半年で製造業向けAIサービスをリリースした開発チームのノウハウ大公開レポート 半年で製造業向けAIサービスをリリースした開発チームのノウハウ大公開レポート はじめに 登壇者 CADDi DRAWERの成り立ち CADDi DRAWERにおけるエンジニアリング Q&A 今後の展望 おわりに はじめに こんにちは。CADDiでエンジニア採用を担当しております上野です。 先日、CADDiから新たに図面の管理、活用を推進するためのサービス『CADDi DRAWER』をリリースしました。 図面は、製造業界では「最重要データ」とも言われており、これらを活用できる状態にする事は必要とされていながらも、長らくソリューションがない状況でした。 「CADDi DRAWER」は、図面の画像検索など、最新の技術をコアにした新しい図面活用プロダクトです。 図面に紐づく様々なデータを活用できる状態にするソリューションを提供しています。 本記事は、約半年で開発されたCADDiの新 SaaS である「CADDi DRAWER」の開発秘話をテーマにしたイベントレポートです。 イベントURL: https://caddi.connpass.com/event/251655/ 登壇者 本イベントでは、CADDi DRAWERの開発をリードするTech LeadやPdMが登壇し、CADDi DRAWERのこれまでの開発とfuture workについてお話しました。 Oniki (Product Manager) Kan (Frontend Engineer) Kawai (AI Lab Tech Lead) CADDi DRAWERの成り立ち 前半は、PdMのOnikiから、CADDi DRAWERにおけるプロダクトの遷移と現在の立ち位置に関する トーク を実施しました。 プロダクト初期の ヒアリ ングとベータ版 従来の図面管理システムでは、作成された図面を承認したり閲覧権限を設定したりと、「管理すること」が目的でした。 「管理すること」は、図面データ活用サイクルではほんの一部にしかすぎません。「CADDi DRAWER」は、本来の図面データ活用サイクルを包括できるシステムを提案しています。 Oniki (Product Manager) CADDi DRAWERは、いきなりの開発は行わず「Problem Solution Fit」をゴールにプロジェクトを進めました。 Problem Solution Fitとは、顧客が抱える問題や課題を解決する製品(プロダクト、サービス)を提供している状態のことです。 顧客の課題を特定し、それを解決しうるソリューションを見いだした後、MVP(Minimum Viable Product, 実用最小限の製品)を製作、リリースして、顧客に受け入れられるかを検証していく。それを繰り返して、製品が顧客の課題を解決している PSF の状態を作っていきました。 Oniki (Product Manager) 実際に、CADDi DRAWERプロジェクトでは、CADDiが培った経験や蓄積されたデータをもとに大きな仮説をたて探索を行うフェーズを長期間実施しています。 この仮説をもとに「もしこれ実現できたらどうなる?」というベータ版を作成しました。 実は、プロジェクト初期は現在の「類似図面を検索機能」ではなく別のソリューションが仮説の軸でした。 数十社の ヒアリ ングを繰り返し、顧客が本当に求めているものを探す中で今の形に落ち着いたと言えます。 Oniki (Product Manager) 例えば、検索結果としてファイル名と図面が並ぶだけでもお客様は驚かれた。エンジニアのぼくらからすると「え?そうなの?」と感じる発見がたくさんあった。 お客様でさえも気が付いていない UX を探り出す、という感覚に近かった。 Oniki (Product Manager) Onikiも言っている通り、長い ヒアリ ングの結果生まれたベータ版をお客様に持って行ってみたところ、その場で得られたのは「これなら使いたい!」と大変ありがたい好感触と「これを自分たちの図面で試したい!」というリク エス トでした。 まさにゴールとしていたProduct Solution Fitが一部実現したような形で、CADDi DRAWERはスタートし今に至っています。 CADDi DRAWERにおけるエンジニアリング 後半では、実際に開発をしているエンジニアから、開発体制や開発の裏話をお話しました。 開発体制 「CADDi DRAWER」開発の体制は大きく2つ「DRAWER Team」と「AI Lab Team」に分かれています。 ベータ版が出て以降は、 システム開発 の側面で様々な課題を乗り越える必要がありました。 数千枚という膨大なデータを実際に動かせることができること 図面データに対してどのような検索ができるのか 発注金額等どのような情報が結果として出たらよいのか さらに「あったらいい機能」は何なのか 実際にこれらを開発すべく、エンジニアチームでも試行錯誤しながらプロジェクトを進めました。 DRAWER Teamでは、お客様の体験を最大限に引き上げるための機能開発を主軸にしています。「CADDi DRAWER」を使うための事前調査や、より使いやすくするためのツール開発などを行っています。 今は DRAWER Teamを分けたり、開発体制を試行錯誤しています。 Kan (Frontend Engineer) 超短期期間のリク エス ト CADDi DRAWERは、ただでさえ図面データというサイズの大きなデータを扱う製品です。 エンジニアチームでは、余裕のある開発期間を想定していましたが、実際のユーザの反響に伴って速度を上げて仕上げる必要がありました。 嬉しいお言葉ですが「お金払うからもうちょっと早く製品を出してほしい」と言われ、開発としてはたいへんでした。「来週お客様が触るの!?」ということもたくさんありました。 Oniki (Product Manager) イベントタイトルにもある通り、半年でのリリースとなり、リリース日程が決まったのは4ヶ月目の頃でした。 より良い機能を小さく提供するために、開発チームでは様々な試行錯誤を実施しました。 Oniki (Product Manager) 「類似って何?」 図面検索を作るAI Labも課題は山積みでした。 「そもそも類似って何?」という状態から課題を解決していきました。 最初の会議で「類似って何ですか?」と聞いたら「わからない」と返ってきた。なので「類似」の定義から始める必要があった。色々模索しましたが、製造業出身の人がいたり、現状のサービスが簡単に利用できたりする CADDiの資産 が大きかったと思います。図面を紙で大量に印刷して、オフィスで並べてどれとどれが似ているのかグルーピングしてIDつけて・・・という泥臭い作業を最初は積み重ねたりもしました。 Kawai (AI Lab Tech Lead) また、システムへの組み込みの設計もDRAWERチームとAI Labが共同で実施しています。 AI Labでは 疎結合 になる を目標としていました。DRAWER Teamから送られてくるデータに対してインデキシングしたり、 Deep Learning を利用して画像抽出のための仕組み作りをしています。 特に「CADDi DRAWER」は「いろんな条件で検索したい」というリク エス トに応える必要があるため、検索のチューニング等に力を入れてきました。 これからは、 Kubernetes と 機械学習 の部分を分離してできるように改善しています。 Kawai (AI Lab Tech Lead) PdMと協力しながら、類似を定義し、システムに落とし込んでいった結果として、半年という短い期間でプロダクト開発に一定の区切りをつけることが出来たとも言えます。 Q&A イベント当日は、チャットにて非常に多くのご質問を頂きました。 登壇者である3人が出来る限り回答し、様々な視点からプロダクト開発を掘り下げるイベントになりました。 Q. AI LabがDRAWER Teamとの関係性で気を付けていることはありますか? AI Lab では社内受託にならないように非常に気を付けています。「CADDi DRAWER」開発で最もよかったと思うのはPdMからの説明だったと思います。自分たちが今から取り組むのは「CADDi DRAWER」のサイクルのうちどこにつながるのか、を最初の段階でしっかり議論しました。そのためメンバーの意思を高めることにつながったと思います。 Kawai (AI Lab Tech Lead) Q. 製品を説明する際、どのように アルゴリズム を説明しているのでしょうか? 「CADDi DRAWER」の場合、画像が多いため 定性評価 が必須だと考えています。 機械学習 的な アルゴリズム をそのまま説明するのではなくデモを基にできること、出来ないことを説明しつつAI Labとしては「出来ないこと」を減らすようにしています。 Kawai (AI Lab Tech Lead) Q. お客様の図面に対して類似画像を検索結果として出しているようですがVertex AI Matching Engineを使用しているのでしょうか? 今回は使用していません。Vertex AI Matching Engineの課題としては、テキストとの混ぜ合わせやフィルター条件での検索などが難しいと考えています。現在のシステムはテキストと ベクター の検索ができるように設計した結果となっています。 Kawai (AI Lab Tech Lead) Q. 図面は一般的なテキストとは性質が異なりますが、どのように 機械学習 のデータとしているのでしょうか? 機械学習 に直接そのまま入れる、ということはしていません。図面に書かれた類似における必要な情報と、不要な情報があります。例えば図面に書かれている手書きのメモなどをデータとして持つべきなのかというところを考えたうえで画像処理を行った上で入力データとしています。 Kawai (AI Lab Tech Lead) Q. 図面特有の記号は検索できますか? 現状図面特有の記号を一部を対応しています。穴あけ、溶接などの記号はPoC段階ですが 機械学習 や OCR で対応できるよう設計しています。 Kawai (AI Lab Tech Lead) 今後の展望 イベントでは、最後に「CADDi DRAWER」の今後について、登壇者3人のそれぞれの視点からディスカッションを行いました。 まずは、お客さまが実際に 図面を軸に活用できる ことを実現したいと思っています。その後は、今回の開発でもあったように事前に思い描いているものとはまったく異なるリク エス トなどが出てくる可能性があるので、今まさにリサーチをしているところです。 Oniki (Product Manager) 現状は検索する、というサイクルにフォーカスがあっていますがAI Labとしては 分析する というサイクルに影響を与えていきたいです。 Kawai (AI Lab Tech Lead) 「検索する」というのはほんの一部しか解決していません。「分析する」「行動する」などのサイクルを実現することで、より PDCA を手助けできるCADDiとして拡張していきたいと思います。 Kan (Frontend Engineer) 1つのプロダクトを全員で作っている様子が伝われば幸いです。 おわりに 本記事では、CADDiの新しいサービスである「CADDi DRAWER」の開発秘話イベントのレポートを記しました。 お読みいただきありがとうございました。 CADDiでは、CADDi DRAWER事業以外も含め、現在積極的に採用を行っています。 まずはカジュアルにお話を聞いてみたい!という方は、ぜひ こちら より面談をお申し込みください。 また、 Tech Blogや勉強会等のイベントについては SNS で随時発信しておりますので、 Twitter のフォローや connpass のメンバー登録をぜひよろしくお願いします。
[toc] はじめに はじめまして。CADDiの学生インターンとしてバックエンドエンジニアをしている渡邊です。 このたび、3ヶ月間のインターンを7月末に終えるにあたり、その成果発表として記事を書きたいと思います。 この記事では、私がインターンを始めるまでの経緯、インターンで行った実際の業務内容、そしてインターンを通しての感想、の3項目について書いていきます。 インターンまでの経緯 CADDiのインターンには、2022年3月のイベント 「Rust完全に理解した(嘘)」 を通じて、カジュアル面談・技術試験・面接を経て参加させていただくことになりました。 インターンでやってみたいこととしては以下の2つがありました。 「業務システムの開発」 今までは、個人での開発、地元企業でのアルゴリズム開発、といった小規模な開発に携わっていたため、より大規模なシステムに携わってみたかった。 「仕事でRustを書く」 過去の業務(アルゴリズム開発)ではC++を使っていたが、言語の使い勝手に不満があった。そこで、日頃から愛用しているRustを業務で使うことでどのような開発体験が得られるのかを知りたかった。 インターンの内容 仕事の概要 生産管理プロダクトチーム傘下の Klein-2022 開発チームに配属され、社員の方々と一緒に開発を行いました。 インターンの最初数日で環境構築やプロダクトの説明を受け、その後は他のチームメンバーと同等の扱いでタスクに割り振られて開発に取り組みました。 勤務形態としては、フルリモートで週3日、各8時間(10:00~19:00)の勤務でした。 Klein-2022 とは 社内で稼働している受発注オペレーション管理システム「Klein」の置き換えを目指すプロダクトです。 「お客様に対して見積・受注を行い、サプライパートナー(SP)様へ製造を発注し、製造された製品を検査した上でお客様へ出荷する」という一連の受発注オペレーションを管理します。 単独で動くわけではなく、社内の他システム(SP様との見積・発注のやりとりを行う「SPP」、倉庫拠点オペレーションを管理する「Polaris」など)とデータを連携することもあります。 Klein-2022の構造 Klein-2022は以下の図のような構造になっています。 Klein-2022は フロントエンド ( Next.js )、 BFF ( NestJS )、 バックエンド ( Rust )という3つのモジュールからなります。モジュール間の通信は、フロントエンド↔︎BFFは GraphQL 、BFF↔︎バックエンドは gRPC を使用しています。 なお、バックエンドはモノリシックではなく、受発注オペレーションの段階に合わせて分割されたマイクロサービスとなっています。 現時点では以下の6個のサービスで構成されています。 アカウント: お客様、SP様、案件担当者などの情報を管理 BOM: 製造する製品の情報を管理 見積: 見積を管理 販売: お客様からの受注、およびお客様への製品の出荷を管理 調達: SP様への発注、および製造された製品の入荷を管理 SC: 拠点検査(CADDi拠点における製品の検査)の情報を管理する Klein-2022のより詳しい設計やその背景を知りたい方は、Klein-2022開発チームのメンバーである松田氏の発表資料をご覧ください。 Rust製の業務WebアプリケーションをRustでリプレイス 典型的な開発フロー 新機能を追加する際の典型的なタスクの流れは以下の図のようになっています。 図の内容を説明します。 まず、Design Docを記述します。 Design Docには、その機能がなぜ必要なのか、ユーザ目線でどのような動作が提供されるのか、実装の設計と理由、などを記載します。 記述自体の時間に加えてチームでのレビューも行うためそれなりの時間を要しますが、これを書くことでチーム内で機能に対するイメージがはっきりし、結果的に開発効率がよくなります。 次に、gRPCインタフェースを定義します。 前節のアーキテクチャ図にあるように、Klein-2022ではバックエンド↔︎BFF間の通信をgRPCで行なっています。 従って、gRPCのインタフェースを先に定義しておくことで、この先のタスクを並行して行うことができます。 gRPCが定義できたら、並行して以下の2つのフローでタスクを行います。 Rust: データベースマイグレーション → バックエンド実装 → 統合テスト実装 TypeScript: BFF実装 → フロントエンド実装 ※「統合テスト」は、バックエンドのgRPCを実際に呼び出して、データベース・バックエンド・gRPCが意図通りに動作するかを確認するテストです。 自分はReactやTypeScriptにはあまり明るくなかったので、gRPCの定義をやった後に、TypeScriptのタスクは他メンバーにお任せしてRustのタスクをやることが多かったです。 上のタスクが全て終わったら、最後に他の方に動作確認をお願いします。 問題なく動作すれば、機能開発は完了です。 チーム開発の進め方 前節でタスクの分解方法を説明したので、分解されたタスクの進め方も含めて、チーム開発がどう動いていたかを説明します。 CADDiではスクラム開発を採用しており、スプリント期間は2週間です。 スプリント開始は水曜日で、この日にはスプリントゴールや扱うユーザーストーリーを決めるスプリントプランニングを行います。 スプリントプランニングでは、各ストーリーのDoD(完了の定義)やストーリーポイント(ストーリーの規模を数値化したもの)についてチームで議論します。 議論してみると、当初自分が想定していたよりも実装する内容が多かったり波及範囲が大きかったりすることが他メンバーの指摘によってわかることがあります。 こういったことが開発中に判明すると大変なので、ここで先んじて議論しておくことは開発効率の向上につながります。 スプリントプランニングの中で、ストーリーは前節で説明したようなタスクに分解されます。 タスクの割り当ては、スプリントゴール達成のために必要な優先順位をチームで議論しながら、優先順位やメンバーごとの得意分野を踏まえてタスクを取っていく形で行われます。また、手が空いたら随時未割り当てのタスクを取ることになります。 私はバックエンド側のタスクを取ることが多かったですが、インターン終盤ではBFFのタスクや簡単なUIの修正(文言の修正、GraphQLの修正などReactがあまり絡まないもの)も取るようになりました。 タスク(特にDesign Doc)を進めていくと、ストーリーの必要性や実現したいことがエンジニアにとって明確でないことがあり、その場合はユーザーやPdM(プロダクトマネージャー)に質問することになります。 Klein-2022は社内で使用される製品なのでユーザは社員です。そのため、ユーザ・PdMどちらに対しても質問は社内Slack・Discordで行うことができます。 インターン中に私からPdMに質問をする機会が何度かありました。質問をするには当然ながら機能の深い理解と機能にからむドメインの知識が必要となるので、働く期間の短いインターンにとっては難しい部類の仕事だと思います。ただ、チームメンバーに相談することで理解・知識は補えるので無理な仕事ではなく、また質問のために勉強することで機能やドメインへの理解が深まることになります。 自分のタスクを処理する他に、自分以外のチームメンバーが出すプルリクエスト(PR)のレビューをすることも日常的に行いました。 PRを作成する際には、チームメンバーのランダムな3人(GitHubの機能で選ばれる)にレビューを依頼することになっています。 幸いなことに私はある程度Rustの経験があったので、レビューを通してRustの経験が比較的浅いチームメンバーの手助けをするという貢献ができました。 以上に述べたチームの動き方はインターンだからといって特別ということはなく、一般のチームメンバーと同じものです。 インターンの感想 初めて大規模な業務コードに携わることとなりましたが、コードの構成が整っているおかげで、数万行規模のコードを触るのも苦になりませんでした。また、バックエンドが日頃から愛好しているRustで書かれていることもあり、事前の想定通り楽しんで開発を行うことができました。 インターン用の特別なタスクというものはなく、普通のチームメンバーと同じタスクを扱いました。ドメイン知識が必要で手ごわいタスクもありましたが、チーム内でDiscordを使って気軽に相談・質問できる体制があり、非常に助かりました。 日頃から技術に関しては色々勉強をしていたとはいえチーム開発の経験はなく、自分の能力でチームに貢献できるか不安がありました。しかし業務に関わっていくうちにチームメンバーから「積極的にタスクを取っていく姿勢がよい」「インターンの枠に収まらず戦力として役に立っている」などといった評価をいただくことができ、自分の能力に自信が持てるようになりました。 最後に 暖かいチームと整理されたコードに迎えられ、実りあるインターンをさせていただくことができました。 Klein-2022チームの皆様、CADDiの皆様、3ヶ月間ありがとうございました! ここまでお読みいただきありがとうございました。キャディにご興味のある方は、お気軽に 求人ページ からカジュアル面談をお申し込みください。また、 各種エンジニア向けイベント も随時開催しています。こちらもぜひご覧ください。
CloudFlare for application 入門 はじめまして、キャディでバックエンドエンジニアをやっている矢野です。 CloudFlareについて、2022年5月24日に開催された社内勉強会で発表させていただきました。 CDNで有名なCloudFlareですが、CDN以外のサービスの展開が最近活発です。 先日サーバレス向けのデータベースD1のプレスリリースが出ていたりと、その背景を含めて最近のCloudFlareの動向についてキャッチアップしました。 [toc] CloudFlareとは CloudFlareのHP によると、以下のように記載されています。  CloudFlareは、インターネット上で運営されている最大のネットワークの1つです。ユーザーは、Webサイトやサービスのセキュリティとパフォーマンスを向上させる目的でCloudFlareサービスを利用しています。 CloudFlareの特徴 大まかな特徴は以下のようになります。 このうち「CDNサーバによるエッジコンピューティング」を中心に解説していきます。 セキュリティ 信頼性 ゼロトラスト DNS DDoS攻撃対策 CDNネットワーク CDNサーバによるエッジコンピューティング  ←★今回はココを中心 サーバレス 安価に始められる エッジコンピューティング(Edge Computing)とは 「Edge」とはコンピューティングモデルの1つで、Device Locally、Edge Server、IoTなどユーザーの身近なものにアクセスするイメージです。 反対に、Cloud や Origin Server など物理的に遠い場合は「Edge」とは呼びません。 「Edge」と聞いたら「ユーザーに近い」イメージ CDN Edge server とは Device Locally、Edge Server、IoTなど ユーザーの身近にあるもの からつながっているルータやISPに位置しているCDNのことを「Edge Server」と呼んでいます。 CloudFlare services の変遷 下の画像は、CloudFlareの管理画面のサイドバーにある機能とローンチされた年度の一覧です。 結構ここ数年で次々と新たなサービスが開発されているのがわかります。 CloudFlare workers CloudFlare workersとは エッジサーバで実行されるサーバレス実行環境(FaaS)です。 無料枠では、リクエストあたりのCPU時間に制限があります。 CloudFlare workersの特徴 エッジサーバで実行されるためスケールする Service Worker APIに似たAPIで記述 JS(TS), Rust, C, C++ etc...が使える ローカル開発環境が充実 Cacheが利用可能 WorkersとPagesの料金設定 | CloudFlare キャッシュ CloudFlare workersで使えるキャッシュは大きく分けて以下の3つがあります。 それぞれ特徴が分かれているので、おさえておきましょう。 後で紹介するデモでは「Workers KV」を使います。 Workers KV 読み取り頻度が高い場合向け 書き込みに関しては、すべてのエッジサーバに伝搬するのに最大で60sかかる アトミックな操作はできない Durable Objects 遅延が少なく、強整合性を持つKV リアルタイム性を要求するアプリケーションでも利用可能(e.g. WebSocket) 有料($5~) Cache API それぞれのエッジサーバのローカルに保存される ブラウザのCache APIと同じインタフェース Cacheが有効なのはサイトの配下のみ(ワーカのAPIそのものには効かない) CloudFlare R2 Storage Object Storageのことで、データ転送量(下り)が無料という特徴があります。 「R2」という名前のとおり「S3」と対抗している雰囲気があり、R2の特徴は以下になります。 S3のAPIと完全互換性がある Really Requesテーブル(真にリクエスト可能な) Repositioning Records(レコードを再配置する) Ridiculously Reliable(途方もなく信頼できる) Radically Reprogrammable(根本的に再プログラム可能な) 参照: CloudFlare R2ストレージの発表 - 高速で信頼できるオブジェクトストレージ、エグレス料金なし Tools for developer 今回使った開発ツールを紹介します。 Wrangler v2 CloudFlare Workers用のCLI https://github.com/cloudflare/wrangler v2からminiflare(simulator)を内蔵 console.logデバッグができるの便利 hono CloudFlare Workers向けのweb framework https://github.com/honojs/hono ローカル開発はwranglerで完結 実際のコマンドは以下にあるものでほぼすべてになります、非常にシンプルです。 $ yarn add global wrangler $ wrangler login $ wrangler kv:namespace create "SAMPLE_KV" $ wrangler r2 bucket create images $ npx wrangler init XXX $ npm install hono $ wrangler dev # Coding... $ wrangler publish 簡易アプリ作成 やっていることは以下のとおりで、 URL を押下するとかわいい猫が見られます。 R2に保存された画像データを取得 R2から一覧を取得して表示 Workers KVでキャッシュ Get R2 object R2でやっているオブジェクトの取得コードは以下になります。 import { Hono } from "hono"; const app = new Hono(); app.get("/image/:id", async (c) => { const key = c.req.param("id"); const object = await BUCKET.get(key); if (!object) { return c.text("Image Not Found", 404); } const body = await object?.arrayBuffer(); c.header("Content-Type", object.httpMetadata.contentType!); return c.body(body); }); app.fire(); R2やWorkers KVの設定 このうち BUCKET .get には下準備が必要です。 1つ目の準備は、型定義です。 xxx.d.ts declare global { const BUCKET: R2Bucket; const SAMPLE_KV: KVNamespace; } 型定義のあと wrangler.toml に設定を書きます。 wrangler.toml name = "cloudflare-worker-demo" main = "src/index.ts" compatibility_date = "2022-05-23" kv_namespaces = [ { binding = "SAMPLE_KV", id = "xx", preview_id = "yy" } ] [[r2_buckets]] binding = 'BUCKET' bucket_name = 'images' preview_bucket_name = 'images' List view 結果の猫一覧は以下のように書いています。 const imageTag = (img_path: string) => { return `<img src="${img_path}" />`; }; app.get("/", async (c) => { const list = await BUCKET.list(); const keys = list.objects.map((headResult) => headResult.key); return c.html(keys.map((k) => imageTag(`/image/${k}`)).join("")); }); Workers KV キャッシュ キャッシュはこのようになります。 // check cache const kv_cache = await SAMPLE_KV.getWithMetadata<Metadata>(key, { type: "arrayBuffer", }); if (kv_cache.value && kv_cache.metadata) { const body = kv_cache.value; const contentType = kv_cache.metadata.contentType; c.header("Content-Type", contentType); return c.body(body); } const object = await BUCKET.get(key); // ... // save cache c.event.waitUntil( SAMPLE_KV.put(key, body, { metadata: { contentType } }) ); 初回キャッシュ時とそれ以降とではずいぶん落ち着いていることが確認できます。 終わりに CloudFlareを使うと、手軽にAPIやサーバを立てられることを体感できました。 CloudFlareでは、今回使用したWorkers KVしか提供していませんでしたが、最近「CloudFlare D1」分散RDBを出してきました。 競合はSpannerやCockroach DB等と考えられるのですが、サーバレスかつエッジネットワーク上で整合性を保つRDBは結構特徴的な位置付けになりそうです。 コスト面はR2と同じくデータ転送に対する課金はなく、割と安価にサービスを提供されると予想されます。 ここまでお読みいただきありがとうございました。キャディにご興味のある方は、お気軽に 求人ページ からカジュアル面談をお申し込みください。また、 各種エンジニア向けイベント も随時開催しています。こちらもぜひご覧ください。 参考資料 CloudFlare本家 - CloudFlare - Webパフォーマンスとセキュリティを追求する企業 | CloudFlare - エッジコンピューティングとは? | CloudFlare - D1を発表:当社初のSQLデータベース DevelopersIO - CloudFlareのR2ストレージ | DevelopersIO - [速報] CloudFlare のエッジ環境で使用できる SQL データベース D1 が発表されました! | DevelopersIO Others - CloudFlare Workers メモ - CloudFlare R2の画像をCache APIでキャッシュして返すメモ - CloudFlare R2もいいぞ! - ゆーすけべー日記 - Remix on CloudFlare WorkersからCloudFlare R2を使う | DevelopersIO
はじめに 現在の8つの挑戦と未来の8つの挑戦 本記事は、以前公開した CADDi Tech 現在の8つの挑戦と未来の8つの挑戦 というコンテンツをより深くご理解いただくために、キャディのメンバーがそれぞれの挑戦の目的や意図を説明する連載記事となっています。 第一回は 「現在の挑戦 01」として「グローバルカンパニーになるための開発体制のグローバル化と多言語化」をテーマに、ベトナムオフィスでベトナムエンジニアリングチームの立ち上げを行っている佐藤がお届けします。 キャディビジネスのグローバル化 キャディは「モノづくり産業のポテンシャルを解放する」をミッションとして、製造業の変革を行おうとしています。創業からこれまでの間は日本のお客様、日本の製造パートナーの方々と取引をさせていただいていて、「日本」の製造業を変革するスタートアップ企業と思われていたのではないでしょうか。 ですが、我々は元々日本に閉じこもるつもりはなく、世界中の製造業を変革する最初の地として日本でビジネスをしていました。そしてようやく準備が整ってきたので、この春から海外展開の第一歩としてベトナムのホーチミン市に初の海外オフィスを設立しました( プレスリリース )。ここを拠点に、ビジネスとテクノロジーの両輪でグローバル化を進めていくのですが、この記事ではテクノロジーのグローバル化についてお話したいと思います。 キャディの技術開発のグローバル化 なぜ開発組織をグローバル化させるのか キャディはビジネスをグローバル化するだけでなく、技術開発もグローバル化しようとしています。その理由は大きく以下の3つになります。 1. 様々な能力を持つ優秀なソフトウェアエンジニアをより多く確保すること。 2. グローバルで活躍できる優秀なソフトウェアエンジニアを確保すること。 3. ビジネスの現場に近い場所に優秀なソフトウェアエンジニアを確保すること。 優秀なソフトウェアエンジニアをより多く確保する 日本のITエンジニアの人口は約120万人と言われています。この数はまだ増加していますが、日本の人口が減少し高齢化が進む中で、キャディの求めるソフトウェア開発のできるエンジニアを日本だけで確保するのは難しくなっています。さらにITエンジニアに対する需要が供給を上回っていて、給与レベルが他の職種と比べて高騰している状況もあります。そのため、エンジニアの採用は簡単ではありません。 一方で、日本の国外に目を転じてみると、全世界でおよそ2,300万人ほどのITエンジニアがいます。その中で、キャディと技術の志向性が合っていてキャディに興味をもっていただける方は限られていると思いますが、日本だけで探すよりは確実に多くの方に出会えると考えています。 グローバルで活躍できるソフトウェアエンジニアの確保 ビジネスがグローバル展開するにしたがって、キャディのビジネスを支える製造業受発注プラットフォームもまたグローバル対応が求められます。それは単純なUIの多言語対応ということだけではなく、長さ重さなどの単位の違いや通貨の違い、さらに各国の商習慣の違いなどによって、システムに対する新たな要求が世界中からやってくるようになります。 その多くは、1つの国だけであれば考慮する必要のない「余計な機能」という風に見えてしまうかも知れません。グローバル化の機能を追加することで、システムの複雑性が増し、テストも大変になり、不具合の可能性も高まります。 ただ、それはビジネスをグローバルに展開しより多くの人たちに我々のソフトウェアを使ってもらうためには必要なことです。そして、その開発に携わるエンジニアには、異なる国や地域の人たちとのコミュニケーションを円滑に進められること、そして異なる要求を理解しできるだけ整合性を保ちながらシステム上で実現していくことが求められます。 もちろん国内にもそういうエンジニアはいますが、日本は内需がそこそこ大きいので国内向けの開発だけをされている方が主流ではないでしょうか。海外には自国外のユーザーのためのソフトウェア開発を中心に行っているエンジニアが多くいて、キャディはそういった方々を積極的に採用していきたいと考えています。 ビジネスの現場に近い場所にソフトウェアエンジニアを確保する キャディのビジネスがグローバル展開することで、「現場」も海外に散らばっていきます。つまり、実際の製造が行われる製造パートナーや調達を行う顧客が海外に存在することになります。そしてその受発注のオペレーションを行うキャディ内部のメンバーも必然的に世界に広がっていくことになります。 上記で述べたように、それぞれの地域で異なる事情があり、システムに対する要件も多岐にわたります。グローバルな感覚を持ったエンジニア集団がそれを集約してシステムに反映させていくということは当然やるのですが、それだけだとどうしてもスピード感が失われることがあります。 それを補うために、開発エンジニアが現場に近いところにいてクイックに開発を進められる体制も同時に作りたいと考えています。実感をもって課題解決に取り組むためには「現場」に出向いて実際に見て聞いて感じる、そして時に質疑応答をして理解を深めていくことが大切です。それをするためには、現場の近くにいて、現地の言葉を話せるエンジニアが必要で、そこで見聞きしたことを即座にシステムに反映させ、PDCAのサイクルを高速にまわしていきたいと考えています。 そのようにして作られたシステムは最初はその地域に特化したようなソリューションかも知れませんが、他の地域でも意外と同じ課題を抱えていたりするので将来的に横展開もしていけます。例えば、ベトナムのチームで開発したソリューションが日本のパートナー工場で使われるようになるという流れもできるかも知れません。これも海外に拠点を構えてエンジニアリングチームを作る理由の1つになります。 グローバル化の2つの軸 キャディの開発組織のグローバル化を考えた時に2つの軸があります。 1つは国内の開発組織に外国出身の方々に入ってきてもらうことです。日本人中心の組織から非日本語話者のメンバーも含めた混成のチームに変わることで日本国内組織のグローバル化を促進します。もう1つは海外に開発拠点を構えてそこでローカルの優秀なエンジニアに入ってきてもらうこと。そしてそれらのメンバーが日本のメンバーとやり取りをしながら組織全体でのグローバル化を促進する。ホーチミン市のオフィスはその第一弾ということになります。 多言語化 この2つの軸で組織のグローバル化を促進した場合に一番問題になるのは言語です。我々は基本となるコミュニケーションの言語は英語と考えています。では流暢に英語が話せなかったら仕事ができなくなるのか、そんなことは無いです。うまく話せないのであれば書けばよいし、うまく書けないのであれば機械翻訳に頼るでも良いと思います。 必要なことは「伝えること」で、複雑な文章を書いたり、込み入った話をする必要はないです。「日本語ならもっとニュアンス含めて伝えられるのに」ということもあると思いますが、込み入った話はだいたい日本語でも伝わっていない。 大切なのは相手に伝わること。読んだり聞いたりする相手の立場でシンプルに必要なことを伝える。相手も日本人同様、英語が必ずしも得意ではない人たちかも知れないので、それを念頭に置いて伝えるということが大切だと思います。 メルカリの方が「やさしいコミュニケーション」という考え方を提唱していますが、まさに、お互いに歩み寄ってコミュニケーションすることが必要です。英語が話せる人はできるだけわかりやすい英語を心がけて、「英語が共通言語なんだからわからない奴が悪い」という雰囲気を作らないようにしなければなりません。 そして「シンプルなコミュニケーション」というと要件だけを伝えるみたいに聞こえてしまうかも知れないですが、自分も相手も、感情のある生身の人間でです。例えば、相手に感謝を伝えること、相手を褒めること、苦労をねぎらうこと、などもコミュニケーションの中で忘れないようにしたいです。 英語習得のトレードオフとコツ 「英語で仕事をすることになると効率が落ちる」と言う方がいます。そしてそれはほとんどの日本人にとっては事実だと思います。また「英語の勉強する時間があったら、技術の習得に時間を使いたい」というエンジニアの方もいらっしゃるでしょう。それはそれで一理あると思います。 ただ、インターネットによって国境を超えて人と人が繋がるようになり、国を超えた人材採用も活発に行われています。我々もその流れで国外の多くの方に応募していただいていますが、本当に思いもしない国々からのお声がけもあります。日本国内でもキャディと同様に外国人の採用を積極的に進めている開発組織の話もよく聞きます。 そんな中で、エンジニアとして「日本語環境」という縛りを持ち続けて技術だけで勝負し続けるのか、海外の人材と一緒に働くあるいは海外で働くという選択肢を持つのか。それを自分のキャリアパスを考える時に意識すべきだと考えていて、それに応じて自分の時間の優先度を判断していくのかなと思います。 なお、「必要に迫られないとなかなか時間を割けない」という方もいます。これもまた事実なのですが、実はこの半分くらいの人は必要に迫られてもなかなかできない。何かと言い訳をして(「効率が落ちる」とか「こちらの優先度が高い」とか)、取り掛かろうとしない。まあ、夏休みの宿題と一緒ですね(笑)。 なので、取り敢えずそういう環境に身を置いて、あとは積極的にやってみるしかないかなと思います。今は昔と比べて便利なツールもたくさんあるわけで、そういうのを使いこなすのもテクニックの1つになるのだと思います。 そして、本当に大切なのは英語を読んだり書いたり話したり聞いたりすることじゃなくて、どうやったら相手に伝えられるのか、どうやったら相手の言いたいことを理解できるのか、そのコツを掴むことなのかなと思います。私もまだ全然できていると思っていません。でも、長い間やってきて、だいぶ遠回りした気もしますが、ある程度蓄積するものができてきて、ようやく伝えたいことをだいぶ伝えられるようになってきたかなと思います。 今後に向けて 組織のグローバル化を一緒に考えて行動する仲間を探しています キャディのグローバル化と多言語化について、現時点での考えを共有させていただきました。冒頭に書いたとおり、キャディのグローバル化は今まだ始まったばかりです。今後、ここで書かれている事が微修正されたり方向転換されたりということも出てくると思います。むしろ、グローバル化の取り組みをしながら積極的にここに知見を追加していきたいと考えています。それを我々と一緒にやっていただけるメンバーを探しています。 エンジニアとして海外メンバーと一緒に働いてみたいという方もいるでしょうし、マネジメント的な立場で組織拡大にチャレンジしてみたいという方もいらっしゃるでしょう。ご興味のある方は、お気軽に 求人ページ からカジュアル面談をお申し込みください。ここでは語りきれない現実の諸々もお話できると思います。または 各種エンジニア向けイベント も随時開催しています。こちらもぜひご覧ください。
はじめに builder マクロを作る(続き) 06-optional-field 目標 実装方針 実装 ガード節で Optional でない型のみエラーを出すようにする Optionでラップされた型はアンラップしてCommandBuilder構造体のフィールドで保持する Optional な型の setter メソッドはラップされた中身の型を引数として受け付けるようにする is_optionとunwrap_optionの実装 is_option関数 unwrap_option関数 AngleBracketed(AngleBracketedGenericArguments) リファクタリング 07-repeated-field 目標 実装方針 実装 Builder 構造体のフィールドではVec型の変数はOptionでラップしない is_vectorとunwrap_vectorの実装 フィールドに付与されたアトリビュートを取得する List Meta(Meta) Lit(Lit) Vec型のフィールドの setter を一括更新用と要素追加用の 2 種類生成する 08-unrecognized-attribute 目標 実装方針 実装 09-redefined-prelude-types 目標 実装方針 実装 まとめ 参考文献 はじめに 前編 では、 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
注意!! 記事を書いた時点からの更新があります この記事の内容は古くなっています。当時の課題は 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エンジニア とも一緒に働きたいと考えています。
はじめまして、キャディでバックエンドエンジニアをやっている秋山です。 趣味で粛々とやっていたバイナリ解析について2022年5月17日に開催された社内勉強会で発表させていただきました :tada: [toc] バイナリ解析とは? バイナリ解析とは、製品や実行ファイルなどソフトウェアの内部の詳細を分析し、一般に公開されてないシステム設計や仕様を明確化し調査することです。 その他にマルウェアを静的解析する際もバイナリ解析を使用して調べたりします。 バイナリ解析って、やってもいいの? 法律的に、脆弱性診断のためのリバースエンジニアリングは合法です。 ただし、市販の製品に対してリバースエンジニアリングを行い、その情報を開発などに使用したり情報を開示すると特許法上や著作権上の違法性が認められる場合があります。 また、ソフトウェアの使用許諾契約書などにリバースエンジニアリング禁止条項として記載されている場合は要注意です。 どう楽しむといいのか? 安全かつ楽しんで解析しましょう。 具体的な方法は以下の2つです。 CTFに参加する マルウェア捕まえて解析する CTFに参加する セキュリティコンテストはいくつかあるので、参加してみるといいかもしれません。 終了後には Writeup という形で解答を掲載する人もいるため、コンテスト終了後に復習できることがあります。 - SECCON Beginners - picoCTF - WaniCTF マルウェア捕まえて解析する 解析対象のバイナリ入手方法 マルウェアを収集するツール マルウェアを収集できるツールをご紹介します。 ハニーポッド(Dionaea) マルウェア収集用のハニーポットです。 検体の収集はもちろん、インシデントログからネットワーク経由の攻撃を再現することもできます。 T-POTを入れると諸々入るのでサクッと試せます。 VIRUSTOTAL 検体をダウンロードすることも可能なサービスです。 普段の用途としては、怪しいファイルをアップロードするとよしわるしをパッと判断してくれます。 ※ アップしたファイルは公開されるので、機密ファイルなどは避けてください。 Cuckoo Sandbox サンドボックスで怪しいファイルを動作させて、動的に解析できるツール 実行時のキャプチャや書き換えられたレジストリ、どこと通信しようとしているかなどを確認できます。 一家に一台あると安心です。 ランサムウェアのWannaCryを動かしてみるとこんな感じです。 1. ファイルをドロップします。 少し待つと、Summaryやキャプチャなどがかえってきます。 解析手法の種類 バイナリを解析するときの手法としては、大きく3つほどあります。 今回は、静的解析をやってみたいと思います。 ① 表層解析 どういった実行形式で実行されているのか、ファイルのハッシュ値(VIRUSTOTALでハッシュ値を検索すればマルウェアが判明することも)など、どういったファイルであるかをデータという視点で解析します。 ② 動的解析 プログラムを実行すると何が見えるかを解析します。 ※ 検体を実行する場合は手元ではなくCuckooSandboxなどを利用することをお勧めします。 ③ 静的解析 プログラムを可視化して構造や動作を解析します。 バイナリ解析(静的解析)で使うツール 解析する上で色々なツールがありますが、今回は radare2 を使ってみます。 radare2  ← 今回はこれをやってみます。 ghidra gdb objdump IDA Pro(有料) OllyDbg バイナリ解析(静的解析)を行う前におさえておきたいこと 解析するうえでABIの部分と、命令セットアーキテクチャ(ISA)の部分をおさえておきましょう。 ABIとは ABIとはApplication Binary Interfaceの略のことで、バイナリレベルで関数やデータの仕様を規定したものです。 関数をコールするときの引数の渡し方などを規定します。 コンパイラで生成されるバイナリはABIに準拠しており、ABIを知ることで一覧のアセンブリ命令が何をしようとしているのかを理解することができます。 ABIは何によって決まるの? ABIは、ISAとOSにより決まります。 厳密には、言語やコンパイラごとに異なることもあるらしいです。 今回の環境は x86-64 と Linux なので System V AMD64 ABI を参考にしています。 参照: https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf レジスタとは CPU内部にある記憶装置のことで、アセンブリが何かをやる際に利用します。 汎用レジスタ 色々な用途に使用できるレジスタで、状況に応じて様々な用途に用いることができます。 - rax - rbx - rcx - rdx - rsi - rdi 特殊レジスタ それぞれ専門的な用途があるレジスタです。 - rbp:現在のスタックフレームにおける底のアドレスを保有。 - rsp:現在のスタックトップのアドレスを保有。 - rip:次に実行するアセンブリ命令のアドレスを保有。 メモ:RCXとECX 64bitでは、RCXなど頭にRがついたレジスタがあります。 32bitでは、ECXなどのレジスタ名になります。 今回のアセンブリでは、64bitの環境なのに頭がEのレジスタを使っています。 これは格納する値が32bitで事足りるので下位32bitだけ使った結果こうなっているらしいです。 参照: https://www.mztn.org/lxasm64/amd04.html バイナリ解析(静的解析)を行う それでは、バイナリ解析(静的解析)を実際にやってみましょう。 解析対象のソース 今回利用するソースコードです。 #include <stdio.h> int add(int a, int b, int c, int d, int e) { int sum = 0; sum = a + b + c + d + e; return sum; } int main(void) { printf("sum = %d\n", add(1, 2, 3, 4, 0)); return 0; } コンパイルする gcc sample.c 実行時のメモリレイアウトは以下が参考になるかと思います。 https://twitter.com/404death/status/968381431146778624/photo/1 解析する 以下のコマンドで解析します。 r2 -d a.out 関数一覧を見てみる aaa  でバイナリ全体を解析しています。 afl  でバイナリのなかにどういった関数があるのかを一覧で出しています。 [0x7f7b5ad84090]> aaa [0x7f7b5ad84090]> afl 0x5564c6ff7050 1 42 entry0 0x5564c6ff9fe0 1 4124 reloc.__libc_start_main 0x5564c6ff7080 4 41 -> 34 sym.deregister_tm_clones 0x5564c6ff70b0 4 57 -> 51 sym.register_tm_clones 0x5564c6ff70f0 5 57 -> 50 entry.fini0 0x5564c6ff7040 1 6 sym..plt.got 0x5564c6ff7130 1 5 entry.init0 0x5564c6ff7000 3 23 map.root_a.out.r_x 0x5564c6ff7210 1 1 sym.__libc_csu_fini 0x5564c6ff7135 1 58 sym.add 0x5564c6ff7214 1 9 sym._fini 0x5564c6ff71b0 4 93 sym.__libc_csu_init 0x5564c6ff716f 1 61 main 0x5564c6ff7030 1 6 sym.imp.printf 0x5564c6ff6000 3 404 -> 393 loc.imp._ITM_deregisterTMCloneTable 0x5564c6ff61aa 5 32 -> 55 fcn.5564c6ff61aa mainとsym.addのアセンブリ全体像 下図の左側が main のなかのアセンブラで、右側が add 関数のアセンブラです。 AT&T構文とIntel構文があるのですが、Intel構文で表示されています。 main関数 関数呼び出しをする際のお決まり(Function prologue)を実行する 関数呼び出しをする際のお決まりのようなものです。 コールされた関数は、呼び出し元に戻る時にスタックの状態が呼び出し時と同じ状態になるように rbp を退避しておきます。 push rbp mov rbp,rsp 有効な命令アドレスの範囲は0x00007FFFFFFFFFFFです。 push rbp  が実行されると、スタックの一番上に積まれます。 push rbp mov rbp,rsp  が実行されると、スタックの番地を rbp に保存します。 これは、main関数におけるスタックの底を指定する処理になります。 この状態になっていると、現在の関数でスタックを好きに使っても呼び出し元に戻る時には元の状態にして戻すことができます。 mov rbp,rsp 引数を渡す add関数に渡すための値をレジスタに設定します。 mov r8d, 0 mov ecx, 4 mov edx, 3 mov esi, 2 mov edi, 1 メモ:関数呼び出しに関する決まりごと 引数の渡し方はABIに準拠しており、関数呼び出しに関する決まりごとはABIのドキュメントの「 Figure 3.4: Register Usage 」に記載があります。 関数をコールする call を実行すると、スタックにリターンアドレスをプッシュします。 call sym.add printf関数に渡す値を設定する printf関数に渡すための値をレジスタに設定します。 第一引数( rdi )にはフォーマットとなる文字列 sum = %d\n のアドレスを設定し、第二引数( esi )には一つ目の %d に表示するための合計値を格納しています。 mov esi, eax ; addの戻り値を第二引数に設定する lea rdi, qword str.sum____d ; 0x5564c6ff8004 ; "sum = %d\n" mov eax, 0 call sym.imp.printf ; int printf(const char *format) メモ:mov eax, 0命令 mov eax, 0 命令は、今まで見てきた引数渡しのパターンに当てはまらないため調べてみました。可変長引数に浮動小数点の値を必要とする場合、その数を設定する必要があるらしいです。今回は整数型なので0を設定しています。 こちらもABIの「3.5.7 Variable Argument Lists」で説明されています。 main関数を終了する main関数の戻り値として eax に0を設定し、スタックに積んでいたベースポインタの値を rbp にポップ(Function epilogue)してきたらretでmain関数を終了します。 mov eax, 0 pop rbp ret add関数 次にadd関数内の処理を呼んでみます。 関数呼び出しをする際のお決まり(Function prologue)を実行する push rbp mov rbp, rsp コールスタックに引数を格納する 渡された引数をコールスタックに格納しています。 メモ [rbp-0x14] という中途半端な場所からスタートしているのは「アライメント境界」というのが関係しているらしいです。 スタックには赤い四角で囲まれているように、引数が積まれています。 sum変数を初期化する 変数領域sumを0で初期化しています。 mov dword [var_4h], 0 引数の合計値を求める 渡された引数1から引数5までの値を足しています。 mov edx, dword [var_14h] ; 1 mov eax, dword [var_18h] ; 2 add edx, eax mov eax, dword [var_1ch] ; 3 add edx, eax mov eax, dword [var_20h] ; 4 add edx, eax mov eax, dword [var_24h] ; 0 add eax, edx sum変数に結果を格納する 足し算の結果を変数sumに当たるアドレス[rbp-0x4]に格納して、戻り値としてeaxレジスタに値を格納しています。 mov dword [var_4h], eax mov eax, dword [var_4h] add関数を終了する add関数の戻り値としてeaxに0を設定して、スタックに積んでいたベースポインタの値をrbpにポップ(Function epilogue)してきたら、retでmain関数の呼び出し元に戻ります。 pop rbp ret 最後までお読みいただきまして、ありがとうございます。
In March 2022, I was fortunately to join CADDi Inc. for a month-long internship as a frontend developer. It was an amazing and fruitful experience. Why I joined the internship Without any explanation, it might be hard to imagine why a company in the manufacturing industry would need a large team of developers. However, the message from the CEO and the CTO about the company explains this: by leveraging the power of IT to facilitate operations, CADDi attempts to transform the manufacturing industry, which I found very interesting. Reading the Company Overview for Engineers also encouraged me to apply for this internship program. It gives a brief introduction to some of the products in CADDi and also introduces the technology stack used. This shows that the company is willing to adopt the best tools available to solve challenging problems, and also that the internship would be an opportunity to learn how to use many new tools. There are also things that would have been hard to learn on one's own, working effectively as a member in a team of developers being one of them. Even though I have a few years of programming experience, I believe that I need to strive to become a better programmer and a better developer. Believing that this would be a precious learning experience, I applied and got an offer for the internship. What I did during the internship As an internship participant, I joined one of the teams in the company and had the chance to experience how it is like to be an actual software engineer. The following are some of the activities I participated in. Development & code review Most of the time in the internship was spent implementing new features and fixing bugs. Even though I joined as a frontend developer, I was able to read and modify the backend code in the same monorepo. I also spent some time reviewing and modifying the CI pipeline to improve developer experience. In addition, we also reviewed each other's pull requests. Apart from catching mistakes in the changed code, it is also an excellent opportunity to share knowledge of how to write more maintainable code and gain understanding of a different part of the product when one is not involved in the development of the part. Scrum meetings I also spent time participating in scrum events like sprint planning, daily scrum, sprint review and sprint retrospective. We were able to raise our concerns about what we find confusing during the development cycle and attempt to fix these problems to make the development process more efficient. I also got to understand how scrum allows a team to work towards a defined goal with trackable metrics. User interview There also happened to be several user interview sessions, where potential users are invited to try out new features and provide feedback to developers. It was a humbling experience, reminding me the reason that we develop the product is always to make something useful for the user. What I had learnt from the internship Technical knowledge This internship has been an opportunity to experience using technologies that I have only heard about but never used before. Usually, in order to learn new technologies, one needs to be proactive in finding new technologies to learn and spend time finding resources and actually try to use the library. Through the internship, it is possible to learn new technologies quickly and gain a deep understanding reading code written by other developers. As CADDi makes use of a frontend-BFF-backend architecture , this is my first experience using Protocol Buffer and gRPC. It is also my first time developing a project that adopts the clean architecture. Fellow developers in the same team had been very kind to answer my questions and explain to me some of the things which I do not understand, for example how the application is deployed to the Kubernetes cluster from CI. Even though I joined a frontend engineer position, I was not limited to frontend development, and was able to learn much about backend development and DevOps. The difference between personal programming and team programming Personal programming and team programming are completely different. For instance, one has to pay particular attention to write code in a way that is easy for other developers to understand. This may include having a common way to structure code, writing comments to explain code if it is not obvious why it is done in a certain way, etc. On the other hand, the friendly environment in CADDi had been really helpful. When one gets stuck while trying to solve a problem, it is easy to consult other team members for help. As members each have their own strengths and weaknesses, this allows us to combine the strengths and develop much faster than a single developer could. The importance of reading and writing documentation When knowledge is shared effectively among team members, it allows developers to solve problems more efficiently, without needing to ask other team members all the time. Meetings are of course one of the ways to do this, but documentation allows this knowledge to be retained even if members change teams. By setting up a general convention on where to put documentation and nurturing a culture of reading it, the knowledge is effectively shared and preserved. Although effort is required to keep the documentation updated, it is arguably time well spent as not only can the onboarding cost for new members be reduced but also can developers reference it at any moment. Ending Notes After the end of the internship, I have gladly been allowed to join CADDi Inc. as a part-time software engineer. Overall, the internship has been a very fruitful experience. I had gained much precious experience that would not have been possible without joining the internship. I believe that internship programs are excellent learning opportunities for aspiring engineering students, and starting early will open you to a world of opportunities.