ようへいの日々精進XP

よかろうもん

最近ギョームでやったこと (5) 〜 Fargate で動いている ECS Scheduled Task (バッチ処理) のリソース使用量を計測出来るようにしました 〜

tl;dr

ご無沙汰しております. ちゃんと仕事をしていますよ, かっぱです.

Fargate で動いている Scheduled Task (いわゆるバッチ処理) がどのくらい CPU やメモリを使用しているのか知りたくて, 幾つかの技術を組み合わせて実装してみましたので紹介させて頂きます.

一応, この仕組を仕込んだ後, 気づいていなかった幾つかの問題について気づくことが出来たりして, 時間をかけても仕込んで良かったなと思えることがありました.

なぜ

なぜ, バッチ処理の CPU やメモリの使用量を知りたかったのか.

  • タスクに割り当てているコンテナリソースタイプは特に指標も無く決めたので, 本当にそのリソースタイプが正しいのか知りたい
  • たまに発生していたメモリ不足によるバッチ処理失敗の時にどのくらいメモリを使っていたのか知りたい
  • 今後のパフォーマンス改善に役立てたい

という理由があるからです.

Lambda で関数を実行すると, 下図のように実行結果のログにどのくらいリソースを使ったのかが出力されます. これと同じことを実現したかったのです!

f:id:inokara:20190901090354p:plain

こんな感じで...

こんな感じで... (1)

f:id:inokara:20190901085824p:plain

  • バッチ処理コンテナとは別のコンテナでリソース使用量を収集する雑なスクリプト (とりあえず, 公開検討中) を動かす
  • バッチ処理コンテナの実行ログにリソース統計情報を JSON で差し込む
  • ログを CloudWatch Logs 経由で Elasticsearch に突っ込む
  • Elasticsearch 上で Kibana を使って可視化する
  • バッチ処理の結果は CloudWatch Events を介して Lambda 経由で Slack の指定したチャンネルに送信 (成功時と失敗時はそれぞれ異なるチャンネルに送信)

こんな感じで... (2)

バッチ処理のログに使用量の統計情報を差し込むようになりました.

f:id:inokara:20190901090557p:plain

こんな感じに...

なりました

CloudWatch Logs から Amazon Elasticsearch に流してあげることで, Kibana で可視化出来るようにもなりました.

f:id:inokara:20190901090922p:plain

  • Task Count では, 時系列にタスクの実行数を確認出来ます
  • Task Duration では, その名の通り, 各種バッチ処理バッチ処理毎の実行時間を確認出来ます
  • Task Resource Usage Average, これが一番やりたかったことで, 各種バッチがどの程度リソースを使っているのかヒートマップを使って大雑把に確認することが出来ます

実装にあたって得た知見等

Task metadata endpoint

ECS のコンテナエージェントが提供するタスクメタデータやコンテナの各種メトリクスを返すエンドポイントで, 2 つのバージョンを提供しています. 今回はバージョンを 3 を利用しています.

コンテナ内では ${ECS_CONTAINER_METADATA_URI} という環境変数に定義されていて, 以下のように curl 等でアクセスすることで, JSON によるレスポンスが得られます.

curl ${ECS_CONTAINER_METADATA_URI}

以下のようにレスポンスが返ってきます. (以下の例は ecs-cli local up を実行して, ローカル環境に擬似的に ECS 環境を起動した際に取得出来る情報です, 実際の ECS クラスタのレスポンスと異なる場合があるかもしれませんのでご了承ください )

bash-4.2# curl -s ${ECS_CONTAINER_METADATA_URI} | python -m json.tool
{
    "CreatedAt": "2019-08-26T09:15:20Z",
    "DesiredStatus": "RUNNING",
    "DockerId": "51fc052d80ed5556130b55c9ff9c40c7b9181b3c2b6f996982cac85da8d40a15",
    "DockerName": "ecs-cli_amazon_linux2_1",
    "Image": "amazonlinux-ruby:collector",
    "ImageID": "sha256:1248b3d7615e4d3be4a3733fc5d5d22ed331137ebe76a76e13e3de710bc2dd4a",
    "KnownStatus": "RUNNING",
    "Labels": {
        "com.docker.compose.config-hash": "6964544214e2426d3a7d019db7766196f0ea386536d8a753a693f77878c5e962",
        "com.docker.compose.container-number": "1",
        "com.docker.compose.oneoff": "False",
        "com.docker.compose.project": "ecs-cli",
        "com.docker.compose.service": "amazon_linux2",
        "com.docker.compose.version": "1.24.1",
        "ecs-local.task-definition-input.type": "local",
        "ecs-local.task-definition-input.value": "/path/to/test-definition.json"
    },
    "Limits": {},
    "Name": "ecs-cli_amazon_linux2_1",
    "Networks": [
        {
            "IPv4Addresses": [
                "169.254.170.4"
            ],
            "NetworkMode": "ecs-local-network"
        }
    ],
    "StartedAt": "2019-08-26T09:15:20Z",
    "Type": "NORMAL",
    "Volumes": [
        {
            "Destination": "/src",
            "Source": "/path/to/src"
        }
    ]
}

Task metadata endpoint には, 以下のように幾つかのパスが提供されています.

  • ${ECS_CONTAINER_METADATA_URI}/task
  • ${ECS_CONTAINER_METADATA_URI}/stats
  • ${ECS_CONTAINER_METADATA_URI}/task/stats

コンテナの CPU 使用率やメモリの使用量は ${ECS_CONTAINER_METADATA_URI}/task/stats は利用して取得しています. 以下のようなレスポンスが返ってきます.

bash-4.2# curl -s ${ECS_CONTAINER_METADATA_URI}/task/stats | python -m json.tool
{
    "51fc052d80ed5556130b55c9ff9c40c7b9181b3c2b6f996982cac85da8d40a15": {
        "blkio_stats": {
            "io_merged_recursive": [],
            "io_queue_recursive": [],
            "io_service_bytes_recursive": [],
            "io_service_time_recursive": [],
            "io_serviced_recursive": [],
            "io_time_recursive": [],
            "io_wait_time_recursive": [],
            "sectors_recursive": []
        },
        "cpu_stats": {
            "cpu_usage": {
                "percpu_usage": [
                    649634389
                ],
                "total_usage": 649634389,
                "usage_in_kernelmode": 80000000,
                "usage_in_usermode": 520000000
            },
            "online_cpus": 1,
            "system_cpu_usage": 1031550000000,
            "throttling_data": {
                "periods": 0,
                "throttled_periods": 0,
                "throttled_time": 0
            }
        },
        "memory_stats": {
            "limit": 8362110976,
            "max_usage": 32415744,
...
        "precpu_stats": {
            "cpu_usage": {
                "percpu_usage": [
                    1874101880
                ],
                "total_usage": 1874101880,
                "usage_in_kernelmode": 240000000,
                "usage_in_usermode": 1540000000
            },
            "online_cpus": 1,
            "system_cpu_usage": 1030560000000,
            "throttling_data": {
                "periods": 0,
                "throttled_periods": 0,
                "throttled_time": 0
            }
        },
        "preread": "2019-08-31T15:42:47.119425377Z",
        "read": "2019-08-31T15:42:48.123613175Z",
        "storage_stats": {}
    }
}

コンテナデザインパターン

特に何も考えず, ECS Task にバッチ処理用のコンテナとそのリソースを監視するコンテナを配置して, リソースの使用量を計測するように実装を進めましたが, この実装はコンテナデザインパターンに照らし合わせるとサイドカーパターン, もしくは, アダプタパターンに該当するようです.

コンテナデザインパターンとは, Design patterns for container-based distributed systems という論文に掲載されている, コンテナシステムの実装パターンで, サイドカーパターンは, 下図のようにメイン処理を行うアプリケーションコンテナとアプリケーションコンテナのログを収集して他のシステムに転送する為の補助コンテナを一緒にデプロイする実装パターンです.

f:id:inokara:20190901091448p:plainDesign patterns for container-based distributed systems より引用

今回の実装を上図にあてはめると Application Pod が, これを ECS の Task となり, Log Saving Sidecar Container がリソース使用量を収集して CloudWatch Logs に送信するコンテナということになります.

また, アダプタパターンについては, 下図ようにアプリケーションコンテナに対して, 監視インターフェースを提供する際に用いられる実装パターンで, 複数の異なったアプリケーションコンテナに対して, 統一したインターフェースでモニタリングを施したい場合等に利用されます.

f:id:inokara:20190901091502p:plainDesign patterns for container-based distributed systems より引用

尚, 先に掲出した論文は英語で書かれており, 英検 8 級の自分にはまったく読めなかったので, Qiita に投稿されていた, コンテナ・デザイン・パターンの論文要約 が非常に詳しく要約されていたので参考にさせて頂きました. 有難うございました.

リソース (CPU 使用率, Memory 使用量) の計算方法

Task metadata endpoint を利用して, コンテナのリソース使用量の監視を行えることはわかりましたが, どのように CPU 使用率やメモリ使用量を算出するのか非常に悩みました. (自分が単に Docker の知識が不足しているだけですが...)

再掲になってしまいますが, Task metadata endpoint から取得出来る情報は以下の通りです.

bash-4.2# curl -s ${ECS_CONTAINER_METADATA_URI}/task/stats | python -m json.tool
{
    "51fc052d80ed5556130b55c9ff9c40c7b9181b3c2b6f996982cac85da8d40a15": {
        "blkio_stats": {
            "io_merged_recursive": [],
            "io_queue_recursive": [],
            "io_service_bytes_recursive": [],
            "io_service_time_recursive": [],
            "io_serviced_recursive": [],
            "io_time_recursive": [],
            "io_wait_time_recursive": [],
            "sectors_recursive": []
        },
        "cpu_stats": {
            "cpu_usage": {
                "percpu_usage": [
                    649634389
                ],
                "total_usage": 649634389,
                "usage_in_kernelmode": 80000000,
                "usage_in_usermode": 520000000
            },
            "online_cpus": 1,
            "system_cpu_usage": 1031550000000,
            "throttling_data": {
                "periods": 0,
                "throttled_periods": 0,
                "throttled_time": 0
            }
        },
        "memory_stats": {
            "limit": 8362110976,
            "max_usage": 32415744,
...
        "precpu_stats": {
            "cpu_usage": {
                "percpu_usage": [
                    1874101880
                ],
                "total_usage": 1874101880,
                "usage_in_kernelmode": 240000000,
                "usage_in_usermode": 1540000000
            },
            "online_cpus": 1,
            "system_cpu_usage": 1030560000000,
            "throttling_data": {
                "periods": 0,
                "throttled_periods": 0,
                "throttled_time": 0
            }
        },
        "preread": "2019-08-31T15:42:47.119425377Z",
        "read": "2019-08-31T15:42:48.123613175Z",
        "storage_stats": {}
    }
}

この結果は, 以下の Docker Engine API のレスポンスそのものなので, 同じようなコンテナリソースを監視するツールの実装を参考にさせて頂きました.

GET /containers/{id}/stats

実際に参考にしたのが, Mackrel の mackerel-container-agent において, コンテナリソースを取得して計算している部分です. 以下にコードの一部を掲載します.

以下は CPU 使用率を計算している部分です.

// https://github.com/mackerelio/mackerel-container-agent/blob/ce7cb283a3cdbc690127c6f67c44bdce7b140619/platform/ecs/metric.go#L112-L115
func calculateCPUMetrics(prev, curr *dockerTypes.StatsJSON, timeDelta time.Duration) float64 {
    // calculate used cpu cores. (1core == 100.0)
    return float64(curr.CPUStats.CPUUsage.TotalUsage-prev.CPUStats.CPUUsage.TotalUsage) / float64(timeDelta.Nanoseconds()) * 100
}

${ECS_CONTAINER_METADATA_URI}/task/stats (/containers/{id}/stats) にアクセスすると, 以下のように, cpu_statsprecpu_stats の 2 つの CPU 使用時間 (ナノ秒) (この時点ではまだ使用率ではない) がレスポンスとして返ってきます.

    "cpu_stats": {
      "cpu_usage": {
        "total_usage": 1255134650,
        "percpu_usage": [
          1255134650
        ],
        "usage_in_kernelmode": 160000000,
        "usage_in_usermode": 1070000000
      },
      "system_cpu_usage": 5705400000000,
      "online_cpus": 1,
      "throttling_data": {
        "periods": 0,
        "throttled_periods": 0,
        "throttled_time": 0
      }
    },
    "precpu_stats": {
      "cpu_usage": {
        "total_usage": 1255134650,
        "percpu_usage": [
          1255134650
        ],
        "usage_in_kernelmode": 160000000,
        "usage_in_usermode": 1070000000
      },
      "system_cpu_usage": 5704430000000,
      "online_cpus": 1,
      "throttling_data": {
        "periods": 0,
        "throttled_periods": 0,
        "throttled_time": 0
      }
    },

今回は, 以下の Ruby コードのように, この 2 つの CPU 使用時間の差分を利用して使用率を計算しています. 1 秒毎にデータを収集している為, 1000000000 ナノ秒で割り算していますが, 単純にデータ収集する期間 (1 秒) 毎の割り算で本当に正しい結果が得られているのかちょっと自信が無いので, もう少し勉強したいと思います

def calc_cpu_percent(current, previous, interval = 1000000000)
  ((current['cpu_usage']['total_usage'].to_f - previous['cpu_usage']['total_usage'].to_f) / interval.to_f * 100).round(3)
end

ちなみに, cpu_statsprecpu_stats については, Docker Engine APIこのあたりで定義されています.

更に, 以下はメモリ使用量を計算している部分です.

// https://github.com/mackerelio/mackerel-container-agent/blob/ce7cb283a3cdbc690127c6f67c44bdce7b140619/platform/ecs/metric.go#L117-L119
func calculateMemoryMetrics(stats *dockerTypes.StatsJSON) float64 {
    return float64(stats.MemoryStats.Usage - stats.MemoryStats.Stats["cache"])
}

メモリ使用量の計算はシンプルだと思います. ${ECS_CONTAINER_METADATA_URI}/task/stats (/containers/{id}/stats) にアクセスすると, 以下のように, "memory_stats がレスポンスとして返ってきます.

    "memory_stats": {
      "usage": 1015808,
      "max_usage": 109654016,
      "stats": {
        "active_anon": 299008,
        "active_file": 0,
        "cache": 102400,
        "dirty": 0,
        "hierarchical_memory_limit": 9223372036854772000,
        "hierarchical_memsw_limit": 0,
        "inactive_anon": 0,
        "inactive_file": 0,
        "mapped_file": 0,
        "pgfault": 3069,
        "pgmajfault": 0,
        "pgpgin": 53097,
        "pgpgout": 53053,
        "rss": 212992,
        "rss_huge": 0,
        "total_active_anon": 299008,
        "total_active_file": 0,
        "total_cache": 102400,
        "total_dirty": 0,
        "total_inactive_anon": 0,
        "total_inactive_file": 0,
        "total_mapped_file": 0,
        "total_pgfault": 3069,
        "total_pgmajfault": 0,
        "total_pgpgin": 53097,
        "total_pgpgout": 53053,
        "total_rss": 212992,
        "total_rss_huge": 0,
        "total_unevictable": 0,
        "total_writeback": 0,
        "unevictable": 0,
        "writeback": 0
      },
      "limit": 8362110976
    }

今回は, CPU 使用率の計算と同様に mackerel-container-agent に倣って, 以下の Ruby コードのように計算しています.

def calc_memory_size(memory_stat)
  ((memory_stat['usage'] - memory_stat['stats']['cache']).to_f / (1024.0 * 1024.0)).round(3)
end

memory_stats については, Docker Engine APIこのあたりで定義されています.

コンテナの起動順序の制御

今回, このリソース使用量収集の仕組みを実装するにあたって, 一番試行錯誤した (今後も試行錯誤するんじゃないかな...) のが, コンテナの起動順序の調整です.

ECS では, 今年の 3 月に Amazon ECS がコンテナの依存関係の管理を強化 という発表をしており, Task Definition 内でコンテナ起動順序 (依存関係) を指定したり, コンテナ停止のタイムアウトを指定出来る機能の提供を開始しています.

実際に Task Definition 内では, 以下のように設定しています.

  {
    "name": "task-monitor",
    "image": "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/xxxxx/task-monitor:dev",
    "essential": true,
    "workingDirectory": "/app",
... 略 ...
    "stopTimeout": 120,
    "dependsOn": [
      {
        "containerName": "batch",
        "condition": "START"
      }
    ]
  }

コンテナの依存関係については, dependsOn キーに指定します. 上記の例では, task-monitor というコンテナの起動は, batch というコンテナの START というステータスを検証して, START であれば起動することを意味しています. batch コンテナはその名の通り, 実際にバッチ処理を行うアプリケーションコンテナです. 当初は, この依存関係を逆に設定していましたが, バッチ処理が起動しないという状況は絶対に避けたかったので (task-monitor の起動に失敗したらバッチ処理が走らないことになるのはダメ), batch コンテナを先に起動させるようにしました.

コンテナ停止のタイムアウトstopTimeout キーに指定します. 最大は 120 秒で, 今回は上記の通り, 最大の 120 秒を設定しています. この stopTimeout は, 依存するコンテナ (上記の例では, task-monitor コンテナが依存しているのは batch コンテナ) が異常終了した際に, 即座に task-monitor を強制終了させるのではなく, stopTimeout で指定した秒数だけ待機した上で停止することが出来る機能です. 本当にこのパラメータが必要なのか, 120 秒も待機する必要があるか等の課題は残っていますが, 検証段階では, このパラメータをつけておくことで, 安定稼働を確認したので, 設定したままにしています.

詳細については, こちら や, こちら に記載されています.

以上

もはや, N 番煎じが甚だしい内容ですが, ECS の内部実装やコンテナリソースの収集等で非常に勉強になったギョームでした.