🤖

TerraformでECS Taskに書くsecretsをうまく運用する

2021/12/17に公開

これは「「はじめに」の Advent Calendar 2021」17日目の記事です。

ECSのタスクは環境変数をパラメータストアやシークレットマネージャーの値を起動時に設定出来る機能があります。

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/specifying-sensitive-data-parameters.html

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/specifying-sensitive-data-secrets.html

具体的な手順は、いつものクラメソさんの記事を参考にしてください。

https://dev.classmethod.jp/articles/ecs-secrets/

https://dev.classmethod.jp/articles/ecs-support-secrets-manager-version/

運用時の問題

シークレット系の情報の管理とアプリケーションで必要な環境変数をあわせる時に、いくつか問題が発生します。

次のようなECSサービスがあったとします。
(※色々省略しているのでこのまま動きません)

resource "aws_ecs_cluster" "example" {
  name = example-api
}

resource "aws_ecs_task_definition" "example" {
  family                   = "example"
  execution_role_arn       = aws_iam_role.example_execution.arn
  task_role_arn            = aws_iam_role.example.arn
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = 1024
  memory                   = 2048
  container_definitions = jsonencode([
    name        = "api"
    image       = "example/api:latest"
    environment = [
      { "name": "ENV", "value": "development" }
    ]
    secrets     = [
      { 
        "name": "TOKEN_FROM_SECRET_MANAGER", 
        "valueFrom": 
          "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:token-json:json_key::" 
      },
      {
        "name": "TOKEN_FROM_PARAMERTER_STORE", 
        "valueFrom": 
          "arn:aws:ssm:ap-northeast-1:123456789012:parameter/foo_token" 
      }
    ])
}

data "aws_ecs_task_definition" "example" {
  task_definition = aws_ecs_task_definition.example.family
}

resource "aws_ecs_service" "example" {
  cluster         = aws_ecs_cluster.example.id
  task_definition = "${aws_ecs_task_definition.example.family}:${max(aws_ecs_task_definition.example.revision, data.aws_ecs_task_definition.example.revision)}"
  desired_count   = 1
}

Task定義のsecretsは、ParameterStoreやSecretManagerから値を読み込み name でしていた環境変数に起動時に設定してくれます。
ただ、このままだと運用上の めんどくさい 問題が発生します

環境変数を追加・変更したいときの問題

以下の順序で作業する必要があります。

  1. ParameterStoreやSecretManagerを追加
  2. 追加したキーと、対応する環境変数名をTask定義に追加
  3. Task定義を更新 terraform apply
  4. 新しいDockerイメージをビルドしてデプロイ

この場合、以下のような問題点が発生します。

  • ECSの起動に失敗する
    • 1で作ったキーを valueFrom に正しく記述する必要があり、1文字でも間違えるとECSの起動に失敗します。
  • 変数名を書き間違える
    • 2の変数名も正しく記述しないと、ECSタスクは起動するものの、アプリケーションが予期しない挙動になります。最悪、変数がないことで正しく動作しないかもしれません。
  • Task定義の更新を忘れる
    • 3のapplyを忘れて4のデプロイを行っても環境変数名を間違えたときと同様です。
  • ParameterStoreやSecretManagerを随時修正する必要がある
    • 環境変数を追加するたびに1のParameterStoreやSecretManager を追加する必要があります。

対応-ECS起動失敗を防ぐ

ECS起動失敗を防ぐために、ParameterStoreやSecretManagerもTerraformで管理することにします。

resource "aws_ssm_parameter" "foo_token" {
  name  = "foo_token"
  type  = "SecureString"
  value = var.foo_token
}

resource "aws_secretsmanager_secret" "token_json" {
  name = "token-json"
}

resource "aws_secretsmanager_secret_version" "token_json" {
  secret_id = aws_secretsmanager_secret.token_json.id
  secret_string = jsonencode({
    json_key = var.json_key_token
  })
}

terraformでのcredential管理は、 SOPSやterraform cloudなど、別の方法を利用する必要がありますが、この記事ではそういった方法は取り扱いません。参考リンクを張っておきます。

https://github.com/mozilla/sops

https://chroju.dev/blog/terraform_with_sops

https://www.terraform.io/cloud-docs/workspaces/variables

ecsは次のようになります

resource "aws_ecs_task_definition" "example" {
  family                   = "example"
  execution_role_arn       = aws_iam_role.example_execution.arn
  task_role_arn            = aws_iam_role.example.arn
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = 1024
  memory                   = 2048
  container_definitions = jsonencode([
    name        = "api"
    image       = "example/api:latest"
    environment = [
      { "name": "ENV", "value": "development" }
    ]
    secrets     = [
      { 
        "name": "TOKEN_FROM_SECRET_MANAGER", 
        "valueFrom": 
          "${aws_secretsmanager_secret.token_json.arn}:json_key::" 
      },
      {
        "name": "TOKEN_FROM_PARAMERTER_STORE", 
        "valueFrom": 
          aws_ssm_parameter.foo_token.arn
      }
    ])
}

対応2-変数名の書き間違いを防ぐ

設定したシークレットの名前を環境変数名と合わせます。
また、複数のアプリケーションで同じ変数名を利用した場合もあるので衝突を防ぐ必要があります。
ParameterStoreはPath形式で記述することで容易に解決ができます。
SecretMangerは1つのSecrets内のJSONなので、別シークレットにしておけばよいです。
JSON内のキーは locals で変数化しておきます。

resource "aws_ssm_parameter" "foo_token" {
  name  = "/example/TOKEN_FROM_PARAMERTER_STORE"
  type  = "SecureString"
  value = var.foo_token
}

resource "aws_secretsmanager_secret" "token_json" {
  name = "token-json"
}

locals {
  sercet_key_name = "TOKEN_FROM_SECRET_MANAGER"
}

resource "aws_secretsmanager_secret_version" "token_json" {
  secret_id = aws_secretsmanager_secret.token_json.id
  secret_string = jsonencode({
    "${local.sercet_key_name}" = var.json_key_token
  })
}
(略)
  container_definitions = jsonencode([
    name        = "api"
    image       = "example/api:latest"
    environment = [
      { "name": "ENV", "value": "development" }
    ]
    secrets     = [
      { 
        "name": local.sercet_key_name
        "valueFrom": 
            "${aws_secretsmanager_secret.token_json.arn}:${local.sercet_key_name}::" 
      },
      {
        "name": element(split("/", aws_ssm_parameter.foo_token.name), 1), 
        "valueFrom": 
            aws_ssm_parameter.foo_token.arn
      }
    ])
(略)

対応3-Task定義とシークレット情報を同期する(多分出来る)

1つキーを追加するたびに、Task定義の修正をしていくと、コードも長くなり、リソースの参照ミスが発生するようになります。
Task定義の設定を書き直さずに、ParameterStoreやSecretManagerのリソース追加に追従させてみます。

/*
example_parameters = {
  TOKEN_FROM_PARAMERTER_STORE: "secret-value",
  TOKEN_FROM_PARAMERTER_STORE_SECOD: "secret-value-second",
  ...(略)
}

example_secrets = {
  TOKEN_FROM_SECRET_MANAGER: "secret-value",
  TOKEN_FROM_SECRET_MANAGER_SECOD: "secret-value-second",
  ...(略)
}

というmap変数を想定
*/

resource "aws_ssm_parameter" "foo_token" {
  for_each = var.example_parameters
  name  = "/example/${each.key}"
  type  = "SecureString"
  value = each.value
}

data "aws_ssm_parameters_by_path" "example_parametes" {
  path = "/example"
  depends_on = [ aws_ssm_parameter.foo_token ]
}

locals {
  parameter_store_values = [
    for key, arn in zipmap(
              data.aws_ssm_parameters_by_path.example_parametes.names,
              data.aws_ssm_parameters_by_path.example_parametes.arns) : {
      name: split("/", key, 1),
      valueFrom: arn
    }
  ]
}

resource "aws_secretsmanager_secret" "token_json" {
  name = "token-json"
}

resource "aws_secretsmanager_secret_version" "token_json" {
  secret_id = aws_secretsmanager_secret.token_json.id
  secret_string = jsonencode(local.example_secrets)
}

data "aws_secretsmanager_secret" "token_json" {
  arn = aws_secretsmanager_secret.token_json.arn
  depends_on = [
    aws_secretsmanager_secret.token_json,
  ]
}

locals {
  secrets_manger_values = [
    for key in keys(jsondecode(nonsensitive(data.aws_secretsmanager_secret_version.api_secrets.secret_string))) : {
      name = key,
      valueFrom = "${data.aws_secretsmanager_secret.api_secrets.arn}:${key}::"
    }
  ]
}
(略)
  container_definitions = jsonencode([
    name        = "api"
    image       = "example/api:latest"
    environment = [
      { "name": "ENV", "value": "development" }
    ]
    secrets     = concat(local.parameter_store_values, local.secrets_manger_values)
(略)

これで、ParameterStoreやSecretManagerのJSONと同期したTask定義が作成できます。
datadepends_on を設定するとdepends_onより前のapplyとdenpend_on以降のapplyと2回applyしたのと同じ効果が得られます(得られるはず。ダメだったら2回更新したら確実。)

これで、最初に出た問題は全て解決できたはずです。
CIツールによって、ECSサービスが利用しているTaskRevisionがずれるかもしれない問題はアドベントカレンダーの14日目のテクニックで回避しています。

https://qiita.com/hajimeni/items/f41a83bdced6d1c091d6

他の問題として

アプリケーション担当の人が「terraformは触りたくない、触れない。けれど環境変数の追加は自分でやりたい」といった場合の解決策

前提

Task定義のDockerイメージタグを latest 固定にするか、別のParameterStoreなどに格納してTerraformから参照させておいてください。(上記リンク参照)
SecretManagerやParameterStoreのValueは ignore_changes に追加しておいてください。

妥協案.1

  • SecretManagerの値はAWSコンソールからアプリケーション担当の人に修正してもらう。
  • 変更を検知して、GithubActionなどでTerraformを実行してTask定義を更新

妥協案.2

  • SecretManagerの値はAWSコンソールからアプリケーション担当の人に修正してもらう。
  • CIパイプラインにterraform applyを組み込んでTask定義を更新

どちらもパラメータストアはキーの追加時に入力ミスがあるので、人間が入力するのであればSecretManagerをつかたほうが良いでしょう。

Discussion