
Terraformモジュールの動的入力に関するコーディングルールを考えてみる
こんにちは!コンサルティング部のくろすけです!
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より引用
基本的なルール
個人的には、シンプルなルールにしたつもりです。
- resourceとvarialbeの対応関係は 1:1 とする(resource : variable = 1 : 1)
可読性が向上すると考えました。 - rule1のため、オプションスキーマは variable = resource とする
可読性が向上すると考えました。resource のオプションと対応させておくことで、確認などもしやすいかと思います。 - rule2のため、任意オプションのオブジェクトタイプは原則 optional を使用する
どうやら default オプションは、variable が object タイプの場合、object の要素一部だけに適応というわけにはいかないようです。よってそもそも optional を使用するしかないというわけでした。 - モジュール固有の入力制御が必要な場合は、validation を使用する
こちらは validation Block で記載した通りです。 - 全てのオプションが任意の場合は default を使用する
optional は variable ブロックの type には直接記述できないため、default を使用する必要があります。 - 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は無視して良いものです。)
validation エラー
validation も意図した通り機能していました。(WARNINGは無視して良いものです。)
心残り
何事も完璧とはいかないもので、このルールに関しても若干の心残りがあります。
メモがてら記載しておきます。
-
IAMポリシーに関して、CloudWatchの対象リソース範囲が広範になってしまった。
解消のためにはモジュール内でARNを動的に取得する必要があります。ただしこれを行なってしまうと、ポリシーそのものを動的に指定しづらくなるため、やむなくこのような形となりました。
記事を書いていて merge を使えば解消できるかもと思いましたが、テンプレート視点でのポリシーの可読性が下がるのでやはりないかな… -
terraform-doc を使用して作成した Readme.md の可読性が微妙。
optional を使用した default値 が、Type に書き込まれてしまうため、見づらいです...
これについては下記の issue が存在していました。
default オプションを使用すると今よりは見やすくなるかもしれません。
(明日の自分に任せよう...)
Add support for terraform 1.3.x optional variables · Issue #656 · terraform-docs/terraform-docs · GitHub
あとがき
Terraform をガシガシ書いてはいますが、可能であれば基本的にはTerraform Registryなどを活用できるといいですね。車輪の再発明は辛い...