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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/942766/21560607-c400-51b2-a3b8-a313e219f41d.png)

## サイドカーで実現していること
* アプリコンテナのリソース使用状況を監視
    * コンテナ単位でのCPU、メモリのメトリクスを収集する。
* アプリのリソース使用状況を監視
    * JVM周りのメトリクスを収集する。

## サイドカーコンテナの設定
* AWS CLI
    * サイドカーが収集したメトリクスをCloudwatchへ送信するのにCLIを使う。
    * メトリクス送信は[`put-metric-data`](https://docs.aws.amazon.com/ja_jp/cli/latest/reference/cloudwatch/put-metric-data.html)コマンドを使う。
* 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が変わってしまうのである程度リソース割当が必要
    * [この記事](https://matsumana.info/blog/2018/12/09/java11-g1gc-default/)がわかりやくまとめてくれている。
    * タスク定義のリソース割り当てが以下を満たすことでデフォルトの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

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

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

|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/942766/1c29c4bd-374b-afc7-8f47-56c2c8db7f01.png)|
|:-:|

#### ポイント２ JMXでメトリクスを取得する（put-metric-jvm.sh）
* ガベージコレクションのメトリクス取得方法
    * https://docs.oracle.com/javase/jp/6/api/java/lang/management/GarbageCollectorMXBean.html
* ヒープ、ノンヒープのメトリクス取得方法
    * https://docs.oracle.com/javase/jp/6/api/java/lang/management/MemoryManagerMXBean.html
* DBコネクション(HikariCP)のメトリクス取得方法
    * https://github.com/brettwooldridge/HikariCP/wiki/MBean-(JMX)-Monitoring-and-Management
* コマンドラインで実行すると以下のような値が返ってくるため、シェルで必要な個所を整形している。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/942766/02a04208-c8f9-076f-31ca-23e56fa3a5f6.png)

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

## 参考資料
* JavaのGCの仕組みを整理する
    * https://qiita.com/e_tyubo/items/48398391a8ef0f24c1be
* ECSメタデータエンドポイント
    * https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/task-metadata-endpoint-v4-fargate.html
* Command-line JMX Client
    * http://crawler.archive.org/cmdline-jmxclient/