🔎

AWSのサーバーレスパターンをOpenTelemetryの自動計測でどこまで可視化できるか

に公開
2026/03/06

サーバーレスにこそオブザーバビリティ

クラウド事業者のマネージドサービスはお好きですか?私は大好きです。

サーバーレス構成が実現すると謳っている、ゼロからのスケールを実現しつつ、大量アクセスも捌けるスケーラビリティ、夢のような世界です。しかし、実際にその構築・運用を始めると、その複雑性に圧倒されることもしばしばです。

この記事では、サーバレスアーキテクチャを用いたアプリケーションにおいて、オブザーバビリティ計装ツールとしてノーコードでのOpenTelemetryとX-Rayによる可視化を試み、OpenTelemetryの手動計装で(私が)本当に欲しかったトレースを作成する方法を紹介します。

AWSでは、エージェント AI 用のサーバーレスアーキテクチャの構築ガイダンスで次のように述べています。

オブザーバビリティがない場合、チームは次の問題に直面します。

  • 実行とエージェントの動作の点を隠す
  • 未検出のコスト異常またはパフォーマンスの低下
  • モデル出力と大規模言語モデル (LLM) の品質に関するインサイトが限られている
  • 非同期ワークフロー全体の根本原因分析が困難

オブザーバビリティは、サーバーレス AI の次の分野で重要な役割を果たします。

  • AI 出力 – LLMs は非決定的です。出力のログ記録と検査が、時間の経過とともにその正確性を検証する唯一の方法です。
  • サーバーレス実行 — AWS Lambda、 AWS Step Functions、および Amazon EventBridge は固定ホストでは実行されません。モニタリングは、サーバーベースではなくトレースベースである必要があります。
  • コストとレイテンシー – Amazon Bedrock の使用はトークンに基づいています。Lambda および Step Functions は、期間と実行ごとに課金されます。
  • セキュリティとガバナンス – プロンプトログ、エージェントツールの使用、API コールは、監査され、アイデンティティとロールのコンテキストに絞り込まれている必要があります。
  • ユーザーエクスペリエンス – 障害、遅延、幻覚は信頼に影響します。これらの問題を早期に検出することは、AI システムに対するユーザーの信頼を維持する上で重要です。

https://docs.aws.amazon.com/ja_jp/prescriptive-guidance/latest/agentic-ai-serverless/observability-and-monitoring.html

上記のガイダンスはエージェントAIシステムについての記載ですが、AIのハルシネーションや非決定的出力、トークンによる課題以外は、すべてのサーバーレスアーキテクチャに通用する課題です。

サーバーレスアーキテクチャは、多数のマネージドサービスを要求に応じて組み合わせていく特性上、クラウドサービス自体の仕様理解と構成の把握が大前提となります。開発中のデバッグや、障害時の調査では、これらの事前知識を基にログ等を辿ってシステムの内情を理解することになります。特にサービス間連携はブラックボックス化しやすく、デバッグに手間がかかりやすいポイントです。

これらの複雑性を読み解く際に必要となるのが、オブザーバビリティの概念です。オブザーバビリティの世界では、各クラウド事業者がOpenTelemetry互換のモニタリングサービスを徐々にスタートさせており、AWSもその例に漏れません。

OpenTelemetryはCNCFプロジェクトということもあり、Kubernetesと組み合わせてマイクロサービス環境で利用するデモコードが多数用意されています。

しかし、実際にKubernetesオンリーで大規模利用している方は決して多くないというのが、私が過去Solution Architectとしてたくさんのお客様を見てきた印象です。クラウド利用が進んでいる開発現場では、様々なマネージドサービスを組み合わせて、自分たちにあった運用規模のサービスを選定して利用しているのが現状です。

AWSではX-Rayという独自規格のオブザーバビリティサービスを運営してきた歴史的経緯もあり、X-Ray統合は機能として存在しているが、OpenTelemetry連携は不明瞭というサービスも存在しています。

この記事では、AWSサーバレスアーキテクチャをOpenTelemetryで計装する際の現在地を検証していきます。

自動計装検証

今回利用するサンプルコードは、AWS公式のaws-samples/serverless-samplesリポジトリからqueue-based-ingestionをベースに変更を加えていきます。

https://github.com/aws-samples/serverless-samples/tree/main/queue-based-ingestion

アーキテクチャ図

API Gateway HTTP APIを前面に、LambdaオーソライザーとSQS統合、Lambda統合を利用してデータストアにDynamoDBとS3を使うサーバレスアーキテクチャの鉄板構成です。

APIGWからSQS、SQSからイベント統合でLambdaを起動するAWSネイティブ統合が設定されており、"SQS Message Processor"からはInvokeFunctionで"Batch Processing Simulation"をAWS SDKで呼び出しています。

こうした連携や関数間の呼び出し、データストアへのアクセスを一連の処理として可視化できることが今回の期待値です。OpenTelemetryでの計装にはコード計装と自動計装がありますが、今回はなるべくサンプルコードに手を入れずに計装することを目指します。サンプルアプリはSAMとCDK両方のサンプルがありますが、デプロイの速さからSAMを使って解説していきます。

また、この記事では、オブザーバビリティバックエンドにSplunk Observability Cloudを利用します。

OTel Lambda Layerによる自動計装

まずは、最も簡単かつ変更も少ない計装方法から試していきます。

AWS Lambdaを計装する場合、OpenTelemetryのガイドではOTel Collector Lambdaレイヤーを追加することで本体のコードを一切変更せずに計装を追加できます。

https://opentelemetry.io/ja/docs/platforms/faas/lambda-auto-instrument/

https://docs.splunk.com/observability/ja/gdi/get-data-in/serverless/aws/otel-lambda-layer/advanced-configuration.html

Splunk Observability Cloudを利用する場合、設定がプリセットされたSplunk Distributionのレイヤーが用意されているので、ドキュメントに従ってこのLambdaレイヤーと必要な環境変数をセットします。

SQSMessageProcessorFunction:
  Type: AWS::Serverless::Function
  Properties:
    Tracing: Active
    Description: Lambda to be invoked by the SQS Queue
    CodeUri: src/api/ # ハマりどころ。解説後述
    Handler: sqs_processor.lambda_handler
    Timeout: 180
    Layers:
      - !Sub "arn:aws:lambda:${AWS::Region}:254067382080:layer:splunk-apm:${SplunkLayerVersion}" # Splunkドキュメントより
    Policies:
      - DynamoDBCrudPolicy:
          TableName: !Ref JobRequestStoreDynamoDBTable
      - LambdaInvokePolicy:
          FunctionName: !Ref BatchSimulatorFunction
    Environment:
      Variables:
        BATCH_SIMULATOR_FUNCTION_NAME: !GetAtt BatchSimulatorFunction.Arn
        SQS_MESSAGE_STORE_TABLE_NAME: !Ref JobRequestStoreDynamoDBTable
        AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument
        SPLUNK_REALM: !Ref SplunkRealm
        SPLUNK_ACCESS_TOKEN: !Ref SplunkAccessToken
        OTEL_SERVICE_NAME: sqs-processor
        OTEL_PROPAGATORS: xray,tracecontext,baggage
        OTEL_RESOURCE_ATTRIBUTES: !Sub "deployment.environment=${DeploymentEnvironment}-${AWS::StackName}"
    Events:
      MySQSEvent:
        Type: SQS
        Properties:
          Queue: !GetAtt JobRequestQueue.Arn
          BatchSize: 10

ハマりどころとしては、サンプルアプリの元のコードではPythonハンドラーのコード参照に Handler: src/api/sqs_processor.lambda_handler とファイルパスとPython関数双方を1行で表現する記法を使っていますが、これとOTel Lambdaレイヤーを同時に使うとモジュール解決でエラーが発生します(詳細は省略)。CodeUri と Handler に分割してこのエラーを防いでいます。

では、自動計装のみで生成してサービスマップを見てみましょう。

Lambdaレイヤーによる自動計装を追加

API Gateway HTTP APIにはテレメトリーデータを送信する機能が無いので、サービスマップに表現されていません。

また、SQSの存在もここからは読み取れませんが、オーソライザーからアクセスしているCognitoやDynamoDB, S3といったサービス群はInferred Serviceとして存在が推測されています。これは、Splunk Observability Cloud側がトレースから存在が把握できる外部サービスを自動的に推測して追加しているためです。

今回のアーキテクチャでは、SQSはAPI Gateway→SQSのAWS統合、Lambdaへの連携はSQSイベント→Lambda Contextとして渡されてはいるものの、実行環境からHTTPリクエストとして把握できない連携方法を取っているため、SQSの存在がトレースから検知できていないと考えられます。

REST APIのX-ray統合を試してみる

OTelの自動計装ではAWSサービス全てを可視化するには至りませんでした。ではAWSのオブザーバビリティツールであるX-Rayのサービスマップではどうでしょうか。

まず、重要な制約としてHTTP APIはシンプルな設定の代わりにX-Ray統合が設定できません。 API Gatewayのテレメトリを取得するためにはまずREST APIに切り替える必要があります。

APIGWとLambdaのX-RayトレーシングをONにした場合の結果が、下記のマップです。

APIGW RESTとLambdaのX-Ray統合をONに

OTelでは検出できなかったAPIGWとSQSの統合がサービスマップに表示されています。一方で、Lambda関数間のInvokeFunctionによる呼び出しはサービスマップで表現されていませんでした。

OTel計装では自動計装でsqs-processorからbatch-simulatorが呼び出されていることが表現されていたのに、これは意外な結果です。

アンチパターン:可視化のためのサービス追加

AWSサービスとLambda関数の関係を明確にしたい場合、HTTP API->SQSの統合をHTTP API->Lambda->SQSに置き換え、AWS SDKを用いてキューを送信するパターンも考えられます。

OpenTelemetryのコントリビューションにはAWSも参加しており、SDKの計装ライブラリが提供されているので、AWS SDKを通じてアクセスすることでリソースの検出が可能となります。OTel自動計装でリソースが検出されないのは、SDKを通じてではなく、AWSネイティブの統合を使っているため、計装外でコンテキストが受け渡されていることに起因しています。

APIGWのSQS統合の代わりにProducer Lambdaを追加

新たに、submit-job-requestというLambda関数を追加して、SQSの代わりにAPI Gatewayのルートに統合しました。処理内容はSQS統合と全く同じで、POSTリクエストのペイロードをもとにSQSキューを作成するだけの処理です。

ただ、可視化のためだけにLambda関数を追加すると、マネージドサービスの利用によって隠蔽されていた運用やコーナーケースの考慮をユーザー責任に抱えることとなります。 仕様確認はともかく、プロダクション環境でこのような構成はおすすめしません。

現状のまとめ

OpenTelemetryの自動計装と、X-Ray統合による可視化は、共に全てのAWSサービスアーキテクチャを可視化するには至りませんでした。

OTel, X-Ray統合とCloudWatch APMサービスマップは共に求めていた情報は。マッピングの正確性を妥協するにしても、HTTP APIからREST APIへの変更はそれなりの変更コストを伴います。

実はAWSではX-RayからOpenTelemetryへの移行を進めている真っ最中です。X-Ray SDKおよびデーモンのサポートは2027/02/25でEOSとなることが発表されており。2026/2/25以降にはメンテナンスモードとなることも合わせて記載されています。

サーバレス系サービスではOpenTelemetry Collectorをはじめとしたエージェント類のインストールができないので、現状テレメトリデータの外部送信は設定できず、AWSサービスとして提供されているネイティブ統合設定に頼るしかありません。本記事で触れたようなHTTP以外のLambda統合(SQS, EventBridgeなど)も同様です。

X-Ray統合がOpenTelemetry統合にアップデートされ、テレメトリデータを外部送信できるようになったなら、AWSユーザーはOTelの特長である仕様のオープン性を活かし、自分たちのテレメトリデータをどのプラットフォームでも扱えるようになるはずです。AWSさんには是非ともテレメトリー送信先の自由を確保してもらいたいところです。

とはいえ、対応を待つだけでは消極的なので、今一度オブザーバビリティの意義について問い直した現実的な案を出してみます。

【補足】実践対応ー手動計装による可視化

AWSサービスのアーキテクチャに基づいたサービスマップではなく、各アプリケーションサービスを中心に考え、サービス間の関連・通信を可視化することをゴールとします。

オブザーバビリティの最終目標は「内部動作や挙動を説明可能にすること」ですので、必ずしもインフラ層を中心に考える必要はありません。

自動計装からオブザーバビリティの実装を始めるのは、ベストプラクティスに沿ったステップですが、運用に求められるテレメトリデータを全て収集できない場合もあります。そのような場合、徐々に必要な個所へ手動計装(コード計装)を追加していくこととなります。

sqs_processor.pyに最小限の手動計装を追加し、AWSのイベントコンテキストを埋め込んでいきます。

sqs_processor.py
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0

import boto3
import json
import os
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer(__name__)
lambda_client = boto3.client('lambda')

def _process_one(record, dynamo_client, simulator_function_name):
    job_request_payload = create_job_reqeust(record)
    dynamo_client.put_item(Item=job_request_payload)
    print('invoking simulator function' + simulator_function_name)

    # Lambda呼び出し前に明示的なスパンを作成
    with tracer.start_as_current_span(
        "lambda.invoke.batch-simulator",
        kind=trace.SpanKind.CLIENT
    ) as span:
        span.set_attribute('faas.invoked_name', simulator_function_name)
        span.set_attribute('faas.trigger', 'http')

        try:
            batch_simulator_response = lambda_client.invoke(
                FunctionName=simulator_function_name,
                Payload=json.dumps(job_request_payload)
            )
            span.set_status(Status(StatusCode.OK))
            print('response from simulator function : ' + str(batch_simulator_response))
        except Exception as e:
            span.set_status(Status(StatusCode.ERROR, str(e)))
            span.record_exception(e)
            raise

    reponse_payload= json.loads(batch_simulator_response['Payload'].read().decode("utf-8"))
    response_body= reponse_payload ['body']
    job_status = response_body ['jobStatus']
    job_request_payload ['jobStatus'] = job_status
    job_request_payload ['jobPayloadLocation'] = response_body ['jobPayloadLocation']
    job_request_payload ['jobPayloadKey'] = response_body ['jobPayloadKey']
    dynamo_client.put_item(Item=job_request_payload)
    print (' sucessfuly updated  job status in DynamoDB '+  str(job_request_payload))

def lambda_handler(event, context):
    current_span = trace.get_current_span()

    # SQSからのイベント情報をスパン属性として記録(Semantic Conventions準拠)
    if event.get('Records'):
        record = event['Records'][0]
        current_span.set_attribute('messaging.system', 'aws_sqs')
        event_source_arn = record.get('eventSourceARN', '')
        queue_name = _extract_queue_name(event_source_arn)
        if queue_name:
            current_span.set_attribute('messaging.destination.name', queue_name)
        current_span.set_attribute('faas.trigger', 'pubsub')
        current_span.set_attribute('messaging.operation.type', 'process')
        current_span.set_attribute('messaging.operation.name', 'process')
        record_count = len(event['Records'])
        if record_count > 1:
            current_span.set_attribute('messaging.batch.message_count', record_count)

    dynamo_table_name = os.environ['SQS_MESSAGE_STORE_TABLE_NAME']
    simulator_function_name = os.environ['BATCH_SIMULATOR_FUNCTION_NAME']
    dynamo_client = boto3.resource('dynamodb').Table(dynamo_table_name)
    print('incoming request payload from SQS'+ str(event))
    for record in event['Records']:
        print(' processing record' + str(record))
        _process_one(record, dynamo_client, simulator_function_name)

def create_job_reqeust(record):
    job_request =  {
        "jobRequestId" :record ["messageId"],
        "jobRequestPayload": record["body"],
        "SentTimestamp" : record["attributes"]["SentTimestamp"],
        "jobStatus":"Submitted"
    }
    return job_request


def _extract_queue_name(event_source_arn):
    if not event_source_arn:
        return None

    parts = event_source_arn.split(':', 5)
    if len(parts) < 6:
        return None

    partition = parts[1]
    region = parts[3]
    account_id = parts[4]
    queue_name = parts[5]

    domain = 'amazonaws.com'
    if partition == 'aws-cn':
        domain = 'amazonaws.com.cn'
    elif partition == 'aws-us-gov':
        domain = 'amazonaws.com'

    queue_url = f"https://sqs.{region}.{domain}/{account_id}/{queue_name}"
    return queue_name

手動計装する際、スパン属性はセマンティック規約に従うよう注意する必要があります。LambdaおよびFaaSのSpanで定義済みの属性および値があるかどうか、事前に確認して進めましょう。

実際にトレースの属性を見ると、今回設定した値が埋め込まれていることが分かります。

messaging。systemなどの値が埋め込まれたトレース

自動計装で取得できるテレメトリーデータが足りないと感じた際は、手動計装を試すチャンスです。運用時に必要な情報を想定して、トレース属性を追加しましょう。ログと違い、最初から構造化された状態で取得でき、オブザーバビリティバックエンドでは大抵ログよりトレースのほうが安価に保存できるので、費用の最適化にも貢献できます。

Discussion