ES Modules を使用する Node.js アプリケーションに対して CloudWatch Application Signals を有効化してみる (EKS の場合)
クラウド事業本部の枡川です。
CloudWatch Application Signals を使うことで AWS のサービスだけで OpenTelemetry 互換のアプリケーションパフォーマンスモニタリングを実装することができます。
ただし、OpenTelemetry 側のサポートにも制約があり、利用可能な環境は限られています。
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
で紹介されている自動計装のセットアップを行う形になります。
EKS で Application Signals を利用する際、Init コンテナが自動で挿入されて必要なコンポーネントを配置してくれたり、必要な環境変数をセットしてくれたりします。
その部分を自前でやる必要があります。
やってみる
今回は EKS 環境で試してみます。
まず、CloudWatch Observability アドオン をインストールした EKS クラスターを作成します。
詳細な手順は下記記事と同様のため、スキップします。
合わせて Aurora PostgreSQL も作成します。
全体的な構成としては下図のようになります。
作成した EKS 上にアプリケーションを作成してきます。
まず、ES Modules を利用した Node.js アプリケーションとして、Hono と Prisma を利用したアプリケーションを用意します。
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
に指定したディレクトリに作成させることができます。
この際、moduleFormat
で esm
を指定することで、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
をフックとして登録する必要があります。
将来的には register を利用した形に移行することで、--experimental-loader=@opentelemetry/instrumentation/hook.mjs
の指定は不要になるようです。
@aws/aws-distro-opentelemetry-node-autoinstrumentation/register
は Node SDK の初期化スクリプトです。
OTEL_AWS_APPLICATION_SIGNALS_ENABLED
を true にした際、 Application Signals を利用できるように良い感じに初期化を行ってくれます。
マニフェストファイルも作成します。
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 側でもサービスとして確認できています。
SLA も良い感じに設定できます。
X-Ray のコンソールにトレースが送信されています。
CloudWatch Application Signals に表示はされるものの、DB アクセス部分にかかる時間は可視化できていません。
Prisma の tracing 機能を使いたい所です。
@prisma/instrumentation@latest
は OpenTelemetry Meta Packages for Node に含まれていないので、SDK 初期化時に明示的に指定する必要があります。
--import @aws/aws-distro-opentelemetry-node-autoinstrumentation/register
は詰まる所 @opentelemetry/sdk-node
を初期化して起動する処理なので、同等の処理を register.ts
などに記載して import すれば上手く動作するはずです。
と思ったのですが、Application Signals を使う場合は、SDK 初期化時に結構いろいろとカスタマイズが必要です。
※ 環境変数で OTEL_AWS_APPLICATION_SIGNALS_ENABLED=true
と指定した場合、SDK 初期化時にそれなりに複雑な処理が挟まります。
@opentelemetry/instrumentation-aws-sdk
に対するパッチ処理なんかも実施しています。
カスタマイズもやってやれないことは無いでしょうが、ちょっと大掛かりになりそうだったので、今回は 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}`);
}
);
上手くスパンを作成できました。
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 へのメッセージ登録にかかった時間を上手く可視化できています。
最後に
ES Modules を利用している際に CloudWatch Application Signals を利用してみました。
追加作業は必要でしたが、そこまで負荷にはならないように思いました。
ただ、@aws/aws-distro-opentelemetry-node-autoinstrumentation/register
をカスタマイズして利用しようと思うとなかなか骨が折れそうだったので、OpenTelemetry Meta Packages for Node に含まれないアプリケーションに対しても自動計装を仕込みたい場合は注意が必要かと思います。