Mackerel APM を zero-code計装から始める
最近、自分がインフラ周りを統括している某ビッグプロジェクトに、2025/5/1にリリースされた Mackerel APM を導入して設定・検証を行っています。
本エントリでは、APM・OpenTelemetry をやってみたいと思っている方に届けば、と思い、そのプロジェクトでの準備プロセスを一例として紹介します。[1]
自分以外の開発メンバーにも APM を利用可能にしてサービス運用に役立ててもらい、チームでプロダクトを育てていければな、っていう境地を目指してやってます。
前提状況
- ターゲットは NestJS のバックエンドAPI(Node.js)。
- ソースコードリポジトリは GitHub で、コンポーネントごとにそれぞれ単独。
infraとapiのリポジトリがこの APM 実装に関連がある(モノレポではない)。 - インフラは AWS Fargate を利用している(サーバレス・コンテナ利用)。ネットワーク構成はスタンダードな3層構造って感じ(ALBの前段にCloudFrontがいるとかありますが)。[2]
- デプロイは GitHub への push をトリガーにして CodePipeline で行われるようにしてある(ecspressoを載せるのは得意です)。 [3]
- ビッグプロジェクトなので、スピード感ある障害対応が求められる、と予想している(そのためにAPMを使いたかった。自分が。)。
ローカルで debug トレース
入(はい)りは試用期間から大変お世話になりました、はてな社の kmuto さんのブログを参考にしました。他にも様々な情報を流していただき、大変感謝。日本語サポート手厚いの 🔰 にはとても助かります。
まずはローカルから。参考情報があるとはいえ、そもそも Node.js の実装にそんなに慣れているわけではないので、ややおそるおそる関連ライブラリのインストールを(npm install ... でやったような記憶)。[4]
//・・・
"dependencies": {
//・・・
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.66.0",
"@opentelemetry/sdk-node": "^0.207.0",
//・・・
そして準備として OpenTelemetry Collector をローカルで動かす意図で、必要な設定を用意。
関連ファイル
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.104.0
container_name: otel-collector
command: ["--config=/etc/otel/otel-collector-config.yaml"]
volumes:
- ./deploy/local/otel/otel-collector-config.yaml:/etc/otel/otel-collector-config.yaml:ro
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
environment:
MACKEREL_APIKEY: ${MACKEREL_APIKEY}
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
exporters:
debug:
verbosity: detailed
service:
pipelines:
metrics:
receivers: [otlp]
exporters: [debug]
logs:
receivers: [otlp]
exporters: [debug]
traces:
receivers: [otlp]
exporters: [debug]
この設定で OpenTelemetry Collector を docker compose -f otel-compose.yml up -d で起動しておいて、アプリケーションも起動。前出 kmuto さんのブログで以下の起動コマンドを見かけていたので
OTEL_SERVICE_NAME=nodejs-zerocode node --require @opentelemetry/auto-instrumentations-node/register index.js
package.json に start-otel:local コマンドを追記して bun run start-otel:local で起動。
//・・・
"scripts": {
//・・・
"start": "NODE_ENV=local nest start",
"start-otel:local": "OTEL_SERVICE_NAME=nodejs-zerocode OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces OTEL_EXPORTER_OTLP_PROTOCOL=http NODE_OPTIONS='--require @opentelemetry/auto-instrumentations-node/register' NODE_ENV=local nest start",
//・・・
DBコンテナを起動し忘れてました!とかありつつ、コンソールでエラーなくアプリケーションを起動できた感じがしたところで、ヘルスチェックのエンドポイントを叩き、Docker Desktop の otel-collector コンテナの Logs を見ると、おお、トレースっぽいのが出とる出とる!
ヨシヨシ、さて、ヘルスチェックのじゃないのも見てみたいよな、となり、開発者とコミュニケーションとって「DBに到達するリクエストのローカルでの投げ方」を教えてもらいました。
ここで苦戦ポイント1(アプリケーションの使い方知らない者の壁)があり、認証の通し方?になりましたが把握してクリアー。
こうして APM への道をまずは開きました。
ローカルでのリクエストと、それのであろうトレース


ローカルから Mackerel に飛ばす
さて次は、ローカルから Mackerel にトレースを飛ばそう、となって、otel-collector-config に手を加えてみました。こんな感じです。
関連ファイル
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.104.0
container_name: otel-collector
command: ["--config=/etc/otel/otel-collector-config.yaml"]
volumes:
- ./deploy/local/otel/otel-collector-config-mkr.yaml:/etc/otel/otel-collector-config.yaml:ro
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
environment:
MACKEREL_APIKEY: ${MACKEREL_APIKEY}
# refs: https://mackerel.io/ja/docs/entry/tracing/installations/opentelemetry-collector
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 500
spike_limit_mib: 100
batch:
# Mackerel では 6MB 以上のリクエストを受け付けません。
# そのため、リクエストあたりの最大スパン数を適当に設定します。
# スパンはトレースにおける作業または操作の単位です。
# データベースへのクエリ実行やアプリケーションの処理の 1 部分などをスパンとして表現できます。
# https://opentelemetry.io/docs/concepts/signals/traces/#spans
# Mackerel には 1 オーガニゼーションあたり月間 500 万スパンまで送信できます
send_batch_size: 5000
send_batch_max_size: 5000
exporters:
otlphttp/mackerel:
endpoint: "https://otlp-vaxila.mackerelio.com"
headers:
Accept: "*/*"
"Mackerel-Api-Key": ${env:MACKEREL_APIKEY}
extensions:
health_check:
service:
extensions: [health_check]
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/mackerel]
exporter の先が Mackerel になってるのがポイントです。processor も batch になってます。まあ、ほぼサンプル通りなんですが。
こうした上で、otel-collector コンテナとアプリケーションを再起動して、API のエンドポイントを叩くと、どうやらトレースが飛んだみたいです。YATTA!
ガチなトレース初めて見ましたわ


ECS からの送信にチャレンジ
こうなるといよいよ Fargate からトレース飛ばそうぜ!になってくるわけで、これには週末の滾る夜中に時間をかけて格闘しました。
ecspresso のデプロイ設定に追記が必要な感じは予想していたので、書いてみてデプロイするのです、がミッション。
ぼくの ecspresso 設定スタイルは jsonnet 活用型(?)になっており、複数の libsonnet ファイルに小分けにしてます。
調整というか追加したのはローカルで検証済みの以下2点が主眼。
- OTel Collector コンテナの設定(サイドカー方式。ADOT(AWS Distro for OpenTelemetry)を利用。)
- アプリケーション側に OTel 関連の必要項目(
NODE_OPTIONSやOTEL_なんとか。一発目で全部を入れてなくてトレース飛ばなかったんですが...)
なお、このフェーズでも kmuto さんの以下ブログ記事を大いに参考にしました。
追加した ecspresso の ECS タスク設定
一発目の追加設定投入


トレース飛ばなかったので二発目

ちょっと迷った点があって、それは collector_config の突っ込み方です。 kmuto さんのブログでは AOT_CONFIG_CONTENT を タスク定義の environment に突っ込んであったのですが、
の記述に従って SSM パラメータに入れて secret から参照する形を整えました。MACKEREL_APIKEY もあるしねー、にもなって。
SSM パラメータにはよく秘匿情報を入れていたんですが、今回初めて Secret じゃない String を突っ込みました。新しい経験の機会に感謝。
2度目の正直で、トレースは出たのですが・・・
トレース出た!が・・・
ヘルスチェックのトレースはいらない・・・


アプリケーション起因じゃなさそうなクライアントエラーが出てる・・・

いくつか調整が必要そうな事項がすぐに見受けられたし、ドバドバとそんなに有益でもなさそうなトレースの流量がやや半端なかったので一旦トレースを止めました。
作業ブランチを切り出してローカルからデプロイしてたので、通常の CodePipeline デプロイで OTel対応なし版で上書きデプロイしました、という感じ。
processor の調整
調整ですが、ぱっと見で「これやろう」と思ったのは以下。
- ヘルスチェックのエンドポイントはトレースいらない
- middleware のトレースも要らなさそう(kmutoさん助言)
- 総当たり攻撃的なアクセスも拾われてるので無視したい(末尾の拡張子ついてるやつ明らかにアプリからじゃない)
そう思って投入した設定が以下
processor 調整 v1 (x)
//・・・
processors:
resourcedetection/ecs:
detectors: [env, ecs]
timeout: 2s
override: false
tail_sampling:
decision_wait: 30s
num_traces: 50000
policies:
- name: exclude-health
type: string_attribute
string_attribute:
key: http.target
values: ["/api/(HEALTHCHECK_ENDPOINT)"] # <- 実際は妥当な値にしてます
invert_match: true
- name: always_sample
type: always_sample
filter/exclude_custom:
error_mode: ignore
traces:
span:
- 'IsMatch(name, "^middleware") == true'
- 'IsMatch(attributes["http.target"], "^/api/v1/") == false'
- 'IsMatch(attributes["http.target"], "^/api/v1/.*\\.(json|js)$") == true'
- 'name == "SecretsManager.GetSecretValue" or name == "Create Nest App"'
- 'attributes["http.target"] == "/computeMetadata/v1/instance"'
- 'IsMatch(attributes["http.target"], "^/v4/") == true'
//・・・
service:
extensions: [health_check]
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, filter/exclude_middleware, batch]
exporters: [otlphttp/mackerel, debug]
これ適用した結果どうなったかというと、トレースがちょっとしか記録されてなくて、Controller層より下のスパンが可視化されてないような感じになっちゃってました。一旦戻して、しばらくドバドバと出して様子見して絞り込み方を考える・・・
その結果適用したのが以下。これでまたそれなりに良さそうなトレースに戻りました。
processor 調整 v2 (△)
//・・・
filter/exclude_custom:
error_mode: ignore
traces:
span:
- 'IsMatch(name, "^middleware") == true'
#- 'IsMatch(attributes["http.target"], "^/api/v1/") == false'
#- 'IsMatch(attributes["http.target"], "^/api/v1/.*\\.(json|js)$") == true'
- 'name == "SecretsManager.GetSecretValue" or name == "Create Nest App"'
- 'attributes["http.target"] == "/computeMetadata/v1/instance"'
- 'IsMatch(attributes["http.target"], "^/v4/") == true'
//・・・
しかしどうも変な、リクエストが空っぽだったり POST / っていうのがあったりを見たので、そのおかしなスパンをざっと眺めて再調整。
processor 調整 v3 (○?)
//・・・
filter/exclude_custom:
error_mode: ignore
traces:
span:
- 'IsMatch(name, "^middleware") == true'
- 'name == "SecretsManager.GetSecretValue" or name == "Create Nest App"'
- 'IsMatch(attributes["http.target"], "^/v4/") == true'
- 'attributes["http.target"] == "/computeMetadata/v1/instance"'
- 'name == "tcp.connect" or name == "tls.connect"' # <- 追加
- 'attributes["http.host"] == "secretsmanager.ap-northeast-1.amazonaws.com"' # <- 追加
- 'attributes["db.statement"] == "SELECT VERSION() AS `version`"' # <- 追加
batch/traces:
timeout: 1s
send_batch_size: 100 # <- 50
//・・・
これで現状は良さそうかなあと思い適用しています。
processor に関しては以下のこれまた kmuto さんのブログを時折見返して参考にしていきたいところ。
one-code 計装へ
記憶ではこのあたりまでは zero-code計装を貫き通したのですが、こんなシーンがあるじゃないですか・・・
AWS マネコンにログインしてある前提ですが、CloudWatch Logs Insights に飛べて、以下のような Query がセットされてるじゃないですか・・・
fields @timestamp, @message, @logStream
| filter trace_id like /12345678901234567890123456789012/ | limit 10000
Query がセットされている様子

これシュッと引けるとめちゃくちゃ調査に役立ちそうに見えたので、ログに trace_id 出したくなって、どうすれば?を聞いたり追ったりしました。
都合よく、logger.ts という「こいつだろ!」っていうソースコードがあったので、AI問答して改修してみてレビューに回してみました。・・・通りました!
こうして、zero-code計装ではなくなりましたが、後悔はありません。むしろ感動しました。
trace_id をログに書き出す実装調整(構造化されてる前提)

開発チームへのデモ会
そろそろ使ってもらえそうな程度に準備が進んだので、「こう使ってね」の資料をガッと Notion で拵えて、さっき起きたエラーのログはこうやって引くことができます、っていう感じのデモンストレーションをしました。
課題を GitHub の issue とか JIRA あたりに連携してステータス管理できると素敵そうですねー、などの要望も飛び出すなどたぶんまずまずの好評で、その日は数名ほどサインアップ要望をいただきました。
説明資料の抜粋(モザイクあり)



これから
ちょっと違和感あったりしてて、serviceNamespace と Service の階層関係を間違って理解してたので、本格運用が始まる前に直したいなー、などと引き続き調整を考えています。
目下のところ、検知したエラー等をどう適切に通知して対応に活かすか、などを検討・検証中。それと、ECS に続いて Lambda のトレース採取にこれもまたローカルから取り組んでいるのですが、どうも思うようにいかず、ぐぬぬぬ、となっています(救済もしくは天啓を求めている)。
ドキュメントや事例を見ながら勘を働かせて Lambda の APM 実装も完成を目指したいなーと思ってます。というか完成しないと自分がおそらく一番困る!
長文にお付き合いいただきありがとうございました。
こちら↓のチームでビッグプロジェクトやってますので、この方面に実装チャレンジしてみたいとか、インフラ面に限定となるのですが我が ant-in-giant Team にちょっと関心ありますとかありましたらご連絡等いただけますと幸いです。
X Post よりピックアップ
-
監視のなんたるかはわかっているつもりですが、OpenTelemetry を扱ったことはないので、もっと良いやり方はあるだろうと思ってます。どんどん共有して欲しいです。 ↩︎
-
CloudFront VPC Origin -> internal ALB というのにチャレンジしてみて構築成功した。コスト低めで済んでいるはず(?)。 ↩︎
-
デプロイ構築時点では arm64 の Fargate をデプロイするのに CodeBuild Self-Hosted Runner が利用できることを認識できていなかった。 ↩︎
-
そういえば一時期
@opentelemetry/instrumentation-nestjs-coreも入れてましたが、なくても大丈夫だったので外しました。 ↩︎

Discussion