ECS task definition を Jsonnet で生成する

Amazon ECS でタスクの構成定義に使う task definition をリポジトリで管理する場合、JSON 形式で取り扱うことが多いと思います。

ECS デプロイツールである ecspresso でも、task definition は環境変数等を展開できる記法があるものの、基本的には JSON ファイルとして扱っています。

これは AWS コンソールで作成した task definition を aws describe-task-definition や ecspresso init コマンドでファイルとして取り出して利用することを前提としているためで、これを YAML や別の形式で扱うことは考えていません。

ところで、実際に ECS でそれなりのサービスを運用すると、task definition JSON の管理で悩むことが増えてきました。

  • JSON は人間が編集するのに便利ではない
    • コメントが書けない、配列末尾の , の有無で余分な diff が発生、key をいちいち " " で括るのが面倒…
  • サイドカーなどがだいたい同じで、一部だけ異なる task definition を複数運用する場面がある
    • 複雑な構造の一部が異なるような場合、環境変数展開ではカバーできないので、コピペせざるを得ない
  • タスク内の各コンテナで共通で埋め込みたい要素がそこそこあるが、揃えて変更するコストが高い
    • environment 要素などが典型

例として、nginx にサイドカーとして mackerel-container-agent を追加した task definition が以下のようにあるとします。

{
  "containerDefinitions": [
    {
      "name": "nginx",
      "image": "nginx:latest",
      "environment": [
        {
          "name": "AWS_REGION",
          "value": "ap-northeast-1"
        },
        {
          "name": "TZ",
          "value": "Asia/Tokyo"
        }
      ],
      "essential": true,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "ecspresso-test",
          "awslogs-region": "ap-northeast-1",
        }
      },
      "portMappings": [
        {
          "containerPort": 80,
          "hostPort": 80,
          "protocol": "tcp"
        }
      ]
    },
    {
      "name": "mackerel-container-agent",
      "image": "mackerel/mackerel-container-agent:v0.4.0",
      "environment": [
        {
          "name": "AWS_REGION",
          "value": "ap-northeast-1"
        },
        {
          "name": "TZ",
          "value": "Asia/Tokyo"
        },
        {
          "name": "MACKEREL_CONTAINER_PLATFORM",
          "value": "ecs"
        }
      ],
      "essential": true,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "ecspresso-test",
          "awslogs-region": "ap-northeast-1",
        }
      },
      "secrets": [
        {
          "name": "MACKEREL_APIKEY",
          "valueFrom": "/MACKEREL_APIKEY"
        }
      ]
    }
  ],
  "cpu": "256",
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "family": "ecspresso-test",
  "memory": "512",
  "networkMode": "awsvpc",
  "requiresCompatibilities": [
    "EC2",
    "FARGATE"
  ],
  "taskRoleArn": "arn:aws:iam::123456789012:role/ecsTaskRole"
}

environment には AWS_REGION と TZ が共通で設定されています (mackerel-container-agent には追加で MACKEREL_CONTAINER_PLATFORM)。

共通で設定したい環境変数に変更がある場合、nginx と mackerel-container-agent の両方のコンテナ定義を変更する必要があります。この例ではタスク内に 2コンテナしかないですが、実際に稼働するタスクでは多数のサイドカーがあったりして、人手で漏れなく変更するのは大変ダルいことになります。

Jsonnet で JSON を生成する

JSON をテンプレートから生成する処理系として、Jsonnet というものがあります。これを使って task definition JSON を生成して、辛さが軽減できないか試してみました。

jsonnet.org

qiita.com

共通要素の括りだし

まず environment 要素の共通部分を抜き出してファイルにします。

[
    {
        "name": "AWS_REGION",
        "value": "ap-northeast-1"
    },
    {
        "name": "TZ",
        "value": "Asia/Tokyo"
    }
]

このファイルを envs.libsonnet として保存して、taskdef.json をコピーして taskdef.jsonnet を以下のように用意します。もとの taskdef.json をちょっといじるだけです。

  • local envs = import 'envs.libsonnet'; を追加
  • "environment": envs, として import した値を展開
# taskdef.jsonnet
local envs = import 'envs.libsonnet'; # envs テンプレートの読み込み
{
  "containerDefinitions": [
    {
      "name": "nginx",
      "image": "nginx:latest",
      "environment": envs, # 読み込んだ envs をここに展開
      "essential": true,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "ecspresso-test",
          "awslogs-region": "ap-northeast-1",
        }
      },
      "portMappings": [
        {
          "containerPort": 80,
          "hostPort": 80,
          "protocol": "tcp"
        }
      ]
    },
    {
      "name": "mackerel-container-agent",
      "image": "mackerel/mackerel-container-agent:v0.4.0",
      "environment": envs + [  # envs 配列への追加
        {
          "name": "MACKEREL_CONTAINER_PLATFORM",
          "value": "ecs"
        }
      ],
      "essential": false,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "ecspresso-test",
          "awslogs-region": "ap-northeast-1",
        }
      },
      "secrets": [
        {
          "name": "MACKEREL_APIKEY",
          "valueFrom": "/MACKEREL_APIKEY"
        }
      ]
    }
  ],
  "cpu": "256",
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "family": "ecspresso-test",
  "memory": "512",
  "networkMode": "awsvpc",
  "requiresCompatibilities": [
    "EC2",
    "FARGATE"
  ],
  "taskRoleArn": "arn:aws:iam::123456789012:role/ecsTaskRole"
}

これを jsonnet コマンドで処理すると、taskdef.json と同一のものが生成されます。

$ jsonnet taskdef.jsonnet | head
{
   "containerDefinitions": [
      {
...

$ # 差分確認
$ diff -w <(jsonnet taskdef.jsonnet | jq --sort-keys .) <(cat taskdef.json | jq --sort-keys .)

JSON より扱いやすい記法への変換

Jsonnet は JSON の superset なので JSON 記法そのままでも扱えますが、jsonnetfmt コマンドを通すと素の JSON よりも人間が扱いやすい Jsonnet の記法に変換してくれます。

$ jsonnetfmt envs.libsonnet
[
  {
    name: 'AWS_REGION',
    value: 'ap-northeast-1',
  },
  {
    name: 'TZ',
    value: 'Asia/Tokyo',
  },
]

key のクォートが不要だったり、配列やオブジェクト末尾の , が許可されたりと、すっきり書けるようになります。

コンテナ定義の共通化

タスク内に複数あるコンテナ定義をテンプレートで共通化してみます。各コンテナの共通要素を定義したファイルを用意して container.libsonnet というファイルに保存します。

# container.libsonnet
local envs = import 'envs.libsonnet';
{
  essential: true,
  environment: envs,
  logConfiguration: {
    logDriver: 'awslogs',
    options: {
      'awslogs-group': 'ecspresso-test',
      'awslogs-region': 'ap-northeast-1',
    },
  },
}

このコンテナ定義テンプレートを使って、nginx コンテナの定義を生成する jsonnet は次のようになります。 import したコンテナ定義テンプレートに、必要な要素を上書き(追加)するだけです。

# nginx.libsonnet
local container = import 'container.libsonnet';
container {
  name: 'nginx',
  image: 'nginx:latest',
  portMappings: [
    {
      containerPort: 80,
      hostPort: 80,
      protocol: 'tcp',
    },
  ],
}

同様に mackerel-container-agent のコンテナ定義も container 定義テンプレートから生成します。environment は配列要素への追加になるため、container.environment + [ ] という形で定義しています。

# mackerel.libsonnet
local container = import 'container.libsonnet';
container {
  name: 'mackerel-container-agent',
  image: 'mackerel/mackerel-container-agent:v0.4.0',
  environment: container.environment + [
    {
      name: 'MACKEREL_CONTAINER_PLATFORM',
      value: 'ecs',
    },
  ],
  secrets: [
    {
      name: 'MACKEREL_APIKEY',
      valueFrom: '/MACKEREL_APIKEY',
    },
  ],
}

この各コンテナ定義を import して、taskdef を生成する jsonnet は以下のようになりました。すっきり!

# taskdef.jsonnet
local mackerel = import 'mackerel.libsonnet';
local nginx = import 'nginx.libsonnet';
{
  containerDefinitions: [
    nginx,
    mackerel,
  ],
  cpu: '256',
  executionRoleArn: 'arn:aws:iam::123456789012:role/ecsTaskExecutionRole',
  family: 'ecspresso-test',
  memory: '512',
  networkMode: 'awsvpc',
  requiresCompatibilities: [
    'EC2',
    'FARGATE',
  ],
  taskRoleArn: 'arn:aws:iam::123456789012:role/ecsTaskRole',
}

この taskdef.jsonnet を jsonnet コマンドでレンダリングすると、もとの taskdef.json と同一内容の JSON が出力されます。

更に似たような task definition を増やしたい場合には taskdef の定義テンプレートを用意して共通項目を括りだし、それを基にそれぞれの JSON を生成してもいいでしょう。

まとめ

取り扱いが煩雑になりがちな JSON による ECS task definition を Jsonnet というテンプレート言語で生成してみました。

Jsonnet のサイトを見るとあまりにいろいろな機能があってびっくりしますが、まずは既存 JSON の共通部分を括り出す程度のテンプレートとして最小限で使うのをお勧めします。

Jsonnet は独自関数定義なども使えるので、やり過ぎると読めなくなる可能性はありそうです。が、生成されるものは plain な JSON なので、最悪読めなくなっても生成後の JSON からリファクタリングをやり直すことは可能です。

デプロイに ecspresso を利用する場合は、デプロイ前に ecspresso diff コマンドを使用すると、現在稼働中の task との差分があるかを検出できます。Jsonnet でリファクタリングした結果意図せず壊していないかの検知に便利だと思います。