LoginSignup
3

More than 1 year has passed since last update.

JVMメトリクスを監視するサイドカーを構築した話

はじめに

こんにちは、GxPの@ttanaka-gxpです。
この記事はグロースエクスパートナーズ Advent Calendar 2020の17日目です。

本年はAWSのインフラ構築、保守とその上で動かすJavaアプリケーションの構築など、AWSに関連する業務が多かったのが印象に残っています。
触ったことのないサービスに触る機会や元々知っていたはずのサービスについてもより理解を深めることができ、勉強になった1年だと感じています。
そんな1年の中で、ECS/Fargate上で動かすJavaアプリを補助するためのサイドカーを構築・保守する案件があり、その時の内容を記事として紹介させて頂きます。

内容としてはECS/Fargate上でJavaアプリが動いているのを前提とし、案件で構築したサイドカーを組み込んだ際のハマりどころや苦労した点などをまとめようと思います。

サイドカーを導入した経緯

弊社はインフラ構築の担当で、運用監視周りの設計・構築を進める中で別ベンダーが開発しているコンテナ上で動くJavaアプリのJVMメトリクスを収集する要件を解決するため、外部から監視の仕組みを作る必要がありました。
ECSの標準で取得出来るメトリクスやContainer InsightsなどのマネージドサービスではJVMの内部情報までは取得できないため、自作で作り込む必要があり、サイドカーという形で構築することになりました。

そもそもサイドカーとは

  • メインとなるコンテナと共にそれを補助する役割を果たすサブのコンテナ。
  • サイドカーの利点
    • メインコンテナに優先的にリソースを割り当てつつ、サイドカーコンテナはあまりのリソースで処理を行える。
    • メインコンテナで動かすアプリの開発とは独立して開発、テストが出来る。
    • 再利用性が高くシステム別にバージョン管理も行える。

概要図

2020-12-14_00h30_11.png

サイドカーで実現していること

  • アプリコンテナのリソース使用状況を監視
    • コンテナ単位でのCPU、メモリのメトリクスを収集する。
  • アプリのリソース使用状況を監視
    • JVM周りのメトリクスを収集する。

サイドカーコンテナの設定

  • AWS CLI
    • サイドカーが収集したメトリクスをCloudwatchへ送信するのにCLIを使う。
    • メトリクス送信はput-metric-dataコマンドを使う。
  • Java
    • 下記、JMXのコマンドラインツールを実行に必要。
  • Command-line JMX Client
    • 実行中のJVMに関するメトリクスを取得するツール。
  • シェルスクリプト群
    • 上記のツールを使ってメトリクスを収集し、Cloudwatchへ送信するシェル。
    • コンテナのエントリーポイントとして起動し、他のシェルを一定周期で起動するシェル。

Javaアプリコンテナの設定

  • サイドカーが想定しているアプリ要件
項目 想定値
JDK Amazon Corretto 11
ガベージコレクタ G1GC
フレームワーク Spring Boot 2系
DBコネクション HikariCP
  • サイドカーからJMXへのアクセスを許可するための設定
    • Dockerfile内で下記の環境変数をセットする。
ENV JAVA_TOOL_OPTIONS="\
-Dcom.sun.management.jmxremote.port=任意のポート \
-Dcom.sun.management.jmxremote.ssl=false \
-Dcom.sun.management.jmxremote.authenticate=false"
  • HikariCP周りのメトリクスを取得するための設定
    • SpringBootの設定ファイルに以下の定義を追加
    • spring.datasource.hikari.register-mbeans=true

タスクロールの権限

  • アプリで必要な権限ポリシーとは別に以下のアクションを許可するポリシーを追加する。
    • cloudwatch:PutMetricData
    • ecs:ListTagsForResource
    • サイドカー内で起動するシェルを実行するのに必要最低限の権限となります。

タスク定義の設定

  • 低スペックだとJava11のデフォルトGCが変わってしまうのである程度リソース割当が必要
    • この記事がわかりやくまとめてくれている。
    • タスク定義のリソース割り当てが以下を満たすことでデフォルトのG1GCが使われるようになると思われる。
      • CPU : 2CPU以上
      • メモリ: 2GB以上
  • アプリコンテナにJMX用のポートを追加
    • Javaアプリコンテナの設定で環境変数に指定した。
    • -Dcom.sun.management.jmxremote.port=任意のポートの部分

ネットワーク設定

IGW、NatGatewayなど、インターネット経由でCloudWatchなどのAWSサービスへアクセスすることも可能ですが、一般的にはVPCエンドポイントで構築することが多いかと思います。
以下のエンドポイントを設定することでインターネットを経由せず、AWSサービスにアクセス出来ます。

  • com.amazonaws.ap-northeast-1.ecs
  • com.amazonaws.ap-northeast-1.monitoring

※ VPC全体に影響するので、同居する他システムがいる場合は慎重に。

サイドカーコンテナ内の一部シェルスクリプト紹介

起動シェル

run.sh
#!/bin/bash

echo "##### start run_cwagent #####"

echo "メトリクス送信を行います"
while true; do (/metric/put-metric-ecs.sh &); sleep 60; done &
while true; do (/metric/put-metric-jvm.sh &); sleep 60; done &
while true; do (/metric/put-metric-jvm-max.sh &); sleep 3600; done &
while true; do (echo "メトリクス送信中"); sleep 60; done
exec "$@"

JVMのメトリクス収集シェル

put-metric-jvm.sh
#!/bin/bash

echo "##### start put-metric-jvm.sh #####"

function put_metics_data() {
    for METRIC_NAME in "${!METRIC_RES_LIST[@]}"; do
        aws cloudwatch put-metric-data --dimensions $1 --timestamp $TIMESTAMP --namespace Jmx --metric-name "${METRIC_NAME}" --value "${METRIC_RES_LIST[${METRIC_NAME}]}" --unit "${UNIT_TYPE_LIST[${METRIC_NAME}]}"
    done
}

# ECSメタデータエンドポイントのURLを変数にセットする
TASK_META_ENDPOINT=${ECS_CONTAINER_METADATA_URI_V4}/task

# メタデータのJSONを変数にセットする
TASK_META_JSON=`curl -s $TASK_META_ENDPOINT`

TASK_ARN=`echo $TASK_META_JSON | jq -r .TaskARN`
TASK_NAME=`echo $TASK_ARN | tr '/' '\n' | tail -1`
# サービスまたはタスク定義からタスク起動時にタグを伝播させるのが前提
# メタデータから取得出来ない情報をタグから取得する
SERVICE_NAME=`aws ecs list-tags-for-resource --resource-arn $TASK_ARN | jq '.tags[] | select(.key == "aws:ecs:serviceName") | .value'`
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
JMX_PORT=10048
JMXCLIENT_PATH=/metric/cmdline-jmxclient-0.10.3.jar
JMX_CMD="java -jar $JMXCLIENT_PATH - localhost:$JMX_PORT"

# JVMのメトリクスを取得しメトリクス名をキーにした連想配列に詰める
declare -A METRIC_RES_LIST=(
    ["JmxGCG1OldCollectionCount"]=`$JMX_CMD "java.lang:name=G1 Old Generation,type=GarbageCollector" CollectionCount 2>&1 | cut -d' ' -f6`
    ["JmxGCG1OldCollectionTime"]=`$JMX_CMD "java.lang:name=G1 Old Generation,type=GarbageCollector" CollectionTime 2>&1 | cut -d' ' -f6`
    ["JmxGCG1YoungCollectionCount"]=`$JMX_CMD "java.lang:name=G1 Young Generation,type=GarbageCollector" CollectionCount 2>&1 | cut -d' ' -f6`
    ["JmxGCG1YoungCollectionTime"]=`$JMX_CMD "java.lang:name=G1 Young Generation,type=GarbageCollector" CollectionTime 2>&1 | cut -d' ' -f6`
    ["JmxHeapMemoryUsageCommitted"]=`$JMX_CMD "java.lang:type=Memory" HeapMemoryUsage  2>&1 | grep committed | cut -d' ' -f2`
    ["JmxHeapMemoryUsageUsed"]=`$JMX_CMD "java.lang:type=Memory" HeapMemoryUsage  2>&1 | grep used | cut -d' ' -f2`
    ["JmxNonHeapMemoryUsageCommitted"]=`$JMX_CMD "java.lang:type=Memory" NonHeapMemoryUsage  2>&1 | grep committed | cut -d' ' -f2`
    ["JmxNonHeapMemoryUsageUsed"]=`$JMX_CMD "java.lang:type=Memory" NonHeapMemoryUsage  2>&1 | grep used | cut -d' ' -f2`
    ["JmxMetaspaceCommitted"]=`$JMX_CMD "java.lang:name=Metaspace,type=MemoryPool" Usage 2>&1 | grep committed | cut -d' ' -f2`
    ["JmxMetaspaceUsed"]=`$JMX_CMD "java.lang:name=Metaspace,type=MemoryPool" Usage 2>&1 | grep used | cut -d' ' -f2`
    ["JmxConnectionPoolActiveConnections"]=`$JMX_CMD "com.zaxxer.hikari:type=Pool (HikariPool-1)" ActiveConnections 2>&1 | cut -d' ' -f6`
    ["JmxConnectionPoolIdleConnections"]=`$JMX_CMD "com.zaxxer.hikari:type=Pool (HikariPool-1)" IdleConnections 2>&1 | cut -d' ' -f6`
    ["JmxConnectionPoolTotalConnections"]=`$JMX_CMD "com.zaxxer.hikari:type=Pool (HikariPool-1)" TotalConnections 2>&1 | cut -d' ' -f6`
    ["JmxConnectionPoolThreadsAwaitingConnection"]=`$JMX_CMD "com.zaxxer.hikari:type=Pool (HikariPool-1)" ThreadsAwaitingConnection 2>&1 | cut -d' ' -f6`
)
# メトリクス名をキーをメトリクスの単位を連想配列に詰める
declare -A UNIT_TYPE_LIST=(
    ["JmxGCG1OldCollectionCount"]=Count
    ["JmxGCG1OldCollectionTime"]=Seconds
    ["JmxGCG1YoungCollectionCount"]=Count
    ["JmxGCG1YoungCollectionTime"]=Seconds
    ["JmxHeapMemoryUsageCommitted"]=Bytes
    ["JmxHeapMemoryUsageUsed"]=Bytes
    ["JmxNonHeapMemoryUsageCommitted"]=Bytes
    ["JmxNonHeapMemoryUsageUsed"]=Bytes
    ["JmxMetaspaceCommitted"]=Bytes
    ["JmxMetaspaceUsed"]=Bytes
    ["JmxConnectionPoolActiveConnections"]=Count
    ["JmxConnectionPoolIdleConnections"]=Count
    ["JmxConnectionPoolTotalConnections"]=Count
    ["JmxConnectionPoolThreadsAwaitingConnection"]=Count
)

DIMENSIONS_TASK=ServiceName=$SERVICE_NAME,TaskName=$TASK_NAME
DIMENSIONS_SEARVICE=ServiceName=$SERVICE_NAME

# Cloudwatchへメトリクスを送信(タスクレベル)
put_metics_data $DIMENSIONS_TASK

# Cloudwatchへメトリクスを送信(サービスレベル)
put_metics_data $DIMENSIONS_SEARVICE

Javaのサンプルコードも含めたサイドカーのサンプルコード全体はGitHubにPushしてあります。
https://github.com/tanaka-gxp/SidecarSample

ポイント1 ECSサービスの情報を取得するのが意外と難しい(put-metric-jvm.sh)

Cloudwatch上でメトリクスを見るにあたり、タスクレベルでは見にくいため、ECSのサービス名などをディメンションに持たせた上でメトリクス送信を行おうとしていたのですが、ECSサービスの名前などを取得する手段が意外と限られており少しハマりました。

  • 案1メタデータから取得する ⇒ ✖
    • コンテナやタスクが所属するクラスターの情報は取得出来るが、なぜかサービスの情報が取得できませんでした。
  • 案2 AWS CLIで取得する ⇒ ✖
    • describe系、list系のコマンドで取得するためにはARNなどサービスを一意に識別する条件が必要だが、そのままだとサービスを識別する条件使える情報がないので、同様に取得できず。
  • 案3 外部からサービスを一意に識別する情報を渡す+案2 ⇒ 〇
    • 具体的にはコンテナの環境変数やタスクのタグで渡すなど
    • この案件ではサービスの「タスクのタグ付け設定」で必要な情報をタスクのタグとして渡しています。
image.png

ポイント2 JMXでメトリクスを取得する(put-metric-jvm.sh)

おわりに

実は構築したのは私ではなく他のメンバーで、ほとんど触ることのないまま保守フェーズで軽微な修正をする機会があり対応自体はすんなり出来たのですが、中身を深く理解しないままはよくないと思い、案件とは関係なく作り直したりして、今回の記事をまとめました。
元々AWS周りの設定については大体理解していたのですが、シェルの中身についてはそもそもJMXとはなんぞやってところから始まっていたので、収集しているメトリクスの内容や収集の方法を調べたことでJavaのコアな部分を理解するための土台を自分の中で作れた気がします。
今後もインフラ、アプリどちらも出来ることを増やしていきつつ精進したいと思います。

参考資料

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
3