インフラ

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

sho

はじめに

西藤です。
当社ではTerraformを活用してAWSクラウド上でのインフラ構築を数多く行っております。
IaC(Infrastructure as Code)化が進むことによりインフラ構成の再活用が進み、また迅速なピアレビューもやりやすくなり、実装スピードが上がることを日々実感しております。

ただし、IaCはその万能さゆえに、予期せぬ形での事故につながる可能性があります。また、「terraform planでは問題なかったのにterraform applyをしたら失敗して慌てた」という経験をお持ちの方もいらっしゃるのではないでしょうか?

今回は、そういったterraform applyを実行する際の不安を減らし、より安全に実行できるようにするための方法を考えてみます。

1. Terraform 標準コマンドを使う

まずは、Terraformを使った構築のスタンダードな流れだと思いますが、Terraformのコードを書いたら、標準のコマンドを使って記述した内容をチェックします。

  • コードを書く→fmtやvalidate, planを使って修正する→確定したら、apply

というのが基本的な流れで、多くのTerraform利用者が実践されていることと思います。
それらのコマンドを掘り下げてみます。

terraform fmt

terraform fmtコマンドは記述されたファイルを正規のフォーマットとスタイルに合わせて整形をしてくれます。

https://www.terraform.io/cli/commands/fmt

たとえば、

resource "aws_instance" "web" {
ami = "ami-078296f82eb463377"
instance_type = "t3.micro"
}

といったインデントが揃っていないコードがあれば

$ terraform fmt
test.tf

という具合にterraform fmtコマンドを実行することで、

resource "aws_instance" "web" {
  ami           = "ami-078296f82eb463377"
  instance_type = "t3.micro"
}

のようにインデントが整理されたコードに書き換えてくれます。
これにより、コードの見え方が一定したものになりますので、自身でのコードの確認やピアレビューを行う際に指摘すべき事項の見逃し防止につながります。

terraform validate

次に、terraform validateコマンドで記述されたファイルが構文的に正しいかなどの検査を行います。
とくにmoduleを使って値の引き渡しをする際のチェックに効果的とされています。

https://www.terraform.io/cli/commands/validate

たとえば

.
├── modules
│   └── web
│       ├── inputs.tf
│       ├── main.tf
│       └── outputs.tf
└── test.tf

と言うような構成をしていて、
呼び出し元の、test.tfでは

module "web" {
  source        = "./modules/web"
  ami           = "aami-078296f82eb463377"        #わざと誤った記載を入れている
  instance_type = "t2.micro"                  #わざと誤った記載を入れている
}

のように書き、
modules/web/inputs.tfでは

# ami for web server
variable "ami" {
  description = "AMI for web server"
  type        = string

  validation {
    condition     = can(regex("^ami-", var.ami))
    error_message = "AMI は 'ami-' からはじまる文字列である必要があります。"
  }
}

# instance type for web server
variable "instance_type" {
  description = "Instance type for web server"
  type        = string

  validation {
    condition     = can(regex("^t3", var.instance_type))
    error_message = "Instance type は 't3' からはじまるタイプを使用します。"
  }
}

のようにAMIやインスタンスタイプに指定される文字列に対して、正規表現を使って条件を定義します。

そして、modules/web/main.tfでは

resource "aws_instance" "web" {
  ami           = var.ami
  instance_type = var.instance_type
}

としたとします。

この際に terraform validateコマンドを実行すると

$ terraform validate
╷
│ Error: Invalid value for variable
│ 
│   on test.tf line 3, in module "web":
│    3:   ami           = "aami-078296f82eb463377"      #わざと誤った記載を入れている
│     ├────────────────
│     │ var.ami is "aami-078296f82eb463377"
│ 
│ AMI は 'ami-' からはじまる文字列である必要があります。
│ 
│ This was checked by the validation rule at modules/web/inputs.tf:6,3-13.
╵
╷
│ Error: Invalid value for variable
│ 
│   on test.tf line 4, in module "web":
│    4:   instance_type = "t2.micro"                            #わざと誤った記載を入れている
│     ├────────────────
│     │ var.instance_type is "t2.micro"
│ 
│ Instance type は 't3' からはじまるタイプを使用します。
│ 
│ This was checked by the validation rule at modules/web/inputs.tf:17,3-13.

のようにエラーが表示されます。
これは、modules/web/inputs.tfで定義されていた各variableごとのvalidation条件とmoduleに引き渡された値を突き合わせて検査した結果、エラーになったため、 メッセージと共に指摘をしてくれています。
これによりインフラ構築に際して、自分達で定義したポリシーに沿わないリソースが作られてしまうことを防止したり、想定外の値が指定されることを防止できます。

ちなみに、各値を修正した上で再実行すると

$ terraform validate
Success! The configuration is valid.

となり、問題なくなったことがわかります。

terraform plan

最後にterraform planによって「このコードで何が作成され、削除され、更新されそうか」を確認します。
上記のvalidateの検査を通過したコードを使うと

$ terraform plan

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:

  # module.web.aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                                  = "ami-078296f82eb463377"
      + 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)
      + id                                   = (known after apply)
      + instance_initiated_shutdown_behavior = (known after apply)
      + instance_state                       = (known after apply)
      + instance_type                        = "t3.micro"
      + ipv6_address_count                   = (known after apply)
      + ipv6_addresses                       = (known after apply)

<略>

    }

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

という具合にEC2インスタンスが作成されようとしていることがわかります。

terraform planが失敗する状況であれば、もちろんapply時にも失敗しますので、apply前の確認に有効です。

ただし、terraform planはあくまで記述したコードをもとにどのような変更が加えられようとしているかの予測を表示するものであり、最終的にその変更が正常に行われるかを確約しません。

The plan command alone does not actually carry out the proposed changes.

https://www.terraform.io/cli/commands/plan

Terraformの文法的には正しいが、AWSのリソースの仕様としては属性の値の指定が間違っている際には

  • planは成功し、apply時に失敗する

ということが起こり、これに苦労することも多いです。

そのため、下記のような追加の対策を入れていきます。

2. tflintを使う

validate, planのコマンドはあらかじめvalidationのconditionで定義しているものだったり、Terraformでの文法的な正しさでしか検証をしてくれません。

たとえば、test.tfにて

module "web" {
  source        = "./modules/web"
  ami           = "ami-078296f82eb463377"
  instance_type = "t3.supermicro" #わざと存在しないインスタンスタイプを指定
}

と言う形で、存在しないEC2のインスタンスタイプを指定しても、検出してくれず、そのままterraform applyまで実行してしまうと

$ terraform apply

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:

  # module.web.aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                                  = "ami-078296f82eb463377"
      + 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)
      + id                                   = (known after apply)
      + instance_initiated_shutdown_behavior = (known after apply)
      + instance_state                       = (known after apply)
      + instance_type                        = "t3.supermicro"
      + ipv6_address_count                   = (known after apply)
      + ipv6_addresses                       = (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

module.web.aws_instance.web: Creating...
╷
│ Error: creating EC2 Instance: InvalidParameterValue: Invalid value 't3.supermicro' for InstanceType.
│       status code: 400, request id: 2e934398-2e8d-471a-824e-f46ee9083df0
│ 
│   with module.web.aws_instance.web,
│   on modules/web/main.tf line 1, in resource "aws_instance" "web":
│    1: resource "aws_instance" "web" {
│ 
╵

と言う具合で、リソースの作成をしようとするタイミングになって、エラーになってしまいます。

仮に、デフォルトブランチにcommit pushされたらapplyをすると言うような自動化をしている場合、正しくapplyできないコードがデフォルトブランチに載ってしまい、IaCで記述されている内容と実態が乖離している状況を招いてしまいます。

これを防ごうとして、たとえばAWSで利用可能なEC2インスタンスタイプの一覧をvalidationのconditionの中に事細かく列挙してカバーしようとするのも労力がかかります。

そこでtflintを活用します。

https://github.com/terraform-linters/tflint

tflintは各providerのリソースの仕様まで掘り下げたrule setを使って検査を行い、実際にAWSのリソースを作るには正しくない記述の箇所を検出してくれるので、活用することで冒頭に書いた

  • 「terraform planでは問題なかったのにterraform applyをしたら失敗した」

という状況を少しでも減らすのに活用できます。

tflintの導入・初期設定方法の詳細な解説は割愛してしまうのですが、
プロジェクトディレクトリの直下に、.tflint.hclと言うファイル名で

plugin "aws" {
  enabled    = true
  version    = "0.17.1"
  source     = "github.com/terraform-linters/tflint-ruleset-aws"
}

と言う設定ファイルを追加して、awsのリソースに対するrule setを使って検査するようにします。
そして、

.
├── .tflint.hcl
├── modules
│   └── web
│       ├── inputs.tf
│       ├── main.tf
│       └── outputs.tf
└── test.tf

の構成でtflintのチェックをかけると

$ tflint --module
1 issue(s) found:

Error: "t3.supermicro" is an invalid value as instance_type (aws_instance_invalid_type)

  on test.tf line 4:
   4:   instance_type = "t3.supermicro" #わざと存在しないインスタンスタイプを指定

Callers:
   test.tf:4,19-34
   modules/web/main.tf:3,19-36

となり、

Error: "t3.supermicro" is an invalid value as instance_type (aws_instance_invalid_type)

という具合に、validationのconditionで細かく設定しなくても、指定したインスタンスタイプが異常であることを指摘してくれました。

このように、tflintとAWS用のrule setを導入することでAWSリソースの仕様に沿った検査が自動的に行えるようになるので、より安全にterraform applyを進められるようになります。

今回は「EC2インスタンスタイプ」の例ですが、説明を読むとAWS用には700以上のルールが準備されているようなので、かなり助けになると思います。

https://github.com/terraform-linters/tflint-ruleset-aws/blob/master/docs/rules/README.md

3. lifecycle prevent_destroy を使う

上記までは、主にリソースがcreateされる際のエラーに注目していましたが、次は作成済みのリソースを安全に保護する方法を考えていきたいと思います。

EC2インスタンスは構築された後に、その中の状態が変わっていき、保持されていくステートフルなリソースです。リソース内の情報は一度削除されてしまうと、IaCでリソースの作り直しをしても削除前の状態に戻りません。(ウェブサーバーのログ、ウェブサイト用にアップロードされたファイルなど)

その一方で、いったんリソースを削除しないと変更できない属性があります。EC2インスタンスのサブネットの変更などがこれにあたります。

たとえば、

resource "aws_instance" "web" {
  ami           = "ami-078296f82eb463377"
  instance_type = "t3.micro"
  subnet_id     = "subnet-075944dcf7f7a9ee9"
}

と言う設定でEC2インスタンスを作成済みだったとします。
それを、サブネットの箇所を変更して

resource "aws_instance" "web" {
  ami           = "ami-078296f82eb463377"
  instance_type = "t3.micro"
  subnet_id     = "subnet-0d6807632b15d21c9"   #サブネットIDを変更
}

のように書き換えて、terraform planを実行してみますと

$ terraform plan
aws_instance.web: Refreshing state... [id=i-0c4957a698fc58406]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
      ~ arn                                  = "arn:aws:ec2:ap-northeast-1:xxxxxxxxxxxxx:instance/i-0c4957a698fc58406" -> (known after apply)
      ~ associate_public_ip_address          = true -> (known after apply)
      ~ availability_zone                    = "ap-northeast-1d" -> (known after apply)
      ~ cpu_core_count                       = 1 -> (known after apply)
      ~ cpu_threads_per_core                 = 2 -> (known after apply)
      ~ disable_api_stop                     = false -> (known after apply)
      ~ disable_api_termination              = false -> (known after apply)
      ~ ebs_optimized                        = false -> (known after apply)
      - hibernation                          = false -> null
      + host_id                              = (known after apply)
      + host_resource_group_arn              = (known after apply)
      ~ id                                   = "i-0c4957a698fc58406" -> (known after apply)
      ~ instance_initiated_shutdown_behavior = "stop" -> (known after apply)
      ~ instance_state                       = "running" -> (known after apply)
      ~ ipv6_address_count                   = 0 -> (known after apply)
      ~ ipv6_addresses                       = [] -> (known after apply)
      + key_name                             = (known after apply)
      ~ monitoring                           = false -> (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         = "eni-06598a96b3714f2a0" -> (known after apply)
      ~ private_dns                          = "ip-172-31-28-21.ap-northeast-1.compute.internal" -> (known after apply)
      ~ private_ip                           = "172.31.28.21" -> (known after apply)
      ~ public_dns                           = "ec2-18-183-118-98.ap-northeast-1.compute.amazonaws.com" -> (known after apply)
      ~ public_ip                            = "18.183.118.98" -> (known after apply)
      ~ secondary_private_ips                = [] -> (known after apply)
      ~ security_groups                      = [
          - "default",
        ] -> (known after apply)
      ~ subnet_id                            = "subnet-075944dcf7f7a9ee9" -> "subnet-0d6807632b15d21c9" # forces replacement
      - tags                                 = {} -> null
      ~ tenancy                              = "default" -> (known after apply)
      + user_data                            = (known after apply)
      + user_data_base64                     = (known after apply)
      ~ vpc_security_group_ids               = [
          - "sg-0c6eb82090d1fd67c",
        ] -> (known after apply)
        # (6 unchanged attributes hidden)

      ~ capacity_reservation_specification {
          ~ capacity_reservation_preference = "open" -> (known after apply)

          + capacity_reservation_target {
              + capacity_reservation_id                 = (known after apply)
              + capacity_reservation_resource_group_arn = (known after apply)
            }
        }

      - credit_specification {
          - cpu_credits = "unlimited" -> null
        }

      + ebs_block_device {
          + delete_on_termination = (known after apply)
          + device_name           = (known after apply)
          + encrypted             = (known after apply)
          + iops                  = (known after apply)
          + kms_key_id            = (known after apply)
          + snapshot_id           = (known after apply)
          + tags                  = (known after apply)
          + throughput            = (known after apply)
          + volume_id             = (known after apply)
          + volume_size           = (known after apply)
          + volume_type           = (known after apply)
        }

      ~ enclave_options {
          ~ enabled = false -> (known after apply)
        }

      + ephemeral_block_device {
          + device_name  = (known after apply)
          + no_device    = (known after apply)
          + virtual_name = (known after apply)
        }

      ~ maintenance_options {
          ~ auto_recovery = "default" -> (known after apply)
        }

      ~ metadata_options {
          ~ http_endpoint               = "enabled" -> (known after apply)
          ~ http_put_response_hop_limit = 1 -> (known after apply)
          ~ http_tokens                 = "optional" -> (known after apply)
          ~ instance_metadata_tags      = "disabled" -> (known after apply)
        }

      + network_interface {
          + delete_on_termination = (known after apply)
          + device_index          = (known after apply)
          + network_card_index    = (known after apply)
          + network_interface_id  = (known after apply)
        }

      ~ private_dns_name_options {
          ~ enable_resource_name_dns_a_record    = false -> (known after apply)
          ~ enable_resource_name_dns_aaaa_record = false -> (known after apply)
          ~ hostname_type                        = "ip-name" -> (known after apply)
        }

      ~ root_block_device {
          ~ delete_on_termination = true -> (known after apply)
          ~ device_name           = "/dev/xvda" -> (known after apply)
          ~ encrypted             = false -> (known after apply)
          ~ iops                  = 100 -> (known after apply)
          + kms_key_id            = (known after apply)
          ~ tags                  = {} -> (known after apply)
          ~ throughput            = 0 -> (known after apply)
          ~ volume_id             = "vol-0ce9ae8fa77c4fe71" -> (known after apply)
          ~ volume_size           = 8 -> (known after apply)
          ~ volume_type           = "gp2" -> (known after apply)
        }
    }

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

変更しようとしたのはサブネットだけですが、大量に変更が出てきます。
そして、subnet_idの項目の箇所に forces replacementと言う表示があるのと、

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


とあることから、作成済みのEC2インスタンスが削除(destroy)された上で、作り直し(add)がされようとしていることがわかります。

しかし、planの表示行数も多いので、一度削除されようとしていることに気が付かずapplyをしてしまい

「サブネットは期待通り変わったが、新たにインスタンスが作り直されていた(インスタンス内にあったデータが消えてしまった)」

と言う事故につながりかねません。(もちろんplanの内容をしっかりと見る。と言うことは必要なことですが)

lifecycleprevent_destroy は設定することでそういった、設定の変更に際してリソースが削除されてしまうことを防止できます。
使い方は該当のリソースにlifecycleの指定をするだけです。

resource "aws_instance" "web" {
  ami           = "ami-078296f82eb463377"
  instance_type = "t3.micro"
  subnet_id     = "subnet-0d6807632b15d21c9" #サブネットIDを変更

  lifecycle { # lifecycle blockを追加
    prevent_destroy = true
  }
}

このようにlifecycleprevent_destroyの指定を入れた上でterraform planを実行すると

$ terraform plan
aws_instance.web: Refreshing state... [id=i-0c4957a698fc58406]
╷
│ Error: Instance cannot be destroyed
│ 
│   on test.tf line 1:
│    1: resource "aws_instance" "web" {
│ 
│ Resource aws_instance.web has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed. To avoid this error and continue
│ with the plan, either disable lifecycle.prevent_destroy or reduce the scope of the plan using the -target flag.
╵
Instance cannot be destroyed

のような警告と共に、リソースの削除を伴う変更点が検出された時にエラーとなって失敗してくれます。
これにより意図せずして、削除を伴った作り直しが行われることを未然に防ぐことができます。

なお、削除を伴わない変更点であれば、問題なく実行できます。
たとえば、インスタンスタイプの変更はエラーなくplan結果が表示されました。

$ terraform plan
aws_instance.web: Refreshing state... [id=i-0c4957a698fc58406]

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" {
        id                                   = "i-0c4957a698fc58406"
      ~ instance_type                        = "t3.micro" -> "t3.nano"
        tags                                 = {}
        # (29 unchanged attributes hidden)

        # (7 unchanged blocks hidden)
    }

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

インフラの構築初期段階においては試行錯誤するところが多く、削除を伴った変更を繰り返しながら構築を進めていくことになることも多いかと思います。
その一方で、リソースが保護されるべき段階に入ったなど「今、どのフェーズにいるのか」を見極めながら、このprevent_destroyのlifecycle設定を導入すると良いでしょう。

4. EC2インスタンスの削除保護の注意点

上記の lifecycle設定にて、設定の変更に伴いインスタンスが削除されることは防止できましたが、「EC2インスタンスの削除保護はどう使うの?」とお思いの方もいらっしゃるかもしれませんので、そこを掘り下げてみます。

Terraform内で設定する際には disable_api_terminationの設定がこれにあたりますが、この設定値は「Terraformを通じた削除」を防止しないと言うことに注意が必要です。

たとえば、

resource "aws_instance" "web" {
  ami           = "ami-078296f82eb463377"
  instance_type = "t3.micro"
  subnet_id     = "subnet-075944dcf7f7a9ee9"

  disable_api_termination = true # disable_api_terminationを追加
}

と言う設定でEC2インスタンスがすでに作られているとします。

このインスタンスをマネジメントコンソール上から削除(終了)しようとすると

操作を選択することができないようになっていて、保護されていることがわかります。

また、CLI上からの削除も試してみますと

$ aws ec2 terminate-instances --instance-ids i-0c4957a698fc58406

An error occurred (OperationNotPermitted) when calling the TerminateInstances operation: The instance 'i-0c4957a698fc58406' may not be terminated. Modify its 'disableApiTermination' instance attribute and try again.

と言うように削除(終了)するためには削除保護の設定を修正することが必要な旨表示されて、失敗します。

ここまでは、期待通りの振る舞いかと思います。

一方で、作成済みのEC2インスタンスのresourceの記述を消し去って、Terraformではdestroyが行われるようにするとどうなるか確認します。
コードで書き表すとしたらこのような具合でしょうか。(すべてコメントアウト)

# resource "aws_instance" "web" {
#   ami           = "ami-078296f82eb463377"
#   instance_type = "t3.micro"
#   subnet_id     = "subnet-075944dcf7f7a9ee9"
#
#   disable_api_termination = true
# }

この状態でterraform applyをしてみます。

$ terraform apply
aws_instance.web: Refreshing state... [id=i-0c4957a698fc58406]

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

Terraform will perform the following actions:

  # aws_instance.web will be destroyed
  # (because aws_instance.web is not in configuration)
  - resource "aws_instance" "web" {
      - ami                                  = "ami-078296f82eb463377" -> null
      - arn                                  = "arn:aws:ec2:ap-northeast-1:xxxxxxxxxxxx:instance/i-0c4957a698fc58406" -> null
      - associate_public_ip_address          = true -> null
      - availability_zone                    = "ap-northeast-1d" -> null
      - cpu_core_count                       = 1 -> null
      - cpu_threads_per_core                 = 2 -> null
      - disable_api_stop                     = false -> null
      - disable_api_termination              = true -> null
      - ebs_optimized                        = false -> null
      - get_password_data                    = false -> null
      - hibernation                          = false -> null
      - id                                   = "i-0c4957a698fc58406" -> null
      - instance_initiated_shutdown_behavior = "stop" -> null
      - instance_state                       = "running" -> null
      - instance_type                        = "t3.micro" -> null
      - ipv6_address_count                   = 0 -> null
      - ipv6_addresses                       = [] -> null
<略>
    }

Plan: 0 to add, 0 to change, 1 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: Destroying... [id=i-0c4957a698fc58406]
aws_instance.web: Still destroying... [id=i-0c4957a698fc58406, 10s elapsed]
aws_instance.web: Still destroying... [id=i-0c4957a698fc58406, 20s elapsed]
aws_instance.web: Still destroying... [id=i-0c4957a698fc58406, 30s elapsed]
aws_instance.web: Still destroying... [id=i-0c4957a698fc58406, 40s elapsed]
aws_instance.web: Still destroying... [id=i-0c4957a698fc58406, 50s elapsed]
aws_instance.web: Still destroying... [id=i-0c4957a698fc58406, 1m0s elapsed]
aws_instance.web: Destruction complete after 1m0s

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

インスタンス削除は失敗することなく完了しました。
disable_api_termination を入れていたので、削除に失敗することを期待する方もいたのではないでしょうか。

しかし、これは仕様通りの挙動で、Terraformにおいては

  • 設定ファイルの中からresourceの記述を削除すると言うことは、そのresourceを削除したいと意図されたものである

と言う前提で設計とされているようです。
今回のようにresourceの記述を消して、applyをすると、disable_api_terminationがオンでも、EC2インスタンスが削除されるのは設計通りです。(※)

そのため、EC2インスタンスの削除保護機能は

  • 「AWSコンソール、CLIにてインスタンスが想定外に削除されてしまうのを防ぐためのもので、Terraformからの削除は防止しない」

と捉えることが必要です。

※:この挙動に関連した議論がされているGitHub上のissueがあり背景を知る上で参考になりました:https://github.com/hashicorp/terraform/issues/17599#issuecomment-373557799

5. IAMのパーミッションで制御

インフラの構築フェーズにおいては、多くのリソースを削除や変更も伴いながら構築していくことになるので、Terraform実行用のIAMには比較的強い権限を与えることもあると思います。

しかし、一方である程度の構築を終えて、運用するフェーズに入ったら、リソースの変更する頻度が下がってきます。そして中には、EC2インスタンスのように削除されないようにすることが必要なものもあります。

上記に書いたように、EC2インスタンスについて、削除保護機能ではTerraformを通じた偶発的な削除は防げないのだとすると、もう一つ考えられるのは、Terraformの実行用のIAMの権限を絞ることです。

たとえば、Terraformの実行用のIAMに以下のような拒否ポリシーを追加します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DenyTerminateInstances",
            "Effect": "Deny",
            "Action": "ec2:TerminateInstances",
            "Resource": "arn:aws:ec2:*:xxxxxxxxxxxx:instance/*",
            "Condition": {
                "StringEquals": {
                    "aws:ResourceTag/Protection": "True"
                }
            }
        }
    ]
}

サービスレベルの条件キーであるaws:ResourceTagの仕組みを使って

  • 値が”True”の“Protection”のタグがついているEC2インスタンスの削除はできない

と言う条件を作ってみました。

この状態で次の内容でEC2インスタンスを作成します。

resource "aws_instance" "web" {
  ami           = "ami-078296f82eb463377"
  instance_type = "t3.micro"
  subnet_id     = "subnet-075944dcf7f7a9ee9"

  disable_api_termination = true
  lifecycle {
    prevent_destroy = true
  }

  # Protectionタグを追加
  tags = {
    "Protection" = "True"
  }
}

そして、その後にインスタンスがdestroyされるように変更(コメントアウト)し、applyします。

# resource "aws_instance" "web" {
#   ami           = "ami-078296f82eb463377"
#   instance_type = "t3.micro"
#   subnet_id     = "subnet-075944dcf7f7a9ee9"

#   disable_api_termination = true
#   lifecycle {
#     prevent_destroy = true
#   }

#   # Protectionタグを追加
#   tags = {
#     "Protection" = "True"
#   }
# }
$ terraform apply
aws_instance.web: Refreshing state... [id=i-0cd129a6a40720c5d]

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

Terraform will perform the following actions:

  # aws_instance.web will be destroyed
  # (because aws_instance.web is not in configuration)
  - resource "aws_instance" "web" {
      - ami                                  = "ami-078296f82eb463377" -> null
      - arn                                  = "arn:aws:ec2:ap-northeast-1:xxxxxxxxxxxx:instance/i-0cd129a6a40720c5d" -> null
      - associate_public_ip_address          = true -> null
      - availability_zone                    = "ap-northeast-1d" -> null
      - cpu_core_count                       = 1 -> null
      - cpu_threads_per_core                 = 2 -> null
      - disable_api_stop                     = false -> null
      - disable_api_termination              = true -> null
      - ebs_optimized                        = false -> null
      - get_password_data                    = false -> null
      - hibernation                          = false -> null
      - id                                   = "i-0cd129a6a40720c5d" -> null
      - instance_initiated_shutdown_behavior = "stop" -> null
      - instance_state                       = "running" -> null
      - instance_type                        = "t3.micro" -> null
      - ipv6_address_count                   = 0 -> null
      - ipv6_addresses                       = [] -> null
<略>
      - tags                                 = {
          - "Protection" = "True"
        } -> null
      - tags_all                             = {
          - "ManagedBy"  = "Terraform"
          - "Project"    = "blog-sample"
          - "Protection" = "True"
        } -> null
<略>
    }

Plan: 0 to add, 0 to change, 1 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: Destroying... [id=i-0cd129a6a40720c5d]
╷
│ Error: terminating EC2 Instance (i-0cd129a6a40720c5d): UnauthorizedOperation: You are not authorized to perform this operation. Encoded authorization failure message: 
<略>
│       status code: 403, request id: a5725471-8062-4c33-b06d-fcd6d99eac29
│ 
│ 
╵

“Destroying”の表示まで進みましたが、権限不足の表示になり、期待通りEC2インスタンスの削除は失敗しました。
aws:ResourceTagの条件のついた拒否ポリシーがちゃんと機能したようです。
これにより、たとえば別件の変更対応しているつもりだったのに、偶発的にEC2インスタンスがTerraformによるdestroyの対象になったとしても、インスタンスに

{"Protection":"True"}

のタグがついていれば削除されず保護されることになります。

このようにしてインフラ構築のフェーズで今どこにいるのかを鑑みて、Terraformを実行するIAMの権限を絞るメンテナンスをしていくことで、terraform applyを実行する際の安全を確保できるのではないでしょうか。

まとめ

以上、terraform applyを少しでも安全にしていくためのアイディアを挙げてみました。

基本的にはterraform applyの前にterraform planをしっかり確認することが大切ではあるのですが、追加の仕組みを入れていき、より快適に実装をできるようにしたいものです。

中には、Terraformの実行体制が複雑になる懸念はあるかもしれませんが、トレードオフを考えながら、少しでもインフラ構築担当者の負担を低減する参考になれば幸いです。

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