Terraformモジュールの動的入力に関するコーディングルールを考えてみる

Terraformモジュールの動的入力に関するコーディングルールを考えてみる

Clock Icon2025.04.22

こんにちは!コンサルティング部のくろすけです!
Terraform のモジュールを作成していく中で、variable の作成について解像度が上がってきたので、自分なりに考えた現場のコーディングルールについてまとめてみます。
(ベストプラクティスとかではないので、悪しからず...)

特筆すべきブロック・オプションなど

今回まとめるにあたり、特筆する Terraform のブロックについて簡単に説明します。

dynamic Blocks

このブロックの何が良いかと言いますと、下記の記述の機能です。

リソース、データ、プロバイダー、プロビジョナー ブロック内でサポートされている特別な動的ブロック タイプを使用して、設定などの繰り返し可能なネストされたブロックを動的に構築できます。

原文

You can dynamically construct repeatable nested blocks like setting using a special dynamic block type, which is supported inside resource, data, provider, and provisioner blocks

Dynamic Blocks - Configuration Language | Terraform | HashiCorp Developerより引用

つまりネストされたブロックは dynamic Blocks を使用することで、動的に指定することが可能となります。
動的に指定と聞くと for_each も考えられますが、ネストされたブロックに対してはコードが複雑になりやすいです。

この辺の比較イメージは下記が参考になります。
TerraformのDynamic Blocksを使ってみた | DevelopersIO

validation Block

このブロックのメリットというか機能はそのままずばり。ですね。

特定の変数にカスタム検証ルールを指定するには、対応する変数ブロック内に検証ブロックを追加します。以下の例では、AMI ID の構文が正しいかどうかを確認します。

原文

You can specify custom validation rules for a particular variable by adding a validation block within the corresponding variable block. The example below checks whether the AMI ID has the correct syntax.

Input Variables - Configuration Language | Terraform | HashiCorp Developerより引用

validation ブロックの本件でのユースケースとしては、モジュール、プロジェクト、ビジネス固有の validation 設定場合に使用するのが良いと思います。例えば、下記などです。

  • resource ブロックで許容はしているが、モジュールの機能仕様としては拒否したい入力値がある
  • リソース名が社内命名規則に則っているかチェックしたい

optional オプジェクトタイプ

このオプジェクトタイプは variable のコーディングルール次第では、必須のオブジェクトタイプになります。
もちろん今回のコーディングルールにおいては必須です。

Terraformは通常、指定されたオブジェクト属性の値を受け取らない場合にエラーを返します。属性をオプションとしてマークすると、Terraformは代わりに不足している属性にデフォルト値を挿入します。これにより、受信モジュールは適切なフォールバック動作を記述できます。

原文

Terraform typically returns an error when it does not receive a value for specified object attributes. When you mark an attribute as optional, Terraform instead inserts a default value for the missing attribute. This allows the receiving module to describe an appropriate fallback behavior.

Type Constraints - Configuration Language | Terraform | HashiCorp Developerより引用

この記述だけだと、後述の default オプションで代替できる様に思えます。
この点は、後述させていただきます。

default オプション

こちらも言わずもがなと思いますが、default値を設定するオプションです。

変数宣言にはdefault引数を含めることもできます。引数がある場合、変数はオプションとみなされ、モジュールの呼び出し時またはTerraformの実行時に値が設定されていない場合はデフォルト値が使用されます。引数にはdefault リテラル値が必要であり、設定内の他のオブジェクトを参照することはできません。

原文

The variable declaration can also include a default argument. If present, the variable is considered to be optional and the default value will be used if no value is set when calling the module or running Terraform. The default argument requires a literal value and cannot reference other objects in the configuration.

Input Variables - Configuration Language | Terraform | HashiCorp Developerより引用

基本的なルール

個人的には、シンプルなルールにしたつもりです。

  1. resourceとvarialbeの対応関係は 1:1 とする(resource : variable = 1 : 1)
    可読性が向上すると考えました。
  2. rule1のため、オプションスキーマは variable = resource とする
    可読性が向上すると考えました。resource のオプションと対応させておくことで、確認などもしやすいかと思います。
  3. rule2のため、任意オプションのオブジェクトタイプは原則 optional を使用する
    どうやら default オプションは、variable が object タイプの場合、object の要素一部だけに適応というわけにはいかないようです。よってそもそも optional を使用するしかないというわけでした。
  4. モジュール固有の入力制御が必要な場合は、validation を使用する
    こちらは validation Block で記載した通りです。
  5. 全てのオプションが任意の場合は default を使用する
    optional は variable ブロックの type には直接記述できないため、default を使用する必要があります。
  6. resource ブロックのネストされたブロックには、dynamic ブロックを活用
    こちらも dynamic Blocks で記載した通りです。
    ただし、lifecycle などのメタ属性は dynamic Blocks が使用できないため、モジュール固有の仕様として静的に設定することにしました。

サンプル

コンテナイメージを使用した Lambda を作成するモジュールを作成しました。
サンプルとしては分量が多いですが、一通り今回の内容が網羅できているかなと思います。
モジュール外からの視点だと、シンプルで可読性高いInputスキーマにできているかなと思います。

module/variables.tf
################################################################################
# ECR                                                                          #
################################################################################
variable "ecr_repository" {
  description = "The ECR repository configuration"
  type = object({
    # Required
    name = string

    # Optional settings
    encryption_configuration = optional(object({
      encryption_type = optional(string)
      kms_key         = optional(string)
    }))
    force_delete         = optional(bool, true)
    image_tag_mutability = optional(string, "IMMUTABLE")
    image_scanning_configuration = optional(object({
      scan_on_push = bool
    }))
    tags = optional(map(string))
  })

  validation {
    condition     = can(regex("^[a-z0-9-_]+-(prd|stg|dev|pvt[1-9][0-9]{0,2})-repository$", var.ecr_repository.name))
    error_message = "The ECR repository name must follow the pattern: {sys_name}-{env}-repository. The env must be one of prd, stg, dev, or pvt[1-9][0-9]{0,2}."
  }

  validation {
    condition     = var.ecr_repository.image_tag_mutability == null || var.ecr_repository.image_tag_mutability == "IMMUTABLE"
    error_message = "The image_tag_mutability value must be \"IMMUTABLE\" only."
  }
}

################################################################################
# Lambda                                                                       #
################################################################################
variable "lambda_function" {
  description = "The Lambda function configuration"
  type = object({
    # Required
    function_name = string

    # Optional settings
    architectures           = optional(list(string), ["arm64"])
    code_signing_config_arn = optional(string)
    dead_letter_config = optional(object({
      target_arn = string
    }))
    description               = optional(string)
    dummy_ecr_repository_name = string
    dummy_ecr_repository_tag  = optional(string, "latest")
    environment               = optional(map(string))
    ephemeral_storage = optional(object({
      size = number
    }))
    file_system_config = optional(object({
      arn              = string
      local_mount_path = string
    }))
    filename = optional(string)
    handler  = optional(string)
    image_config = optional(object({
      command           = optional(list(string))
      entry_point       = optional(list(string))
      working_directory = optional(string)
    }))
    kms_key_arn = optional(string)
    layers      = optional(list(string))
    logging_config = optional(object({
      log_format = string

      application_log_level = optional(string)
      log_group             = optional(string)
      system_log_level      = optional(string)
    }))
    memory_size                        = optional(number, 128)
    package_type                       = optional(string, "Image")
    publish                            = optional(bool, true)
    reserved_concurrent_executions     = optional(number)
    replace_security_groups_on_destroy = optional(bool)
    runtime                            = optional(string)
    s3_bucket                          = optional(string)
    s3_key                             = optional(string)
    s3_object_version                  = optional(string)
    skip_destroy                       = optional(bool)
    source_code_hash                   = optional(string)
    snap_start = optional(object({
      apply_on = string
    }))
    lambda_timeout = optional(number, 3)
    tracing_config = optional(object({
      mode = string
    }))
    vpc_config = optional(object({
      security_group_ids = list(string)
      subnet_ids         = list(string)

      ipv6_allowed_for_dual_stack = optional(bool)
    }))
    tags = optional(map(string))
  })

  validation {
    condition     = can(regex("^[a-z0-9-_]+-(prd|stg|dev|pvt[1-9][0-9]{0,2})-lambda$", var.lambda_function.function_name))
    error_message = "The Lambda function name must follow the pattern: {sys_name}-{env}-lambda. The env must be one of prd, stg, dev, or pvt[1-9][0-9]{0,2}."
  }

  validation {
    condition     = var.lambda_function.package_type == null || var.lambda_function.package_type == "Image"
    error_message = "The package_type value must be \"Image\" only."
  }

  validation {
    condition = try(var.lambda_function.logging_config.log_format == "JSON",
      var.lambda_function.logging_config.log_format == null,
    var.lambda_function.logging_config == null)
    error_message = "The log_format value must be \"JSON\"."
  }

  validation {
    condition = try(var.lambda_function.tracing_config.mode == "Active",
      var.lambda_function.tracing_config.mode == null,
    var.lambda_function.tracing_config == null)
    error_message = "The tracing_config mode value must be \"Active\"."
  }
}

variable "lambda_alias" {
  description = "The Lambda alias configuration"
  type = object({
    # Required
    name = string

    # Optional settings
    description = optional(string)
    routing_config = optional(object({
      additional_version_weights = map(number)
    }))
  })

  validation {
    condition     = can(regex("^(prd|stg|dev|pvt[1-9][0-9]{0,2})$", var.lambda_alias.name))
    error_message = "The Lambda alias name must follow the pattern: {env}. The env must be one of prd, stg, dev, or pvt[1-9][0-9]{0,2}."
  }
}

################################################################################
# IAM Role for Lambda                                                          #
################################################################################
variable "lambda_execution_role" {
  description = "The IAM role for the Lambda function"
  type = object({
    # Required
    name = string

    # Optional settings
    assume_role_policy = optional(object({
      Version = string
      Statement = list(object({
        Effect = string
        Action = list(string)
        Principal = object({
          Service = string
        })
      }))
      }), {
      Version = "2012-10-17",
      Statement = [
        {
          Effect = "Allow",
          Action = [
            "sts:AssumeRole",
          ],
          Principal = {
            Service = "lambda.amazonaws.com"
          }
        }
      ]
    })
    description           = optional(string)
    force_detach_policies = optional(bool)
    max_session_duration  = optional(number)
    name_prefix           = optional(string)
    path                  = optional(string)
    permissions_boundary  = optional(string)
    tags                  = optional(map(string))
  })

  validation {
    condition     = can(regex("^[a-z0-9-_]+-(prd|stg|dev|pvt[1-9][0-9]{0,2})-lambda-role$", var.lambda_execution_role.name))
    error_message = "The Lambda execution role name must follow the pattern: {sys_name}-{env}-lambda-role. The env must be one of prd, stg, dev, or pvt[1-9][0-9]{0,2}."
  }
}

variable "lambda_execution_policy" {
  description = "The IAM policy for the Lambda function"
  type = object({
    # Required
    name = string

    # Optional settings
    policy = optional(object({
      Version = string
      Statement = list(object({
        Effect   = string
        Action   = list(string)
        Resource = list(string)
      }))
      }), {
      Version = "2012-10-17",
      Statement = [
        {
          Effect = "Allow",
          Action = [
            "logs:CreateLogGroup",
            "logs:CreateLogStream",
            "logs:PutLogEvents",
          ],
          Resource = [
            "arn:aws:logs:*:*:*",
          ]
        }
      ]
    })
    description = optional(string)
    name_prefix = optional(string)
    path        = optional(string)
    tags        = optional(map(string))
  })

  validation {
    condition     = can(regex("^[a-z0-9-_]+-(prd|stg|dev|pvt[1-9][0-9]{0,2})-lambda-policy$", var.lambda_execution_policy.name))
    error_message = "The Lambda execution policy name must follow the pattern: {sys_name}-{env}-lambda-policy. The env must be one of prd, stg, dev, or pvt[1-9][0-9]{0,2}."
  }
}

################################################################################
# CloudWatch Logs                                                              #
################################################################################
variable "cloudwatch_log_group" {
  description = "The CloudWatch log group configuration"
  type = object({
    # Optional settings
    name_prefix       = optional(string)
    skip_destroy      = optional(bool, false)
    log_group_class   = optional(string, "STANDARD")
    retention_in_days = optional(number, 3)
    kms_key_id        = optional(string)
    tags              = optional(map(string))
  })
  default = {
    name_prefix       = null
    skip_destroy      = false
    log_group_class   = "STANDARD"
    retention_in_days = 3
    kms_key_id        = null
    tags              = null
  }

  validation {
    condition     = try(var.cloudwatch_log_group.log_group_class == "STANDARD", var.cloudwatch_log_group.log_group_class == null)
    error_message = "The log_group_class value must be \"STANDARD\"."
  }
}

module/main.tf
################################################################################
# Common                                                                       #
################################################################################
data "aws_caller_identity" "self" {}
data "aws_region" "self" {}

################################################################################
# ECR                                                                          #
################################################################################
resource "aws_ecr_repository" "lambda" {
  name = var.ecr_repository.name
  dynamic "encryption_configuration" {
    for_each = var.ecr_repository.encryption_configuration != null ? [1] : []
    content {
      encryption_type = var.ecr_repository.encryption_configuration.encryption_type
      kms_key         = var.ecr_repository.encryption_configuration.kms_key
    }
  }
  force_delete         = var.ecr_repository.force_delete
  image_tag_mutability = var.ecr_repository.image_tag_mutability
  dynamic "image_scanning_configuration" {
    for_each = var.ecr_repository.image_scanning_configuration != null ? [1] : []
    content {
      scan_on_push = var.ecr_repository.image_scanning_configuration.scan_on_push
    }
  }

  tags = merge(
    {
      Name   = var.ecr_repository.name
      Module = "lambda"
    },
    var.ecr_repository.tags != null ? var.ecr_repository.tags : {}
  )
}

resource "aws_ecr_repository_policy" "lambda" {
  repository = aws_ecr_repository.lambda.name
  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Sid    = "LambdaECRImageRetrievalPolicy",
        Effect = "Allow",
        Principal = {
          Service = "lambda.amazonaws.com"
        },
        Action = [
          "ecr:BatchGetImage",
          "ecr:GetDownloadUrlForLayer"
        ]
      }
    ]
  })
}

################################################################################
# Lambda                                                                       #
################################################################################
resource "aws_lambda_function" "lambda" {
  function_name = var.lambda_function.function_name
  role          = aws_iam_role.lambda.arn

  architectures           = var.lambda_function.architectures
  code_signing_config_arn = var.lambda_function.code_signing_config_arn
  dynamic "dead_letter_config" {
    for_each = var.lambda_function.dead_letter_config != null ? [1] : []
    content {
      target_arn = var.lambda_function.dead_letter_config.target_arn
    }
  }
  description = var.lambda_function.description
  dynamic "environment" {
    for_each = var.lambda_function.environment != null ? [1] : []
    content {
      variables = var.lambda_function.environment
    }
  }
  dynamic "ephemeral_storage" {
    for_each = var.lambda_function.ephemeral_storage != null ? [1] : []
    content {
      size = var.lambda_function.ephemeral_storage.size
    }
  }
  dynamic "file_system_config" {
    for_each = var.lambda_function.file_system_config != null ? [1] : []
    content {
      arn              = var.lambda_function.file_system_config.arn
      local_mount_path = var.lambda_function.file_system_config.local_mount_path
    }
  }
  filename = var.lambda_function.filename
  handler  = var.lambda_function.handler
  dynamic "image_config" {
    for_each = var.lambda_function.image_config != null ? [1] : []
    content {
      command           = var.lambda_function.image_config.command
      entry_point       = var.lambda_function.image_config.entry_point
      working_directory = var.lambda_function.image_config.working_directory
    }
  }
  image_uri = (
    "${data.aws_caller_identity.self.account_id}.dkr.ecr.${data.aws_region.self.name
      }.amazonaws.com/${var.lambda_function.dummy_ecr_repository_name
    }:${var.lambda_function.dummy_ecr_repository_tag}"
  )
  kms_key_arn = var.lambda_function.kms_key_arn
  layers      = var.lambda_function.layers
  dynamic "logging_config" {
    for_each = var.lambda_function.logging_config != null ? [1] : []
    content {
      log_format = var.lambda_function.logging_config.log_format

      application_log_level = var.lambda_function.logging_config.application_log_level
      log_group             = var.lambda_function.logging_config.log_group
      system_log_level      = var.lambda_function.logging_config.system_log_level
    }
  }
  memory_size                        = var.lambda_function.memory_size
  package_type                       = var.lambda_function.package_type
  publish                            = var.lambda_function.publish
  reserved_concurrent_executions     = var.lambda_function.reserved_concurrent_executions
  replace_security_groups_on_destroy = var.lambda_function.replace_security_groups_on_destroy
  runtime                            = var.lambda_function.runtime
  s3_bucket                          = var.lambda_function.s3_bucket
  s3_key                             = var.lambda_function.s3_key
  s3_object_version                  = var.lambda_function.s3_object_version
  skip_destroy                       = var.lambda_function.skip_destroy
  source_code_hash                   = var.lambda_function.source_code_hash
  dynamic "snap_start" {
    for_each = var.lambda_function.snap_start != null ? [1] : []
    content {
      apply_on = var.lambda_function.snap_start.apply_on
    }
  }
  timeout = var.lambda_function.lambda_timeout
  dynamic "tracing_config" {
    for_each = var.lambda_function.tracing_config != null ? [1] : []
    content {
      mode = var.lambda_function.tracing_config.mode
    }
  }
  dynamic "vpc_config" {
    for_each = var.lambda_function.vpc_config != null ? [1] : []
    content {
      ipv6_allowed_for_dual_stack = var.lambda_function.vpc_config.ipv6_allowed_for_dual_stack
      security_group_ids          = var.lambda_function.vpc_config.security_group_ids
      subnet_ids                  = var.lambda_function.vpc_config.subnet_ids
    }
  }

  lifecycle {
    ignore_changes = [
      image_uri, last_modified
    ]
  }

  depends_on = [
    aws_cloudwatch_log_group.lambda
  ]

  tags = merge(
    {
      Name   = var.lambda_function.function_name
      Module = "lambda"
    },
    var.lambda_function.tags != null ? var.lambda_function.tags : {}
  )
}

resource "aws_lambda_alias" "lambda" {
  name             = var.lambda_alias.name
  function_name    = aws_lambda_function.lambda.arn
  function_version = aws_lambda_function.lambda.version

  description = var.lambda_alias.description
  dynamic "routing_config" {
    for_each = var.lambda_alias.routing_config != null ? [1] : []
    content {
      additional_version_weights = var.lambda_alias.routing_config.additional_version_weights
    }
  }

  lifecycle {
    ignore_changes = [
      function_version
    ]
  }
}

################################################################################
# IAM Role for Lambda                                                          #
################################################################################
resource "aws_iam_role" "lambda" {
  name               = var.lambda_execution_role.name
  assume_role_policy = jsonencode(var.lambda_execution_role.assume_role_policy)

  description           = var.lambda_execution_role.description
  force_detach_policies = var.lambda_execution_role.force_detach_policies
  max_session_duration  = var.lambda_execution_role.max_session_duration
  name_prefix           = var.lambda_execution_role.name_prefix
  path                  = var.lambda_execution_role.path
  permissions_boundary  = var.lambda_execution_role.permissions_boundary

  tags = merge(
    {
      Name   = var.lambda_execution_role.name
      Module = "lambda"
    },
    var.lambda_execution_role.tags != null ? var.lambda_execution_role.tags : {}
  )
}

resource "aws_iam_role_policy_attachment" "lambda" {
  role       = aws_iam_role.lambda.name
  policy_arn = aws_iam_policy.lambda.arn
}

resource "aws_iam_policy" "lambda" {
  name   = var.lambda_execution_policy.name
  policy = jsonencode(var.lambda_execution_policy.policy)

  description = var.lambda_execution_policy.description
  name_prefix = var.lambda_execution_policy.name_prefix
  path        = var.lambda_execution_policy.path

  tags = merge(
    {
      Name   = var.lambda_execution_policy.name
      Module = "lambda"
    },
    var.lambda_execution_policy.tags != null ? var.lambda_execution_policy.tags : {}
  )
}

################################################################################
# CloudWatch Logs                                                              #
################################################################################
resource "aws_cloudwatch_log_group" "lambda" {
  name = "/aws/lambda/${var.lambda_function.function_name}"

  name_prefix       = var.cloudwatch_log_group.name_prefix
  skip_destroy      = var.cloudwatch_log_group.skip_destroy
  log_group_class   = var.cloudwatch_log_group.log_group_class
  retention_in_days = var.cloudwatch_log_group.retention_in_days
  kms_key_id        = var.cloudwatch_log_group.kms_key_id

  tags = merge(
    {
      Name   = "/aws/lambda/${var.lambda_function.function_name}"
      Module = "lambda"
    },
    var.cloudwatch_log_group.tags != null ? var.cloudwatch_log_group.tags : {}
  )
}
service/main.tf
module "lambda" {
  source = "../../../modules/lambda"
  ecr_repository = {
    name        = "${var.sys_name}-${var.env}-repository"
    description = "ECR repository for ${var.sys_name} in ${var.env}"
  }

  lambda_function = {
    function_name             = "${var.sys_name}-${var.env}-lambda"
    description               = "Lambda function for ${var.sys_name} in ${var.env}"
    dummy_ecr_repository_name = "dummy-${var.env}-repository"
  }

  lambda_alias = {
    name        = var.env
    description = "Lambda alias for ${var.sys_name} in ${var.env}"
  }

  lambda_execution_role = {
    name        = "${var.sys_name}-${var.env}-lambda-role"
    description = "IAM role for Lambda function ${var.sys_name} in ${var.env}"
  }

  lambda_execution_policy = {
    name        = "${var.sys_name}-${var.env}-lambda-policy"
    description = "IAM policy for Lambda function ${var.sys_name} in ${var.env}"
  }
}

実行結果

正常

問題なく作成できました。(WARNINGは無視して良いものです。)
CleanShot 2025-04-22 at 17.20.20@2x.png

validation エラー

validation も意図した通り機能していました。(WARNINGは無視して良いものです。)
CleanShot 2025-04-22 at 17.21.49@2x.png

心残り

何事も完璧とはいかないもので、このルールに関しても若干の心残りがあります。
メモがてら記載しておきます。

  • IAMポリシーに関して、CloudWatchの対象リソース範囲が広範になってしまった。
    解消のためにはモジュール内でARNを動的に取得する必要があります。ただしこれを行なってしまうと、ポリシーそのものを動的に指定しづらくなるため、やむなくこのような形となりました。
    記事を書いていて merge を使えば解消できるかもと思いましたが、テンプレート視点でのポリシーの可読性が下がるのでやはりないかな…

  • terraform-doc を使用して作成した Readme.md の可読性が微妙。
    optional を使用した default値 が、Type に書き込まれてしまうため、見づらいです...
    CleanShot 2025-04-22 at 17.38.47@2x.png

    これについては下記の issue が存在していました。
    default オプションを使用すると今よりは見やすくなるかもしれません。
    (明日の自分に任せよう...)
    Add support for terraform 1.3.x optional variables · Issue #656 · terraform-docs/terraform-docs · GitHub

あとがき

Terraform をガシガシ書いてはいますが、可能であれば基本的にはTerraform Registryなどを活用できるといいですね。車輪の再発明は辛い...

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.