TECH PLAY

株式会社エブリー

株式会社エブリー の技術ブログ

385

この記事は every Tech Blog Advent Calendar 2024 の 22 日目の記事です。 データ&AIチームでデータエンジニアを担当している塚田です。 弊社のデータ基盤はDatabricksをベースにデータストアとしてAmazon S3を利用しています。 今回、データストアとして利用しているAWSアカウントとは異なるAWSアカウント上でデータ利用する必要があり、比較検討しましたのでその内容をご紹介します。 クロスアカウントにおけるS3で保持しているデータの利用方法 S3にはデータの参照やコピー方法が多数あり、全てを網羅するとそれだけで長くなってしまうので、今回は以下のシチュエーションで利用する前提でいくつか方法を考えてみます。 データを保持しているアカウントとは別のAWSアカウント上のAthenaによるクエリ実行でデータを取得させる どちらのアカウントの話をしているのかわかりにくくなるため 基盤側アカウント と 利用側アカウント で表記します。 データは基盤側アカウントに保持しアクセスする場合 基盤側アカウントのS3バケットのバケットポリシーに利用側アカウントからのアクセスを許可する設定を行う 基盤側アカウントにS3 アクセスポイントを作成し、そのバケットポリシーに利用側アカウントからのアクセスを許可する設定を行う 利用側アカウントにS3 アクセスポイントを作成する データを基盤側アカウントから利用側アカウントにコピーしてアクセスする場合 AWS DataSyncを利用しアカウント間でデータ連携を行う S3 クロスリージョンレプリケーション(CRR)を利用しアカウント間でデータ連携を行う S3 バッチオペレーションを利用しアカウント間でデータ連携を行う 比較・検討 前項で複数の方法を挙げてみましたので、それぞれについて比較・検討していきます。 具体的な設定方法の記載はしておりませんので、実際に利用する場合には公式ドキュメントなどを参照し実施するようにしてください。 データは基盤側アカウントに保持しアクセスする場合 基盤側アカウントのS3バケットのバケットポリシーに利用側アカウントからのアクセスを許可する設定を行う 個人的にですが、一番メジャーでかつ設定が容易な方法だと思います。 データを保持している基盤側アカウントがアクセス制御を行うため管理体制として正常に機能する状態が実現できます。 基盤側アカウントにS3 アクセスポイントを作成し、そのバケットポリシーに利用側アカウントからのアクセスを許可する設定を行う ほぼ、S3バケットのバケットポリシーを利用したアクセス許可と同じ内容で余計なアクセスポイントを作成することによって管理するリソースが増えているように感じるかもしれません。 ただ今回は利用先が1アカウントの前提で記載していますが、データ基盤で利用しているS3は他のAWSアカウントでも利用するシチュエーションは十分に考えられます。 都度S3バケットのバケットポリシーを変更するのはミスをする可能性もあり他のアクセス制限に影響が発生する場合も考えられます。 その点でS3 アクセスポイントを利用することによりアクセスポイント側でアクセス制御が行えるため上記のようなリスクを回避することができます。 利用側アカウントにS3 アクセスポイントを作成する こちらもS3 アクセスポイントを利用しますが、作成するアカウントが利用側アカウントになっています。 この案の特徴としては利用側アカウントにアクセスしポイントのリソースが作成されているため、どのバケットに対してのアクセスが行える設定がされているのかを知ることができるところにあると思っています。 アクセス制御自体は基盤側アカウントで行いますのでアクセス管理が煩雑になるというリスクは発生します。 データを基盤側アカウントから利用側アカウントにコピーしてアクセスする場合 今回の利用方法であればデータをコピーするメリットがなく以下のデメリットがあるため、検討はしましたが、データのコピーを行う方式自体合わないと判断しました。 データが複数箇所で管理されるためデータ基盤側が想定していない利用をされる場合がある 利用側アカウントから他の環境にデータ連携を行うなど データ容量が多いためストレージコストが倍になってしまう 差分更新ができたとしても保持するデータの総量は増える データ更新には多少なりとも時間がかかりデータ基盤側の処理完了とデータ更新のタイミングを考慮する必要がある 利用するサービスによっては利用するための設定が煩雑になる場合があり、データを利用したいというシンプルな要件に対して対応コストがかかる 最終判断 今回は 利用側アカウントにS3 アクセスポイントを作成する を選択しました。 判断理由 データ基盤側のデータは利用するがAthenaを介した方法のためその参照データがどこにあるか重要ではありません。 ただし、AWS Glue crawlerを利用したスキーマの自動生成を行いたいためS3のどこに該当のファイルを配置しているかを把握した上で利用するニーズがありました。 (アクセスポイントを利用するとマネジメントコンソールでデータの構成を参照することができる) 上記のような理由により、利用側アカウントにS3 アクセスポイントを作成し参照管理する方式としました。 おわりに Amazon S3はオブジェクトストレージとして様々なシチュエーションで利用することができるサービスだと思います。 その結果として多数の機能、連携できるサービスも多岐にわたっているため利用時に何を採用するべきかは悩むポイントでもあります。 今回ご紹介した方式や判断については全てに通用するものではありませんが、利用用途に沿った調査と判断をした例として参考になれば幸いです。
アバター
この記事は every Tech Blog Advent Calendar 2024 の21日目の記事です。 はじめに こんにちは、リテールハブ開発部のネットスーパーチームでFlutterエンジニアをしている野口です。 今回は弊社で運用しているFlutterアプリのログの出し方を整理した際の話をします。 なぜログを整理するか 弊社で運用しているアプリの中で事業譲渡で引き継いだアプリがあります。 そのアプリは以下のように3つのパターンでログを出しており、統一されていませんでした。 print logger ( https://pub.dev/packages/logger ) developer.log (DevToolを使う方法: https://docs.flutter.dev/testing/code-debugging) 統一されていないため以下のような問題があり、整理することにしました。 ログがどこで出てるのか把握しづらい 開発者によって書き方が異なってしまう エラーなどの問い合わせの際に、状況が把握できない ログ整理の方針 ログを整理するにあたり、以下の理由で logger に統一することにしました。 デフォルトでログレベルのサポートがある logger はデフォルトで以下のログレベルを提供しており、これを利用することでログの重要度に応じたログの出しわけができます。 ログレベルの種類 trace debug info warning error wtf(What a Terrible Failure) また、デフォルトでログレベルに合わせて色が変わるため視認性が良くなります。 実際にログを整理する 1. 不要なログ出力コードを整理 まず初めに、既存のログ出力をしている箇所で不要なログを削除しました。 print や developer.log の使用箇所はログ出力する意味がないものだったので削除しました。 logger は例外処理のログ出力で使用していたので、後述のログ出力方法に置き換えるようにします。 2. ログの出力処理を一元化 次に、 AppLog クラスを作成し、ログの出力処理を一元化しました。 これにより、統一された方法でログを出力できるようになり、誤った使い方を防ぐことができるようになります。 class AppLog { static final Logger _logger = Logger(); static void debug({ String message, StackTrace stackTrace}) { _log(message: message, level: Level.debug, stackTrace: stackTrace); } static void info({ String message, StackTrace stackTrace}) { _log(message: message, level: Level.info, stackTrace: stackTrace); } static void error({ String message, Exception exception, StackTrace stackTrace}) { _log(message: message, level: Level.error, exception: exception, stackTrace: stackTrace); } static void _log({ String message, Level level, Exception exception, StackTrace stackTrace}) { if (!kReleaseMode){ _logger.log(level, message, exception, stackTrace); } if (level.index >= Level.info.index && message != null ) { FirebaseCrashlytics.instance.log(message); } if (level.index >= Level.error.index && exception != null ) { FirebaseCrashlytics.instance.recordFlutterError(FlutterErrorDetails(exception: exception, stack: stackTrace)); } } } ログレベルはdebug、info、errorの3種類に分けており、 _log 関数の中でログレベルに応じて処理を分けています。 また、 !kReleaseMode ではリリースビルドでログを表示しないようにしており、本番環境では表示されないため、安心して使用できます。 debugログ debugログは、主に開発時に変数の値やアプリの状態を確認したい場合に使用します。 if (!kReleaseMode){ _logger.log(level, message, exception, stackTrace); } debugログの使用例: void fetchData() { final data = { "key" : "value" }; AppLog.debug(message: "fetchData() called with data: $data" ); } infoログ infoログは、アプリの動作状況やユーザーの操作イベントを記録したい場合に使用します。 また、Firebase Crashlyticsに情報を送信することで、本番環境での挙動を把握するのにも役立ちます。 if (!kReleaseMode){ _logger.log(level, message, exception, stackTrace); } if (level.index >= Level.info.index && message != null ) { FirebaseCrashlytics.instance.log(message); } infoログの使用例: void userLogin( String username) { AppLog.info(message: "User logged in with username: $username" ); } errorログ errorログは、例外が発生した場合や補足したいエラーを記録する際に使用します。 また、Firebase Crashlyticsに例外情報を送信することで、問題を迅速にトラッキングできます。 if (!kReleaseMode){ _logger.log(level, message, exception, stackTrace); } if (level.index >= Level.error.index && exception != null ) { FirebaseCrashlytics.instance.recordFlutterError(FlutterErrorDetails(exception: exception, stack: stackTrace)); } errorログの使用例: void processPayment() { try { // エラーが発生する可能性のある処理 throw Exception( "Payment processing failed" ); } catch (e, stackTrace) { AppLog.error( message: "Payment error occurred" , exception: e, stackTrace: stackTrace, ); } } 終わりに 今回はログの出し方を整理する話をしました。 ログの出し方を一元化したことで、ログの使い方が開発者に伝わりやすくなり、誤ったログの使い方を防ぐことができるようになりました。 さらに、以前は本番環境でユーザーが abd logcat など使用すればログが見える状況でしたが、ログを本番環境で出ないようにし、誤ってユーザー情報がログ出力されるなどのリスクを防ぐことでセキュリティ面でも改善ができました。 ご覧いただきありがとうございました。
アバター
この記事は every Tech Blog Advent Calendar 2024 の 20 日目の記事です。 (2025年5月更新) 目次 はじめに エンジニア内定者研修を実施するに至った背景 エンジニア内定者研修の概要 エンジニア内定者研修の目的 エンジニア内定者研修カリキュラム terminal および Git/GitHub の基礎 ネットワーク/インフラ基礎 DB 研修 プログラム基礎 Web 基礎 Web アプリケーション開発基礎 個人開発 受講者のフィードバック おわりに はじめに こんにちは。 トモニテ開発部ソフトウェアエンジニア兼、CTO室Dev Enableグループの庄司( ktanonymous )です。 現在弊社では、2025年新卒のエンジニア内定者向けに初めての内定者研修を実施しております。 今回の記事では、その内容について紹介したいと思います。 エンジニア内定者研修を実施するに至った背景 まずは、なぜこのタイミングで初めての内定者研修の実施を決定したのか、その背景についてお話ししたいと思います。 弊社では、今年の2月に、開発組織を横断して様々な課題解決を促進するための DevEnable というグループが立ち上がりました。 (以下の記事で DevEnable グループを設立した際の話について綴ってあります) 筆者も所属している DevEnable グループでは、新卒エンジニア向けのサポートも積極的に行っていきたいと考えています。 そんな中で今回のエンジニア内定者研修の実施に至ったのには、主に2つの理由があります。 1つ目の理由としては、これが一番大きいですが、内定者の意欲がありました。 内定者側から「学習機会があれば入社前でも積極的に利用していきたい」という意欲的な意見が上がっていました。 そういった意見を受けて、DevEnableとしても、内定者の積極性と学習意欲に応えたいと感じていました。 そこで、入社前の段階ではありますが、内定者向けに研修を実施するのが良いのではないかと考えました。 2つ目の理由として、今年度の初めに実施したエンジニア新卒研修での反省があります。 DevEnableグループの発足後、1つの新しい大きな取り組みとして、2024年新卒エンジニアの入社後のサポートをするために新卒エンジニア研修を実施しました。 (この新卒研修もエンジニア向けのものは初めてとなる取り組みでした。新卒研修の概要に関しては以下の記事をご覧ください) それまでは、新卒全体での職能に依らない研修はこれまでも実施されていましたが、エンジニア向けの研修は行われておらず OJT でのオンボーディングが基本となっていました。 そのため、エンジニアにフォーカスした研修を実施したことで、エブリーのエンジニアとしてのマインドセットの獲得や 幅広い知識をインプットすることでスムーズな開発組織への参画をサポートすることができました。 一方で、先の記事でも触れている通り、専門領域外を含めて社内の実際の技術スタックに触れてもらったため、以下のようなネガティブなフィードバックもありました。 最低限必要な知識などを事前に共有することで、もう一段階踏み込んだ講義内容になると感じた。 研修で使用する各種ツールの使い方についての研修か資料があると取り組みやすいと思いました。 このようなフィードバックを受け、今後も入社後のより良い新卒研修を実施していくために、受講者の知識のベースラインを揃えておくことが重要ではないかと考えていました。 上記のように、内定者の意欲を受け、新卒研修でのフィードバックを参考にしてエンジニア向けの内定者研修を実施することを決定しました。 エンジニア内定者研修の概要 エンジニア内定者研修の目的 先述の通り、内定者研修では内定者の学習意欲に応えること、来年度入社する新卒のエンジニアメンバーが入社後の研修を通じてよりスムーズに開発組織にジョインできるように ベースとなる基礎知識を学べる機会を提供することを主な目的としています。 具体的には、以下のような目的と方針を設定しました。 目的 入社前に基本的な技術や知識をキャッチアップする環境を提供する 方針 入社前に身に着けてほしい技術や知識のキャッチアップをサポートする 基礎知識を早期にキャッチアップすることで入社後の研修・オンボーディングをよりスムーズに進められるようになる 上記の目的と方針を踏まえ、筆者を含めた新卒3年目までの若手メンバーが中心となり、研修を企画・運営しています。 エンジニア内定者研修カリキュラム 今回の研修では以下のテーマで講義を行うこととしました。 各回 2 時間を目安に、2 週間に 1 回のペースで実施しています。 (執筆現在、第6回まで完了しています) ターミナルおよび Git/GitHub の基礎 ネットワーク/インフラ基礎 DB 研修 プログラム基礎 Web 基礎 Web アプリケーション開発基礎 個人開発 また、遠方から参加する方もいるため、全ての講義はオンラインで実施して録画を残すようにしています。 ただし、学業を優先してもらいたい思いもあったため、任意での参加としています。 terminal および Git/GitHub の基礎 terminal および Git/GitHub の講義では、下記のようなトピックを通じて、 普段から利用機会の多いCLI(ターミナル)やチームでの開発を行うにあたり弊社でも利用している Git/GitHub の基本的な使い方について学びました。 ターミナル/シェルとは何か コマンドはどのようにして実行されているのか 主要なコマンドの紹介 Git の基本的な仕組み Git の基本的なコマンド GitHub の使い方 ネットワーク/インフラ基礎 ネットワーク/インフラ基礎の講義では、OSI 参照モデルを中心に、 ネットワークやインフラの基礎知識について学びました。 具体的には以下のトピックを取り上げました。 プロトコル TCP/IP ネットワークセグメント HTTP アドレス DB 研修 DB 研修では、DB の基本概念やバックエンド/データ系それぞれの視点での利用について学びました。 具体的には以下のトピックを取り上げました。 「データ」の種類 データ基盤 サーバーエンジニア視点でのデータ利用 データエンジニア視点でのデータ利用 プログラム基礎 プログラム基礎の講義では、プログラムの基本的な構造やデータ構造、アルゴリズムについて学びました。 具体的には以下のトピックを取り上げました。 データ構造 配列とリスト スタックとキュー ハッシュ 木構造 アルゴリズム ソート 探索 Web 基礎 Web 基礎の講義では、API や Web アプリケーションの基本構成や仕組み、 バックエンド/フロントエンドそれぞれの役割について学びました。 具体的には以下のトピックを取り上げました。 Web アプリケーションの基本構成 ブラウザでの HTML のレンダリングについて ブラウザとサーバーの通信について ページの配信方式 フロントエンドとバックエンドの役割 Web アプリケーション開発基礎 Web アプリケーション開発基礎の講義では、アーキテクチャやテスト、コーディング時に意識することなど、 組織/チームでの開発に携わるうえで重要となってくる考え方について学びました。 具体的には以下のトピックを取り上げました。 良いコードを書くために意識すること アーキテクチャ テスト CI/CD 個人開発 個人開発では、入社までの期間を通じて、OpenAI の API を利用した Webアプリケーションの開発に取り組んでもらいました。 各々が課題感を持っている事柄に対して自分なりのソリューションを考え、個性豊かなアプリケーションを開発してくれました。 作ってもらったアプリケーションはどれも面白いもので、社内向けに実施した成果報告会では、とてもワクワクしたという声もありました。 受講者のフィードバック 研修の改善のために、受講者からのフィードバックをアンケートで収集しており、その中でも以下のようなポジティブな意見が見受けられました。 以前勉強してから時間が経っていたので、いい復習になりました。それらの知識がエブリーの中でどういうところに当てはまっているのかも知ることができました。 学校の授業ではあまりやらないような,実際のアプリケーション開発に役立つ部分をやってくださり,とても勉強になった 研修自体はまだ続きますが、今回得られたフィードバックをもとに、今後の研修の改善に活かしていきたいと考えています。 講義の風景① 講義の風景① おわりに 今回の記事では、現在進行中の弊社で初めての実施となるエンジニア向け内定者研修についてご紹介いたしました。 内定者研修を通じて、今後入社するエンジニアのメンバーが入社後のオンボーディングをよりスムーズに進められるようにサポートすることができると考えております。 また、研修の企画・運営に携わった若手メンバーにとっても、 知識の整理や研修の主要メンバーとしてという新たなチャレンジの機会となり、貴重な成長の機会となっていると感じています。 今回の研修の経験を含めて、今後もエンジニアの成長を支援する取り組みを続けていきたいと考えています。 最後まで読んでいただき、ありがとうございました。
アバター
この記事は every Tech Blog Advent Calendar 2024 19 日目の記事です。 はじめに こんにちは、 @きょー です!普段はデリッシュキッチン開発部のバックエンド中心で業務をしています。 導入 Go 1.24 が来年の 2 月にリリースされます。 ドラフトではありますが Go 1.24 のリリースノートは既に公開されています(2024 年 12 月 19 日時点)。 tip.golang.org encoding/json に omitzero という json タグが追加され omitempty と何がどう違うのか気になったため、調べたことについて共有していこうと思います。 encoding/json とは json のエンコードとデコードを実装しているパッケージになります。json と Go の値の対応付けを Marshall、Unmarshal によって行っています。 pkg.go.dev サーバーからのレスポンスを json 形式にしてデータを返す際や、リクエストからデータを読み込む際に使うことが多いパッケージかと思います。 まずは omitzero を理解するために omitempty の挙動とその問題点についてみていきます。 omitempty について Go の構造体を json に変換する際に、フィールドの値がゼロ値の場合にそのフィールドを出力しないという便利な機能です。この機能を活用することで、json のサイズを削減したり、不要な情報を排除したりできます。 以下のように json タグに omitempty とつければ機能します。 type User struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` } 実際に空の構造体を用意して動かしてみましょう。全てのフィールドがゼロ値となり、何も出力されないことが期待されます。 func main() { u := User{} jsonData, _ := json.Marshal(u) fmt.Println( string (jsonData)) } output は以下のようになり、省略してほしい値は出力されていないことが確認できました。 {} しかし omitempty には 2 つの課題がありました 課題1 プリミティブな値で構成された空の構造体に対して omitempty を指定するとフィールドが出力されてしまう点です。 例を見ていきましょう。 以下のような構造体で、先ほどと同じように実行してみます。 type User struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` SampleStruct SampleStruct `json:"sample_struct,omitempty"` } type SampleStruct struct { SampleField1 int `json:"sample_field_1,omitempty"` SampleField2 int `json:"sample_field_2,omitempty"` } output は以下のようになり、 omitempty を指定している構造体の値が空なので何も出力されないことを期待しますが、構造体のフィールド名が出力されてしまっています。 { " sample_struct ": {} } 課題2 課題 1 に繋がるところではありますが、構造体自体はその中身が空であっても omitempty はゼロ値とはみなさない点です。 これも例を見ていきましょう。 以下のような構造体で、先ほどと同じように実行してみます。 type User struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"` } output は以下のようになりました。 int や string などのプリミティブな値は期待通り出力されていませんが、構造体を指定した箇所は出力されてしまっています。 { " created_at ": " 0001-01-01T00:00:00Z ", " updated_at ": " 0001-01-01T00:00:00Z " } これは Go の仕様で以下の値しかゼロ値を定められていないためです。 変数や各要素 ゼロ値 bool false 数値型 0 文字列 "" ポインタ nil 関数 nil インターフェース nil スライス nil チャンネル nil マップ nil tip.golang.org 上記のような悩みを解決できるものが omitzero になります。 omitzero について Go 1.24 から使える json タグの一つで、 omitempty と同様にフィールドに指定することで使えるようになります。 先ほどあげた課題が解決されるようになったので、確認していきます。 課題1に対して プリミティブな値で構成された空の構造体も省略されるようになります。 以下のような構造体で、先ほどと同じように実行してみます。 type User struct { ... SampleStruct SampleStruct `json:"sample_struct,omitzero"` } type SampleStruct struct { ... } output は以下のようになり、期待している値になっていることが確認できました! {} 課題2に対して omitzero オプションが追加されたフィールドの構造体に IsZero メソッド(帰り値は bool )がある場合はそのメソッドを用いてゼロ値かどうかを判定し、ゼロ値である場合は omitempty と同様にエンコードから省略されるようなります。 以下のような構造体で、先ほどと同じように実行してみます。 type User struct { ... CreatedAt time.Time `json:"created_at,omitzero"` UpdatedAt time.Time `json:"updated_at,omitzero"` } output は以下のようになり、カスタムした値を初期に設定した構造体も省略されていることがわかります。 {} これは time パッケージの Time 構造体にある IsZero メソッドがゼロ値チェックに使われ、ゼロ値であったために省略されるようになったことを示しています // IsZero reports whether t represents the zero time instant, // January 1, year 1, 00:00:00 UTC. func (t Time) IsZero() bool { return t.sec() == 0 && t.nsec() == 0 } pkg.go.dev 例として Time 構造体を使いましたが、以下のように構造体とそのメソッドである IsZero を用意することで自分たちのアプリケーションに合わせたゼロ値を定義できるようになります。 type User struct { ... SampleStruct SampleStruct `json:"sample_struct,omitzero"` } type SampleStruct struct { ... } func (s SampleStruct) IsZero() bool { // 何の値をゼロ値とするか決められるようになる return s.SampleField1 == 1 && s.SampleField2 == 1 } func main() { u := User{} jsonData, _ := json.Marshal(u) fmt.Println( string (jsonData)) } 使う際は慎重に ここまでゼロ値の省略について話してきましたが、使う際は慎重に使っていきましょう( omitempty もそうですが)。 例えばレコードごとの ID や作成日時などはサーバー側では一見不要に見えるかもしれませんが本当に不要かどうかは判断できません。 省略しても良いかどうかはアプリケーションごとのロジック(ゼロ値をそのまま使いたい場合もたくさんある)にもよりますし、フロントやクライアント側でどう使われているかを把握していなければ適切に使うことは難しい気がしています。 まとめ 以上 Go 1.24 で encoding/json に追加される omitzero オプションの説明でした。軽くまとめます。 omitempty ゼロ値の場合、json 出力から省略する omitzero プリミティブな値で構成された空の構造体の場合はフィールド名も含めて省略する 構造体に IsZero メソッドが実装されている場合、そのメソッドの結果に基づいてゼロ値かどうかを判断し、ゼロ値であれば省略する どちらを使うべきか 構造体のゼロ値判定が必要な場合 omitzero 構造体以外のゼロ値を判定したい場合 omitempty 注意点 必須フィールドやビジネスロジック上重要なフィールドを誤って省略しないように注意が必要 最後に Go 1.24 楽しみですね! 今回紹介したものはほんの一部ですので、気になった方はぜひリリースノート追ってみてください! 明日のアドベントカレンダーは 庄司( ktanonymous )さんによる 「エブリー初のエンジニア向け内定者研修を実施しています」 です! 参考 https://tip.golang.org/doc/go1.24 https://tip.golang.org/ref/spec#The_zero_value https://github.com/golang/go/issues/45669 https://devcenter.upsun.com/posts/go-124/ PS みなさんは好きなゲームありますか?僕は最近 DAVE THE DIVER というゲームにハマっているのですが、永遠に時間が溶けててやばいです。ゲームのやりすぎで朝日を見る事も厭わない勇気有る方達、どうぞ一歩前へ。 store.steampowered.com
アバター
この記事は every Tech Blog Advent Calendar 2024 の18日目の記事です。 はじめに こんにちは、TIMELINE 開発部 Service Development をしている hond です! 今回は12/8に行われ、弊社がISUポンサーとして協賛した ISUCON 14 に会社の同期と友人と共に参加した際に行った事前準備と当日の流れ、反省をまとめていこうと思います。 本ブログ以外にも ISUCONに向けて勉強したこと や ISUCON14 に ISUポンサーの枠で出場しました もアドベントカレンダーとして投稿されているのでぜひ読んでみてください! 事前準備 前提 今回はチームメンバー全員がGoの経験があることから言語はGo、 ISUCON 12予選 より他のDBだとしても使い慣れているMySQLに移行することが良いことを知っていたのでMySQLを使うことを前提に準備を行いました。 概要 事前準備としては主に下記の三つを行いました。 達人が教えるWebパフォーマンスチューニング 〜ISUCONから学ぶ高速化の実践 の読解 過去問 競技に使うツール導入スクリプトの作成 達人が教えるWebパフォーマンスチューニング 〜ISUCONから学ぶ高速化の実践の読解 こちらの本はモニタリングや負荷試験、ログの集計をはじめとしたWebサービスのパフォーマンス改善を行う際に必要なメソッドがまとまったものになっています。 また、本番同様の環境が準備された private-isu に対し、どのような改善を行えばスコアが上がるか丁寧に順序立てて説明されているのでISUCONに参加したことない人やWebサービスのパフォーマンス改善の概要を知りたい人にはおすすめの一冊です! 過去問 AWS環境で構築するものであればISUCON 5以降の一部予選、本戦の問題が公式にてAMIでの実行方法が公開されています。( ISUCON過去問 )本番では例年EC2サーバーが三台用意されるので先ほどのAMIを元に三台起動すると本番同様の環境で過去問に着手することができます。 過去問を解いた後は一部の予選・本戦問題では公式から解説と講評 ISUCON13 問題の解説と講評 が出ているのでそちらを参考に答え合わせを行うとより理解が深まります。 ISUCON 13ではDNSが初登場するなどまだまだ進化し続けるISUCONなので過去問を解いて引き出しを増やしておくと良さそうです。 競技に使うツール導入スクリプトの作成 競技ではMySQLスロークエリーの解析やアクセスログの集計、APIサーバーのプロファイリングが必要になるので、それらを円滑に行うためのツールやcnfを事前にまとめておきました。 MySQLスロークエリーの解析 初期状態ではスロークエリーログの有効化や出力先が設定されていないのでmy.cnfに以下を記述します。 [mysqld] slow_query_log = 1 slow_query_log_file = /var/log/mysql/mysql-slow.log long_query_time = 0 上記によって /var/log/mysql/mysql-slow.log にスロークエリーログ(0秒を閾値にしているので全てのクエリー)が出力されるようになっているので、それらを解析するためのツールとして pt-query-digest を導入します。 pt-query-digestを用いることでそれぞれのクエリーが全体のリクエストタイムに締める割合やRow sentやRow examineを可視化することができます。 最後にこの状態ではスロークエリーログが同一ファイルに出力されベンチマークごとの結果を確認できないので、一時的に出力先を変更するためにpt-query-digestのラッパーの query-digest を導入します。 下記のコマンドを実行することでquery-digestを実行することができます。 perl ./query-digester -duration 10 アクセスログの集計 基本的にISUCONではnginxを経由してAPIサーバーに到達するかたちになっているので、nginxのcnfを修正しJSON形式でログが出力されるようにします。 http { // 省略 log_format json escape=json '{"time":"$time_iso8601",' '"host":"$remote_addr",' '"port":$remote_port,' '"method":"$request_method",' '"uri":"$request_uri",' '"status":"$status",' '"body_bytes":$body_bytes_sent,' '"referer":"$http_referer",' '"ua":"$http_user_agent",' '"request_time":"$request_time",' '"response_time":"$upstream_response_time"}'; access_log /var/log/nginx/access.log json; //省略 } 次にアクセスログを集計するツールとして alp を導入します。 alpはステータスコードやカウント数をはじめレスポンスタイムの最大・平均・最小値などのメトリクスでアクセスログを集計できるツールとなっています。 最後にMySQLのスロークエリーログ同様にベンチマークごとに結果を確認するために、アクセスログファイルをローテートするスクリプトを作成します。 #!/bin/sh new_file_name =/var/log/nginx/access.log. `date +%Y%m%d-%H%M%S` mv /var/log/nginx/access.log $new_file_name nginx -s reopen APIサーバーのプロファイリング 今回APIサーバーの言語はGoを選択しているので、プロファイリングツールとして pprof を用います。 pprofの導入はとても簡単で、下記のように main.go に数行追加するだけでプロファイリングできるようになります! func main() { // ここから runtime.SetBlockProfileRate( 1 ) runtime.SetMutexProfileFraction( 1 ) go func () { log.Fatal(http.ListenAndServe( "localhost:6060" , nil )) }() // ここまでを追加する log.Fatal(http.ListenAndServe( "localhost:8080" , nil )) } 当日 ここからは当日の流れになります。 ツール導入スクリプトの実行 10:00 ~ 10:30 競技に使うツール導入スクリプトの作成 にて作成したスクリプトを実行し計測可能な状態に、一部のコードをGithubに連携しdevelop branchにmergeしたらEC2にデプロイされる状態にしました。 その後初めてのベンチマークの実行を行いスコアの計測を行いました。 この時点でのスコアは700点程度。 10:30 ~ 13:00 計測の準備が完了したので実際にベンチマークを実行してその結果を確認していきました。 初期の実装ではMySQLのCPU使用率が175%付近になりAPIサーバーやnginxがうまく使えていない状態だったのでquery-digestを用いてスロークエリーログの確認を行いました。 スロークエリーの改善では主にquery-digestを用いてトータルレスポンスの割合が上位になっているクエリーの特定、EXPLAINを用いて実行計画の確認を行いクエリーの改善を行いました。 query-digestと実行計画の結果から上位のスロークエリーはテーブルにインデックスが不足していることでフィルタ処理される行の割合、調査される行の見積もりが悪化していることがわかったのでそれぞれのテーブルに対してインデックスの追加を行い改善を行いました。 オーナーに紐づく椅子を取得するクエリーに関してはサブクエリーを実行するなどクエリー自体の改善が必要そうだったので僕がインデックスの追加を行っている間にチームメイトに改善をお願いしました。 インデックスの追加とクエリーの改善によりスロークエリーは改善されお昼時点で3000点を超え一時80位台に到達しました! 14:00 ~ 16:00 スロークエリーの改善は完了していたので、マニュアルの スコアの計算 とアクセスログの結果から配車と通知の改善が必要そうなことを確認し対象の箇所の改善に挑みました。 スコアの計算は下記のように示されていました。 以下に示す要素の合計がスコアとなります。 - 椅子がライドとマッチした位置から乗車位置までの移動距離の合計 * 0.1 - 椅子の乗車位置から目的地までの移動距離の合計 - ライド完了数 * 5 結果としては二時間ほどかけて改善に挑んだのですが、アプリケーションの理解不足でめぼしい改善ができずスコアとしては横ばいでした。 16:00 ~ 18:00 16:00以降は当初より予定していたAPIサーバーとDBのインスタンスを分離する作業に着手しました。 初期実装ではnginx, APIサーバー、DBは全て一つのインスタンスで完結していてスロークエリー改善後もMySQLがCPU使用率の大半を占めていたのでnginxとAPIサーバーのインスタンス、DBのインスタンスと二台構成にする対応を開始しました。 作業としてはAPIサーバーの向き先DBのホストの修正とMySQLで bind-address を追加するのみだったので練習で行った通りに作業を完了しベンチマークでスコアを測定しました。 CPU負荷の大半を占めていたMySQLが分離したことでAPIサーバーで使えるCPUが増えベンチマークのログを見るとスコアが8000点を超えるなど順調に思われましたが下記のクリティカルエラーが発生ベンチマークが失敗する状態になってしまいました。 msg=クリティカルエラーが発生しました error="椅子に想定していないライドの状態遷移の通知がありました (CODE=12): ride_id: 01JEJMQWN1HEGJM5NZQ40A6KG2, expect: MATCHING, got: COMPLETED" クリティカルエラーの内容によると15:00まで改善に着手していた通知の不整合とのことだったのでチームメイトとペアプロで改善を試みましたが、対象のクリティカルエラーは改善することはできず複数台構成を諦めることになりました。 後日同僚にクリティカルエラーについて話したところそのチームでも同様の現象が見られ、初期実装では通知間隔が短く複数台構成にしてパフォーマンスが向上することで不整合が発生するので通知間隔を延ばせば改善できたとのことでした。 通知間隔をどこまで延ばして良いかなどは当日マニュアルの 猶予時間 に記述されていました。 結果 結果スコアの推移は下記画像のようになりました。 インデックスやクエリーの修正によるスロークエリーの改善を行った14:00付近以降はめぼしい改善ができていないので約四時間スコアが横ばいのまま競技終了を迎えました。 しかし、 5.環境チェックNG で失格になりました😭 おそらく原因としては僕が複数台構成にするときにAPIサーバーをdisableにしたような気が。 まとめ 僕自身3回目のISUCON参加となるのですが、今まではろくに準備を行わず参加していたのでそれまでに比べれば入念に準備を行えたため当日は納得のいく動きをできたと感じました(結果としては失格ですが笑)。 複数台構成にした時にベンチマークでクリティカルエラーが発生した通知機能に関してはヒントとなる猶予時間が、当日のマニュアルに記述されていたので改めてマニュアルを確認することは大事だと感じました。 また、ペアプロを行うときにアプリの仕様について確認し合う場面が多くあったので、チームで時間を取りマニュアルの読み合わせを行う時間を取ることでその後の実装で共通認識を持って作業できるのではないかと思いました。 失敗の原因になった環境チェックに関して、当日マニュアルの 競技環境の確認 に環境チェックを行うコマンドが明記されています。 今回はこのチェックを行うことを怠り終了間際までGoのコードと向き合っていたので、普段の過去問を行う際や次回本番では環境確認時にエラーが発生することがあることも踏まえ終了時間の30分以上前には環境確認を含めた締め作業を行おうと思います。 来年はより高スコアを狙えるように普段の業務からパフォーマンスの改善を行えるところがないかなど意識していきます。
アバター
この記事は every Tech Blog Advent Calendar 2024 の17日目の記事です。 はじめに エブリーでデリッシュキッチンの開発をしている本丸です。 恥ずかしながら今までWebのパフォーマンスの調査をしたことがなかったのですが、直近で触れる機会があったため、どのように調査したのか簡単にではありますが説明させて頂こうかと思います。 背景 デリッシュキッチンでSEO対策を行う中で、別のエンジニアの方からPageSpeed Insightsを使ってパフォーマンスの改善を行なってはどうかという話が上がりました。 PageSpeed Insightsについてあまりよくわかっていなかったため調査するところから開始しました。(パフォーマンスでページのランキングが上がるという記述は公式のドキュメントからは見つけられておりませんので、確実にページのランキングに効くという内容ではないことはご認識いただければと思います。) PageSpeed Insights Google DeveloperのPageSpeed Insights API について によれば、PageSpeed Insightsとは下記のことができるツールとされています。執筆時点(2024/12/16)では日本語のページと英語のページに差分があるので 英語ページ を参照していただく方が良いかもしれません。 PageSpeed Insights では、モバイル端末やパソコン向けのページの実際のパフォーマンスに関するレポートと、そうしたページの改善方法を確認できます。 PageSpeed Insightsでは、実際のユーザーの情報の統計からのスコアとLighthouseを使用したシミュレーション環境のスコアを出力してくれます。 Lighthouse chrome for developersのLighthouse の概要 によると、Lighthouseは下記のことができるツールとされています。 Lighthouse は、ウェブページの品質を改善するためのオープンソースの自動化ツールです。公開されているウェブページでも、認証が必要なウェブページでも実行できます。パフォーマンス、ユーザー補助、プログレッシブ ウェブアプリ、SEO などの監査が行われます。 LighthouseではCPUやネットワーク環境を指定することができ、指定したシミュレーション環境でのパフォーマンスを確認することができます。また、パフォーマンスのスコアが低いものに関してどのような改善方法があるのかを教えてくれます。 話の本筋と外れますが、Chromeの開発者ツールなどからPageSpeed Insightsの一機能としてではなくLighthouse単体として使用することもできます。 公開されているものであればPageSpeed Insightsを使用しても良いのですが、ローカルや開発環境など外部に公開していない環境でパフォーマンスを確認したいときにはLighthouseをChromeの開発者ツールから使うのが便利そうです。 デリッシュキッチンをPageSpeed Insightsで確認 デリッシュキッチンをPageSpeed Insightsで確認すると実際のユーザー情報の統計からのスコアは次のようになっていました。 こちらのスコアでは各指標でユーザーごとに良好・改善が必要・不良の3つのどれかで判定され、その75パーセンタイルが最終的な結果として出力されます。 各指標が何を表しているのかを下記にまとめます。 Largest Contentful Paint (LCP) ユーザーがページにアクセスしてから、表示領域内で最も大きなコンテンツが表示されるまでの時間 Interaction to Next Paint (INP) ユーザーがページ内で行うクリックなどの操作の応答性の指標 Cumulative Layout Shift (CLS) ユーザーが予期しないレイアウトの変更がどの程度発生したかの指標 First Contentful Paint (FCP) ユーザーがページにアクセスしてから最初のコンテンツが描画されるまでの時間 Time to First Byte (TTFB) リソースをリクエストしてから最初のデータが返ってくるまでの時間 デリッシュキッチンのスマホ表示では全ての指標で良好を示しているので、実際にデリッシュキッチンを使用しているほとんどのユーザーの環境ではパフォーマンスが問題ないと言えるかと思います。 シミュレーション環境に関しては次のようになっていました。 パフォーマンス、ユーザー補助、おすすめの方法、SEOの4つの項目があるのですが、今回はパフォーマンスに注目していこうかと思います。 パフォーマンスでは次の5つの指標を確認することができます。 First Contentful Paint (FCP) 最初にコンテンツが描画されるまでにかかった時間 Largest Contentful Paint (LCP) 表示領域内で最大のコンテンツが表示されるまでにかかった時間 Total Blocking Time (TBT) コンテンツの最初の描画から、50ms以上かかったタスクの処理時間の合計 Cumulative Layout Shift (CLS) ブラウザの表示領域内で意図しないレイアウトの変更がどの程度あったかの指標 Speed Index (SI) ページのロード中にコンテンツがどれだけ早く視覚的に表示されるか 残念ながらデリッシュキッチンは、CLSの指標以外のスコアが全体的に低いようです。 PageSpeed Insights(Lighthouseでも同様)ではシミュレーション環境で見つかった問題をどのように修正すれば良いかの方針も示してくれます。 例えば、TBTのスコアに悪影響を及ぼしているメインスレッドの処理に関する問題がある場合は図のように方針を示してくれます。 Webのパフォーマンス改善に向けて ここからは少し話が変わりますが、PageSpeed InsightsでTBTの指標が悪いことがわかったのでもう少しだけ詳細に見ていこうかと思います。 Chromeの開発者ツールの中にパフォーマンスの項目があります。 このパフォーマンスを確認してみると次のような結果になりました。(ボトルネックがわかりやすいようにCPUとNetworkの品質を落としています。) 画像のうち、赤枠で囲った箇所がメインスレッドのパフォーマンスを表している箇所で、その中で赤で表示されているものが50ミリ秒以上かかっていてTBTのスコアに悪影響を与えているものになります。 この次に、何の処理がパフォーマンスに悪影響を与えているかの確認などをしていくことになるかと思うのですが、本記事では概要をつかむところまでということでここまでにしておこうかと思います。 まとめ PageSpeed Insightsを使って、実際にデリッシュキッチンのパフォーマンスを見てみましたが、改善の余地がたくさんあることがわかりました。 現段階では、パフォーマンスに関する調査を行い、改善の方針を立てるというところまでしかできておりません。来年から実際にパフォーマンスの改善に向けて動く予定ですので、実際に改善を進める中で何かお伝えできる点があればまた共有させていただければと思います。
アバター
この記事は every Tech Blog Advent Calendar 2024 の 16日目の記事です。 はじめに こんにちは。 株式会社エブリーの開発本部データ&AIチーム(DAI)でデータエンジニアをしている吉田です。 今回は、Databricks Mosaic AIによるLLM アプリケーションの評価についてのお話です。 背景 近年、LLMを利用したアプリケーションが増えており、DELISH KITCHENでもAIによる料理アシスタントとして「デリッシュAI」の提供を開始しました。 そのような状況の中で、サービスにLLMアプリケーションを組み込む際には、アプリケーションの評価がますます重要な課題となっています。 しかし、LLMアプリケーションの品質は、データの質、モデルの性能、プロンプト、retrieverの性能など複数の要素が影響するため、評価は複雑で難しい課題です。 また、アプリケーションの評価に利用するユーザーからフィードバックを収集するためには、レビュー環境の構築やテスト人員の確保、さらに収集したフィードバックの分析といった作業が必要となります。 そこで、Databricks Mosaic AI Agent Evaluationを利用することで、アプリケーション評価にかかる負担を軽減することが可能です。 Databricks Mosaic AI Agent Evaluationとは Databricksドキュメント、 Mosaic AIエージェント評価とは? によると、以下のように説明されています。 Agent Evaluationは、開発者がRAGアプリケーションやチェーンを含む エージェントAIアプリケーションの品質、コスト、およびレイテンシを評価するのに役立ちます。 エージェント評価は、品質の問題を特定し、それらの問題の根本原因を特定するように設計されています。 Agent Evaluation の機能は、 MLOps ライフサイクルの開発フェーズ、ステージングフェーズ、本番運用フェーズ全体で統合されており、すべての評価メトリクスとデータは MLflowランに記録されます。 https://docs.databricks.com/ja/generative-ai/agent-evaluation/index.html 主に以下のような機能が提供されています。 Review App作成 : ユーザからフィードバックを効率よく収集できるアプリケーションを簡単に作成できます 評価と原因分析:集めたフィードバックを活用して、評価指標を可視化し、問題の根本原因を明確にします モデルのデプロイに合わせてReview Appが作成されることで、ユーザからのフィードバックを効率的に収集できる仕組みが提供されます。 また、Databricks Model ServingやVector Search、Unity Catalogと組み合わせることで、LLMアプリケーションの構築、運用、評価を単一のプラットフォームで行うことができます。 LLMアプリケーションの評価 今回は、RAGアプリケーションを例にLLMアプリケーションの評価を行います。 テストには Generative AI Cookbook の 10 minute demo of Mosaic AI Agent Framework & Agent Evaluation 上のコードを利用しました。 Unity CatalogにRAGモデルが登録されている前提で、以下の手順で評価を行います。 Serving EndpointにRAGモデルを登録 & Review Appの作成 Review Appを利用して、ユーザからフィードバックを収集 収集したフィードバックを利用して、評価と根本原因分析 Review Appの作成 Review Appの作成は、以下のコードを実行することで行います。 import mlflow from databricks import agents mlflow.set_registry_uri( 'databricks-uc' ) model_name = "{catalog}.{schema}.{model_name}" # Unity Catalogにモデルを登録 model_register_info = mlflow.register_model(model_uri= "{model_uri}" , name=model_name) # Serving Endpointにモデルをデプロイ & Review Appが作成される deployment_info = agents.deploy(model_name=model_name, model_version=model_register_info.version) agent.deploy を実行することで、Serving Endpointにモデルがデプロイされます。 このとき、Review App用のfeedbackというモデルが同時に作成され、自動的にReview Appが作成されます。 フィードバックの収集 Review Appを活用することで、ユーザからフィードバックを迅速に収集できる環境を整えられます。 これにより、モデルデプロイ後数分から十数分でフィードバックの収集をスタートでき、URL共有するだけでユーザの評価を収集する仕組みが構築されます。 ドメイン知識を持つ社内メンバーやステークホルダーからのフィードバックを収集することで、モデルの品質向上に寄与する重要なインサイトを得ることができます。 Review Appのフィードバック入力 Review Appを利用することでユーザはLLMアプリケーションを利用しつつ、フィードバックを提供できます。 主に以下のようなフィードバックを収集します。 回答に対する正誤 フィードバックの理由 期待される回答 参照されたドキュメントへの正誤 収集されたフィードバックは、自動的にDeltaテーブルに記録され後続の分析に活用することが可能です。 評価と根本原因分析 収集したフィードバックを活用し、以下のような評価を行います。 回答は、ユーザの質問に対応しているか 回答は、期待される回答と比較して適切か 回答は、取得されたドキュメントを元にしているか 取得されたドキュメントは適切か 収集されたデータは以下のような形式で記録されています。一部のカラムを抜粋しています。 {catalog_name}.{schema_name}.{model_name}_payload_assessment_logs : フィードバックの詳細 text_assessment : LLMからの回答に対する評価 ratings : 評価 suggested_output : 期待される回答 retrieval_assessment : 取得されたドキュメントに対する評価 {catalog_name}.{schema_name}.{model_name}_payload_request_logs : ユーザのリクエストとアプリケーションの回答 request : ユーザのリクエスト response : アプリケーションの回答 これらのデータを利用して、以下のような評価データセットを作成し、 mlflow.evaluate を利用して評価を行います。 import mlflow import pandas as pd eval_df = pd.DataFrame([ { "request_id" : "{ID}" , "request" : "{ユーザのリクエスト}" , "response" : "{アプリケーションの回答}" , "expected_retrieved_context" : "[{期待されるドキュメント}]" , "expected_response" : "{期待される回答}" , } ]) eval_results = mlflow.evaluate( data=eval_df, model={評価したいモデル}, model_type= "databricks-agent" , ) mlflow.evaluate を実行することで、モデルの評価を行います。 Evaluation Results Evaluation Result Detail モデル出力と期待結果の比較を行うことで、モデルの性能を評価しています。 評価に対する根拠や、推奨される改善点が表示されるため、モデルの品質向上に寄与する重要な情報を得ることができます。 また、モデル間で評価を比較することで、改善の方向性を検討することも可能です。 終わりに LLMアプリケーションの評価は、サービスの品質向上やユーザ体験の最適化において非常に重要なプロセスです。 Databricks Mosaic AI Agent Evaluationを活用することで、従来は複雑だった評価作業を効率化し、フィードバックの収集から分析まで一貫したプロセスで実施できる強力なフレームワークが提供されます。
アバター
この記事は every Tech Blog Advent Calendar 2024 の 15 日目の記事です。 iPadOS 18の新しいタブバー iPadOS 18では、タブバーのデザインが一新され、これまで画面下部にあったタブバーが画面上部のナビゲーションバー内に移動しています。これによってコンテンツを表示するスペースがより広くなる利点があります。 新デザインはほぼ強制的に適用されるため、タブバーを持つ既存アプリで何らかの対処が必要になる場合があります。トモニテアプリでの対応をご紹介します。 参考 WWDC2024 iPadOSのタブとサイドバーの利用体験を向上 https://developer.apple.com/jp/videos/play/wwdc2024/10147/ 新しいタブバーデザインの適用条件 iPadOS 17上で動作 iPadOS 18上で動作 Xcode 15.xでビルド 旧デザイン 旧デザイン Xcode 16でビルド 旧デザイン 新デザイン タブバーが画面上部にある Xcode 16以降でビルドしたアプリをiPadOS 18以降で動作させた場合だけ、新規デザインが適用されます。 Xcode 16への移行スケジュール 2025年4月以降、App Store ConnectにアップロードするアプリはiOS 18、iPadOS 18に対応したSDKでビルドする必要があります。 そのため2025年3月中にXcode 16に移行する必要があり、新しいタブバーデザインへの対応もそれまでに完了しておく必要があります。 https://developer.apple.com/jp/news/?id=utw4yhtp 既存デザインを維持する方法 タブバーの旧デザインを選択する公式の方法はありませんが、 traitOverrides.horizontalSizeClass を利用して強制的にiPhoneと同じ表示にすることで変化を回避できるようです。 SwiftUI TabView { ... } .environment(\.horizontalSizeClass, .compact) UIKit class MyTabBarController : UITabBarController { override func viewDidLoad () { super .viewDidLoad() traitOverrides.horizontalSizeClass = .compact } } 引用 https://stackoverflow.com/questions/78631030 このコードでは MyTabBarController の配下のViewControllerも影響を受けるため、それらのViewControllerが想定と異なる動作になってしまう可能性があります。また、MacOSでは別の問題があります。 将来のiPadOSでもこの方法が有効な保証はないため推奨できませんが、一時的な回避策としてこの方法の採用を検討しても良いかもしれません。 新しいタブバーデザインに対応 navigationItem.titleView を使っている場合 トモニテアプリでは navigationItem.titleView に検索文字列を入力するためのテキストフィールドを置いていました。 Xcode 16 + iPadOS 18では以下のように、 navigationItem.titleView に配置したビューとタブが重なって表示されてしまいます。 トモニテアプリでは今後、iPadOSでは navigationItem.titleView を使わないこととして、ビューを他の箇所に移動することにしました。ここでは navigationItem.rightBarButtonItems にボタンとして配置することにしました。 navigationItem.title を使っている場合 Xcode 16 + iPadOS 18ではタイトルの文字列が以下のように表示されます。ナビゲーションバーが太くなり、2段目にタイトルが表示されます。 無駄にスペースを消費するため、iPadOSでは今後、重要でない場合はタイトルに表示しないなどの対応をするのが望ましいと思います。トモニテでは、情報が重複する箇所ではタイトルを表示しない対応をしました。 その他 その他、タブバーの旧デザインに依存した箇所を見直す必要があるかもしれません。 タブアイコンの画像が表示されないため視認性が悪化する可能性がある タブバーのカスタマイズをしている場合、カスタマイズができなくなる 最後に Xcode 16 + iPadOS 18のタブバーデザイン変更への対応は、UXが大きく変わり影響範囲が広くなる可能性があります。デザイナー/PdMと連携して十分余裕を持ったスケジュールで対応する必要があると感じました。 この記事が、同様の課題に直面している開発者の方々の参考になれば幸いです。
アバター
この記事は every Tech Blog Advent Calendar 2024 の 14 日目の記事です。 はじめに こんにちは。 開発本部のデータ&AIチームでデータサイエンティストをしている古濵です。 直近開発に取り組んでいるデリッシュAIのアーキテクチャについてご紹介します。 DELISH KITCHENでは 「作りたい!が見つかる」をサービスのコンセプトとして、様々な機能を提供してきました。 一方、ユーザーひとりひとりの多様なニーズに合わせたレシピを提案していくには既存機能だけでは難しい部分があります。 そこで、AIによる料理アシスタントとして「デリッシュAI」ベータ版を一部ユーザーから提供し始めています。 アーキテクチャ AI Server あらかじめレシピのタイトル、手順、タグ情報などを持ったデータをEmbedding化するバッチを組んでおく そのレシピEmbedding情報をベクトル検索できるように Mosaic AI Vector Search を用いたベクトルストアを用意 RAGの仕組みを活用して、ユーザのクエリをベクトル検索し、検索して得られたレシピのコンテキストをプロンプトに入れ込んで、LLMにリクエスト LLMはOpenAI APIを利用し、Structured Outputs機能を活用して、レシピの提案コメントとレシピIDを返す これらのコードをmlflow.pyfunc.PythonModelを継承したクラスとして定義し、mlflow.pyfunc.log_modelでモデルを保存(後述する 今度の課題>コード管理 参照) Mosaic AI Foundation Model Serving の機能で、保存したモデルをデプロイし、Seving EndpointsのURLを取得 例) https://{サブドメイン}.cloud.databricks.com/serving-endpoints/{モデル}/invocations API GatewayにSeving EndpointsのURLを登録しておく Delish Server ユーザからのリクエストを受け取る 過去の会話履歴に基づいてレシピ提案するため、レシピデータと会話履歴を取得し、レシピのメタデータを持った会話履歴データを作成 API Gatewayにユーザのクエリと会話履歴データをリクエスト API Gatewayのマッピングテンプレート機能を使って、リクエストのフォーマットを変換し、DatabricksのModel Servingにリクエスト Model Serving Endpointからのレスポンス(AI検索結果)を受け取り、レシピIDがハルシネーションしていないかなど、レスポンスが適切かをチェック ユーザにレシピを提案するレスポンスを返す 同時に会話履歴を踏まえたレシピの提案をするために会話履歴を保存 同様に蓄積したデータを分析するために、event logを保存 意図 職責による実装の分離 弊社では、データ&AIチームがデータの収集・分析基盤の開発・運用を担当し、事業部のサーバーサイドエンジニアがユーザに提供するためのAPIやバックエンドシステムの開発・運用を担当していることが多いです。 今回のデリッシュAIの開発も同様に、データ&AIチームが機械学習モデルを開発し、事業部のサーバーサイドエンジニアがこれらのモデルをサービスに統合するという役割分担をしました。 これにより、それぞれのチームが普段使い慣れている技術をベースに開発を進めることができ、短期間でデリッシュAIを提供することができています。 API Gatewayの活用 Delish ServerとDatabricksの繋ぎ込み部分は、 Amazon API Gateway マッピングテンプレートと Amazon SageMaker を使用して機械学習を搭載した REST API の作成 を参考に、API Gatewayを採用しています。これは、 以前のPoC実績 をベースにしています。 API Gatewayを利用することで、以下の2点の恩恵を受けられると考えています。 1. ログやメトリクスの拡充 ログやメトリクスは、DatabricksのMosaic AI Foundation Model Servingでも確認できるのですが、詳細を見ることができる情報量がAPI GatewayからCloundWatchに送られるログの方が多いです。実際に開発時にも、CloundWatchのログからエラーの原因を特定することができた実績もあります。 2. リクエスト/レスポンスの柔軟なフォーマット変換 「モデルのI/O」と「APIのリクエスト/レスポンス」が必ずしも一致しない場合があります。 また、何らかの理由でフォーマットを変更したい可能性もあります。 そのとき、マッピングテンプレートを変更することで、「モデルのI/O」と「APIのリクエスト/レスポンス」のどちらかを変更したい場合でも、柔軟な対応が可能になります。 今後の課題 コード管理 デリッシュAIのロジックであるRAGの実装部分は以下のようなコード構成になっています。 これまでは、これをDatabricksの1ノートブック内に全てコーディングする管理をしていました。 しかし、これから開発がさらに進むことを想定して、ある程度コードを共通化したいニーズが生まれて来ており、新たなコード管理の整備が必要だと感じています。 import mlflow from mlflow.models import infer_signature def generate_answer (query, conversation_history): # ここでRAGの仕組みを活用してレシピの提案コメントを生成 return response class DelishAI (mlflow.pyfunc.PythonModel): def predict (self, context, input ): query = str ( input [ 'query' ][ 0 ]) conversation_history = list ( input [ 'conversationHistory' ][ 0 ]) return generate_answer(query, conversation_history) # サンプルデータ query_sample = "もっとスパイシーなカレーのレシピ教えて" conversation_history_sample = [...] input_sample = { "query" : query_sample, "conversationHistory" : conversation_history_sample } output_sample = generate_answer(query_sample, seed_sample, conversation_history_sample) # シグネチャの作成(モデルのI/Oを保存時に付与) signature_sample = infer_signature(input_sample, output_sample) # Unity Catalogで登録するモデル名 model_name = f "{catalog}.{schema}.{model}" # モデルの保存 with mlflow.start_run(): model_info = mlflow.pyfunc.log_model( artifact_path= "model" , python_model=DelishAI(), signature = signature_sample, registered_model_name=model_name, ) 評価データの収集 Databricksでは、 Mosaic AI Agent Frameworkを使用してAIエージェントを作る ことができます。 AIエージェントをデプロイすると、Databricks上で簡単に自分たちが作成したモデルをレビューアプリとして利用することができます。 レビューアプリは AIエージェントの品質に関するフィードバックを利用者から集める ことができ、社内で公開することで、toC向けの機能だとしても品質向上に役立てることができると考えています。 デリッシュAIも、Slackbotで社内提供させるところから始まって改善を繰り返しており、実際にどんな使われ方をしているかを把握することは、開発する上で重要だと感じています。 ただ、AIエージェントの利用には 入出力のスキーマが特定のフォーマット である必要があります。ここでも柔軟にフォーマット変更できるマッピングテンプレートが活きてくると考えています。 まとめ デリッシュAIのアーキテクチャについてご紹介しました。 今後も、ユーザのニーズに合わせた提案していくために、デリッシュAIの機能を拡充していく予定です。
アバター
この記事は every Tech Blog Advent Calendar 2024 13 日目の記事です。 はじめに こんにちは、DELISH KITCHEN でクライアントエンジニアを担当している kikuchi です。 普段会話をする際に、話す相手は誰か、言及する対象は人であるか物であるか、性別はどうか、といった様々な情報から微妙にニュアンスを変えて話すことがありますが、 もしアプリでユーザの特性によって文言を出し分ける、というような機能を実装する場合は、条件分岐が複雑化するなど多くの手間がかかってしまいます。 今回はそのユーザの特性の中でもユーザの性別 (文法上の性別) によって、簡単にアプリ上で表示する文言を切り替えることができる Grammatical Inflection API という機能を紹介したいと思います。 Grammatical Inflection API を使用することで、性別による複雑な条件分岐を実装する手間を省くことができます。 文法上の性別とは 一言で「文法上の性別」と記載しても少し分かりづらいと思いますので、具体例を記載したいと思います。 Android Developer サイト で本件について「フランス語でサービスに登録されていることをユーザに知らせるメッセージの例」があるため引用します。 Masculine-inflected form: 「Vous êtes abonné à...」 (English: 「You are subscribed to...」) Feminine-inflected form: 「Vous êtes abonnée à...」 (English: 「You are subscribed to...」) Neutral phrasing that avoids inflection: 「Abonnement à...activé」 (English: 「Subscription to ... enabled」) この様に英語では文法が変わりませんが、フランス語では微妙に文法が変わっている (「abonné」と「abonnée」で差異がある) ことが分かります。 日本語でも「あなたは〇〇に登録しています」といった統一の文法になると思いますが、上記の様に文法上の性別への対応が必要な言語も存在するため、 多言語対応をする場合は言語に合わせて適切な文法を設定することがユーザに対しての適切なアプローチとなります。 Grammatical Inflection API の概要 こちらの API で提供される機能は大きく分けて 2 つあります。 文法上の性別を選択する 性別によって文字列のリソースを分ける まず 1 についてですが、こちらで選択できる性別の修飾子は 女性的 (feminine) 男性的 (masculine) 中性的 (neuter) の 3 つが存在します。 そして 2 についてですが、Android は以前から言語で文字列のリソースを分けることができますが、そこに更に性別でもリソースを分けることができる様になります。 先程のフランス語を例にすると フランス語、かつ女性的 : res/values-fr-feminine/strings.xml フランス語、かつ男性的 : res/values-fr-masculine/strings.xml フランス語、かつ中性的 : res/values-fr-neuter/strings.xml といった分け方ができ、例えば 1 の機能で女性的 (feminine) を選択していると、 res/values-fr-feminine/strings.xml のリソースから文字列が読み込まれる様になります。 なお、本 API ですが、 Android 14 以降の端末のみサポートされる Android Studio Giraffe Canary 7 以降の環境のみ、性別のリソースの修飾子 (values-fr-masculine の masculine の部分) がサポートされる といった制約があるため、事前に対象の OS を絞る、開発環境を新しくするといった対応が必要となります。 実装方法 本項目では具体的な実装方法について説明したいと思います。 文法上の性別を選択する 文法上の性別を選択する API の実装方法について説明します。 まずは AndroidManifest.xml で API を実施する Activity に宣言を追加します。 <activity android:name=".TestActivity" android:configChanges="grammaticalGender" ← こちらを追加する android:exported="true"> </activity> そして次に性別を選択する API を以下の様に実装します。 val gIM = getSystemService(requireContext(), GrammaticalInflectionManager::class.java) gIM?.setRequestedApplicationGrammaticalGender(Configuration.GRAMMATICAL_GENDER_FEMININE) GrammaticalInflectionManager のサービスにアクセスし、setRequestedApplicationGrammaticalGender メソッドを呼ぶのみとなります。 こちらで指定できる値は Configuration.GRAMMATICAL_GENDER_FEMININE : 女性的 Configuration.GRAMMATICAL_GENDER_MASCULINE : 男性的 Configuration.GRAMMATICAL_GENDER_NEUTRAL : 中性的 となります。 本 API ですが、アプリの初回起動時のアンケート、あるいは設定画面などで性別を選択する UI を用意し、ユーザが選択したタイミングで性別を選択する API を実行する、といった使い方ができるかと思います。 なお、選択した性別を取得する API は以下の様に実装します。 val gIM = getSystemService(requireContext(), GrammaticalInflectionManager::class.java) val grammaticalGender = gIM?.applicationGrammaticalGender こちらも選択のケースと同様で、GrammaticalInflectionManager サービスにアクセスし applicationGrammaticalGender で値を取得するのみとなります。 性別によって文字列のリソースを分ける 次に性別によって文字列のリソースを分ける方法ですが、こちらは特にコードを書く必要はありません。 概要で説明した通り、性別のリソースファイルを用意するのみとなります。 ●res/values-fr/strings.xml (言語のリソースファイル) <resources> <string name="test">test</string> </resources> ●res/values-fr-feminine/strings.xml (女性的のリソースファイル) <resources> <string name="test">test_feminine</string> </resources> ●res/values-fr-masculine/strings.xml (男性的のリソースファイル) <resources> <string name="test">test_masculine</string> </resources> ●res/values-fr-neuter/strings.xml (中性的のリソースファイル) <resources> <string name="test">test_neuter</string> </resources> 文法上の性別を選択する API で Configuration.GRAMMATICAL_GENDER_FEMININE を設定していた場合、test の識別子のリソースにアクセスすると 女性的のリソースファイルにアクセスするため、「test_feminine」という文字列が取得できる様になります。 なお、文法上の性別を選択する API を実行していない場合は言語のリソースファイルにアクセスするため「test」という文字列が取得できます。 実装としては以上となります。 注意点 言語のリソースファイルには全てのリソースの識別子が網羅されており、性別によって変化させたい文字列のみ性別のリソースファイルに定義する必要があります。 また言語のリソースファイルに定義されていないリソースの識別子を性別のリソースファイルに定義はできません。 具体例を記載します。 ●res/values-fr/strings.xml (言語のリソースファイル) <resources> <string name="test">test</string> </resources> ●res/values-fr-feminine/strings.xml (性別のリソースファイル) <resources> <string name="test">test_feminine</string> </resources> ●res/values-fr-masculine/strings.xml (性別のリソースファイル) <resources> <string name="test_aaa">test_masculine</string> </resources> このようなケースの場合、 values-fr-masculine に 「test」が無いが、values-fr に定義されているのでエラーにはならない (values-fr の「test」が読み込まれる) values-fr-masculine に 「test_aaa」が定義されているが、values-fr に定義されていないためエラーになる となるため、エラーを回避する場合は values-fr にも test_aaa を定義する必要があります。 こちらは大本のリソースファイルと言語のリソースファイル (values/strings.xml と values-fr/strings.xml) の関係と同様のルールとなります。 また、values-fr-neuter が存在しませんが、values-fr が存在しているのでエラーとはなりません。 まとめ 翻訳自体はかなり専門的な知識が必要となりますが、私自身海外のアプリを使用する際は翻訳が雑だと怪しいと感じ、逆に翻訳が行き届いていると丁寧なアプリだと感じることがあるので、 こういった細かい部分を丁寧に対応することがユーザの獲得、継続利用に繋がると考えています。 また日本語の場合でも、性別によって言葉を変えることでよりターゲットを絞った訴求ができることも考えられるため、一度この機能の導入を検討してみてはいかがでしょうか。
アバター
はじめに この記事は every Tech Blog Advent Calendar 2024 の12日目の記事です。 DELISH KITCHENのiOSアプリ開発を担当している池田です。今回はiOSプロジェクトでのGraphQLクライアントをApollo iOSから自前実装へ移行した経験についてお話しします。 背景 DELISH KITCHENのAPIの一部でGraphQLを利用しており、開発効率向上のために Apollo iOS を導入していました。これにより、GraphQLの利用をより簡単に行える環境を整えていました。導入時の詳細については以下の記事をご参照ください。 tech.every.tv GraphQLについて GraphQLでは、必要な情報だけを取得できたり複数のエンドポイントのリクエストをひとつにまとめたりできる柔軟なデータ取得が特徴です。クライアント側で必要なデータを宣言的に指定できるため、データの過不足なく効率的な通信が可能になります。一方で、新しい技術仕様の習得やクエリ設計のベストプラクティスの理解など、学習コストが比較的高いことが課題として挙げられます。 Apollo iOSを使う利点 Apollo iOSには主に以下の利点があります: GraphQLのスキーマとクエリからSwiftコードを自動生成できることによる開発効率の向上 クライアントサイドでのキャッシュ管理の簡略化とパフォーマンスの最適化 型安全性の保証による実行時エラーの防止 DELISH KITCHENでは、特にコードの自動生成による開発効率向上を目的としてApollo iOSを導入していました。 自前実装の経緯 Apollo iOSは、コード自動生成機能により当初の目的であった開発効率化を実現していました。しかし、DELISH KITCHENのAPIの大半はRESTfulで、GraphQLの利用は限定的であったため、実際の効率化の効果は想定を下回っていました。 さらに、今後もGraphQLの利用を積極的に拡大しない方針が決定されたことで、Apollo iOSを維持するコストが相対的に高くなってきました。具体的には以下の課題が浮き彫りになりました: iOSプロジェクトのApollo iOSへの依存関係の管理 コード自動生成に必要な非Swiftファイルの維持 自動生成されたファイルによるプロジェクト管理上の複雑さ これらの状況を踏まえ、Apollo iOSへの依存を解消し、必要最小限の機能に特化したGraphQLクライアントを自前で実装することを決定しました。 GraphQLクライアントの自前実装 GraphQLは本質的にはHTTP POSTリクエストであり、適切なリクエストボディを構築することで実装が可能です。ここでのクエリ文字列自体はApollo iOS使用時と同様にスキーマを元に構築する必要があります。以下に基本的な実装例を示します: // GraphQLのエンドポイントURL let url = URL(string : "https://api.example.com/graphql" ) ! var urlRequest = URLRequest(url : url ) urlRequest.httpMethod = "POST" urlRequest.setValue( "application/json" , forHTTPHeaderField : "Content-Type" ) // スキーマを元に構築したGraphQLのクエリGraphQLのクエリ let query = """ mutation CreateOrder($productId: ID!, $quantity: Int!, $shippingAddress: AddressInput!) { createOrder(productId: $productId, quantity: $quantity, shippingAddress: $shippingAddress) { orderId totalPrice estimatedDeliveryDate status } } """ // 変数の定義 let variables : [ String : Any ] = [ "productId" : "prod_123456" , "quantity" : 2 , "shippingAddress" : [ "street": "123 Main St", "city": "Tokyo", "postalCode": "100-0001", "country": "Japan" ] ] // リクエストボディの構築 let body : [ String : Any ] = [ "query" : query , "variables" : variables ] // リクエストの実行 urlRequest.httpBody = try ? JSONSerialization.data(withJSONObject : body ) do { let (data, response) = try await URLSession.shared.data( for : urlRequest ) // レスポンス処理 if let httpResponse = response as? HTTPURLResponse { switch httpResponse.statusCode { case 200 : let json = try JSONSerialization.jsonObject(with : data ) print( "注文が成功しました:" , json) default : print ( "エラーが発生しました。ステータスコード:" , httpResponse.statusCode) } } } catch { print( "リクエストエラー:" , error) } このように、GraphQLクライアントの基本的な機能は標準的なネットワーキング処理で実装することができます。実際のプロジェクトでは、この基本実装をベースに、既存のRESTful APIクライアントと同じインターフェースで利用できるよう設計し、ネットワーキング層の実装を共通化しました。 おわりに 今回は、Apollo iOSから自前実装への移行について紹介しました。GraphQLの利用範囲や開発方針に応じて、時にはサードパーティライブラリへの依存を見直し、シンプルな実装へ移行することも選択肢のひとつとなり得ることが分かりました。 この記事が、同様の課題に直面している開発者の方々の参考になれば幸いです。
アバター
はじめに この記事は every Tech Blog Advent Calendar 2024 11 日目の記事です。 こんにちは。DELISH KITCHEN 開発部 RHRA グループ所属の池です。 2024年6月、エブリーは5つの小売アプリの運営について事業譲渡を受け、『 retail HUB 』へ移管しました。 移管してから半年間、引き継ぎ元の企業様からサポートをいただきながら、システムの移管と運営を行ってきました。 システムの移管は、システムを構成する各種サービス・ツールの公式の移管手順に従って基本的には行いますが、中には記述が不明瞭な場合もあり、試行錯誤が必要でした。 本記事では、事業譲渡を受けた小売アプリのシステム移管作業をまとめ、移管作業における注意点もあわせて紹介します。 同じようにシステムを引き継ぐ機会がある方々の参考になれば幸いです。 また、引き継ぎ作業の前段階で行ったシステムデューデリジェンスについても先日別の記事にて紹介しておりますので、もし興味があればご覧ください。 tech.every.tv 移管対象のシステムについて 事業譲渡を受けた小売アプリのシステムは、iOS/Androidのネイティブアプリと、入稿管理画面の Web アプリケーションサーバー、アプリ向け API サーバー、それらを構成するシステム(AWS環境、ロードバランサー、データベース、バッチサーバーなど)です。 これらは以下を利用して構築され、それぞれが移管対象となります。 システム GitHub リポジトリ AWS アカウント Firebase プロジェクト Google Analytic プロパティ ドメイン App Store Connect アプリ Google Play Console アプリ 外部サービスの契約 PUSH通知サービス システム監視サービス WAFサービス SMSサービス その他 ドキュメント Backlog また、移管にあたっての前提ですが、移管元の企業様はエブリーへの事業継承後も引き続き一部の開発を担う契約となっているため、システム移管後も開発が継続できる状態を保つことが前提となります。 そのため、各種移管において、ユーザーアカウントの権限設定やユーザー自体の移管も含めて検討・作業を行う必要があります。以下の内容はその前提を踏まえた作業内容および注意点としてお伝えします。 それでは、それぞれのシステムに関する移管作業まとめていきます。 GitHub リポジトリ の移管 GitHib リポジトリの移管は 公式手順 にて確認できますが、公式手順の他にもいくつかの考慮事項がありました。 エブリーのGitHubに招待する移管元開発者の権限設定 移管対象のリポジトリのみを閲覧操作できるような権限設定が必要 移管に伴う影響の確認 上記を踏まえて、GitHubリポジトリの移管は次の手順で行いました。 移管元企業様にGitHubリポジトリと連携している外部サービスを洗い出していただく 事前に移管元の開発者を移管先のGitHub組織に招待し、移管対象のリポジトリだけを閲覧できるように設定したTeamに所属させておく 移管元GitHubにてリポジトリの移管を行う リポジトリの Settings > Transfer から移管先のGitHub組織名を入力して移管を行う 移管先GitHubにてリポジトリが移管されたことを確認する 移管されたリポジトリを対象のTeamに所属させる 1.で洗い出した外部サービスにおいて、GitHubと再接続させる 注意点 注意点は2つあります。 移管対象のリポジトリ自体に直接属しているGitHubアカウントが合わせて移管される リポジトリを参照しているサービスとの連携が切れる 1点目について、GitHub組織のシートに空きがある場合、移管元のリポジトリ自体に直接属しているGitHubアカウントも合わせて移管されてしまいます。 私たちもそのことを正しく把握できていなかったため、移管後に意図しない引き継ぎ元企業様のGitHubアカウントがエブリーのGitHub組織に属している状態になっており、費用が余分に発生していました。幸いにも権限設定を正しく管理していたため、他のリポジトリを閲覧操作できることはなかったのですが、組織自体の権限設定次第では見せてはいけないリポジトリが閲覧操作できてしまう可能性があるので、注意が必要です。 2点目について、移管を行うと、GitHubリポジトリを参照している外部サービスにおいて参照できない状態になるため、再接続するまで使えなくなります。 私たちのシステムでは AWS CodePipeline で GitHub リポジトリと接続しており、移管後に再接続が必要でした。もし他にも接続しているサービスがあれば再接続が必要になるので、移管前の準備として接続しているサービスやツールを洗い出して影響範囲を確認しておく必要があります。 AWS アカウント の移管 前提として、移管対象のAWSアカウントは 移管元企業様の AWS Organizations に各AWSアカウントが所属おり、それらをエブリーの AWS Organizations に属する形へと移管を行います。 AWS Organizations 間におけるAWSアカウントの移管は こちらの手順 にて確認できます。 公式手順以外には以下のようなことが考慮事項でした。 AWSアカウントの所有権の譲渡 移管後にAWSアカウントを利用する移管元開発者の権限設定 まず、移管の前段でAWSアカウントの所有権の譲渡を行いました。所有権の譲渡が完了してしまえば、後の作業は全て弊社内の作業にて完結するようになるので、先に移しておくと作業しやすくなります。 所有権の譲渡については、今までは譲渡同意書をAWSに提出する必要があったようですが、今年から譲渡同意書は不要になりました。 譲渡要件は こちら で確認ができます。今回の作業では以下のアカウント情報を更新することで所有権を譲渡できました。 アカウント設定 連絡先情報 支払いの詳細設定 所有権をエブリーに移した上で、権限設定の考慮事項を踏まえて AWS Organizations 間におけるAWSアカウント移管の作業を以下の手順で行いました。 移管元の開発者を移管対象のAWSアカウントごとにIAMユーザーとして作成(管理上の都合で各AWSアカウントごとにそれぞれIAMユーザーとして作成) 最小限の権限となるようにポリシー設定を行う 移管先の AWS Organizations にてAWSアカウントの招待を行う 招待の画面にて、移管対象のAWSアカウントIDを入力することで招待を行える 招待したAWSアカウントに設定されているメールアドレスに招待メールが届き、招待を承認する 移管したAWSアカウントにSSOログインできるように設定を行う 以上でAWSアカウントの移管は完了です。 注意点 AWSアカウントの移管にあたっては、大きく注意すべきポイントはありませんでしたが、強いてあるとすれば、移管したタイミングで請求書が分かれるということです。 移管した月は請求書が2つに分かれるので、そのことを把握していないと片方の請求書を見落としてしまう可能性があるので、ご注意ください。 また、今回は各AWSアカウントごとにIAMユーザーを作成する形をとりましたが、移管元企業様が引き続き開発を行える状態をどのように維持するかについて、事前に十分に協議して擦り合わせておくことが大切です。 Firebase プロジェクト の移管 Firebase プロジェクトはGoogle Cloud プロジェクトと連携しているので、該当のGoogle Cloud プロジェクトの移管を行うことでFirebase プロジェクトを移管できます。 移管の前提として、今回移管したプロジェクトは組織に属していない「組織なし」のプロジェクトを、エブリー組織に属するよう形への移管となります。 組織なしのプロジェクトの移管は こちらの手順 にて確認できます。 GitHubやAWSの移管と近しいですが、手順書以外の考慮事項は以下でした。 プロジェクトに設定されているIAMの整理 作業者アカウントの権限準備 上記を踏まえて次の手順を移管作業を行いました。 プロジェクトに設定されているIAMの整理 プロジェクトに設定されているIAMは移管時に移管先の組織に属するように設定されてしまうため、事前に全てのIAMを確認して整理します。 作業者のアカウント権限を準備 移行作業を実施する Google Cloud アカウントは以下のような状態にする必要があります。 移管対象プロジェクトのオーナー 移管先の組織の管理者 移管の実施 Google Cloud コンソールからコマンド実行するためのターミナルを開き、移管コマンドを実行します。 // 移行コマンド gcloud beta projects move <移管対象のPROJECT_ID> --organization <移管先のORGANIZATION_ID> // プロジェクトが属しているORGANIZATION_IDを取得するコマンド。移管確認用。 gcloud projects describe <移管対象のPROJECT_ID> --format=json | jq -r ".parent" 注意点 注意点はGitHubリポジトリの移管と同様で、アカウントも一緒に移管されることです。想定しないアカウントが移管先の組織に属する形になることを防ぐため、移管前にIAMの整理を行うことが大切です。 Google Analytics プロパティ の移管 Google Analytics プロパティの移行手順は公式の [GA4] プロパティを移行する 手順にて行いました。 手順の流れは Firebase プロジェクトの移管と同様で、以下のような流れでした。 プロパティに属しているアカウントの整理 作業者のアカウント権限を準備 移行作業を実施する Googleアカウントを以下のように権限設定する必要があります 移管対象プロパティの管理者 移行先のGAアカウントに対する管理者もしくは編集者 移管の実施 Google Analytics コンソール画面から公式の手順に沿って移管を実行することができます 注意点 まず、移管にあたって一つ理解が必要なのは、Google Analytics は GAアカウントに複数のGAプロパティが属する構造となっており、GAアカウントとGAプロパティはそれぞれで権限設定が行えるという点です。 GAアカウントとGAプロパティの言葉の違いを理解した上で、適切にそれぞれのユーザー権限設定を行うこと大事です。 また、注意点ではないですが、移管作業では以下の点について問題ないかどうかをドキュメントから正確に読み解くことが難しかったので、記録として残しておきます。 過去のデータが引き継がれるか 移管の実施はデータ収集に影響がないか これらは問題ありませんでした。過去のデータは引き継がれ、データ収集も影響がないことを確認しました。 ドメインの移管 移管対象のドメインはDoレジで管理されており、エブリーで主に利用しているお名前.comへと移管しました。移管手順は 他者からお名前.comへのドメイン移管 に沿って比較的楽に実施することができます。 作業自体に関しては注意点は特にありませんが、作業に費用が発生するため、注意が必要です。 App Store Connect アプリ の移管 App Store Connect アプリについては移管はこれから実施を予定しており、現在計画立てを行っている段階です。 計画立ての途中ではありますが、現時点で以下のような影響がわかっています。 移管実施に伴うユーザー影響 キーチェーンにデータ保存している場合に参照できなくなる Sign in with Apple でログインできなくなる 譲渡における影響は アプリの譲渡の概要 にて確認できます。 Google Play Console アプリ の移管 Google Play Console アプリについてもこれから移管する予定となっているので、本記事では割愛いたします。 移管作業が完了したらまた具体的な話をお伝えできればと思います。 まとめ 本記事では、システム移管の作業内容と、作業から得られた注意点を紹介しました。 移管するシステムの状態によって具体の作業内容は少しずつ異なってくると思うので、その点を踏まえてご参考にしていただければ幸いです。 最後までお読みいただき、ありがとうございました。
アバター
この記事は every Tech Blog Advent Calendar 2024 の 10 日目の記事です。 エブリーで小売業界に向き合いの開発を行っている @kosukeohmura です。 エブリーは ISUCON14 にて ISUポンサーとして協賛いたしました。社に 1 枠の参加確定枠を頂き、僕は社内で きょー と mbook と組んでチーム EveryBitCounts として出場する機会をいただけました。残念ながら最終スコアは 0 と惨敗でしたが、前日までの準備と当日のこと、それから反省について書きたいと思います。 tech.every.tv 前日までの準備 チームの 3 人はいずれも業務で Go を使うバックエンドのエンジニアですが ISUCON の参加や学習経験はありませんでした。前準備として社内の ISUCON 参加経験者を招き、概要の説明を受け、それから準備のための数時間のミーティングを数回組みました。 過去回の競技内容・レギュレーションを確認し、下記の攻略記事を読み合わせたところ、 isucon.net 計測結果を見て、問題を見出す。 比較的簡単に(見える)修正を施す。 改善されたことを確認する。 以上を繰り返すとそれなりの高得点を目指せそうだと捉えました。また上記の中で「計測結果を見て、問題を見出す。」ステップには比較的自信がなかったことから、練習用の t3.micro の EC2 インスタンスを立ち上げ学習を行いました。 具体的に行ったことを記します: GitHub でのソースコード管理 当日用のリポジトリを作成、当日必要となりそうなコマンドを Makefile 化しておく RSA 鍵ペアを生成し設置。EC2 サーバー上で git push などが行えるようにする デプロイ EC2 サーバー上で git pull した後サーバーアプリをビルドし、各種サービスの再起動までを 1 コマンドで行えるように MySQL スロークエリの表示 スロークエリログの有効化、しきい値の設定方法メモ mysqldumpslow の結果の読み方の把握 alp での NGINX のアクセスログの集計 コマンドの実行、結果の読み方の把握 -m オプションを使っていい感じに結果を束ねて表示する pprof の使い方 プロファイリング方法、プロファイリング結果の読み方の把握 問題の見出し方 計測結果から変更対象の箇所を特定するまでどのような思考を経るかを話し合う 当日 チーム全員起床に成功しオフラインで集まり作業しました。時系列でざっと流れを書きます。 開始〜12:00 まず技術要素(MySQL, NGINX, go-chi/chi, jmoiron/sqlx)を軽く確認し、公開されたドキュメントを読み込みました。この間に 1 人はソースコードを Git 管理したりベンチマーク計測したりと開発準備を行いました。結果、開始 1 時間以内には変更をデプロイできるようになり、チームのうち 2 人はドキュメントを一通り把握した状態となりました。 その後 mysqldumpslow や alp での集計を行いつつ、以下 3 つの問題に目をつけて 3 人でそれぞれ分担・着手しました。 GET /api/owner/chairs で椅子をリストアップするクエリが重いこと GET /api/app/nearby-chairs での処理が遅く、中身を見ると椅子全件取得後に椅子とライドでの多重ループで O(N * M) なクエリ発行がされていること GET /api/internal/matching の処理内容がランダムかつ椅子 1 つのみに対しての処理であり、適切な椅子とライドのマッチングができずスコア算出に大きく影響を及ぼしそうなこと 前日までの準備の甲斐あって、デプロイや計測ツールの準備については問題なく行えました。また問題箇所の特定についても(通知周りの問題を後回しにしてはいますが)おおよそ的を得た内容だったと思います。 この時点のスコア: 900 前後 (初期状態) 12:00〜14:00 昼食を取りつつ、それぞれの問題の解消に向けて処理を読み改修方針を考える時間でした。それぞれが別々の問題に取り組んでいるので会話もまばらになります。 僕は定期実行される GET /api/internal/matching でのマッチング処理の改善を担当していました。表には出ない Endpoint であり、マッチングできさえすれば好きに変えて良い(Endpoint 自体廃止しても良い)とのことで、いろんなことを考え方針策定に時間をかけていました。具体的には下記のような事を考えていました。 マッチングの間隔: 初期状態では 500ms ごとの処理だが、この間隔はどの程度椅子の稼働率に影響があるのか? ユーザーからの評価の上げ方: 高い評価を得るほどにユーザーが増え結果スコアも伸びるが、距離の長いライドに遅い椅子がマッチすると到着が遅くなり評価が得られないということになるか。 むしろ長距離ライドに対しては速い椅子が確保できるまでマッチングを控えたほうが平均の評価としては上がるのか? とはいえマッチングまでの時間も評価対象だし、、むしろ長距離過ぎるライドは無視するのが得策か?でも緯度経度に制限がない以上どこからを長距離としてもいいかわからないな、、? 全体効率の最適化: 椅子の性能(スピード)にかかわらず片っ端から近い順にマッチングさせたほうが全体としては効率が良くなるか? 速い椅子をフル稼働状態にすることを優先し、遅い椅子の稼働は控えめにしたほうが高効率か? 結果、考えても良くわからなくなってきたので、メンバーと相談し マッチング処理の定期実行を廃止。ライドが新規発生した際と評価完了した際、それとオーナーにより新しい椅子が有効化された際にそれぞれマッチング処理を実行するようにする マッチング間隔の短縮は、ライドのマッチング時間短縮と椅子の稼働率向上それぞれに寄与する。よって短いに越したことはないだろうということで マッチングの際、速い椅子を遅い椅子より優先的に使い、また乗車位置に近い椅子を遠い椅子より優先的に使うように 評価の上げ方は細かくは非公開であり、また全体効率も考えてもわからないと判断し一旦の決め。試行錯誤前提のロジック と方針を立てて実装を開始しました。この時点ではスコアは伸びていないものの、まっとうに方針を決められたことで、これからしっかり実装すればスコアを爆上げできるとと考えていました。 この時点のスコア: 1,000 前後 14:00〜16:00 立てた方針に沿って実装を行いました。変更箇所が思ったより多く、見立てより時間がかかりました。 この時間帯にはぼちぼち他のメンバーの修正が完了し、デプロイが行われはじめました。しかしデプロイしても期待した上がり幅が得られなかったり、FAIL し revert したりしている様子でした。 このとき、開発フロー面での課題が明らかになってきました: 変更後の検証作業が直列でしか行えない 3 人で作業しているにも関わらず、共通の Makefile などを main ブランチにコミットした後、そのままの成り行きで全員 main ブランチを使っている ブランチと同様に、3 台のマシンが与えられたにも関わらず 1 台のみを 3 人で共用している デプロイ環境整備を行ったメンバー 1 人のみが成り行きでサーバー上での作業役となり、他のメンバーのデプロイ作業の肩代わりやデプロイ順番待ちの管理をする羽目になっている ベンチマーカーが FAIL した際に直接の原因 (何の Assert に失敗したのか) はわかるものの、根本原因を知る手段 (エラーログを見るなど) がなく確実な修正ができない この時点で開発フローを変えようという気にはなりませんでしたが、開発フローの問題は最後までつきまといました。 細かな経緯は忘れましたが、主に ride_statuses テーブルにインデックスが貼られたことにより、スコアは倍増しました。 この時点のスコア: 2,120 16:00〜終了 16:00 過ぎに僕の担当していたマッチング処理の修正が完了しました。僕としてはいち早く適用しスコア爆上げと行きたかったところですが FAIL し、その原因が修正できず revert しました。 サーバーを全員で 1 台しか使っておらず、他の修正のデプロイ&ベンチマーカー実行の試行錯誤が盛んに行われていたことから、僕は担当していたマッチング処理の修正適用を諦め、せめてサーバーと RDB のマシンを分けようとしましたが、間に合いませんでした。 終了前の数十分は焦りが増す中でベンチマーカーが FAIL を示すようになり、 git reset で変更を切り戻しては混んできたベンチマーカーに Enqueue し、祈り、FAIL し絶望するという辛い時間を過ごし、終了時間を迎えました。 最終スコア: 0 悲しい結果となりましたが、以降記憶が新しいうちに振り返ってみます。 良かったところ 準備の成果を十二分に発揮できました。変更を Git 管理し反映できる状態をスムーズに作れ、また alp 等を使った問題特定も早期かつ妥当に行うことができました。 反省 圧倒的素振り不足 一度でも本番相当の演習を行っておけば気付けるような開発フローの問題が露呈し、クリティカルな敗因となりました。社内で一番良い成績を取った別チームのメンバーも「問題を何度も解いた」と書いてますし、全員揃った状態実践を想定した演習を一度でも行っておくべきでした。 tech.every.tv パフォーマンス改善には改修を伴う 何を当たり前のことと思われるかもしれませんが、ISUCON の勘所は問題の特定だと捉え、それさえ正しくできればその後の修正は普段の開発業務の延長であろうと高をくくっていました(これはチーム全体というより僕だけかもしれません)。 本番では問題特定には概ね成功したものの、それを解消するための変更に失敗しました。普段の開発ではローカルでの開発環境があり、気軽に単体テストを書き CI/CD に反映を委ねます。それがない状況でどのように改修を行うのかを真面目に検討できていませんでした。 担当の割り振り方 これは結果論かもしれませんが、一見関係の薄そうに見える処理についても元をたどると共通の問題に当たったりします。今回(気軽に相談は挟むものの)ハッキリと問題ごとに担当を分け作業をしたので、多くの表面的な問題の背後に存在する根本的な原因について相談し共通の意思決定に持っていくことができませんでした。分担するにしてもその単位を細かくするなど担当の割振りは改善の余地があると考えています。 具体的には chairs テーブルに最新の位置情報やマッチング状態が存在しないことが、 rides や ride_statuses , chair_locations テーブルとの JOIN やループ処理を発生させパフォーマンス劣化につながるという事態が複数箇所で見られたはずで、そこはチーム共通で指針を立てられたら良かったと思います。 ドキュメントを全員で読み合わせなかった 最初にデプロイの仕組みを整備したメンバーは、その後クエリのチューニング作業を担当し、そのまま実作業に入りました。その結果、そのメンバーはスコアの算出方法すら後半に知るような事態が起こりました。 複数人で協力して作業するにあたり、まずドキュメントを読み合わせ、不明点を解消し、何を目指していくのかの認識をざっと揃えておくことがその後の作業のスムーズさに大きく寄与すると感じました。 さいごに 反省点はたくさんありますが、ISUCON14 に楽しく参加することができました。他の方のリポジトリやブログ記事を読みたいと思っていますが、全然追いついていません。 またこの場を借りて運営の方々への感謝を申し上げます。Discord や公式ブログでのアナウンスもとてもわかりやすかったですし、当日のライブ配信も楽しく観覧しました。出題内容やドキュメント、当日の Dashboard も良くできており、ワクワクしながら競技に参加することができました。ありがとうございました。 当日の問題やドキュメントを含むリポジトリが公開されており、docker compose でも動かせるようですので時間を見つけてまた触ってみようと思います。 github.com エブリーでは、ともに働く仲間を募集しています。不甲斐ない結果でしたが、少しでもエブリーに興味を持っていただけた方は、一度カジュアル面談にお越しください! corp.every.tv
アバター
この記事は every Tech Blog Advent Calendar 2024 9 日目の記事です。 はじめに こんにちは。DELISH KITCHEN開発部の村上です。 DELISH KITCHENでは、これまでの『レシピ動画アプリ』から『AI料理アシスタント』を目指すべく、これまで以上にAI領域に力を入れています。詳しくはこちらにも記載があるので、ぜひご覧ください。 AI/LLMでtoC向けサービスはどう変わるのか?『DELISH KITCHEN』は、「レシピ動画アプリ」から「AI料理アシスタント」へ このAI活用は社内での業務改善にも進んでおり、直近でOpenAI APIを用いた社内システムの開発をする機会がありました。その中で今回はVercelの AI SDK を使う機会があったのでAI SDKを用いたストリーミング可能なUIをwebアプリケーション内で実現する方法を紹介します。 AI SDKとは VercelのAI SDKはAI/LLMを用いたwebアプリケーション開発を支援するためのツールです。AI/LLMを用いた開発ではOpenAI, Claudeなど外部APIへの繋ぎこみやチャットUIの実装、チャット履歴の保存、ストリーミング機能、RAGの利用などの機能が求められたりしますが、これを全て自分で開発しようと思うと、たとえwebフレームワークを使っていてもかなり手間がかかってしまいます。AI SDKを利用することでこうした実装工数を削減し、より周辺の機能開発に時間を割くことができます。 現状AI SDKは以下の3つから構成されています。 AI SDK Core テキスト生成、構造化オブジェクトの生成、LLM(大規模言語モデル)を使用したツール呼び出しを行うためのプロバイダーに依存しない統一APIの提供 AI SDK UI チャットやその他ユーザーインターフェースを構築するためのツールの提供 AI SDK RSC React Server Components (RSC) を使用してユーザーインターフェースをストリーミングする機能。※現在は実験的な開発段階。 AI SDKは同じ開発元から出ているNext.jsはもちろんNuxtやSvelteなど他環境への対応もしていますが、AI SDK RSCはNext.jsのApp Routerだけをサポートしていたりするので、環境別で何が使えるかは公式を参照するのがおすすめです。 https://sdk.Vercel.ai/docs/getting-started/navigating-the-library#environment-compatibility AI/LLMを用いたアプリケーションにおけるストリーミング機能 特にLLMを用いたwebアプリケーション開発をしていく場合、開発上ケアしておきたいポイントはいくつかありますが、その一つにストリーミング機能があります。 まず、単純な一問一答的なものを実現しようと思っても質問内容や回答によってはAPIでLLMを用いて回答を生成する段階でその出力までユーザーに待ち時間が発生してしまいます。さらに単純な1回のAPI呼び出しだけで終わればいいですが、多くの場合では複数のAPI呼び出しや処理のステップを経て、出力結果を作っていくのでその待ち時間はユーザー体験として無視できないものになってきます。 そこで身近なところであれば、ChatGPTでも全ての回答を生成し終わる前に回答の出力が段階的に行われ、ユーザーが体感する待ち時間を軽減していると思いますが、出力や処理の途中でもユーザーにフィードバックできるようなストリーミング機能が求められます。 AI SDKではAI SDK RSCの中でその機能をサポートしているので以降ではいくつかの種類に分けて機能を紹介していきます。 テキストの出力結果をストリーミングで表示する Server Server側ではまず createStreamableValue でServerからClientにストリーミングで送るためのデータの格納先を準備し、 streamText を使ってOpenAI APIなどProviderからストリーミングされた出力結果で更新します。 'use server' ; import { streamText } from 'ai' ; import { openai } from '@ai-sdk/openai' ; import { createStreamableValue } from 'ai/rsc' ; export async function generate ( input : string ) { const stream = createStreamableValue( '' ); ( async () => { const { textStream } = streamText( { model : openai( 'gpt-4o-mini' ), prompt : input, } ); for await ( const delta of textStream) { stream.update(delta); } stream.done(); } )(); return { output : stream.value } ; } Client Client側ではServer側が createStreamableValue で生成されたデータを readStreamableValue を用いることで簡単に読み取ることができるので受け取ったものを処理するhooksを定義します。 import { StreamableValue, readStreamableValue } from 'ai/rsc' import { useEffect, useState } from 'react' export const useStreamableText = ( content : string | StreamableValue < string > ) => { const [ rawContent , setRawContent ] = useState( typeof content === 'string' ? content : '' ) useEffect(() => { ;( async () => { if ( typeof content === 'object' ) { let value = '' for await ( const delta of readStreamableValue(content)) { if ( typeof delta === 'string' ) { setRawContent((value = value + delta)) } } } } )() } , [ content ] ) return rawContent } 表示するコンポーネント側ではServerからの結果を上記で定義した useStreambleText を使って表示するだけで簡単に実現できます。 'use client' ; import { useState } from 'react' ; import { generate } from '@/lib/actions' ; import { useStreamableText } from '@/lib/hooks' ; import { StreamableValue } from 'ai/rsc' ; export default function QuestionAnswer () { const [ answer , setAnswer ] = useState< string | StreamableValue < string >>( '' ); return ( < div > < button onClick = { async () => { const { output } = await generate( '簡単に作れるお弁当レシピを教えてください。' ); setAnswer(output); } } > Ask </ button > { answer && < AssistantMessage answer = { answer } /> } </ div > ); } export function AssistantMessage ( { answer , } : { answer : string | StreamableValue < string > ; } ) { const text = useStreamableText(content); return ( < div > { text } </ div > ); } オブジェクトの出力結果をストリーミングで表示する Server Server側では、同じように createStreamableValue を利用するところは同じですが、ここではオブジェクト形式の出力に対応する streamObject を利用して、プロバイダーから逐次送信される構造化されたデータで更新します。AI SDKではOpenAI APIの Structured Outputs にも対応しているので、 structuredOutputs パラメーターで指定します。 'use server' ; import { streamObject } from 'ai' ; import { openai } from '@ai-sdk/openai' ; import { createStreamableValue } from 'ai/rsc' ; import { z } from 'zod' ; export async function generateObject ( input : string ) { const stream = createStreamableValue( { answer : '' , quotation_links : [] } ); ( async () => { const { objectStream } = streamObject( { model : openai( 'gpt-4o-mini' , { structuredOutputs : true , } ), schema : z.object( { answer : z.string(), quotation_links : z.array( z.object( { title : z.string(), link : z.string(), } ) ), } ), prompt : input, } ); for await ( const delta of objectStream) { stream.update(delta); } stream.done(); } )(); return { output : stream.value } ; } 上記の例では、 objectStream には常に { answer: '', quotation_links: [] } の形式が保たれた形で随時そのテキスト情報や配列要素が追加されていくので、特に複雑な加工処理をすることなく、Client側で利用可能な状態になります。 Client Client側では、テキストをストリーミングする時と同じように readStreamableValue を使用してストリーミングされたオブジェクトを受け取り、動的に更新します。 import { StreamableValue, readStreamableValue } from 'ai/rsc' ; import { useEffect, useState } from 'react' ; type AnswerObject = { answer : string ; quotation_links : { title : string ; link : string } [] ; } ; export const useStreamableObject = ( content : AnswerObject | StreamableValue < AnswerObject > ) => { const [ rawContent , setRawContent ] = useState< AnswerObject | null >( typeof content === 'object' && !( 'subscribe' in content) ? content : { answer : '' , quotation_links : [] } ); useEffect(() => { ( async () => { if ( typeof content === 'object' && 'subscribe' in content) { let value: AnswerObject | null = null ; for await ( const delta of readStreamableValue(content)) { if ( typeof delta === 'object' ) { setRawContent((value = { ...value, ...delta } )); } } } } )(); } , [ content ] ); return rawContent; } ; 定義した useStreamableObject を使用して、ストリーミングで受け取ったオブジェクトデータを表示します。 'use client' ; import { useState } from 'react' ; import { generateObject } from '@/lib/actions' ; import { useStreamableObject } from '@/lib/hooks' ; import { StreamableValue } from 'ai/rsc' ; type AnswerObject = { answer : string ; quotation_links : { title : string ; link : string } [] ; } ; export default function ObjectDisplay () { const [ answer , setAnswer ] = useState< AnswerObject | StreamableValue < AnswerObject > | null >( null ); return ( < div > < button onClick = { async () => { const { output } = await generateObject( '環境問題に関する最新のレポートを教えてください。' ); setAnswer(output); } } > Ask </ button > { answer && < AssistantObjectMessage answer = { answer } /> } </ div > ); } export function AssistantObjectMessage ( { answer , } : { answer : AnswerObject | StreamableValue < AnswerObject > ; } ) { const data = useStreamableObject(answer); return ( < div > { data ? ( < div > < p > { data.answer } </ p > < ul > { data.quotation_links. map (( item , index ) => ( < li key = { index } > < a href = { item. link } target = "_blank" rel = "noopener noreferrer" > { item. title } </ a > </ li > )) } </ ul > </ div > ) : ( 'Loading...' ) } </ div > ); } オブジェクト形式のストリーミングのメリットは上記のようにそれぞれ異なる要素に対して個別のスタイリングや処理を行うことができる点です。今までのテキストでのストリーミングは表現方法が限定的になるか、やろうと思ってもテキスト情報を変換するような複雑な処理をしないといけず不安定になってしまいますが、オブジェクト形式で受け取れることによって、アプリケーションによって独自の見せ方が可能になり、自由度が上がりました。 処理に合わせてUI自体をストリーミングで表示する Vercelではテキストやオブジェクトだけではなく、UI自体をストリーミングすることができます。この機能を使うことでテキストやオブジェクトだけではなく、LLMの回答結果をUIで表示することも可能になります。 今回は、AIアプリケーションでありがちな回答を出力するまで進捗を表示するUIを例に紹介します。 一番最初に利用した AssistantMessage と以下の WorkflowProgress コンポーネントをServerとClientでやりとりします。 進捗を表示するWorkflowProgressコンポーネント export function WorkflowProgress ({ workflowSteps }) { const completedSteps = workflowSteps . filter (( step ) => step . status === 'completed' ) . length ; const totalSteps = workflowSteps . length ; const progressValue = ( completedSteps / totalSteps ) * 100 ; return ( < div className = "p-4 bg-gray-100 rounded-lg shadow" > < h3 className = "font-semibold text-lg" > Progress </ h3 > < progress value = { progressValue } max = "100" className = "w-full mb-2" ></ progress > < ul > { workflowSteps . map (( step ) => ( < li key = { step . id } className = "mb-2" > < strong > { step . name } : </ strong > { step . status } </ li > ))} </ ul > </ div > ) ; } Server サーバー側では createStreamableUI を利用して、ストリーミングするコンポーネントを追加、更新することができます。今回はAI SDKの機能紹介がメインのため、step毎の具体的な処理については省略します。 'use server' ; import { streamText } from 'ai' ; import { openai } from '@ai-sdk/openai' ; import { createStreamableUI } from 'ai/rsc' ; import { WorkflowProgress } from '@/components/WorkflowProgress' ; import { AssistantMessage } from '@/components/AssistantMessage' ; export async function generateWithSteps ( input : string ) { const workflowSteps = [ { id : 'step1' , name : '質問を解析中' , status : 'in-progress' , tasks : [] } , { id : 'step2' , name : '探索方法を検討' , status : 'pending' , tasks : [] } , { id : 'step3' , name : '関連データを取得' , status : 'pending' , tasks : [] } , ] ; const displayUI = createStreamableUI( < WorkflowProgress workflowSteps = {workflowSteps} /> ); ( async () => { // Step 1: 質問を解析 await new Promise (( resolve ) => setTimeout (resolve, 2000 )); workflowSteps[ 0 ]. status = 'completed' ; workflowSteps[ 1 ]. status = 'in-progress' ; displayUI.update( < WorkflowProgress workflowSteps = {workflowSteps} /> ); // Step 2: 探索方法を決定 await new Promise (( resolve ) => setTimeout (resolve, 2000 )); workflowSteps[ 1 ]. status = 'completed' ; workflowSteps[ 2 ]. status = 'in-progress' ; displayUI.update( < WorkflowProgress workflowSteps = {workflowSteps} /> ); // Step 3: データを取得 await new Promise (( resolve ) => setTimeout (resolve, 2000 )); workflowSteps[ 2 ]. status = 'completed' ; displayUI.update( < WorkflowProgress workflowSteps = {workflowSteps} /> ); // 結果を生成 const { textStream } = streamText( { model : openai( 'gpt-4o-mini' ), prompt : input, } ); let generatedText = '' ; for await ( const delta of textStream) { generatedText += delta; // 進捗状況のコンポーネントから回答結果のコンポーネントに変える displayUI.update( < AssistantMessage answer = {generatedText} /> ); } displayUI.done(); } )(); return { display : displayUI.value } ; } Client クライアント側では、定義したアクションを呼び出し、その結果をそのまま表示することで簡単に動的なUIが作れます。実際の挙動はまず最初にProgressを表示し、回答結果がLLMから出力され始めるとProgressは非表示になり、回答が生成されていくような形になります。 'use client' ; import { useState } from 'react' ; import { generateWithSteps } from '@/lib/actions' ; export default function DynamicProgressDisplay () { const [ display , setDisplay ] = useState< React.ReactNode | null >( null ); return ( < div > < button onClick = { async () => { const { display } = await generateWithSteps( '環境問題に関する最新のレポートを教えてください。' ); setDisplay(display); } } className = "mb-4 px-4 py-2 bg-blue-500 text-white rounded" > Ask </ button > { display } </ div > ); } 最後に 今回はVercelのAI SDKを使って、いくつかの手法でストリーミング可能なUIをアプリケーション上で実装する方法を紹介しました。この他にも会話履歴の保存やその状態の管理を簡単に扱えるように豊富な機能が提供されています。AI SDK RSCはまだベータ的な位置付けですが、webでLLMを使ったアプリケーションをする際には比較的簡単にリッチなアプリケーションが作れるので、ぜひ社内ツールや簡単に動くものを作りたい場合にはVercelのAI SDKを使ってみてください。 冒頭でも紹介したようにDELISH KITCHENではこれまでの『レシピ動画アプリ』から『AI料理アシスタント』への変化を起こそうとしています。ぜひ、この取り組みに興味を持った方は一度カジュアル面談でお話しましょう! corp.every.tv
アバター
はじめに この記事は every Tech Blog Advent Calendar 2024 の8日目の記事です。 こんにちは、リテールハブ開発部でバックエンドエンジニアをしています。 実はまだ転職して2ヶ月のため、まだまだわからないことだらけですが、 現在、Laravelを利用したAPI開発をしていて、その中でPestを利用した単体テストを行なっています。 前職のAPIテストは結合テストメインで行っていて、単体テストはほとんど行なっていなかったのですが、 検証データの準備や継続的なテストにおいて課題がありました。 今回LaravelもPestも初めて利用するのですが、今回行った単体テストを経験する中で、 これを利用すれば以前感じていた課題が解決できるかもしれないと思いました。 本ブログでは、課題解決できると思った点や実際に単体テストを経験して感じたことなどをお伝えできればと思っています。 同じ課題感をお持ちの方やテストに興味のある方はぜひ読んでいただけると嬉しいです。 今回はLaravel、Pestを例にしたお話にはなりますが、他のフレームワークでも同様のケースは多いのかなと思っています。 前職で行っていたAPIテストで感じていた課題 私が今まで行っていたAPIのテストは基本的にはDBにテストデータを投入し、 データと各パラメーターによって変わるAPI結果を検証する結合テストがメインでした。 できる限りのパターンを網羅し、各ロジックが検証できるようデータ準備をしていたりもしました。 その後はSeleniumなどを使用してテストパターンを登録して自動テストなども行なっていました。 しかし、以下のような課題がありました。 「データ整備の課題」 検証用のデータをDBにあらかじめ入れないと検証できない DBデータの最新化や別件の対応などで内容が変わったりと検証データの担保がしにくい 検証データが不足していて意図した結果が得られていないケースがある 外部要因による結果を利用するテストが行いにくい 「検証データの信頼性の課題」 自動テストの実行でエラーが発生した場合、検証データによるものかロジックによるものかの特定に時間がかかる 上記課題は、DBデータを固定化したり、検証用DBを別途作ったりして回避しようとしていましたが、 やはりそれだけではデータパターンの担保や変更されないことの保証はうまくできていないことがありました。 特にデータ担当が別部署だったりするとなお難しい状況でした。 Laravel、Pestを利用したテストについて LaravelやPest以外でも同じような機能はあると思いますが、 今回のテストでは、LaravelのModel、Factoryを利用して、コード記述によって任意のテストデータを作成(DB投入)し、 Pestを使って各テストケース毎に独立した検証ができるテストを経験しました。 簡単ではありますが、単体テストのメリットとデメリットを記載してみました。 メリット メソッド単位で1つ1つ検証することができる ケース毎に独立したテストができるため、環境やデータ状況に左右されない Mockを利用して仮想的な結果を混ぜることで、データ整備だけでは難しいケースにも記述次第で対応できる デメリット テストケースが増えるほどコストも増加する 機能仕様の変更などをすると単体テストにも影響が大きくあり変更負荷が高い 当然ではありますが、今回行ったような単体テストは、1ケースずつ細かく行うため工数はどうしてもかかるなと思いました。 様々なケースを考えながら記述もするので単純作業というわけでもなく、 そもそもテストコードが間違えていたら意味もないため、手早くこなすのも厳しそうです。 また、最終的にはAPIの機能検証も別途必要なため、複合して行う必要があります。 課題が解決できると思った点 上記だけでは、やはり工数の問題などで優先度を下げて対応してしまいそうではありますが、 今回自分の中で非常に使えそうと思ったのは、 指定のテーブルに任意のデータをコード記述により固定で入れられ、検証したいデータパターンを柔軟に記述することができる点 簡単にテストデータを作る仕組みがあり、複数データ作成の手間も省ける点 1テストケース毎に他のデータの影響を与えず独立して実行することができる点 かなと思いました。 簡単な例ですが、以下の要件に合わせたPestのサンプルコードがあります。 getListメソッドのテストを行いたい APIリクエストからのパラメーターはidのみ status引数の値によって分岐があり結果が変わる statusはAPIのリクエストからは指定できず、外部要因によって決定される コードの記述のみでデータ投入、メソッド実行、結果比較を行うことができます。 Pest記述のサンプルコード <?php namespace Tests\Unit\Sample; // ここにTest前の処理を記述 beforeEach ( function () { $ this -> sample = new Sample () ; }) ; // ここにTest後の処理を記述 afterEach ( function () { }) ; // 1テストケースずつ以下の形式で作成していく it ( '指定IDとステータスがokの場合一覧を検索できること。' , function () { // 検索したいパラメーター $ id = 1 ; $ status = 'ok' ; // statusはAPIのパラメータからは指定できない // 任意のテーブルにデータを入れる機能 // この記述によりデータがテストケース毎に固定できる。 SampleTable :: factory () -> create ( [ 'id' => 1 , // primary key 'status' => 'ok' , 'name' => 'サンプル1' , ] ) ; SampleTable :: factory () -> create ( [ 'id' => 2 , // primary key 'status' => 'ok' , 'name' => 'サンプル2' , ] ) ; // id, statusから一覧を取得するメソッドのテスト $ result = $ this -> sample -> getList ( $ id , $ status ) ; // 返却内容は以下の記述でチェックできます。 // 結果が何件返ってきているかをチェック expect ( $ result -> count ()) -> toBe ( 2 ) ; // 結果のnameが正しいかチェック expect ( $ result [ 0 ][ 'name' ]) -> toBe ( 'サンプル1' ) ; expect ( $ result [ 1 ][ 'name' ]) -> toBe ( 'サンプル2' ) ; }) ; // 2ケース目 it ( '指定IDは合っていてもステータスがok以外のためヒットしないこと。' , function () { // 検索したいパラメーター $ id = 1 ; $ status = 'test' ; // statusはAPIのパラメータからは指定できない // 任意のテーブルにデータを入れる機能 SampleTable :: factory () -> create ( [ 'id' => 1 , // primary key 'status' => 'ok' , 'name' => 'サンプル1' , ] ) ; SampleTable :: factory () -> create ( [ 'id' => 2 , // primary key 'status' => 'ok' , 'name' => 'サンプル2' , ] ) ; // id, statusから一覧を取得するメソッドのテスト $ result = $ this -> sample -> getList ( $ id , $ status ) ; // 返却内容は以下の記述でチェックできます。 // 結果が何件返ってきているかをチェック expect ( $ result -> count ()) -> toBe ( 0 ) ; }) ; <?php class Sample { public function getList ( int $ id , string $ status ) : Collection { $ query = SampleTable :: query () -> where ( 'id' , $ id ) ; if ( $ status == 'ok' ) { $ query -> where ( 'status' , $ status ) ; } else { $ query -> where ( 'status' , 'ng' ) ; } return $ query -> get () ; } } 上記、2つのテストケースがありますが、 1つ目はid=1, status=okでデータが取得できるケース 2つ目はid=1, status=testでデータが取得できないケース どちらのケースもid=1、id=2と入れていますが、各テストは独立しているのでデータの重複や状態を考慮する必要がなく、 自由なデータを入れることができます。 また、サンプル内の「status」はAPIパラメータ指定ではなく、何らかの条件で決まる場合、 外部APIの結果などの別要因で決まる値 他のデータの組み合わせで決まる値 上記のようなケースでも単体テストでは値を固定してそれぞれのパターンを簡単に試すこともできます。 前職で行っていたテストではこういった外部要因で決まった値によって変わるテストが、 データ調整や外部APIの結果を無理やり変えたりと大変苦労していました。 また、サンプルでは非常にシンプルなテストですが、 複数使用したテーブルやテーブルの項目が増える場合 いろんな条件によって検索する値が変わる場合 通常は起こり得ないデータを入れないとテストできない場合 など複雑化していくケースの場合、 あらかじめ検証したいデータを投入しておく API検証時に各パラメーターのパターンを変えて試す だけではどうしても漏れが起きてしまったり、複雑なケースのデータ投入の限界や他のテストケースに影響を与えないデータ考慮などが必要になってきてしまいます。 また、もしエラーが発生した場合にそれが検証データによるものか、ロジックによるものかの判断もより難しくなってきます。 これが1テストケースごとで独立したデータ、テストであれば、 難しいデータパターンもコードに1度書いてしまえば、同じ検証、同じ結果が担保できる 各ケースが様々な記述をしても、他のケースに影響を与えない  (データパターンも自由に変更可能) ある時からエラーが発生するケースを検知した場合でもデータによるものではなくロジックによるものと判断しやすい このメリットを利用することができれば、今まで課題だった問題が解決できそうと思ったところです。 実際に使ってみて感じたところ ただ今回のような単体テスト実行は、前職のプロジェクトでは一時的に導入はしたものの、 前述の通り、 対応コストが多く掛かってしまう 機能変更した場合の修正コストも都度必要 通常のプログラムとは別の記述方法が必要 が、やはり大きく影響し、最初は導入しても徐々に対応しなくなってしまうケースがほとんどでした・・・。 しかし、今までのようにすべてやらなくなるのではなく、 一部だけでも取り入れる方法はメリット部分を大きく活かせそうかなと思いました。 この一部だけというのが、また別の課題も生みそうではありますが、 検証しにくいデータパターンだが重要なケース データの変化が起きやすく安定した検証がしにくいケース 一部の重要なロジックだけでも固定データと合わせて動作保証を担保したいケース よく利用されるもの、共通のケース など もちろん現在のプロジェクトで実施しているようなできる限り網羅するのが良いと思っていますが、 それぞれのプロジェクトの状況によっては、上記のような必要な部分だけに今回のようなテストコードのコストをかけるだけでも大きな効果があるのではないかと思いました。 最後に 抽象的な表現でわかりにくい点もあったかと思いますが、 データパターン検証、安定した検証に今回のような単体テストも有効そうであることは伝わりましたでしょうか。 今回のような単体テスト方法はしばらく触れてきていなかったのもあり、大変勉強になりました。 今後のテスト検証の際にぜひ少しでも参考にしていただければ幸いです。 最後までお読みいただき、ありがとうございました。
アバター
はじめに この記事は every Tech Blog Advent Calendar 2024 の7日目の記事です。 エブリーでデータサイエンティストをしている山西です。 今回は、A/Bテスト結果のレポーティングを自動化した事例をご紹介します。 ビジネスサイドが抱く「統計学的なとっつきにくさ」を解消し、結果を解釈しやすく伝えるための試みです。 図1: 結果のレポーティングの雰囲気(評価指標に対して、ダッシュボード上で結果を確認できる) ※ 本記事はランダム化比較実験や統計的仮説検定の基礎知識を前提としています。これらの知見をビジネスに還元する取り組み事例として、何かしらご参考になれば幸いです。 以下、経緯を順に説明していきます。 背景 私たちが運営するレシピ動画サービス『DELISH KITCHEN』では、日々の機能改善に A/Bテスト基盤 ※1 を活用しています。 これは、 1. ユーザー展開の準備(control群、test群への割り当て) 2. 観察指標のデータ集計 3. 統計的仮説検定(観察指標の「test群とcontrol群の差」を検定) 4. 結果のダッシュボード可視化(BIツールRedashをインターフェースとし、日次バッチ更新) を一気通貫で行う仕組みです。 これまで数年にわたり活用実績を積み重ねており、現在では社内の複数事業部で利用されています。 アプリ内機能の開発・改善 機械学習アルゴリズムの性能検証 広告やランディングページのデザイン改善 など、その用途は多岐にわたります ※2 。 こうして、 ビジネスサイドがデータドリブンに仮説検証を試みる文化 が着実に根付いてきました。 ※1 A/Bテスト基盤の詳細については、以下の記事をご覧ください。 tech.every.tv ※2 参考までに、直近1年の実施回数は50回でした。A/Bテストの実験成熟度モデル:Fabijan et al. (2017)では、年間のテスト実施回数で成熟度を簡易的に見積もるアイデアが提唱されています。これにならえば、ちょうどWalk Phase(年に50回以下)からRun Phase(年に250回以下)の境界にあたり、大規模なA/Bテスト推進組織への道がひらけた状態ともいえます。 課題 一方で、 ビジネスサイドに結果を正しく解釈してもらうこと そのために適切な実験のデザインをすること に関しては、一定の課題感が残りました。 以下にその事例をAs-Is、To-Be ※3 の体裁で整理します。 As-Is(実際にあった例) To-Be(目指したい状態) ・有意差や信頼区間を考慮せず、指標の結果値だけで判断する ・誤差幅と有意性を考慮して結果を解釈できるようにしたい ・有意差が出ていないことを「効果が無かった」と断定してしまう ・有意差がない場合は「差があったとは言えない」と判断できるようにしたい ・有意差と効果量を混同し、「有意だからビジネスインパクトが大きいだろう」と解釈してしまう ・有意性と効果量を区別し、それぞれ正しく考察できるようにしたい ・「有意差が出ていないから、出るまで期間を伸ばそう」と判断してしまう(p-hacking) ・都合の良い結果を導く危険性を共有し、事前の実験デザインを遵守できるようにしたい ・結果を見ながら元の仮説を書き換える(HARKing) ・仮説が不明瞭なまま検証を進めようとする。 ・A/Bテストは仮説検証の手段であることを共有し、後から仮説を変える危険性を伝えたい ・大きな変更によるネガティブ影響を恐れ、展開率を必要以上に抑える ・結果を早く見たいので期間を短く設定する ・検出力を確保するため、適切なサンプルサイズと実験期間を設定できるようにしたい ※3 To-Beの部分が全く実践できていないわけではありませんが、共通認識として推し進める段階には至っていない現状をAs-Isと対比して示しています。 発生要因 前提知識のばらつき これらの問題の主な原因は、 結果を解釈する人々の前提知識にばらつきがあること だと考えられます。 統計的仮説検定の結果は本来、有意差や信頼区間の意味を理解しつつ、適切に解釈する必要があります。 しかし、専門知識を必ずしも有していない人々にその解釈を委ねると、「事実が示す以上の解釈」が生じる可能性があります。 その結果、「数字の一人歩き」や「データに基づかない意思決定」といった問題が発生しやすくなり、意思決定のリスクが増大してしまいます。 「ビジネスの関心事」と「統計的な正しさ」とのギャップ また、時には 統計的仮説検定としての理想的な実験デザインが完遂できない ことがあります。 先述した「サンプルサイズ不足の状態でA/Bテストを進めてしまう」ケースがその一例です。 ビジネスサイドは収益最大化のため、時には短期間でPDCAを回す判断を行いたい場合もあります。 一方、観察指標によってはサンプルサイズの確保に時間を要する場合があります。 そうなると、「サンプルサイズ確保のために数週間、数ヶ月かけて仮説検定の正しさを立証する」ことよりも、「1施策を1〜2週間で実施し、不確実性を認めつつ結果を判断したい」ことに興味が向く場合もあります。 これはこれで一つの尊重すべき視点である ※4 一方、統計的視点を薄め、感覚と経験則に頼る傾向を強めてしまうことになります。 これでは、A/Bテストの意義が薄れてしまいます。 ※4「1施策の結果考察の確からしさを犠牲にする」策が本当にKPIの最大化に寄与するか否かは、別途定量的に分析してみないとわからないことだと思います。が、本記事の範疇を越えるため、ここでの深入りは避けます。 それをサポートするのがデータサイエンティストの役割では? 「こうした問題を防ぐためには、データサイエンティストがサポートすべきでは?」という指摘はもっともです。 しかし、実際の運用においては、いくつかの課題が浮き彫りになっています。 運用規模の拡大 A/Bテスト基盤の導入初期は、データサイエンティストとビジネスサイドが密に連携して結果を解釈していました。 しかし、運用規模が多くの部署に拡大するにつれ、データチームが全施策に関与することが難しくなっています。 データ解釈の視点の啓蒙活動の限界 ビジネスサイドへデータ解釈の際の心構えを啓蒙することも有効な解決策ですが、それだけでは限界があります。 学習を促す側・される側双方に一定のコストがかかるうえ、個々人の学習意欲や、担当者交代による知識の断絶といった属人性の課題があります。 全社的な見解の統一の必要性 実務者間で解釈を共有しても、他の利害関係者がダッシュボードを見た際に、「数字の一人歩き」や「誤解」が再燃することがあります。 特に、これが意思決定の上層部との間で起こると、認識のズレが意思決定を揺るがす原因になり得ます。 課題解決のための方向性 ここまで挙げてきたように、「誰でも気軽にA/Bテストを推進し、結果をダッシュボードで観察できること」の弊害が見え始めました。 一方で、「データドリブンな仮説検証を全社的に試みようとする文化の良い点」は引き続き維持したいところです。 また、ビジネスのスピード感を優先するがために「科学的な正しさ」の比重を下げなければいけない場合も、「その不確実性によって起き得るリスク」を意思決定者が認知し、公平に判断してもらう状態を目指したいです。 このような経緯から、「統計的仮説検定のデータ解釈をもっと良い感じに共通認識化させたい」という機運が高まることとなりました。 解決策: ダッシュボードからレポーティングへの昇華 これらの課題間の解決策として「言葉で解釈を手助けする」レポートをダッシュボードに追加することにしました。 コンセプトは「記述的なダッシュボードから、言葉によるレポーティングへの昇華」です。 これまでビジネスサイドとA/Bテストの結果を振り返るやりとりの中で「事実の整理としてのレポートはある程度パターン化できる」という気づきから、実装する運びとなりました。 以下に、結果の説明文の生成イメージを紹介していきます。 有意性の有無 や、 観測値(testとcontrolの指標の差)のプラス、マイナス に応じて、動的に生成内容を切り替えるようにしています ※5 。 ※5: 今回の主題ではないため詳しくは触れませんが、Redash上でPythonを実行する機構を用いて、各種統計的検定結果を動的に取得、埋め込む形でレポートを構築しました。 例1: 有意に結果がプラスとなったケース 図2:有意に結果がプラスとなった場合のレポーティング 例2: 有意に結果がマイナスとなったケース 図3:有意に結果がマイナスとなった場合のレポーティング 例1、例2では、「有意性と実際の効果の量を区別し、それぞれ正しく考察できるようにしたい。」というTo-Beを意識しています。 例3: 有意差が観察されなかったケース 図4: 有意差が観察されなかった場合のレポーティング 「誤差幅と有意性を考慮して結果を解釈できるようにしたい」 「検出力を確保するため、適切なサンプルサイズと実験期間を設定できるようにしたい」 というTo-Beを踏まえた内容が含まれています。 こだわり ビジネスサイドにとって理解しやすい言葉を意識する(専門用語を過度に使用せず、統計独特の言い回しを適宜言い換える) 言外の解釈に発展させないようにする(「信頼区間を95%正しい」と誤認させない、「有意差がないことは、効果がなかったことを必ずしも意味しない」など) などの工夫と共に、慎重に言葉を選びました。 また、例3で挙げたように、理想的な実験デザインが完遂できなかったとしても、 その不確実性やリスクを事前に告知する 工夫を説明文に施しました。 意思決定者が、ビジネス視点とデータ解釈の視点を公平に判断できる状態を期待しています。 最後に A/Bテストの運用における実務での気づきから、「自動レポーティング」という新たなアプローチを開拓した事例をご紹介しました。 本記事執筆時点では、これから運用を始める段階です。 自動レポーティングの導入により、統計的な観点を伴う解釈を関係者間で共有し、データ解釈における視座の向上を期待しています。 今後も、データドリブンに施策推進を行う社内文化の醸成と、その質の向上を図っていきたいと考えています。
アバター
全社的にSSH辞めるためには この記事は every Tech Blog Advent Calendar 2024 の 6 日目の記事です。 はじめに エブリーTIMELINE開発部の内原です。 全社的にSSHの利用を中止することができたので、そのような意思決定をすることに至った経緯や、その後の状況について紹介します。 なお前提として、下記記事はAWSに限定した内容となっています。 エブリーではGCP(GCE)も一部のサービスで利用しているのですが、GCEについては下記で説明する問題の影響がなかったため対象外としています。 SSH利用を中止したい理由 以下のような理由から、運用的にいろいろ辛い部分があったためです。 脆弱性対応で疲弊する 一般的にSSHサーバとしてOpenSSHが用いられることが多いと思いますが、このソフトウェアには時折セキュリティ脆弱性の問題が見つかることがあります。この脆弱性については放置できないケースも多いので、その都度工数が発生します。 今年だと CVE-2024-6387 の問題がありました。 共有アカウントにおけるセキュリティリスク キーペアを用いてEC2にログインするケースなど共有アカウントでログインする運用では、退職者であってもログインできてしまうリスクがあります。 また共有アカウントの運用では、監査の観点でも誰がなにをしたかについても追跡が難しくなります。 個別アカウントでの運用は大変 かといって、ユーザ個別のアカウント運用を行うのはわりと面倒です。 手動で管理するのは当然として、なんらか外部サービスと連携してアカウント管理を自動化するアプローチであっても、面倒なことには変わりありません。 セキュリティグループ運用が面倒 SSHを使うためにはSSHポート番号(22番)を開放する必要がありますが、この管理方法についても考慮すべきことが多いです。 ポートは全体開放(0.0.0.0/0)するか? 全体開放しないならどういう運用で開放するか? 管理コンソールで担当者が直接更新するか? なんらかIaCツールを用いるか? IPアドレスが頻繁に変わる場合はどうするか? SSH利用を中止した後の代替手段 上記のようにSSHを利用し続けることは無視できないリスクがあると考えたため、SSHの利用を全社的に中止することにしました。 ただそうは言っても、現状の運用でSSHを利用しているケースも存在していたため、代替手段を用意する必要がありました。 EC2へのログインにSession Managerを利用する AWS Systems Manager Session Managerを利用することでSSHの代替を行うことができます。 Session ManagerはEC2インスタンスに対してSSHの代替となるリモートシェルを提供するサービスです。 最近のEC2インスタンスならば通常SSM Agentは起動していますが、数年以上前に作成したインスタンスの場合はSSM Agentが起動していないことがあるため、その場合はSSM Agentを 手動でインストール する必要があります。 また、インスタンスIAMロールには AmazonSSMManagedInstanceCore ポリシーがアタッチされている必要があります。 EC2インスタンスを利用しないアプローチ もしくは、踏み台用のEC2インスタンスを用いるのではなく、ECS Fargate Taskを都度起動するアプローチを採ることも可能です。以前にその対応を行った記事がありますので、参考にしてください。 RDS踏み台サーバをよく見かけるECS Fargate+PortForward+Adhocな機構に変更する 実際のSSH利用例と代替手段 EC2インスタンスへのログインを行なっているケース EC2インスタンスにログインしてなんらかシェル操作を行なっているようなケースです。その場合は以下のようなコマンドでリモートシェルを利用することができます。 対応前 $ ssh -i path/to/key.pem $ec2_user @ $ec2_host sh-5. 2 $ 対応後 $ aws --profile $profile ssm start-session --target $instance_id Starting session with SessionId: foo.bar@nrcazkfv3a6gkcmdmihy7i4pbq sh-5. 2 $ ローカル環境からのRDSへの接続用Proxyとして利用しているケース RDSのインスタンスはVPC内に存在するため直接接続することができないので、SSH Port Forwardingを利用してリモート接続するようなケースです。 例えば以下のようなコマンドでローカル環境からmysqlサーバに接続することができます。 対応前 $ ssh -L 3306: $remote_db_host :3306 $ec2_user @ $ec2_host $ mysql -h 127 . 0 . 0 . 1 -u $db_user -p $db_name Enter password: mysql > 対応後 このようなケースについても、AWS Systems Manager Session Managerのポート転送を利用することで代替することができます。 $ aws --profile $profile ssm start-session --target $instance_id \ --document-name AWS-StartPortForwardingSessionToRemoteHost \ --parameters ' {"host":["YOUR-REMOTE-DB-HOST"],"portNumber":["3306"],"localPortNumber":["3306"]} ' $ mysql -h 127 . 0 . 0 . 1 -u $db_user -p $db_name Enter password: mysql > 全社の状況把握と方針策定 状況把握 まずは全社で利用している全EC2インスタンスのリストを作成し、それぞれのインスタンス利用状況を可視化することにしました。 その際は以下のようなコマンドで一覧化したものをスプレッドシートに書き出し、担当部署を割り当てて部署ごとに利用状況を記載してもらいました。 $ aws ec2 describe-instances | jq -r ' .Reservations[] as $r | $r.Instances[] | select(.State.Name!="terminated") | [$r.OwnerId, .InstanceId, (.Tags // [] | from_entries.Name // "NoName"), (.SecurityGroups[0].GroupName // "NoName"), .LaunchTime, .InstanceType, .State.Name] | @tsv ' 上記コマンドによって以下のような出力を得られます。 AccountName OwnerId InstanceId TagName SecurityGroupName LaunchTime InstanceType State.Name ************ i-***************** INSTANCE-NAME SECURITY-NAME YYYY-MM-DDThh:mm:ss+00:00 INSTANCE-TYPE running 対象としたインスタンス数は全社で70個ほどで、これを担当する複数の部署に割り当てました。 なお、部署によってはインフラ構成が大きく異なっているケースもあり、管轄する個数にはだいぶ偏りがある状態でした。(ちなみに自分が所属しているTIMELINE開発部では該当するインスタンスは存在しませんでした) 方針策定 各部署では以下のいずれかの方針を選択してもらうことにしました。 インスタンスの削除 stopping状態のままになっているインスタンスやすでに利用していないインスタンスなど、削除しても問題ないインスタンスについては削除することにします。 SSHポート番号閉鎖 本来はSSHサーバ自体を停止するのが望ましいのですが、EC2の機構上動作しているインスタンスからSSHを無効化するのが難しかったため、SSHポート番号の閉鎖で対応することにしました。 ポート番号が塞がれていれば事実上外部からSSHで攻撃されるリスクは考慮しなくてよくなると考えたためです。 その後の状況 最初にリストを作成してから1ヶ月半ほどで、全部署での対応が完了しました。 前述の通り部署によって対象個数に偏りがあったため最終的にはそれなりの時間がかかることになりましたが、各部署のご協力あって無事完了させることができました。 上記対応を行った結果、現在は全社的にSSHの利用が中止され、セキュリティ上のリスクは大幅に軽減されました。 また、今後新たにEC2インスタンスを起動する場合にも同様の対処が行われるよう、全社的な運用ルールを別途定める予定です。 まとめ SSHの利用を中止することで、セキュリティ上のリスクを軽減することができました。またSSHのアカウント管理に関する煩雑さもなくなり、運用コストの削減にもつながりました。さらに運用ルールを定めて、今後ともにセキュリティを維持していくことが重要と考えています。 以上、全社的にSSHの利用を中止するために行った取り組みについて紹介しました。
アバター
この記事は every Tech Blog Advent Calendar 2024 5 日目の記事です。 はじめに こんにちは、DELISH KITCHEN 開発部でソフトウェアエンジニアをしている24新卒の新谷です。 今回は12/8開催のISUCON14に向けて、ISUCON初参加の私が勉強したことについてまとめていきます。 また、everyはISUポンサーとして協賛しており、詳しくは以下をご覧ください。 tech.every.tv 初参加に向けたざっくりの戦略 今回参加したチームは、日本CTO協会の新卒合同研修で知り合った新卒メンバーで出場しました。 (日本CTO協会の新卒合同研修についてのブログは こちら ) 全員がISUCON初参加ということで、それぞれ役割を決めて、それに向けて勉強を進めました。 そのうち私は、DB周りのインデックス担当ということで、DBのインデックスの張り方を中心に勉強しました。 また、役割はあるもののチーム全員で共通して勉強したこととして、以下があります。 Go言語 N+1の対策 オンメモリキャッシュのやり方 JOINなどのSQL構文をスラスラ読める&書けるようにする 過去問を解く 特にN+1の解消に関しては、ISUCONでは頻出するパターンのため、JOINして解決するのかIN句で解決するのかキャッシュで回避するのかなど、事前にかなり話し合いました。 それぞれ詳しく勉強したことについては、以下で紹介していきます。 DBのインデックスについて なぜインデックスの勉強が必要か インデックス・ショットガンと呼ばれるアンチパターンがあるように、無闇にインデックスを張るとパフォーマンスが悪化することがあります。 特に、INSERTやUPDATEが多いテーブルは、書き込みのオーバーヘッドが大きくなるため、インデックスを張る際には注意が必要です。 MySQLのインデックス ISUCONでは、DBにMySQLを使用することが多いため、MySQLのインデックスについて勉強しました。 以下の記事は、インデックスの基礎を学ぶのに参考になりました。 こちらはInnoDBにおけるインデックスの基礎知識を学べる他、インデックスを張るときのよくある間違いについても解説されています。 techlife.cookpad.com こちらは、MySQLのクエリーライフサイクルやUsing filesort, Using whereが何をしているのかをトランプを例に解説されています。 www.slideshare.net 上記を勉強するとEXPLAINの結果の意味がわかるようになるのと、インデックスを張るときの注意点がわかるようになります。 Go言語 こちらに関しては私は普段から業務でGoを書いているので特段勉強はしませんでした。 ただ、ISUCONではDBを操作する際に sqlx を使うことが多いため、sqlxの使い方については事前に勉強しました。 全てのメソッドは覚えませんでしたが、以下についてはスラスラ書けるようにしました。 1行を取得するときの Get if err := db.Get(&user, "SELECT * FROM users WHERE id = ?" , id); err != nil { return err } 複数行を取得するときの Select if err := db.Select(&users, "SELECT * FROM users WHERE age = ?" , age); err != nil { return err } In句などを使うときの In query, args, err := sqlx.In( "SELECT * FROM users WHERE id IN (?)" , ids) if err != nil { return err } query = db.Rebind(query) if err := db.Select(&users, query, args...); err != nil { return err } Bulk Insertもできる NamedExec _, err := db.NamedExec( "INSERT INTO users (name, age) VALUES (:name, :age)" , users) if err != nil { return err } N+1の対策 N+1に関しては、大きく分けて以下の解決方法があると考えています。 JOINしてN個のクエリを1つにまとめる IN句を使用してN個のクエリを1つにまとめる クエリで取得しているデータをキャッシュする 基本的にJOINをする方が効率的ですが、実装が大変です。また、キャッシュは実装は簡単ですが、書き込みや更新があるデータに関しては注意が必要です。 そこで私たちのチームでは以下の方針で決めていました。 基本的にはJOINを使う方針 書き込みや更新がないデータは、キャッシュで実装する 1対多の関係にあるデータは、IN句を使う 書き込みや更新があるが、ユースケース的に書き込み処理などが少ないデータは、キャッシュで実装する また、上記以外に、アプリケーション側でデータを絞っているにも関わらず、LIMIT句をつけていない場合は優先してLIMIT句をつけることも重要です。N+1自体の解消にはなりませんが、これによってDB負荷のボトルネックが改善され、点数が伸びることもあります。 オンメモリキャッシュのやり方 N+1の解消などにおいて、オンメモリキャッシュを使う場合は、どのように実装するのかチームで決めていました。 まず、書き込みや更新がないデータに関してはMap型で事前にキャッシュするようにしていました。 ただ、書き込みや更新があるデータに関しては、スレッドセーフなキャッシュを実現する必要があります。 そこで、私たちのチームでは、ISUCON用に開発された catatsuy/cache を使うことにしました。 github.com 当初は、 sync のMapを使うことを検討していましたが、以下の理由からcacheを使うことにしました。 Genericsを使っているため、型キャストが不要 キャッシュの有効期限を簡単に設定できる パフォーマンスもSync.Mapとほぼ変わらない 他にも理由はありましたが、主に上記の理由からcacheを使うことにしました。 JOINなどのSQL構文をスラスラ読める&書けるようにする N+1の解消において、基本的にはJOINを使う方針となったので、チーム全員がJOINに対して慣れる必要がありました。 また、そもそもISUCONではサブクエリを使った複雑なクエリやORDER BY句を使ったクエリなども出題されるため、SQLをスラスラ読めるようにすることが重要だと考えました。 SQLは以下のネットの問題集を使って勉強しましたが、他にも問題集などはあるため正直なんでもいいと思います。 SQL練習問題 | TECH PROjin 普段ORMを使っていたりすると、意識してSQLを書かなかったりすることもあると思うので、良い勉強になりました。 過去問を解く 過去問に関しては、時間的に全て解くことは難しかったため、直近の問題を何度も解くことにしました。 ISUCON13 ISUCON12の予選 ISUCON11予選 private-isu(過去問ではないが) 解説などを見ながら解いたりして、ボトルネックの特定のやり方や、どのようなアプローチで解いているのかを理解しました。 また練習中は、Copilotを切ったりコピペをしないようにしていましたが、これが意外と練習になりました。 まとめ 以上がISUCON14に向けて勉強したことです。 本番どうなるかは分かりませんが、練習してきたことを活かして、全力で取り組みたいと思います。 また、DBなどはISUCONのために勉強しましたが、普段の業務でも使える知識が多かったです。 そのため、ISUCONに活かすだけでなく、普段の業務でも活かせる部分は積極的に取り入れていきたいと思います。
アバター
はじめに この記事は every Tech Blog Advent Calendar 2024 の4日目の記事です。 DelishKitchenやヘルシカのバックエンドやらインフラやらをやっているyoshikenです。 今回は弊社でも利用しているUID生成に便利なSonyflakeについて説明していきます。 UIDとUUIDの違い まず、UIDとUUIDの違いについて理解をしましょう。 UUID RFC 9562 で標準化されている"普遍的にユニークな識別子"のことです。UUIDは、主にデータベースの主キーや分散システムにおけるオブジェクト識別子として使用され、形式は以下のようになっています。 例: f81d4fae-7dec-11d0-a765-00a0c91e6bf6 (8-4-4-4-12 計32文字16進数というフォーマットです。細かい仕様はRFCを参照してください。) 注: RFC 4122は既に廃止されています。 【RFC 9562】新しい UUID の概要紹介 | ymstmsys site UUIDの目的は、 グローバル規模での一意性の担保 です。 UID こちらは特に定まったフォーマットなどはなく、 特定のスコープ内で 一意に識別が可能というのが目的です。 DBのAuto Incrementもそういう意味ではUIDと呼べます。 ソフトウェアの世界に限らず、社員番号や学校のクラスの出席番号もある意味UIDと呼んで差し支えないかと思います。 ここでの注意点はUUIDの目的であった グローバル規模での という点は保証していないということです。 たとえば僕のeveryでの社員番号が200番だったとしても、他社で社員番号が200番の人は別の人を指しています。 DBのAuto Incrementも別テーブルでは衝突をしてしまいます。 そういったグローバル規模での一意性を担保するのであればUUIDを使用すべきです。 とはいえ、例えば学校のクラス40人程度にUUIDで一意性を〜というのはオーバーエンジニアリングになってしまうので、UIDで出席番号を割り振る程度がコストも掛からず可読性もよくなります。 クラスや学年が変わると被ってしまうので、スコープを学内にして学籍番号にするなど、適切にコントロールをしていくことでUIDだけでも問題ない場合があります。 卒業式に「6年2組、出席番号番号f81d4fae-7dec-11d0-a765-00a0c91e6bf6、吉田健太」なんて聞きたくないですね。 Sonyflake sony/sonyflake: A distributed unique ID generator inspired by Twitter's Snowflake Sonyflake is a distributed unique ID generator inspired by Twitter's Snowflake. Sonyflake focuses on lifetime and performance on many host/core environment. READMEにも書いてあるとおり、Twitter社の Snowflake をインスパイアした分散型UID生成ライブラリです 構成要素は以下の通りで、合計63ビットで表現されます。 0 15 32 (ビット) +--------------------------+-----------------------------+ | タイムスタンプ (39ビット) | +--------------------------+-----------------------------+ |タイムスタンプ(続き)| シーケンス (8ビット)|マシンID (16ビット)| +------------------------------------------------------+ 実際に出力されるのは以下のような数列が生成されます。 542479593760621806 以下は生成するexampleコードです。 package main import "github.com/sony/sonyflake" func main() { instance := sonyflake.NewSonyflake(sonyflake.Settings{}) if instance == nil { panic ( "sonyflake not created" ) } id, err := instance.NextID() if err != nil { panic ( "ID not created" ) } println (id) } 弊社ではレシピのナンバリングやユーザーID発行に使用されています。 選定理由ですが、随分前なので正確なところが不明ですが、Sonyflakeが現行のUUIDv7と比べても UUIDv7が128bitにたいしてSonyflakeは63bitとサイズが小さく取り回しがよい Sonyflakeは全て数値かつ単調増加に近しい挙動なのでB-treeインデックスを考えると効率が良い UUIDv7もタイムスタンプがあり以前に比べるとパフォーマンスが良くなりましたが、それでもSonyflakに歩があります 生成コストが安い 見た目がわかりやすい(可読性) というメリットがあります。 そもそも導入当時の2016年前後ではUUIDv7はRFC化されておらず、ULIDも出始めギリギリといったところです。 またSonyflake自体分散システム利用されることが前提のため、オートスケールで複数台のノードが立ち上がっていても問題となりません。 以上のことから当時Sonyflakeを選定するのは妥当性があると思います。 また導入から現在も特に大きな障害は発生しておらず安定して運用できています。 まとめ Sonyflakeは、UUIDv4に比べて生成コストが低く、インデックスの効率も良いというメリットがあります。しかし、UUIDv7ほどのユニーク性は持っていないため、衝突が許されない要件での使用には適していません。 用法用量を正しく守り使用すれば高いパフォーマンスを発揮することが期待できます。 以上でSonyflakeの紹介を終わります
アバター
トモニテのウェブアクセシビリティ向上に向けて この記事は every Tech Blog Advent Calendar 2024 の 3 日目の記事です。 はじめに こんにちは!トモニテにて開発を行っている吉田です。 今回は最近私が少し気にするようにしている(今更?とは言わないでもらえると嬉しい...)ウェブアクセシビリティについて、所属しているトモニテを対象に記事にします。 そもそもアクセシビリティとは? 「アクセシビリティ」という言葉は、Access(近づく、アクセスするの意味)と Ability(能力、できることの意味)からできています。近づくことができる」「アクセスできる」という意味から派生して、「(製品やサービスを)利用できること、又はその到達度」という意味でも使われます。 Access + Ability -> Accessibility ウェブアクセシビリティは、ウェブにおけるアクセシビリティのことです。利用者の障害などの有無やその度合い、年齢や利用環境にかかわらず、あらゆる人々がウェブサイトで提供されている情報やサービスを利用できること、またその到達度を意味します。 なぜウェブアクセシビリティを意識する必要があるのか 現代社会でウェブサイトは老若男女が利用する重要な情報収集源の 1 つとなっています。 しかし、ウェブアクセシビリティに配慮して作られていないと利用者によっては情報を得ることが難しくなってしまいます。 そんな状況を防ぐためにウェブサイトで提供している情報やサービスを誰もが利用できるようにウェブアクセシビリティを確保する必要があります。 ウェブアクセシビリティを確保できているとは ウェブアクセシビリティを確保できているとは以下の状態を指します。 目が見えなくても情報が伝わること・操作できること。 キーボードだけで操作できること。 一部の色が区別できなくても得られる情報が欠けないこと。 音声コンテンツや動画コンテンツで、音声が聞こえなくても話している内容が分かること。 3 ウェブアクセシビリティが確保できている状態とは? より 上記を満たしたウェブサイトであれば視覚障害のある人、聴覚障害のある人、色覚特性のある人など、ウェブサイトの閲覧にお困りの症状をお持ちのかたでもウェブサイトを介して情報を入手したり、サービスを利用できたりするようになります。 そこでエンジニアとしてウェブアクセシビリティにどう貢献できるか考えてみました。 私が考えたのは以下 2 点が開発業務において関わってくることではないかと考えました。 目が見えなくても情報が伝わること・操作できること。 キーボードだけで操作できること。 (ここまではウェブアクセシビリティとは? 分かりやすくゼロから解説!を参考に記載) https://www.gov-online.go.jp/useful/article/202310/2.html 記事では 1 つ目の「目が見えなくても情報が伝わること・操作できること」に焦点を当てます。 スクリーンリーダーを使ってトモニテ web をさわってみた 今回は目が見えない人やロービジョンの人を想定してスクリーンリーダーを使ってトモニテ web を使ってみました。 スクリーンリーダーに利用したのは Mac に標準搭載の VoiceOver です。 スクリーンリーダーを利用してみて気になったのが alt 属性の設定漏れです。 alt 属性は周知の通り <img> 要素で指定された画像が読み込まれない場合に表示する予備(代替)テキストを指定します。 それだけでなく alt テキストはスクリーンリーダーや他の支援技術によって使用され、音読されたり、点字出力端末に送られたりコンテンツを十分に活用できるようサポートする役割があります。 mdn にも alt 属性の指定には以下のような記述がありました。 画像の alt 文字列を選ぶときは、ページ上に画像があることに触れずに、電話で誰かにページを読み聞かせるときのことを想像してみてください。 HTMLImageElement: alt プロパティ より では 実際に存在した alt 属性の設定漏れについてふれていきます。 こちらはトモニテのアプリストアへのリンク画像です。 スクリーンショット内、下部の四角い箱内のテキストは Voice Over で読み上げられるテキストです。 スクリーンリーダーで読み込んでみるとリンクが設定されているが画像だということはわかりますが画像についての説明がありません。 ユーザーからしてみればそこに何かはあるのに内容がないとなっているのは不自然で、しかしその領域をタップするとストアに遷移するという状況です。 原因はシンプルで画像の alt が指定されていなかったことでした。 <img alt src='https://~~' alt 属性を props に渡す形で修正しました どの画像に alt 属性の設定漏れがあるのか特定したいのですが、スクリーンリーダーを使って調べるには数が膨大ですし、コード上で検索をかけるにしても全ページを対象に調べるのは少し骨が折れそうです... トモニテでは Next.js を利用しているのですが何か良い方法がないかと調べたところ eslint-plugin-jsx-a11y パッケージが有効だということが分かりました。 eslint-plugin-jsx-a11y 利用手順 eslint-plugin-jsx-a11y を利用するには前提として ESLint のインストールが必須になります。 # npm npm install eslint --save-dev # yarn yarn add eslint --dev eslint のインストールが完了したら eslint-plugin-jsx-a11y をインストールします。 # npm npm install eslint-plugin-jsx-a11y --save-dev # yarn yarn add eslint-plugin-jsx-a11y --dev インストールが完了したら.eslintrc.js の rules に'jsx-a11y/alt-text'を追加します。 'jsx-a11y/alt-text': error 「これで alt 設定漏れが検知できる!」とリンターを走らせてみたのですが何も検知できません...(alt が設定できていないコンポーネントがあるのは確認済) ドキュメントをよく見ると以下の記載がありました。 By default, this rule checks for alternative text on the following elements: <img> , <area> , <input type="image"> , and <object> . jsx-a11y/alt-text より そのため実装内に <img> 要素が存在しない場合、検知できません。 そのため alt-text ルールにオプションを加えることにしました。 'jsx-a11y/alt-text': [ 'error',{ 'img': ['componentA', 'componentB'] } ], これは img をラップしている componentA と componentB に Props として alt が渡っているか確認することができます。 改めてリンターを走らせると以下のようにエラーとして alt 属性の設定漏れを検知することができました! /app/src/pages/example.js 20:13 error componentA elements must have an alt prop, either with meaningful text, or an empty string for decorative images jsx-a11y/alt-text まとめ 今回は alt 属性にのみ焦点を当てましたが、アクセシビリティを向上させるにはその他にも改善することはたくさんあります。 引き続きアクセシビリティを確保できるよう改善を進め、トモニテをよりたくさんの方に利用してもらえるサービスにしていきたいと思います! 参考資料 https://www.gov-online.go.jp/useful/article/202310/2.html developer.mozilla.org www.npmjs.com github.com
アバター