ES Modules を使用する Node.js アプリケーションに対して CloudWatch Application Signals を有効化してみる (EKS の場合)

ES Modules を使用する Node.js アプリケーションに対して CloudWatch Application Signals を有効化してみる (EKS の場合)

クラウド事業本部の枡川です。
CloudWatch Application Signals を使うことで AWS のサービスだけで OpenTelemetry 互換のアプリケーションパフォーマンスモニタリングを実装することができます。
ただし、OpenTelemetry 側のサポートにも制約があり、利用可能な環境は限られています。

https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Application-Signals-supportmatrix.html#ESM-limitations

Node.js においても、現時点では Common JS 環境で利用することが推奨されています。

The AWS Distro for Opentelemetry Node.js supports two module systems: ECMAScript Modules (ESM) and CommonJS (CJS). To enable Application Signals, we recommend that you use the CJS module format because OpenTelemetry JavaScript’s support of ESM is experimental and a work in progress.
https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Application-Signals-supportmatrix.html#CloudWatch-Application-Signals-supportmatrix-node

とはいえ、明示的にインスツルメント処理を行えば ES Modules のアプリケーションでも Application Signals を利用できるようなので、試してみました。

ESM support is ongoing; a few adjustments are needed for configuration and startup commands.
https://github.com/open-telemetry/opentelemetry-js/blob/main/doc/esm-support.md

ES Modules での利用に必要なものを整理する

まず、Node.js のバージョンは 18.19.0 以上である必要があります。

The Node.js version must be 18.19.0 or later.
https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Application-Signals-supportmatrix.html

また、@aws/aws-distro-opentelemetry-node-autoinstrumentation@opentelemetry/instrumentation をインストールする必要があります。
最後に、NODE_OPTIONS=' --import @aws/aws-distro-opentelemetry-node-autoinstrumentation/register --experimental-loader=@opentelemetry/instrumentation/hook.mjs' のように NODE_OPTIONS を指定する必要があります。
つまり、aws-otel-js-instrumentation で紹介されている自動計装のセットアップを行う形になります。

https://github.com/aws-observability/aws-otel-js-instrumentation/tree/914fdd76b663f6150977d702fcdf4400c55c6931/aws-distro-opentelemetry-node-autoinstrumentation

https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Application-Signals-Enable-EKS.html

EKS で Application Signals を利用する際、Init コンテナが自動で挿入されて必要なコンポーネントを配置してくれたり、必要な環境変数をセットしてくれたりします。
その部分を自前でやる必要があります。

やってみる

今回は EKS 環境で試してみます。
まず、CloudWatch Observability アドオン をインストールした EKS クラスターを作成します。
詳細な手順は下記記事と同様のため、スキップします。

https://dev.classmethod.jp/articles/eks-cloudwatch-application-signals/

合わせて Aurora PostgreSQL も作成します。
全体的な構成としては下図のようになります。

esmodule.png

作成した EKS 上にアプリケーションを作成してきます。
まず、ES Modules を利用した Node.js アプリケーションとして、HonoPrisma を利用したアプリケーションを用意します。
index.ts は下記のように作成しています。

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PrismaClient } from "./generated/prisma/client.js";

const prisma = new PrismaClient();
const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});
app.get("/users", async (c) => {
  const users = await prisma.user.findMany();
  return c.json(users);
});

serve(
  {
    fetch: app.fetch,
    port: 80,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

prisma.schema は下記を利用しました。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init

generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  runtime      = "nodejs"
  moduleFormat = "esm"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id    Int    @id @default(autoincrement())
  email String @unique
  name  String
  posts Post[]
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String
  published Boolean
  user      User    @relation(fields: [userId], references: [id])
  userId    Int
}

provider として prisma-client-js を利用した場合、Prisma クライアントが node_modules の中に作成されますが、prisma-client を指定することで output に指定したディレクトリに作成させることができます。
この際、moduleFormatesm を指定することで、ES Modules を利用した形でクライアントを生成できるようなので使ってみます。
Early Access とは記載されているものの、将来的には prisma-client がデフォルトになるようです。

prisma-client (Early Access): Newer and more flexible version of prisma-client-js with ESM support; it outputs plain TypeScript code and requires a custom output path
https://www.prisma.io/docs/orm/prisma-schema/overview/generators

明示的に @aws/aws-distro-opentelemetry-node-autoinstrumentation@opentelemetry/instrumentation@0.54.0 をインストールします。

npm install @aws/aws-distro-opentelemetry-node-autoinstrumentation
npm install @opentelemetry/instrumentation@0.54.0

package.json は下記のようになっています。

{
  "name": "my-app",
  "type": "module",
  "scripts": {
    "dev": "tsx watch --env-file=.env.dev src/index.ts",
    "build": "tsc",
    "start": "node dist/src/index.js"
  },
  "prisma": {
    "seed": "tsx prisma/seed.ts"
  },
  "dependencies": {
    "@aws/aws-distro-opentelemetry-node-autoinstrumentation": "^0.6.0",
    "@hono/node-server": "^1.14.2",
    "@opentelemetry/instrumentation": "^0.202.0",
    "@prisma/client": "^6.8.2",
    "hono": "^4.7.10"
  },
  "devDependencies": {
    "@types/node": "^20.11.17",
    "prisma": "^6.8.2",
    "tsx": "^4.7.1",
    "typescript": "^5.8.3"
  }
}

DockerFile は下記のように準備します。
環境変数の設定は AWS 公式ドキュメント 記載のものをそのまま指定しています。

FROM node:22-bookworm

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npx prisma generate
RUN npm run build

ENV OTEL_AWS_APPLICATION_SIGNALS_ENABLED=true
ENV OTEL_TRACES_SAMPLER_ARG='endpoint=http://cloudwatch-agent.amazon-cloudwatch:2000'
ENV OTEL_TRACES_SAMPLER='xray'
ENV OTEL_EXPORTER_OTLP_PROTOCOL='http/protobuf'
ENV OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='http://cloudwatch-agent.amazon-cloudwatch:4316/v1/traces'
ENV OTEL_AWS_APPLICATION_SIGNALS_EXPORTER_ENDPOINT='http://cloudwatch-agent.amazon-cloudwatch:4316/v1/metrics'
ENV OTEL_METRICS_EXPORTER='none'
ENV OTEL_LOGS_EXPORTER='none'
ENV NODE_OPTIONS='--import @aws/aws-distro-opentelemetry-node-autoinstrumentation/register --experimental-loader=@opentelemetry/instrumentation/hook.mjs'
ENV OTEL_SERVICE_NAME='hono'
ENV OTEL_PROPAGATORS='tracecontext,baggage,b3,xray'

EXPOSE 80
CMD ["npm", "run", "start"]

ES Modules を利用する際、NODE_OPTIONS--import @aws/aws-distro-opentelemetry-node-autoinstrumentation/register --experimental-loader=@opentelemetry/instrumentation/hook.mjs のように @opentelemetry/instrumentation/hook.mjs をフックとして登録する必要があります。

https://github.com/open-telemetry/opentelemetry-js/blob/main/doc/esm-support.md

将来的には register を利用した形に移行することで、--experimental-loader=@opentelemetry/instrumentation/hook.mjs の指定は不要になるようです。

https://github.com/open-telemetry/opentelemetry-js/issues/4933

@aws/aws-distro-opentelemetry-node-autoinstrumentation/register は Node SDK の初期化スクリプトです。
OTEL_AWS_APPLICATION_SIGNALS_ENABLED を true にした際、 Application Signals を利用できるように良い感じに初期化を行ってくれます。

https://github.com/aws-observability/aws-otel-js-instrumentation/blob/914fdd76b663f6150977d702fcdf4400c55c6931/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-opentelemetry-configurator.ts

マニフェストファイルも作成します。
EKS の場合、instrumentation.opentelemetry.io/inject-nodejs: "true" のアノテーションを付与することで起動時に環境変数などが差し込まれますが、今回は手動で設定するので不要です。
AWS 公式ドキュメント記載の通り、下記環境変数を設定します。

  • OTEL_RESOURCE_ATTRIBUTES_POD_NAME
  • OTEL_RESOURCE_ATTRIBUTES_NODE_NAME
  • OTEL_RESOURCE_ATTRIBUTES_DEPLOYMENT_NAME
  • POD_NAMESPACE
  • OTEL_RESOURCE_ATTRIBUTES

今回は下記を利用します。

apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: default
  name: hono
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: hono
  replicas: 1
  template:
    metadata:
      labels:
        app.kubernetes.io/name: hono
    spec:
      containers:
        - image: xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/hono-app:v4
          imagePullPolicy: Always
          name: hono
          ports:
            - containerPort: 80
          env:
            - name: DATABASE_URL
              value: postgres://postgres:password@sample-aurora-postgres-cluster.cluster-xxxxxxxx.ap-northeast-1.rds.amazonaws.com:5432/postgres
            - name: OTEL_RESOURCE_ATTRIBUTES_POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: OTEL_RESOURCE_ATTRIBUTES_NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
            - name: OTEL_RESOURCE_ATTRIBUTES_DEPLOYMENT_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.labels['hono']
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: OTEL_RESOURCE_ATTRIBUTES
              value: "k8s.deployment.name=$(OTEL_RESOURCE_ATTRIBUTES_DEPLOYMENT_NAME),k8s.namespace.name=$(POD_NAMESPACE),k8s.node.name=$(OTEL_RESOURCE_ATTRIBUTES_NODE_NAME),k8s.pod.name=$(OTEL_RESOURCE_ATTRIBUTES_POD_NAME)"
          resources:
            requests:
              cpu: "0.5"

kubectl apply 実行後、正しく Pod が起動しました。

% kubectl describe pod hono-59dcb7d7d6-p2nhf
Name:             hono-59dcb7d7d6-p2nhf
Namespace:        default
Priority:         0
Service Account:  default
Node:             i-06e3841e5afe0ce99/10.0.101.234
Start Time:       Sun, 08 Jun 2025 12:37:56 +0900
Labels:           app.kubernetes.io/name=hono
                  pod-template-hash=59dcb7d7d6
Annotations:      instrumentation.opentelemetry.io/inject-nodejs: true
Status:           Running
IP:               10.0.101.113
IPs:
  IP:           10.0.101.113
Controlled By:  ReplicaSet/hono-59dcb7d7d6
Containers:
  hono:
    Container ID:   containerd://e6ddd5c7cbfdabbb2171ef09520528804701a9adca316948630a57b88f336ff5
    Image:          xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/hono-app:v4
    Image ID:       xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/hono-app@sha256:691cdb40f130ee33a5be47c2f7438e2e9ba3ba63ddb77dc2b2ce926893cd7117
    Port:           80/TCP
    Host Port:      0/TCP
    State:          Running
      Started:      Sun, 08 Jun 2025 12:38:23 +0900
    Ready:          True
    Restart Count:  0
    Requests:
      cpu:  500m
    Environment:
      DATABASE_URL:                              postgres://postgres:password@sample-aurora-postgres-cluster.cluster-xxxxxxx.ap-northeast-1.rds.amazonaws.com:5432/postgres
      OTEL_RESOURCE_ATTRIBUTES_POD_NAME:         hono-59dcb7d7d6-p2nhf (v1:metadata.name)
      OTEL_RESOURCE_ATTRIBUTES_NODE_NAME:         (v1:spec.nodeName)
      OTEL_RESOURCE_ATTRIBUTES_DEPLOYMENT_NAME:   (v1:metadata.labels['hono'])
      POD_NAMESPACE:                             default (v1:metadata.namespace)
      OTEL_RESOURCE_ATTRIBUTES:                  k8s.deployment.name=$(OTEL_RESOURCE_ATTRIBUTES_DEPLOYMENT_NAME),k8s.namespace.name=$(POD_NAMESPACE),k8s.node.name=$(OTEL_RESOURCE_ATTRIBUTES_NODE_NAME),k8s.pod.name=$(OTEL_RESOURCE_ATTRIBUTES_POD_NAME)
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-cchv2 (ro)
Conditions:
  Type                        Status
  PodReadyToStartContainers   True
  Initialized                 True
  Ready                       True
  ContainersReady             True
  PodScheduled                True
Volumes:
  kube-api-access-cchv2:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  3607
    ConfigMapName:           kube-root-ca.crt
    ConfigMapOptional:       <nil>
    DownwardAPI:             true
QoS Class:                   Burstable
Node-Selectors:              <none>
Tolerations:                 node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type    Reason     Age    From               Message
  ----    ------     ----   ----               -------
  Normal  Scheduled  4m29s  default-scheduler  Successfully assigned default/hono-59dcb7d7d6-p2nhf to i-06e3841e5afe0ce99
  Normal  Pulling    4m29s  kubelet            Pulling image "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/hono-app:v4"
  Normal  Pulled     4m2s   kubelet            Successfully pulled image "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/hono-app:v4" in 26.678s (26.678s including waiting). Image size: 599280886 bytes.
  Normal  Created    4m2s   kubelet            Created container: hono
  Normal  Started    4m2s   kubelet            Started container hono

コンテナ内に乗り込んで index.js を確認すると下記のようになります。

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PrismaClient } from "./generated/prisma/client.js";
const prisma = new PrismaClient();
const app = new Hono();
app.get("/", (c) => {
  return c.text("Hello Hono!");
});
app.get("/users", async (c) => {
  const users = await prisma.user.findMany();
  return c.json(users);
});
serve(
  {
    fetch: app.fetch,
    port: 80,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

prisma で生成された client.js も確認してみます。
良い感じに ES Modules 形式で import するファイルが生成できています。

/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// @ts-nocheck
/**
 * This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
 *
 * 🟢 You can import this file directly.
 */
import * as process from "node:process";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
import * as runtime from "@prisma/client/runtime/library";
import * as $Enums from "./enums.js";
import * as $Class from "./internal/class.js";
import * as Prisma from "./internal/prismaNamespace.js";
export * as $Enums from "./enums.js";
/**
 * ## Prisma Client
 *
 * Type-safe database client for TypeScript
 * @example
 * ```
 * const prisma = new PrismaClient()
 * // Fetch zero or more Users
 * const users = await prisma.user.findMany()
 * ```
 *
 * Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
 */
export const PrismaClient = $Class.getPrismaClientClass(__dirname);
export { Prisma };
// file annotations for bundling tools to include these files
path.join(__dirname, "libquery_engine-debian-openssl-3.0.x.so.node");
path.join(
  process.cwd(),
  "src/generated/prisma/libquery_engine-debian-openssl-3.0.x.so.node"
);

Kubernetes Service を作成します。

apiVersion: v1
kind: Service
metadata:
  namespace: default
  name: hono
spec:
  ports:
    - port: 80
      targetPort: 80
      protocol: TCP
  type: NodePort
  selector:
    app.kubernetes.io/name: hono

IngressClass を作成します。

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  namespace: default
  labels:
    app.kubernetes.io/name: LoadBalancerController
  name: alb
spec:
  controller: eks.amazonaws.com/alb

最後に Ingress を作成します。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  namespace: default
  name: hono-ingress
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
spec:
  ingressClassName: alb
  rules:
    - http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: hono
                port:
                  number: 80

作成された ALB 経由でアクセスすると、Prisma 経由で DB 内のデータも取得できました。

% curl http://k8s-default-honoingr-ab839e5909-1140029561.ap-northeast-1.elb.amazonaws.com/users
[{"id":1,"email":"alice@prisma.io","name":"Alice"},{"id":2,"email":"bob@prisma.io","name":"Bob"}]

Application Signals 側でもサービスとして確認できています。

signal.png

SLA も良い感じに設定できます。

signal2.png

signal3.png

X-Ray のコンソールにトレースが送信されています。

trace.png

CloudWatch Application Signals に表示はされるものの、DB アクセス部分にかかる時間は可視化できていません。
Prisma の tracing 機能を使いたい所です。

@prisma/instrumentation@latest は OpenTelemetry Meta Packages for Node に含まれていないので、SDK 初期化時に明示的に指定する必要があります。

https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/metapackages/auto-instrumentations-node

--import @aws/aws-distro-opentelemetry-node-autoinstrumentation/register は詰まる所 @opentelemetry/sdk-node を初期化して起動する処理なので、同等の処理を register.ts などに記載して import すれば上手く動作するはずです。

https://github.com/aws-observability/aws-otel-js-instrumentation/blob/main/aws-distro-opentelemetry-node-autoinstrumentation/src/register.ts

と思ったのですが、Application Signals を使う場合は、SDK 初期化時に結構いろいろとカスタマイズが必要です。
※ 環境変数で OTEL_AWS_APPLICATION_SIGNALS_ENABLED=true と指定した場合、SDK 初期化時にそれなりに複雑な処理が挟まります。

https://github.com/aws-observability/aws-otel-js-instrumentation/blob/914fdd76b663f6150977d702fcdf4400c55c6931/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-opentelemetry-configurator.ts

@opentelemetry/instrumentation-aws-sdk に対するパッチ処理なんかも実施しています。

https://github.com/aws-observability/aws-otel-js-instrumentation/blob/914fdd76b663f6150977d702fcdf4400c55c6931/aws-distro-opentelemetry-node-autoinstrumentation/src/patches/instrumentation-patch.ts

カスタマイズもやってやれないことは無いでしょうが、ちょっと大掛かりになりそうだったので、今回は DB アクセス時に手動でスパンを作成してみました。

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PrismaClient } from "./generated/prisma/client.js";
import { trace } from "@opentelemetry/api";

const prisma = new PrismaClient();
const app = new Hono();
const tracer = trace.getTracer("hono");

app.get("/", (c) => {
  return c.text("Hello Hono!");
});
app.get("/users", async (c) => {
  const span = tracer.startSpan("fetch-users");
  const users = await prisma.user.findMany();
  span.setAttribute("db.operation", "findMany");
  span.setAttribute("user.count", users.length);
  span.end();
  return c.json(users);
});

serve(
  {
    fetch: app.fetch,
    port: 80,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

上手くスパンを作成できました。

span.png

users パスへアクセスした時の処理がほぼほぼ DB アクセスのみで分かり辛いので SQS へメッセージを登録する処理も追加します。

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PrismaClient } from "./generated/prisma/client.js";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { trace } from "@opentelemetry/api";

const prisma = new PrismaClient();
const app = new Hono();
const tracer = trace.getTracer("hono");

const sqsClient = new SQSClient({
  region: process.env.AWS_REGION || "ap-northeast-1",
});
const QUEUE_URL = process.env.SQS_QUEUE_URL;

app.get("/", (c) => {
  return c.text("Hello Hono!");
});
app.get("/users", async (c) => {
  const span = tracer.startSpan("fetch-users");
  const users = await prisma.user.findMany();
  span.setAttribute("db.operation", "findMany");
  span.setAttribute("user.count", users.length);
  span.end();
  // SQS へのメッセージ処理終わり
  const sendMessageCommand = new SendMessageCommand({
    QueueUrl: QUEUE_URL,
    MessageBody: `Fetched ${users.length} users`,
  });
  await sqsClient.send(sendMessageCommand);
  console.log(`Sent message to SQS: Fetched ${users.length} users`);
  // SQS へのメッセージ処理終わり
  return c.json(users);
});

serve(
  {
    fetch: app.fetch,
    port: 80,
  },
  (info) => {
    console.log(`Server is running on http://localhost:${info.port}`);
  }
);

こちらは上手く自動計装が効きました。
DB アクセスにかかった時間と SQS へのメッセージ登録にかかった時間を上手く可視化できています。

add-sqs.png

最後に

ES Modules を利用している際に CloudWatch Application Signals を利用してみました。
追加作業は必要でしたが、そこまで負荷にはならないように思いました。
ただ、@aws/aws-distro-opentelemetry-node-autoinstrumentation/register をカスタマイズして利用しようと思うとなかなか骨が折れそうだったので、OpenTelemetry Meta Packages for Node に含まれないアプリケーションに対しても自動計装を仕込みたい場合は注意が必要かと思います。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.