技術(tech)

Terraformで実現するAWS ECSタスク定義の環境変数・シークレット管理最適化

はじめに

複数環境・複数サービスのAWS ECSを運用していると、環境変数やシークレットの管理で頭を悩ませることが多いんじゃないでしょうか。

「開発環境と本番環境で同じ環境変数を何度も定義している」
「シークレット情報の管理方法がバラバラ」
「新しいサービス追加のたびに同じような設定作業を繰り返している」

こういった課題を抱えているなら、今回の記事が参考になるかもしれません。私自身も同じ悩みを抱えていたので、Terraformを活用した環境変数とシークレットの一元管理手法をまとめてみました。

  • 想定読書時間:10分
  • 取り扱う技術スタック:Terraform、AWS ECS、AWS SecretsManager、コンテナ設定管理

対象とする読者

  • Terraformの基本と、AWS ECSの概念を理解している方
  • コンテナの環境変数について基本的な知識がある方
  • 複数環境・複数サービスの設定管理で困っている方

技術レベルとしては中級者向けの内容になります。

背景

プロジェクトが成長するにつれて、環境変数の数とタスク定義の数が増えていき、管理が複雑になっていきました。具体的には以下のような問題が出てきました:

  • 環境変数とシークレットが区別なく混在している
  • 複数のタスク定義で同じ設定を何度も書いている
  • 環境固有の設定と共通設定がごちゃまぜになっている

これらの問題は小規模なうちは気にならなくても、サービス数や環境が増えるにつれて無視できない課題になります。

解決する課題

今回取り組んだ具体的な課題は次の3つです:

  1. タスク定義間での環境変数の重複管理をなくす
  2. 環境変数とシークレットを明確に分ける
  3. 環境別設定を構造化し、新しい環境を簡単に追加できるようにする

この課題を解決することで、設定管理の保守性が向上し、新しい環境やサービスを追加する際の作業効率が大幅に上がります。また、機密情報の管理も適切に行えるようになり、セキュリティリスクも軽減できます。

実装方法

私が採用したアプローチは以下の3点です:

  1. 環境変数テンプレートファイルを構造化する
  2. 環境変数とシークレットを明示的に分離する
  3. Terraformのローカル変数を使って動的に環境変数を組み立てる

検討した代替案としては、環境ごとに完全に別ファイルを作成する方法もありましたが、管理が複雑になるため見送りました。また、すべての環境変数をSecretsManagerで管理することも考えましたが、コスト増とパフォーマンス低下が懸念されたため採用しませんでした。

プロジェクト構成

実装全体を理解するために、プロジェクトのディレクトリ構成と各ファイルの役割を紹介します。

ディレクトリ構造

terraform-ecs-project/
├── main.tf              # プロジェクトのメインエントリーポイント
├── variables.tf         # 変数定義
├── outputs.tf           # 出力定義
├── environments/        # 環境別設定
│   ├── dev.tfvars       # 開発環境の変数値
│   ├── staging.tfvars   # ステージング環境の変数値
│   └── prod.tfvars      # 本番環境の変数値
├── ecs.tf               # ECSクラスターとサービス定義
├── secretsmanager.tf    # SecretsManagerリソース定義
└── task_definitions/    # タスク定義テンプレート
    ├── common_env_vars.tftpl    # 共通環境変数テンプレート
    ├── service_a.tftpl          # サービスAのタスク定義テンプレート
    └── service_b.tftpl          # サービスBのタスク定義テンプレート

主要ファイルの役割

各ファイルの役割について簡単に説明します。実際のコードを交えながら見ていきましょう。

1. main.tf

プロジェクトのエントリーポイントとなるファイルです。

provider "aws" {
  region = var.aws_region
}

locals {
  env = var.environment
}

# その他の共通設定...

2. ecs.tf

ECSクラスター、サービス、タスク定義を管理するファイルです。

# ECSクラスター定義
resource "aws_ecs_cluster" "main" {
  name = "${var.prefix}-cluster-${var.env}"
}

# タスク定義とECSサービスの定義(サービスごとに定義)
resource "aws_ecs_task_definition" "service_a" {
  # 詳細は後述の実装詳細セクションを参照
}

resource "aws_ecs_service" "service_a" {
  name            = "service-a"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.service_a.arn
  desired_count   = var.service_a_count

  # その他の設定...
}

# 同様にサービスBの定義

3. secretsmanager.tf

シークレット管理用リソースを定義するファイルです。

# 各環境ごとのシークレット定義
resource "aws_secretsmanager_secret" "app_secrets" {
  name = "${var.env}/app-secrets"
  description = "アプリケーション用シークレット情報(${var.env}環境)"

  tags = {
    Environment = var.env
    Application = "my-app"
    ManagedBy   = "terraform"
  }
}

# 注: シークレットの具体的な値はセキュリティのためAWSコンソールから手動で設定します

4. task_definitions/common_env_vars.tftpl

環境変数とシークレットの共通テンプレートファイルです。JSONフォーマットで環境ごとの設定を定義します。

{
  "common": {
    "environment": [
      { "name": "APP_USER", "value": "user" },
      // その他の共通環境変数
    ],
    "secrets": [
      { "name": "DB_PASSWORD", "valueFrom": "${secrets_arn}/DB_PASSWORD" },
      // その他の共通シークレット
    ]
  },
  "dev": {
    // 開発環境固有の設定
  },
  "prod": {
    // 本番環境固有の設定
  }
}

5. task_definitions/service_a.tftpl、service_b.tftpl

各サービス用のコンテナ定義テンプレートファイルです。

[
  {
    "name": "service-a-${env}",
    "image": "${ecr_repo_url}:${image_tag}",
    "environment": ${common_env_vars},
    "secrets": ${common_env_secrets},
    // サービス固有の設定
  }
]

実装詳細

1. 環境変数テンプレートファイルの構造化

{
  "common": {
    "environment": [
      { "name": "APP_USER", "value": "user" },
      { "name": "DB_USER", "value": "db_admin" }
    ],
    "secrets": [
      { "name": "DB_PASSWORD", "valueFrom": "${secrets_arn}/DB_PASSWORD" },
      { "name": "API_KEY", "valueFrom": "${secrets_arn}/API_KEY" }
    ]
  },
  "dev": {
    "environment": [
      { "name": "DB_ENDPOINT", "value": "db-dev.example.com" }
    ],
    "secrets": []
  },
  "prod": {
    "environment": [
      { "name": "DB_ENDPOINT", "value": "db-prod.example.com" }
    ],
    "secrets": []
  }
}

この構造にした理由は単純で、環境変数の重複を減らし、どの環境でどの値が使われるかを一目で把握できるようにするためです。共通部分と環境固有部分が明確に分離されているので、変更の影響範囲も把握しやすくなります。

2. Terraformでの環境変数処理の実装

locals {
  # 通常の環境変数はfileで読み込んで処理
  env_vars_tpl = file("task_definitions/common_env_vars.tftpl")

  # 環境変数の結合処理
  common_env_vars = jsonencode(concat(
    jsondecode(local.env_vars_tpl)["common"].environment,
    jsondecode(local.env_vars_tpl)[var.env].environment
  ))

  # シークレット用テンプレート処理 - ARNを置き換えた形でシークレットを作成
  common_env_secrets_tpl = templatefile(
    "task_definitions/common_env_vars.tftpl",
    {
      secrets_arn = aws_secretsmanager_secret.app_secrets.arn
    }
  )

  # シークレットの結合処理
  common_env_secrets = jsonencode(concat(
    jsondecode(local.common_env_secrets_tpl)["common"].secrets,
    jsondecode(local.common_env_secrets_tpl)[var.env].secrets
  ))
}

ここでの処理のポイントは2つあります:

  1. 通常の環境変数: file()関数でテンプレートファイルを読み込み、共通の環境変数と環境固有の変数を結合しています。

  2. シークレット変数: templatefile()関数を使用して、読み込み時に直接SecretsManagerのARNを置換しています。こうすることで、シークレットパスを動的に生成できます。

この2段階の処理により、非機密情報と機密情報を適切に分離しながも、同一のテンプレートファイルから効率的に設定を生成できます。templatefile()を使うことで、環境変数ファイル内で変数置換(${secrets_arn}など)が可能になります。

3. Secrets Manager リソースの定義

resource "aws_secretsmanager_secret" "app_secrets" {
  name        = "${var.env}/app-secrets"
  description = "アプリケーション用シークレット情報(${var.env}環境)"

  # 削除保護の設定(本番環境では有効に)
  recovery_window_in_days = var.env == "prod" ? 30 : 7

  tags = {
    Environment = var.env
    Application = "my-app"
    ManagedBy   = "terraform"
  }
}

セキュリティ面を考慮して、環境ごとにSecretsManagerリソースを作成しています。環境変数とは別に管理することで、機密情報の取り扱いを明確に区別しています。

なお、実際のシークレット値はTerraformではなくAWSコンソールから直接設定しています。これは機密情報がコードやバージョン管理システムに保存されるリスクを避けるためです。

4. 複数タスク定義での環境変数活用

resource "aws_ecs_task_definition" "service_a" {
  family = "service-a-${var.env}"
  # ... 他の設定 ...

  container_definitions = templatefile(
    "task_definitions/service_a.tftpl",
    {
      env                = var.env
      environment        = local.environment
      common_env_vars    = local.common_env_vars
      common_env_secrets = local.common_env_secrets
      # シークレットのARN参照を直接渡す
      secrets_arn        = aws_secretsmanager_secret.app_secrets.arn
      # サービス固有の変数
      service_specific_var = "value-a"
    }
  )
}

ここでは、先ほど作成した共通環境変数とシークレットをタスク定義テンプレートに渡しています。また、シークレットのARNをテンプレートに直接渡すことで、テンプレート内で${secrets_arn}/KEY_NAME形式の参照を実現しています。

同様に別のサービスでも共通の環境変数セットを利用できるので、設定の一貫性が保たれます。

5. タスク定義テンプレートでの環境変数使用

[
  {
    "name": "service-${env}",
    "image": "123456789012.dkr.ecr.region.amazonaws.com/service-${env}",
    "environment": ${jsonencode(concat(jsondecode(common_env_vars), [
      {
        "name": "RAILS_ENV",
        "value": "${environment}"
      },
      {
        "name": "SERVICE_TYPE",
        "value": "service-a"
      },
      {
        "name": "SERVICE_SPECIFIC_VAR",
        "value": service_specific_var
      }
    ]))},
    "secrets": ${jsonencode(concat(jsondecode(common_env_secrets), [
      {
        "name": "SERVICE_API_KEY",
        "valueFrom": "${secrets_arn}/SERVICE_API_KEY"
      }
    ]))},
    "essential": true,
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "/ecs/service-${env}",
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "ecs"
      }
    }
  }
]

タスク定義テンプレート内では、共通環境変数にサービス固有の変数を動的に追加しています。jsondecode()で共通環境変数をデコードし、サービス固有の変数と結合してから再エンコードするという流れです。

よくある落とし穴と対策

実装してみて気づいた問題点とその対策を紹介します:

  • JSONフォーマットの問題
    テンプレート内のJSON構文エラーがデプロイ失敗の原因になることが何度かありました。特に配列の末尾カンマには注意が必要です。JSONバリデーターでテンプレートを事前に検証することをおすすめします。

  • シークレット参照パスの間違い
    valueFromの形式が正しくないとECSコンテナが起動しません。AWS CLIなどで実際のSecretsManager参照をテストしておくと安心です。

  • 環境変数の衝突
    同名の環境変数が異なる場所で定義されると上書きされてしまいます。私はサービス名をプレフィックスにするなどして、変数名が重複しないようにしています。

  • テンプレート変数未定義エラー
    テンプレート内で未定義の変数を参照するとエラーになります。デフォルト値を設定しておくと便利です(例:${var.name != "" ? var.name : "default"})。

実践ステップ

これから同様の仕組みを導入したい方向けに、実践ステップをまとめておきます。

  1. 環境変数の棚卸しと分類
    すべての環境変数を列挙し、「共通」「環境固有」「シークレット」に分類します。

  2. 環境変数テンプレートファイルの作成

    {
     "common": { "environment": [...], "secrets": [...] },
     "env1": { "environment": [...], "secrets": [...] },
     "env2": { "environment": [...], "secrets": [...] }
    }
  3. Terraform locals ブロックの実装

    locals {
     # 環境変数の処理
     env_vars_tpl = file("path/to/template.tftpl")
     common_env_vars = jsonencode(concat(
       jsondecode(local.env_vars_tpl)["common"].environment,
       jsondecode(local.env_vars_tpl)[var.env].environment
     ))
    
     # シークレット用テンプレート処理 - ARNを置き換え
     common_env_secrets_tpl = templatefile(
       "path/to/template.tftpl",
       {
         secrets_arn = aws_secretsmanager_secret.app_secrets.arn
       }
     )
    
     # シークレットの結合処理
     common_env_secrets = jsonencode(concat(
       jsondecode(local.common_env_secrets_tpl)["common"].secrets,
       jsondecode(local.common_env_secrets_tpl)[var.env].secrets
     ))
    }
  4. タスク定義テンプレートの更新

    container_definitions = templatefile(
     "path/to/service_template.tftpl",
     {
       env = var.env,
       common_env_vars = local.common_env_vars,
       common_env_secrets = local.common_env_secrets,
       # サービス固有の変数
       service_var = "value"
     }
    )
  5. テストとデプロイ

    • terraform validate でシンタックスチェック
    • terraform plan で変更内容を確認
    • 開発環境へデプロイしてコンテナ起動を確認

Q&A

Q1: 環境変数とシークレットをどのように区別すべきですか?

A1: 一般的なルールとして、API キー、パスワード、アクセストークンなどの認証情報はシークレットとして扱い、エンドポイント URL、ポート番号、機能フラグなどの非機密情報は通常の環境変数として扱います。シークレットは AWS SecretsManager などの専用サービスで管理し、暗号化して安全に保存します。

Q2: 新しい環境を追加する場合の手順は?

A2: 環境変数テンプレートファイルに新しい環境のセクションを追加し、Terraform 変数に新環境名を追加するだけです。既存のタスク定義はテンプレート処理によって自動的に新環境用の設定を生成します。

おわりに

Terraformとテンプレート機能を活用することで、ECSの環境変数管理を大幅に改善できることを紹介してきました。適切な構造化と分離により、多環境・多サービスの設定管理が効率化されるというのが私の経験です。

今後の展望としては、環境変数管理のためのカスタムモジュールや検証ツールの開発なども考えられますが、まずは今回紹介したアプローチを試してみてください。

同様の課題に取り組まれている方、他の解決策やアイデアがあればぜひコメントでお知らせください!

参考文献