Technote

by sizuhiko

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 いじっているとビルド時に工夫が必要だったりとか、いくつかハマりポイントはありますが、わかってしまえば対策は難しくありませんでした。 実際はものすごい調べるの時間かかったりするところもあったので、同じような課題になった人の解決に役立てば幸いです。

Next.js を standalone ビルドしたアプリで New Relic エージェントを動かす

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

前の記事で Next.js を standalone ビルドしたアプリを App Runner にデプロイするところまで書きました。

そのアプリでテスト環境のとき New Relic エージェントを入れたいということでやってみたんですが、ものすごいハマりどころが多かったので誰かの役に立てばと思い記事にします。

エージェントのインストールと起動方法

New Relic の Node.js エージェントは node-newrelic というリポジトリにある OSS ライブラリです。 利用するときは以下のように Node.js の –require module オプションを使って起動するようです。

$ node -r newrelic your-program.js

Next.js で利用する場合の例として Next.js example projects というのも用意されていますが、今回は standalone モードでビルドされてますので、 Custom Next.js servers というところのやり方と一緒で結局 --require module モードで起動することになります。

やってみる

まず npm install newrelic でパッケージを追加しておいて、 Next.js のリポジトリにある standalone ビルドの Dockerfile で起動スクリプトを以下のように変更します。

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "-r", "newrelic", "server.js"]

はい、起動しません。

Error: Cannot find module ‘/app/nodemodules/lodash/lodash.js’. Please verify that the package.json has a valid “main” entry at Object. (/app/nodemodules/@newrelic/security-agent/lib/nr-security-agent/lib/core/commonUtils.js:20:16)

調べよう

newrelic/csec-node-agent という依存モジュールの package,json の中に lodash があるのを見つけました。

で、使っている場所はこの行なんですが、いやな使い方をしていますね。

const lodash = require('lodash')
// ここを
const isEmpty = require('lodash/isEmpty')

下のように記述してくれていれば良い(実際 isEmpty しか使ってない)のですけど… まぁとはいえ動きません。

まず何でかっていうと、そもそも Next.js は standalone モードでビルドされていて、コンテナサイズを小さくするために Next.js から依存関係にないパッケージは入りません。 つまり New Relic のエージェントを --require module モードで起動すると、依存しているパッケージがないので動かないという、まぁ至極その通りな感じであります。

で、いろいろネットの情報を調べていたら、サーバー起動する直前で npm i newrelic すると良いよというのを見つけました。 以下のソース

# Install the next.js plugin after it copies the standalone server and static bits to workdir
# I cannot figure out why If I just install `@newrelic/next` and add to project's package.json
# why it does not copy them over but it does not for some reason so we will add it after all the copying
# occurs
RUN npm i newrelic

USER nextjs

EXPOSE 3000

ENV PORT 3000

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"

CMD ["node", "-r", "newrelic", "server.js"]

なるほどねー、ってことでやってみました。

…. 動かない….

なんでかって言うと、僕らのアプリでは、依存関係で(厳密にいうと依存の依存に)すでに lodash があったんですね。つまりこの状況( /app/node_modules/lodash/ が存在する )で依存の依存で lodash 入れようと思ってもすでに入っているので、追加(上書き)では入りません。 まぁそうですよね。npm とはそういうものです。で、その lodash は standalone モードで使ってないモジュールは消されているので lodash.js という一等地の全部インポートファイルはありません。せめて require('lodash/isEmpty') なっていれば…(isEmptyはあった)。 とはいえ、他にも依存モジュールあったんで、lodash だけの問題とも言い切れませんね。

先人が通った道

Next.js に New Relic 導入し、docker コンテナの起動に失敗した話 に出会いました。 node_modules の下のファイルをコピーする….。まぁ動きそうな気はするけど、コピーはいやだなぁ….ということで違う方法を模索します。

過去には、Next.js 用のライブラリがあったようで、そのときの issue も見つけました。

でそのときの対応が node-newrelic リポジトリになるときのマイグレーション issue も見つけました。

ということで、何だか standalone モードへの対応は考慮されているようですが、前述のとおり Web アプリ側でも依存関係にあってエージェント側でも使っているみたいなケースでは失敗してしまうこともありえます。 僕がソースコードを見回った結果は lodash だけっぽかったので、そこだけ修正すれば大丈夫なのかもですけど。

グローバルインストールという脇道(ハック)

アプリケーションの下の node_modules だと入らないので、別のところでクリーンインストールすれば良いのでは?という結論に至りました。 とりあえずアプリと別のディレクトリを掘ってやってもよかったんですけど、面倒だったのでグローバルインストールを使います。

# Install dependencies only when needed
FROM base AS deps

# 長いので省略 一番下に追加
RUN npm i -g newrelic

マルチステージビルドの deps の最後でグローバルインストールします。 続いて standalone ビルドしたファイルをコピーした後で、グローバルインストールフォルダを丸っとコピーして持ってきます。 で、そこに NODE_PATH 環境変数を通すというハックを入れました。

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

COPY --from=deps /usr/local/lib/node_modules /usr/local/lib/node_modules
ENV NODE_PATH=/usr/local/lib/node_modules

おそらく gist の例とかは newrelic が依存しているライブラリを Next.js のアプリが使っていない前提で書かれていると思われる。

結局 newrelic が依存しているものを Next が良い感じに standalone ビルドすると当然 newrelic なんぞ?みたいになって動かないからな。ライブラリ作者はそこまで気が回ってないのだろう

グローバルはハックな方法だと思うけど、正しいやり方をサポートは対応してくれるんかな….

ライブラリは OSS なんで issue 書いてよ、というのは、まぁそのとおり(まだやってません、すみません)。

やってみる(2回目)

動きません….. 😭

Cannot find module ‘@newrelic/native-metrics’\nRequire stack:\n- /usr/local/lib/nodemodules/newrelic/lib/sampler.js\n- /usr/local/lib/nodemodules/newrelic/lib/agent.js\n- /usr/local/lib/node_modules/newrelic/index.js\n- internal/preload

Cannot find module ??なんですって?

"optionalDependencies": {
    "@contrast/fn-inspect": "^4.2.0",
    "@newrelic/native-metrics": "^11.0.0",
    "@prisma/prisma-fmt-wasm": "^4.17.0-16.27eb2449f178cd9fe1a4b892d732cc4795f75085"
},

なんか optionalDependencies があるんですけど、 npm i newrelic だけじゃないの?ドキュメントに書いてあった?

調べよう(2回目)

ありましたよ、ドキュメント。 Node.jsのVM測定

New Relic Node.js エージェントの v2.0.0 以降、ネイティブモジュールはオプションの依存関係となり、自動的にインストールされるようになりました。

じゃぁインストールされんのかな?と思うじゃないですか。ダメです。

展開プラットフォームでネイティブ モジュールをコンパイルするには、 node-gypパッケージの手順に従います。ネイティブ Node.js モジュールをインストールするための前提条件は次のとおりです。 プラットフォーム 前提条件 Unix/Linux Python(v2.7推奨、v3.x.xは未サポート)、make、C/C++コンパイラ(GCCなど)

いやいや、 Python 必要なん? (node-gyp だから当然だけど)。 つまり自動インストールしようとした結果、インストールに必要なもの(今回でいうとgccとかPytnonとか)がインストールされてないので、自動インストール自体が失敗するということになります。

Node.jsエージェントのインストール という公式ドキュメントをみると

オプション:追加のNode.jsランタイムレベル統計情報を取得するため、@newrelic/native-metricsパッケージがインストールされていることを確認してください。

ってことで、まぁこれは必要なようです。

結局のところの deps は以下のようになりました。

# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
# RUN apk add --no-cache libc6-compat
RUN apk update && \
  apk upgrade && \
  apk add --no-cache libc6-compat make gcc g++ python3
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi

RUN npm i -g newrelic
RUN npm i -g @newrelic/native-metrics

やってみる(3回目)

動いたー🎉

まとめ

以下、苦労したところ

  • ドキュメントが分散していてわかりずらい(最初から github でなく公式 Docs みておいた方が良かったかも)
  • Next.js の standalone モードとは驚くほど相性が悪い

前者は改善の余地ありそうだけど、後者は何ともならなそうですね。 今回は別の場所で入れて NODE_PATH するというハックを使いましたが、結局エージェントとWebアプリで依存関係の競合が起きたときに困ると言うことには違いないです。たとえば違うバージョン使っていたら?とかそれが原因で動かなかったら?ということは今後容易に起きそうです。 希望としてはエージェントは1ファイルにパッキングしておいて欲しいということですね。というか require module で動くんだから自分が必要なパッケージは固めておいて欲しいですよね。Node.js のアプリだったらみんながみんなフルセットの node_modules を使っていないことがあるのは Node.js 使っている人ならわかりそうなのになぁという気分です。

ちゃんと時間が取れて、良い感じの英文が書けたら issue 出そうかな。それも OSS への貢献ですね。 この記事で同じような境遇になった人が、解決の助けになったら、と思います。

PHPカンファレンス小田原2025に参加しました

この記事は PHPカンファレンス小田原2025 に参加したレポートです。

今年も月刊PHPカンファレンスなようですが、昨年末ぐらいから多忙のため通常のPHP勉強会にも出れない日々が続いておりました。 ようやく落ち着いてきてイベントにも出れるようになってきたので、久々のイベント参加です(というか、もう小田原ぐらいには落ち着いてないとダメだろうという前提でチケットを購入していた)。

小田原は新宿駅から乗り換えなしで行けるし、都民にとってはPHPカンファレンス東京と並んで行きやすいカンファレンスです。

なんか去年もそうだった記憶があるけど、ロマンスカーあんまり乗らないので事前に購入するという発想がなかった。ロマンスカーなら70分、快速急行だと100分ぐらいなのでギリ間に合うかなという感じ。

はい。

受付してノベルティをGETして会場へ急足。小田原のノベルティTシャツが今年もステキだった(写真撮り忘れた)。まだ着てないけど昨年のもお気に入りなんで、今年のも何度も切る場面がありそう。

参加したセッション

AM

午前中はメイン会場の かま

  • キーノート OSSコントリビュートをphp-srcメンテナの立場から語る
  • PHP 8.x 時代のクラス設計
  • PHPと旅する OSI 7階層
  • PHPバージョンアップから始めるOSSコントリビュート
  • 古き良きLaravelのシステムは関数型スタイルでリファクタできるのか

OSSコントリビュートは普段から心がけているので、自分が使うライブラリだったり、自分が作るライブラリだったり、いろいろあるけど貢献する意識づけとかはチームメンバーにも植え付けていきたいし、カンファレンスとかにも出て刺激をうけてほしいですね。会場アンケートでコントリビュートしたことある人、というアンケートに、こういうところに来る人なんでバイアスかかってますけどね、というコメントがありましたけど、確かにーと思うところ。

PHPは個人サービスを作る時のバックエンドで長らく使っていたけど、最近はNode.jsばかりなので昔のサービスのメンテぐらいでしか書いてないけど、クラス設計の話を聞くと昔から他の言語のいいところを取り入れて進化してきたので、クラスの話を聞くとJavaっぽいと思うのは自然な感じかな。いろいろ最新状況が知れてよかった。

OSI7階層の話は、別のカンファレンスでそれぞれの層の話を聞いたりしてるので、全体の流れの話で聞けるのもよかった。僕はそういう突き詰める系はあんまりやらないけど、自分の好きな開発ができるときって楽しいですよね。僕だと個人Webサービス作ってる時が楽しいです。

リファクタリングは、どのタイミングでやるかは非常に重要だけど、聞いた話レベルでやるのは大変そうだなーと思った。改善は一気にできないことが多いので、少しでもやりやすい方法で進められると良いですね。

ランチ

ランチではランチコラボで、地元のお店でプチサービスが受けられるところが準備されていたり、ぼっち飯にならないようにランチマッチング企画が準備されていたり、すごい充実してました。 とはいえ、僕はタイミングを逃して1人で美味しそうな料理とお水があるお店に吸い込まれていきました。 小田原の路地にあるお店、どこも入り口が広くて開放していたので、様子が伺えてよかったです。

PM

午後は あじ

  • 恣意性から考える、変更に強いモデルの作り方
  • タイムゾーンの奥地は思ったよりも闇深いかもしれない

最後は かま に戻って

  • New RelicのAPMを活用したECサービスにおけるメール遅延解消の舞台裏
  • スポンサーエレベータピッチ
  • LT
  • ぺちおだ大合戦

恣意性はふむふむ、そうだなーと思うところがあったり、自分はこうだなーと思うところがあったりして、考え方の整理ができてよかったです。

タイムゾーンはちょっと思ってた内容と違ってたけど、まぁUTCにしとけってとことはそうですね!

アプリの計測は大事で、最近はAWS使うことが多いのでX-Rayを使っていることが多いけど、何を使うにせよデータがないと困りますしね。最近業務でX-Ray使っていて新しい知見もあったので、ブログにも書いていこうと思います。

ぺちおだ大合戦、最初はどんなことが起きるんだろう?と思ったけど、終わってみてこれはすごい企画だなーというのと、運営の準備大変だっただろうな、お疲れ様でした。とても楽しかったです!という一言です。とにかくすごい。

懇親会

万葉の湯で豪華な懇親会がありました。畳部屋での立食形式もあんまり体験したことない感じだった。こちらも写真は撮ってませんでした…(楽しかったという記憶は間違いない)。 最終のロマンスカーの時間があるので、少し早く離脱して無事帰宅できました。

全体的な感想

昨年も参加して、今年もいろいろ考えられてて企画力がすごいですよね。

来年のことについてはコメントしませんが、これだけの企画をやると燃え尽き症候群とかになっても不思議ではないと思うので、ゆっくり休んで他のカンファレンスに参加して充電?してかな。

参加した方もためになったし、元気がもらえたし、楽しかったし、とても充実した1日でした。