TECH PLAY

株式会社エブリー

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

410

この記事は every Tech Blog Advent Calendar 2025 の 18 日目の記事です。 はじめに AgentCoreの全体アーキテクチャ AWS Provider バージョン要件 Gateway の構築 必須パラメータ authorizer_type の選択 protocol_type について Gateway Target の構築 必須パラメータ target_configuration のターゲット種類 tool_schema の定義 credential_provider_configuration SigV4署名によるGateway呼び出し Identity Provider の構築 必須パラメータ APIキーの指定方法 Secrets Manager との連携 エージェントからのAPIキー取得 IAM設計のポイント AgentCore Runtime用ロール 必要なアクション Gateway呼び出し権限 GatewayからLambdaを呼び出す権限 AgentCore Runtime のデプロイ agentcore configure agentcore launch まとめ 参考リンク はじめに こんにちは、開発1部に所属している25卒の江﨑です。 2025年10月に一般提供開始されたAmazon Bedrock AgentCoreは、AIエージェントの構築・運用を支援するマネージドサービスです。エージェントの実行環境(Runtime)、既存APIやLambdaをツールとして呼び出すためのインターフェース(Gateway)、認証管理(Identity)など、エージェント開発に必要な機能がまとめて提供されています。 今回、私が担当するデリッシュリサーチというサービスで分析エージェントを構築する中で、AgentCoreのインフラをTerraformで管理しました。 しかし、AgentCore関連のTerraformリソースは、事例がほとんど見当たりませんでした。 本記事では、実際にAgentCoreをTerraformで構築した経験をもとに、各リソースの設定方法とポイントを解説します。 AgentCoreの全体アーキテクチャ 今回構築したシステムの全体像は以下のとおりです。 アーキテクチャ図 このエージェントは、レシピのレビューコメントを分析し、インサイトを生成します。処理の流れは以下のとおりです。 まず、DynamoDBに保存されたキャッシュを確認します。同じレシピに対する分析結果が24時間以内に存在すれば、それを返却して処理を終了します。 キャッシュがない場合、AgentCore Gateway経由でLambda関数を呼び出し、Athenaからレビューデータを取得します。次に、取得したレビューコメントをOpenAI APIに送信し、カテゴリにグルーピングします。最後に、グルーピング結果をもとに要約文を生成し、DynamoDBにキャッシュして返却します。 Terraformで管理する主なリソースは以下の3つです。 リソース 役割 aws_bedrockagentcore_gateway エージェントからのリクエストを受け付けるGateway aws_bedrockagentcore_gateway_target Gateway配下のターゲット(Lambda等) aws_bedrockagentcore_api_key_credential_provider API Key認証情報の管理 AWS Provider バージョン要件 AgentCore関連リソースを使用するには、AWS Provider 6.17以上が必要です。 terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 6.17" } } } 2025年10月に、Provider 6.17と6.18で、AgentCore関連のリソースが一気に追加されました。 v6.17.0で追加 aws_bedrockagentcore_gateway / aws_bedrockagentcore_gateway_target aws_bedrockagentcore_api_key_credential_provider aws_bedrockagentcore_agent_runtime / aws_bedrockagentcore_agent_runtime_endpoint aws_bedrockagentcore_browser / aws_bedrockagentcore_code_interpreter v6.18.0で追加 aws_bedrockagentcore_memory / aws_bedrockagentcore_memory_strategy aws_bedrockagentcore_oauth2_credential_provider aws_bedrockagentcore_workload_identity / aws_bedrockagentcore_token_vault_cmk これにより、AgentCoreの主要なリソースはTerraformで管理できるようになっています。 Gateway の構築 AgentCore Gatewayは、既存のAPI、Lambda関数、各種サービスをMCP互換のツールに変換して、AIエージェントから呼び出せる統一したインターフェースを提供してくれます。 resource "aws_bedrockagentcore_gateway" "review_analysis" { name = "RecipeReviewAnalysisGateway" description = "Gateway for Recipe Review Analysis Agent" authorizer_type = "AWS_IAM" protocol_type = "MCP" role_arn = aws_iam_role.agentcore_gateway.arn } 必須パラメータ Gatewayの作成には以下の4つのパラメータが必須です。 パラメータ 説明 name Gatewayの名前 authorizer_type 認証方式( AWS_IAM または CUSTOM_JWT ) protocol_type プロトコル(現時点では MCP のみ) role_arn GatewayがAWSサービスにアクセスする際に使用するIAMロールのARN authorizer_type の選択 authorizer_type は認証方式を指定します。 値 説明 AWS_IAM IAM認証(SigV4署名) CUSTOM_JWT カスタムJWT認証(OpenID Connect対応) 今回は AWS_IAM を採用しました。理由は以下のとおりです。 IdentityやCognitoの設定・管理が不要 追加の認証設定が不要でシンプル CUSTOM_JWT を選択した場合は、 authorizer_configuration ブロックでOpenID Connectの設定(discovery_url等)が必要になります。 protocol_type について protocol_type はGatewayが使用するプロトコルを指定します。現時点では MCP (Model Context Protocol)のみがサポートされています。 MCPはエージェントがツールを呼び出す際の標準プロトコルで、ツールスキーマの定義やレスポンス形式が規格化されています。 Gateway Target の構築 Gateway Targetは、Gatewayが呼び出す具体的なツール(Lambda関数など)を定義します。 resource "aws_bedrockagentcore_gateway_target" "recipe_review_fetcher" { gateway_identifier = aws_bedrockagentcore_gateway.review_analysis.gateway_id name = "RecipeReviewFetcher" description = "Fetch recipe reviews via Lambda" target_configuration { mcp { lambda { lambda_arn = data.aws_lambda_function.get_recipe_reviews.arn tool_schema { inline_payload { name = "fetch_reviews" description = "指定されたレシピIDのレビューコメントを取得します" input_schema { type = "object" description = "レビュー取得リクエスト" property { name = "recipe_id" type = "string" description = "レビューを取得するレシピのID" required = true } } } } } } } credential_provider_configuration { gateway_iam_role {} } } 必須パラメータ Gateway Targetの作成には以下の3つのパラメータが必須です。 パラメータ 説明 name Targetの名前 gateway_identifier 親GatewayのID target_configuration ターゲットエンドポイントの設定 target_configuration のターゲット種類 target_configuration の mcp ブロック内で、以下のターゲット種類を選択できます。 種類 説明 lambda Lambda関数をターゲットにする(今回使用) mcp_server 外部のMCPサーバーをターゲットにする open_api_schema OpenAPIスキーマベースでAPIを定義 smithy_model Smithyモデルベースで定義 今回は lambda を使用し、Athenaでレビューデータを取得するLambda関数を呼び出しています。 tool_schema の定義 tool_schema はエージェントがツールを呼び出す際のインターフェースを定義します。 inline_payload で直接定義するか、 s3 でS3上のスキーマファイルを参照できます。 tool_schema { inline_payload { name = "fetch_reviews" description = "指定されたレシピIDのレビューコメントを取得します" input_schema { type = "object" description = "レビュー取得リクエスト" property { name = "recipe_id" type = "string" description = "レビューを取得するレシピのID" required = true } } } } この定義により、エージェントは「fetch_reviews」というツールを認識し、 recipe_id パラメータを渡して呼び出すことができます。 credential_provider_configuration credential_provider_configuration はターゲット呼び出し時の認証方式を指定します。 認証方式 説明 gateway_iam_role GatewayのIAMロールを使用(今回使用) api_key APIキー認証(外部API向け) oauth OAuth認証(外部サービス向け) 今回は gateway_iam_role を使用し、GatewayのIAMロールでLambdaを呼び出しています。 credential_provider_configuration { gateway_iam_role {} } gateway_iam_role を指定すると、GatewayのIAMロールを使用してLambdaを呼び出します。これにより、別途認証情報を管理する必要がなくなります。 SigV4署名によるGateway呼び出し Gatewayの authorizer_type に AWS_IAM を指定した場合、エージェントからGatewayを呼び出す際にSigV4署名が必要です。 以下は、MCPクライアントを使用してSigV4署名付きでGatewayを呼び出す例です。 import boto3 from mcp import ClientSession from mcp_lambda.client.streamable_http_sigv4 import streamablehttp_client_with_sigv4 session = boto3.Session(region_name= "ap-northeast-1" ) credentials = session.get_credentials() async with streamablehttp_client_with_sigv4( url=gateway_url, credentials=credentials, region= "ap-northeast-1" , service= "bedrock-agentcore" , ) as (read, write, _): async with ClientSession(read, write) as mcp_session: await mcp_session.initialize() result = await mcp_session.call_tool( "RecipeReviewFetcher___fetch_reviews" , arguments={ "recipe_id" : recipe_id} ) ポイントは以下のとおりです。 mcp_lambda パッケージの streamablehttp_client_with_sigv4 を使用 service には bedrock-agentcore を指定 ツール名は {TargetName}___{tool_name} の形式(アンダースコア3つ) Identity Provider の構築 AgentCore Identityは、エージェントが外部APIにアクセスする際の認証情報を管理します。今回はOpenAI APIのキーを管理するために aws_bedrockagentcore_api_key_credential_provider を使用しました。 # Secrets ManagerからAPIキーを取得 data "aws_secretsmanager_secret" "openai_api_key" { name = "bedrock-agentcore/openai-api-key" } data "aws_secretsmanager_secret_version" "openai_api_key" { secret_id = data.aws_secretsmanager_secret.openai_api_key.id } # AgentCore Identity ProviderにAPIキーを登録 resource "aws_bedrockagentcore_api_key_credential_provider" "openai" { name = "OpenAIApiKey" api_key_wo = jsondecode (data.aws_secretsmanager_secret_version.openai_api_key.secret_string) [ "OPENAI_API_KEY" ] api_key_wo_version = 1 } 必須パラメータ パラメータ 説明 name Providerの名前(変更するとリソースが再作成される) APIキーの指定方法 APIキーは以下の2つの方法で指定できます。 方法 パラメータ 説明 通常 api_key APIキー値がTerraform plan/stateに表示される Write-Only(推奨) api_key_wo + api_key_wo_version stateファイルに保存されずセキュア 本番環境では api_key_wo の使用を推奨します。 api_key_wo は Write-Onlyの属性です。Terraformの state ファイルには保存されず、セキュリティが確保されます。 api_key_wo_version はキーのバージョン管理に使用します。キーを更新する際はこの値をインクリメントすることで、Terraformに変更を検知させます。 Secrets Manager との連携 API Keyは直接Terraformに記述せず、Secrets Managerから取得しています。事前に以下のようなシークレットを作成しておきます。 aws secretsmanager create-secret \ --name "bedrock-agentcore/openai-api-key" \ --secret-string '{"OPENAI_API_KEY":"sk-..."}' \ --region ap-northeast-1 このリソースを作成すると、AgentCore側でも自動的にSecrets Managerにシークレットが作成され、 api_key_secret_arn 属性で参照できます。 エージェントからのAPIキー取得 登録したAPIキーは、エージェントのコード内で @requires_api_key デコレータを使用して取得できます。 from bedrock_agentcore.identity.auth import requires_api_key IDENTITY_OPENAI_PROVIDER = "OpenAIApiKey" # Terraformで登録したname @ requires_api_key (provider_name=IDENTITY_OPENAI_PROVIDER) async def get_openai_api_key (*, api_key: str ) -> str : """api_key はデコレータにより自動注入される""" return api_key デコレータが自動的にIdentity Providerから認証情報を取得し、 api_key 引数に注入してくれます。これにより、エージェントのコード内でAPIキーを直接扱う必要がなくなります。 IAM設計のポイント AgentCoreを運用するには、適切なIAMロールとポリシーの設計が重要です。 AgentCore Runtime用ロール resource "aws_iam_role" "agentcore_review_analysis" { name = "RecipeReviewAnalysisAgentRole" assume_role_policy = jsonencode ( { Version = "2012-10-17" Statement = [ { Effect = "Allow" Principal = { Service = "bedrock-agentcore.amazonaws.com" } Action = "sts:AssumeRole" } ] } ) } 信頼ポリシーで bedrock-agentcore.amazonaws.com を指定することで、AgentCoreサービスがこのロールを引き受けられるようになります。 必要なアクション AgentCore Identity経由でAPI Keyを取得する場合、以下のアクションが必要です。 data "aws_iam_policy_document" "agentcore_identity" { statement { sid = "TokenVault" effect = "Allow" actions = [ "bedrock-agentcore:GetResourceApiKey" ] resources = [ "arn:aws:bedrock-agentcore:ap-northeast-1:$ { var.account_id } :token-vault/*" , "arn:aws:bedrock-agentcore:ap-northeast-1:$ { var.account_id } :workload-identity-directory/*" ] } OAuth2認証を使用する場合は、 bedrock-agentcore:GetResourceOauth2Token も追加します。 Gateway呼び出し権限 エージェントがIAM認証でGatewayを呼び出すには、 bedrock-agentcore:InvokeGateway アクションが必要です。 data "aws_iam_policy_document" "agentcore_invoke_gateway" { statement { sid = "InvokeGateway" effect = "Allow" actions = [ "bedrock-agentcore:InvokeGateway" ] resources = [ "arn:aws:bedrock-agentcore:ap-northeast-1:$ { var.account_id } :gateway/$ { aws_bedrockagentcore_gateway.review_analysis.gateway_id } " ] } } GatewayからLambdaを呼び出す権限 Gateway Targetで credential_provider_configuration に gateway_iam_role を指定した場合、GatewayのIAMロールに lambda:InvokeFunction 権限が必要です。 resource "aws_iam_role_policy" "agentcore_gateway_lambda" { name = "agentcore-gateway-lambda-invoke" role = aws_iam_role.agentcore_gateway.id policy = jsonencode ( { Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = "lambda:InvokeFunction" Resource = data.aws_lambda_function.get_recipe_reviews.arn } ] } ) } この権限がないと、GatewayからLambdaを呼び出す際にエラーが発生します。 AgentCore Runtime のデプロイ AgentCore Runtimeについては、Terraformではなく AgentCore CLI を使用してデプロイしました。CLIがビルド・デプロイ・環境設定を自動化してくれるため、Runtimeの管理には CLI の方が適していると判断しました。 agentcore configure まず agentcore configure でエージェントの設定を行います。 オプション 説明 --entrypoint エージェントのエントリーポイントとなるPythonファイル --name エージェント名 --execution-role エージェントが使用するIAMロールのARN --requirements-file 依存ライブラリを記載したファイル --region デプロイ先のリージョン agentcore configure \ --entrypoint ./invoke.py \ --name recipe_review_analysis_agent \ --execution-role arn:aws:iam::<account-id>:role/RecipeReviewAnalysisAgentRole \ --requirements-file ./requirements.txt \ --region ap-northeast-1 --execution-role には、Terraformで作成したIAMロールのARNを指定します。CLIがデプロイ時に自動でこのロールをRuntimeに割り当ててくれます。 agentcore launch 設定完了後、 agentcore launch でAWSにデプロイします。 --env オプションで環境変数を渡すことができます。 agentcore launch \ --env GATEWAY_URL="https://<gateway-name>.gateway.bedrock-agentcore.ap-northeast-1.amazonaws.com/mcp" TerraformでGatewayを作成した際に出力されるURLを、 --env で環境変数としてエージェントに渡しています。 まとめ 本記事では、Amazon Bedrock AgentCoreをTerraformで構築する方法を解説しました。 主なポイントは以下のとおりです。 AWS Provider 6.17以上が必要 aws_bedrockagentcore_gateway でGatewayを作成し、認証方式とプロトコルを指定 aws_bedrockagentcore_gateway_target で呼び出し先(Lambda等)とtool_schemaを定義 aws_bedrockagentcore_api_key_credential_provider で外部API用のAPIキーを管理 IAMロールの信頼ポリシーに bedrock-agentcore.amazonaws.com を指定 RuntimeはAgentCore CLIでデプロイし、Terraformで作成したIAMロールを --execution-role で指定 AgentCoreはまだ新しいサービスであり、ドキュメントや事例が少ない状況です。私たちも試行錯誤しながら構築を進めている段階ですが、本記事が同様の構築を行う方の参考になれば幸いです。 参考リンク Terraform AWS Provider - bedrockagentcore_gateway Terraform AWS Provider - bedrockagentcore_gateway_target Terraform AWS Provider - bedrockagentcore_api_key_credential_provider Amazon Bedrock AgentCore CLI
アバター
この記事は every Tech Blog Advent Calendar 2025 の 17 日目の記事です。 目次 はじめに 最近のAIの動向に関する所感 AI駆動開発を意識したドキュメント運用の観点 何をドキュメンテーションするか どこにドキュメントを保存するか どのようにドキュメントを更新するか どのようにAIに利用させるか トモニテでの運用方針 1. ドキュメント用リポジトリの作成 2. ドキュメントリポジトリへのドキュメントの集約 3. ドキュメントの活用 4. 今後について おわりに はじめに こんにちは。 開発本部開発1部トモニテ開発部所属の庄司( @ktanonymous )です。 本記事では、AI駆動開発を意識したドキュメント運用について、 どのような選択肢があるのか、社内での運用例、自チームでの運用方針などの側面から考えてみたことを簡単にまとめていきたいと思います。 なお、本記事の内容は「正解」ではないので、「それは違うのでは?」「もっと〇〇した方が良いよ」といったご指摘などがありましたら、 ぜひぜひ X(旧Twitter) などで取り上げていただけますと幸いです。 ※ 本記事で触れないこと プロトコルや各種手法の原理 マルチエージェント モデルやツールの比較 最近のAIの動向に関する所感 最近は、AI技術の発展により開発フローにとどまらずドキュメントの作成や運用のあり方も大きく変わってきていると感じています。 少し前までは、ドキュメントの作成・運用とその他の作業はほとんど独立していました。 「必要に応じてドキュメントを作成し、それを参照しながら開発する」という基本的な流れは、 LLMが登場してからもそれほど大きくは変化していなかったように思います。 精度向上に伴いAIを活用するシーンこそ増えていましたが、チャットベースでその都度コンテキストを与えながら、 局所的にサポートしてもらう形が主だったんじゃないかなという印象があります。 しかし、ここ1年くらいで MCP(Model Context Protocol) を筆頭にLLMの機能が目まぐるしい勢いで拡張され、 LLMの機能のツール化やLLM自体のツール化、LLM同士の協調などが可能になってきました。 エディタでのローカル開発ひとつ取っても、コードベースや要件がまとめられたドキュメントへのリンクなどの最低限のコンテキストだけを与えて依頼すれば、 「必要な情報を調べる -> 実装計画を提案する -> 計画に沿って実装する -> 実装結果をまとめる・レビューする」といった一連のフローを自律的に行わせることが可能になっています。 「AI駆動開発(AI-Driven Development)」という言葉も浸透してきているほどAIが担う役割が増えてきており、多くのAIサービスベンダーが「コンテキストエンジニアリング」を重要視しているように、 AIに与えるコンテキストの重要性も増してきています。 AIに与えたコンテキストからより正確な出力を得るうえで、内部の人間しか知り得ないプロジェクトに関する知識を含めることが重要になります。 そういった情報は何かしらのツールを用いて社内ドキュメントとしてまとめられていることが多いかと思います。 それらを踏まえて、何をドキュメンテーションするのか、どこにドキュメントを保存するのか、どのようにドキュメントを更新していくのか、 どのようにドキュメントを利用していくのか、といった観点が重要で難しい問題になるのかなと感じています。 AI駆動開発を意識したドキュメント運用の観点 前述の通り、AIとの共同作業においてはAIに与えるコンテキストが出力結果に大きく影響します。 今回は、AI駆動開発を意識したドキュメント運用を考えるにあたり、以下の4つの観点から整理していきたいと思います。 何をドキュメンテーションするか どこにドキュメントを保存するか どのようにドキュメントを更新するか どのようにAIに利用させるか 何をドキュメンテーションするか ドキュメントを作成するにあたり、何を目的としてどんなドキュメントを残すのかという観点があります。 例えば、ドキュメントの読者が誰かという点について、エンジニアだけを想定するのか他の職種の読者も想定するのか、 もしくはAIが読めれば十分という考え方もあると思います。 ほかにも、何をドキュメントに残したいのか(要件定義書なのか、仕様書なのか、設計書なのか、etc...)ということも考えられます。 ドキュメントに記載すべき内容やその構成は、読者やドキュメントの役割のようなドキュメンテーションをする目的によって変わってくるので、 チーム内での認識の共有が重要になるかと思います。 どこにドキュメントを保存するか そもそも、ドキュメントはどこにどんな構成で置いておくのが良いのかという観点もあります。 保存場所としては、GitHubやNotion、Confluenceなど様々な選択肢が考えられます。 構成についても、1箇所にまとめるのか分散させるのか、どのような単位でネームスペースを区切るのかなど様々な要素があると思います。 ここでは、AIにコンテキストとして渡しやすいか、人が読む際に必要な情報にアクセスしやすいかなどの側面から適した保存場所や構成が考えられると思います。 例えば、AIのコンテキストという観点ではローカルかMCP経由で参照しやすいツールの方が適しているかもしれません。 職種を問わずアクセスのしやすさを重視するのであれば、組織内で利用しているツールにまとめて、 開発などで使う際には別途AIに参照させられるような機構を作ってしまう方が良いかもしれません。 ドキュメントの管理方法については、コードベースごとに対応する内容のドキュメントをまとめるやり方や コードベースとは別にドキュメントを集約するやり方など、そのほか様々な選択肢があるかと思います。 どのようにドキュメントを更新するか ドキュメントをどのように更新していくかという観点も重要なポイントになります。 ドキュメントの更新に関しては、更新の方法やタイミング、誰が更新するのかなどの要素が挙げられます。 例えば、更新の方法については、人が手動で修正する方法やCIなどで自動生成する方法はもちろん、 AIに更新内容を渡してドキュメントを修正させるといったアプローチも採れるようになってきました。 また、更新のタイミングについても、変更が発生するたびに都度更新するのか、定期的に棚卸しをして対応するのか、 あるいは、1度作成したものは魚拓として更新はせずに変更は差分として新しくドキュメントを作成するという考え方もできるかもしれません。 更新する担当者に関しては、変更に携わる人が更新も担当するやり方や別途責任者を決めて更新作業を集約するやり方などが考えられると思います。 ドキュメントのAI活用を考慮する場合、ドキュメントの情報の正確性はAIに与えるコンテキストの質に大きく影響してしまいます。 ドキュメントの鮮度を維持する(最新情報が明確に判別できるようにする)ことが、AI駆動開発を意識する上では重要な要素になると言えるかと思います。 どのようにAIに利用させるか 整備したドキュメントを活用するために、コンテキストや参照情報としてドキュメントの情報をAIに与える方法も考える必要があります。 CursorやClaudeCodeのようなAIエディタを利用してローカルから参照させる方法もありますし、 手元の作業環境とは別に情報を集約するようなAIツールを作ってしまうという方法も考えられます。 最近では、MCPを利用したドキュメント参照が普及してきており、GitHubやAtlassianのMCPで組織内のコードやドキュメントを参照したり、 AWS Knowledge MCPのように外部ドキュメントを参照したりすることが日常的になっているかと思います。 これを踏まえると、MCP経由で情報を参照させるのが手っ取り早いと考えることもできるかもしれません。 Cursor の GitHub MCP でのファイル参照について GitHub MCP には get_file_contents という tool があり、 ファイルパスを指定することで該当ファイルの内容を参照できるようになっています。 しかし、2025年12月10日時点で、 Cursor からこのツールでファイルの内容を参照しようとしても正しくレスポンスを読み取ることができずに失敗してしまうようです 1 。 古いバージョンを利用することで解決したという報告もありますが、コミット履歴やローカルからファイル内容を参照させることも検討する必要があります。 (※ 筆者が Cursor 以外で確認できていないので、Cursor 以外で同様の事象が発生するかは言及を避けさせていただきます) また、単に接続するだけでなく「どのタイミングでどのドキュメントを参照すべきか」をAIに指示することも重要です。 例えば AGENTS.md 2 などのルールファイルに「DB設計については docs/schema を参照すること」のようにドキュメントへの導線をAIのシステムプロンプトやルールに組み込むことで、 適切なタイミングで必要な情報を取得するように教えて効率よく質の高い出力を得やすくなります。 トモニテでの運用方針 トモニテでは、前述の観点も踏まえつつ、以下の状態を目指したドキュメント整備を構想しています。 サービスの構成や施策の背景・仕様が確認できる AIが容易にコンテキストを取得できる 具体的に実践しようとしている内容を簡単に紹介します。 1. ドキュメント用リポジトリの作成 開発作業で自然に連携できるよう、ドキュメントは GitHub リポジトリにマークダウン形式で集約しようと考えています。 弊社ではエンジニアの他にPMも GitHub やAIエディタが利用できるような体制であり、 想定されるドキュメントの基本的な利用者がエンジニアとPMということもあり、GitHubを利用する判断をしました。 また、GitHubのマークダウン形式ではMermaid記法が利用でき、図をテキストベースで管理できるというメリットもあります。 テキストベースで管理できるため、画像ファイルよりもAIが内容を解釈しやすく、修正もしやすいのが良い点です。 なお、仕様の変更などが発生した場合は、その変更に関する担当者が責任を持ってドキュメントに反映するという運用方針でのドキュメント管理を意識しています。 2. ドキュメントリポジトリへのドキュメントの集約 トモニテではインフラ、APIサーバー、プロダクトAのフロントエンド、プロダクトBのフロントエンド、のように複数のリポジトリが存在しています。 (一部モノレポを採用しているケースもありますが、話の本筋は変わらないので個別に言及することはしません) 今回新たに作成したドキュメントリポジトリは、トモニテのドキュメントマスタとしての役割を持たせることを構想しています。 そのため、各施策の前提となるような、サービス全体の構成や各施策の背景・仕様が確認できるようなドキュメントはドキュメントリポジトリに作成し、 各領域にフォーカスした知識は各リポジトリにドキュメントを作成するような構成をイメージしています。 これにより、AIも人も、ドキュメントリポジトリを確認することで全体観を把握しつつ、 特定の領域に関する知識への導線から必要な情報を適切に取得することができるようになると考えています。 トモニテでのドキュメント運用イメージ ドキュメントリポジトリ /documents/ ├── project_a/ │ └── design_docs/ │ ├── overview.md │ ├── current_system.md │ ├── feature_a.md │ ├── feature_b.md │ ├── architecture.md │ ├── api_design.md │ ├── database.md │ ... │ └── README.md ... └── README.md ※ 既存ドキュメントが充実しておらず新しくジョインするメンバーもいたので現在のシステムを1つのドキュメントにまとめました。 APIサーバー /api-server/ ├── .cursor/ │ └── rules/ │ └── docs-context.mdc ├── docs/ │ ... │ └── ai/ ... └── README.md ※ チームメンバー全員が cursor を利用していて、AGENTS.md がまだ普及していないタイミングだったので .cursor/rules/ ディレクトリを作成しました。 ※ ツールごとにルールファイルを作りたくなかったので、 docs/ai/ ディレクトリを参照するようにマスタとなるルールファイルを作成しました。 3. ドキュメントの活用 トモニテでのドキュメントリポジトリの運用は始めたばかりなので、まだまだ内容は不足していますが、 直近のプロジェクトで design docs 3 を作成してリポジトリに保存しています。 プロジェクトを進めるにあたり、design docs を参照することでAIの実装精度が高まっていることも実感できていますし、 実装中に仕様に微修正が入った際に、実装PRを参照してドキュメントを更新させることもできています。 4. 今後について AI駆動開発を見据えたドキュメント運用は、まだまだ対応すべきことも検討すべき課題も多いと感じています。 より本格的に運用を進めていかないと見えてこない課題もあるかと思います。 少しずつ運用を本格化していき、社内の他のチームや社外の事例からも学びながら、 より良いドキュメント運用を目指して改善していきたいと思います。 おわりに 今回の記事では、AI駆動開発を意識したドキュメント運用について幾つかの観点から考えたことを整理してみました。 これまでは人間が読むことを前提に作られていたドキュメントですが、AIのコンテキストとして利用することを意識すると、 保存場所や書き方の最適解も少しずつ変わってくるのかなと感じています。 ドキュメントの運用自体が始まったばかりで、まだまだ試行錯誤が必要な段階ではありますが、 実際の運用を通じて、自分たちのチームに合った形を少しずつ見つけていければと思います。 本記事の内容が、皆さんのチームでのドキュメント運用を考えるきっかけや参考になれば幸いです。 最後まで読んでいただき、ありがとうございました。 cursor fails to read get_file_contents of github official mcp(2025年12月10日閲覧) ↩ AGENTS.md(2025年12月10日閲覧) ↩ Design Docs at Google (2025年12月10日閲覧) ↩
アバター
この記事は every Tech Blog Advent Calendar 2025 の 16 日目の記事です。 はじめに こんにちは、リテールハブ開発部でバックエンドエンジニアをしているホシと申します。 現在、小売アプリ開発で Laravel 11 を利用しながら日々サービス開発に取り組んでいます。 先日、サービスのパフォーマンス改善を目的として、MySQL の SQL チューニングを行う機会がありました。 これまでも EXPLAIN を使って実行計画を確認することが多かったのですが、以前から「EXPLAIN の内容と実際の動作が一致しない」ケースをいくつか経験していました。今回のチューニングでも同じような状況があり、実際に SQL を実行しながら挙動を確かめる必要がありました。 しかし MySQL 8.0 系では、より深い分析が可能な EXPLAIN ANALYZE が導入され、実際の実行内容を踏まえた「リアルな実行計画」を確認できるようになっています。 私自身、SQL チューニングから少し遠ざかっていたこともあり、しっかり活用できていなかったのですが、意外とまだ使っていない方もいるのではと感じました。 そこで本記事では以下を中心にお話できればと思っています。 EXPLAIN / EXPLAIN ANALYZE の違いについて 実行計画の読み方と注意点 推定と実測が大きく乖離するケース 使いどころと避けるべき点 まとめ 1. EXPLAIN / EXPLAIN ANALYZE の今までの経緯 MySQL における実行計画確認は長らく EXPLAIN 一択 でした。 「EXPLAIN」は初期から存在し、5.6 からは更新系も可能に ただし長年「推定計画のみ」で、実際の動作は把握しにくい 行数推定やコスト推定が外れるケースも度々あった その後 MySQL 8.0 系でオプティマイザが大幅に改善され、 MySQL 8.0.18(2019 年)で EXPLAIN ANALYZE が追加 されました。 特徴: 実際にクエリを実行する 実測行数(actual rows) 実行時間(actual time) 実際の JOIN 順 内部処理の詳細 が取得できるようになり、より精細な実行計画解析が可能になりました。 2. MySQL の EXPLAIN / EXPLAIN ANALYZE の違い EXPLAIN と EXPLAIN ANALYZE でもっとも大きな違いは、「実際に SQL を実行するかどうか」にあります。 ■ EXPLAIN(推定) SQL を実行せず、オプティマイザの「推定計画 」を表示 使用インデックス、JOIN 順序、読み込む推定行数などがわかる 統計情報に依存するため、実際と大きく異なる場合がある ■ EXPLAIN ANALYZE(実測) SQL を 実際に実行して 実行計画を取得 実際の行数、実際の実行時間、ループ回数などが表示される 推定と実測のギャップが明確にわかり、ボトルネック特定に非常に有効 ただし、DELETE / UPDATE(INSERT は未対応) の更新系クエリでは、 実際に実行されるため、トランザクション内による実行、ロールバックを行わないとデータに影響が出てしまいます。 また、通常と同様にロックが発生する点にも注意が必要です。 3. 実行計画の読み方 実際の実行計画の読み方を簡単ですがご紹介します。 次のようなシンプルなテーブル JOIN を例にします。 SELECT * FROM orders o JOIN order_items i ON o.id = i.order_id WHERE o.status = ' PAID ' AND i.price > 1000 ; 実行計画は次のようになります。 EXPLAIN 結果 id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE o ALL PRIMARY NULL NULL NULL 3 33.33 Using where 1 SIMPLE i ref order_id order_id 4 retail-app.o.id 1 33.33 Using where 補足 orders (o) テーブルは 全件走査(type = ALL) のため、条件列にインデックスがない状態。 order_items (i) は ref 結合 で、 order_id インデックスを使用。 両方 Using where のため、最終フィルタはクエリ条件で行われている。 さらに JOIN が増えると以下のような多段構造になっていきます。 SQL例 SELECT o.id AS order_id, c.name AS customer_name, i.product_id, i.price FROM orders o JOIN customers c ON o.customer_id = c.id JOIN order_items i ON o.id = i.order_id WHERE o.status = ' PAID ' AND i.price > 1000 ; EXPLAIN 結果(orders → customers → order_items) id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE o ALL PRIMARY, customer_id NULL NULL NULL 3 33.33 Using where 1 SIMPLE c eq_ref PRIMARY PRIMARY 4 retail-app.o.customer_id 1 100.00 NULL 1 SIMPLE i ref order_id order_id 4 retail-app.o.id 1 33.33 Using where 補足メモ orders(o) : インデックス不使用 → 全件走査(ALL) customers(c) : 主キーで eq_ref → 高速な1件特定 order_items(i) : ref 結合で order_id を利用 orders の絞り込みが弱いと、JOIN 全体の効率が下がりやすい構造になる 多段 JOIN では特にデータ数がテーブルごとに極端に異なっていたりすると推定ミスが起きやすく、EXPLAIN ANALYZE の価値がより高まります。 4. EXPLAIN では見えない「ズレ」の例 極端にはなりますが、以下に2つの例を挙げてみました。 ■ 例 1:統計情報が古い SELECT * FROM users WHERE created_at >= CURDATE(); EXPLAIN(推定) の結果を抜粋 type=range, rows=10 → MySQL は「現在のデータは 10 行くらい」と予測している。 EXPLAIN ANALYZE(実測) -> Filter: (users.created_at >= curdate()) (cost=0.35 rows=10) -> Table scan on users (cost=0.35 rows=1,204,293) rows examined: 1,204,293 actual time=0.001..1200.226 rows=1,204,293 loops=1 → 実際には 120万行を全件スキャンし、1.2 秒も時間がかかっている。 なぜ EXPLAIN では10行と予測していたのに、実際は120万件となり「ズレる」場合があるのか? 統計情報は自動更新されるが、更新タイミングは一定でない 大量 INSERT の直後は「古い推定」のままのことがある 古い統計のままだと「10行程度」など誤判断をしてしまう場合がある ■ 例 2:JOIN 順の推定が不適切 個人的にはこちらの方が特に厄介かなと感じています。 SELECT * FROM products p JOIN product_tags t ON p.id = t.product_id WHERE t.tag = ' SALE ' ; 期待する動作: product_tags から SALE 行のみ抽出 その product_id を使って products を引く しかし EXPLAIN の推定(抜粋): table type key rows Extra p ALL NULL 1200000 Using where t ref tag 10 Using index → products が 120 万行フルスキャンされるプランになっている。 EXPLAIN ANALYZE(実測) -> Nested loop inner join (cost=0.90 rows=10) -> Table scan on p (cost=0.35 rows=1,200,000) rows examined: 1,200,000 actual time=0.004..5500.891 rows=1,200,000 loops=1 -> Filter: (t.tag = 'SALE') (cost=0.55 rows=10) -> Index lookup on t using product_id (product_id=p.id) actual time=0.05..0.10 rows=1 loops=1 → 実際には 5.5 秒かけて 120 万行を読み切っている。 上記は、 p テーブルを120万件フルスキャン p の各行に対して t テーブルを product_id でインデックス検索 見つかった t 行の中で tag = 'SALE' のものだけ採用 「120万行をひとつずつ見て、その都度 t テーブルを1件検索する」構造になっています。 この部分が重くなってしまう原因です。(Nested Loop JOIN) 考えられる原因: product_tags.tag のカーディナリティ推定が外れていた MySQL が「SALE は大量にあるだろう。フィルタにならない」と誤解 そのため、「products を先に読んだほうが速い」という間違った結論になったりする 実際には SALE 件数が少なければ、 product_tags を起点に読む方が圧倒的に速い。 このケースが特に起きた時に一見問題ない SQL のつもりが、「突然クエリが数秒〜十数秒に悪化する」場合もありえます。 統計情報が最新でも起きる可能性があり、これがかなり厄介だったりします。 5. EXPLAIN ANALYZE が教えてくれる「実際の実行計画」 EXPLAIN ANALYZE では以下が明確になります。 ■ 1. どこがボトルネックか actual time / rows / loops により、処理の重い箇所を特定できる。 ■ 2. 推定と実測のズレ 推定 rows と actual rows の差を見ることで、オプティマイザの誤判断を発見できる。 ■ 3. 実際の実行時間 ミリ秒単位でどの処理が時間を使っているか把握できる。 ■ 4. JOIN 順序が適切か loops 値などから、JOIN の選択順が正しいか判断できる。 ■ 5. インデックスが効いているか 行数の多さから、フルスキャンかどうか一目でわかる。 チューニングをする際、これをやれば解決といった方法は明確にないため、 ケースごとにインデックス見直し、結合方法の改善、条件指定の組み替えなどを行いながら解決する必要があります。 EXPLAIN ANALYZE の使用でそれが以前よりも実際の原因がわかりやすくなるのは大きな違いかなと思います。 6. EXPLAIN ANALYZE を本番で使う際の注意点 本番で使用する際には必ず以下を理解しておく必要があります。 更新系の EXPLAIN ANALYZE は特に注意する 先ほども少し触れましたが、更新系の場合、ANALYZE は実際に実行されてしまうので、 トランザクション内で確実に戻せることが確認できた上であれば実施は可能です。 しかし、 クエリ自体は本当に実行される ロックは実際に取られる トリガーが動く可能性がある(環境による) ロックの影響で他の処理が待たされる という点があり、実行には細心の注意が必要です。 特に「ロック中に他処理が待ち状態になるのは本番ではリスク」になります。 また、更新系ではキャンセル時の挙動も考慮する必要があります。 キャンセル時のロールバック、想定外のロックの遅延なども怖い要因かなと思います。 本番で更新系 ANALYZE は特別な理由がない限り実行しない 上記のことから、基本は以下のルールで行うのが良いのかなと思います。 本番で EXPLAIN ANALYZE を使うのは SELECT のみに限定 UPDATE / DELETE (INSERT は未対応)の ANALYZE は 検証環境で行う 重いクエリの ANALYZE は本番で実行しない ただ、本番でしか再現しないケースなどでは、本番で実行して試したいところですが、 上記ルールに従い、リスクを軽減できるようにしていきたいです。 7. ヒント句や FORCE INDEX は最終手段 また、EXPLAIN 機能の話とは少しずれますが、 チューニングを行なう際、想定通りの実行計画になかなかならず、 ヒント句やFORCE INDEX などを使用したいケースがあります。 (使うと解消できる状況) ただ、ヒント句は便利ですが、以下の理由で「最終手段」とした方が良いのかなと個人的には思います。 データ量・分布が変わると逆効果になる場合がある 計画が固定されるため柔軟性が落ちてしまう 長期メンテコストを考えると、逆効果の場合も そのため、まず優先すべきは: 統計情報の更新 正しいインデックス設計 JOIN 条件の見直し SQL の簡潔化 を行った上で、それでも必要であればという意識が必要かなと感じています。 8. まとめ いかがでしたでしょうか。 最後にEXPLAIN ANALYZEについての比較を表にまとめました。 比較項目 EXPLAIN EXPLAIN ANALYZE 実行されるか 実行しない(推定) 実行される(実測) 出力 推定行数・推定コスト 実行行数・実際の時間 統計情報の影響 大きい 小さい 本番への影響 小さい ロックなど注意 更新系クエリ 実行されない 実行→ロールバック 主な用途 計画確認 実挙動の把握 精度 誤差が大きい場合あり 実測で高い精度 推定と実測は大きく異なることがあり、特に JOIN や統計情報が絡むケースでは差が顕著になる 多くのパフォーマンス問題は、統計情報の更新・インデックス改善・JOIN や SQL の見直しで解決可能 ヒント句は強力だが、状況が変わると逆効果になる場合もあるため「最終手段として使った方が良い」 以上、MySQL の実行計画についてのお話でした。 今後はどちらも有効に活用して改善対応を進めていければと思っています。 少しだけでもSQLチューニング作業の参考になれば幸いです。 最後までお読みいただきありがとうございました。
アバター
この記事は every Tech Blog Advent Calendar 2025 の15日目の記事です。 はじめに こんにちは! 開発1部デリッシュキッチンの蜜澤です。 今回はクラスタリングとcos類似度を用いて表記揺れ辞書を作成してみたので、どのように作成したかを紹介させていただきます。 本記事では具体的なコードは記載せず、実際に行った手順の紹介のみになります。 やりたいこと デリッシュキッチンでユーザーが検索したワードの中で意味が同じものをまとめて、同じワードに変換するための辞書を作成します。 ユーザーが実際に検索したワード(sub)と統一した表記(main)が格納された以下のような辞書が今回作成したいものになります。 sub main 竜田揚げ 竜田揚げ 竜田あげ 竜田揚げ たつたあげ 竜田揚げ 課題 一定の検索回数以上だったワード約17000ワードを対象に、表記揺れ辞書の作成を行うため、人力で行うと膨大な時間がかかってしまいます。 生成AIを使うにしても一気に約17000ワードを渡す必要があるため、処理にかなり時間がかかることが予想されます。 今回試した方法 前述の課題を踏まえて、今回は以下のような手順で、クラスタリングとcos類似度を使用して表記揺れ辞書の作成を試みました。 1.対象ワードを全てひらがなに変換 2.ひらがな変換したワードをベクトル化 3.ベクトルに対してクラスタリングを実施 4.クラスタごとに、cos類似度でグループ分け 1.対象ワードを全てひらがなに変換 「竜田揚げ」と「たつたあげ」のように漢字とひらがなの表記は最終的には同じグループにしたいのですが、そのままベクトル化してしまうと、離れてしまい、同じクラスタにすらならない可能性があるため、まずは全ワードにひらがなを振ります。 OpenAI APIを利用して、以下のように元のワードをひらがなに変換したカラムを作成しました。 word word_hira 竜田揚げ たつたあげ 竜田あげ たつたあげ たつたあげ たつたあげ 2.ひらがな変換したワードをベクトル化 1.で変換したひらがなのカラムを、OpenAI APIを利用してベクトル化します。 ベクトル化には既存のパッケージなどを利用しても良いですが、今回は手軽にできるAPI利用にしました。 3.ベクトルに対してクラスタリングを実施 2.で作成したベクトルに対して、総当たりでcos類似度を求めてしまうと計算量が膨大になってしまうので、総当たりの組み合わせ数を減らすために、まずはK-means法でクラスタリングを行います。 K-means法は最初にクラスタ数を決める必要があります。 今回はワードが約17000語あり、表記揺れのパターンは3~4つくらいになることが多いという経験則から、k=5000にしました。 かなり適当な決め方であり、最適なクラスタ数を求めたらもっと少なくなると思います。 しかし、今回はとにかく手軽に行いたい、かつ、クラスタリングはあくまでも大雑把に分けることが目的なので、クラスタ数の最適化は行いませんでした。 「竜田揚げ」が含まれるクラスターは以下のようになりました。 word word_hira cluster_id 竜田揚げ たつたあげ 0 竜田あげ たつたあげ 0 たつたあげ たつたあげ 0 竜田 たつた 0 たつた たつた 0 たつくり たつくり 0 4.クラスタごとに、cos類似度でグループ分け 3.で作成されたクラスタごとにword_hiraのcos類似度行列を作成し、類似度の閾値を超えたものに対して同じgroup_idを振ります。 今回は処理時間のことも考えて、一度グループに割り当てられたワードは処理順が後のワードとの類似度の方が高くても、別のグループに再度割り当てられない設計にしたので、閾値を高く設定して、ミスマッチが減るようにしました。 cos類似度の閾値を0.8、0.9、0.95の場合で試した結果、0.9がちょうど良い結果になりました。 「たつたあげ」「たつた」「たつくり」がそれぞれ別のグループになっているので、理想的と言えます。 word word_hira cluster_id group_id 竜田揚げ たつたあげ 0 1 竜田あげ たつたあげ 0 1 たつたあげ たつたあげ 0 1 竜田 たつた 0 2 たつた たつた 0 2 たつくり たつくり 0 3 閾値を0.8にした場合は「たつた」と「たつたあげ」が同じグループになったり、「ごまだれ」と「ごまだれそうめん」が同じグループになったりしたので、条件が緩すぎました。 閾値を0.95にした場合は「なすとあつあげ」「なすあつあげ」が違うグループになってしまい、同じグループにしたい組み合わせが違うグループになってしまったので条件が厳しすぎました。 実行結果まとめ 今回1.~4.の手順を実行した結果、17354ワードが11886グループに分かれました。 グループごとにwordの中から統一後の表記にするものを決めれば、今回作成したかった表記揺れ辞書が作成できる状態にできました。 1つしかワードがないグループがかなり多い結果となりましたが、検索回数が少ないワードに関しては他に表記揺れパターンがない場合も多々あるので、ある程度納得できました。 最も肝心な精度に関しては、全グループを目視で確認したわけではないので体感にはなってしまいますが、8割程度はあっているものが作成できたと思います。 現状の表記揺れ辞書の運用では、最終的には人が目視で内容を確認する工程を入れているため、叩き台を作ろうくらいの気持ちでの試みだったので、十分な精度かなと思います。 今後の課題 今回のクラスタリングとcos類似度を用いたやり方で多くのワードの表記揺れ辞書の作成はできますが、以下のような対応しきれないパターンもいくつかありました。 意味的に(ほぼ)同じもの 「パスタ」「スパゲッティ」 組み合わせワードの順不同対応 「大根と豚肉」「豚肉と大根」 小文字と大文字 「 きゃべつ」「きやべつ」 濁点 「たつくり」「たづくり」 これらのパターンはクラスタリングの時点で違うグループになってしまうので、別のアプローチを試す必要があります。 特に「パスタ」と「スパゲッティ」を同じグループにするのはかなり大変なのではないかと思っています。 まとめ 本記事ではクラスタリングとcos類似度を用いて、多数のワードの中から表記揺れ辞書を作成する方法を紹介させていただきました。 体感で8割くらいあっている辞書を作成できたものの、目視確認なしで運用できるほどの精度ではなかったので、今後も良いやり方がないかを考えていきたいと思います。 最後まで読んでいただきありがとうございました。 表記揺れの対応に苦しんでいる方の一助になれたら幸いです!
アバター
この記事は every Tech Blog Advent Calendar 2025 の 14 日目の記事です。 はじめに こんにちは。デリッシュキッチン開発部でバックエンドエンジニアをしている鈴木です。 Docker を使ってローカル環境で開発をしている方なら、かつて macOS 上の Docker Desktop でコンテナ内のファイルアクセスが非常に遅いという問題に悩まされた経験があるかもしれません。ホットリロード付きの開発サーバーがファイル変更に反応するのが遅かったり、テストスイートやビルドに時間がかかったりするケースです。 この問題の背景には、 開発の利便性とパフォーマンスのトレードオフ が存在していました。ホスト上のコードをコンテナに共有すれば編集が簡単ですが、macOS 特有のアーキテクチャ 1 により性能が低下します。一方、コンテナ内部にファイルを置けば高速ですが、ホストから直接編集できず開発体験が損なわれます。 本記事では、なぜ macOS でホスト上のコードをコンテナに共有する際に遅延が発生するのかをその仕組みから解説し、Docker Desktop が開発の利便性とパフォーマンスのトレードオフにどう対処してきたか、そして最新の技術 (VirtioFS, Synchronized File Shares) によってどのように両立が可能になったかを紹介します。 1. なぜファイル共有の仕組みが必要なのか Docker コンテナは通常、ホスト OS と分離された独立のファイルシステムを持ちます [1]。開発を行う際には、ホスト上のディレクトリをコンテナ内に共有する仕組みが必要になります。 Bind マウントと Named Volume Docker には、ホストとコンテナ間でファイルを共有する主な方法が2つあります。 Bind マウント は、ホスト上の特定ディレクトリ (例: /Users/suzuki/myapp ) をコンテナ内のパス (例: /app ) に直接共有する機能です [2]。ホストのエディタでファイルを編集すると、その変更がすぐにコンテナに反映されます。この利便性から、 開発中のソースコードの共有に最適 です。 Named Volume は、Docker 自身が管理するストレージ領域です [3]。ホストのファイルシステムから独立しており、Docker が内部で管理します。永続化が必要なデータベースファイルや、頻繁に変更しない依存ライブラリの保存に適しています。 以下の表は、両者の特性を比較したものです [2], [3]。 観点 Bind マウント Named Volume データの実体 ホスト OS 上のディレクトリ Docker 管理領域 性能 (macOS) 遅い (ホスト OS - VM 間の通信が発生) 速い (VM 内で完結) ホストからの編集 可能 (リアルタイム反映) 困難 (docker cp で取り出す必要) ユースケース 開発中のソースコード 永続化が必要なDBや依存ライブラリ 開発ワークフローでは、ホスト上のエディタでコードを書き、それをコンテナ内で即座に実行できることが重要です。そのため、性能面での課題はありますが、 本記事では Bind マウントに焦点を当てます 。 macOS特有の問題 ここで重要なポイントがあります。 Mac 版 Docker Desktop では、Linux コンテナがホスト OS 上で直接動作していない という点です [4], [5]。 Docker Desktop は内部で軽量の Linux 仮想マシン (VM) を動かしており、コンテナはこの VM 上で動作しています [4]。そのため、ホストの macOS ファイルシステムと VM 内部の Linux コンテナとの間でファイル共有のための仲介が必要になります [4]。 Linux ネイティブ環境との違い Linux ホスト上 : コンテナはホストカーネルを共有しており、Bind マウントしてもオーバーヘッドなくカーネル経由で直接ホスト FS にアクセスできる [6] Docker Desktop (macOS) : ホスト OS - VM 間の通信が発生し、これが遅さの原因となる Fig. 1 アーキテクチャ比較 (a) Linux ネイティブ環境 (b) Docker Desktop (macOS) 環境 このように、ファイル共有の仕組みはホストとコンテナ間でファイルをやり取りするために必須ですが、Docker Desktop (macOS) では VM 層が存在するため、ファイルアクセスのたびにホスト OS - VM 間の通信が発生します。この 通信の積み重ねが、性能低下の根本原因 となっています [7]。 2. Docker Desktop のファイル共有の仕組み では、ホスト OS - VM 間の通信を発生させているファイル共有の仕組みとは、具体的にどのようなものなのでしょうか。 Docker Desktop のファイル共有は、ホスト OS と VM 間でファイルシステムを中継する仮想ドライバによって実現されています [8]。 この仕組みは以下の主要なコンポーネントから構成されています。 構成要素 ホスト側ファイルサーバー (osxfs / gRPC-FUSE / virtiofsd など) ホスト(macOS)上で動くプロセス [8] 共有対象ディレクトリの実際のファイル操作を担当する VM 側ドライバ (FUSE クライアント / VirtioFS ドライバ) Linux VM 内で動作するファイルシステムドライバ [8] コンテナからのファイルIO要求を受け付ける 通信チャネル (vsock/Hypervisor 共有メモリ 等) ホストと VM 間でデータをやり取りするためのチャネル Fig. 2 Docker Desktop のファイル共有アーキテクチャ これらのコンポーネントがどのように連携するかを見てみましょう。 コンテナ内のファイル操作は、VM 内のドライバが受け取り、ホスト OS 上の対応するファイルに処理を渡し、結果をコンテナに返すというリモートプロシージャーコール的な処理が行われます [8]。この一連の流れが、すべてのファイルアクセスで繰り返されることが、性能問題の直接的な原因となります。 3. 性能問題の実例と実際に問題になるケース この仕組みが実際の開発現場でどのような問題を引き起こすのか、具体例を通して見ていきましょう。 Node.js開発環境での問題 大量の小さなサイズのファイルを含む Node.js プロジェクトをコンテナで動かすケースで、問題を具体的に見てみましょう。 シナリオ あなたは Mac で React アプリケーションの開発をしています。ソースコードはホスト上にあり、 docker run -v ~/myapp:/usr/src/app ... のように Bind マウントしてコンテナに共有しています。 コンテナ内で npm install を実行すると、node_modules に大量のパッケージ (数万ファイル) がインストールされます。続いて開発サーバーを起動すると、依存関係を解析するために node_modules 以下のファイルを再帰的に読み取ります。 何が起きているか コンテナ内プロセスが発行する大量のシステムコール ( open , read , stat , readdir など) が、一つ一つホストとの間を往復します。 Fig. 3 Bind マウントでのファイルアクセスフロー この往復を依存ファイル数だけ繰り返すため、以下の問題が発生します。 ホスト OS - VM 間の通信遅延が積み重なり、ファイル数に比例して処理時間が増大 [7] メタデータ操作 (stat, readdir) が特にボトルネックになる [7] 結果 : ある空の React アプリ (約 37k の小ファイル) でのベンチマークでは、Linux ネイティブ環境に比べて約 3.5 倍の時間がかかり、従来の gRPC-FUSE 実装では最大 10 倍以上遅くなるケースも確認されている [9] Docker 公式も「モダンな開発ツール (コンパイラやパッケージマネージャ) は何千もの readdir() , stat() , open() を発行する。仮想ファイルシステムではその一つ一つがホストとVMの間を渡らなければならない」と指摘しています [7]。 実際に問題になるケース 上記の Node.js 例に限らず、以下のような状況で、Bind マウントの性能問題が実際に表面化します。 依存ファイルが多いプロジェクトのビルド/起動 : Node.js の node_modules 、Java の Maven 、Python の venv など [7] ホットリロードやファイル監視 : 対象ディレクトリ全体を監視するために繰り返される stat 操作 自動テストの実行 : テストごとに多数のモジュールをインポートする操作 パッケージの依存関係解決・インストール : npm install や composer install など ポイント : 小規模なアプリや数個の大きなファイルを扱う程度であれば、体感できる差は出にくいです。問題は、大量のファイルに対して頻繁にアクセスするケースに集中します [7]。 4. 技術の進化による解決策 第 3 章で見たように、Bind マウントには性能問題がありました。しかし、ホストで快適に編集できるという利便性は開発には不可欠です。このトレードオフに対し、Docker Desktop はどのように対処してきたのでしょうか。実は、段階的な技術改善により、このトレードオフの緩和、そして最終的には解決が実現されてきました。 技術の進化 Legacy osxfs (初期) : Docker for Mac の独自実装。大量ファイルで遅いという問題があった [9] gRPC-FUSE (2020~2022年頃) : プロトコル効率化を図ったが、依然としてボトルネックは残った [9] VirtioFS (近年) : Docker Desktop 4.x で macOS 12.5 以降でデフォルト採用。ほとんどの開発者に十分な性能を提供 [7], [10] Synchronized File Shares (同期共有) (Docker Desktop 4.27+) : 大規模プロジェクト向けに、利便性と性能の完全な両立を実現 [10] この中で、現在利用可能な主要な2つの技術 (VirtioFS と Synchronized File Shares) について、詳しく見ていきましょう。 VirtioFS: ほとんどの開発者に十分な解決策 従来の osxfs や gRPC-FUSE では、ホスト OS - VM 間の通信の実装が非効率でした。各ファイル操作がネットワークプロトコルに似た方式で処理され、大きなオーバーヘッドが発生していました。 VirtioFS は、この通信経路を根本から見直します。仮想化技術の標準規格である virtio に基づいた専用の高速通信チャネルを使用し、仮想マシンとホストが同じ物理マシン上にあるという事実を活かした最適化を実現します [11]。依然として Bind マウントでありホスト OS - VM 間の通信は発生しますが、通信プロトコルとデータパスが大幅に効率化されることで、劇的な性能向上を達成しています。 Fig. 4 VirtioFS による通信経路の最適化 この改善により、ファイルシステム操作の時間が従来の Bind マウントと比較して最大 98% 短縮されました [7]。実際のベンチマークでは、ある PHP プロジェクトで約 4 倍の速度向上 (93.7 s → 25.5 s) が確認されています [12]。Docker 公式も「ほとんどの開発者とプロジェクトにとって優れた初期設定の解決策」と評価しており [10]、VirtioFS は ほとんどの開発者にとって十分な性能 を提供します。ただし、特に大規模なプロジェクトでは、さらなるパフォーマンスが必要になる場合があります [10]。 Synchronized File Shares (同期共有) : 大規模プロジェクト向けの完全な解決策 VirtioFS はほとんどのケースで十分ですが、数千~数百万のファイルを含む大規模プロジェクト (100,000 ファイル以上の大規模リポジトリや、数百 MB ~ 数 GB の総容量のプロジェクト) では、追加のパフォーマンスが必要になります [10]。 VirtioFS で通信効率は大幅に改善されましたが、大規模プロジェクトでは依然としてホスト OS - VM 間の通信そのものがボトルネックになります。数万回、数十万回のファイル操作が発生する場合、いくら通信を効率化しても、その回数の多さが性能に影響するのです。 Synchronized File Shares は、この問題に対して根本的に異なるアプローチを取ります。 コンテナのファイルアクセスとホスト OS - VM 間の通信を完全に分離 するのです [10]。具体的には、VM 内に ext4 形式のキャッシュ領域を作成し、ホストのファイルのコピーをここに保持します。コンテナはこの VM 内キャッシュに直接アクセスするため、すべてのファイルシステムコール ( readdir() , stat() , open() / read() / write() / close() ) が Linux カーネルで直接処理され、ホスト OS - VM 間の通信が発生しません。一方、オープンソースのファイル同期技術である Mutagen [13] がバックグラウンドでホストとキャッシュを超低遅延で双方向同期します [10]。コンテナからは VM 内ローカルアクセスとなり、ネイティブ Linux 並みの性能を実現しつつ、ホストでの編集も自動的にコンテナに反映されます。 Fig. 5 Synchronized File Shares のアーキテクチャ その結果、従来の Bind マウントと比較して 2 ~ 10 倍の速度向上が実現されました [10]。 ただし、ファイルを二重に保持するためディスク容量を余分に消費します [10]。また、同期は非同期で行われるため、ホストで編集してからコンテナに反映されるまで若干のラグが発生する可能性がありますが、通常の開発では問題にならない程度です。なお、この機能は有料アカウント (Docker Pro、Team、または Business) が必要です [10]。 Synchronized File Shares (同期共有) は、 大規模プロジェクトにおいて利便性と性能の両立を実現 します。VirtioFS で十分な性能が得られない場合の解決策となります。 まとめ macOS 上の Docker Desktop では、コンテナが VM 上で動作するというアーキテクチャ上の制約により、ファイル共有に特有の性能問題が発生します。開発にはホストで編集できる Bind マウントが適していますが、macOS ではホスト OS - VM 間の通信が頻繁に発生し、大量の小さなファイル操作が必要な開発環境では、この通信コストが積み重なって性能が大幅に低下します。 この問題の背景には、 利便性とパフォーマンスのトレードオフ が存在していました。Bind マウントはホストから直接編集できるため開発体験が優れていますが、ホスト OS - VM 間の通信により性能が犠牲になります。一方、Named Volume は VM 内部で完結するため高速ですが、ホストから直接編集できないという不便さがありました。 Docker Desktop はこの問題に対し、段階的に解決策を提供してきました。 VirtioFS は、ホスト OS - VM 間の通信の効率を大幅に改善し、ほとんどの開発者にとって十分な性能を実現しました。さらに、 Synchronized File Shares (同期共有) は、VM 内に ext4 キャッシュを配置してコンテナのアクセスを高速化しつつ、バックグラウンドでホストと同期することで、大規模プロジェクトにおいても利便性と性能の両立を可能にしました。 このアーキテクチャの変更により、利便性と性能のトレードオフが解消され、開発者はホストでの快適な編集とネイティブ Linux 並みの性能を同時に享受できるようになりました。 Appendix: FUSE による追加のオーバーヘッド 本文ではホスト OS - VM 間の通信を主なボトルネックとして説明しましたが、初期の Docker Desktop 実装 (osxfs/gRPC-FUSE) では、さらに FUSE によるオーバーヘッド も存在していました。 FUSEとは FUSE (Filesystem in Userspace) は、ファイルシステムの処理をユーザ空間のプロセスで実装できる仕組みです [14]。通常のファイルシステム (ext4 など) はカーネル内で処理が完結するため高速ですが、FUSE では追加の処理が発生します。コンテナ内のアプリケーションがファイル操作を行うと、その要求はまずカーネル内の FUSE モジュールに届きます。FUSE モジュールはこれをユーザ空間のファイルサーバープロセスに転送し、処理結果を再びカーネル経由でアプリケーションに返します。 Fig. 6 FUSE によるファイルアクセスのデータパス この カーネル空間とユーザ空間の間のコンテキストスイッチ が、ホスト OS - VM 間の通信に加えて追加のオーバーヘッドとなっていました [7]。 VirtioFSによる改善 VirtioFS は、より効率的な通信パスを使うことで、この FUSE スタックのオーバーヘッドを大幅に削減しています。これが、VirtioFS で 4 倍程度の性能向上が実現された理由の一つです。 参考文献 [1] Docker Inc., "Manage data in Docker," Docker Documentation. [Online]. Available: https://docs.docker.com/storage/ [2] Docker Inc., "Use bind mounts," Docker Documentation. [Online]. Available: https://docs.docker.com/storage/bind-mounts/ [3] Docker Inc., "Use volumes," Docker Documentation. [Online]. Available: https://docs.docker.com/storage/volumes/ [4] Docker Inc., "Docker Desktop for Mac," Docker Documentation. [Online]. Available: https://docs.docker.com/desktop/mac/ [5] Docker Inc., "Docker Desktop for Windows," Docker Documentation. [Online]. Available: https://docs.docker.com/desktop/windows/ [6] Docker Inc., "Docker storage drivers," Docker Documentation. [Online]. Available: https://docs.docker.com/storage/storagedriver/ [7] Docker Inc., "Speed boost achievement unlocked on Docker Desktop 4.6 for Mac," Docker Blog, Mar. 2022. [Online]. Available: https://www.docker.com/blog/speed-boost-achievement-unlocked-on-docker-desktop-4-6-for-mac/ [8] Docker Inc., "Change your Docker Desktop settings," Docker Documentation. [Online]. Available: https://docs.docker.com/desktop/settings-and-maintenance/settings/ [9] P. Mainardi, "Docker on MacOS is slow and how to fix it," CNCF Blog, Feb. 2023. [Online]. Available: https://www.cncf.io/blog/2023/02/02/docker-on-macos-is-slow-and-how-to-fix-it/ [10] Docker Inc., "Announcing Docker Desktop 4.27: Speed Boost with Synchronized File Shares," Docker Blog, Feb. 2024. [Online]. Available: https://www.docker.com/blog/announcing-synchronized-file-shares/ [11] S. Hajnoczi et al., "Virtio-fs: A shared file system for virtual machines," virtio-fs Project. [Online]. Available: https://virtio-fs.gitlab.io/ [12] J. Geerling, "New Docker for Mac VirtioFS file sync is 4x faster," Jeff Geerling's Blog, Mar. 2022. [Online]. Available: https://www.jeffgeerling.com/blog/2022/new-docker-mac-virtiofs-file-sync-4x-faster [13] Docker Inc., "Mutagen joins Docker," Docker Blog, Jan. 2022. [Online]. Available: https://www.docker.com/blog/mutagen-acquisition/ [14] "Filesystem in Userspace," Linux Kernel Documentation. [Online]. Available: https://www.kernel.org/doc/html/next/filesystems/fuse.html この問題は Windows (WSL2) でも同様に発生します。基本的な仕組みと解決策は Windows にも適用できます。 ↩
アバター
プロダクションで稼働しているAI機能のフレームワークをLangGraphに完全移行しました この記事は every Tech Blog Advent Calendar 2025 の 13 日目の記事です。 背景 課題解決のために 明確な責務分離が可能 ワークフローの変更柔軟性、拡張性 プロバイダに依存しない プロバイダの置き換えやすさ 型制約(Structured Outputのため) テストのしやすさ LangGraphの採用 ディレクトリ構成 Stateの定義 Node の定義 サブワークフローの定義 メインワークフローの定義 アーキテクチャの責務分離 おわりに こんにちは、開発1部に所属している25卒の岩﨑です。 本記事では、プロダクションで稼働しているAI機能のフレームワークをLangGraphに完全移行したことについてお話しします。 背景 デリッシュキッチンでは、会話形式でレシピ検索できるデリッシュAIという機能を提供しています。 推論にはWorkflow型を採用しており、LLMのAPIをフルスクラッチの関数パイプラインから呼び出すような実装となっています。 機能を公開して1年以上が経過し、様々な機能追加や改善サイクルが回された結果、ワークフローが複雑になりいくつかの問題が発生しました。 1つはアーキテクチャの責務が混同している問題があります。 コアとなるビジネスロジックやLLMの呼び出し処理は、再利用可能な形で関数化されています。 しかしビジネスロジックを呼び出すワークフローや、処理の過程で変化するデータ構造の状態管理の責務が明確に分かれていなかったため、追加・修正実装の負荷が高い状態に陥っていました。 特にワークフローに関してはレスポンス速度の向上のために処理を並列化していたりするので、流れが特に追いにくい状態でした。 またユーザ入力や処理の出力に対して分岐処理をする実装もあり、ワークフロー自体がロジックを持ってしまうことも問題としてありました。 2つ目は、変更の柔軟性がないことです。 アーキテクチャが明確に責務分けされていないことによって、特定の処理順序を置き換える場合や並列化を行う場合に、どの状態をどのように置き換えるべきかがわかりづらい状態になっていました。 新しい施策や精度改善のために頻繁にワークフローを変更する場面が増えてきたこともあってこの辺りの変更負荷が高いことは実装する上でもストレスに感じるようになりました。 したがって課題をひとことでまとめると、フルスクラッチで実装する限界がきたということになります。 今後、WorkflowとAgentの混合型を実装するであったり、コンテキストエンジニアリングに関わる実装を追加する場合に、現状の実装では複雑になりすぎてリリースとともに開発スピードが低下していくことは自明でした。 これらの課題に対処するためにワークフローを可視化したものをドキュメントとして用意していましたが、精度を改善する過程では様々な角度で試してよかったものを採用するようなサイクルを回すことが多い都合上、更新のたびにワークフローが変化していき、ちょっとでもドキュメントを放置したら陳腐化してしまうという課題もありました。 課題解決のために これらの課題解決を考える上で、技術観点はもちろんですが、それだけでなくプロダクト観点でも長期的に運用できるようなアーキテクチャであることが理想です。 ここからは先ほどあげた課題を解決するための要素をいくつか並べていきます。 明確な責務分離が可能 課題から、ワークフロー・状態管理・ビジネスロジックの責務を分離できるような仕組みにする必要がありました。 責務が明確に分かれることで変更に強いアーキテクチャになると考えたためです。 ワークフローの変更柔軟性、拡張性 責務分離の話と重複しますが、ワークフローの責務が明確化されることによって状態を意識することなく(ワークフローの処理動作の依存関係は考慮する必要があります)順序の組み替え、並列化などに対応できることが望ましいです。 また、ReActであったりWorkflowとAgentの混合型、コンテキストエンジニアリングといった追加要素に関する要件が出てきた場合も、各処理が疎結合であることによって容易に拡張できることも期待しています。 プロバイダに依存しない 今日最高性能だったモデルが数日後にはそうではなくなっている時代で、特定のプロバイダに依存するような仕組みはできるだけ避けたいところです。 フレームワークによっては、ある機能が特定のサービスプロバイダに依存している実装など見られたため、次に示す置き換えやすさという観点でも依存しない仕組みを採用することが長期的に運用する上で大事なのではないかと思います。 プロバイダの置き換えやすさ ここはどちらかというと必須というよりは理想に近い話です。 プロバイダ依存せずとも置き換えが困難だった場合、置き換えやすくするにはプロバイダに依存しないインターフェースを別で定義しなければなりません。 しかしながらここは独自にインターフェースを定義すれば済む話ではあるので必須とはしませんでした。 型制約(Structured Outputのため) 構造化データをLLMに出力させるためには型制約が必須です。 しかしながら、型制約はOpenAIやGoogleが提供するAPIではかなり前から対応されています。 Anthropicにおいても2025-11-14にClaudeのStructured Outputに対応したため、主要プロバイダが提供するAPIにおいてはPydanticと併用することによって型安全な出力が可能となりました。 www.claude.com 移行前の実装でもPydanticを用いて型安全な出力になるよう実装していたため、ここは引き続き必須要件となります。 テストのしやすさ LLMによる出力は決定論的に評価できないため、別途LLM as a Judgeによる品質評価やプロダクションから構築したデータセットを用いた評価・改善のループを回す必要があります。 しかしLLMのテストに関してはソースコードとは切り離して考える必要があると思うので、LLMに関わる処理をMock化することでLLM以外の処理に関してはテスト可能となります。 責務を明確に分け、LLMをMock化することでルールベースの処理や状態の流れなどに対するユニットテストが比較的簡単に書けるようになるため、テストのしやすさも要素として取り入れました。 LangGraphの採用 現時点において、これらの全ての要素にマッチするのがLangChainおよびLangGraphでした。 2025-10-22のタイミングでv1.0がリリースされ、マイナーバージョンやパッチバージョンでは破壊的変更が行われないことが明記されていることも決め手の1つです。 blog.langchain.com docs.langchain.com ディレクトリ構成 ここからはLangGraph適用後のディレクトリ構成について紹介します。 / ├── docs ├── src │   ├── const │   ├── nodes │   ├── schema │   │   └── filters │   ├── types │   │   └── state │   ├── utils │   ├── workflows │   │   ├── main_workflow.py │   │   └── sub_workflows │   └── evaluation └── tests ※ 一部わかりやすさのために省略している部分があります ここではworkflows, nodes, types/state の3つについて取り扱います。 LangGraphでは、LLMの処理を表すNodeとLLMの処理同士をどう繋ぐかを表すEdgeで整理され、大域的に扱う変数をStateとして定義することで、Workflowを組むことができます。 Node, Edge, Stateの関係 Stateの定義 Stateはワークフロー全体で共有される状態を表しています。 TypedDictを継承して定義することで、型安全な状態管理が可能になります。 例えば、入出力のStateは以下のように定義できます。 # types/state/input_output.py from typing import TypedDict class InputState (TypedDict, total= False ): """入力スキーマ""" user_input: str options: dict class OutputState (TypedDict, total= False ): """出力スキーマ""" result: str metadata: dict Node の定義 Nodeは単一責務の処理ロジックを担当します。 ビジネスロジックは基本的にこのNodeに記述するようにしています。 class NodeA : """ノードA - 特定の処理を担当""" def __init__ (self): self.llm = ChatOpenAI(model= "gpt-5-mini" , timeout= 5.0 , max_retries= 2 ) def execute (self, input_data: str ) -> dict : """処理を実行""" processed = input_data.upper() return { "field_a" : processed} サブワークフローの定義 サブワークフローは関連するNodeをまとめたグラフです。 処理の論理的なまとまりごとにサブワークフローを作成することで、再利用性とテスト容易性が向上します。 from langgraph.graph import END, START, StateGraph from langgraph.graph.state import CompiledStateGraph from my_app.nodes.phase1 import NodeA, NodeB from my_app.types.state import State class Phase1Workflow : """Phase1サブワークフロー""" def __init__ (self): self.node_a = NodeA() self.node_b = NodeB() def step_a (self, state: State) -> dict : """ステップA""" user_input = state[ "user_input" ] result = self.node_a.execute(user_input) return { "field_a" : result[ "field_a" ]} def step_b (self, state: State) -> dict : """ステップB""" user_input = state[ "user_input" ] result = self.node_b.execute(user_input) return { "field_b" : result} def build_graph (self) -> CompiledStateGraph: """グラフを構築""" g = StateGraph(State) g.add_node( "step_a" , self.step_a) g.add_node( "step_b" , self.step_b) # 並列実行 g.add_edge(START, "step_a" ) g.add_edge(START, "step_b" ) g.add_edge( "step_a" , END) g.add_edge( "step_b" , END) return g.compile() 各stepは状態管理を責務としてもち、build_graph メソッドがワークフローの責務を持っています。 build_graphでは、StateGraph を使ってノードを追加し、add_edge でノード間の依存関係を定義しています。 上記の例では START から step_a と step_b の両方にエッジを作成することで、2つの処理が並列実行されます。 このように、並列化は単にエッジの向き先を変えるだけで実現できるところも魅力的な部分です。 メインワークフローの定義 メインワークフローは複数のサブワークフローを組み合わせることで作成されるエンドツーエンドのパイプラインです。 # workflows/main_workflow.py from langgraph.graph import END, START, StateGraph from langgraph.graph.state import CompiledStateGraph from my_app.types.state import InputState, State from my_app.workflows.sub_workflows import ( Phase1Workflow, Phase2Workflow, Phase3Workflow, ) class MainWorkflow : """メインワークフロー""" def __init__ (self): self.phase1 = Phase1Workflow() self.phase2 = Phase2Workflow() self.phase3 = Phase3Workflow() def build_graph (self) -> CompiledStateGraph: """メインワークフローを構築""" g = StateGraph(State, input =InputState) # サブワークフローをノードとして追加 g.add_node( "phase1" , self.phase1.build_graph()) g.add_node( "phase2" , self.phase2.build_graph()) g.add_node( "phase3" , self.phase3.build_graph()) # 順次実行: phase1 → phase2 → phase3 g.add_edge(START, "phase1" ) g.add_edge( "phase1" , "phase2" ) g.add_edge( "phase2" , "phase3" ) g.add_edge( "phase3" , END) return g.compile() def run (self, user_input: str , options: dict = None ) -> dict : """ワークフローを実行""" initial_state: State = { "user_input" : user_input, "options" : options or {}, "field_a" : "" , "field_b" : [], "field_c" : [], "field_d" : 0.0 , "field_e" : "" , "result" : "" , "metadata" : {}, } graph = self.build_graph() return graph.invoke(initial_state) StateGraph に input=InputState を渡すことで、外部から渡される入力の型を制限できます。 また、サブワークフローの build_graph() の戻り値をそのままノードとして追加できるため、メインのワークフローが肥大化することなく実装できます。 アーキテクチャの責務分離 以上のような設計により、以下のように責務を分離することができました。 層 責務 types/state/ 状態の型定義 nodes/ ビジネスロジック workflows/sub_workflows/ サブワークフローのグルーピング workflows/main_workflow.py 全体のフロー制御 ワークフローの順序変更や並列化は workflows/ 内のエッジの向き先を変えるだけで完結し、ビジネスロジック(nodes/)に影響を与えません。 逆に、特定の nodes/ の処理内容を変更しても、workflows/ には影響しません。 以上により、明確な責務の分離と変更にも耐えうる柔軟なコードベースが構築できたのではないかと思います。 またプロバイダの変更においても、LangChainではParter packagesとしてプロバイダごとのpackageを公開しているため、適用したい箇所のライブラリを変更するだけで置き換えが可能です。 OpenAI:langchain-openai Anthropic:langchain-anthropic Google:langchain-google-genai おわりに 本記事ではフルスクラッチの関数型パイプラインによって稼働していたコードベースをLangChain, LangGraphに移行する意思決定の過程について説明しました。 最初からLangGraphにすべきだったかという問いに対しては必ずしもそうとは言えません。 フレームワークは実際に使ってみないと知見が蓄積しないので試す姿勢は大事だと思います。 とはいえ内部処理をちゃんと理解しないままフレームワークに依存するのは危険なので、どのようなフレームワーク思想の元で、なにを解決したいのかを考えるべきなのかなと今回のリファクタリングを通して思いました。 エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
アバター
この記事は every Tech Blog Advent Calendar 2025 の 12日目の記事です。 はじめに エブリーでデリッシュキッチンの開発をしている本丸です。 日々、GeminiやClaudeCodeに支えられて業務を行っているのですが、利用する中でチャットのような双方向の通信について気になりました。双方向通信にはいくつか種類がありますが、今回は双方向通信の1つであるWebTransportについてまとめていければと思います。 WebTransportとは WebTransportは、QUIC(HTTP/3)プロトコルをベースとしたクライアントとサーバー間の双方向通信を実現するAPIです。 WebTransportには以下のような特徴があります。 QUIC基盤 : UDP上で動作するQUICプロトコルを使用 多重ストリーミング : 単一接続で複数のストリームを並列処理 ヘッドオブライン阻害回避 : QUICプロトコルを利用するため、パケット損失で他のパケットを待たせることがない 接続移行 : QUICプロトコルを利用するため、ネットワーク変更時も接続継続が可能 信頼性/非信頼性の選択 : ストリーム(信頼性あり)・データグラム(信頼性なし)の使い分けができる WebTransportの通信フロー WebTransportの通信は以下のフェーズに分かれて動作します。 セッション確立の詳細 QUIC接続確立 UDP上でQUICプロトコルによる接続確立 TLS 1.3による暗号化ハンドシェイク WebTransportセッション開始 HTTP/3のCONNECTメソッドを使用 protocol="webtransport" ヘッダーで識別 サーバーが200 OKで応答すればセッション確立 データ通信 Reliable Streams : 順序保証・到達保証あり Unreliable Datagrams : 低遅延優先・順序保証なし 双方向通信の種類 WebTransportが出現するまでにも双方向通信を実現する技術は存在しており、そちらについても軽く触れていけばと思います。一部、サーバーからクライアントへの単方向通信も含みます。 HTTP Long Polling 従来のHTTPではクライアントからのリクエストに対してサーバーからレスポンス返すため、サーバーから能動的にデータを送信することができませんでした。 あらかじめクライアントからサーバーにリクエストを送信しておき、サーバーが任意のタイミングでレスポンスを返せるようにすることで、双方向通信を実現するのがLong pollingです。 Server-Sent Events (SSE) HTTPプロトコルを利用して、サーバーからクライアントに向けての単方向通信を実現します。レスポンスのMIMEタイプに text/event-stream を指定することで接続を維持します。 WebSocket Long PollingやSSEでも双方向通信を実現することはできますが、それらに利用されているHTTPが双方向通信を目的として作成されたプロトコルではなかったため、双方向通信で利用しやすい形で新しく設計されたのがWebSocketです。 WebSocketはTCP上で動くプロトコルなので、WebTransportのヘッドオブライン阻害回避などのQUIC上で動くメリットは持ち合わせていません。 Web Transportのgolangでの実装 webtransport-go を利用して実装したサンプルを載せておきます。 ローカルでチャットアプリを作って動作確認をしたのですが、WebTransportの処理に関わる箇所だけ抜粋しています。 また、下記には注意していただけると助かります。 - 一部コードの抜粋のためサンプルコードだけでは動作しない - ローカルでの動作確認だけのため、実際のネットワーク環境で動くかまでは未確認 Server import ( "context" "encoding/json" "net/http" "time" "github.com/quic-go/quic-go/http3" "github.com/quic-go/webtransport-go" ) func StartWebTransportServer() { // WebTransportハンドラーを作成 mux := http.NewServeMux() // ①QUIC/HTTP3接続確立 // WebTransportServer自体をHTTP/3サーバーとして使用 server := &webtransport.Server{ H3: http3.Server{ Handler: mux, Addr: ":8443" , }, CheckOrigin: func (r *http.Request) bool { return true }, } // WebTransportハンドラーを登録 mux.HandleFunc( "/" , func (w http.ResponseWriter, r *http.Request) { // ②WebTransport セッション確立 // Upgrade()の中でmethodやprotocolが正しいかのチェックなども行っている // WebTransportセッションにアップグレード session, err := server.Upgrade(w, r) if err != nil { return } // ③双方向ストリーミング通信 // 双方向ストリームセッション処理を開始 go handleBidirectionalSession(session) }) // WebTransportサーバーを起動 go func () { err := server.ListenAndServeTLS( "cert.pem" , "key.pem" ) if err != nil { return } }() } // WebTransport用メッセージ構造体 type WebTransportMessage struct { Content string `json:"content"` Timestamp time.Time `json:"timestamp"` Type string `json:"type"` } // 双方向ストリームでの受信処理 func handleBidirectionalReceive(stream webtransport.Stream) { buf := make ([] byte , 4096 ) for { n, err := stream.Read(buf) if err != nil { return } var msg WebTransportMessage if err := json.Unmarshal(buf[:n], &msg); err != nil { continue } // 応答送信(同じ双方向ストリーム) response := map [ string ] interface {}{ "status" : "message_received" , } responseData, _ := json.Marshal(response) if _, err := stream.Write(responseData); err != nil { return } } } // 双方向ストリームでの送信処理 func sendToBidirectionalStream(stream webtransport.Stream, data [] byte ) error { _, err := stream.Write(data) if err != nil { return err } return nil } // 双方向ストリームベースのセッション処理 func handleBidirectionalSession(session *webtransport.Session) { defer session.CloseWithError( 0 , "bidirectional session ended" ) ctx, cancel := context.WithCancel(session.Context()) defer cancel() // クライアントからの双方向ストリームを待機 stream, err := session.AcceptStream(ctx) if err != nil { return } // 双方向ストリームでの受信と送信を並行処理 go handleBidirectionalReceive(stream) } Client import ( "context" "crypto/tls" "net/http" "net/url" "time" "github.com/quic-go/quic-go" "github.com/quic-go/webtransport-go" ) func main() { u, err := url.Parse(serverURL) if err != nil { return } u.Path = "/" // // WebTransport Dialer設定 dialer := &webtransport.Dialer{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true , NextProtos: [] string { "h3" }, ServerName: "localhost" , }, QUICConfig: &quic.Config{ MaxIdleTimeout: 30 * time.Second, HandshakeIdleTimeout: 10 * time.Second, KeepAlivePeriod: 15 * time.Second, EnableDatagrams: true , // WebTransport requires datagram support }, // StreamReorderingTimeoutを設定 StreamReorderingTimeout: 10 * time.Second, } ctx := context.Background() // WebTransportリクエストヘッダーを設定 reqHeader := make (http.Header) reqHeader.Set( "Sec-WebTransport-Http3-Draft" , "draft02" ) reqHeader.Set( "Origin" , serverURL) // より長いタイムアウト付きコンテキスト dialCtx, cancel := context.WithTimeout(ctx, 30 *time.Second) defer cancel() // Dial()でWebTransportプロトコルでのセッションを開始する httpResp, session, err := dialer.Dial(dialCtx, u.String(), reqHeader) if err != nil { return } defer session.CloseWithError( 0 , "client disconnecting" ) if httpResp.StatusCode != 200 { return } // 双方向ストリームを作成して永続的に接続 biStream, err := session.OpenStream() if err != nil { return } defer biStream.Close() // 双方向ストリームでの受信処理(サーバー応答用) go handleBidirectionalReceive(biStream) // 以降の処理はbiStreamを利用してserverと同じような処理になる } AWS上で利用する上での注意点 弊社のシステムはAWS上で動作しているものが多く、ロードバランサーとしてはALBを利用していることが多いのですが、WebTransport(の基盤技術であるQUIC)がALBに対応していないようです。 ドキュメントによるとALBでは、HTTP/2とWebSocketに対応しているようです。 docs.aws.amazon.com NLBはQUICに対応しているとのことなので、WebTransportを利用したい場合はNLBを検討することになりそうです。 docs.aws.amazon.com まとめ 今回はWebTransportを中心とした双方向通信について学んだことをまとめてみました。WebTransportはQUICベースの比較的新しい技術であり、従来のWebSocketなどの技術の問題を改善していることがわかりました。 一方で、AWS環境ではまだALBがHTTP/3やQUICに対応しておらず、WebTransportを実用するにはいくつかの技術的なハードルがあることも確認できました。現時点では従来のWebSocketやSSEといった技術を組み合わせることが現実的な選択肢となりそうです。 参考文献 IETF Draft - WebTransport over HTTP/3 quic-go/webtransport-go - GitHub AWS Application Load Balancer Documentation AWS Network Load Balancer Documentation
アバター
ヘルシカiOSアプリのアーキテクチャについて この記事は every Tech Blog Advent Calendar 2025 の 11 日目の記事です。 はじめに こんにちは。開発部でiOSエンジニアをしている野口です。 ヘルシカiOSアプリの開発を担当しています。今回はヘルシカiOSアプリの設計で採用しているクリーンアーキテクチャについてご紹介します。 この記事では、以下の内容を解説します。 クリーンアーキテクチャの各層(Feature/Usecase/Repository/Infra/Model)の役割 SPMを用いたマルチモジュール構成と依存関係の管理方法 実際のコード例を通じた実装パターンの紹介 DIコンテナの設計と実装 AIによるコーディングを意識した設計 iOSアプリのアーキテクチャ設計に興味がある方や、クリーンアーキテクチャの導入を検討されている方の参考になれば幸いです。 アーキテクチャについて ヘルシカiOSアプリではクリーンアーキテクチャを採用しています。 構成は以下のようになっています。 各層の役割 Feature(Presentation)層 Feature層は一般的なMVVMの構成でViewで画面を構築し、ViewModelで状態を管理します。 UsecaseとModelに依存しています。 Usecase層 Usecase層はビジネスロジックを定義する層です。 Feature層のViewModelから呼び出され、ビジネスロジックを記載します。 RepositoryとModelに依存しています。 Repository層 Repository層は、Infra層のDataStoreを呼び出してデータの取得・保存を行う層です。また、メモリ上でデータを一時的にキャッシュする処理も担当します。 Modelに依存しています。 Infra層 Infra層は、外部APIやLocalStorage、KeyChainなど外部リソースへのアクセスを担当する層です。 DataStoreの具体的な実装はInfra層に配置し、そのインターフェース(Protocol)はRepository層で定義しています。これにより依存性逆転の原則を適用し、Repository層がInfra層の実装詳細に依存しない設計を実現しています。テスト時のモック差し替えが容易になり、外部サービスの変更影響を局所化できます。 RepositoryとModelに依存しています。 Model層 Model層はドメインモデルを定義する層で、どの層からも参照されます。 クリーンアーキテクチャでは、Data層にEntityを、Domain層にModelを定義し、両者を分離する設計もあります。しかし本プロジェクトでは、バックエンドAPIがドメインモデルに沿った構造でレスポンスを返すため、EntityとModelを分けずに共通のモデルを使用しています。これにより、同じような構造のモデルを重複して定義する必要がなくなり、シンプルな設計になります。 画面都合で別モデルを使用したい場合は、Feature層にModelを定義しています。 SPMを用いたマルチモジュール構成 ヘルシカiOSアプリではSPM(Swift Package Manager)を用いたモジュール構成を採用しています。 SampleApp/ ├── App/ # アプリケーションのルートディレクトリ │ ├── Dependency.swift # 依存関係の注入 │ └── ViewModelProvider.swift # ViewModelの注入 ├── Packages/ # ローカルパッケージ群 │ ├── Feature/ │ ├── Usecase/ │ ├── Repository/ │ ├── Infra/ │ └── Model/ パッケージファイルで依存関係を明示する パッケージファイルでは依存するパッケージを指定します。 依存関係をパッケージファイルで明示的に定義することで、ViewModelがRepositoryを直接参照するといったアーキテクチャ違反を防ぎ、依存関係を適切に管理できます。 具体的には以下のようなパッケージファイルを作成します。 dependencies に依存するパッケージを指定します。 なお、本記事で紹介するコードはStrict Concurrency Checkingには対応していません。Swift 6への移行は今後の課題として取り組む予定です。 // Packages/Feature/Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name : "Feature" , products : [ .library ( name: "Feature" , targets: [ "Feature" ]) ] , dependencies : [ .package ( name: "Usecase" , path: "../Usecase" ) , .package ( name: "Model" , path: "../Model" ) ] ) // Packages/Usecase/Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name : "Usecase" , products : [ .library ( name: "Usecase" , targets: [ "Usecase" ]) ] , dependencies : [ .package ( name: "Repository" , path: "../Repository" ) , .package ( name: "Model" , path: "../Model" ) ] ) // Packages/Repository/Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name : "Repository" , products : [ .library ( name: "Repository" , targets: [ "Repository" ]) ] , dependencies : [ .package ( name: "Model" , path: "../Model" ) ] ) 前述の通り、Repository層はModel層にのみ依存しています。 // Packages/Infra/Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name : "Infra" , products : [ .library ( name: "Infra" , targets: [ "Infra" ]) ] , dependencies : [ .package ( name: "Repository" , path: "../Repository" ) , .package ( name: "Model" , path: "../Model" ) ] ) 誤って意図しない依存関係のパッケージを使用した際には、このファイルに依存関係が追加されるのでレビューの際に変更に気づくことが容易にできます。依存関係を明確に定義することでAIが安全にコードを生成できるようになります。 実装例 ユーザー情報の取得を例にして実装例を紹介します。 Feature ViewModelは ObservableObject に準拠し @Published で状態管理します。UseCaseをDIで注入し、UseCaseの結果をViewModelの状態にバインドします。 protocol UserViewModel : ObservableObject { var user : User? { get } var isLoading : Bool { get } func onAppear () } @MainActor public final class UserViewModelImpl : UserViewModel { @Published private ( set ) var user : User? @Published private ( set ) var isLoading : Bool = false private let fetchUserUseCase : FetchUserUseCase public init (fetchUserUseCase : FetchUserUseCase ) { self .fetchUserUseCase = fetchUserUseCase } func onAppear () { Task { isLoading = true let result = await fetchUserUseCase.execute() switch result { case .success( let user ) : self.user = user case .failure( let error ) : print (error) } isLoading = false } } } Usecase UseCaseはビジネスロジックを実装し、Repositoryを呼び出してデータを取得します。 この例ではシンプルにRepositoryを呼び出すだけですが、実際のプロダクトコードでは複数のRepositoryからデータを取得して結合したり、バリデーション処理を行うなど、より複雑なビジネスロジックを実装します。 public protocol FetchUserUseCase { func execute () async -> Result < User , UseCaseError > } public final class FetchUserUseCaseImpl : FetchUserUseCase { private let userRepository : UserRepository public init (userRepository : UserRepository ) { self .userRepository = userRepository } public func execute () async -> Result < User , UseCaseError > { let result = await userRepository.fetchUser() switch result { case .success( let user ) : return .success(user) case .failure( let error ) : return .failure(.repositoryError(error.localizedDescription)) } } } Repository RepositoryはDataSourceを呼び出しデータ取得・キャッシュ管理を行います。 DataSource Protocolは Repository層で定義 し、依存性逆転を実現します。 // Packages/Repository/Sources/Repository/DataSource/UserDataSource.swift public protocol UserDataSource { func fetchUser () async -> Result < User , RepositoryError > } // Packages/Repository/Sources/Repository/UserRepository.swift public protocol UserRepository { func fetchUser () async -> Result < User , RepositoryError > } public final class UserRepositoryImpl : UserRepository { private let userDataSource : UserDataSource private var cachedUser : User? // キャッシュ public init (userDataSource : UserDataSource ) { self .userDataSource = userDataSource } public func fetchUser () async -> Result < User , RepositoryError > { let result = await userDataSource.fetchUser() if case .success( let user ) = result { cachedUser = user } return result } } Infra Repository層のProtocolを実装し、APIClientを使用してデータ取得します。 public final class UserDataSourceImpl : UserDataSource { private let apiClient : APIClient public init (apiClient : APIClient ) { self .apiClient = apiClient } public func fetchUser () async -> Result < User , RepositoryError > { let result = await apiClient.request(FetchUserRequest()) switch result { case .success( let response ) : return .success(response.user) case .failure( let error ) : return .failure(.apiError(error.localizedDescription)) } } } Model Modelはシンプルな struct でプロパティと計算プロパティを定義します。 Decodable はInfra層で拡張することで、Model層の独立性を保ちます。 public struct User { public let name : String public let weight : Float public let height : Float } // Packages/Infra/Sources/Infra/Decodable/User+Decodable.swift extension User : Decodable { enum CodingKeys : String , CodingKey { case name case weight case height } public init (from decoder : Decoder ) throws { let container = try decoder.container(keyedBy : CodingKeys.self ) name = try container.decode(String. self , forKey : .name) weight = try container.decode(Float. self , forKey : .weight) height = try container.decode(Float. self , forKey : .height) self . init ( name : name , weight : weight , height : height ) } } DIについて DIはUIKitとSwiftUIが混在しているため、グローバルアクセス可能なシングルトンとしてDependencyクラスを定義し、アプリ起動時にViewModelProviderを設定しています。SwiftUIのみであれば、Environmentに置き換えることも可能です。 具体的には、AppDelegateの application(_:didFinishLaunchingWithOptions:) で Dependency.shared.set(ViewModelProvider()) を呼び出して初期化します。 // AppDelegate.swift func application (_ application : UIApplication , didFinishLaunchingWithOptions launchOptions : [ UIApplication.LaunchOptionsKey : Any ] ?) -> Bool { let viewModelProvider = ViewModelProvider() Dependency.shared. set (viewModelProvider) return true } // App/Dependency.swift public final class Dependency { public static let shared = Dependency() private var viewModelProvider : ViewModelProvidable? public var viewModel : ViewModelProvidable { guard let viewModelProvider else { preconditionFailure( "アプリ起動時に `set(_:)` してから利用してください." ) } return viewModelProvider } private init () {} public func set (_ viewModelProvider : ViewModelProvidable ) { self .viewModelProvider = viewModelProvider } } // App/ViewModelProvider.swift final class ViewModelProvider : ViewModelProvidable { func userViewModel () -> UserViewModelImpl { UserViewModelImpl( fetchUserUseCase : fetchUserUseCase ) } private lazy var fetchUserUseCase : FetchUserUseCaseImpl = { FetchUserUseCaseImpl( userRepository : userRepository ) }() private lazy var userRepository : UserRepositoryImpl = { UserRepositoryImpl( userDataSource : userDataSource ) }() private lazy var userDataSource : UserDataSourceImpl = { UserDataSourceImpl( apiClient : apiClient // APIClientもDIで注入しています。 ) }() } DIしたものを利用するために以下のようなProtocolを定義します。これによって、ViewModelProviderを利用する側では、ViewModelの具体的な実装を知らなくても、ViewModelProviderを通じてViewModelを利用することができます。 // Packages/Feature/Sources/Feature/ViewModelProvidable.swift public protocol ViewModelProvidable { func userViewModel () -> UserViewModelImpl } まとめ ヘルシカiOSアプリで採用しているクリーンアーキテクチャについてご紹介しました。 本記事のポイントをまとめます: 各層の責務分離 : Feature層(UI/状態管理)、Usecase層(ビジネスロジック)、Repository層(データアクセス/キャッシュ)、Infra層(外部リソースアクセス)、Model層(ドメインモデル)と明確に責務を分離しています SPMによるモジュール管理 : パッケージファイルで依存関係を明示することで、アーキテクチャ違反を防ぎ、レビュー時に変更を検知しやすくなります 依存性逆転の原則 : DataSourceのProtocolをRepository層で定義することで、Repository層がInfra層の実装詳細に依存しない設計を実現しています DI(依存性注入) : シングルトンのDependencyクラスを通じて依存関係を一元管理し、ViewModelProviderで各層のインスタンスを生成・注入しています AIがコードを書く時代では、依存関係を明確に定義し、責務を分離することでAIにとってもわかりやすく、安全にコード生成することが可能になると考えています。 実際の開発ではCursorを活用しています。Usecase、Repository、Infra、Model層は構造化されたパターンが多いため、AIによるコード生成との相性が良く、ほぼAIに任せることができています。 一方、Feature層は画面ごとに実装方針にばらつきがあり、完全にAIへ委ねるのは難しい印象です。また、簡単なロジックをViewModelに持たせるかUseCaseに切り出すかは状況に応じた判断が必要であり、この辺りは引き続き人間が担う領域だと感じています。
アバター
この記事は every Tech Blog Advent Calendar 2025 の 10 日目の記事です。 開発2部の内原です。 今回は、Go 1.26で追加される予定の slog.MultiHandler について調べてみたので書いてみます。 概要 Go 1.21で導入された log/slog は構造化ログを扱えるため便利なのですが、複数の出力先(標準出力とファイル、標準出力とFluentdなど)に異なる設定でログを出力したい場合、 io.MultiWriter を使うか、サードパーティのライブラリに頼る必要がありました。 Go 1.26では、この問題を解決するために NewMultiHandler 関数が追加されます。これにより、複数のハンドラーを同時に利用できるようになり、出力先ごとに異なるログレベルやフォーマットを設定することが可能になります。 この記事では、 slog.MultiHandler の基本的な使い方と、実際のユースケースを想定した実装例を紹介します。 Go 1.26のインストール方法 Go 1.26はまだ正式リリースされていないため、開発版をビルドする必要があります。以下の手順でソースからビルドしました。 $ git clone https://go.googlesource.com/go $ cd go/src $ ./make.bash $ export GOROOT=$(pwd)/.. $ export PATH=$GOROOT/bin:$PATH $ go version go version go1.26-devel_f22d37d574 Mon Dec 1 14:59:40 2025 -0800 darwin/arm64 slog.MultiHandlerとは NewMultiHandler 関数は、複数のハンドラーを受け取り、それらすべてにログを送信する MultiHandler を返します。以下の関数シグネチャになっています。 func NewMultiHandler(handlers ...Handler) *MultiHandler 従来の io.MultiWriter との違いは、各ハンドラーに対して異なる設定(ログレベル、フォーマットなど)を適用できる点です。例えば、標準出力にはすべてのログを出力し、ファイルには重要なレベルのログのみを出力する、といった柔軟な設定が可能になります。 slog.MultiHandlerの想定ユースケース 以下では、実際のアプリケーションで利用されそうなユースケースと、その実装を書いてみます。 標準出力とログファイル 開発環境では人間が読みやすい形式で出力し、本番環境ではファイルにも記録を残したいというケースです。 環境別に実装を分けるのも手ですが、両方出力すればよいので簡単に思いました。 実装例 package main import ( "log/slog" "os" ) func main() { // 標準出力用(テキスト形式、DEBUG以上) stdoutHandler := slog.NewTextHandler( os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, }, ) // ファイル用(JSON形式、INFO以上) logFile, err := os.OpenFile( "app.log" , os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666 ) if err != nil { panic (err) } defer logFile.Close() fileHandler := slog.NewJSONHandler( logFile, &slog.HandlerOptions{ Level: slog.LevelInfo, }, ) multiHandler := slog.NewMultiHandler(stdoutHandler, fileHandler) logger := slog.New(multiHandler) slog.SetDefault(logger) slog.Debug( "debug (stdout only)" ) slog.Info( "info (stdout & file)" ) slog.Warn( "warn (stdout & file)" ) slog.Error( "error (stdout & file)" ) } この実装により以下のような運用になります。 標準出力にはDEBUG以上のログがテキスト形式で表示される ファイルにはINFO以上のログがJSON形式で記録される 開発時は標準出力を確認、本番環境ではファイルを解析 標準出力とFluentd ログをFluentdなどのログ収集システムに送信しつつ、標準出力には人間が読みやすい形式で出力するケースです。 Dockerなどのコンテナ環境であればログ転送機構(docker logging driverなど)を用いることでアプリケーションは標準出力に送信するだけで外部ロギング機構に対応することはできますが、その場合すべてのログが転送されてしまうため、細かいコントロールはできなくなります。 実装例 fluent.conf は以下を想定。 <source> @type forward port 24224 bind 0.0.0.0 </source> package main import ( "context" "log/slog" "os" "github.com/fluent/fluent-logger-golang/fluent" ) type FluentdHandler struct { logger *fluent.Fluent tag string level slog.Level } func NewFluentdHandler(tag string , opts *slog.HandlerOptions) (*FluentdHandler, error ) { logger, err := fluent.New(fluent.Config{ FluentHost: "127.0.0.1" , FluentPort: 24224 , FluentNetwork: "tcp" , MarshalAsJSON: true , }) if err != nil { return nil , err } level := slog.LevelInfo if opts != nil && opts.Level != nil { level = opts.Level.Level() } return &FluentdHandler{ logger: logger, tag: tag, level: level, }, nil } func (h *FluentdHandler) Enabled(ctx context.Context, level slog.Level) bool { return level >= h.level } func (h *FluentdHandler) Handle(ctx context.Context, r slog.Record) error { data := make ( map [ string ] interface {}) data[ "level" ] = r.Level.String() data[ "msg" ] = r.Message data[ "time" ] = r.Time r.Attrs( func (a slog.Attr) bool { data[a.Key] = a.Value.Any() return true }) return h.logger.PostWithTime(h.tag, r.Time, data) } func (h *FluentdHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return h } func (h *FluentdHandler) WithGroup(name string ) slog.Handler { return h } func (h *FluentdHandler) Close() error { if h.logger != nil { return h.logger.Close() } return nil } func main() { // 標準出力用(テキスト形式、DEBUG以上) stdoutHandler := slog.NewTextHandler( os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, }, ) // Fluentd用(INFO以上) fluentdHandler, err := NewFluentdHandler( "app" , &slog.HandlerOptions{ Level: slog.LevelInfo, }, ) if err != nil { panic (err) } defer fluentdHandler.Close() multiHandler := slog.NewMultiHandler(stdoutHandler, fluentdHandler) logger := slog.New(multiHandler) slog.SetDefault(logger) slog.Info( "app started" , "version" , "1.0.0" ) slog.Warn( "high resource usage" , "cpu" , 85.5 , "memory" , 90.2 ) slog.Error( "failed to connect database" , "error" , "connection timeout" ) } この実装により以下が可能になります。 標準出力には開発者が確認しやすい形式でログが表示される Fluentdには構造化されたJSON形式でログが送信され、ログ分析システムで処理可能になる 各ハンドラーで異なるログレベルを設定できるため、標準出力にはすべてのログ、Fluentdには重要なログのみを送信、といった制御が可能になる 環境ごとのログレベル設定 開発環境ではすべてのログを標準出力に、本番環境では重要なログのみをファイルに記録する、といった環境に応じた設定の分岐をするケースです。handlersの内容を変化させるだけなので分かりやすいと感じました。 実装例 func setupLogger(env string ) { var handlers []slog.Handler if env == "development" { // 開発環境用 標準出力 DEBUG以上 handlers = append (handlers, slog.NewTextHandler( os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}, )) } if env == "production" { // 本番環境 ファイル INFO以上 logFile, _ := os.OpenFile( "app.log" , os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666 ) handlers = append (handlers, slog.NewJSONHandler( logFile, &slog.HandlerOptions{Level: slog.LevelError}, )) } multiHandler := slog.NewMultiHandler(handlers...) slog.SetDefault(slog.New(multiHandler)) } まとめ Go 1.26で追加される slog.MultiHandler により、複数の出力先に対して異なる設定でログを出力できるようになります。これにより、以下のようなメリットが得られます。 出力先ごとに異なるログレベルやフォーマットを設定可能 サードパーティのライブラリに依存せずに実装可能 開発時は標準出力で確認し、本番環境ではファイルやログ収集システムに送信する、といった使い分けが容易 適切なユースケースがあれば使っていきたいと考えています。
アバター
この記事は every Tech Blog Advent Calendar 2025 の 9 日目の記事です。 はじめに こんにちは。リテールハブ開発部小売アプリチームの池です。 Flutter で開発しているアプリの中で、Email のワンタイムパスワード(OTP)を利用した認証機能を検証する機会がありました。 iOS には、SMS やメールで届いた認証コードをクイックタイプバーと呼ばれるキーボード上部に候補として表示し、タップするだけで入力できる自動入力(AutoFill)機能があります。 次の画像は OTP 自動入力が動作した際の UI イメージです。 OTP AutoFill 動作時の見た目 ユーザーの手入力やコピー&ペーストの手間を無くすことができるため、認証フローのユーザー体験にとって大切な機能だと考えています。 しかし、Apple 公式ドキュメントには SMS を前提とした記述が中心で、Email 経由の OTP 自動入力については情報が多くありませんでした。 どのような条件で動作するのか把握しづらいため、本記事では実機を用いて Email 経由の OTP 自動入力の挙動について検証します。 なお、本記事では iOS のみを検証対象としており、Android は対象外としています。 公式ドキュメントからわかること 私が確認した範囲にはなりますが、まずは、Apple および Flutter の公式ドキュメントから把握できる内容を整理します。 Apple 公式ドキュメントでわかること Apple の公式ドキュメント( One-time codes 、 About the Password AutoFill workflow 、 Enabling Password AutoFill on a text input view 、 WWDC18 Session 204 )から確認できる主なポイントは以下のとおりです。 AutoFill は Apple 純正の Messages(SMS)および Mail アプリに対応 UITextContentType.oneTimeCode を設定することで AutoFill 対象の入力欄と認識される SMS には「code」または「passcode」というキーワードがコード文字列の近くに含まれている必要がある SMS から認証コードをパースできた場合、受信後最大 3 分間 QuickType バーに表示される 認証コード入力欄にカスタム入力ビューを使用すると、AutoFill の UI が表示されない Flutter 公式ドキュメントでわかること Flutter の公式ドキュメント( AutofillHints.oneTimeCode )から、以下の内容が確認できます。 SMS ワンタイムコードの入力フィールドであることを示すヒント iOS で UITextContentType.oneTimeCode にマッピングされる つまり、Flutter では AutofillHints.oneTimeCode を設定することで、iOS ネイティブの UITextContentType.oneTimeCode と同等の動作が期待されます。 公式ドキュメントから明確に確認できないこと 一方で、以下の点については公式ドキュメントに明記されていません。 Gmail などサードパーティメールアプリでの挙動 通知設定が挙動に影響するかどうか Email 本文にも SMS 同様「フォーマット要件」があるか AutofillHints.oneTimeCode 設定有無による挙動差分(設定しない場合でも AutoFill が動作していたため) これらの点について実機を用いて検証を行いました。 検証内容 検証環境 項目 バージョン iOS 26.1 Flutter 3.35.6 検証パターン Email 経由の OTP 自動入力について、以下の 3 つの軸の組み合わせを検証しました。 軸 条件 メールアプリ Apple 純正 Mail / Gmail Flutter の AutofillHints.oneTimeCode 設定 あり / なし 通知設定 ON / OFF さらに、Email 本文にもフォーマット要件(SMS の「code」相当)があるかどうかについても検証対象としました。 検証に使用した実装 検証には以下の 2 パターンの TextFormField 実装を使用しました(OTP 自動入力に関連する箇所のみ抜粋)。 パターン 1: AutofillHints.oneTimeCode なし TextFormField( keyboardType: TextInputType.number, ... ) パターン 2: AutofillHints.oneTimeCode あり TextFormField( keyboardType: TextInputType.number, autofillHints: const [AutofillHints.oneTimeCode], ... ) 検証結果 結果 ①:Mail と Gmail の挙動の違い 結論から言うと、Apple Mail はどのパターンも動作したが、Gmail は通知設定が必須です。 oneTimeCode 設定 メールアプリ 通知設定 結果 なし Mail ON 動作する なし Mail OFF 動作する なし Gmail ON 動作する なし Gmail OFF 動作しない あり Mail ON 動作する あり Mail OFF 動作する あり Gmail ON 動作する あり Gmail OFF 動作しない 画像では正確な挙動が分かりづらいですが、以下が純正 Mail アプリおよび Gmail で通知 ON/OFF における AutoFill の挙動の画像になります。 Mail では通知 OFF でもクイックタイプバーに認証コードが表示されています。 Mail アプリ 通知 ON Mail アプリ 通知 OFF 一方で、Gmail では通知 OFF にするとクイックタイプバーに認証コードが表示されませんでした。 Gmail 通知 ON Gmail 通知 OFF この結果から、Mail ではメール本文を頼りに認証コードが検出され、Gmail では通知本文を頼りに認証コードを抽出されていると推測できます。 追加検証:プッシュ通知(FCM)からの OTP も検出されるか Gmail で通知設定によって挙動が変わることから、「サードパーティメールアプリの場合、通知を頼りに iOS が認証コードを抽出しているのでは?」という仮説が生まれたので、FCM 経由で認証コードを含む通知を送信して検証しました。 次の画像の内容で通知を送ります。 結果、FCM からのプッシュ通知でも、クイックタイプバーに認証コードが表示されました。 つまり、iOS はメールアプリに限らず、通知に含まれる認証コードも AutoFill の対象としていることが確認できました。 結果 ②:Email のフォーマット要件 SMS では「code」または「passcode」というキーワードが必要という要件がありますが、Email についても同様の要件があるか検証しました。 以下の 3 パターンのメール本文で OTP 自動入力が動作するか確認しました。 パターン メール本文例 結果 数字のみ 123456 動作しない 「認証」キーワード 認証:123456 動作しない 「コード」キーワード コード:123456 動作する 数字のみ 認証:123456 コード:123456 結果、「コード」というキーワードが含まれている場合のみ OTP AutoFill が動作しました。これは SMS と同様に、Email でも「code」に相当するキーワード(日本語の「コード」を含む)が必要であることを示唆しています。 検証結果のまとめ ここまでの事実を整理すると、以下になります。 iOS が OTP を検出する経路は 2 種類ある Apple 純正アプリ(Mail / Messages) → 本文へ直接アクセス サードパーティアプリ → 通知本文のみ参照 そのため、Gmail は通知 OFF で AutoFill が動作しないという挙動になる。 Email にも「キーワード要件」がある 「コード」「code」「passcode」など、コード番号の意味を示す語が必要 補足 AutofillHints.oneTimeCode の設定について 今回の検証では AutofillHints.oneTimeCode の設定有無で OTP 自動入力の挙動に違いは見られませんでした。しかし、Apple 公式ドキュメントでは UITextContentType.oneTimeCode の設定が必要とされています。設定なしで動作したのはあくまで検証時の結果であり、この挙動が保証されているわけではありません。 また、Apple は Associated Domains と組み合わせた domain-bound OTP(OTP を特定ドメインに紐づけるセキュリティ機能)をサポートしており、この機能にも UITextContentType.oneTimeCode の設定が必要です。 今回の検証では AutoFill に限り動作はしたものの保証されているものではなく、domain-bound OTP 機能もあるので、公式ドキュメントに則り AutofillHints.oneTimeCode は設定しておくことを推奨します。 終わりに Email 経由の OTP AutoFill の挙動は、SMS に比べて公式情報が乏しくブラックボックスな部分が多いです。今回の検証により実践的な知見を得ることができました。 また、AutoFill による入力はもちろん大切ですが、ユーザーの利用しているメールアプリや設定次第では AutoFill が効かないことがわかったため、手動入力も意識した UI 設計を心がける必要があると学びました。 Email 経由の OTP AutoFill の挙動は iOS のアップデートにより変化する可能性がありますが、本記事が少しでも参考になれば幸いです。
アバター
目次 はじめに Step Functions とは 突然のエラー発生 Step Functions のペイロードサイズ制限 制限の概要 なぜこの制限があるのか 問題のワークフロー構成 修正前の定義(抜粋) 解決策: ResultWriter と ItemReader の活用 ResultWriter とは ItemReader とは 修正後のワークフロー構成 修正後の定義(抜粋) まとめ この記事は every Tech Blog Advent Calendar 2025 の 8 日目の記事です。 はじめに こんにちは。開発本部開発1部デリッシュキッチンMS2に所属している惟高です。 私が担当しているプロジェクトでは、AWS Step Functions を使って Athena でデータを集計し、結果をエクスポートするバッチ処理を実行しています。ある日、処理対象のデータ量が増えたタイミングで突然ワークフローが停止し、調査の結果 Step Functions のペイロードサイズ制限に起因する問題だと判明しました。 この記事では、問題の原因と Step Functions の制限について調査した内容、そして ResultWriter と ItemReader を使った解決方法を紹介します。 Step Functions とは AWS Step Functions は、複数の AWS サービスをワークフローとして連携させるサーバーレスのオーケストレーションサービスです。JSON ベースの Amazon States Language(ASL) でワークフローを定義し、 Lambda 関数や Athena クエリなどを順次・並列に実行できます。 今回のワークフローでは、 Map ステート を使って複数のアイテムを並列処理しています。 Map ステート には以下の2つのモードがあります。 モード 特徴 インライン Map 状態を含むワークフローのコンテキストで実行。最大 40 の同時反復 分散 子ワークフローとして分散実行。10,000 を超える同時反復が可能 今回問題が発生したワークフローでは、大量のデータを処理するために分散モードを使用していました。 突然のエラー発生 ある日、定期実行していたワークフローが以下のエラーで停止しました。 The state/task 'Map' returned a result with a size exceeding the maximum number of bytes service limit. エラーメッセージを見ると、 Map ステートの出力サイズが上限を超えたことが原因のようです。処理対象のデータ量が増えたことで、分散 Map で並列処理した結果が蓄積され、次のステートに渡せなくなっていました。 Step Functions のペイロードサイズ制限 調査したところ、Step Functions には 256KB のペイロードサイズ制限 があることがわかりました。 制限の概要 AWS の公式ドキュメント によると、Step Functions では以下の制限があります。 ステート間で受け渡しできるデータの最大サイズは 256KB この制限は入力・出力の両方に適用される 制限を超えると States.DataLimitExceeded エラーが発生する なぜこの制限があるのか Step Functions はサーバーレスのワークフローサービスであり、ステート間のデータはサービス内部で管理されます。 AWS のベストプラクティス では、256KB を超える可能性があるデータは S3 に保存することが推奨されています。 問題のワークフロー構成 エラーが発生していた当時のワークフローは、以下のような構成でした。 Map(分散モード)─── S3から入力データを読み込み(ItemReader) └── Map(1)(インラインモード)─── 各クエリを実行 └── SQL生成 → 実行 → 結果取得 ↓ 結果を収集 ↓ 【ここで 256KB を超過】 ↓ Map(2)(インラインモード) └── エクスポート処理 ※ Athena のクエリ結果そのものではなく、結果が格納された S3 パスのみを受け渡していましたが、処理対象のアイテム数が増えるとメタデータが蓄積され、256KB を超えてしまいました。 修正前の定義(抜粋) { " Map ": { " Type ": " Map ", " Next ": " Map (2) ", " Iterator ": { " StartAt ": " Map (1) ", " States ": { " Map (1) ": { " Type ": " Map ", " ItemsPath ": " $.queries ", " Iterator ": { // ... Athena クエリ実行処理 } } } , " ProcessorConfig ": { " Mode ": " DISTRIBUTED ", " ExecutionType ": " STANDARD " } } , " ItemReader ": { " Resource ": " arn:aws:states:::s3:getObject ", " ReaderConfig ": { " InputType ": " JSON " } , " Parameters ": { " Bucket.$ ": " $.target.bucket ", " Key.$ ": " $.target.key " } } // ResultWriter がない → 結果が直接次のステートへ } , " Map (2) ": { " Type ": " Map ", " ItemProcessor ": { " ProcessorConfig ": { " Mode ": " INLINE " } // ... エクスポート処理 } } } 解決策: ResultWriter と ItemReader の活用 Step Functions の分散 Map には、大きな結果を S3 に書き出す ResultWriter という機能があります。これと ItemReader を組み合わせることで、256KB の制限を回避できます。 ResultWriter とは ResultWriter は、分散 Map の実行結果を S3 に書き出す機能です。設定すると、以下のような構造でファイルが出力されます。 指定したS3プレフィックス/ └── {実行ID}/ ├── manifest.json # マニフェストファイル ├── SUCCEEDED_0.json # 成功した子ワークフローの結果 ├── SUCCEEDED_1.json # (5GB を超える場合は分割される) ├── FAILED_0.json # 失敗した子ワークフローの結果 └── PENDING_0.json # 未実行の子ワークフローの情報 マニフェストファイルには、エクスポート場所やマップ実行 ARN、各結果ファイルへの参照などのメタデータが含まれています。 ItemReader とは ItemReader は、分散 Map の入力データを S3 から読み込む機能です。通常、Map ステートは ItemsPath で指定した配列をループしますが、 ItemReader を使うと S3 に保存された JSON や CSV ファイルから直接データを読み込めます。 これにより、以下のメリットがあります。 大量データの処理 : 256KB の入力制限を気にせず、S3 上の大きなデータセットを直接処理できる 柔軟なデータソース : JSON、CSV、マニフェストファイルなど様々な形式に対応 ポインタ指定 : ItemsPointer を使って、ファイル内の特定のパスにあるデータを抽出できる 今回のワークフローでは、 ResultWriter で書き出したマニフェストファイルを ItemReader で読み込み、結果ファイルの一覧を取得するために使用しています。 修正後のワークフロー構成 Map(分散モード)─── S3から入力データを読み込み(ItemReader) └── Map(1)(インラインモード)─── 各クエリを実行 └── SQL生成 → 実行 → 結果取得 ↓ ResultWriter で S3 に結果を書き出し ← 【ポイント】 ↓ 【S3 経由でデータを受け渡し】 ↓ 「S3に保存した処理取得」Map(分散モード)─── マニフェストから結果ファイル一覧を取得(ItemReader) └── Map(2)(分散モード)─── 各結果ファイルを読み込み(ItemReader) └── エクスポート処理 ※ マニフェストには複数の結果ファイル(SUCCEEDED_0.json, SUCCEEDED_1.json など)への参照が含まれるため、それぞれを並列処理するために Map を使用しています。 ポイントは、最初の Map に ResultWriter を追加して結果を S3 に書き出し、後続の処理では ItemReader を使って S3 から読み込むようにした点です。 修正後の定義(抜粋) 1. ResultWriter の追加 最初の Map に ResultWriter を追加し、結果を S3 に書き出すようにしました。 { " Map ": { " Type ": " Map ", " Next ": " S3に保存した処理を取得 ", " Iterator ": { // ... 既存の処理 } , " ResultWriter ": { " Resource ": " arn:aws:states:::s3:putObject ", " Parameters ": { " Bucket ": " your-bucket-name ", " Prefix ": " workflow-results/ " } , " WriterConfig ": { " OutputType ": " JSON ", " Transformation ": " FLATTEN " } } } } 2. マニフェストから結果ファイルを読み込む Map の追加 ResultWriter の出力には ResultWriterDetails というフィールドが含まれ、マニフェストファイルの場所が記載されています。これを ItemReader で読み込みます。 { " S3に保存した処理を取得 ": { " Type ": " Map ", " ItemProcessor ": { " ProcessorConfig ": { " Mode ": " DISTRIBUTED ", " ExecutionType ": " STANDARD " } , " StartAt ": " Bucket付与 ", " States ": { " Bucket付与 ": { " Type ": " Pass ", " Next ": " Map (2) ", " Parameters ": { " Bucket ": " your-bucket-name ", " Key.$ ": " $.Key " } } , " Map (2) ": { // ... 後続の処理 } } } , " ItemReader ": { " Resource ": " arn:aws:states:::s3:getObject ", " ReaderConfig ": { " InputType ": " JSON ", " ItemsPointer ": " /ResultFiles/SUCCEEDED " } , " Parameters ": { " Bucket.$ ": " $.ResultWriterDetails.Bucket ", " Key.$ ": " $.ResultWriterDetails.Key " } } } } ポイントは ItemsPointer: "/ResultFiles/SUCCEEDED" の部分です。マニフェストファイル内の成功した結果ファイル一覧を直接参照し、それぞれを並列処理のアイテムとして扱います。 3. 各結果ファイルを読み込んで処理 最後に、各結果ファイルを ItemReader で読み込み、エクスポート処理を実行します。 { " Map (2) ": { " Type ": " Map ", " ItemProcessor ": { // ... エクスポート処理 } , " ItemReader ": { " Resource ": " arn:aws:states:::s3:getObject ", " ReaderConfig ": { " InputType ": " JSON " } , " Parameters ": { " Bucket.$ ": " $.Bucket ", " Key.$ ": " $.Key " } } } } まとめ Step Functions の 256KB ペイロードサイズ制限に遭遇した際の解決方法を紹介しました。 ポイント: Step Functions のステート間で受け渡せるデータは 256KB まで 大きなデータを扱う場合は S3 を経由 する設計が必要 分散 Map の ResultWriter で結果を S3 に書き出せる ItemReader の ItemsPointer でマニフェストから必要な情報を抽出できる 今回のエラーをきっかけに Step Functions の制限と、それを回避するための機能について理解を深めることができました。同様の問題に遭遇した方の参考になれば幸いです。 最後まで読んでいただき、ありがとうございました。
アバター
この記事は every Tech Blog Advent Calendar 2025 の7日目の記事です。 はじめに デリッシュキッチンのiOSアプリを開発している成田です。 iOSアプリを開発していると、アイコンやロゴなどの画像アセットを扱う場面が必ずあります。 アイコンやロゴなどのベクター画像を扱う際、 PDF と SVG の2つの形式が候補として出てくるかと思いますが、どちらを使えば良いか迷ったことはないでしょうか。 今回はPDFとSVGをいくつかの観点から比較し、どちらを選ぶべきか考えてみたいと思います。 PDFとSVGはそもそも何が違うのか まず、両者の設計目的の違いを見てみましょう。 項目 SVG PDF 設計目的 Web用ベクター画像 環境に依存しない文書形式 ファイル形式 テキスト(XML) バイナリ 開発元 W3C Adobe SVGは「画像」のための形式 SVGは「Scalable Vector Graphics」の略で、Web上でベクター画像を扱うために設計された形式です。 テキスト(XML)形式で、描画データだけを持つシンプルな構造になっています。 <svg viewBox="0 0 24 24"> <circle cx="12" cy="12" r="10" fill="#FF6B00"/> </svg> 例えば上記は円を描画するものですが、XML形式なので人間にも読み取ることができます。 PDFは「文書」のための形式 PDFは「Portable Document Format(ポータブル・ドキュメント・フォーマット)」の略で、1993年にAdobeが開発したファイル形式です。 Adobe公式 によると、PDFの設計目的は「どのコンピューターやソフトウェアで文書を作成しても、作成時の見た目のまま表示・印刷できること」でした。 PDFはベクター画像やラスター画像も含められる汎用的なコンテナ形式です。 PDFは文書形式として設計されているため、描画データ(ベクター・ラスター画像)の他にも、メタデータ(作成者、日時)やフォント情報、ページ構造、印刷設定など様々な情報が含まれるとみられます。 アイコンやロゴなどの用途で必要なのは描画データだけですが、PDFではそれ以外の情報も含まれるため、その分オーバーヘッドが生じます。 一方でSVGは最初からベクター画像のために設計されているため、アイコン用途にはより適していると言えそうです。 iOSのサポート 形式 サポート開始 PDF iOS 11(2017年) SVG iOS 13(2019年) iOSではPDFの方が古くからサポートされています。iOS12以下をサポートする必要がある場合はPDFを選ぶ必要がありますがありますが、2025年12月現在では大体のアプリはiOS12以下はサポートしていないと思うので基本的にはどちらを使っても特に不具合等は起こらないとみられます。 Appleの公式ドキュメントを読んでもどちらを推奨するなどの旨の記述は見つかりませんでした。 ファイルサイズ シンプルなアイコンの場合 シンプルなアイコンの場合、一般的にSVGはPDFより軽くなります。 実際に上のようなシンプルなアイコンをPDFとSVGで書き出して比較したところ、ファイルサイズは以下のようになりました。 形式 サイズ SVG 約0.8KB PDF 約5KB 複雑な画像の場合 ただし、複雑なパスやグラデーションが多いような画像では、PDFの方が軽くなる可能性もありそうです。 SVGはテキスト形式のため、複雑な曲線を表現するには大量の座標データをそのまま保持する必要がありますが、一方でPDFはバイナリ圧縮されるため、ある種の複雑なベクターデータでは圧縮効率が高くなる場合があると考えられます。 Git上の管理 PDFはバイナリ形式なのでGitで差分を確認することができませんが、SVGはテキスト(XML)形式なので、Gitで差分を確認することができます。 PRのレビューで変更内容を確認できることは大きなメリットなのでGit管理のしやすさにおいてはSVGの方が適しています。 レンダリングの品質 iOSアプリでSVGとPDFを使った際のレンダリング品質の差については、調べた限りでは明確な違いは見つかりませんでした。 どちらもベクター形式なので拡大縮小しても劣化せず、レンダリング品質・機能面ではどちらを選んでも問題なさそうです。 結論 ここまでいくつかの観点で比較してきましたが、 iOS 13以降をサポートするアプリであればSVGを使うのが良さそう です。 SVGを選ぶ理由 アイコンは「画像」なので、画像用に設計されたSVGが適切 シンプルなアイコンならファイルサイズが小さい Gitで差分が見える (PRレビューがしやすい) おわりに 今回はiOSアプリにおけるベクター画像の形式としてSVGとPDFを比較しました。 PDFは長くiOS開発で使われてきた実績がありますが、iOS13でSVGがサポートされた今、アイコン用途ではSVGを選ぶメリットの方が大きいと感じます。 特にGitで差分が見えるようになるのは、チーム開発において地味ながら嬉しいポイントです。
アバター
この記事は every Tech Blog Advent Calendar 2025 の 6 日目の記事です。 こんにちは、株式会社エブリーで Android アプリ開発を担当している岡田です。 弊社では開発スピード向上のための選択として、UseCase を削るアーキテクチャ改修を行いました。 こちらについて、少しお話しさせていただければと思います。 概要: 従来のアーキテクチャの紹介 弊社では Google Developers が提唱している、レイヤードアーキテクチャ を採用しています。 optional として紹介されている ドメイン層 も採用しています。ドメイン層 には主に UseCase を記述しています。 従来のアーキテクチャでは ViewModel が Repository を参照する場合、UseCase を介するような設計になっていました。 UI レイヤー が データ層 を直接参照してはいけないという、教科書的で厳格な「Clean Architecture」の解釈に沿った設計です。 一見するとこれは確からしい素敵なアーキテクチャに思えますが、実は Android アプリを開発する上では大きな課題を抱えています。 課題:冗長な UseCase の量産 従来のアーキテクチャでは、たとえ単純なデータ取得であっても、必ずUseCaseを作成していました。 例えば、ユーザープロフィールを表示するだけの機能でも、以下のようなコードが必要でした。 /** * 従来の UseCase */ class GetUserProfileUseCase @Inject constructor ( private val userRepository: UserRepository ) { // Repositoryを呼ぶだけ(パススルー) suspend operator fun invoke(userId: String ): User { return userRepository.getUser(userId) } } /** * 呼び出される側の Repository (インターフェース) */ interface UserRepository { // 戻り値も引数も UseCase と全く同じ suspend fun getUser(userId: String ): User } ご覧の通り、 GetUserProfileUseCase の実装は、 UserRepository のメソッドを右から左へ受け流すだけのパススルーな処理です。 アプリの機能を拡張するたびに、このような冗長な UseCase を作成しなければならないのは、単なる手間の問題にとどまりません。 クラスが増えれば、それに付随する Unit Test の記述も必要となり、プロジェクト全体のコード量は肥大化します。 特にマルチモジュール構成を採用している場合、こうしたファイル数の増加はビルド時間の悪化に直結します。 さらに、Pull Request の差分が本質的ではないコードで埋め尽くされることは、レビュワーの認知的負荷を高め、開発効率を低下させるという悪循環に陥っていました。 弊社のアプリはサーバーがビジネスロジックを持つケースが多く、上記の問題が顕在化していました。 この件については、 Google Developers のドキュメント: ドメイン層 > データ層のアクセス制限 でも触れられています。 ドメイン層を実装する際のもう 1 つの考慮事項は、UI レイヤーからデータ層への直接アクセスを許可するか、すべてをドメイン層経由で強制するかです。 この制限を設ける利点は、たとえばデータ層への各アクセス要求で分析ログを実行している場合など、UI がドメイン層 ロジックをバイパスするのを防ぐことができることです。 ただし、潜在的に重大な欠点は、データ層への単純な関数呼び出しであってもユース ケースを追加する必要があり、メリットがほとんどないにもかかわらず複雑さが増す可能性があることです。 必要な場合にのみユースケースを追加するのが良いアプローチです。UIレイヤーがほぼユースケースを通じてのみデータにアクセスしていることがわかった場合は、この方法でのみデータにアクセスするのが合理的かもしれません。 最終的に、データ層へのアクセスを制限するかどうかの決定は、個々のコードベースと、厳格なルールを好むか、より柔軟なアプローチを好むかによって決まります。 Google Developers としても、アプリによって使い分けた方が良いという見解のようです。 解決策:ドメイン層(UseCase)の使用をアプリがビジネスロジックを持つ場合にのみ限定する 最終的に、ドメイン層(UseCase)の使用をアプリがビジネスロジックを持つ場合にのみ限定することに決めました。 ビジネスロジックを持たない場合は、 ViewModel から Repository を直接参照することで、冗長な記述を排除できます。 このアーキテクチャは、 Now In Android と同様のものです。 docs/ArchitectureLearningJourney.md を見ると、以下のような図があります。 UI レイヤー が データ層 を直接参照することを許容しています。 実際のコード MainActivityViewModel.kt を見ても、直接 Repository を Inject しています。 @HiltViewModel class MainActivityViewModel @Inject constructor( userDataRepository: UserDataRepository, // <= ここ ) : ViewModel() { val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map { Success(it) }.stateIn( scope = viewModelScope, initialValue = Loading, started = SharingStarted.WhileSubscribed(5_000), ) } 弊社のコードベースも上記のアーキテクチャを採用し、大幅なコード削減を達成しました。 おまけ: 公式 Android ガイダンスが提唱するアーキテクチャと、クリーンアーキテクチャの違い 公式 Android ガイダンスが提唱するアーキテクチャとクリーンアーキテクチャには違いが多いです。 Now In Android には過去このような Discussions があり、ここでクリーンアーキテクチャとの違いについて議論されています。 github.com 簡単にまとめると、以下になります。 依存関係の方向(Dependency Direction) クリーンアーキテクチャ 公式 Android ガイダンス データ層 が ドメイン層 に依存 ドメイン層 が データ層 に依存 ドメイン層の扱い クリーンアーキテクチャ 公式 Android ガイダンス 必須 任意(Optional) クリーンアーキテクチャでは ドメイン層 を中心として設計 されていますが、 公式 Android ガイダンス では データ層 を中心として設計 する形になっています。 これは大抵の Android アプリはビジネスロジックをサーバーに任せるケースが多いためです。 まとめ 「Clean Architecture の純粋さ」を守ることよりも、「チームの開発生産性」と「コードの実用性」を優先する選択をしました。 最初は「レイヤーを飛ばすこと」に抵抗がありましたが、Google のガイドラインという後ろ盾と、実際のコードのスッキリ具合を見て、今ではチーム全体がこの変更をポジティブに捉えています。 もし、「UseCase を書くのが面倒だ」「コードが無駄に多い」と感じているなら、一度 「その UseCase は本当に必要か?」 をチームで話し合ってみてはいかがでしょうか。
アバター
【実践】RDS for MySQL 8.4アップグレード Blue/Green Deploymentsを添えて この記事は every Tech Blog Advent Calendar 2025 の 5 日目の記事です。 背景 バージョン8.0と8.4の変更点と対応について 1. パラメータグループの作成 2. デフォルト値が変更されたパラメータ innodb_purge_threads group_replication_exit_state_action binlog_format innodb_change_buffering innodb_buffer_pool_instances 3. アップグレード前に必須の対応事項 FLOAT/DOUBLE列でのAUTO_INCREMENTの廃止 パーティショニングキーでのインデックスプレフィックスの禁止 4. 認証プラグインに関する重要な変更 5. TLSバージョンのサポート変更 6. 廃止された機能とユーティリティ memcachedインターフェース 削除されたユーティリティ その他の削除された機能 7. アップグレード前の事前チェック 8. 推奨される対応手順 変更点のまとめ 【実践1】ローカル開発環境でテスト 目的 手順 結論 【実践2】AWS上でテスト環境構築&QA 目的 手順 結論 【実践3】TerraformでBlue/Green Deployments 目的 手順1:Backupsを有効にする 問題1 解決策1 手順2:engine_version の変更適用 Blue/Green Deployments実行ログ(一部抜粋) まとめ 最後に  こんにちは、開発本部 開発2部 RetailHUB NetSuperグループに所属するホーク🦅アイ👁️です。 背景  昨年に引き続きMySQL8.4についての記事を書くことにしました。続編という位置づけで実際に私の所属チームで運用しているAmazon RDS for MySQL 8.0が2026年7月31日には標準サポート終了予定になってしまうので今年のうちに8.4にアップグレードをすることになったというのが背景です。 tech.every.tv  とはいえ、どうせアップグレードするならただin-place upgradeするより新しい試みをしてみたいと思い、公式DOCを拝読していたらAmazonのサービス機能のひとつとしてフルマネージドなBlue/Green Deployments(2022年12月GA)というものが存在していることに気がついたのでこれを使って簡単low downtimeでアップグレードしたい!となりました。  以降、前半部分で8.0と8.4の違いについて説明します。後半部分で実践して起きた問題点なども踏まえながら話させていただきます。 バージョン8.0と8.4の変更点と対応について  RDS for MySQL 8.0から8.4へのアップグレードにおいて、DBパラメータの設定について確認すべき重要な変更点があります。デフォルトのパラメータ設定のままでも基本的には動作しますが、以下の点を理解し、必要に応じて調整することを推奨します。 1. パラメータグループの作成  メジャーバージョンアップグレードを行う際は、既存のカスタムパラメータグループが新しいバージョンと互換性がない場合があります。MySQL 8.4用の新しいパラメータグループを事前に作成し、アップグレード時に適用することが推奨されます。デフォルトのパラメータグループを使用している場合でも、アップグレード前に8.4用のパラメータグループに切り替えることで、アップグレード時のエラーを防ぐことができます。 参考: Amazon RDS for MySQL のメジャーバージョンアップグレードの概要 2. デフォルト値が変更されたパラメータ  MySQL 8.4では、以下のパラメータのデフォルト値が変更されています。これらの変更が既存のアプリケーションのパフォーマンスや動作に影響を与える可能性があるため、事前に確認することが重要です。 innodb_purge_threads 新デフォルト値 : LEAST({DBInstanceVCPU/2},4) 影響 : InnoDBの履歴リストの長さが大きくなりすぎないように自動調整されます。高負荷環境では、この設定により purge スレッド数が適切に調整されます。 group_replication_exit_state_action 新デフォルト値 : OFFLINE_MODE 影響 : Group Replicationを使用している場合に影響します。通常のRDS for MySQLでは直接的な影響はありません。 binlog_format 新デフォルト値 : ROW 影響 : バイナリログのフォーマットがROW形式になります。レプリケーションを使用している場合、データの一貫性が向上しますが、ログサイズが増加する可能性があります。 innodb_change_buffering 新デフォルト値 : none 旧デフォルト値 : all 影響 : チェンジバッファリングが無効化されます。これにより、セカンダリインデックスへの挿入・更新操作のパフォーマンスに影響を与える可能性があります。 innodb_buffer_pool_instances 新デフォルト値 : innodb_buffer_pool_size とCPU数に基づいて動的に計算 旧デフォルト値 : 8 (または innodb_buffer_pool_size < 1 GiBの場合は 1 ) 影響 : バッファプールインスタンス数が自動的に最適化されます。 参考: MySQL 8.4 What Is New - InnoDB system variable default value changes 3. アップグレード前に必須の対応事項  以下の変更は、アップグレード前に対応しないとアップグレードが失敗する可能性があります。 FLOAT/DOUBLE列でのAUTO_INCREMENTの廃止  MySQL 8.4では、 FLOAT または DOUBLE 型の列に AUTO_INCREMENT を使用することができなくなりました。この組み合わせを使用しているテーブルが存在する場合、 アップグレード前に必ず修正する必要があります 。そうしないとアップグレードが失敗します。 対応方法: -- 既存のAUTO_INCREMENTを使用しているFLOAT/DOUBLE列を確認 SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE EXTRA LIKE ' %auto_increment% ' AND DATA_TYPE IN ( ' float ' , ' double ' ); -- 該当する列をINTやBIGINTに変更 ALTER TABLE table_name MODIFY column_name BIGINT AUTO_INCREMENT; パーティショニングキーでのインデックスプレフィックスの禁止  MySQL 8.4では、パーティショニングキーにインデックスプレフィックス(例: KEY (column_name(10)) )を持つ列を使用できなくなりました。該当するテーブルがある場合は、アップグレード前にパーティショニング定義を変更する必要があります。 参考: MySQL 8.4 What Is New - Features Removed 4. 認証プラグインに関する重要な変更  MySQL 8.0では既に caching_sha2_password がデフォルトの認証プラグインとなっていますが、 MySQL 8.4では mysql_native_password プラグインが無効化 されています。これにより、以下の点を確認する必要があります: 明示的に mysql_native_password を使用しているユーザーアカウントがある場合、アップグレード後に接続できなくなる 既存のユーザーアカウントの認証方式を事前に確認し、必要に応じて caching_sha2_password に変更する アプリケーションが caching_sha2_password 認証方式に対応していることを確認する 参考: MySQL 8.4 What Is New - Native password authentication 5. TLSバージョンのサポート変更  MySQL 8.4では、 TLS 1.2およびTLS 1.3のみがサポート されています。古いTLSバージョン(TLS 1.0、TLS 1.1)を使用しているクライアントは接続できなくなります。アップグレード前に、以下を確認してください: アプリケーションおよびクライアントがTLS 1.2以上に対応しているか SSL/TLS接続設定を使用している場合、接続が正常に行えるかテストする 参考: MySQL 8.4 What Is New - TLS protocol support 6. 廃止された機能とユーティリティ  MySQL 8.4では、以下の機能とユーティリティが廃止されています。 memcachedインターフェース   memcachedインターフェースのサポートが廃止 されています。この機能を使用している場合は、代替手段への移行が必要です。 削除されたユーティリティ 以下のユーティリティがMySQL 8.4で削除されました: mysql_upgrade : MySQL 8.0.16で非推奨となり、8.4で削除されました。アップグレード処理は自動的に実行されます mysqlpump : MySQL 8.0.34で非推奨となり、8.4で削除されました。代わりに mysqldump またはMySQL Shellのダンプユーティリティを使用してください mysql_ssl_rsa_setup : MySQL 8.0.34で非推奨となり、8.4で削除されました。MySQLサーバが起動時に自動的にSSL/RSA証明書を生成します その他の削除された機能 INFORMATION_SCHEMA.TABLESPACES テーブル DROP TABLESPACE と ALTER TABLESPACE の ENGINE 句 LOCK TABLES ... WRITE の LOW_PRIORITY 句 参考: MySQL 8.4 What Is New - Features Removed 7. アップグレード前の事前チェック  Amazon RDSは、アップグレード前に自動的に事前チェックを実行し、非互換性を検出します。事前チェックで問題が検出された場合、アップグレードは自動的にキャンセルされ、 PrePatchCompatibility.log に詳細が記録されます。このログを確認し、必要な修正を行ってから再度アップグレードを試みることができます。 参考: Amazon RDS for MySQL のメジャーバージョンアップグレードの概要 8. 推奨される対応手順  安全にアップグレードを実施するため、以下の手順を推奨します: 破壊的変更の事前確認 : FLOAT/DOUBLE列でのAUTO_INCREMENT使用状況、パーティショニングキーでのインデックスプレフィックス使用状況を確認し、該当する場合は事前に修正 テスト環境での検証 : 本番環境でのアップグレード前に、テスト環境で同様のアップグレードを実施し、アプリケーションの動作を確認 パラメータグループの作成 : MySQL 8.4用の新しいパラメータグループを作成し、必要な設定を反映 スナップショットの取得 : アップグレード前に最新のスナップショットを取得 接続設定の確認 : TLS 1.2以上のサポート、認証プラグインの対応を確認 パフォーマンスモニタリング : アップグレード後、CloudWatch メトリクスやスロークエリログを監視し、パフォーマンスの変化を確認(特に innodb_change_buffering のデフォルト値変更の影響を確認) 変更点のまとめ  RDS for MySQL 8.0から8.4へのアップグレードにおいて、デフォルトのDBパラメータ設定のままでも基本的には動作しますが、以下の点に特に注意が必要です: 必須対応事項 : - FLOAT/DOUBLE列でのAUTO_INCREMENT使用の確認と修正(アップグレード失敗の原因になる) - パーティショニングキーでのインデックスプレフィックスの確認と修正 接続に影響する変更 : - mysql_native_password の無効化(既存ユーザーの認証方式確認が必要) - TLS 1.0/1.1のサポート終了(TLS 1.2以上への対応が必須) パフォーマンスに影響する可能性のある変更 : - innodb_change_buffering のデフォルト値が none に変更 - その他複数のInnoDBパラメータのデフォルト値変更  事前にテスト環境で検証を行い、特に必須対応事項については本番環境のアップグレード前に必ず対応してください。 【実践1】ローカル開発環境でテスト 目的  8.0から8.4への仕様変更に伴うアプリケーションサーバの既存実装の改修要否を知ること 手順  目的を達成するためにローカルPCのDocker上にmysqlコンテナを起動させてアプリケーションコンテナからAPIの動作確認をしました。 1. 8.0のDBバックアップと8.4へのリストア # on MySQL 8.0 $ mysqldump --single-transaction --skip-lock-tables -p -h 127. 0 . 0 . 1 -P 3306 -uappuser -B app_test > app_test_dump20251201.sql # on MySQL 8.4 $ mysql -p -h 127. 0 . 0 . 1 -P 3308 -uappuser < app_test_dump20251201.sql 2. ユーザの作成&権限付与  ローカル環境は、今回8.4用の動作検証を新しく行うために完全新規の接続ユーザを作成したので、CREATE USER文と権限付与を行いました。元々ローンチしたときはMySQL5.7で運用していたので現在のMySQL8.0既存ユーザの認証プラグインも mysql_native_password を設定しているので8.4でもその認証プラグインを使うようにしておけばそれでよいです。 mysql> CREATE USER ' appuser ' @ ' % ' identified WITH mysql_native_password BY ' [パスワード] ' ; + ---------+------+-----------------------+ | user | host | plugin | + ---------+------+-----------------------+ | appuser | % | mysql_native_password | + ---------+------+-----------------------+ 1 row in set ( 0 . 00 sec) mysql> FLUSH PRIVILEGES ; # 権限付与が足りない場合は指定データベースの全テーブルに対して全権限を付与 mysql> SHOW GRANTS FOR `appuser`@`%`; GRANT ALL PRIVILEGES ON `apptest`.* TO `appuser`@`%` 3. API実行  すべてのAPIをリクエスト送信して正常レスポンスが返ってくることを確認しました。 結論  動作確認結果から、MySQL8.4に対して既存実装の改修は不要という結論に至りました。 【実践2】AWS上でテスト環境構築&QA 目的  既存のDB InstanceはTerraform管理下にあるため最終的にはTerraformでBlue/Green Deploymentsしたいのですがそれを実行すると良くも悪くもフルマネージドなためテストする暇もなくapply途中で旧Blue環境が削除されてしまいます。  よって、目的はterraform applyする前に別途新しいDB Instanceを作成してアプリケーションテスト(QA)を実施して問題なく動作すること 手順 コンソールで既存DB InstanceのSnapshot作成 手順1:スナップショット作成 SnapshotをMySQL8.4.7にUpgradeさせる 手順2:スナップショットのアップグレード QA用のParameter GroupをMySQL8.4ベースのもので作成して、設定変更をさせておく UpgradeさせたSnapshotを元にRestore AWS Cloud Mapでリストアした新しいDB InstanceをサービスにRegisterしてCNAMEを紐づけ直す AWS Cloud Mapでサービス登録した既存DB InstanceをDeregister アプリケーションのQA実施 結論  手順4では、userの権限や認証プラグインはそのままリストアされているので問題ありませんでした。AWS上でのQAも正常な結果でしたので問題なく動作することが確認取れました。よって、このままBlue/Green Deploymentsを実施可能という判断になりました。 【実践3】TerraformでBlue/Green Deployments 目的   terraform apply コマンド実行し全自動でmajor version を8.0から8.4にアップグレードすること 手順1:Backupsを有効にする  既存DB Instanceの設定は自動バックアップを無効化にしていました。AWS公式のDocumentsによれば、Blue/Green Deploymentsを実行する際には自動バックアップを有効化にしなければならない(must)という文言の記載がありましたのでまず有効化にすることにしました。 [Preparing an RDS for MySQL or RDS for MariaDB DB instance for a blue/green deployment] Before you create a blue/green deployment for an RDS for MySQL or RDS for MariaDB DB instance, you must enable automated backups. For instructions, see Enabling automated backups. resource "aws_db_instance" "app_db" { engine_version = "8.0" backup_retention_period = 1 blue_green_update { enabled = false } } 問題1 backup_retention_period=1で自動バックアップ保持期間を最短の1日にして一旦有効化しようとしたら、以下のようにapply直後にすぐ差分が出てしまうことが判明しました。 $ terraform apply ... module.rds.aws_db_instance.app_db: Modifications complete after 1m20s [ id = db -XXXXXXXX ] Apply complete ! Resources: 0 added, 1 changed, 0 destroyed. $ terraform plan ... ~ resource " aws_db_instance " " app_db " { ~ backup_retention_period = 0 - > 1 id = " db-XXXXXXXX " tags = {} # (55 unchanged attributes hidden) } Plan: 0 to add, 1 to change, 0 to destroy.  原因は、apply_immediately argument値をtrueにしないと即時反映されないからのようです。つまり、DB Instance再起動を伴う変更ということがわかりました。AWS公式DOCにも以下のような注意書きがありました。 ⚠️Important An outage occurs if you change the backup retention period of a DB instance from 0 to a nonzero value or from a nonzero value to 0. 解決策1  DBインスタンス再起動を実施することによってダウンタイムが発生してしまうというのは避けたかったので強引に自動バックアップ有効化の変更をBlue/Green Deploymentsの対象としてSwitchoverさせればこのダウンタイムが最小化するかもと思い試してみました。方法は、以下の変更を terraform apply 1回で同時適用させることです。 resource "aws_db_instance" "app_db" { engine_version = "8.0" backup_retention_period = 1 blue_green_update { enabled = true } }  結果は、Blue/Green Deployments自体は発動しましたが、Green環境が作成される前にBlue環境のDB Instanceが結局再起動して自動バックアップを有効化にしていたので無駄に終わってしまいました。。したがって、再起動ダウンタイムはどうしても発生してしまうということになりますのでご注意ください。 自動バックアップ有効化実施時のログ 手順2:engine_version の変更適用  以下の変更を terraform apply 実行で適用させました。DBパラメータグループは使用中の8.0用のものを更新してしまうとSnapshot作成やBlue環境で使用するParameter groupsが存在しなくなってエラーを引き起こしそうだったので新規で8.4用のリソースを作成しました。 resource "aws_db_instance" "app_db" { engine_version = "8.4" parameter_group_name = aws_db_parameter_group.mysql_8_4.name backup_retention_period = 1 blue_green_update { enabled = true } } resource "aws_db_parameter_group" "mysql_8_4" { name = "mysql-8-4" family = "mysql8.4" } $ terraform apply ... ... module.rds.aws_db_instance.app_db: Modifications complete after 30m57s [ id = db -XXXXX ] Apply complete ! Resources: 1 added, 1 changed, 0 destroyed.  このフルマネージドな実行時間31分の間にどのようなことが行われているのかを知るためにコンソール上でUIの変更やLogs&eventsタブのログがどのような出力が出るかなどを監視していました。 BlueがModifyingのとき(Backupを作成中)、接続は成功していました Blue,GreenどちらもAvailableになるのは18分30秒くらいでした Switchover開始前にNew Blueインスタンスの方のイベントログにTerminate connectionと表示されて terraform apply 実行前から敢えてDBに接続し続けていたセッションが切れていることを確認取れています。レプリケーション整合性の担保のための挙動のようです。その後すぐに新規接続を試みれば成功はしました。おそらくこの瞬間が公式DOCにある The switchover typically takes under a minute with no data loss and no need for application changes. に相当する時間なのかと思われます。 $ aws ssm start-session --target ecs: < cluster_name > _ < task_id > _ < Container runtime ID > --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters ' {"host":["<endpoint_name>"],"portNumber":["3306"], "localPortNumber":["3306"]} ' Connection accepted for session [ XXXXX ] Connection to destination port failed, check SSM Agent logs. $ aws ssm start-session --target ecs: < cluster_name > _ < task_id > _ < Container runtime ID > --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters ' {"host":["<endpoint_name>"],"portNumber":["3306"], "localPortNumber":["3306"]} ' Connection accepted for session [ xxxxx ] 元々Deletion protectionがEnabledの設定をしていたのですが、旧Blue環境のDB Instance自動削除は無事に成功していました! Blue/Green Deployments実行ログ(一部抜粋) リソース名 タイプ 日時 イベント内容 bgd-9h8u8mpxqbfcl8ho Blue/green deployment November 20, 2025, 19:02 (UTC+09:00) Your blue/green deployment bgd-9h8u8mpxqbfcl8ho will create a read replica of app_db with storage type gp3, and allocated storage 20. app_db (New Blue) Primary November 20, 2025, 19:04 (UTC+09:00) Backing up DB instance app_db (New Blue) Primary November 20, 2025, 19:06 (UTC+09:00) Finished DB Instance backup bgd-9h8u8mpxqbfcl8ho Blue/green deployment November 20, 2025, 19:24 (UTC+09:00) Blue/green deployment tasks completed. You can make more modifications to the green environment databases or switch over the deployment. bgd-9h8u8mpxqbfcl8ho Blue/green deployment November 20, 2025, 19:29 (UTC+09:00) Switchover started on blue/green deployment app_db. app_db (New Blue) Primary November 20, 2025, 19:29 (UTC+09:00) Switchover from primary app_db to app_db-green-lqjrxz started. app_db (New Blue) Primary November 20, 2025, 19:29 (UTC+09:00) The primary app_db-green-lqjrxz environment is now accepting read and write operations at the database level. The write downtime during the switchover lasted approximately 2 seconds. DNS propagation might take additional time to complete. まとめ  本記事では、Amazon RDS for MySQL 8.4へアップグレードするための方法としてフルマネージドBlue/Green DeploymentsをTerraformから実行したときのベストプラクティスを紹介いたしました。既存運用中のDB Instanceが元から自動バックアップ有効化にしている場合にはダウンタイムは何も気にせずに完全自動化に任せて簡単にアップグレードができることがわかりました(2026年1月20日にダウンタイムが 5 秒未満に短縮されたそうです!: 詳しくはこちら )。Switchover時に発生した接続断の許容ができるレベルのサービスであればメンテナンス中の作業にするまでもなくオンラインで実施可能という実感でした。 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
アバター
この記事は every Tech Blog Advent Calendar 2025 の 4 日目の記事です。 開発1部でデリッシュキッチンのバックエンドをメインに担当している塚田です。 はじめに 弊社では デリッシュリサーチ というサービスのビジュアライズにAWSが提供するQuickSightを活用していました。 AWSが先日発表した「QuickSuite」は、生成AIで開発・業務・運用の作業をまとめて手助けし、仕事の効率を上げるためのツール集です。 本記事では、従来の関連サービスからの変更点、実運用での活用を想定したパターンを検討したのでその内容を記事にさせていただきます。 QuickSuiteの位置づけと構成の全体像 主なできること 開発を楽にする: コード提案、リファクタ、テスト作成、PR作成/説明 調べ物を速くする: 社内ドキュメントやデータを安全に検索・要約 定型作業を自動化する: チケット対応や運用手順を手順どおり支援 セキュリティと管理 社内データについては権限に合わせて検索・利用 QuickSightのからの主な変更点 開発体験の統合や企業内ナレッジ活用 既存のファイルストア、ナレッジベース、データウェアハウス(DWH)に対するRAGの組込み 検索・回答のガードレール、出典提示、ポリシー準拠/監査性の強化 代表ユースケース データ分析のセルフサービス化 例: 自然言語から各種データソース向けSQL生成、ダッシュボード雛形まで自動作成。データガバナンスに沿ったアクセス制御 異常値が発生した際の対応の高速化 例: アラートから発火→メトリクス/ログ/トレースを収集→暫定原因と影響範囲を生成→対応策の検討 サポート対応の効率化 例: 顧客問い合わせをチケット化、類似事例/FAQ/手順を提案、回答案と影響範囲の注意点を自動添付 アーキテクチャ・パターン セキュアRAG基盤 企業データソース(DWHやオブジェクトストレージなど)→ インデクシング/埋め込み → ポリシー付き検索 → 生成 ポイント: VPCエンドポイント、KMS暗号化、監査(CloudTrail/CloudWatch)、PIIマスキング 開発ワークフロー統合 GitHub/Jira/CI/CD と連携した「チケット駆動の自動修正→PR→レビュー補助」 変更影響分析(関数/モジュール依存、テストカバレッジ)を組み込み 導入ステップ 新規で導入する場合を考えた時に以下のようなフローや対応が必要に感じました。 対象業務の選定 「効果が定量化しやすい/安全に実験できる」ユースケースを検討(例: ドキュメントQA、テスト生成、自動要約) データ基盤の整備 データカタログ、スキーマ管理、アクセス境界、メタデータの整備 セキュリティ/ガバナンス設計 IAMロール/ポリシー、監査ログ、プロンプトや出力内容の検討 PoC設計と評価指標 品質(正答率/再現性)、効率、安全性 段階的ロールアウト 先行チーム→横展開。トレーニングとプロンプトスタイルガイドの整備 運用・継続改善 フィードバックループ(曖昧質問の定義、ナレッジの鮮度管理)、コスト監視 コスト ユーザー課金という部分は変わらずユーザー単価の変更 コスト予想が行いやすく導入後のイメージが見通せる リスク 生成AIを活用しているため必ずしも正しい内容を提示できるわけではない ガードレールをプロダクトとして提示する必要あり データ漏洩や必要以上の回答をする可能性がある /越権参照: 厳格なIAM・ネットワーク分離・プロンプト抑止語(秘密、顧客情報)・DLP バージョン差異の発生で想定していない出力が発生する可能性 以前のAWS関連サービスとの比較 従来 社内で開発・運用しているQuickSightなどのBIツールのみでの分析や評価を実施 生成AIのRAGやエージェント構築は設計と統合作業が重く、運用ガードレールを自作 QuickSuite 開発/検索/自動化を「業務フロー」として束ね、エンドツーエンドに管理・監査 エージェントの実行制御をあらかじめ検討可能 まとめ 弊社ではデータ基盤としてDatabricksを活用しており、社内へ展開するメリットはあまり感じられませんでした。 ただ、すでにプロダクトでQuickSightを利用している場合、QuickSightからあったBIの導入のしやすさに加え、AI(エージェント)の導入・統合がしやすくなったと感じています。 今までBIとAI関連のプロダクトをシームレスに提供することに課題を感じていたためこのような機能を活用しながらユーザーが活用しやすいプロダクトとして活用していきたいと感じました。
アバター
Swift Observationフレームワークの利点と動作 この記事は every Tech Blog Advent Calendar 2025 の 3日目の記事です。 こんにちは、デリッシュキッチンでiOSエンジニアをしている谷口恭一です。 デリッシュキッチンのiOSでは現在、状態の変更通知の仕組みとして主にCombineを使用しています。最低互換のiOSバージョンをiOS16としているため、まだObservationフレームワークの導入はできていません。 しかし、おそらく来年にはiOS17+になると予想され、SwiftUIではObservationを使用したモダンな状態管理システムで新規画面は開発するかもしれません。 AppleはObservationフレームワークを、 ObservableObject から非常に簡単かつ安全に移行できるように設計しているため、既存画面改修などの際には、少しずつObservationに移行していく可能性もあります。 よって、Observation自体の仕組みや、他の状態の変更通知システムに対する利点をある程度は理解する事が重要であると考えています。 そこで、フレームワークの調査や学習を行い、そのアウトプットとしてObservationについて解説していくというのがこの記事の目的です。 まず、Observationフレームワークとは何かについて説明し、既存のCombineの ObservableObject を用いた方法との違いと利点を説明し、最後にObservation自体の動作の概要について説明します。 Observationフレームワークとは Appleによると、Observationは以下の3つの機能を提供するフレームワークです。 Marking a type as observable( 型を観測可能にする ) Tracking changes within an instance of an observable type( 観測可能な型のインスタンス内の変更を追跡する ) Observing and utilizing those changes elsewhere, such as in an app’s user interface( アプリのUIのようなどのような場所からもこれらの変更を観測し活用できる ) https://developer.apple.com/documentation/Observation よって、値の変更を観測可能にする機能と、それを通知する機能であると言えます。SwiftUI側はObservationの機能を内部的に利用して、値の変更をUI再描画に活用しています。 しかしObservationの値の変更の活用自体はSwiftUIのスコープに限らず、どのような場所からもできるという汎用的な機能であることがわかります。 とはいえ、大部分のユースケースはSwiftUIのUIシステムに対するデータバインディングだと思います。 例えば、以下のようなSwiftUIの画面実装で役に立ちます import SwiftUI @Observable class Car { var name : String = "" var needsRepairs : Bool = false } struct ContentView : View { @State private var car1 = Car() var body : some View { VStack(spacing : 20 ) { Text(car1.name.isEmpty ? "No name" : car1.name ) TextField( "Name" , text : $car1 .name) .textFieldStyle(.roundedBorder) Toggle( "Needs repairs" , isOn : $car1 .needsRepairs) } .padding() } } #Preview { ContentView() } この実装では、車の名前と、修理が必要かどうかという2つの値がユーザのインタラクションによって変更される可能性があり、それらの変更をUI上に即座に反映させたいという要件があります。 このようなときに、監視対象のクラスに対して @Observable マクロを付けると、そのクラスのプロパティの値をそれぞれ監視/通知し、SwiftUI側が変更通知によって自動でUI更新までやってくれるというのが大まかな機能です。監視対象のクラスは Observable プロトコルに適合されます。 なお、 @State はViewのライフサイクルでインスタンスを破棄せず、この値がView階層内でSSoT(Single Source of Truth、信頼できる唯一の情報源)な値となるために引き続き付ける必要があります。しかし、このプロパティラッパーは、値の監視やObservationの機能とは全く異なるものです。 ここで、 Observable プロトコルと、 @Observable マクロという概念が登場しました Appleによると、 Observable は以下のようなプロトコルです。 A type that emits notifications to observers when underlying data changes. 内在するデータが変更されたときにオブザーバーに通知を送信する型 https://developer.apple.com/documentation/observation/observable 内在するデータとは、このプロトコルに準拠した型のプロパティのことだと思われます。このプロトコルは単にそれが監視ができるということを表すだけであるということに注意が必要です 次に、 @Observable マクロについては以下のような説明があります。 Defines and implements conformance of the Observable protocol. Observable プロトコルの準拠を定義および実装します。 https://developer.apple.com/documentation/observation/observable() よってこのマクロでは、2つのことを行っていることがわかります マクロを付けた型を Observable プロトコルに準拠させる マクロを付けた型に対して、 Observable プロトコルの準拠のための実装をする ここでのプロトコルは、概念や型を抽象化、共通化するような目的のプロトコルの使い方とは若干異なるような気がしました。プロトコルを特定の制約の明示に使っていて、その実装などはマクロのコード追加が担うというコンセプトのようです。 Observationフレームワークが提供する機能としてはこれでほぼすべてになります。非常にシンプルであることがわかります。 SwiftUI側は、 Observable に適合した型に対してUI更新などの特別な操作を内部的に仕込んでおくことによって、その変更を活用することができるという仕組みになっています。 ObservableObjectとの違いについて 現在でもSwiftUIでの状態管理にはCombineの ObservableObject プロトコルと @StateObject プロパティラッパーがよく使われていて、Observationによる実装はこちらとほぼ同等な機能を提供していると思います。 ではなぜObservationが必要なのでしょうか?また、 ObservableObject に対して、Observationフレームワークはどのような利点があるのでしょうか?私は、主に以下の4つあると考えています。 監視したい型のプロパティそれぞれに対して監視が行える ネストした型の子要素も監視できる SwiftUIのView階層で、監視したい値を伝播させる必要がない 監視対象の各プロパティに対して、監視することを明示する必要がない まず1つ目が、監視したい型のプロパティそれぞれに対して監視が行えるという点です。 ObservableObject の場合、このプロトコルに適合したクラスの @Published なプロパティの変更によって、そのクラスを使用するUI要素すべてが再描画されてしまうという問題がありました。しかし、監視対象のプロパティがたくさんある場合、そのプロパティに関係があるUI要素のみを再描画するほうが理にかなっています。Observationはこのような要件を満たすことができています。 次に、監視対象の型のネストについてです。 ObservableObject に適合したクラスの @Published なプロパティを ObservableObject に準拠させて監視しようとしても、ネストされた子オブジェクトの変更は自動では通知されません。これは単純な理由で、状態の変更通知はView側で ObservableObject プロトコルの objectWillChange() というメソッドによって行われるため、ネストした子オブジェクトの objectWillChange() は呼ばれないから通知されないという訳です。 よって、子オブジェクトの objectWillChange() が呼ばれると、親オブジェクトの objectWillChange() を連鎖的に呼ぶような実装を明示的にすればこの問題は解決できますが、クリーンな方法とは言えなそうです。 Observationでは、単にアクセスした値を自動的に監視するというコンセプトのため、その値を保持するデータ構造に依存しません。よって、ネストされていても問題なく変更通知を受け取ることができます。 3つめにSwiftUI上での値の伝播についてです。 SwiftUIでは、 @StateObject や @State で定義したViewのスコープのライフサイクルにおいて、この値の監視とUI更新を行います。よって、子Viewでの値の変更には対応していません。よって、 @Binding というプロパティラッパーを使用して、子Viewと値を共有するという仕組みを提供しています。よって、末端の子Viewで値が変更されると、その子Viewを含むView階層全体を更新します。データの伝播が仕組みとして複雑であるという点と、1つ目の問題と同様にパフォーマンス的な問題があります。しかし、Observationフレームワークでは、変更対象の値を持つ各Viewそれぞれが値を監視するというシンプルな仕組みのため、値の伝播のための複雑な機能を必要としません。 最後に、 ObservableObject の実装では、監視対象のプロパティに @Published というプロパティラッパーを付与する必要があるという点です。SwiftUIで使うクラス内のミュータブルな値は、基本的に監視したいというユースケースなはずです。よって、監視するという状況をデフォルトにして、監視したくないときにそれを明示するほうが自然であるという考え方もできると思います。Observationはこのような方法を取るため、よりスッキリとしたコードを書くことができます。これは書き心地の問題なので、これまでのものより些細な問題かもしれません。 Observationフレームワークの動作の概要 それではObservationフレームワークが提供する Observable マクロはどのような実装をしているのでしょうか?Swiftのマクロは展開することができるため、実装を確認することができます。実装を見れば大体何をしていそうかが推測できると思います。Xcode上ではマクロを表す部分( @Observable )を右クリックすると、Expand Macrosという選択肢が出てきます。 @Observable class Car : Observable { var name : String = "" var needsRepairs : Bool = false } この例のようなクラスのマクロをXcodeを使って展開すると以下のようになります。 @Observable class Car : Observable { @ObservationTracked var name : String = "" { @storageRestrictions ( initializes: _name ) init (initialValue) { _name = initialValue } get { access(keyPath : \.name) return _name } set { withMutation (keyPath : \.name) { _name = newValue } } _modify { access(keyPath : \.name) _ $observationRegistrar . willSet ( self , keyPath : \.name) defer { _ $observationRegistrar . didSet ( self , keyPath : \.name) } yield & _name } } @ObservationIgnored private var _name : String = "" @ObservationTracked var needsRepairs : Bool = false { @storageRestrictions ( initializes: _needsRepairs ) init (initialValue) { _needsRepairs = initialValue } get { access (keyPath : \.needsRepairs) return _needsRepairs } set { withMutation (keyPath : \. needsRepairs) { _needsRepairs = newValue } } _modify { access (keyPath : \.needsRepairs) _ $observationRegistrar willSet ( self , keyPath : \.needsRepairs) defer { _ $observationRegistrar didSet ( self , keyPath : \.needsRepairs) } yield & _needsRepairs } } @ObservationIgnored private var _needsRepairs : Bool = false @ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar() internal nonisolated func access < Member > ( keyPath : KeyPath < Car , Member > { _ $observationRegistrar .access( self , keyPath : keyPath ) } internal nonisolated func withMutation < Member , MutationResult > ( keyPath : KeyPath < Car , Member > , _ mutation : () throws -> MutationResult ) rethrows -> MutationResult { try _ $observationRegistrar .withMutation(of : self , keyPath : keyPath , mutation) } } まず、Observationフレームワークの登場人物は以下になります ObservationRegistrar : どこでどの @ObservationTracked なプロパティを見ているかを保持する登録簿のようなものを管理する責務を持つ access(keyPath:) : プロパティが読まれたことをレジストラに伝える withMutation(keyPath:, mutation: ) : 値の変更を監視者全体に伝える @Observable マクロを付けると、定義したプロパティはすべて計算プロパティになります。SwiftのAttached Macroはコードの変更や削除はできず、追加することしかできないという制約があります。これはコードの安全性を保つためです。しかし、元の変数を _name のように別の変数によけて、本来の変数を計算プロパティにすると言う方法はAttached Macroのコンセプトの割には結構技巧的だなと個人的には思いました。 これによって、定義したプロパティの get や set で特定の処理を行うことができるようになっています。マクロを使用する部分の本質はここだと思っています。ここでしたい処理の殆どは ObservationRegistrar という値の監視/通知を司る構造体経由で行っていることがわかります。 ObservationRegistrar の access というメソッドでは監視対象の登録、 withMutation では変更の全体通知のような役割を持っています。 また、 ObservationRegistrar の定義の末尾に以下のようなグローバル関数が定義されています。 public func withObservationTracking < T > ( _ apply : () -> T , onChange : @autoclosure () -> @Sendable () -> Void ) -> T この関数は、 apply 内でレジストラに登録されたプロパティの変更を検出すると、 onChange 内のクロージャが実行されるというシンプルな関数です。 Appleのドキュメントでは以下のようなコード例が示されていました。 func render () { withObservationTracking { for car in cars { print(car.name) } } onChange : { print( "Schedule renderer." ) } } 一見するとこの関数は不思議に思うかもしれません。なぜなら、 apply 内では単に print 文を呼んでいるだけで、意味のないコードに見えるからです。しかし、 Observable マクロの仕組みを見るとわかるように、プロパティの get で access が呼ばれないと、監視対象に追加されないという設計のため、このプロパティを1度でも参照する必要があるということです。 SwiftUIのViewのbodyでは、このメソッドにUI再描画の必要があるプロパティを渡して監視する機能が内部で実装されているのであろうということが推測できます。これによって、必要なViewのスコープで必要なプロパティだけを監視して部分的に再描画するということが可能になっている技術的な理由がわかると思います。 まとめ Observationは以下の機能を提供するシンプルなフレームワークである 型を観測可能にする 観測可能な型のインスタンス内の変更を追跡する アプリのUIのような、どのような場所からもこれらの変更を観測し活用できる SwiftUIでの状態管理では、ObservationフレームワークはCombineの ObservableObject に対していくつかのメリットがある Observationフレームワークは、値の監視機能をマクロを使って実装されていて、 ObservationRegistrar 経由で監視と通知が行われている SwiftUIにおいてObservationフレームワークを使用したシンプルな状態管理はiOSアプリ開発におけるデファクトスタンダードになると私は思います。もちろんCombineなどを使用するべきユースケースもあると思います。デリッシュキッチンの最低互換バージョンが上がったら、実際に移行してみて試してみたいと思います。
アバター
はじめに 導入背景 バックエンドで直面した課題 RevenueCat の魅力 Webhook によるイベント通知 ダッシュボード A/B テスト基盤がある 実際に使って感じたメリット 工夫した点 サブスクリプションの有効期限が切れているにも関わらずプレミアムステータスのままのユーザーがいればステータスを切り替えるバッチを作成 まとめ 参考 この記事は every Tech Blog Advent Calendar 2025 の 2 日目の記事です。 はじめに こんにちは、トモニテで開発を担当している吉田です。 今年 6 月末、弊社サービス「トモニテ」でサブスクリプションサービスのトモニテプレミアムサービスをリリースしました(iOS のみ、2025/11/28 時点)。サブスクリプション機能を実装するにあたり、バックエンド側でレシート検証やステータス管理の実装に多くの工数がかかることが課題でした。 そこで、サブスクリプション管理に RevenueCat を導入しました。本記事では、バックエンドエンジニアの視点から、RevenueCat を運用してみて感じたことについて紹介します。 導入背景 バックエンドで直面した課題 iOS, Android で異なるところもありますがアプリからの購読登録のざっくりフローは以下の通りです。 アプリからストアへ購読の決済要求 ストアからレシートの発行 レシートをサーバーで検証 検証後アプリでユーザーのステータス更新 主にサーバー側で対応する必要があるのは 3,4 になります。 レシートについては Apple(App Store)と Google(Google Play Store)で形式や記載内容が異なるためそれぞれのプラットフォーム用に処理を用意する必要があります。 またトモニテプレミアムはサブスクリプションサービスなのでユーザーのサブスクリプションステータスの変更をモニタリングしステータスの変更処理もする必要があります。 両プラットフォームでサーバー通知があり、iOS では App Store Server Notifications を、Android では Real-time Developer Notifications(RTDN) を利用して変更を通知できます。 このサーバー通知を受け取るために iOS では ATS プロトコルを使用してサーバーと安全なネットワーク接続を確立する必要があります。Android は Google Cloud Pub/Sub が使用されるためそのセットアップが必要になります。(Pub/Sub に関しては必須ではありませんが、利用しない場合には Google Play Developer API をポーリングする必要があります) 加えて通知で受け取った情報と最新のレシートを照らし合わせてサブスクリプションステータスを検証する処理を準備する必要があり、それも両プラットフォーム用に作成する必要があります。 まとめると以下の対応を ×2 ずつする必要があります。(もし今後 web からの課金が増える場合にはその対応も増えることになる) 購読開始時のレシート検証 サブスクリプションのサーバー通知対応 1,2 の各種メンテナンス 上記経緯からレシート検証の自動化が実現できて直感的なダッシュボードが提供され、分析機能も充実している RevenueCat を導入することにしました。 RevenueCat の魅力 RevenueCat にはたくさんの機能が提供されていますが、個人的には以下が特にいいなと思っています。 Webhook によるイベント通知 RevenueCat にもプラットフォームと同様にサブスクリプションイベントに関するリアルタイム通知が存在します。イベント発生のたびに指定したエンドポイントにデータが送信されます。弊社では送られたデータをいくつかピックアップし自社サーバーに保存して社内の管理画面でユーザーのステータスを確認できるようにしています。また Webhook を利用しサブスクリプションのライフサイクルに基づいてワークフローを起動できたり、発生したイベントに対してユーザーに通知を実行することもできます(解約操作をしたユーザーにサブスクリプションサービスの魅力を提示するなど)。 また送信に失敗(サーバーからステータスコード 200 が返らなかった)しても実行間隔を延ばしながら 5 回リトライしてくれます。 実際に受け取ったリクエストボディは以下になります。(sandbox 環境下、2025/11/17 時点) app_user_id は独自に設定しています { " api_version ": " 1.0 ", " event ": { " aliases ": [ " $RCAnonymousID:<RevenueCatが生成したランダムなアプリユーザーID> ", " <customed_app_user_id> " ] , " app_id ": " <app_id> ", " app_user_id ": " <customed_app_user_id> ", " commission_percentage ": 0.1364 , " country_code ": " JP ", " currency ": " JPY ", " entitlement_id ": null , " entitlement_ids ": [ " Premium " ] , " environment ": " SANDBOX ", " event_timestamp_ms ": 1763356620218 , " expiration_at_ms ": 1763357148000 , " id ": " <id> ", " is_family_share ": false , " is_trial_conversion ": false , " metadata ": null , " offer_code ": null , " original_app_user_id ": " <customed_app_user_id> ", " original_transaction_id ": " <original_transaction_id> ", " period_type ": " NORMAL ", " presented_offering_id ": " paywall_v2 ", " price ": 41.399 , " price_in_purchased_currency ": 6400 , " product_id ": " PlanAnnually ", " purchased_at_ms ": 1763353548000 , " renewal_number ": 20 , " store ": " APP_STORE ", " subscriber_attributes ": { " $adjustId ": { " updated_at_ms ": 1750735215184 , " value ": " <adjustId> " } , " $attConsentStatus ": { " updated_at_ms ": 1749721029062 , " value ": " denied " } , " $deviceVersion ": { " updated_at_ms ": 1760408728747 , " value ": " iPhone12,1-iOS-Version 18.5 (Build 22F76) " } , " $firebaseAppInstanceId ": { " updated_at_ms ": 1762771906755 , " value ": " <firebaseAppInstanceId> " } , " $idfa ": { " updated_at_ms ": 1750735215021 , " value ": " <idfa> " } , " $idfv ": { " updated_at_ms ": 1750735215021 , " value ": " <idfv> " } , " $ip ": { " updated_at_ms ": 1763027995620 , " value ": " <ip_address> " } , " route ": { " updated_at_ms ": 1750323201312 , " value ": " mypage " } } , " takehome_percentage ": 0.85 , " tax_percentage ": 0.0909 , " transaction_id ": " <transaction_id> ", " type ": " RENEWAL " } } ダッシュボード RevenueCat のダッシュボードでは iOS, Android それぞれのデータを一元管理できる他、アプリの収益も確認できます。 Charts & Metrics セクションでは、収益、サブスクリプション、コホートと LTV、コンバージョンファネル、トライアル、解約と返金、未完了期間などの概要メトリクスを確認できます。またコード不要で後述の A/B テストの管理もできます。 A/B テスト基盤がある RevenueCat Experiments というモバイルサブスクリプションアプリビジネスの価格設定とペイウォール設計の最適化に特化した A/B テスト機能があります。こちらは offering(ペイウォールで提示する製品、トライアル期間、価格などのパッケージのこと)を定義し、新規ユーザーに対しコントロール群とトリートメント群の割り当てを行いユーザーに割り当てられたグループに応じたペイウォールが表示されます。出し分けるだけでなく実験開始から 24 時間以内に実験結果を確認することができます。 実際に使って感じたメリット 使ってみて、開発工数の削減が最も大きなメリットだと感じました。レシート検証やステータス管理の実装が不要になったため、プレミアムサービスの機能実装により多くの時間を割くことができました。 工夫した点 サブスクリプションの有効期限が切れているにも関わらずプレミアムステータスのままのユーザーがいればステータスを切り替えるバッチを作成 Webhook はリトライを 5 回までしてくれると前述しましたがそれでも絶対にデータを取りこぼすことがないとは言い切れません。もし定期購入していないにも関わらずプレミアムステータスのままのユーザーがいればそれは弊社の売り上げ棄損にも繋がります。 RevenueCat が提供している API ではサブスクリプションの有効期限切れのユーザーを洗い出すような機能は提供されていなかったため、自社サーバーに保存しているデータに対して有効期限が切れているにも関わらずプレミアムステータスのままのユーザーがいればステータスを切り替えるバッチを作成しました。 また Webhook は基本的には イベント後5~60 秒の間に通知がされるようになっていますがキャンセルイベントは最大 2 時間の遅延が発生するとなっていたのでバッチではこちらを考慮するようにしています。 遅延について Most webhooks are usually delivered within 5 to 60 seconds of the event occurring - cancellation events usually are delivered within 2hrs of the user cancelling their subscription. You should be aware of these delivery times when designing your app. まとめ RevenueCat を導入することで、サブスクリプション管理に関する開発工数を大幅に削減できました。これにより、プレミアムユーザーサービスの機能実装により多くの時間を割くことができ、6 月末のリリースに間に合わせることができました。RevenueCat がなければ、このタイミングでのリリースは難しかったと感じています。 導入から 5 ヶ月ほど経過していますが、RevenueCat 起因の問題は発生しておらず、安定して運用できています。 同様の課題を抱えている方の参考になれば幸いです。 参考 www.revenuecat.com developer.apple.com developer.android.com
アバター
目次 はじめに Go でのエラー構造 再帰的エラーハンドリング エラーハンドリングのパターン errors.As で値取り出してチェック errors.Is で値の一致 Go1.26 で追加予定の errors.AsType まとめ この記事は every Tech Blog Advent Calendar 2025 の 1 日目の記事です。 はじめに こんにちは、開発本部開発 1 部トモニテグループのエンジニアの rymiyamoto です。アドベントカレンダートップバッターを務めさせていただきます! 今回はまだ時期尚早ですが Go1.26 で errors.AsType が導入されることが予定されており、それに伴うエラーの扱いについて振り返ってみたいと思います。 tip.golang.org ※ この記事は執筆時点で最新の Go1.25.4 をベースに書いています。 Go でのエラー構造 Go のエラーは単なる Error メソッドを持つだけのインターフェースです。 このインターフェースを担保した型は error 型として扱うことができます。 // The error built-in interface type is the conventional interface for // representing an error condition, with the nil value representing no error. type error interface { Error() string } github.com この状態でのエラーでは単純に Error メソッドを呼び出して文字列を取得するだけです。そのため、エラーの種類を識別するために文字列の比較を行うことになってしまいます。またそのままエラー同士の比較もできますが、これはエラーの値が完全に一致しない限り false になってしまいます。 そのため Go1.13 のタイミングで再帰的エラーハンドリングが導入されました。 go.dev 再帰的エラーハンドリング 発生したエラーに対して新たな情報を追加し、エラーチェーンを構築するアプローチです。この手法により、エラーが発生した元のコンテキストから、そのエラーをキャッチして処理した箇所までの全体像を把握することが可能になります。 実際に fmt.Errorf で %w 返しているエラーの型は以下のようになっています。 type wrapError struct { msg string // 全体のエラーメッセージ err error // ラップ元のエラー } func (e *wrapError) Error() string { return e.msg } func (e *wrapError) Unwrap() error { return e.err } github.com これによりラップする前のエラーを Unwrap メソッドで取得することができ、階層的な構造になっていてもエラーを辿ることができます。 エラーハンドリングのパターン errors.As で値取り出してチェック errors.As の実装は以下のようになっており、処理の流れをまとめるとこのようになります。 import ( "internal/reflectlite" ) func As(err error , target any) bool { if err == nil { return false } if target == nil { panic ( "errors: target cannot be nil" ) } val := reflectlite.ValueOf(target) typ := val.Type() if typ.Kind() != reflectlite.Ptr || val.IsNil() { panic ( "errors: target must be a non-nil pointer" ) } targetType := typ.Elem() if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) { panic ( "errors: *target must be interface or implement error" ) } return as(err, target, val, targetType) } func as(err error , target any, targetVal reflectlite.Value, targetType reflectlite.Type) bool { for { // 現在のエラー値が、ターゲットの型に代入可能かをチェック if reflectlite.TypeOf(err).AssignableTo(targetType) { targetVal.Elem().Set(reflectlite.ValueOf(err)) return true } // エラーが独自の As メソッドを実装している場合、それを呼び出してチェック if x, ok := err.( interface { As(any) bool }); ok && x.As(target) { return true } // エラーチェーンを辿る: Unwrap() メソッドでラップされた下位のエラーを取得 switch x := err.( type ) { case interface { Unwrap() error }: // 単一のエラーをラップしている場合: アンラップして次のループで再チェック err = x.Unwrap() if err == nil { return false } case interface { Unwrap() [] error }: // 複数のエラーをラップしている場合: それぞれに対して再帰的にチェック for _, err := range x.Unwrap() { if err == nil { continue } if as(err, target, targetVal, targetType) { return true } } return false default : return false } } } var errorType = reflectlite.TypeOf((* error )( nil )).Elem() github.com これにより比較対象のエラーの型に代入可能かをチェックし、可能であればそのエラーの値を取得することができます。 var mysqlErr *mysql.MySQLError if errors.As(err, &mysqlErr) { fmt.Println( "MySQL error occurred:" , mysqlErr.Number) } 細かいですが errors.As のターゲットは必ずポインタである必要があります。これは、Go が関数の引数を値渡しするため、エラーチェーン内の見つかったエラーをターゲット変数に実際に書き込む(代入する)ためには、呼び出し元で定義した変数のメモリアドレス(ポインタ)を渡す必要があるためです。 errors.Is で値の一致 errors.Is の実装は以下のようになっており、基本の流れは errors.As と同じですが、比較対象のエラーの型に代入可能かをチェックする代わりに、現在のエラー値が、比較対象のターゲットエラー(target)と厳密に等しいか(err == target)をチェックします。 func Is(err, target error ) bool { if err == nil || target == nil { return err == target } isComparable := reflectlite.TypeOf(target).Comparable() return is(err, target, isComparable) } func is(err, target error , targetComparable bool ) bool { for { // 現在のエラー値が、ターゲットエラーと厳密に等しいか(err == target)をチェック if targetComparable && err == target { return true } // エラーが独自の Is メソッドを実装している場合、それを呼び出してチェック if x, ok := err.( interface { Is( error ) bool }); ok && x.Is(target) { return true } // エラーチェーンを辿る: Unwrap() メソッドでラップされた下位のエラーを取得 switch x := err.( type ) { case interface { Unwrap() error }: // 単一のエラーをラップしている場合: アンラップして次のループで再チェック err = x.Unwrap() if err == nil { return false } case interface { Unwrap() [] error }: // 複数のエラーをラップしている場合: それぞれに対して再帰的にチェック for _, err := range x.Unwrap() { if is(err, target, targetComparable) { return true } } return false default : return false } } } github.com これにより比較対象のエラーと厳密に等しいか(含んでいるか)をチェックし、等しい場合は true を返します。 var ErrNotFound = errors.New( "not found" ) if errors.Is(err, ErrNotFound) { fmt.Println( "not found" ) } Go1.26 で追加予定の errors.AsType これまで errors.As では都度エラーを代入するためのポインタ変数を定義する必要がありましたが、Go1.26 では errors.AsType が追加されることで、エラーを代入するための変数を定義する必要がなくなります。呼び出し方からわかるように Go 1.18 で導入されたジェネリックを活用しています。 // ~ Go1.25 func FindMysqlErrorCode(err error ) ( bool , uint16 ) { var mysqlErr *mysql.MySQLError if errors.As(err, &mysqlErr) { return true , mysqlErr.Number } return false , 0 } // Go1.26 ~ func FindMysqlErrorCode(err error ) ( bool , uint16 ) { if mysqlErr, ok := errors.AsType[*mysql.MySQLError](err); ok { return true , mysqlErr.Number } return false , 0 } 実装の方も基本はこれまでの errors.As と同様になっており、処理の流れはこのようになります。 func AsType[E error ](err error ) (E, bool ) { if err == nil { var zero E return zero, false } var pe *E // lazily initialized return asType(err, &pe) } func asType[E error ](err error , ppe **E) (_ E, _ bool ) { for { // 現在のエラー値が、型パラメータ E の型に一致するかをチェック if e, ok := err.(E); ok { return e, true } // エラーが独自の As メソッドを実装している場合、それを呼び出してチェック if x, ok := err.( interface { As(any) bool }); ok { if *ppe == nil { *ppe = new (E) } if x.As(*ppe) { return **ppe, true } } // エラーチェーンを辿る: Unwrap() メソッドでラップされた下位のエラーを取得 switch x := err.( type ) { case interface { Unwrap() error }: // 単一のエラーをラップしている場合: アンラップして次のループで再チェック err = x.Unwrap() if err == nil { return } case interface { Unwrap() [] error }: // 複数のエラーをラップしている場合: それぞれに対して再帰的にチェック for _, err := range x.Unwrap() { if err == nil { continue } if x, ok := asType(err, ppe); ok { return x, true } } return default : return } } } github.com まとめ error を普段から使うことは多かったですが、改めて実装の中身を除いてみると、開発者がエラーの抽出の仕方をあまり意識しなくても済むようになっていることがわかりました。 Go1.26 では errors.AsType が追加されることで、エラーを代入するための変数を定義する必要がなくなります。これによりエラーの抽出の仕方をより簡潔に、柔軟にすることができると思います。 今後とも Go の進化に食らいついていきながら、より良いエラーハンドリングを実現していきたいと思います。
アバター
目次 はじめに every Tech Blog Advent Calendar 2025 の公開スケジュール 最後に はじめに こんにちは、開発本部開発 1 部トモニテグループのエンジニアの rymiyamoto です。 今年も残り 1 ヶ月ちょっととなり、年末の恒例イベント every Tech Blog Advent Calendar 2025 を開催します! このカレンダーでは、エブリーのエンジニアが日々の学びや実践的な技術ノウハウを発信していきます。 技術的な工夫や挑戦の裏側など、幅広いテーマでお届けしますので、ぜひチェックしてください! 過去のアドベントカレンダーはこちらからどうぞ! tech.every.tv tech.every.tv tech.every.tv every Tech Blog Advent Calendar 2025 の公開スケジュール アドベントカレンダーの記事は、11/27~12/25 の日程で順次公開していきます! tech.every.tv 最後に エブリーでは、新しい技術に挑戦しながら成長したい仲間を募集中です。 もし、このブログを読んで「もっと話を聞いてみたい」と感じていただけたら、ぜひカジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!🎅✨
アバター
Grafana LGTMスタックをローカルで検証してみた はじめに こんにちは!デリッシュキッチンで主にバックエンドの開発を担当している秋山です。 オブザーバビリティの向上に向けてGrafanaやその関連ツールを検証する一環で、Grafana LGTMスタックをローカルに構築し実際に触ったので、そのあたりを紹介します。 オブザーバビリティについて 本題に入る前にオブザーバビリティについて簡単に説明できればと思います。 オブザーバビリティ とは、システムの内部で起きていることを外部から把握する能力のことです。 日々のパフォーマンス確認/改善やエラー発生時の調査などに役立ちます。 システムの複雑性が増す中で必要性が高まっています。 オブザーバビリティの主要シグナルとしてメトリクス、トレース、ログが存在します。 メトリクス システムの健康状態を数値で表すデータです。 CPU使用率 メモリ使用量 レイテンシー エラーレート など トレース 1つのリクエストに対する一連の処理を可視化するデータです。リクエストが通過するマイクロサービスやデータストアへのアクセスなど、処理の流れ全体を追跡できます。 ログ システムやアプリケーションが出力する記録です。 アプリケーションログ セキュリティログ システムログ 監査ログ など LGTMスタックとは LGTMスタック は、Grafana Labsが提供するオブザーバビリティ(可観測性)を実現するための統合スタックです。以下の4つのコンポーネントの頭文字から名付けられています: L (Loki) : ログを扱うツール G (Grafana) : メトリクス、ログ、トレースを統合的に可視化するダッシュボード T (Tempo) : 分散トレースを扱うツール M (Mimir+Prometheus) : メトリクスを扱うツール これら4つを組み合わせることで、オブザーバビリティの主要シグナルである メトリクス・トレース・ログ を統合的に扱うことができます。 https://grafana.com/docs/opentelemetry/docker-lgtm/ より 今回は、これらのツールをローカル環境で構築し、実際にどのように連携するのかを検証してみました。 Opentelemtryとは 検証では docker-otel-lgtm を使用するのですが、その中でOpenTelemetry Collectorを使用しているため、Opentelemtryについて先んじて簡単に説明させていただきます。 Opentelemtry とは、アプリケーションからメトリクス・ログ・トレースといったテレメトリー(観測)データを統一的に収集・送信するためのOSSです。 アプリケーションを言語、インフラ、ランタイム環境に関係なく簡単に計装できることを目的としています。 ベンダーに依存することなくテレメトリーデータを扱えるメリットがあります。データの送信先としてOTLP(OpenTelemetry Protocol)対応しているツールであれば連携が可能です 上で紹介した図中のOpenTelemetry collectorはアプリケーションからテレメトリーデータを受け取り、各ツールに送信する役割を担っています。 ローカル検証 docker-otel-lgtm を使って検証しました。 docker-otel-lgtmはLGTMスタック+OpenTelemetry collectorを1つのdockerにまとめてくれている公式のプロジェクトです。 そのため、このdocker imageを使って起動するだけで即座にLGTM+OpenTelemetry collectorの環境を用意し、各ツールの機能検証を簡単に開始することができます。 1 . LGTM+OpenTelemetry collectorを起動する 起動はとてもシンプルで、下記のコマンドを実行するだけです。 # Unix/Linuxの場合 ./run-lgtm.sh 2 . 起動したOtel collectorに向けて観測データを送信する docker-otel-lgtmが起動すると、OpenTelemetry Collectorがポート4317(gRPC)と4318(HTTP)でリクエストを受け付けます。 そのため、アプリケーションからのデータ送信はgRPCかHTTPのどちらかの通信方法を選択できますが、今回の検証ではHTTPを使っています。 exampleのサーバーを使ってテレメトリデータを送信する場合 Grafana全体の使用感をサクッと知りたい時は既に用意されたexampleのサーバーを使うことができます。 Go,Java,Pythonなどを使ったexampleがあったので、今回はGoで試してみました。 cd examples/go # 起動 ./run.sh サイコロを振るアプリケーションサーバーが起動します。 goの場合は8081ポートに立つので、 curl http://127.0.0.1:8081/rolldice のようにリクエストすれば、サイコロの数字が返ってきます。 リクエスト後、Grafana( http://127.0.0.1:3000 )にアクセスすると送られてきたテレメトリデータを使った情報を閲覧することができます。 デフォルトでは下記のようなダッシュボードがいくつか用意されていました。 ダッシュボード また、ExploreページからTempoを使ったトレースデータの確認などもできました。 Exploreページ 自前のアプリケーションサーバーを使って観測データを送信する場合 自分が普段触っているサーバーで検証したい事もあると思います。 その場合も、起動したOtel Collectorに向けてデータを送信するだけです。 既にOpenTelemetryを使用して計装を行っている場合は、 送信先をローカルで起動したOtel Collector(httpであれば http://localhost::4318 )に変更するだけで済みます。 未計装の場合、まずは公式の記事を参考に計装を進めていただければと思います。 Goの場合は下記の記事が参考になりますが、試してみたところ案外すんなり計装できました。 opentelemetry.io 下記は実際に開発しているアプリケーションのトレースをしてみた画面です。 実際のトレース画面 他マイクロサービスやDBへのアクセスも含めたトレースを確認できました。 所感 検証の環境について 簡単に検証するためのプロジェクトを公式が用意してくれているのは非常に助かりました。 Grafana LGTMスタックについて 今回使用したdocker-otel-lgtmは検証用の環境を作るものなので簡単に構築できましたが、実運用では可用性やセキュリティ面などを考慮したサーバー構成やツールの設定が必要です。 トレース・メトリクス・ログを統合的に扱うために複数のツールを導入する必要があることを踏まえると、全部自前で用意する場合運用の難しさがありそうだなと思いました。 また、ツールごとの使用方法も理解する必要があるため学習コストが気になりましたが、AIを使うことによって一定の負荷は軽減できそうでした。 下記はAIに作ってもらったダッシュボードの画像です。 ダッシュボードの内容をJSONとして定義できるため、AIの活用が簡単にできます。 ダッシュボード 参考文献 https://github.com/grafana/docker-otel-lgtm?tab=readme-ov-file https://grafana.com/blog/2024/03/13/an-opentelemetry-backend-in-a-docker-image-introducing-grafana/otel-lgtm/ 関連記事 tech.every.tv
アバター