ecspresso advent calendar 2020 day 20 - Jsonnetによる定義ファイル生成

8 min read読了の目安(約7600字

Amazon ECS のデプロイツールである ecspresso の利用法をまとめていく ecspresso Advent calendar 20日目です。

定義ファイルが JSON であることの問題

ecspresso が取り扱うサービス/タスク定義ファイルは JSON 形式です。これは awscli や AWS SDK Go で扱える JSON 形式との互換性を重視しているためです。

しかし実際にある程度複雑なサービスを運用すると、JSON 形式では不便なこともあります。

  • JSON は人間が編集するのに便利ではない
    • コメントが書けない、配列末尾の , の有無で余分な差分が発生するなど
  • サイドカーなどがほぼ同じで、一部だけ異なるタスク定義を複数運用する場面がある
    • 複雑な構造の一部が異なるような場合、環境変数展開ではカバーできないので重複した記述になる
  • タスク内の各コンテナで共通する要素がある場合、変更時のコストが高い
    • environment 要素などが典型的です

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

{
  "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"
}

nginx と mackerel-container-agent のコンテナの environment には AWS_REGIONTZ が共通で設定されています。

共通で設定したい環境変数に追加や削除がある場合、すべてのコンテナ定義内の記述を変更する必要があります。この例ではタスク内に2コンテナしかありませんが、実際に稼働するタスクでは多数のサイドカーがあることが多く、変更はより煩雑になります。

Jsonnet で JSON を生成する

JSON をテンプレートから生成する処理系として、Jsonnet というものがあります。ここでは Jsonnet を使って定義ファイルの JSON を生成することで、編集に伴う煩雑さを減らす方法を紹介します。

https://jsonnet.org/

https://qiita.com/yugui/items/f4a5e0e9dbd8ddffa48e

共通要素の括りだし

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

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

このファイルを envs.libsonnet として保存して、taskdef.json をコピーして taskdef.jsonnet を用意します。変更点は次の通りです。

  • 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 の上位互換なので、JSON をそのままでも扱えますが、jsonnetfmt コマンドを通すと素の JSON よりも人間が扱いやすい Jsonnet の記法に変換できます。

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

キーのクォートが不要になり、配列やオブジェクトの最後の要素の末尾 , が許可されるので、可読性と編集性が向上します。

コンテナ定義の共通化

タスク内に複数あるコンテナ定義をテンプレートで共通化してみます。各コンテナの共通要素を定義したファイルを用意して 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 のコンテナ定義もコンテナ定義テンプレートから生成します。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 して、タスク定義の JSON を生成する 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 が出力されます。

似たような構成のタスク定義を作りたい場合には、タスク定義のテンプレートを用意して共通項目を括りだし、それを基にそれぞれの JSON を生成してもよいでしょう。

Jsonnet をどう使うか

Jsonnet のサイトを見ると多くの機能がありますが、ecspresso の定義ファイルを簡素化するという意味では、まずは既存 JSON の共通部分を括り出す程度のテンプレートとして最小限で使うのをお勧めします。

Jsonnet は独自関数定義なども使えるので、過度に共通化を進めると将来の可読性に問題が出る可能性があります。ただし、結果的に生成されるものは純粋な JSON なので、最悪の場合は生成後の JSON からリファクタリングをやり直すことは可能でしょう。

Jsonnet で定義ファイルを生成した後は、ecspresso diff コマンドを使用することで、現在稼働しているサービス/タスクと差分があるかどうかを検出できます。


21日目は ecspresso の設計思想と、ecspresso がやること、やらないことを説明します。

https://zenn.dev/fujiwara/articles/ecspresso-20201221