スクエニ ITエンジニア ブログ

[中級] ハマグリ式! AWS で使う Terraform の落とし穴

はじめに

この記事を見つけたけど、後で見ようと思ったそこのあなた!

ぜひ下のボタンから、ハッシュタグ #ハマグリ式 でツイートしておきましょう!

こんにちハマグリ。貝藤らんまだぞ。 今回は AWS および Terraform の中級者向けに、ハマグリ式! AWS で使う Terraform の落とし穴をご紹介します!

中級者って?

ハマグリ式では、下記のようにレベルを設定しています。

  1. 初級者:初めてクラウドサービスを利用する人で、基本的な操作(例:ファイルの保存や、サーバーの起動)をインターフェースを通じて行うことができます。また、シンプルなセキュリティルールの設定や、一部の問題のトラブルシューティングに対応できます。
  2. 中級者:より深い知識を持ち、コードを用いて操作を自動化したり、より複雑なタスク(例:自動でサーバーの数を増減させる)を行います。また、より高度な監視や、全体のシステム設計と実装について理解があります。
  3. 上級者:幅広く深い知識を持ち、大規模で複雑なシステムを設計、実装、維持する能力があります。最先端のテクノロジーを活用し、安全性、耐障害性、効率性を最大化するためのソリューションを提供します。

なお上記は ChatGPT による出力ですが、この記事でほかに生成 AI によって出力された文章はありません。ただし、Terraform のコードは生成 AI の出力を一部利用しています。

ハマグリ式って?

貝藤らんまが作成するブログ記事のブランド名です。あまり気にせず読み飛ばしてください。

何を書くの?

以下の通りです。

  • この記事で扱うツール
$ terraform --version
Terraform v1.5.1
on linux_amd64
+ provider registry.terraform.io/hashicorp/aws v5.5.0
  • この記事で書くこと
    • AWS の構築で Terraform を使う場合の落とし穴 (気を付ておかないと困ったことになるもの)
  • この記事で書かないこと
    • Terraform の基礎
    • Terraform のディレクトリデザイン
    • サンプルコード
    • 構築のベストプラクティス

免責事項

  • この記事に書かれていることは弊社の意見を代表するものではありません。
  • この記事に書かれていることには一定の調査と検証を実施しておりますが、間違いが存在しうることはご承知おき下さい。

AWS で使う Terraform の落とし穴

Terraform が AWS リソース作成で非常に役立つ IaC ツールであることは、皆さんご存知かと思います。

しかし構成が複雑になっていくにしたがって、「リファクタリングが必要にならないようにこう書いておけばよかった」「Terraform 外で変更が発生することを考慮すべきだった」ということが発生することでしょう。

そういった、Terraform で AWS リソースを作成・構成するときの「落とし穴」をいくつかご紹介します。

セキュリティグループルールのポート閉塞問題

さまざまなリソースへ紐づけるセキュリティグループですが、たとえば本番環境などで「公開だから内部へのすべてのトラフィックを開けよう」という場合、以下のようなコードになります。

resource "aws_security_group" "example" {
  name        = "example"
  vpc_id      = "vpc-1234567890abcdefgh"

  ingress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

}

しかしその後「TCP しか使わないことになったから余計な UDP をふさごう」ということになり、下記のように変更したとします。

resource "aws_security_group" "example" {
  name        = "example"
  vpc_id      = "vpc-1234567890abcdefgh"

  ingress {
    from_port   = 0
    to_port     = 0
    protocol    = "TCP" # ここを変更
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

}

このコードだとTCP のすべてのポートを許可しておるのではなく、「0ポートだけを許可」している状態になってしまいます。

そもそも、すべてのトラフィックのときになぜかポートを指定しなければならず、それが 0 であることはイレギュラーです。

ですが疲れていると、「0ポートは通常使わないものの存在する」ということを忘れたりするかもしれません。

もしプロトコルは TCP だが全ポートを許可したい、という場合は以下のように書く必要があります。

resource "aws_security_group" "example" {
  name        = "example"
  vpc_id      = "vpc-1234567890abcdefgh"

  ingress {
    from_port   = 0     # ここを変更
    to_port     = 65535 # ここを変更
    protocol    = "TCP"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

}

もちろんこのことはドキュメントに記載されているぞ。

S3 ときどき仕様がしれっと変わる問題

S3 は厄介なリソースで、仕様の変更によって「コードを変えていないのに突然差分が発生した」なんてことが時々あります。

2022年の3月ごろ IP アドレスを追加しようと plan を実行したときに、以下のようなエラーが発生しました。

Error: Value for unconfigurable attribute

  on .terraform/modules/s3_cf/main.tf line 56, in resource "aws_s3_bucket" "cloudfront":
  56: resource "aws_s3_bucket" "cloudfront" {

Can't configure a value for "grant": its value will be decided automatically
based on the result of applying this configuration.

IP アドレスの変更なので、もちろん S3 リソースには変更を加えていません。

結論を言うと、原因は AWS provider の仕様変更でした。変更内容 を見ると、

resource/aws_s3_bucket: The acl and grant arguments have been deprecated and are now read-only. Use the aws_s3_bucket_acl resource instead. (#22537)

と書いてあり、v3 から v4 への変更で S3 リソースの大分離が断行されていたようなのです。

当時の issue もそこそこ荒れていた記憶があります。

ほかには、server_side_encryption_configuration が突然追加差分として出てくるといったこともありました。

(参考: https://github.com/hashicorp/terraform-provider-aws/releases/tag/v3.76.1)

せめて provider は構築時のバージョンで固定したほうがいいぞ。

RDS Aurora インスタンス微調整できない問題

AWS のデータベースといえば RDS Aurora ですが、Aurora のインスタンスを作成する Terraform ドキュメント上の例は、記事作成時点で下記のようになっています。

resource "aws_rds_cluster_instance" "cluster_instances" {
  count              = 2
  identifier         = "aurora-cluster-demo-${count.index}"
  cluster_identifier = aws_rds_cluster.default.id
  instance_class     = "db.r4.large"
  engine             = aws_rds_cluster.default.engine
  engine_version     = aws_rds_cluster.default.engine_version
}

なぜか突然 meta-argument である count が出てきてますね。

これを鵜呑みにしてコードに盛り込むと、運用時に苦しい思いをする可能性があります。

というのは、 Aurora のインスタンスは (クラスター内に2台以上ある場合) ライターとリーダーに分かれているからです。

インスタンスを3台から2台に減らすことになった。でも3台目がライターだから、このままでは減らせない……。

Terraform コードを書き換えるか、それともフェイルオーバしてから count を減らすか……。

といった悩みが発生するかもしれません。

なんならフェイルオーバーの優先順位と組み合わせてインスタンスクラスをばらばらにする運用もなくはないでしょう。

結局 for_each を使う羽目になるし、そもそも RDS Aurora に限らず count を書いたほうがいい、という場面はあまりありません。

全体的に変更を加えることになりますが、最終的に以下のようにするのがよいでしょう。

resource "aws_rds_cluster_instance" "cluster_instances" {
  for_each                   = var.instance
  apply_immediately          = true
  ca_cert_identifier         = "rds-ca-2019"
  identifier                 = each.key
  cluster_identifier         = "example"
  engine                     = "aurora-mysql"
  instance_class             = each.value.instance_class
  db_parameter_group_name    = each.value.db_parameter_group.name
  auto_minor_version_upgrade = false
  monitoring_interval        = contains(keys(each.value), "enhanced_monitoring_role_arn") ? 60 : 0
  monitoring_role_arn        = lookup(each.value, "enhanced_monitoring_role_arn", null)
  performance_insights_enabled    = length(regexall("^db\\.t[23]", each.value.instance_class)) == 0
}

しれっと書かれているけど、運用を考えると auto_minor_version_upgrade を無効化して performance_insights_enabled を有効化したほうがいいかもだぞ。

そもそも count 使うべきじゃない問題

前の続きになりますが、どうしても面倒であれば、

for_each = toset([for i in range(3): tostring(i)])

といった range 関数を使う書き方もできるでしょう。 count との違いは state の index_key が「数値型」になるか「文字列型」になるか、ですね。

たとえば、

resource "aws_s3_bucket" "count" {
  count = 2
  bucket = count.index
}

というバケットを apply して作成したとします。

この1つ目のバケットの設定を変えようと、後から for_each に変更して

locals {
  s3 = {
    0 = {
      Environment = "Dev"
    }
    1 = {
      Environment = "Prd"
    }
  }
}

resource "aws_s3_bucket" "count" {
  for_each = local.s3
  bucket = each.key
  tags = {
    Environment = each.value.Environment
  }
}

に頑張って書き換えたとします。これを apply しようとすると

  # aws_s3_bucket.count[0] will be destroyed
  # (because resource does not use count)

...(中略)...

  # aws_s3_bucket.count["0"] will be created

という風に作り直されてしまいます。

もちろんここから

terraform state mv 'aws_s3_bucket.count[0]' 'aws_s3_bucket.count["0"]'

を実行すれば回避できます。

ただし

for_each = toset([for i in range(3): tostring(i)])

としておけば、state の mv は不要です。

リソース数が多い場合は、terraform state mv をループするよりいったんは range を使うようにして、locals を工夫するほうがよさそうな気がします。

ぶっちゃけここまでするなら CDK for Terraform を検討してもよさそうだぞ。

ECS Service のタスク定義リビジョン問題

ちょっと限定的な例になりますが、最後に ECS のトピックをひとつ。

ECS を構築する人はタスク定義も Terraform で管理したいと思うでしょう。

たとえば、以下のような書き方が考えられます。

resource "aws_ecs_task_definition" "example" {
  family   = "example"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"

  container_definitions = file("example.json")
}

resource "aws_ecs_service" "example" {
  name            = "example-service"
  cluster         = "example-cluster"
  task_definition = aws_ecs_task_definition.example.arn
  desired_count   = 1  
}

しかしタスク定義については、アプリケーション開発の進行をスムーズにするために、開発側へ一部の権限を渡すような場面もあると思います。

そうするといつの間にかタスクのリビジョンが増えていた、といったことが当然発生します。

そこへ何も考えずにこのコードを apply してしまうと、サービスのタスク定義が古い内容でリビジョンされてしまうことになります。

それを避けるためには、タスク定義を作成した後にその時点でのタスク定義のリビジョンを取得すればよいでしょう。

タスク定義に変更がなく、作成の発生しない場合は、単純に現在のタスク定義のリビジョンを見に行くだけになります。

これをコード化すると下記のようになります。

resource "aws_ecs_task_definition" "example" {
  family                   = "example"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"

  container_definitions = jsonencode("example.json")
}

# data リソースを追加
data "aws_ecs_task_definition" "example" {
  task_definition = "example"
  # depends_on とすることで作成時に apply 完了まで待ってくれる
  depends_on = [aws_ecs_task_definition.example]
}

resource "aws_ecs_service" "example" {
  name            = "example-service"
  cluster         = "example-cluster"
  task_definition = data.aws_ecs_task_definition.example.arn # ここを変更
  desired_count   = 1  
}

インフラとアプリ開発の境目が薄くなりつつある昨今、このあたりの取り回しは検討が必要かもだぞ。

まとめ

まとめは ChatGPT に箇条書きでお願いしようと思います。

以上のブログ記事のまとめを箇条書きで書いてください
記事の著者が語っている体の、ですます調で書いてください
  • AWSおよびTerraformを使用する際に、注意すべき落とし穴をいくつか紹介しています。ここで扱うツールはTerraform (バージョン1.5.1)とクラウドプロバイダーのAWS (バージョン5.5.0)です。
  • セキュリティグループルールでのポートの扱いについて、TCPの全ポートを許可する場合の記述方法について注意が必要です。特に、すべてのポートを指定したい場合と、特定のポートだけを指定したい場合の書き方の違いを理解することが重要です。
  • S3の仕様が予告なく変わることがあり、その結果として予期せぬコードの差分が発生する可能性があります。これは主に、AWS providerの仕様変更によるもので、常に最新のドキュメントをチェックして変更を追いかける必要があります。
  • RDS Auroraのインスタンス設定については、運用の観点からTerraformのドキュメントにあるようなcountを使った設定よりも、for_eachを使った設定の方が適しています。
  • countについては、基本的に使用を避けるべきです。リソースの数が多い場合、countの代わりにrange関数を使用すると、インデックスキーが文字列型になり、状態の管理が容易になります。
  • ECSのタスク定義については、アプリケーション開発の進行をスムーズにするために、開発側に一部の権限を委譲することが考えられます。しかし、その結果としてタスクのリビジョンが増える可能性があります。この問題を避けるためには、タスク定義を作成した後、その時点でのタスク定義のリビジョンを取得することが推奨されます。

ぜひ下のボタンから、ハッシュタグ #ハマグリ式 で感想をツイートしてください!

今後ともハマグリ式をどうぞよろしくお願いいたします!

この記事を書いた人

記事一覧
SQUARE ENIXでは一緒に働く仲間を募集しています!
興味をお持ちいただけたら、ぜひ採用情報ページもご覧下さい!