ecspresso v2とTerraform null_resourceで一発構築

SREチームの藤原です。

この記事はTech KAYAC Advent Calendar 2022 5日目の記事です。

この記事では筆者が開発しているAmazon ECSデプロイツール ecspresso (v2)と、Terraformnull_resourceを組み合わせて、 TerraformによるECS関連リソース作成とecspressoによるECSサービスのデプロイを一発で実行する手法について説明します。

ecspresso とは

github.com

ecspressoは筆者(fujiwara)が開発している、Amazon ECS用のデプロイツール(OSS)です。ECSにタスク定義とサービスをデプロイするツールはAWSが作っているものを含めて世の中に多々ありますが、ecspressoは次のような特徴を持っています。

  • Go 言語で書かれた OSS (MIT LICENSE) です
  • ECS サービスとタスクに関わる最小限のリソースをコード管理し、デプロイを実行するためのツールです
    • ECS サービスとタスクが動作するにあたって必要な関連リソース(例: IAM Role, ELB target group, VPC, Subnet, Security gruop など)を作成/管理する機能はありません
  • Terraform の State を管理している tfstate ファイルを読み、その情報を使うことができます
  • 既存の ECS サービスとタスクの情報を元に、構成ファイルを生成する機能があります
    • AWS コンソールや他のツールでデプロイしている既存サービスを、あとから ecspresso で管理するように変更できます

詳しくはecspresso handbookの1章(無料公開)も参照して下さい。 zenn.dev

ecspressoは、基本的にECSのタスク定義とサービスしか管理しないため、ECSが動作する際に必要な他のAWSのリソース (IAM RoleやVPC、ロードバランサーなど)は、別の手段で管理する必要があります。 *1

Terraformと組み合わせる

ECSサービスが必要とする関連リソースについては、カヤックでは主にTerraformによる管理を行っています。

ecspressoにはサービス定義やタスク定義ファイルの中でtfstate(Terraformが構成しているリソースの情報を保持しているファイル)を参照して、その属性を展開する機能があります。

{
  "launchType": "FARGATE",
  "networkConfiguration": {
    "awsvpcConfiguration": {
      "securityGroups": [
        "{{ tfstate `data.aws_security_group.http.id` }}"
      ],
      "subnets": [
        "{{ tfstate `aws_subnet.az-a.id` }}",
        "{{ tfstate `aws_subnet.az-c.id` }}"
      ]
    }
  }
}

このように記述すると、実際にTerraformによって構築されたリソースのIDをハードコードすることなく、Terraformのコード上で管理しているリソース名で記述できます。

環境ごとにtfstateを分割して同一のリソース名にしておくことで、参照するtfstateを切り替えるだけで複数環境へのECSデプロイを統一的に管理することもできます。

ecspresso + Terraform構成の面倒なところ

ECSへのアプリケーションのデプロイと、ECSが要求する依存リソースは更新のサイクルが異なることが多いため、長期運用する場合や管理するチームが分かれている場合などは特に、2つのツールを使い分けることにメリットがあります。

しかし、例えば検証用の環境を1から立ち上げて不要になったらすぐに削除したい、というような場合には1つのツールで完結するほうが便利です。

また、ECSが依存するリソース(例: IAM Role)と、ECS依存するリソース(例: Application Auto Scaling)を両方1つのTerraformで管理しようとすると、terraform applyを1回で済ませることができません。

  1. TerraformでIAM Roleを作成
    • この時点ではECSサービスはないため、Application Auto Scalingはコメントアウトするなどして作成しないようにする
  2. ecspressoでECSサービスを作成
  3. TerraformでApplication Auto Scalingを作成

と順に手作業をする必要があり、これが面倒なところでした。

ecspresso v2とTerraform null_resourceによる一発構築手法

ところでecspressoでは、もうすぐv2をリリースする予定です。v2での変更点は、以下の記事を参照して下さい。

sfujiwara.hatenablog.com

v1ではECSサービスを新規に作成する場合にはecspresso create、作成済みのサービスに変更やデプロイを行う場合にはecspresso deployと、サブコマンドを使い分ける必要がありました。

この仕様は冪等性がなく不便なことが多かったため、v2では新規作成も更新もecspresso deployコマンドのみで実行できるようになりました。

そして、この挙動とTerraformのnull_resourceを組み合わせることで、terraform applyを1回実行するだけで関連リソースとECSへのデプロイが完結できるようになりました!

具体的には、次のようなことが可能です。

  • 関連リソースの初期構築とECSへのデプロイをterraform applyのみで行える
  • Terraformで管理しているリソースに変更があった場合もterraform applyからecspresso deployが呼ばれるため自動でデプロイされる
    • ECSに関連しないリソースの変更ではデプロイは発生しません
  • もちろん単独でecspresso deployすることも可能
  • terraform destroyするとECSサービスも関連リソースも削除される

null_resourceとは

The null_resource resource implements the standard resource lifecycle but takes no further action.

とあるように、それ自身はTerraformリソースとしてのライフサイクルを持ちますが、単体では「何もしない」リソースです。

ただし、他のリソースの変更をトリガーにしてprovisionerでTerraformから外部コマンドを実行できます。そこでecspressoを実行することで、ECSサービスが依存するリソースも、ECSサービス自身も、ECSサービスに依存するリソースも、terraform apply一撃で構築できますし、terrraform destroy一撃で削除できます。

具体例

Terraformで管理するものは以下とします。

  • ECSが依存するリソース
    • IAM Role (タスク実行ロール)
    • ECSクラスタ
  • ECSに依存するリソース
    • Application Auto Scaling target

ECSが依存するリソースの.tfは普通に記述します。

resource "aws_iam_role" "ecs-task-execution" {
  name = "ecs-task-execution-oneshot"
  assume_role_policy = data.aws_iam_policy_document.ecs.json
}

resource "aws_iam_role_policy_attachment" "ecs-task-execution" {
  role       = aws_iam_role.ecs-task-execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_ecs_cluster" "oneshot" {
  name = "oneshot"
}

null_resourceによってecspressoでデプロイする記述は次のようになります。

  • triggersにECSが依存するリソースを記述
  • provisioner "local-exec"ecspresso deployを実行する
    • environmentで依存リソースの値を渡す
  • when = destroy条件でECSタスクを0にしてサービスを削除する記述も

ポイントは「environmentで依存リソースの値を渡す」点です。最初にterraform applyを実行してる途中にはtfstateに値が書き込まれていないため、ecspresso側からtfstate参照でIDなどを解決できません。しかしterraformの中では実行中でも作成済みのリソースの値は参照できるため、それを環境変数でecspresso側に渡してあげることで解決します。

// ECSが依存するリソースの変更でトリガーされるnull_resource
resource "null_resource" "ecspresso" {
  triggers = {
    cluster            = aws_ecs_cluster.oneshot.name,
    execution_role_arn = aws_iam_role.ecs-task-execution.arn,
  }

  provisioner "local-exec" {
    command     = "ecspresso deploy"
    working_dir = "."
    environment = { // 環境変数で依存リソースの値(ecspressoで参照するもの)を渡す
      ECS_CLUSTER        = aws_ecs_cluster.oneshot.name,
      EXECUTION_ROLE_ARN = aws_iam_role.ecs-task-execution.arn
    }
  }

  provisioner "local-exec" {
    command     = "ecspresso scale --tasks 0 && ecspresso delete --force"
    working_dir = "."
    when        = destroy // terraform destroy時に発動する条件
  }
}

ecspressoが使用するタスク定義は、次のようにします。ポイントは「 環境変数があればその値、なければtfstateを参照する」ためにテンプレート関数を{{ or }}を使って記述することです。

terraform applyによってnull_resourceがトリガーされて実行される場合は環境変数が使われます。Terraform経由ではなくecspresso deployを単独で実行した場合は、tfstateを参照することになります。

{
  containerDefinitions: [{
    essential: true,
    image: 'nginx:latest',
    name: 'nginx',
  }],
  cpu: '256',
  executionRoleArn: '{{or (env `EXECUTION_ROLE_ARN` ``) (tfstate `aws_iam_role.ecs-task-execution.arn`)}}',
  family: 'nginx',
  memory: '512',
  networkMode: 'awsvpc',
  requiresCompatibilities: ['FARGATE'],
}

最後に、ECSサービスに依存するリソースを記述します。data resourceでecspressoで管理しているサービスを参照し、それに依存する形でApplication Auto Scaling targetを定義しています。

data "aws_ecs_service" "oneshot" {
  cluster_arn  = aws_ecs_cluster.oneshot.name
  service_name = "nginx"
  depends_on = [
    null_resource.ecspresso,
  ]
}

resource "aws_appautoscaling_target" "nginx" {
  max_capacity       = 10
  min_capacity       = 1
  resource_id        = "service/${aws_ecs_cluster.oneshot.name}/${data.aws_ecs_service.oneshot.service_name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

実行結果の例

terraform applyで一撃構築されている様子がこちらです。IAM Role、ECS Cluster、ecspresso deploy、Application Auto Scaling targetの順で作成されていることが分かります。

aws_ecs_cluster.oneshot: Creating...
aws_iam_role.ecs-task-execution: Creating...
aws_iam_role.ecs-task-execution: Creation complete after 2s [id=ecs-task-execution-oneshot]
aws_iam_role_policy_attachment.ecs-task-execution: Creating...
aws_iam_role_policy_attachment.ecs-task-execution: Creation complete after 0s [id=ecs-task-execution-oneshot-20221202070518071000000001]
aws_ecs_cluster.oneshot: Still creating... [10s elapsed]
aws_ecs_cluster.oneshot: Creation complete after 11s [id=arn:aws:ecs:ap-northeast-1:314472643515:cluster/oneshot]
null_resource.ecspresso: Creating...
null_resource.ecspresso: Provisioning with 'local-exec'...
null_resource.ecspresso (local-exec): Executing: ["/bin/sh" "-c" "ecspresso deploy"]
null_resource.ecspresso (local-exec): 2022/12/02 16:05:26 nginx/oneshot Starting deploy
null_resource.ecspresso (local-exec): 2022/12/02 16:05:27 nginx/oneshot Service nginx not found. Creating a new service
null_resource.ecspresso (local-exec): 2022/12/02 16:05:27 nginx/oneshot Starting create service
null_resource.ecspresso (local-exec): 2022/12/02 16:05:27 nginx/oneshot Registering a new task definition...
null_resource.ecspresso (local-exec): 2022/12/02 16:05:27 nginx/oneshot Task definition is registered nginx:19
null_resource.ecspresso (local-exec): 2022/12/02 16:05:27 nginx/oneshot Service is created
null_resource.ecspresso (local-exec): 2022/12/02 16:05:30 nginx/oneshot Waiting for service stable...(it will take a few minutes)
null_resource.ecspresso: Still creating... [10s elapsed]
null_resource.ecspresso (local-exec): 2022/12/02 16:05:31 (service nginx) has started 1 tasks: (task 911019d2120b41ffa3b50da400cf5cb3).
null_resource.ecspresso (local-exec): 2022/12/02 16:05:40 nginx/oneshot  PRIMARY nginx:19 desired:1 pending:1 running:0
null_resource.ecspresso: Still creating... [20s elapsed]
null_resource.ecspresso (local-exec): 2022/12/02 16:05:50 nginx/oneshot  PRIMARY nginx:19 desired:1 pending:0 running:1
null_resource.ecspresso: Still creating... [30s elapsed]
null_resource.ecspresso (local-exec): 2022/12/02 16:06:06 nginx/oneshot Service is stable now. Completed!
null_resource.ecspresso: Creation complete after 39s [id=7075482505817107284]
data.aws_ecs_service.oneshot: Reading...
data.aws_ecs_service.oneshot: Read complete after 0s [id=arn:aws:ecs:ap-northeast-1:314472643515:service/oneshot/nginx]
aws_appautoscaling_target.nginx: Creating...
aws_appautoscaling_target.nginx: Creation complete after 1s [id=service/oneshot/nginx]

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

まとめ

ecspresso v2 とTerraform null_resourceを組み合わせることで、関連リソースもECSサービスも1コマンドの実行で構築/削除が完結する手法を紹介しました。

ecspresso は現在 v1.99.6をプレリリースしています。 試していただけると嬉しいです。

*1:この設計には意図があります。ecspresso handbookの設計思想と実装 の章を参照して下さい