はじめに
業務でTerraform歴が約1年になり、Tipsを備忘録も兼ねて書き記したいと思います。
基本AWS環境 + Terraformの一通り触ったことある方向けの話になります。
本記事では主に以下2点にフォーカスし、公式ドキュメントに書かれている一般的な内容は極力省いています。
- Terraform実務でのコード管理の工夫
- ECSの運用関連
参考:
複数リポジトリに跨るリソース参照の共通化方法
背景と課題
Terraformを使っていると、1つのリポジトリだけで完結せず、複数リポジトリにまたがってリソースを参照したいというケースはよくあると思います。
その際、命名規約やリソースIDの扱い方で下記の課題がありました。
- 命名規約の適用が徹底されず、担当者によってリソース名にばらつきが出てしまう
- 命名規約を
よく忘れる思い出すのに、毎回時間がかかる - 複数リポジトリを跨いでリソースを参照する際、
terraform apply
の出力からリソースIDを手作業で転記する必要があり、作業が煩雑になっていた
対応したこと
- 命名規約やグローバル変数のように扱いたい環境設定を、モジュールとして別リポジトリに切り出して共通化
- ただし一部(EFSのアクセスポイントIDなど)は
data
で参照できないため、SSM Parameter Storeに保存して集中管理 - 各リポジトリのTerraformコードからは
data
ブロックを使って参照するよう統一
例:
# 共通化したモジュールの例
locals {
_PREFIX = "${var.system_name}-${var.env}"
# 固定的に利用するリソース名
NAME = {
ECS_TASK_ROLE = "${local._PREFIX}-ecs-task"
ECS_TASK_EXECUTION_ROLE = "${local._PREFIX}-ecs-task-execution"
}
# format関数で利用できる形式
FMT = {
SG = "${local._PREFIX}-sg-%s"
ECS_TASK_NAME = "${local._PREFIX}-%s"
TARGET_GROUP = "${local._PREFIX}-tg-%s"
}
}
output "NAME" {
value = local.NAME
}
output "FMT" {
value = local.FMT
}
# 共通モジュールの利用例
variable "NAME" {
type = map(string)
}
variable "FMT" {
type = map(string)
}
# ロールを利用する例
data "aws_iam_role" "ecs_task" {
role_name = var.NAME.ECS_TASK_ROLE
}
# セキュリティグループを作成する際の例
resource "aws_security_group" "app" {
name = format(var.FMT.SG, "app")
# ...
}
# タスク定義名を生成する例
resource "aws_ecs_task_definition" "app" {
family = format(var.FMT.ECS_TASK_NAME, "web")
# ...
}
参考:
変数設定のヘルパーモジュールを利用する
背景と課題
とあるTerraformコードを、システムごとにGitブランチを分け、さらに各環境(dev/stg/prod)ごとにディレクトリと tfvars
を作成する運用していました。
例:
# systemA ブランチ
envs
├── dev
├── stg
└── prod
# systemB ブランチ
envs
├── dev
├── stg
└── prod
上記の運用で、下記のような課題がありました。
-
tfvars
で管理する変数が数十項目と多く、更新作業に負荷がかかっていた -
variable
ブロックでデフォルト値を設定しようにも、各システムごと(数十) × 各環境(dev/stg/prod)の管理が必要だった - 定期的に変更があり、コード管理の負担が大きい
# tfvarsで定義していた変数例
ecs_task_list = [
{
task_name = "app-name-001"
container_port = 8080
cpu = "1024"
memory = "2048"
health_check_path = "/app/001/health"
listener_path_pattern = ["/app/001/*"]
listener_port = 8080
listener_rule_priority = 100
},
{
task_name = "app-name-002"
container_port = 8080
cpu = "2048"
memory = "4096"
health_check_path = "/app/002/health"
listener_path_pattern = ["/app/002/*"]
listener_port = 8080
listener_rule_priority = 200
},
# ...
]
対応したこと
variable
ブロックでデフォルト値の設定を行うことも可能ですが、下記の理由から共通値やフォーマット済みの変数を一括で返す「ヘルパーモジュール」を利用する方式を採用しました。
-
variable
不採用にした理由-
variable
では(locals
と異なり)組み込み関数を直接使った柔軟な生成ロジックを持たせにくい - デフォルト値の修正時に各システムブランチ + 各環境ディレクトリに同様の変更が必要となり、適用漏れのリスクが高い
-
例:
# ヘルパーモジュールの例
locals {
default_port = 8080
converted_task_list = [
for idx, task in var.ecs_task_list : {
# ECSタスク定義関連
task_name = task.task_name
container_port = coalesce(task.container_port, local.default_port)
health_check_path = task.health_check_path
cpu = coalesce(task.cpu, "1024")
memory = coalesce(task.memory, "2048")
# ALB関連
listener_port = coalesce(task.listener_port, local.default_port)
listener_path_pattern = task.listener_path_pattern
# パスパターンはユニークにする運用想定のため、連番を割り当て
listener_rule_priority = idx + 1
}
]
}
output "ecs_task_list" {
value = local.converted_task_list
}
参考:
Terraformのフラットモジュール化
背景と課題
プロジェクトの過渡期にコードを作成したため、モジュール構成が定まる前に本番コード化し、下記のような課題がありました。
- 抽象度の低い小さなモジュールが乱立し、どのモジュールがどのリソースを管理しているか分かりづらい
- ネストが深くなり、コード変更時に影響範囲を把握するのが難しい
- 他チームのエンジニアなどがコードを読む際に時間がかかる
対応したこと
- Terraform公式ドキュメントの推奨に沿い、モジュール構成をフラット化
- ルートモジュール以外では、基本
module
ブロックは宣言しない方針に変更 - リファクタリングの過程で冗長なモジュールを削除し、意味のある一定単位で抽象度が高くなるようにモジュールを調整
上記対応後は、全体的なコードの見通しが良くなり、変更時の影響範囲が把握しやすくなりました。
プロジェクトの事情でフラット化がすぐに適用できないケースもあるかと思いますが、可能であれば早めに導入するのが望ましいと感じています。
例:
# モジュール構成例
## Before(抽象度が低い)
modules
├── efs
├── elb
│ ├── alb
│ └── nlb
├── s3
└── vpc
└── subnet
## After(抽象度が高い)
modules
├── lb
├── network
└── storage
参考:
ECSコンテナのイメージURIをTerraformで引き継ぐ方法
背景と課題
ECSのタスク定義では latest
タグを使わず、GitのコミットID(SHA)をECRのイメージURIに付与する運用をしています。(アプリのバージョンとECRイメージを紐付けて管理するため)
そのため、下記のような課題がありました。
- CIでタスク定義を更新する際、コミットID(SHA)付きのイメージURIが生成される
- Terraformが「前回のタスク定義のイメージURI」を保持していないため、新たにTerraform実行するとイメージURIが引き継げずに差分が発生する
- 当初は
external
プロバイダー経由のシェルスクリプトで「前回のタスク定義のイメージURI」を取得していたが、Terraformの実行速度が目に見えて遅くなるという問題があった
対応したこと
調べれば分かることなのですが、日本語の情報が少なかったので記載しておきます。
下記のようなコードで、直前のタスク定義からイメージURIを参照するようにしました。
- 初回
apply
実行時、use_latest_image
をfalse
で実行 - 2回目以降
apply
する際は、use_latest_image
をtrue
で実行し、直前のタスク定義のイメージURIを引き継ぎ
# サンプルコード
data "aws_ecs_container_definition" "previous" {
count = var.use_latest_image ? 1 : 0
task_definition = data.aws_ecs_task_definition.previous[0].family
container_name = var.task_name # タスク定義の名称を指定
}
resource "aws_ecs_task_definition" "this" {
container_definitions = jsonencode([
{
# ...
image = var.use_latest_image ? data.aws_ecs_container_definition.previous[0].image : var.task_name
}
])
# ...
}
参考:
ECS環境変数をS3で外部管理してチーム運用を改善する方法
背景と課題
アプリとインフラでチーム分けがされている組織で、下記のような課題がありました。
- 環境変数の管理にタスク定義のenvironmentを利用していた
- 環境変数の追加・変更をするのにアプリチーム↔︎インフラチームでコミュニケーションが都度必要だった
- インフラチームが1つに対し、複数のアプリチームがあるため、運用面で負担が大きかった
例:
# environmentの設定例
resource "aws_ecs_task_definition" "this" {
container_definitions = jsonencode([
{
# ...
environment = [
{
name = "APP_ENV"
value = "production"
},
{
name = "DB_HOST"
value = "db.example.com"
},
{
name = "LOG_LEVEL"
value = "INFO"
},
{
name = "API_BASE_URL"
value = "https://api.example.com/v1"
}
]
}
])
# ...
}
対応したこと
インフラチームではなく、アプリチーム主体で自由に設定できるよう、下記の方針としました。
- タスク定義のenvironmentではなく、S3管理(.env形式の設定ファイル)のenvironmentFiles(S3)へ変更
- タスク実行ロールには、参照対象バケット/オブジェクトのみに限定した
s3:GetObject
権限を付与(最小権限の付与を推奨) -
aws s3 sync
コマンドを実行するCI/CDパイプラインを作成し、環境変数ファイルをデプロイできる仕組みを構築
例:
# environmentの設定例
resource "aws_ecs_task_definition" "this" {
container_definitions = jsonencode([
{
# ...
environmentFiles = [
{
type = "s3"
value = "arn:aws:s3:::${var.env_bucket}/${var.env_key}"
}
]
}
])
# ...
}
# .envの設定例
APP_ENV=production
DB_HOST=db.example.com
LOG_LEVEL=INFO
API_BASE_URL=https://api.example.com/v1
参考:
まとめ
- 命名規約・ID・共通値は「モジュール化 + 参照パターンの統一」で迷いをなくす
- 多数の環境値は「ヘルパーモジュール」で整形して
tfvars
の負担を下げる - モジュール構成はできるだけフラットにして、読みやすさと影響範囲の把握を優先
- ECSのイメージURIは直前定義をデータ参照することでCI/CDとTerraformを両立
- 環境変数は
environmentFiles
(S3 + .env)でアプリ主体の運用に寄せる
本記事がTerraform運用の参考になれば幸いです。