🏎️

DR(ディザスタリカバリ)サイトをTerraformのOverride Filesを活用して構築する

2025/03/28に公開

はじめに

こんにちは、株式会社スマートラウンドVP of Reliabilityの@shonansurvivorsです。SRE等を担当しています。

本記事では、AWS東京リージョンをメインサイトとするWebサービスについて、Terraformを用いて大阪リージョンにDisaster Recovery(以下、DR)サイトを構築する際の一事例を解説します。

本記事のポイント

基本的にDRサイト用のAWSリソースは、Symbolic linkでメインサイトのtfコードを参照して作成しますが、IAMやS3バケットについては名前の衝突を避けるためTerraformのOverride Filesの機能を使います。この手法により、平時の保守開発の認知負荷を上げることなく、DRサイトのためのIaCを用意できます。

なお、DRの戦略は、バックアップ&リストア、パイロットライト、ウォームスタンバイ、マルチサイト active/activeなどがありますが、本記事ではバックアップ&リストアを想定して解説していきます。

https://docs.aws.amazon.com/ja_jp/prescriptive-guidance/latest/strategy-database-disaster-recovery/defining.html

そのため、DRサイトのAWSリソースは必要になったタイミングで作成しますが、RDSや、エンドユーザーの固有のファイルを格納するS3バケットなど、ステートフルなリソースは平時からクロスリージョンでバックアップやレプリケーションを行うようにします。

Terraformに関する前提

本記事は、以下のような「moduleを使わずに複数環境を構築するファイル構成」を前提として説明を進めていきます。

-- terraform.sh # {env}.tfbackendや{env}.tfvarsを読み込むラッパースクリプト 
-- <project-name>/
   -- backend.tf     # backendブロックを記述
   -- dev.tfbackend  # backendの具体的な設定値を管理
   -- stg.tfbackend  # 同上
   -- prod.tfbackend # 同上

   -- variables.tf # variableの定義を管理
   -- dev.tfvars   # variableの値を管理(Git管理しても問題の無いもののみ)
   -- stg.tfvars   # 同上
   -- prod.tfvars  # 同上

   -- terraform.sh -> ../terraform.sh # Symbolick link

   -- providers.tf
   -- versions.tf 

   -- vpc.tf      # 各種resource定義
   -- ecs.tf      # 同上
   -- ecs_iam.tf  # 同上
   -- (other tfs) # 同上

   -- <component-name>/ # tfstateを分けたいもの(詳細後述)があれば別ディレクトリにする
      -- (some files)      # ファイル構成の考え方は上位ディレクトリと同じ
   -- (other components/)

そのため、moduleを使って複数環境を構築している場合は、本記事の手法のいくつかはそのまま流用できないと思います...ご容赦ください。

前述の構成について、より詳しく知りたい方は以下の別記事をご覧ください。

記事中にも記載がありますが、この構成では

terraform init -backend-config=dev.tfbackend
terraform plan -var-file=dev.tfvars

といったように、backend設定と各種変数値のファイルを読み込ませることで環境を切り替えられるようにしており、一方でこれらコマンドを毎回正しく指定するのは手間でミスも起こり得るため、ラッパースクリプトを用意し、

./terraform.sh dev plan

のように、複数環境を楽に安全に切り替えられるようにしています。

メインサイトとDRサイトでディレクトリを分ける

まず、メインサイトとDRサイトでディレクトリを分けるようにします。

そして、tfファイルについては、DRサイトからSymbolickでメインサイトを参照するようにします。

これにより、メインサイトのコードを原則DRサイトで流用することができます。

-- <project-name>/   # メインサイト
   -- backend.tf
   -- {env}.tfbackend

   -- variables.tf
   -- {env}.tfvars

   -- providers.tf
   -- versions.tf 

   -- vpc.tf             # 各種resource定義
   -- ecs.tf             # 同上
   -- ecs_iam.tf         # 同上
   -- rds.tf             # 同上
   -- rds_replication.tf # 同上
   -- s3.tf              # 同上
   -- s3_replication.tf  # 同上
   -- (other tfs) # 同上

-- <project-name>-dr/ # DRサイト
   -- backend.tf      -> ../<project-name>/backend.tf
   -- {env}.tfbackend 

   -- variables.tf    -> ../<project-name>/valiables.tf
   -- variables_dr.tf # 詳細後述
   -- {env}.tfvars

   -- providers.tf    -> ../<project-name>/providers.tf
   -- versions.tf     -> ../<project-name>/versions.tf

   -- vpc.tf              -> ../<project-name>/vpc.tf
   -- ecs.tf              -> ../<project-name>/ecs.tf    
   -- ecs_iam.tf          -> ../<project-name>/ecs_iam.tf 
   -- ecs_iam_override.tf # 詳細後述
   -- rds.tf              -> ../<project-name>/rds.tf    
   -- rds_override.tf     # 詳細後述    
   -- s3.tf               -> ../<project-name>/s3.tf    
   -- s3_override.tf      # 詳細後述    
   -- (other tfs)         -> ../<project-name>/(other tfs)

backendをメイン・DR双方に対応させる

本記事で扱う構成はbackendの設定をtfbackendファイルに持たせています。そのため、リージョンの違いもtfbackendファイル側に持たせます。

tfstate保管用のS3バケットについてですが、S3バケット名は世界中でユニークである必要があるため、大阪リージョンを意味するサフィックスとして-apne3を付加しました。

<project-name>/backend.tf
terraform {
  backend "s3" {}
}
<project-name>/prod.tfbackend
bucket       = "example-prod-terraform-state"
key          = "example.tfstate"
encrypt      = true
profile      = "example-prod"
region       = "ap-northeast-1"
use_lockfile = true
<project-name>-dr/prod.tfbackend
bucket       = "example-prod-terraform-state-apne3"
key          = "example.tfstate"
encrypt      = true
profile      = "example-prod"
region       = "ap-northeast-3"
use_lockfile = true

providerをメイン・DR双方に対応させる

AWSのproviderブロックのregionについては変数でリージョンの違いを吸収します。

<project-name>/backend.tf
provider "aws" {
  region  = var.aws_region
  profile = "example-${var.env}"
}
<project-name>/variables.tf
variable "aws_region" {
  type = string
}

variable "env" {
  type = string
}
<project-name>/prod.tfvars
env          = "prod"
aws_region   = "ap-northeast-1"
<project-name>-dr/prod.tfvars
env          = "prod"
aws_region   = "ap-northeast-3"

DR用のIAMを異なるnameで作成する

IAMはリージョナルリソース(リージョンごとに作成されるリソース)ではなくグローバルリソースであり、nameは同一AWSアカウント内でユニークである必要があります。

そのため、本記事のコンセプトであるSymbolick linkを使ったDRサイト用リソース作成は、IAMに関してはname重複エラーで失敗してしまいます。

この課題に対する対応としては、

  • IAMロールをメインサイト・DRサイトで共用し、IAMポリシーを両サイトで使えるような内容とする

という方法が考えられますが、いくつか問題があります。

<project-name>/ecs_iam.tf
# 略

resource "aws_iam_role_policy" "example_ecs_task_execution_ssm" {
  name = "ssm"
  role = aws_iam_role.webapp_ecs_task_execution.id

  policy = jsonencode(
    {
      "Version" : "2012-10-17",
      "Statement" : [
        {
          "Effect" : "Allow",
          "Action" : [
            "ssm:GetParameters",
            "ssm:GetParameter"
          ],
          "Resource" : [
            "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/example/*",
            "arn:aws:ssm:${var.aws_region_dr}:${data.aws_caller_identity.current.account_id}:parameter/example/*"
      ]
    }
  )
}

まず、常にDRサイトでも使えるようなIAMポリシーにメンテし続ける必要があり、平時の開発時の認知負荷が高まってしまう点が挙げられます。

  • IAMポリシーステートメントのResource要素を追加する時、メインサイトのARNだけでなく、DRサイトのARNも記述する必要がある
  • その必要を無くすために、ARNのリージョンID部分を*としてしまう方法も考えられるが、最小権限原則の考え方からベターとはいえない
  • 上記いずれの方法を取ったとしても、プルリクエスト時に考慮漏れが発生しやすく、レビュー指摘とその修正が少なくない頻度で起こりうる懸念がある(= 開発速度の低下)

また、DRサイト側のディレクトリでのTerraformリソースとしての取り回しが煩雑になります。

実体のAWSリソースは1つなので、DRサイト側のディレクトリでImportしてTerraform Resourceとして管理しようとするのは避けた方が良いでしょう。異なるtfstateで実体が同じAWSリソースを二重管理することになるからです。

かといって、DRサイト側のディレクトリでData Sourceとして参照することにしたとしても、そこから波及して他のtfの記述も変更しなければならない...といったことになります。

resource "aws_codebuild_project" "example" {
  # 略

   # 波及して以下のような修正が各所で必要になってしまう   
-  service_role  = aws_iam_role.codebuild_build_example.arn
+  service_role  = data.aws_iam_role.codebuild_build_example.arn
  # 略
}

そこで、TerrafromのOverride Filesの仕組みを使うことでこの課題を解消します。

Override Filesがどのような機能かをざっくり説明すると、_override.tfで終わる名前のtfファイルでは、他のtfファイルの内容を部分的に上書きします。

以下の例では、DRサイト用のIAMロールのみ、nameにサフィックスとして-apne3を付けています。name以外は、メインサイト用のtfの内容をそのまま踏襲します。

-- <project-name>/   # メインサイト
   -- ecs_iam.tf

-- <project-name>-dr/ # DRサイト
   -- variables_dr.tf     # DRサイトでのみ使用するvariablesの定義
   -- ecs_iam.tf          -> ../<project-name>/ecs_iam.tf 
   -- ecs_iam_override.tf # IAMのnameを上書きするために使用
<project-name>-dr/variables_dr.tf
variable "dr_suffix" {
  description = "リソース名がメインサイトのものと重複しないようにするためのサフィックス"
  type        = string
  default     = "-apne3"
}
<project-name>/ecs_iam.tf
resource "aws_iam_role" "example_ecs_task_execution" {
  name = "example-ecs-task-execution"

  # 略(その他の各種定義)
}

resource "aws_iam_role_policy" "example_ecs_task_execution_ssm" {
  name = "ssm"
  role = aws_iam_role.example_ecs_task_execution.id

  policy = jsonencode(
    {
      "Version" : "2012-10-17",
      "Statement" : [
        {
          "Effect" : "Allow",
          "Action" : [
            "ssm:GetParameters",
            "ssm:GetParameter"
          ],
          "Resource" : [
            # 注: var.aws_regionはメインサイトではap-northeast-1, DRサイトではap-northeast-3が入る
            "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/example/*"
      ]
    }
  )
}
<project-name>-dr/ecs_iam_override.tf
resource "aws_iam_role" "example_ecs_task_execution" {
  name = "example-ecs-task-execution${var.dr_suffix}"

  # 注: overrideファイル側では、nameだけを指定すれば良い
}

DRサイト用のIAMロールでは、nameは-apne3のサフィックスが付きますが、Terraformリソース名は、aws_iam_role.example_ecs_task_executionのままなので、他のTerraformリソースからの参照が崩れることはありません。

S3バケット

S3バケットは、バケット名が世界中でユニークである必要があるので、DRサイト用のバケットではIAMと同様にOverride Filesを利用してサフィックスを付けます。

<project-name>-dr/s3_override.tf
resource "aws_s3_bucket" "example" {
  bucket = "example${var.dr_suffix}"
}

なお、エンドユーザーの固有のファイル(プロフィール画像など)を格納しているS3バケットなどは、空のバケットをDRサイトに構築しても意味が無いので、平時からメインサイトクロスリージョンでレプリケートしておきます。この場合、レプリケート先のDRサイトのS3バケットは、DRサイト用のディレクトリではなく、メインサイト用のディレクトリのtfファイルであらかじめ作成しておきます。

なお、本記事では、DRサイトからさらに別サイトへのレプリケーションは考慮しません。そのため、DRサイトのディレクトリでは、s3_replication.tfへのSymbolic linkは配置しません。

(ある程度tfファイルを分割しておくことで、このようにSymbolic linkの作成有無で、DRサイトに不要なAWSリソースを作成しないようコントロールできます)

-- <project-name>/   # メインサイト
   -- s3.tf
   -- s3_replication.tf # メインサイトからDRサイトへレプリケーションが必要なS3バケットとレプリケーション設定を定義

-- <project-name>-dr/ # DRサイト
   -- s3.tf             -> ../<project-name>/s3.tf 
   -- s3_override.tf    # S3のバケット名を上書きするために使用
<project-name>/s3_replication.tf
resource "aws_s3_bucket" "main" {
  bucket = "example-user-assets"
}

resource "aws_s3_bucket" "dr" {
  bucket = "example-user-assets-apne3"

  provider = aws.dr
}

data "aws_kms_alias" "aws_s3" {
  name = "alias/aws/s3"
}

data "aws_kms_alias" "aws_s3_dr" {
  provider = aws.dr

  name = "alias/aws/s3"
}

resource "aws_iam_role" "s3_replication_dr" {
  name = "s3-replication-dr"

  assume_role_policy = jsonencode(
    {
      "Version" : "2012-10-17",
      "Statement" : [
        {
          "Effect" : "Allow",
          "Principal" : {
            "Service" : "s3.amazonaws.com"
          },
          "Action" : "sts:AssumeRole"
        }
      ]
    }
  )

  managed_policy_arns = [
    aws_iam_policy.s3_replication_dr.arn
  ]
}

resource "aws_iam_policy" "s3_replication_dr" {
  name = "s3-replication-dr"

  policy = jsonencode(
    {
      "Version" : "2012-10-17",
      "Statement" : [
        {
          "Effect" : "Allow",
          "Action" : [
            "s3:ListBucket",
            "s3:GetReplicationConfiguration",
            "s3:GetObjectVersionForReplication",
            "s3:GetObjectVersionAcl",
            "s3:GetObjectVersionTagging",
            "s3:GetObjectRetention",
            "s3:GetObjectLegalHold"
          ],
          "Resource" : [
            aws_s3_bucket.main.arn,
            "${aws_s3_bucket.main.arn}/*"
          ]
        },
        {
          "Effect" : "Allow",
          "Action" : [
            "s3:ReplicateObject",
            "s3:ReplicateDelete",
            "s3:ReplicateTags",
            "s3:GetObjectVersionTagging",
            "s3:ObjectOwnerOverrideToBucketOwner"
          ],
          "Condition" : {
            "StringLikeIfExists" : {
              "s3:x-amz-server-side-encryption" : [
                "aws:kms",
                "AES256"
              ]
            }
          },
          "Resource" : [
            "${aws_s3_bucket.dr.arn}/*"
          ]
        },
        {
          "Effect" : "Allow",
          "Action" : [
            "kms:Decrypt"
          ],
          "Condition" : {
            "StringLike" : {
              "kms:ViaService" : "s3.${var.aws_region}.amazonaws.com",
              "kms:EncryptionContext:aws:s3:arn" : [
                "${aws_s3_bucket.main.arn}/*"
              ]
            }
          },
          "Resource" : [
            data.aws_kms_alias.aws_s3.target_key_arn
          ]
        },
        {
          "Effect" : "Allow",
          "Action" : [
            "kms:Encrypt"
          ],
          "Condition" : {
            "StringLike" : {
              "kms:ViaService" : [
                "s3.${var.aws_region_dr}.amazonaws.com"
              ],
              "kms:EncryptionContext:aws:s3:arn" : [
                "${aws_s3_bucket.dr.arn}/*"
              ]
            }
          },
          "Resource" : [
            data.aws_kms_alias.aws_s3_dr.id
          ]
        }
      ]
    }
  )
}

resource "aws_s3_bucket_replication_configuration" "main" {
  bucket = aws_s3_bucket.main.id

  role = aws_iam_role.s3_replication_dr.arn

  rule {
    id       = "disaster-recovery"
    status   = "Enabled"
    priority = 0

    delete_marker_replication {
      status = "Enabled"
    }

    destination {
      bucket = aws_s3_bucket.dr.arn

      encryption_configuration {
        replica_kms_key_id = data.aws_kms_alias.aws_s3_dr.id
      }

      replication_time {
        status = "Enabled"

        time {
          minutes = 15
        }
      }

      metrics {
        status = "Enabled"

        event_threshold {
          minutes = 15
        }
      }
    }

    filter {}

    source_selection_criteria {
      sse_kms_encrypted_objects {
        status = "Enabled"
      }
    }
  }

  lifecycle {
    prevent_destroy = true
  }
}

RDS

RDSはクロスリージョンでバックアップを取得しておき、DRサイトではこれを利用してRDSインスタンスを起動するようにします。

-- <project-name>/   # メインサイト
   -- rds.tf
   -- reds_replication.tf 

-- <project-name>-dr/ # DRサイト
   -- rds.tf             -> ../<project-name>/rds.tf 
   -- rds_override.tf
<project-name>/rds.tf
# 割愛
<project-name>/rds_replication.tf
data "aws_kms_alias" "aws_rds_dr" {
  provider = aws.dr

  name = "alias/aws/rds"
}

# 新規作成時はterraform apply完了まで10分以上時間がかかる。
# 最初のバックアップが取得されるとapplyも完了する。
resource "aws_db_instance_automated_backups_replication" "this" {
  provider = aws.dr

  source_db_instance_arn = aws_db_instance.this.arn
  retention_period       = 35
  kms_key_id             = data.aws_kms_alias.aws_rds_dr.target_key_arn
}
<project-name>-dr/rds_override.tf
resource "aws_db_instance" "this" {
  restore_to_point_in_time {
    # バックアップのARNは固有のIDが含まれていて、かつData Sourceによる参照もできないので、ハードコーディングする
    source_db_instance_automated_backups_arn = {
      dev  = "arn:aws:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:auto-backup:ab-xxx..."
      stg  = "arn:aws:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:auto-backup:ab-xxx..."
      prod = "arn:aws:rds:${var.aws_region}:${data.aws_caller_identity.current.account_id}:auto-backup:ab-xxx..."
    }[var.env]
    use_latest_restorable_time = true
  }

  # Database options
  db_name = null # restore時に指定するとエラーになるため

  lifecycle {
    ignore_changes = [
      password, # 意図せぬパスワード変更が発生しないよう念の為
    ]
  }
}

DRサイト側のディレクトリでapplyすれば、メインサイトからのクロスリージョンバックアップを使ってRDSインスタンスが起動します。

ここでもTerraformのOverride Filesを活用することで、メインサイトのRDSのtfファイルを活かしつつ(Symbolic Linkで参照しつつ)、DRサイトに最小限の定義のtfファイルを配置するだけでリストアを実現することができています。

Secrets Managerやパラメータストア

Secrets Managerは、クロスリージョンのレプリケーションをサポートしているので、そちらを使います。

https://docs.aws.amazon.com/ja_jp/secretsmanager/latest/userguide/replicate-secrets.html

パラメータストアは、クロスリージョンのレプリケーションは無いので、Lambdaを使って、平時からDRサイトへレプリケートすることが考えられます。

TerraformやLambdaのコードは割愛します。

Route53やCloudFront

グローバルリソースであるRoute53やCloudFrontについては既存のリソースを流用しつつ、一部の値をDRサイト用に変更する必要があるかと思います。

DRサイト側のディレクトリでDRサイト用のtfファイルをimportブロック付きで配置し、applyによりimportしつつDRサイト用の値に更新する方法が考えられます。

同一Terraform ResourceをメインサイトとDRサイトそれぞれのtfstateで二重管理することになってしまいますが、やむなしと考えます。

おわりに

以上、TerraformのOverride Filesを活用したDR(ディザスタリカバリ)サイトの構築手法の一事例でした。本記事がお役に立てば幸いです。

また、スマートラウンドでは全職種で仲間を募集中です。本記事を読んで興味を持たれた方はぜひ以下もご覧ください。

https://jobs.smartround.com/

カジュアル面談も随時受け付けています。お気軽にお申し込みください。

スマートラウンド テックブログ

Discussion