AWS

terraform applyをより安全に実行するためにできること(postcondition編)

sho

はじめに

西藤です。

terraform applyをより安全に実行するためにできること 

terraform applyをより安全に実行するためにできること(precondition編) 

の続編記事です。

postconditionとは

前回は、terraform バージョン1.2.0で追加された”precondition”を使って、terraform applyをより安全にしていくための仕組みを紹介しました。

前回の復習にはなりますが、これは、terraformのバージョン1.2.0で追加された

  • precondition
  • postcondition

の一つで

https://github.com/hashicorp/terraform/releases/tag/v1.2.0

これらはterraformのresource, data sourceそして、moduleのoutputにおける条件などを定義し、条件が満たされなかった際にはエラーメッセージを発して、処理の進行をストップしてくれる仕組みです。

今回はリソースが作成された後に、条件を満たしているかを判定する"postcondition"の動作を見ていき、AWSクラウドでのインフラ作成においてどのように役立てられるのか検討していきます。

例1:public DNSの割り当て有無の確認

まずは、公式のドキュメントに記載のある例を見て行きます。

例えば、EC2インスタンスを使ってWebサイトを公開したい時、適切な形で設定されたネットワーク内に作成すれば、通常public DNSの割り当ても行われ問題なくWebサイトを展開できます。

しかし、なんらかの理由でVPCなどのネットワーク設定に不足があった場合、public DNSの割り当てがされずにEC2インスタンスが作成されてしまい、後になって初めて設定の不足が発覚するという可能性があります。

これはEC2インスタンス用に記述したterraformのコードに不足がなかったとしても、発生しうるものです。しかし、こう言った状況はエンドユーザーに影響が生じる前に予防したいものです。

そこでその対策としてpostconditionが使用できますので、その例を見ていきます。

(13行目から19行目にlifecycleの設定を記載)

data "aws_subnet" "subnet" {
  filter {
    name   = "tag:Name"
    values = ["postcondition-test-subnet-public1-ap-northeast-1a"] # DNS hostnamesが有効なVPCのサブネット
  }
}

# EC2 Instance
resource "aws_instance" "web" {
  ami           = "ami-03dceaabddff8067e" # Amazon Linux 2023 AMI
  instance_type = "t2.micro"
  subnet_id = data.aws_subnet.subnet.id
  lifecycle {
    # The EC2 instance must be allocated a public DNS hostname.
    postcondition {
      condition     = self.public_dns != ""
      error_message = "EC2 instance must be in a VPC that has public DNS hostnames enabled."
    }
  }
}

このコードでterraform apply を実行した結果は以下のとおりです。

% terraform apply
data.aws_subnet.subnet: Reading...
data.aws_subnet.subnet: Read complete after 0s [id=subnet-0dba04ee5e81760bc]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                                  = "ami-03dceaabddff8067e"
(中略)
      + instance_type                        = "t3.medium"
(中略)
      + security_groups                      = (known after apply)
      + source_dest_check                    = true
      + subnet_id                            = "subnet-0dba04ee5e81760bc"
      + tags_all                             = {
          + "ManagedBy" = "Terraform"
          + "Project"   = "blog-sample"
        }
      + tenancy                              = (known after apply)
      + user_data                            = (known after apply)
      + user_data_base64                     = (known after apply)
      + user_data_replace_on_change          = false
      + vpc_security_group_ids               = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions in workspace "blog-sample"?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 13s [id=i-0325023883a1babcf]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

問題なくEC2インスタンスが作成されました。

次にDNS hostnamesが無効なサブネットを指定する形で実行してみます。(4行目を変更)

data "aws_subnet" "subnet" {
  filter {
    name   = "tag:Name"
    values = ["postcondition-test-without-dns-subnet-public1-ap-northeast-1a"] # DNS hostnamesが無効なVPCのサブネット
  }
}

# EC2 Instance
resource "aws_instance" "web" {
  ami           = "ami-03dceaabddff8067e" # Amazon Linux 2023 AMI
  instance_type = "t3.medium"
  subnet_id     = data.aws_subnet.subnet.id
  lifecycle {
    # The EC2 instance must be allocated a public DNS hostname.
    postcondition {
      condition     = self.public_dns != ""
      error_message = "EC2 instance must be in a VPC that has public DNS hostnames enabled."
    }
  }
}

terraform applyの実施結果は以下のとおり

% terraform apply
data.aws_subnet.subnet: Reading...
data.aws_subnet.subnet: Read complete after 1s [id=subnet-08573c58cb4d2cc02]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                                  = "ami-03dceaabddff8067e"
(中略)
      + instance_type                        = "t3.medium"
(中略)
      + source_dest_check                    = true
      + subnet_id                            = "subnet-08573c58cb4d2cc02"
      + tags_all                             = {
          + "ManagedBy" = "Terraform"
          + "Project"   = "blog-sample"
        }
      + tenancy                              = (known after apply)
      + user_data                            = (known after apply)
      + user_data_base64                     = (known after apply)
      + user_data_replace_on_change          = false
      + vpc_security_group_ids               = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions in workspace "blog-sample"?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 12s [id=i-036cc68761ba908bf]
╷
│ Error: Resource postcondition failed
│ 
│   on test.tf line 16, in resource "aws_instance" "web":
│   16:       condition     = self.public_dns != ""
│     ├────────────────
│     │ self.public_dns is ""
│ 
│ EC2 instance must be in a VPC that has public DNS hostnames enabled.

期待通り、インスタンスの作成が完了した直後にエラー(EC2 instance must be in a VPC that has public DNS hostnames enabled.)を発しています。

なお、AWSマネジメントコンソールの画面を見てみるとEC2インスタンスは作成されていることがわかります。

エラーになったからといってロールバックするわけではないようです。

そして、EC2の作成が終わった上で、再度terraform planを実行すると

% terraform plan
data.aws_subnet.subnet: Reading...
data.aws_subnet.subnet: Read complete after 0s [id=subnet-08573c58cb4d2cc02]
aws_instance.web: Refreshing state... [id=i-036cc68761ba908bf]

Planning failed. Terraform encountered an error while generating this plan.

╷
│ Error: Resource postcondition failed
│ 
│   on test.tf line 16, in resource "aws_instance" "web":
│   16:       condition     = self.public_dns != ""
│     ├────────────────
│     │ self.public_dns is ""
│ 
│ EC2 instance must be in a VPC that has public DNS hostnames enabled.
╵

のような表示で、postconditionの条件を満たさないリソースが存在していると、それ以降のterraformコマンドは即座に失敗となるようです。

これにより期待しない状態のリソースの存在を許さない体制が期待できます。

例2:暗号化されているRootボリュームを使用しているかの確認

もう一例、公式ドキュメントから挙げてみます。

ITシステムを構築するときに、そのセキュリティ要件として

  • 「ストレージが暗号化されていること」

ということが要求されていることは多くあるでしょう。

その一方で、EC2インスタンスはストレージの暗号化を行わなくても作成は可能ですし、その中で稼働するシステムも精緻に暗号化されているか検知する仕組みを導入していなければ、暗号化が行われていなくとも稼働することができます。

つまり、

  • 「暗号化されている必要があったのに、暗号化されておらず運用を開始していた」

ということは起きうるわけです。

その対策としても、このpostconditionを活用できます。

はじめにエラーなく成功する例は以下の通り

(26行目からpostconditionを記述)

# EC2 Instance
resource "aws_instance" "web" {
  ami           = "ami-03dceaabddff8067e" # Amazon Linux 2023 AMI
  instance_type = "t3.medium"
  subnet_id     = data.aws_subnet.subnet.id
  # EC2のルートボリュームを暗号化する設定
  root_block_device {
    encrypted = true
  }
  lifecycle {
    # The EC2 instance must be allocated a public DNS hostname.
    postcondition {
      condition     = self.public_dns != ""
      error_message = "EC2 instance must be in a VPC that has public DNS hostnames enabled."
    }
  }
}

data "aws_ebs_volume" "volume" {
  filter {
    name   = "volume-id"
    values = [aws_instance.web.root_block_device[0].volume_id]
  }

  lifecycle {
    postcondition {
      condition     = self.encrypted
      error_message = "The server's root volume is not encrypted."
    }
  }
}
% terraform apply
data.aws_subnet.subnet: Reading...
data.aws_subnet.subnet: Read complete after 1s [id=subnet-0dba04ee5e81760bc]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
 <= read (data resources)

Terraform will perform the following actions:

  # data.aws_ebs_volume.volume will be read during apply
  # (config refers to values not yet known)
 <= data "aws_ebs_volume" "volume" {
      + arn                  = (known after apply)
      + availability_zone    = (known after apply)
      + encrypted            = (known after apply)
      + id                   = (known after apply)
      + iops                 = (known after apply)
      + kms_key_id           = (known after apply)
      + multi_attach_enabled = (known after apply)
      + outpost_arn          = (known after apply)
      + size                 = (known after apply)
      + snapshot_id          = (known after apply)
      + tags                 = (known after apply)
      + throughput           = (known after apply)
      + volume_id            = (known after apply)
      + volume_type          = (known after apply)

      + filter {
          + name   = "volume-id"
          + values = [
              + (known after apply),
            ]
        }
    }

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                                  = "ami-03dceaabddff8067e"
(中略)

      + root_block_device {
          + delete_on_termination = true
          + device_name           = (known after apply)
          + encrypted             = true
          + iops                  = (known after apply)
          + kms_key_id            = (known after apply)
          + throughput            = (known after apply)
          + volume_id             = (known after apply)
          + volume_size           = (known after apply)
          + volume_type           = (known after apply)
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions in workspace "blog-sample"?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 12s [id=i-074e491089032237e]
data.aws_ebs_volume.volume: Reading...
data.aws_ebs_volume.volume: Read complete after 1s [id=vol-0d131eee9ee371e3e]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

問題なくEC2インスタンスが作成されました。

次に、失敗する例だと以下のようになります。

# EC2 Instance
resource "aws_instance" "web" {
  ami           = "ami-03dceaabddff8067e" # Amazon Linux 2023 AMI
  instance_type = "t3.medium"
  subnet_id     = data.aws_subnet.subnet.id
  #   EC2のルートボリュームを暗号化する設定をコメントアウト
  #   root_block_device {
  #     encrypted = true
  #   }
  lifecycle {
    # The EC2 instance must be allocated a public DNS hostname.
    postcondition {
      condition     = self.public_dns != ""
      error_message = "EC2 instance must be in a VPC that has public DNS hostnames enabled."
    }
  }
}

data "aws_ebs_volume" "volume" {
  filter {
    name   = "volume-id"
    values = [aws_instance.web.root_block_device[0].volume_id]
  }

  lifecycle {
    postcondition {
      condition     = self.encrypted
      error_message = "The server's root volume is not encrypted."
    }
  }
}
% terraform apply
data.aws_subnet.subnet: Reading...
data.aws_subnet.subnet: Read complete after 0s [id=subnet-0dba04ee5e81760bc]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
 <= read (data resources)

Terraform will perform the following actions:

  # data.aws_ebs_volume.volume will be read during apply
  # (config refers to values not yet known)
 <= data "aws_ebs_volume" "volume" {
      + arn                  = (known after apply)
      + availability_zone    = (known after apply)
      + encrypted            = (known after apply)
      + id                   = (known after apply)
      + iops                 = (known after apply)
      + kms_key_id           = (known after apply)
      + multi_attach_enabled = (known after apply)
      + outpost_arn          = (known after apply)
      + size                 = (known after apply)
      + snapshot_id          = (known after apply)
      + tags                 = (known after apply)
      + throughput           = (known after apply)
      + volume_id            = (known after apply)
      + volume_type          = (known after apply)

      + filter {
          + name   = "volume-id"
          + values = [
              + (known after apply),
            ]
        }
    }

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                                  = "ami-03dceaabddff8067e"
      + arn                                  = (known after apply)
      + associate_public_ip_address          = (known after apply)
      + availability_zone                    = (known after apply)
      + cpu_core_count                       = (known after apply)
      + cpu_threads_per_core                 = (known after apply)
      + disable_api_stop                     = (known after apply)
      + disable_api_termination              = (known after apply)
      + ebs_optimized                        = (known after apply)
      + get_password_data                    = false
      + host_id                              = (known after apply)
      + host_resource_group_arn              = (known after apply)
      + iam_instance_profile                 = (known after apply)
      + id                                   = (known after apply)
      + instance_initiated_shutdown_behavior = (known after apply)
      + instance_state                       = (known after apply)
      + instance_type                        = "t3.medium"
      + ipv6_address_count                   = (known after apply)
      + ipv6_addresses                       = (known after apply)
      + key_name                             = (known after apply)
      + monitoring                           = (known after apply)
      + outpost_arn                          = (known after apply)
      + password_data                        = (known after apply)
      + placement_group                      = (known after apply)
      + placement_partition_number           = (known after apply)
      + primary_network_interface_id         = (known after apply)
      + private_dns                          = (known after apply)
      + private_ip                           = (known after apply)
      + public_dns                           = (known after apply)
      + public_ip                            = (known after apply)
      + secondary_private_ips                = (known after apply)
      + security_groups                      = (known after apply)
      + source_dest_check                    = true
      + subnet_id                            = "subnet-0dba04ee5e81760bc"
      + tags_all                             = {
          + "ManagedBy" = "Terraform"
          + "Project"   = "blog-sample"
        }
      + tenancy                              = (known after apply)
      + user_data                            = (known after apply)
      + user_data_base64                     = (known after apply)
      + user_data_replace_on_change          = false
      + vpc_security_group_ids               = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions in workspace "blog-sample"?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 13s [id=i-04e7bdd3a92ad2820]
data.aws_ebs_volume.volume: Reading...
data.aws_ebs_volume.volume: Read complete after 0s [id=vol-0281543219736631f]
╷
│ Error: Resource postcondition failed
│ 
│   on test.tf line 34, in data "aws_ebs_volume" "volume":
│   34:       condition     = self.encrypted
│     ├────────────────
│     │ self.encrypted is false
│ 
│ The server's root volume is not encrypted.

期待通り、メッセージと共にエラー(The server's root volume is not encrypted.)となりました。

そしてこの例においてもAWSリソースの作成自体は行われています。

このようにpostconditionを活用していてエラーとなった場合、例1と同様にterraformコマンドが必ずエラーになるようになるため、条件を満たさないリソースの状況を解消することが必ず求められるようになるわけです。

例3:削除保護・停止保護

上記の2例は公式のドキュメントに記載されていた例です。

それ以外に、オリジナルでもう1つシナリオを考えてみます。

例えば、

  • 「誤作動でサービスが停止してはいけないので、EC2インスタンスにて”削除保護”、”停止保護”がいずれも効いていることを必要とする」

というシナリオはいかがでしょうか?上記までの例の書き方にならって以下のようにコードを作成しました。

# EC2 Instance
resource "aws_instance" "web" {
  ami           = "ami-03dceaabddff8067e" # Amazon Linux 2023 AMI
  instance_type = "t3.medium"
  subnet_id     = data.aws_subnet.subnet.id
  root_block_device {
    encrypted = true
  }
  lifecycle {
    # 削除保護が有効になっていること
    postcondition {
      condition     = self.disable_api_termination == true
      error_message = "Instance is not protected from termination"
    }
    # 停止保護が有効になっていること
    postcondition {
      condition     = self.disable_api_stop == true
      error_message = "Instance is not protected from stop"
    }
  }
}

そしてこのまま実行すると以下のようなエラーが出ます。

aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 13s [id=i-055dd07a68c715121]
╷
│ Error: Resource postcondition failed
│ 
│   on test.tf line 19, in resource "aws_instance" "web":
│   19:         condition     = self.disable_api_termination == true
│     ├────────────────
│     │ self.disable_api_termination is false
│ 
│ Instance is not protected from termination
╵
╷
│ Error: Resource postcondition failed
│ 
│   on test.tf line 24, in resource "aws_instance" "web":
│   24:         condition     = self.disable_api_stop  == true
│     ├────────────────
│     │ self.disable_api_stop is false
│ 
│ Instance is not protected from stop

リソースの作成が行われたのちに、各postconditionの判定が行われていることがわかります。

1つの条件がエラーになったらその時点で判定を中断しているわけではなく、すべての条件の判定が行われているので、トラブルシュートもしやすいことが期待できます。(「1つエラーを解消したら、次のエラー・・・」ということにならなくて済む)

さて、これを修正する際にはどうするか?postconditionのエラー解消につながる変更点が入っていれば、terraformコマンドはエラーで止まらずに操作が行えますので、applyを実行すると以下のようになりました。

# EC2 Instance
resource "aws_instance" "web" {
  ami                     = "ami-03dceaabddff8067e" # Amazon Linux 2023 AMI
  instance_type           = "t3.medium"
  subnet_id               = data.aws_subnet.subnet.id
  disable_api_stop        = true
  disable_api_termination = true
  root_block_device {
    encrypted = true
  }
  lifecycle {
    # Terminate protection must be enabled for the instance
    postcondition {
      condition     = self.disable_api_termination == true
      error_message = "Instance is not protected from termination"
    }
    # Stop protection must be enabled for the instance
    postcondition {
      condition     = self.disable_api_stop == true
      error_message = "Instance is not protected from stop"
    }
  }
}
% terraform apply
data.aws_subnet.subnet: Reading...
data.aws_subnet.subnet: Read complete after 0s [id=subnet-0dba04ee5e81760bc]
aws_instance.web: Refreshing state... [id=i-055dd07a68c715121]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # aws_instance.web will be updated in-place
  ~ resource "aws_instance" "web" {
      ~ disable_api_stop                     = false -> true
      ~ disable_api_termination              = false -> true
        id                                   = "i-055dd07a68c715121"
        tags                                 = {}
        # (29 unchanged attributes hidden)

        # (8 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Do you want to perform these actions in workspace "blog-sample"?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_instance.web: Modifying... [id=i-055dd07a68c715121]
aws_instance.web: Modifications complete after 1s [id=i-055dd07a68c715121]

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

変更が問題なく行われて、postconditionによるエラーが解消したことがわかります。

preconditionとの比較とまとめ

以上、postconditionを活用した3例を紹介しました。

前回の記事では、”precondition”を、構築されるインフラにおいて期待される状態の前提条件の確認に使いましたが、

一方で、今回のpostconditionはインフラが構築された後にチェックが行われます。

Hashicorpはこれらの使い分けについて

  • Use Preconditions for Assumptions
  • Use Postconditions for Guarantees

と表現しています。

https://developer.hashicorp.com/terraform/language/expressions/custom-conditions#choosing-between-preconditions-and-postconditions

Preconditionを使うことで、コードを引き継いだ担当者が「このリソースがどうあるべきと意図されていたか」を理解できるようになり、Postconditionsを使うことで、コードを引き継いだ担当者が「このリソースにおいて、変更されず維持されるべき設定はどれか」を理解できるようになります。

Terraformのフレームワークの中で、しかも不一致の際にはエラーを発生させることで、期待された設定を持ったリソースができることをより確実なものにできます。従来、実装者によってリソースが作成される際の振る舞いにブレが生じる余地が残っていましたが、preconditionとpostcondtionの登場でより精緻に、作成されるリソースに期待される状態を記述できるようになりました。これによりAWSクラウドでインフラ構築を行う際のterraform applyの振る舞いをより確実なものにでき、安心を高めることができます。

本記事がこれらの機能の活用でより安全にTerraformでの実装をするための参考になれば幸いです。

AUTHOR
sho
sho
記事URLをコピーしました