サーバーレス環境ではオブザーバビリティの実現が難しい場合がありますが、AWS Distro for OpenTelemetry(ADOT)を使用すると、標準化されたベンダーニュートラルな方法でテレメトリーの収集やエクスポートが可能になり、運用を簡素化できます。ADOTなら、業界標準のOpenTelemetry APIを活用して、特定のオブザーバビリティベンダーに縛られることなくアプリケーションを計装できます。

コンテナ化したLambda関数には、標準のLambdaレイヤーに対応していないという課題があります。ADOTは通常、Lambda関数のLambdaレイヤーとしてデプロイされるため、代替手段によって実行環境にテレメトリーエージェントを導入する必要があります。この記事では、Dockerのマルチステージビルドを用いてADOTをコンテナイメージに直接埋め込む実践的な回避策を紹介し、収集したテレメトリーデータをNew Relicにエクスポートする手順を説明します。

コンテナイメージを使う理由

AWS Lambdaは、ZIPパッケージ(Lambdaレイヤーを含む)とコンテナイメージという2種類のデプロイ方式に対応しています。コンテナイメージによるデプロイには、次のような大きな利点があります。

  • 展開パッケージのサイズは最大10GB(ZIPは250MBまで)まで対応可能
  • 既存のCI/CDパイプラインと一貫性のあるツール
  • 実行環境の依存関係やバージョンを完全に管理できる
  • 構築済みの依存関係により、複雑なアプリケーションのコールドスタート時間を短縮

導入に伴う課題

コンテナイメージはLambdaレイヤーを直接使用できません。LambdaレイヤーはZIPデプロイ用の機能です。ZIP形式でデプロイすると、AWSは実行時にレイヤーの内容を/optに展開します。コンテナイメージを使うと、この仕組みがスキップされます。コンテナイメージはユーザーがすべてを制御できる自己完結したファイルシステムである一方、AWSにはレイヤーの内容を組み込むためのフックが用意されていません。そのため、ADOTのインテグレーションには代替のアプローチが必要になります。

解決策:Dockerのマルチステージビルド

重要な点は、Lambdaレイヤーは単純なZIPアーカイブであり、実行時に/optに展開されるということです。Dockerのビルド時にADOTレイヤーの内容をダウンロードして展開することで、この仕組みを再現できます。

プロジェクト構造

├── Dockerfile
├── template.yaml
└── src/
    ├── index.js
    └── package.json

ステップ1:Dockerfileの作成

マルチステージビルドでは、軽量のAlpineコンテナにADOTレイヤーをダウンロードし、その内容をLambdaイメージにコピーします。

# ステージ1:ビルダー - ADOT Lambdaレイヤーをダウンロードして展開する
# イメージサイズの軽量化とダウンロードの高速化のためにAlpineを使用する
FROM alpine as builder

# レイヤーの取得と展開に必要なツールをインストールする
RUN apk add --no-cache curl unzip

# ADOTレイヤーのURL - 別の言語ランタイムを使用する場合は変更してください
ARG ADOT_LAYER_URL="https://github.com/aws-observability/aws-otel-js-instrumentation/releases/latest/download/layer.zip"

# /optにダウンロードして展開する(AWSがレイヤーに使用する場所と同じ)
RUN curl -Lo /tmp/layer.zip "${ADOT_LAYER_URL}" && \
    unzip /tmp/layer.zip -d /opt && \
    rm /tmp/layer.zip

# ステージ2:最終的なLambdaイメージ
FROM public.ecr.aws/lambda/nodejs:22

# ビルダーからADOTレイヤーの内容をコピーする
COPY --from=builder /opt /opt

# ADOTラッパースクリプトに実行権限を付与する
RUN chmod +x /opt/otel-instrument

# 依存関係をインストールし、関数コードをコピーする
COPY src/package.json ${LAMBDA_TASK_ROOT}
RUN npm install

COPY src/index.js ${LAMBDA_TASK_ROOT}

CMD [ "index.handler" ]

注:chmod +xコマンドは必須です。ADOTが関数を計装するには、ラッパースクリプトに実行権限を付与する必要があります。

ステップ2:SAMテンプレートの設定

SAMテンプレートでは、Lambda関数を設定し、ADOTに必要な環境変数を設定します。デフォルトでは、ADOTはAWS X-Rayにエクスポートしますが、ここではエクスポート先をNew Relicに設定します。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Container Lambda with ADOT and New Relic

Parameters:
  NewRelicLicenseKey:
    Type: String
    Description: "New Relic Ingest License Key"
    NoEcho: true

Globals:
  Function:
    Timeout: 30
    MemorySize: 256
    LoggingConfig:
      LogFormat: JSON
    Environment:
      Variables:
        AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument
        # 干渉を防ぐためにAWS Application Signalsを無効にする
        OTEL_AWS_APPLICATION_SIGNALS_ENABLED: 'false'
        # 特定の計装を有効にする(任意)
        OTEL_NODE_ENABLED_INSTRUMENTATIONS: 'aws-sdk,aws-lambda,http,pino'
        OTEL_SERVICE_NAME: container-lambda-hello
        OTEL_PROPAGATORS: 'tracecontext,baggage'
        # エクスポーターズ
        OTEL_TRACES_EXPORTER: otlp
        OTEL_METRICS_EXPORTER: otlp
        OTEL_LOGS_EXPORTER: otlp
        # OTLPエンドポイント(シグナル別)
        OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
        OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: https://otlp.nr-data.net:4318/v1/traces
        OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: https://otlp.nr-data.net:4318/v1/metrics
        OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: https://otlp.nr-data.net:4318/v1/logs
        OTEL_EXPORTER_OTLP_HEADERS: !Sub "api-key=${NewRelicLicenseKey}"

Resources:
  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      PackageType: Image
      Architectures:
        - x86_64
      Events:
        Api:
          Type: HttpApi
    Metadata:
      DockerTag: latest
      DockerContext: .
      Dockerfile: Dockerfile

Outputs:
  ApiEndpoint:
    Description: "API Gateway endpoint URL"
    Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/"

New Relic用の主な環境変数:

  • OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:トレース用のエンドポイント
  • OTEL_EXPORTER_OTLP_HEADERS:New Relic Ingestライセンスキーを指定するヘッダー(CloudFormationパラメーター経由で渡されます)
  • OTEL_SERVICE_NAME:New Relic上でサービスを識別する名前
  • OTEL_NODE_ENABLED_INSTRUMENTATIONS(任意) 構造化ログ用のpinoなど、特定の計装を有効にする

ステップ3:カスタムインストゥルメンテーションおよびメトリクスの追加(任意)

より詳細な可観測性を実現するには、pinoのOpenTelemetry API を使用して、カスタムスパン、メトリクス、構造化ログを追加します。以下の依存関係を追加してください。

{
  "type": "module",
  "dependencies": {
    "@opentelemetry/api": "^1.9.0",
    "pino": "^9.0.0"
  }
}

メトリクスヘルパーを作成します(metrics.js)。

import { metrics } from '@opentelemetry/api';

const meter = metrics.getMeter('my-lambda-metrics');

const workDurationHistogram = meter.createHistogram('work_item_duration', {
    description: 'Duration of work items in milliseconds',
    unit: 'ms',
});

const workItemCounter = meter.createCounter('work_item_count', {
    description: 'Count of work items processed',
});

export const recordWorkMetrics = (id, duration, success = true) => {
    workDurationHistogram.record(duration, { 'work.item.id': id, 'work.success': success });
    workItemCounter.add(1, { 'work.item.id': id, 'work.success': success });
};

ハンドラーにPinoロギングを組み込みます。PinoはOpenTelemetryライブラリでデフォルトでサポートされており、実行時にパッチが適用されます。

import { trace, SpanStatusCode } from '@opentelemetry/api';
import pino from 'pino';
import { recordWorkMetrics } from './metrics.js';

// Pino logger - ADOT auto-instruments this
const logger = pino({ level: 'info' });

const doWork = async (id) => {
    const tracer = trace.getTracer('my-lambda-tracer');
    const span = tracer.startSpan(`doWork-${id}`);
    span.setAttribute('work-item-id', id);

    try {
        logger.info({ itemId: id }, `Starting work item ${id}`);
        const delay = Math.floor(Math.random() * 200) + 100;
        await new Promise(resolve => setTimeout(resolve, delay));
        
        logger.info({ itemId: id, duration: delay }, `Completed work item ${id}`);
        recordWorkMetrics(id, delay, true);
        span.setStatus({ code: SpanStatusCode.OK });
        return { id, duration: delay };
    } catch (error) {
        logger.error({ itemId: id, error: error.message }, `Work item ${id} failed`);
        recordWorkMetrics(id, 0, false);
        span.recordException(error);
        span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
        return { id, error: error.message };
    } finally {
        span.end();
    }
};

export const handler = async (event) => {
    const tracer = trace.getTracer('my-lambda-tracer');
    return tracer.startActiveSpan('handler-span', async (span) => {
        logger.info({ event }, 'Event received');
        try {
            logger.info('Initializing processing...');
            const results = await Promise.all([1, 2, 3].map(doWork));
            
            span.setStatus({ code: SpanStatusCode.OK });
            span.end();
            return { statusCode: 200, body: JSON.stringify({ results }) };
        } catch (error) {
            logger.error({ error: error.message }, 'Handler failed');
            span.recordException(error);
            span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
            span.end();
            return { statusCode: 502, body: JSON.stringify({ error: 'Bad Gateway' }) };
        }
    });
};

ステップ4:ビルドとデプロイ

# コンテナイメージをビルドする
sam build

# AWSにデプロイする
sam deploy --guided

ガイド付きデプロイでは、SAMが次の処理を行います。

  • コンテナイメージ用のECRリポジトリの作成
  • イメージのビルドとプッシュ
  • API Gateway経由でのLambda関数のデプロイ

ステップ5:New Relicでの検証

関数を数回呼び出したら、New Relicでテレメトリデータを確認します。

1. APM & Services:OTEL_SERVICE_NAMEに設定した名前でサービスを検索します

![New Relic APMには、コンテナLambdaサービスがトレースとパフォーマンスメトリクスとともに表示されます](プレースホルダー:screenshot_newrelic_apm_service_list.png)

2. Distributed Tracing:トレースのウォーターフォール全体(カスタムスパンを含む)を表示します

![分散トレースには、ハンドラースパンと子スパンのdoWorkスパン、時間の内訳が表示されます](プレースホルダー:screenshot_newrelic_distributed_trace.png)

3. Errors & Custom Instrumentation:span.recordException()で記録された例外はすべて、Errors inboxに表示されます。OpenTelemetry APIを用いて作成したカスタムスパン(doWork-{id}スパンなど)は、その属性とともにトレースに表示されます。

![New Relic Errorsの受信トレイには、記録された例外がスタックトレースとカスタムスパンの属性とともに表示されます](プレースホルダー:screenshot_newrelic_errors_inbox.png)

考慮事項

ADOTを使用する場合、Lambda関数に幾つかの運用オーバーヘッドが発生するため、注意する必要があります。

  1. コールドスタート:ADOTはOpenTelemetry Lambdaレイヤーを再パッケージしたものであるため、コールドスタート時に若干のオーバーヘッドが発生します。
  2. メモリ制限:まず256MBから開始し、ワークロードに応じて調整することをおすすめします。New RelicのLambda監視機能でメモリ使用量を監視し、割り当てを最適化してください。

主なポイント

  • コンテナベースのLambdaは、Dockerのマルチステージビルドを使ってADOTを利用できます
  • ネイティブなLambdaレイヤーと同様に、ADOTレイヤーは/optに展開されます
  • New RelicのOTLPエンドポイントを使うと、ADOTとのシームレスな統合が可能になります
  • カスタムインストゥルメンテーションにより、自動インストゥルメンテーション以上に詳細なトレースを実現できます

まとめ

コンテナイメージとOpenTelemetryは、サーバーレスのオブザーバビリティを実現する強力な組み合わせです。ADOTをコンテナビルドに直接組み込み、New Relicにエクスポートすることで、柔軟性の高いコンテナデプロイと、詳細かつ実用的なテレメトリーの両方の利点を得られます。

始める準備は良いですか?以下のリソースをご覧ください。