ウェルスナビ開発者ブログ

WealthNaviの開発に関する記事を書いてます。

タスク数100超え!モノレポとエスプレスタックで支えるECS管理の仕組み(ecspresso/ecschedule)

こんにちは、インフラエンジニアの和田です。

弊社は、WEBアプリケーションおよびバッチ処理の実行基盤として Amazon Elastic Container Service(以下「ECS」と呼ぶ) を採用しています。現在では複数チームの開発者が 100 を超えるタスク定義を運用する規模にまで拡大しています。この記事では、増え続けるECS定義をモノレポとエスプレスタック(ecspresso/ecschedule)で管理した事例を紹介します。

ECSの運用で発生した悩み

ECSを利用する開発者やアプリケーション数が増えるにつれ、下記のような悩みをよく見かけるようになりました。

  • ECSやタスク定義の設定が煩雑で開発速度が落ちる
    • なぜか起動失敗(基本は設定の誤りや漏れだが、いろんなパターンがある)
    • 監視エージェントをサイドカーとして使用する場合の設定内容が難しい
    • バッチ処理を定期実行する方法を毎回調べてる
  • 意図しない変更を防ぐためTerraformの練度を上げる必要がある
    • CodePipelineでimage値を更新する構成の場合、Terraformの反映で先祖返りすることがある
    • タスク定義の更新タイミングによってタスク数が増減することがある
    • ケースに応じてignore_changesやdataリソースを駆使するべきだが、学習コストはそれなり
  • タスク定義が増え、メンテコストが増大
    • 全てのタスク定義に共通の設定変更を行うのが辛い
    • タスク定義をアプリリポジトリで管理しているケースもあり、変更調整が大変

リポジトリ分割と採用ツール

当初はほぼ全てのAWSリソースをTerraformのみで管理していましたが、リソースのライフサイクルとそれを扱う人を意識した結果、専用リポジトリでタスク定義とScheduledTaskを管理することにしました。

利用者 変更頻度 説明
AWSリソース全般の管理 インフラ(時々開発者) Terraform Cloudを利用。ECS周辺のリソースを作成する。初期構築以降、変更が発生する機会は少ない。
イメージのビルド 開発者 CodeBuildを利用。VCS ProviderからのWebHookを受け取りビルドを開始する。ビルドしたイメージはECRに登録する。
タスク定義とScheduledTaskの管理 開発者(時々インフラ) CodePipeline + CodeBuildを利用。mainブランチへのマージをトリガーに反映する。

この記事では「タスク定義とScheduledTaskの管理」パイプラインに絞って紹介します。方針としては、 ecspressoecschedule に任せられる所は最大限任せ、足りない機能を軽めに実装する感じです。

fig.1 タスク定義とScheduledTaskの管理パイプライン
fig.1 タスク定義とScheduledTaskの管理パイプライン

採用したツール

パイプラインで扱いやすい軽量なCLIと、Terraform連携機能を持つツールを採用しました。

ツール名 説明
ecspresso ECS service / task に関わる最小限のリソースをコード管理し、デプロイを実行するための軽量ツール。コンテナへのログインやECSイベントの確認も可能
ecschedule ECS taskの実行スケジュールをコード管理するための軽量ツール
jsonnet JSONYAMLを生成するためのテンプレート言語。コードの重複を削減するために採用。jsonnetでtaskdef.jsonのコード量を削減する方法はこちらの記事が参考になります

エスプレスタックの由来。

モノレポ管理

定義の一覧性や横断的な変更の容易性を考慮し、アプリケーションコードとは別のリポジトリで一括管理をする方針としました。モノレポ管理は設定の見通しが良くなる反面、複数人で複数アプリケーションを管理する場合、パイプラインが複雑になる可能性があります。対応内容の詳細については「パイプラインの実装」にて説明します。

jsonnetの利用イメージ

jsonnet で生成した定義ファイルを ecspressoecschedule で反映するイメージです。

fig.2 jsonnetの利用イメージ
fig.2 jsonnetの利用イメージ

jsonnet の使い方は簡単で、下記のように -m でアウトプットディレクトリ、コマンドのパラメータに .jsonnet ファイル を指定するだけです。

jsonnet -m ./path/to/output_dir ./path/to/hoge.jsonnet

本コマンドを実行することで ./path/to/output_dir 以下に taskdef.json や、ecspresso.jsonschedule.yaml が生成されます。

定義ファイルのサンプル

開発者が触ることになる定義ファイルは taskdef.jsonnet のみになります。

アプリケーション開発者が触る定義ファイル(taskdef.jsonnet)

local p = {
  env: 'prod',
  name: 'hoge-app',
  cluster: $.env + '-hoge-apps',
  use_apm: true,
};

// taskdefのcontainerDefinitions以外の部分(省略)
local base = (import '../../libs/taskdef/base.libsonnet') + { p_env: p.env, p_name: p.name };
// 1. taskdefのアプリケーションコンテナ部分
local container = (import '../../libs/taskdef/container.libsonnet') + { p_env: p.env, p_name: p.name, p_use_apm: p.use_apm };
// 2. taskdefのサイドカーコンテナ(DatadogAgentコンテナ)部分
local container_datadog = (import '../../libs/taskdef/container_datadog.libsonnet') + { p_env: p.env, p_name: p.name, p_use_apm: p.use_apm };
// 3. ecspresso定義
local ecspresso = (import '../../libs/ecspresso/ecspresso.libsonnet') + { p_cluster: p.cluster, p_service: p.name };
// 4. ecschedule定義
local schedule_base = (import '../../libs/ecschedule/base.libsonnet') + { p_cluster: p.cluster };
local schedule_rule = (import '../../libs/ecschedule/rule.libsonnet') + { p_env: p.env, p_name: p.name };

{
  # taskdef.jsonという名前のファイルを生成
  'taskdef.json':
    base {
      cpu: '4096',
      memory: '10240',
      containerDefinitions: [
        # 変更点のみを定義することで記述量を削減できる
        container {
          cpu: 4086,
          memoryReservation: 9216,
          environment+: [
            {
              name: 'SPRING_PROFILES_ACTIVE',
              value: p.env,
            },
          ],
        },
        # サイドカーとしてdatadogコンテナを追加
        container_datadog,
      ],
    },
    
  # ecspresso.jsonという名前のファイルを生成
  'ecspresso.json':
    ecspresso,
    
  # schedule.yamlという名前のファイルを生成
  'schedule.yaml':
    schedule_base {
      rules: [
        schedule_rule {
          description: '平日日中1時間ごとに実行するバッチ',
          scheduleExpression: 'cron(0 0-9 ? * MON-FRI *)',
          containerOverrides: [{
            name: weekly1,
            command: ['java xxxx'],
          },
          schedule_rule {
          description: '平日18時に実行するバッチ',
          scheduleExpression: 'cron(0 18 ? * MON-FRI *)',
          containerOverrides: [{
            name: daily1,
            command: ['java yyyy'],
          }],
        }
      ],
    },
}

共通部分の詳細は次のとおりです。

1. taskdef.jsonのアプリケーションコンテナ部分(libs/taskdef/container.libsonnet)

{
  p_env:: null,
  p_name:: null,
  p_use_apm:: false,
  p_java_opts:: null,
  p_java_opts_apm:: '-javaagent:/usr/local/bin/dd-java-agent.jar',
  name: $.p_name,
  image: '<IMAGE_URI>',
  cpu: '256',
  memoryReservation: '512',
  essential: true,
  portMappings: [
    {
      hostPort: 8080,
      protocol: 'tcp',
      containerPort: 8080,
    },
  ] + if $.p_use_apm then // p_use_apm: true の場合portMapping定義を追加
    [{
      hostPort: 10080,
      protocol: 'tcp',
      containerPort: 10080,
    }] else [],
  environment: [
    {
      name: 'TZ',
      value: 'Asia/Tokyo',
    },
  ] +  if $.p_use_apm then
             [
               {
                 name: 'JAVA_OPTS',
                 value: $.p_java_opts_apm + ' ' + $.p_java_opts_jmx + ' ' + $.p_java_opts,
               },
               省略 // DatadogAgent向けの設定
             ] else [],
  dockerLabels: if $.p_use_apm then
    {
    省略 // DatadogAgent向けのJMX設定
    } else {},
}

2. taskdef.jsonのサイドカーコンテナ部分(libs/taskdef/container_datadog.libsonnet)

// 関数も使える
local datadog_api_key_arn(env) = if env == 'prod' then
  'arn:aws:secretsmanager:ap-northeast-1:xxxxxxxxxxxx:secret:datadog/apikey-xxxx'
else
  'arn:aws:secretsmanager:ap-northeast-1:yyyyyyyyyyyy:secret:datadog/apikey-xxxx';

{
  p_env:: null,
  p_use_apm:: null,
  p_name:: null,
  name: 'datadog',
  image: 'gcr.io/datadoghq/agent:latest-jmx',
  cpu: 10,
  memoryReservation: 256,
  portMappings: [] + if $.p_use_apm then [{
    hostPort: 8126,
    protocol: 'tcp',
    containerPort: 8126,
  }] else [],
  essential: true,
  environment: [
    省略  // APMを使用しない場合のDatadogAgent設定
  ] + if $.p_use_apm == true then
    [
    省略 // APMを使用する場合のDatadogAgent設定
    ] else [],
  mountPoints: [],
  volumesFrom: [],
  secrets: [
    {
      name: 'DD_API_KEY',
      valueFrom: datadog_api_key_arn($.p_env), // 関数を呼び出し
    },
  ]
}

3. ecspresso定義(libs/ecspresso/ecspresso.libsonnet)

// 関数でパラメータのチェックもできる
local cluster(c) = if c == null
then error 'no cluster configured'
else c;

{
  p_cluster:: null,
  p_service:: null,
  region: 'ap-northeast-1',
  cluster: cluster($.p_cluster),
  service: $.p_service,
  task_definition: 'taskdef.json',
  timeout: '10m0s',
  plugins: [],
}

4. ecschedule定義(lib/ecschedule/~)

  • base(lib/ecschedule/base.libsonnet)
{
  p_cluster:: null,
  region: 'ap-northeast-1',
  cluster: $.p_cluster,
  rules: [],
}
  • rule(lib/ecschedule/rule.libsonnet)
省略
{
  p_env:: null,
  p_name:: null,
  name: $.p_env + '-ecs-schedule-' + $.p_name,
  scheduleExpression: null,
  targetId: 'default',
  タスク定義: $.p_env + $.p_name,
  launch_type: 'FARGATE',
  platform_version: 'LATEST',
  network_configuration: {
    aws_vpc_configuration: {
      subnets: subnets($.p_env),
      security_groups: security_groups($.p_env),
      assign_public_ip: 'DISABLED',
    },
  },
}

パイプラインの実装

mainへのPRが作成されたら自動でdryrunし、PRがmainにマージされた際に自動反映を行います。実装内容の詳細は省略し、何点かのポイントに絞って記載します。下記画像は再掲です。

fig.1 タスク定義とScheduledTaskの管理パイプライン
fig.1 タスク定義とScheduledTaskの管理パイプライン

差分検出

都度、 taskdef.jsonnet から ecspresso.jsonschedule.yaml を生成し、実環境との差分を表示します。毎回全ての差分を確認すると時間がかかるため、gitのファイル変更情報を元に差分ビルドを行えるようにしています。

# PR内(作業中ブランチ内)で更新されたファイルの一覧を出力するコマンド
GIT_TRUNK_COMMIT_ID=`git show-branch --merge-base origin/main HEAD`
git -c core.quotepath=false diff ${GIT_TRUNK_COMMIT_ID} HEAD --name-only --diff-filter=ACRM

# 上記のコマンドで差分として出力された `taskdef.jsonnet` から `ecspreso.json` や `schedule.yaml` を生成し、実環境との差分をとる

差分はこのように出力されます。

[INFO] タスク定義(outputs/hoge-app/prod/taskdef.json) の差分を表示します
         },
         {
+          "name": "MESSAGE",
+          "value": "hello, world"
+        },
+        {

[INFO] タスクの実行スケジュール(outputs/hoge-app/prod/schedule.yaml) の差分を表示します
[ecschedule] 💡 diff of the rule "prod-ecs-schedule-hoge-app"
name: hourly
description: 平日日中、1時間毎に集計処理を実行する
scheduleExpression: cron(0 0-910 ? * MON-FRI *) # 現状値の9が赤色で表示されて、変更後の値の10が緑色で表示される

ecspressoの考慮点

ECS serviceとして稼働していないアプリケーションは ecspresso で差分を確認することができないため、タスク定義(latest)を直接探して差分を表示するよう実装しています。

ecscheduleの考慮点

上記の差分例は、実行スケジュールを9時を10時に変えたときのものですが、差分がカラーシーケンス付きで表示されてしまいます。差分がない場合も現在の設定内容が出力されるため、diffの有無を判断するには工夫が必要です。
ワークアラウンドとして ecschedule diff コマンドの出力結果にカラーシーケンスが含まれているかどうかを正規表現でチェックする方法が考えられます。

ecschedule -conf $SCHEDULE_CNFIG diff -all > $DIFF_RESULT_FILE 2>&1
cat "$DIFF_RESULT_FILE" | grep -qE "\x1B\[([0-9]{1,3}(;[0-9]{1,2};?)?)?[mGK]"

反映の高速化

ecspresso のデフォルト挙動は新しいリビジョンのタスクが安定稼働するまで処理を待つため、同時に複数のタスク定義を変更したい場合長時間待たされることになります。その場合は、下記のように実装することで高速化が可能です。

  1. deployサブコマンドに --no-wait オプションを付与し、処理をバックグラウンドで進める
  2. waitサブコマンドで各処理の終了を待機する

crontabのJST表記対応

ScheduledTaskはUTCで定義する必要があります。cron定義は事故の元なので、 taskdef.jsonnet 上ではJSTで定義し、 schedule.yaml を生成するタイミングでUTCに自動変換するようスクリプトを書いてます。
事故が発生しやすいケースとしては、実行する曜日を指定をしている かつ UTC視点で日を跨ぐような場合です。

cron(*/2 8-20 ? * MON-FRI *)

この状態でdryrunすると、下記のようにビルド時に分割案が提案されます。

[ERROR] UTC視点で日付を跨ぐルールを指定しないでください。下記のようにルールを分割してください
  - jst: cron(*/2 8 ? * MON-FRI *)
   utc: cron(*/2 23 ? * SUN-THU *)
  - jst: cron(*/2 9-20 ? * MON-FRI *)
   utc: cron(*/2 0-11 ? * MON-FRI *)

ecspresso verifyによるチェック

ecspressoverifyサブコマンドは、ECSが起動失敗する原因となる設定を万遍なくチェックしてくれる頼もしいサブコマンドです。詳細は作者のfujiwaraさんの記事をご確認ください。

OPAによるポリシーチェック

jsonnet で定義を共通化することでゆるい強制はできるのですが、一部定義は変更を反映したタイミングでエラーになることがあります。Open Policy Agent(OPA)を使って反映前に気付けるようにしています。

FargateのCPU/MEMの組み合わせチェック

Fargateのタスク定義に割り当てられるCPU/MEMの組み合わせは決まっています。(詳細

FargateのCPU/MEMの組み合わせチェック実装例

import future.keywords.in

allowed_combinations = [
    {"cpu": "256", "memory": ["512", "1024", "2048"]},
    {"cpu": "512", "memory": ["1024", "2048", "3072", "4096"]},
    {"cpu": "1024", "memory": ["2048", "3072", "4096", "5120", "6144", "7168", "8192"]},
    {"cpu": "2048", "memory": ["4096", "5120", "6144", "7168", "8192", "9216", "10240", "11264", "12288", "13312", "14336", "15360", "16384"]},
    {"cpu": "4096", "memory": ["8192", "9216", "10240", "11264", "12288", "13312", "14336", "15360", "16384", "17408", "18432", "19456", "20480", "21504", "22528", "23552", "24576", "25600", "26624", "27648", "28672", "29696", "30720"]},
    {"cpu": "8192", "memory": ["16384", "17408", "18432", "19456", "20480", "21504", "22528", "23552", "24576", "25600", "26624", "27648", "28672", "29696", "30720", "31744", "32768", "33792", "34816", "35840", "36864", "37888", "38912", "39936", "40960", "41984", "43008", "44032", "45056", "46080", "47104", "48128", "49152", "50176", "51200", "52224", "53248", "54272", "55296", "56320", "57344", "58368", "59392", "60416", "61440"]},
    {"cpu": "16384", "memory": ["32768", "33792", "34816", "35840", "36864", "37888", "38912", "39936", "40960", "41984", "43008", "44032", "45056", "46080", "47104", "48128", "49152", "50176", "51200", "52224", "53248", "54272", "55296", "56320", "57344", "58368", "59392", "60416", "61440", "62464", "63488", "64512", "65536", "66560", "67584", "68608", "69632", "70656", "71680", "72704", "73728", "74752", "75776", "76800", "77824", "78848", "79872", "80896", "81920", "82944", "83968", "84992", "86016", "87040", "88064", "89088", "90112", "91136", "92160", "93184", "94208", "95232", "96256", "97280", "98304", "99328", "100352", "101376", "102400", "103424", "104448", "105472", "106496", "107520", "108544", "109568", "110592", "111616", "112640", "113664", "114688", "115712", "116736", "117760", "118784", "119808", "120832", "121856", "122880"]},
]

# Fargateのみ組み合わせをチェックする
allow_resource_combination {
    combination := allowed_combinations[_]
    input.cpu == combination.cpu
    input.memory in combination.memory
} else {
    # requiresCompatibilitiesがEC2の場合はチェックしない
    "EC2" in input.requiresCompatibilities
}

コンテナに割り当てたCPU/MEMの合計上限超えチェック

各コンテナのCPU/MEM割り当て値がタスクCPU/MEMの合計値を超えるとエラーになります。

コンテナに割り当てたCPU/MEMの合計上限超えチェックの実装例

allow_resource_allocation_limit {
    sum([container.cpu | container := input.containerDefinitions[_]]) <= to_number(input.cpu)
    sum([container.memoryReservation | container := input.containerDefinitions[_]]) <= to_number(input.memory)
}

さいごに

IaC(Infrastructure as Code )向けパイプラインの最適な実装方法は、組織の規模や関係性、監査要件等で変わるため、試行錯誤するしかないものだと思います。そんな私も例に漏れず、開発者体験の向上させつつセキュアなパイプラインを作るにはどうすればいいか日々頭を捻りながら働いています。
今でこそECSの利用が増えてきましたが、まだEC2で動いている モノリシックなアプリケーションや、EKS上にマイクロサービスがいたりと、ワークロードの多様性にも向き合う必要があります。さらに今後はマルチプロダクト化も見据えており、事業のスケールを支える基盤作りの重要性が高まっています。

📣ウェルスナビは一緒に働く仲間を募集しています📣

hrmos.co

筆者プロフィール

和田 雄樹(わだ ゆうき)

2018年1月ウェルスナビにインフラエンジニアとして入社。
山が綺麗に見える場所でひっそりと暮らしている。
最近はEKSのパイプラインを整備したりコンテナ化の推進をしている。