Technote

by sizuhiko

PHPカンファレンス福岡2025に参加した

PHPカンファレンス10周年おめでとうございます。

過去の参加ブログのリンクから:

ほとんど参加していると思っていたら、そうでもなかった。

前日入り

今回も前日入りしましたが、いきなり飛行機が遅延するというトラブルに。

こちらは昨年と同じ書き出しを1行コピペしてきましたが、今年も遅延から始まりました。 お昼ぐらいの来福者は時間どおりだったそうなので、朝だけだったようです。

福岡に到着したら WeWork 天神ブリッククロスへ。永和システムマネジメントでは東京支社がWeWork京橋内になったため、今回のように福岡に前日入りしてもWeWork拠点があれば作業できるのでとても便利。

こちらも昨年からのコピペに見えますが、WeWork の場所が変更となっています。昨年訪問した大名は新しい天神ブリッククロスに引越したようです。こちらは8月にオープンしたということで、まだ3ヶ月ぐらいの新しい拠点で、すごい新築の香りがしていました。

プロジェクトが多忙なこともあり、始発に近い時間に東京を出て、普通の勤務時間に仕事を開始してました。

お昼はWeWork近くにあった能古うどんへ。

夕方からは昨年と同じくWeWorkに知っている人たちが続々来て【非公式】PHPカンファレンス福岡2025・前日Meetup が行われました。昨年は袋詰めに参加していたのですが、今年はこちらに参加(偶然居合わせ?w)ていました。

そのあとは懇親会/2次会にも混ざって、そのあとは Rummy 行って美味しいラムをいただいて、締めにラーメンを食べて3時ちょいに寝ました。なんと気づくとほぼ昨年と同じパターン。

当日

ちゃんと朝起きれて今年は無事会場へ到着。

今回は多くの時間セッションに参加してました。PHP養分がたくさん補充されました。アンカンもなかったので、大量のインプットです。

技術面もエモさもたくさんあって、とても充実した1日となりました。来週からの仕事のモチベにもなります(PHP書いてないけど)。

AIというキーワードが多くあって、やっぱりそういう時流というか避けては通れない何かみたいなのは感じますね。とはいえ、その中で僕ら技術者がやるべきことというか、やることが変わっていく中での模索が続いているというのは、こういうリアルなコミュニティという現場で得られるなぁと強く感じました。

ちなみにお昼は、隣のウェストで。店中が同じようなイベントTシャツを着た人で溢れていて、お店の人も「何?」って後で話したりしてるんでしょうねぇw(そういえばランチの写真はFacebookにしかupしてなかった

そのあとはそのあとは懇親会 → 2次会 → 3次会 の流れで4時過ぎに解散となりました。 初めましての方、お久しぶりの方とたくさん話せました。楽しかった!(そして飲みすぎた!

※3次会の始まりのポストより

後日

今年はアフターハックがなかったので、ホテルの隣のエンジニアカフェにてこのブログを書いています。

2019年の記事の最後は

PHPカンファレンス福岡、来年も参加できると良いなー。

2024年の記事の最後は

そしてまた参加できるように願っています。

で終えていました。

PHPカンファレンス福岡は現体制での開催は今年で最後ということが事前に告知されております。 最後の赤瀬さんの挨拶を思い出して、ウルウルしながらこの1行を書いています。

福岡のコミュニティとのつながりでいうと、PHPMatsuri Fukuoka (ブログ記事: PHP Matsuri2012を終えて 〜 Retrospective of PHP Matsuri 2012 in Fukuoka)からで、最初にもリンクした過去の自分の記事を含めて見ながら懐かしいなぁというのと、少し寂しさも感じていました。

ところが懇親会で @kis さんがコミュニティの継続を熱く願って(少し無茶振り?)、fukuoka.php としての活動は新しい世代で始めるという流れを作ってくれました。

そうPHPカンファレンス福岡の始まりも、あのツイートからでしたからね

もちろん各地のPHPカンファレンスなどで福岡のコミュニティのみなさまと再開できることはあると思いますが、また福岡の地での再会があるかもしれません。

最後に Kenichiro トリオ写真を撮っていただいたので入れておきます。

Step Functions の HTTP タスクでプライベート API にアクセスする

Step Functions には HTTP タスクという便利な統合機能があり、ワークフローで HTTPS API を呼び出すことができるようになっています。 そうすることで Step Functions から単に HTTP の API を呼び出すだけなら Lambda を作らなくても良くなりました。便利ですね。

2024 Re:Invent の発表でプライベートAPIへのアクセスがサポートされた

ただ、これには制限があって、インターネットからアクセス可能なパブリックな API にしかアクセスできませんでした。つまり VPC 経由でアクセスする必要がある API には依然として VPC Lambda が必要だったのです。 しかし2024年の Re:Invent でプライベートAPIへのアクセスがサポートされることになり、益々利用シーンが増えそうです。

参考記事:

CDK では?

さっそく使ってみたいと思ったのですが、当然すぐにCDK ではゴニョゴニョできないのかな?とインターネットを調べてみたところ、さっそくサンプルがあるようです。

参考記事:

L2コンストラクタはまだないので、 CfnResourceGatewayCfnResourceConfiguration を使って作っていくようです。

具体的なCDKのコードはこちらです。

やってみた

とりあえず、あっさりできました。

注意点としてはプライベートAPIといいつつ、EventBridge Connection は HTTPs しか使えないので、 HTTP のプライベートAPIにアクセスしたい!という要望がある場合は工夫が必要です。まぁ VPC の中に LB をおいて、そこにプライベートAPIにアクセスするHTTPsエンドポイントを置いて、HTTPに変換して通すみたいな感じですかね。

めっちゃハマること

プライベートAPIのドメイン名を変更したい

0 -> 1 で作るときは問題ないんですけどね。

たとえば仮のドメイン名とかで構築していて、いざ実際の名前が決まったら置き換えたい、みたいなこと普通にあるじゃないですか。

    resourceConfig.addPropertyOverride('ResourceConfigurationDefinition.DnsResource', {
      DomainName: props.apiDomainName,
      IpAddressType: 'IPV4',
    })

たとえばリソースコンフィグでプライベートAPIのドメイン名を設定しておく必要があるんですが、このプロパティの値を変えるみたいなケースです。

変更するとき cdk diff するとちゃんと差分になって変更できそうに見えます。

Resources
[~] AWS::VpcLattice::ResourceConfiguration Api/ResourceConfig ApiResourceConfig50DED18C
 └─ [~] ResourceConfigurationDefinition
     └─ [~] .DnsResource:
         └─ [~] .DomainName:
             ├─ [-] 変更前のドメイン名
             └─ [+] 変更後のドメイン名
✨  Number of stacks with differences: 1

なんで、そのまま cdk deploy するじゃないですか。

DeployStack | 0/4 | 10:07:54 AM | UPDATE_IN_PROGRESS   | AWS::CloudFormation::Stack                 | DeployStack User Initiated
DeployStack | 0/4 | 10:08:00 AM | UPDATE_IN_PROGRESS   | AWS::VpcLattice::ResourceConfiguration     | Api/ResourceConfig (ApiResourceConfig50DED18C) 
DeployStack | 1/4 | 10:08:02 AM | UPDATE_COMPLETE      | AWS::VpcLattice::ResourceConfiguration     | Api/ResourceConfig (ApiResourceConfig50DED18C) 
DeployStack | 2/4 | 10:08:04 AM | UPDATE_COMPLETE_CLEA | AWS::CloudFormation::Stack                 | DeployStack 
DeployStack | 3/4 | 10:08:05 AM | UPDATE_COMPLETE      | AWS::CloudFormation::Stack                 | DeployStack 
 ✅  DeployStack
✨  Deployment time: 38s

成功しているように見えるじゃないですか。

でも、AWSのマネコンから確認すると….. 変わってません!!!!(衝撃、マジか

原因は不明です…

プライベートAPIのドメイン名を変更したいときの解決方法

一度 EventBridge Connection をパブリックAPIに戻します。 下のプライベートAPIの設定や、 CfnResourceGatewayCfnResourceConfiguration も全部削除します(ソースコード的にはコメントアウトでも良いけど)。

    const connection = new Connection(this, 'ApiConnection', {
      authorization: Authorization.apiKey('Authorization', props.secret.secretValue),
      connectionName: 'api-connection',
      description: 'プライベートAPIコネクション',
    })
    // ここから
    const cfnConnection: CfnConnection = connection.node.children[0] as unknown as CfnConnection
    cfnConnection.addPropertyOverride('InvocationConnectivityParameters', {
      ResourceParameters: {
        ResourceConfigurationArn: resourceConfig.attrArn,
      },
    })
    // ここまでを削除

して、一度デプロイしてリソースを削除して、再度元に戻してドメイン名の変更を適用して 0 -> 1 の状態で作り直します。

ちなみに、AWSのマネコンからドメイン名を変更しようと思っても GUI からもできないので、そういうものなのかもしれません。(diff と deploy は何をもって成功したのかはわからん…

まとめ

ということで、あまりスマートな解決策ではありませんが、現時点ではこういうやり方しかないのかなと思います。 いい感じに CDK からでも変更できるようになると良いなーと願っています(時間できたら issue 書こうかな

S3 に設置した zip ファイルから Lambda 関数をデプロイする

CDK で Lambda をデプロイするとき、S3 にある zip ファイルからのデプロイや、ソースコードからのデプロイなどいくつかのやり方があると思います。

今回は S3 の zip からデプロイする方法についてです。App Runner は ECR にコンテナイメージをデプロイして、それを自動連携しているので、Lambda もアプリイメージのデプロイと実際のアプリデプロイを分離しようと思ったわけです。 S3 の zip からデプロイする CDK のコードは以下のような感じになります。

    return new Function(this, 'SampleFunction', {
      functionName: 'sample',
      code: Code.fromBucket(
        Bucket.fromBucketName(this, 'Resource', `lambda-packages`),
        'sample.zip'
      ),
      handler: 'index.handler',
      architecture: Architecture.ARM_64,
      runtime: Runtime.NODEJS_20_X,
    })

Bucket.fromBucketName でアプリ側でデプロイ済みの S3 バケットを指定して、その中に入っている sample.zip からデプロイします。

で、このとき sample.zip の中身が変化していたとき、CDK では差分があるとしてデプロイされるのかな?と思っていました。

S3 の zip が異なるものでもデプロイはされない

でも、実際はそんなことなく zip が変更されたあとで cdk diff しても差分にはなってくれませんでした。 ちょうどそれらしい話題の issue Lambda Function Code.FromBucket() does not update “Code Entry Type” in AWS console がありました。

簡単に解説すると「CloudFormation は、変更されたテンプレートを元のテンプレートと比較し、変更セットを生成します。」ということで props に指定している箇所自体に変更がなければ変更とみなされないということですね。 具体的にいうと zip のプロパティまでは参照されないということです。

解決策として提案されているのは

  • Code.fromAsset() を使う。つまりソースコードからのデプロイ
  • objectVersionプロパティを指定。バージョン番号は別途管理する必要がある。

という感じでした。

S3 の変更イベントをトリガーにデプロイする

ということで、CDK がどうとかではなく、普通に思いつく解決策は S3 への PutObject を起点にデプロイをすることです。

S3へのレプリケーションをトリガーにLambdaのコード更新・新規バージョン発行を実行する とか、これ関係の記事はたくさんあるので、参考になります。

具体的な Lambda コードは、こんな感じになります。

import { LambdaClient, UpdateFunctionCodeCommand, UpdateFunctionCodeCommandInput } from '@aws-sdk/client-lambda'
import { S3Handler } from 'aws-lambda'

const client = new LambdaClient({ region: 'ap-northeast-1' })

export const handler: S3Handler = async (event) => {
  const bucket = event.Records[0].s3.bucket.name
  const object = event.Records[0].s3.object.key

  const functionNameMap =  {
    'sample.zip': 'sample',
    'sample-1.zip': 'sample1',
  }
  const FunctionName = functionNameMap[object]
  if (FunctionName === undefined) return

  const input: UpdateFunctionCodeCommandInput = { FunctionName, S3Bucket: bucket, S3Key: object }
  const updateCommand = new UpdateFunctionCodeCommand(input)
  await client.send(updateCommand)
}

で、それを CDK でデプロイします。

    const func =  new NodejsFunction(this, 'LambdaUpdaterFunction', {
      functionName: 'lambda-function-updater',
      entry: path.join(__dirname, 'src', 'index.ts'),
      handler: 'handler',
      architecture: Architecture.X86_64,
      runtime: Runtime.NODEJS_20_X,
    })
    func.addToRolePolicy(
      new PolicyStatement({
        actions: ['lambda:UpdateFunctionCode'],
        resources: [
            // 更新対象の Lambda の ARN を列挙する
        ],
      })
    )
    const packageBucket = Bucket.fromBucketName(this, 'Resource', `lambda-packages`)
    packageBucket.grantRead(func)
    packageBucket.addEventNotification(
      EventType.OBJECT_CREATED_PUT,
      new LambdaDestination(func)
    )

こちらはソースコードからデプロイするので NodejsFunction とかを使って簡易に実現します。 Lambda に zip ファイルからデプロイする対象の Function ARN を列挙して更新の許可を与えます。

zip ファイルが置かれているバケットの読み込み許可を更新 Lambda につけて、バケットにファイルが追加/変更されたときのイベントで Lambda を実行するように関連づけます。

この記事は参考になりました。

おまけ: NodejsFunction では ARM アーキテクチャを使えない

最初、lambda-function-updater をデプロイするとき、アーキテクチャに ARM を指定していたのですが、うまく動きませんでした。

なんでか調べたところ、こちらの issue Wrong SAM container image architecture when trying to build NodeJS lambda がありました。

使えなくないけど、公式にサポートしてないやり方になるよ、ってことで、まぁこの Lambda ぐらいだったら良いか… ってことで X86 アーキテクチャにしました。 もし ARM にしたいならビルドは自分でやっておいて zip からやった方が良いですね(話がループする)。

おまけ2: Lambda 関数をモノレポにしているとき sam build は使えない

S3 に zip ファイルをアップロードする前に TypeScript で書かれた Lambda を zip にするとき sam cli を使ってビルドしようとしたのですが、うまくいきませんでした。 調べてみると以下の issue を見つけました。

モノレポでビルドするのは茨の道のようです。 自分で esbuild を呼び出してビルドした方が簡単ですね。Lambda のコードを esbuild でビルドする方法はいろいろありますので、困らないでしょう。 僕らは package.json に定義しておきました。

        "build": "esbuild index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js"

ネスト構造の Step Functions を CDK でデプロイする

この記事はネスト構造の Step Functions を CDK でデプロイしようとしたとき、うまくいかないことがあったので記録したものです。

なお Step Functions のワークフロー定義は、別途 JSON で用意されていて、CDK からは生成しない前提になっています。 ファイルは definitions の下に入っている想定です。

CDK でネスト構造の Step Functions を 定義する

たとえば ParentStateMachine から ChildStateMachine を実行すると仮定しましょう。

    const parent = new StateMachine(this, 'ParentStateMachine', {
      definitionBody: DefinitionBody.fromFile(
        path.join(__dirname, `/definitions/parent.json`),
        {}
      ),
    })
    const child = new StateMachine(this, 'ChildStateMachine', {
      definitionBody: DefinitionBody.fromFile(
        path.join(__dirname, `/definitions/child.json`),
        {}
      ),
    })
    // 子ステートマシーンの実行を親ステートマシーンに許可する
    child.grantStartExecution(parent)

みたいにすれば実行できるのかな?と思ってました。

デプロイしてみたが実行できない

実際にデプロイしてみたところ動きませんでした。

AWS の公式ドキュメント実行中の実行から新しい AWS Step Functions ステートマシンを起動するを確認しました。

ネストされたステートマシンの IAM アクセス許可の設定 というところに書いてあります。

親ステートマシンは、ポーリングとイベントを使用して子ステートマシンが実行を完了したかどうかを判断します。ポーリングには states:DescribeExecution のアクセス許可が必要ですが、EventBridge 経由で Step Functions に送信されるイベントには events:PutTargets、events:PutRule、events:DescribeRule のアクセス許可が必要です。

というか grantStartExecution ってそれらの権限当たらないの?って思うじゃないですか。

CDKのソースコードを見てみました。

  public grantStartExecution(identity: iam.IGrantable): iam.Grant {
    return iam.Grant.addToPrincipal({
      grantee: identity,
      actions: ['states:StartExecution'],
      resourceArns: [this.stateMachineArn],
    });
  }

identityStateMachine だったら actions を追加して欲しいなぁ….(って思いますよね)。これだと grant って言ってる割にただ単に actions に関数名のを追加するだけでしょ?ってなるからなー(愚痴です)。

L2コンストラクタを継承してメソッドを追加する

ということで、 StateMachine を継承したコンストラクタを作り、 grantNestedExecute という許可メソッドを追加してみました。

  grantNestedExecute(parent: StateMachine) {
    this.grantStartExecution(parent)
    this.grantRead(parent)
  }

あとは先ほどの定義を修正するだけです。

    const parent = new MyStateMachine(this, 'ParentStateMachine', {
      definitionBody: DefinitionBody.fromFile(
        path.join(__dirname, `/definitions/parent.json`),
        {}
      ),
    })
    const child = new MyStateMachine(this, 'ChildStateMachine', {
      definitionBody: DefinitionBody.fromFile(
        path.join(__dirname, `/definitions/child.json`),
        {}
      ),
    })
    // 子ステートマシーンの実行を親ステートマシーンに許可する
    child.grantNestedExecute(parent)

HTTP メソッドを実行する場合

Step Functions で HTTP メソッドを実行するみたいなのも同様に HTTP タスクを実行するための IAM アクセス許可を参照して許可設定を追加する必要があります。

先ほどの例と同じで MyStateMachinegrantConnection みたいなメソッドを追加しておきます。

  grantConnection() {
    const stack = Stack.of(this)
    this.addToRolePolicy(
      new PolicyStatement({
        actions: ['states:InvokeHTTPEndpoint'],
        resources: [
          stack.formatArn({
            service: 'states',
            region: stack.region,
            account: stack.account,
            resource: 'stateMachine:*',
          }),
        ],
      })
    )
    this.addToRolePolicy(
      new PolicyStatement({
        actions: ['events:RetrieveConnectionCredentials'],
        resources: [
          stack.formatArn({
            service: 'events',
            region: stack.region,
            account: stack.account,
            resource: 'connection/*',
          }),
        ],
      })
    )
    this.addToRolePolicy(
      new PolicyStatement({
        actions: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'],
        resources: [
          stack.formatArn({
            service: 'secretsmanager',
            region: '*',
            account: '*',
            resource: 'secret:events!connection/*',
          }),
        ],
      })
    )
  }

まとめ

Step Functions のワークフロー定義を、別途 JSON から取り込むと、必要な権限がいい感じにはなってくれないことがわかりました。 でも Workflow Studio みたいなビジュアルエディタでワークフロー書くのが便利なんで、それはそれで使いたいんですよね。

grant メソッドの CDK コードを確認して、公式ドキュメントと見比べながら適切な権限があたっているか確認しながら進めていくとハマることなく進められると思います。

Next.js standalone ビルドしたアプリを App Runner で動かして X-Ray で計測する

こちらの記事は Next.js で作ったアプリケーションを AppRunner にデプロイする の続編となります。 前編を読まないとわからない内容ではないですが、もし良ければ事前に確認してください。

前の記事で Next.js を standalone ビルドしたアプリを App Runner にデプロイするところまで書きました。 さらに別の流れでNext.js を standalone ビルドしたアプリで New Relic エージェントを動かすという記事も公開しましたが、こちらはテスト環境での IAST 用でした。 本番の計測は X-Ray を使う(なぜ New Relic でない?とかは置いておいて)ので、 CDK / App Runner / Next.js standalone という構成でどうやって実現したのかを記録していきます。

App Runner と X-Ray

AWS のサービスで X-Ray を使うとき、たとえば Lambda や StepFunctions で利用したいなと思ったときは簡単に利用できます。

たとえば StepFunctions では CDK を使って、以下のように tracingEnabledtrue すると X-Ray トレーシングが有効になります。

// Define a second state machine with the Task state above
new sfn.StateMachine(this, 'ParentStateMachine', {
  definition: task,
  tracingEnabled: true,
});

Lambda の場合は、以下のように tracingActive にすると X-Ray トレーシングが有効になります。

new lambda.Function(this, 'Function', {
  codeSigningConfig,
  runtime: lambda.Runtime.NODEJS_18_X,
  tracing: Tracing.ACTIVE,
  handler: 'index.handler',
  code: lambda.Code.fromAsset(path.join(__dirname, 'lambda-handler')),
});

簡単ですね。

で、App Runner ではどうするのかな?と思って調べていたら、AWS App RunnerでAWS X-Rayを使った分散トレースをAWS CDKで構築してみる(App Runner + Aurora Serverless v2) という記事を見つけました。

なるほど、OpenTelemetry 必要なのか。単にフラグをONにするだけではできないんですね。

この時点で役に立ちそうな記事もいくつか読んでおきました。

参考記事からやり方を検討する

参考記事を確認して、タスクを整理しました。

  • CDK で OpenTelemetry 計測を有効にする(最初の参考記事)
  • Next.js の OpenTelemetry サポートを有効にする(次の2つの参考記事)

CDK で OpenTelemetry 計測を有効にする

AWS App RunnerでAWS X-Rayを使った分散トレースをAWS CDKで構築してみる(App Runner + Aurora Serverless v2)App Runnerサービスを作成 でやっているように、App Runner で OpenTelemetryを有効にするにはコンストラクタではなく、CloudFormation クラスを使って作っていますが、現時点は Service の props に observabilityConfiguration があるので、これにセットすると良さそうです。

    const observabilityConfiguration = new ObservabilityConfiguration(this, 'ObservabilityConfiguration', {
      observabilityConfigurationName: 'webapp-observability',
      traceConfigurationVendor: TraceConfigurationVendor.AWSXRAY,
    })
    const service = new Service(this, 'WebAppService', {
      source: Source.fromEcr({
        imageConfiguration: { port: 3000 },
        repository: props.ecrRepository,
        tagOrDigest: props.webAppVersion,
      }),
      serviceName: 'webapp-service',
      autoDeploymentsEnabled: true,
      observabilityConfiguration,
      healthCheck: HealthCheck.http({
        healthyThreshold: 2,
        interval: Duration.seconds(10),
        path: '/favicon.ico',
        timeout: Duration.seconds(10),
        unhealthyThreshold: 10,
      }),
    })

元々の Service に ObservabilityConfiguration で生成した設定を observabilityConfiguration に渡すだけです。ここは簡単ですね。 ポイントとしては traceConfigurationVendorAWSXRAY に設定するところぐらいかな。

Next.js の OpenTelemetry サポートを有効にする

こちらも参考記事を元に設定していきます。

まず依存ライブラリを追加します。今回はこのあたりを利用しています。

  • @opentelemetry/api
  • @opentelemetry/exporter-trace-otlp-grpc
  • @opentelemetry/id-generator-aws-xray
  • @opentelemetry/instrumentation-aws-sdk
  • @opentelemetry/instrumentation-http
  • @opentelemetry/instrumentation-undici
  • @opentelemetry/propagator-aws-xray
  • @opentelemetry/sdk-node
  • @opentelemetry/sdk-trace-node
  • @opentelemetry/semantic-conventions
  • @opentelemetry/resources

Next.js の公式ドキュメント Instrumentation 章 を読むと

To set up instrumentation, create instrumentation.ts|js file in the root directory of your project (or inside the src folder if using one).

ということなので instrumentation.ts をルートディレクトリか、src フォルダを使っている場合はその中に入れる必要がありそうなので、src/instrumentation.ts を設置しました。

続きのページに Next.js の公式ドキュメント OpenTelemetry 章 があったので、こちらも確認しました。

今回は Vercel を使うわけではないので、 Manual OpenTelemetry configuration の部分を参考にしていきます。

そうすると以下のようにランタイム判定してダイナミックローディングする必要がありそうなので、instrumentation.ts から instrumentation.node.ts を呼ぶように設定します。

// `instrumentation.ts`
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    await import('./instrumentation.node.ts')
  }
}

あとは instrumentation.node.ts で X-Ray に出力されるような設定をします。

// instrumentation.node.ts
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'
import { AWSXRayIdGenerator } from '@opentelemetry/id-generator-aws-xray'
import { AwsInstrumentation } from '@opentelemetry/instrumentation-aws-sdk'
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'
import { AWSXRayPropagator } from '@opentelemetry/propagator-aws-xray'
import { Resource } from '@opentelemetry/resources'
import { NodeSDK } from '@opentelemetry/sdk-node'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'

const sdk = new NodeSDK({
  idGenerator: new AWSXRayIdGenerator(),  // AWS X-RayフォーマットのIDを生成
  textMapPropagator: new AWSXRayPropagator(), // AWS X-Ray Trace ヘッダー伝播プロトコルを利用
  resource: Resource.default().merge(new Resource({ [ATTR_SERVICE_NAME]: 'webapp-service' })), // App Runner のサービス名を指定
  instrumentations: [ // 利用する拡張機能
    new HttpInstrumentation(), // HTTP API をトレース
    new AwsInstrumentation({ suppressInternalInstrumentation: true }), // aws-sdk をトレース
    new UndiciInstrumentation(), // Node fetch API をトレース
  ],
  spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],
})

sdk.start()

こんな感じです。

ビルドして確認してみたがコードが生成されない

ここで next build してコードが生成されるか確認してみました。

すると、instrumentation.ts が識別されてないのか?ビルド結果にコードが生成されません。

こういうときはソースコードを読むしかないですね。Next.js のビルドコードは next.js/packages/next/src/build/index.ts にあるようです。

で、それっぽいところを検索していると、これか?という部分

// #L882-L886
      const instrumentationHookDetectionRegExp = new RegExp(
        `^${INSTRUMENTATION_HOOK_FILENAME}\\.(?:${config.pageExtensions.join(
          '|'
        )})$`
      )

おやおやおや?拡張子のマッチが pageExtensions なんですけど。少し前の公式ドキュメントに何て書いてありましたっけ?

To set up instrumentation, create instrumentation.ts|js file in the root directory of your project (or inside the src folder if using one).

です。tsjs なんでしょ。なんで pageExtensions 使ってんのよ??このファイルはページなのか?

おそらくほとんどの人は ts のままで動くんでしょうが、僕らはこのコンフィグパラメータに覚えがあります。

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  pageExtensions: ['tsx', 'api.ts'],
}

tsxapi.ts だけにしてるんですわ。今回は PagesRouter 使ってるんですけど、 /pages/api/ の下の API コードのテストコードを同じディレクトリに設定したいんですね。 ルートで src と test を分けたくない。めんどいじゃないですか、そういうの。なんで実装とテストコードは同じディレクトリに入れたいんです。で、テストコードは API ではないので、pageExtensions を設定してページやAPIは .tsx.api.ts としています。たとえばこうすると user.api.ts というのがあったとき、同じディレクトリに user.test.ts というのを入れることができ、これはテストコードなのでビルドに含まれず、API でもないコードという対応ができます。 あとは API ではないコードとかも入れることができます(それはやってないけど)。

なんで、 instrumentation のファイル名を pageExtensions にするとは、何やってくれてんねん!って感じなんですけど、いったん従うしかないので、 instrumentation.api.ts というファイル名にし….ませんでした。

いろいろ理由はあるんですけど、CI/CD でビルドする直前に instrumentation.tsinstrumentation.api.ts にコピーする対応にしています。 たとえば、これは pageExtensions じゃおかしい!という issue を僕が出して ts|js になったときファイル名をリネームするのは嫌だなぁというとこで、CI/CDでコピーするところだけなら修正しても良いかなと。

で、手元では手動でファイルをコピーして next build したらファイルがビルドされていることが確認できました👏

デプロイして動かしてみる

出ない……

全体的には良さそうなんですが、何でかねー

困ったときは初心に戻って AWS の公式ドキュメントを漁ります。 X-Ray を使用した App Runner アプリケーションのトレース というのが App Runner のドキュメントの中にありました。

App Runner サービスで X-Ray トレースを使用するには、サービスのインスタンスに X-Ray サービスとやり取りするためのアクセス許可を付与する必要があります。

なるほどー、それはそうか。OpenTelemetry 指定で X-Ray のポリシー適用されるかな?と思ったけど、ObservabilityConfiguration の vendor 判断してまではやってくれないようです。

    const observabilityConfiguration = new ObservabilityConfiguration(this, 'ObservabilityConfiguration', {
      observabilityConfigurationName: 'webapp-observability',
      traceConfigurationVendor: TraceConfigurationVendor.AWSXRAY,
    })
    const service = new Service(this, 'WebAppService', {
      source: Source.fromEcr({
        imageConfiguration: { port: 3000 },
        repository: props.ecrRepository,
        tagOrDigest: props.webAppVersion,
      }),
      serviceName: 'webapp-service',
      autoDeploymentsEnabled: true,
      observabilityConfiguration,
      healthCheck: HealthCheck.http({
        healthyThreshold: 2,
        interval: Duration.seconds(10),
        path: '/favicon.ico',
        timeout: Duration.seconds(10),
        unhealthyThreshold: 10,
      }),
      instanceRole: new Role(this, 'WebAppServiceXRayWrite', {
        assumedBy: new ServicePrincipal('tasks.apprunner.amazonaws.com'),
        managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName('AWSXRayDaemonWriteAccess')],
      }),
    })

CDK で Service を作るときのプロパティに instanceRole を追加しました。

まとめ

Next.js standalone ビルドしたアプリを App Runner で動かしていたところに、 X-Ray での計測を追加してみました。 他の AWS サービスと違って X-Ray を有効にするのがちょっと面倒だったり、 pageExtensions いじっているとビルド時に工夫が必要だったりとか、いくつかハマりポイントはありますが、わかってしまえば対策は難しくありませんでした。 実際はものすごい調べるの時間かかったりするところもあったので、同じような課題になった人の解決に役立てば幸いです。