コンテンツにスキップ

プラクティス集@Terraform

はじめに

本サイトにつきまして、以下をご認識のほど宜しくお願いいたします。


01. リポジトリ構成規約

リポジトリ分割のメリット

リポジトリを分割することにより、以下のメリットがある。

  • 認可スコープをリポジトリ内に閉じられるため、運用チームを別に分けられる。


アプリとIaCを同じリポジトリで管理

アプリケーションと同じリポジトリにて、terraformディレクトリを作成し、ここに.tfファイルを配置する。

repository/
├── app/ # アプリケーション
├── infra/
│   └── terraform/
│       ├── modules/ # ローカルモジュール
...


アプリとIaCを異なるリポジトリで管理 (推奨)

▼ ローカルモジュールを使用する (推奨)

アプリケーションとは異なるリポジトリにて、.tfファイルを配置しつつ、ルートモジュールとモジュールは同じリポジトリ内で管理する。

repository/
├── modules/ # ローカルモジュール
│   ├── foo-module/
│   ...

├── main.tf # ルートモジュール
├── variables.tf
...

▼ リモートモジュールを使用する

アプリケーションとは異なるリポジトリにて、.tfファイルを配置しつつ、ルートモジュールとモジュールは異なるリポジトリ内で管理する。

ルートモジュールでリモートモジュールのリポジトリのURLを指定し、参照する。

repository/
├── main.tf # ルートモジュール (リポジトリのURLを指定し、リモートモジュールを読み込む)
├── variables.tf
...
repository/
├── main.tf # リモートモジュール
├── outputs.tf
├── variables.tf
...

▼ ローカルモジュール / リモートモジュールの両方を使用する

ルートモジュールでは、ローカルモジュールとリモートモジュールのコールを実行する。

repository/
├── foo-system # 今後新しく作る他のシステム
├── bar-system
└── baz-system
    ├── module/ # まとめられるresourceブロックはローカルモジュール
    ├── README.md
    ├── acm.tf
    ├── eks.tf # eksのリモートモジュールをコール
    ├── iam.tf
    ├── locals.tf
    ├── nonprd
    │   ├── backend.tf
    │   └── terraform.tfvars # baz-system/terraform.tfstate
    ├── provider.tf
    ├── remote_state.tf
    ├── route53.tf
    ├── ssm.tf
    ├── sts.tf
    └── variable.tf


03. ローカルモジュールのディレクトリ構成規約

ルートディレクトリ構成方法の種類

▼ 対象リソース別

特定のリソースの設定が対象のリソースごとに異なる場合、冗長性よりも保守性を重視して、リソースに応じたディレクトリに分割する。

またスクリプトを使用するリソース (例:Lambda) では、そのソースコードをモジュール下で管理する。

repository/
└── modules/ # ローカルモジュール
    ├── cloudwatch/ # CloudWatch
    │   ├── alb/        # ALB
    │   ├── cloudfront/ # CloudFront
    │   ├── ecs/        # ECS
    │   ├── lambda/     # Lambda
    │   │   └── functions/
    │   │       └── foo_function/ # スクリプトのディレクトリ
    │   │
    │   └── rds/        # RDS
    
    └── waf/ # WAF
        ├── alb/         # ALB
        ├── api_gateway/ # API Gateway
        └── cloudfront/  # CloudFront


サブディレクトリ構成方法の種類

▼ 実行環境別

特定のリソースの設定が実行環境ごとに異なる場合、冗長性よりも保守性を重視して、実行環境に応じたディレクトリに分割する。

repository/
└── modules/ # ローカルモジュール
    ├── route53/ # Route53
    │   ├── tes/ # テスト環境
    │   ├── stg/ # ステージング環境
    │   └── prd/ # 本番環境
    
    ├── ssm/ # Systems Manager
    │   ├── tes/
    │   ├── stg/
    │   └── prd/
    
    └── waf/ # WAF
        └── alb/
            ├── tes/
            ├── prd/
            └── stg/

▼ リージョン別

特定のリソースの設定がリージョンごとに異なる場合、冗長性よりも保守性を重視して、リージョンに応じたディレクトリに分割する。

repository/
└── modules/ # ローカルモジュール
    └── acm/ # ACM
        ├── ap-northeast-1/ # 東京リージョン
        └── us-east-1/      # バージニアリージョン

▼ 共通セット別

WAFで使用するIPパターンセットと正規表現パターンセットには、CloudFrontタイプとRegionalタイプがある。

Regionalタイプは、同じリージョンの異なるクラウドプロバイダーのリソース間で共有できるため、共通セットとしてディレクトリ分割を実行する。

repository/
└── modules/ # ローカルモジュール
    └── waf/ # WAF
        ├── alb/
        ├── api_gateway/
        ├── cloudfront/
        └── regional_sets/ # Regionalタイプのセット
            ├── ip_sets/   # IPセット
            │   ├── tes/
            │   ├── stg/
            │   └── prd/
            
            └── regex_pattern_sets/ # 正規表現パターンセット
                ├── tes/
                ├── stg/
                └── prd/

▼ リソースのセットアップ別

リソースをセットアップする上で異なる種類のリソースが必要になる場合に、それらのresourceブロックを一つにまとめて管理する。

repository/
└── modules/ # ローカルモジュール
    └── eks/ # AWS EKS
        ├── auto_scaling/ # AutoScaling
        │   ├── main.tf
        │   ├── outputs.tf
        │   └── variables.tf
        
        ├── iam/ # IAMロール
        │   ├── main.tf
        │   ├── outputs.tf
        │   └── variables.tf
        
        ├── kubernetes/ # Kubernetesリソース (例:RoleBinding、StorageClass、など)
        │   ├── main.tf
        │   ├── outputs.tf
        │   └── variables.tf
        
        ├── launch_template/ # 起動テンプレート
        │   ├── main.tf
        │   ├── outputs.tf
        │   └── variables.tf
        
        ├── security_group/ # セキュリティグループ
        │   ├── main.tf
        │   ├── outputs.tf
        │   └── variables.tf
        
        └── node_group/ # Nodeグループ
            ├── main.tf
            ├── outputs.tf
            └── variables.tf


その他

▼ policiesディレクトリ

ポリシーのために.json形式を定義する場合、Terraformのコードにハードコーディングせずに、切り分けるようにする。

また、『カスタマー管理ポリシー』『インラインポリシー』『信頼ポリシー』も区別し、ディレクトリを分割している。

注意点として、templatefile関数でこれを読みこむ時、bashファイルではなく、tplファイルとして定義しておく必要あるため、注意する。

repository/
└── modules/ # ローカルモジュール
    ├── ecr/ # ECR
    │   └── ecr_lifecycle_policy.tpl # ECRライフサイクル
    
    ├── ecs/ # ECS
    │   └── container_definitions.tpl # コンテナ定義
    
    ├── iam/ # IAM
    │   └── policies/
    │       ├── customer_managed_policies/ # カスタム管理ポリシー
    │       │   ├── aws_cli_executor_access_policy.tpl
    │       │   ├── aws_cli_executor_access_address_restriction_policy.tpl
    │       │   ├── cloudwatch_logs_access_policy.tpl
    │       │   └── lambda_edge_execution_policy.tpl
    │       │
    │       ├── inline_policies/ # インラインポリシー
    │       │   └── ecs_task_policy.tpl
    │       │
    │       └── trust_policies/ # 信頼ポリシー
    │           ├── cloudwatch_events_policy.tpl
    │           ├── ecs_task_policy.tpl
    │           ├── lambda_policy.tpl
    │           └── rds_policy.tpl
    
    └── s3/ # S3
        └── policies/ # バケットポリシー
            └── alb_bucket_policy.tpl

▼ opsディレクトリ

CI/CDパイプライン上のterraformコマンドの実行で必要なシェルスクリプトは、opsディレクトリで管理する。

repository/
├── .circleci/ # CIツール (例:GitHub Actions、CircleCI、GitLab CI、ArgoWorkflow、Tekton、など) の設定ファイル
└── ops/ # TerraformのCI/CDの自動化シェルスクリプト


04. 命名規則と並び順

環境変数 (通常変数も同じ)

▼ 対象リソースに合わせる命名

複数のリソースで共有する場合 (将来的にそうなる可能性も含めて) は、Globalに配置し、グローバルな名前を付ける。

クラウドプロバイダーのリソースのアルファベット順に環境変数を並べる。

# ---------------------------------------------
# Global
# ---------------------------------------------
camel_case_prefix = "Bar"
region            = "ap-northeast-1"
environment       = "stg"
service           = "bar"

一方で、特定のリソースのみで使用する環境変数/通常変数の場合は、対象のリソース、種類名、オプション名、がわかるように命名する。

# 種類が無い時 (thisの時)
# 例:ecs_service_desired_count = 2
<使用するクラウドプロバイダーのリソースの名前>_<オプション名> = ****


# 種類がある時
# 例:ecs_task_cpu = 1024
<使用するクラウドプロバイダーのリソースの名前>_<種類名>_<オプション名> = ****

▼ list型、map型の命名

複数の値を持つlist型やmap型の環境変数であれば複数形で命名する。

一方で、string型など値が1個しかなければ単数形とする。

*実装例*

例として、VPCを示す。

# @ルートモジュール

# ---------------------------------------------
# Variables VPC
# ---------------------------------------------
vpc_availability_zones             = { a = "a", c = "c" }
vpc_cidr                           = "*.*.*.*/23"
vpc_subnet_private_datastore_cidrs = { a = "*.*.*.*/27", c = "*.*.*.*/27" }
vpc_subnet_private_app_cidrs       = { a = "*.*.*.*/25", c = "*.*.*.*/25" }
vpc_subnet_public_cidrs            = { a = "*.*.*.*/27", c = "*.*.*.*/27" }

▼ boolean型の命名

count引数による条件分岐でリソースの作成の有無を切り替えている場合、enable_***という名前のboolean型環境変数を用意する。

enable_foo = true


moduleブロック

▼ 命名規則

sourceオプションで指定するディレクトリ名で命名する。

スネークケースによる命名を採用する。

# @ルートモジュール

# ---------------------------------------------
# ALB
# ---------------------------------------------
module "alb" {
  # ローカルモジュール
  source = "../modules/alb"
}
# @ルートモジュール

module "alb" {
  # リモートモジュールを参照する。
  source = "git::https://github.com/hiroki-hasegawa/terraform-alb-modules.git"
}


resourceブロック、dataブロック

▼ リソースに種類がある場合

リソース名で、リソースタイプを繰り返さないようにする。

もし種類がある場合、リソース名でその種類を表す。

*実装例*

例として、VPCを示す。

# ---------------------------------------------
# Resource VPC ルートテーブル
# ---------------------------------------------

# 良い例
resource "aws_route_table" "public" {

}

resource "aws_route_table" "private" {

}
# ---------------------------------------------
# Resource VPC ルートテーブル
# ---------------------------------------------

# 悪い例
resource "aws_route_table" "route_table_public" {

}

resource "aws_route_table" "route_table_private" {

}

▼ リソースに種類が無い場合

リソースタイプに、1個のリソースしか種類が存在しない場合、thisで命名する。

thisと命名されたresourceで、後から種類が増える場合は、既存のthisは後からリファクタリングするとして、新規リソースには種類名を名付ける。

ただし、リファクタリングすることが大変なため、非推奨である。

*実装例*

# ---------------------------------------------
# Resource Internet Gateway
# ---------------------------------------------

resource "aws_internet_gateway" "this" {

}

▼ 接頭辞、接尾辞の場合

Lambda以外では、作成されるクラウドプロバイダーのリソースの名前は以下の通りとする。

  • ケバブケース
  • <接頭辞>-<種類>-<接尾辞>とする。
  • 接頭辞は、 <実行環境>-<サービス名>とする。
  • 接尾辞は、クラウドプロバイダーのリソース名とする。

*実装例*

例として、CloudWatchを示す。

  • 接尾辞は、 <実行環境>-<サービス名>
  • 種類は、alb-httpcode-4xx-count
  • クラウドプロバイダーのリソース名は、CloudWatchAlarmを省略してAlarm
resource "aws_cloudwatch_metric_alarm" "alb_httpcode_target_4xx_count" {

  alarm_name = "prd-foo-alb-httpcode-target-4xx-count-alarm"

}

LambdaではLambda関数が稼働する。

接尾辞にfunctionとつけることは冗長と判断したため、関数名のみで命名する。

*実装例*

例として、Lambdaをしめす。

  • 接尾辞は、 <実行環境>-<サービス名>
  • 種類は、echo-helloworld
  • クラウドプロバイダーのリソース名のlambdaは省略する。
resource "aws_lambda_function" "echo_helloworld" {

  function_name    = "prd-foo-echo-helloworld"

}


outputブロック

▼ リソースに種類がある場合

<リソース名>_<リソースタイプ>_<attribute名>』で命名する。可読性の観点から、リソース一括ではなく、具体的なattributeを出力する。

*実装例*

例として、CloudWatchを示す。

リソース名はecs_container_nginx、リソースタイプはaws_cloudwatch_log_group、attributeはnameオプションである。

output "ecs_container_nginx_cloudwatch_log_group_name" {
  value = aws_cloudwatch_log_group.ecs_container_nginx.name
}

*実装例*

例として、IAM Roleを示す。

# ---------------------------------------------
# Output IAM Role
# ---------------------------------------------
output "ecs_task_execution_iam_role_arn" {
  value = aws_iam_role.ecs_task_execution.arn
}

output "lambda_execute_iam_role_arn" {
  value = aws_iam_role.lambda_execute.arn
}

output "rds_enhanced_monitoring_iam_role_arn" {
  value = aws_iam_role.rds_enhanced_monitoring.arn
}

▼ リソースに種類が無い場合

リソース名がthisである場合、outputブロック名ではこれを省略しても良い。

*実装例*

例として、ALBを示す。

# ---------------------------------------------
# Output ALB
# ---------------------------------------------
output "alb_zone_id" {
  value = aws_lb.this.zone_id
}

output "alb_dns_name" {
  value = aws_lb.this.dns_name
}

▼ 冗長な場合 (アンチパターン)

ルール通りに命名すると、一部のクラウドプロバイダーのリソースで冗長な名前になってしまうことがある。

この場合は、省略を許容する。

*実装例*

ルール通りに名付けると、『laravel_ecr_repository_repository_url』というoutputブロック名になってしまう。

repositoryが二回繰り返されることになるため、1個省略している。

# ---------------------------------------------
# Output ECR
# ---------------------------------------------
output "laravel_ecr_repository_url" {
  value = aws_ecr_repository.laravel.repository_url
}

output "nginx_ecr_repository_url" {
  value = aws_ecr_repository.nginx.repository_url
}


descriptionオプション

一部のクラウドプロバイダーのリソースでは、descriptionオプションで説明文を設定できる。

基本的には英語で説明する。

また、文章ではなく、『関係代名詞/形容詞/副詞/前置詞 + 単語』を使用して、『〇〇△△』『〇〇△△』といった説明になるようにする。

variable "foo" {
  description = "説明文です"
  type = string
}

ヒアドキュメントを使用して、複数行で定義することもできる。

variable "foo" {
  description = <<EOF
  説明文です。
EOF
  type = string
}


04-02. 並び順

環境変数

▼ 並び順

Globalな環境変数を先頭に配置し、その他のクラウドプロバイダーのリソース固有の環境変数はアルファベット順に変数を並べる。


moduleブロック

▼ 並び順

環境変数を並べる# Variablesコメントと、モジュール間の値を受け渡しを並べる# Output valuesコメントに分ける。

# Variablesコメントの部分では、terraform.tfvarsファイルと同じ並び順になるようにする。

また、# Output valuesコメントの部分では、outputブロックをモジュールに渡す時にクラウドプロバイダーのリソースのアルファベット順で並べる。

# @ルートモジュール

# ---------------------------------------------
# ALB
# ---------------------------------------------
module "alb" {
  source = "../modules/alb"

  # Variables
  environment                   = var.environment                   # Global
  region                        = var.region
  service                       = var.service
  alb_listener_port_http        = var.alb_listener_port_http        # ALB
  alb_listener_port_https       = var.alb_listener_port_https
  ecs_container_nginx_port_http = var.ecs_container_nginx_port_http # ECS

  # Output values
  api_acm_certificate_arn                = module.acm_an1.api_acm_certificate_arn
  global_accelerator_acm_certificate_arn = module.acm_an1.global_accelerator_acm_certificate_arn
  alb_s3_bucket_id                       = module.s3.alb_s3_bucket_id
  alb_security_group_id                  = module.security_group.alb_security_group_id
  public_a_subnet_id                     = module.vpc.public_a_subnet_id
  public_c_subnet_id                     = module.vpc.public_c_subnet_id
  vpc_id                                 = module.vpc.vpc_id
}


resourceブロック、dataブロック

▼ 設定の並び順、行間

最初にcount引数やfor_each引数を設定し改行する。

その後、各リソース別の設定を行間を空けずに記述する (この順番にルールはなし) 。

最後に共通の設定として、tagsdepends_onlifecycle、の順で配置する。

ただし実際、これらの全ての設定が必要なリソースはない。

*実装例*

# ---------------------------------------------
# EXAMPLE
# ---------------------------------------------
resource "aws_baz" "this" {
  for_each = var.vpc_availability_zones # 最初にfor_each
  # スペース
  subnet_id = aws_subnet.public[*].id # 各設定 (順番にルールなし)
  # スペース
  tags = {
    Name = format(
      "prd-foo-%d-baz",
      each.value
    )
  }
  # スペース
  depends_on = []
  # スペース
  lifecycle {
    create_before_destroy = true
  }
}


05. 開発環境

terraformコマンドのセットアップ

▼ docker-compose.ymlファイルを用いる場合

作業者間でterraformコマンドのバージョンを統一するために、docker-compose.ymlファイルを作成する。

version: "3.8"

services:
  terraform:
    container_name: foo-terraform
    image: hashicorp/terraform:1.0.0
    volumes:
      - .:/var/infra
    working_dir: /var/infra

goのバイナリファイルを実行するためには、docker-compose runコマンドの実行する必要がある。

ただし、実行のたびにコンテナが増えてしまうため、--rmを使用する。

また、毎度コマンドを実行することが面倒なため、Makefileでまとめてしまう。

# NOTE:
# ローカル環境にて、以下の形式でコマンドを実行できます。
# make <ターゲット名> env=<環境ディレクトリ名>

env=

init:
    docker-compose run --rm terraform -chdir=./"${ENV}" init -reconfigure

fmt:
    docker-compose run --rm terraform fmt -recursive

validate: init fmt
    docker-compose run --rm terraform -chdir=./"${ENV}" validate

▼ asdfパッケージを使用する場合

バージョンを統一するために、.tool-versionsファイルを作成する。

$ asdf local terraform <バージョン>
# .tool-versionsファイル
terraform <バージョンタグ>

asdfパッケージを使用して、terraformコマンドをインストールする。

.tool-versionsファイルに定義されたバージョンがインストールされる。

$ asdf plugin list all | grep terraform

terraform   *https://github.com/asdf-community/asdf-hashicorp.git
...


$ asdf plugin add terraform https://github.com/asdf-community/asdf-hashicorp.git
$ asdf install


開発方法

前提として、バックエンドにS3を使用しているものとする。

Makefileのコマンドを実行する前に、provider.tfファイルのbackendオプションを、『s3』から『local』に変更する。

terraform {

  backend "local" {
  }
}

GitHubリポジトリにこの変更をプッシュしないように気を付ける必要がある。

ただし、バックエンドにs3を指定するterraform initコマンドをCIのステップを設けておけば、provider.tfファイルでlocalの指定していことがエラーになってしまうような仕組みを作れる。

もし間違えてコミットしてしまった場合は、元に戻すように再コミットすればよい。

#!/bin/bash

terraform -chdir=./prd init \
  -upgrade \
  -reconfigure \
  -backend=true \
  -backend-config="bucket=prd-foo-tfstate-bucket" \
  -backend-config="key=terraform.tfstate" \
  -backend-config="encrypt=true"
Error: Invalid backend configuration argument

The backend configuration argument "bucket" given on the command line is not expected for the selected backend type.


.gitignore

Terraformを開発する上でバージョン管理するべきではないファイルを.gitignoreファイルに記載する。

**/.terraform/*
*.tfstate
*.tfstate.*
crash.log
crash.*.log


06. CIツールに関する脆弱性対策

記入中...


機密な変数やファイルの扱い

▼ パラメーターの暗号化とSecretストア

方法 リポジトリ リポジトリ + キーバリュー型ストア リポジトリ + クラウドキーバリュー型ストア
バージョン管理 管理できる。 管理できる。 管理できない。
暗号化 base64方式エンコード値をリポジトリ内でそのまま管理する。非推奨である。 base64方式エンコード値を暗号化キー (例:AWS KMS、Google Cloud CKM、GnuPG、PGP、など) で暗号化する。 base64方式エンコード値を暗号化キー (例:AWS KMS、Google Cloud CKM、GnuPG、PGP、など) で暗号化する。
Secretストア なし リポジトリ上でキーバリュー型ストア (例:SOPS、Hashicorp Vault) で管理する。クラウドインフラへのプロビジョニング時にbase64方式エンコード値に復号化する。 クラウドプロバイダー内のキーバリュー型ストア (例:AWS パラメーターストア、Google Cloud SecretManager、など) で管理する。クラウドインフラへのプロビジョニング時にbase64方式エンコード値に復号化する。

tfstateファイルへの書き込みを防ぐ

機密な変数をignore_changes引数を使用して、tfstateファイルへの書き込みを防ぐ。

その上で、特定の方法 (例:SOPS、kubesec、AWS Secrets Manager) で実際の値を管理し、これをdataブロックで参照する。

ただし、リソースによってはignore_changes引数を使えないものがある (例:SSMパラメーターストア) 。

(1)

初期構築時にダミー値を割り当ててプロビジョニングする。

この時点で、tfstateファイルにはダミー値が書き込まれる。

(2)

実際の値をSecretManagerやAWS RDSのコンソール画面から設定する。

(3)

ignore_changes引数を設定する。

以降のプロビジョニングで、tfstateファイル上はダミー値のままになる。

# AWS RDSの場合
resource "aws_rds_cluster" "this" {

  # 実際の値はSecrets Managerから参照する。
  master_username = var.rds_db_master_username_ssm_parameter_value
  master_password = var.rds_db_master_password_ssm_parameter_value

  lifecycle {
    ignore_changes = [
      # ユーザー名とパスワードがtfstateファイルに書き込まれなくなる。
      master_username,
      master_password
    ]
  }
}

tfstateファイルへの平文を妥協する

tfstateファイルへ平文で定義されてしまうことを妥協する。

その代わりに、クライアントとバックエンド間の通信時に盗まれたり、バックエンド管理時に参照されることを防げるように、バックエンドの暗号化機能やアクセスポリシーを使用する。


tfstateファイルの暗号化

バックエンドのファイル暗号化を使用する。

バックエンド内のtfstateファイルを暗号化しておき、ダウンロード時だけ復号化するようにしておく。


outputブロックの暗号化

outputブロックに機密な変数を含む場合は、sensitiveオプションを有効化する。


07. アップグレード

設計規約

(1) 現在のTerraformのバージョンでterraform applyコマンドを実行

アップグレードと同時に新しいクラウドプロバイダーのリソースをデプロイせずに、アップグレードのみに専念する。

そのために、現在のTerraformのバージョンでterraform applyコマンドを実行することにより、差分が無いようにしておく。

(2) アップグレード以外の作業を済ませておく

低いバージョンのTerraformに対して、より高いバージョンはデプロイできる。

しかし、高いバージョンのTerraformに対して、より低いバージョンをデプロイできない。

そのため、アップグレードしてしまうと、それ以外のTerraformバージョンの異なる作業に影響が出る。

(3) マイナーバージョン単位でアップグレード

Terraformでは、マイナーバージョン単位でアップグレードを実行することが推奨である。

そのため、現在のバージョンと最新バージョンがどんなに離れていても、必ず1個ずつマイナーバージョンをアップグレードするように気をつける。

この時、次のマイナーバージョンの『最新』までアップグレードしてしまって問題ない (例:現在のバージョンがv0.13.0であれば、0.14系の最新にアップグレード) 。

また、アップグレードの都度、リリースを実行する。

(4) terraform planコマンドの警告/エラーを解決

アップグレードに伴って、非推奨/廃止の機能がリリースされ、警告/エラーが出力される場合がある。

警告/エラーを解決できるように、記法やオプション値を修正する。

場合によってはtfstateファイルの差分として表示されているのみで、実インフラとの差分ではない場合もあるため、terraform planコマンド時に差分があったとしても、実インフラに影響がなければ問題ない。

(5) プロバイダーをアップグレードしたい場合はTerraformもアップグレード

Terraformとプロバイダーのバージョンは独立して管理されている。

プロバイダーはTerraformが土台になって稼働するため、もしプロバイダーをアップグレードしたい場合は、Terraformもアップグレードする。

一方で、Terraformをアップグレードしたい場合は、必ずしもプロバイダーをアップグレードする必要はない。

アップグレードガイドについては、以下のリンクを参考せよ。

(6) Terraformとプロバイダーのアップグレードは別々にリリース

プロバイダーをアップグレードしたい場合はTerraformもアップグレードすることになる。

この場合、できるだけリリースは分けた方が良い。

Terraformまたはプロバイダーのアップグレードを別々にリリースするようにすれば、リリース時にインシデントが発生した時に、どちらが原因なのかを明確化できる。

反対に、一緒にリリースしてしまうとどちらが原因なのかわかりにくくなってしまう。


新バージョンの自動検出

外部のツール (例:renovate) を使用して、プロバイダーやTerraformの新バージョンを自動的に検出する。


08. CIパイプライン

仕様書自動作成

terraform-docsを使用して、variableブロック、outputブロック、moduleブロック、などの仕様書を作成する。

作成した仕様書を自動コミットできるようにする。

$ terraform-docs markdown . --output-file=README.md

TF_DOCSタグで囲われた場所のみを自動的に追記/更新してくれる。

# foo-terraformリポジトリ

<!-- BEGIN_TF_DOCS -->

## Requirements ... ## Providers ... ## Modules ... ## Resources ... ## Inputs
... ## Outputs ...

<!-- END_TF_DOCS -->


各ブロックのホワイトボックステスト

▼ 整形

Terraformの整形コマンド (terraform fmtコマンド) を使用して、ソースコードを整形する。

▼ 静的解析

観点 説明 補足
Terraformの文法の誤りテスト Terraformの静的解析コマンド (terraform validateコマンド) を使用する。機能追加/変更を含むブロックの文法の誤りを検証する。代わりとして、外部の静的解析ツール (例:tflint) を使用しても良い。
ベストプラクティス違反テスト
Terraformの脆弱性診断 外部の脆弱性診断ツール (例:tfsec) を使用する。報告されたCVEに基づいて、Terraformの実装方法に起因する脆弱性を検証する。

▼ ドライラン

テスト環境に対してterraform planコマンドを実行することにより、ドライランを実施する。

terraform planコマンドの結果は可読性が高いわけではないため、差分が多くなるほど確認が大変になる。

リリースの粒度を小さくし、差分が少なくなるようにする。

▼ 単体テスト

テスト環境に対してterraform applyコマンドを実行することにより、機能追加/変更を含むブロックが単体で正しく動作するか否かを検証する。

代わりとして、外部のテストツール (例:Terratest) を使用しても良い。

この時、リソース名をランダム値にしておくと、他の開発者とリソース名が重複せずに良い。

また、確認後にリソースをterraform apply -destroyコマンドで削除する。

残骸のリソースが残ることがあるため、合わせてテスト環境の全てのリソースをツール (例:cloud-nuke) で削除する。

▼ 機能テスト

アプリの文脈であると、各エンドポイントを1個の機能ととらえて、エンドポイントの機能テストを実施することがある。

ただ、クラウドプロバイダーが用意するエンドポイントが多すぎて、膨大な機能テストになる可能性がある。

▼ 結合テスト

テスト環境に対してterraform applyコマンドを実行することにより、機能追加/変更を含む複数のブロックを組み合わせた結合テストを実施する。

代わりとして、外部のテストツール (例:Terratest) を使用しても良い。

この時、リソース名をランダム値にしておくと、他の開発者とリソース名が重複せずに良い。

また、確認後にリソースをterraform apply -destroyコマンドで削除する。

残骸のリソースが残ることがあるため、合わせてテスト環境の全てのリソースをツール (例:cloud-nuke) で削除する。


ホワイトボックステスト結果の通知

▼ Terraformを使用する場合

Terraformには通知能力がなく、手動で知らせる必要がある。

そこで、terraform planコマンドの結果をクリップボードに出力し、これをプルリクに貼り付ける。

grepコマンドを使用して、差分の表記部分のみを取得すると良い。

これを確認し、差分が正しいかをレビューする。

$ terraform plan -var-file=foo.tfvars -no-color \
    | grep -A 1000 'Terraform will perform the following actions' \
    | pbcopy

▼ Terraform以外を使用する場合

通知ツール (例:tfnotify、tfcmt) を使用して、GitHub上にterraform planコマンドの結果が通知されるようにする。

これを確認し、差分が正しいかをレビューする。


各ブロックのブラックボックステスト

▼ 結合テスト

ステージング環境に対してterraform applyコマンドを実行することにより、機能追加/変更を含む複数のブロックが正しく連携するか否かを検証する

例えば、

ALB
⬇︎⬆︎
⬇︎⬆︎
EC2
⬇︎⬆︎
⬇︎⬆︎
RDS

といった構成のインフラがあった時に、AWSリソース単体の細かい設定値まではテストせずに、以下の観点で結合テストを実施する。

  • AWSリソース間の疎通がうまくいくか
  • ALBにリクエストを飛ばしてRDSが期待値を返却するか
リクエストを送信
⬇︎⬆︎
⬇︎⬆︎
ALB
⬇︎⬆︎
⬇︎⬆︎
EC2
⬇︎⬆︎
⬇︎⬆︎
RDS

▼ 総合テスト

ステージング環境に対してterraform applyコマンドを実行することにより、既存機能/追加/変更を含む全てのブロックを組み合わせた総合テストを実施する。

▼ 擬似的総合テスト

クラウドプロバイダーのモック (例:LocalStack) を使用して、擬似的な総合テストを実施しても良い。


08-02. CDパイプライン

レビュー

▼ コンソール画面にログイン

ただコードを眺めているより、レビュー対象がコンソール画面のどこに相当するのかも並行して確認した方が、Terraformを理解しやすい。

コンソール画面にログインする。

▼ クラウドプロバイダーのドキュメントや技術記事を確認

コンソール画面の相当する設定箇所がわかったところで、設定値が正しいか否かを確認する。

以下を確認する。

  • クラウドプロバイダーのドキュメント
  • 技術記事

▼ Terraformのドキュメントや技術記事を確認

AWSを作成する場合、TerraformのAWSプロバイダーを使用している。

以下を確認する。

注意点として、AWSプロバイダーのバージョンを確認し、リファレンスの閲覧バージョンを切り替える必要がある。

以下の点でレビューする。

  • プロジェクトの設計規約に即しているか
  • リファレンスに非推奨と注意書きされた方法で実装していないか
  • リリースの粒度は適切か

developブランチへのマージは問題ないか

developブランチにマージするコミット = 次にリリースするコミット である。

他にリリースの優先度が高い対応がある場合、またリリースの粒度が大きすぎる場合、同時にリリースしないように、developブランチへのマージに『待った!』をかけること。


デプロイ

▼ 大原則:プルリクエストを1個ずつリリース

基本的には、プルリクエストを1個ずつリリースする。

ただし、軽微なupdate処理が実行されるプルリクエストであれば、まとめてリリースしても良い。

もしリリース時に問題が発生した場合、インフラのバージョンのロールバックする必要がある。

経験則で、create処理やdestroy処理よりもupdate処理の方がエラーが少ないため、ロールバックにもたつきにくい。

プルリクエストを複数まとめてリリースすると、create処理やdestroy処理が実行されるロールバックに失敗する可能性が高くなる。

▼ 既存のリソースに対して、新しいリソースを紐付ける場合

既存のリソースに対して、新しいリソースを紐付ける場合、新しいリソースの作成と紐付けを別々にリリースする。

ロールバックでもたつきにくく、またTerraformで問題が発生したとしても変更点が紐付けだけなため、原因を追究しやすい。

▼ Terraformとプロバイダーの両方をアップグレードする場合

Terraformとプロバイダーを別々にリリースする。

▼ DBインスタンスの設定変更でダウンタイムが発生する場合

DBインスタンスの設定変更でダウンタイムが発生する場合、それぞれのDBインスタンスに対する変更を別々にリリースする。

また、リリース順序は以下の通りとする。

プライマリーインスタンスのリリース時にフェールオーバーが発生するため、ダウンタイムを短縮できる。

(1)

リードレプリカの変更をリリースする。

(2)

プライマリーインスタンスの変更をリリースする。リリース時にフェールオーバーを発生し、現プライマリーインスタンスはリードレプリカに降格する。また、前のリリースですでに更新されたリードレプリカがプライマリーインスタンスに昇格する。新しいリードレプリカがアップグレードされる間、代わりに新しいプライマリーインスタンスが動作する。

ダウンタイムが発生するDBインスタンスの設定項目は以下のリンクを参考にせよ。

RDSの項目として書かれており、Auroraではないが、おおよそ同じなため参考にしている。


ロールバック

▼ Terraformを使用する場合

Terraformには、クラウドインフラのバージョンのロールバック機能がなく、手動でロールバックする必要がある。

そこで、過去のリリースタグをterraform applyコマンドでデプロイすることにより、バージョンをロールバックする。

今回のリリースのcreate処理が少ないほどterraform applyコマンドでdestroy処理が少なく、反対にdestroy処理が少ないほどcreate処理が少なくなる。

もしリリース時に問題が発生した場合、インフラのバージョンのロールバックする必要があるが、経験則でcreate処理やdestroy処理よりもupdate処理の方がエラーが少ないため、ロールバックにもたつきにくい。

そのため、Rerun時にどのくらいのcreate処理やdestroy処理が実行されるかと考慮し、過去のリリースタグをterraform applyコマンドを実行するか否かを判断する。

▼ Terraform以外を使用する場合

CDパイプラインがない場合と同じである。


08-03. 事後処理

デプロイの通知

▼ Terraformを使用する場合

Terraformには通知能力がなく、手動で知らせる必要がある。

terraform applyコマンドの結果をクリップボードに出力し、これをリリースチケットに貼り付ける。

▼ Terraform以外を使用する場合

通知ツール (例:tfnotify、tfcmt) を使用して、GitHub上にterraform applyコマンドの結果が通知されるようにする。