Posted on :: 3461 Words :: Tags: , ,

HCL 基礎

これまで3回にわたって Terraform コードを書いてきました。でも、自分が何を書いているか本当に理解していますか?

resourcedata違いや、list(string)map(string)使い分けを知らずに、ただ例をコピーしているなら、この Part で全て解決します。今回は HashiCorp Configuration Language (HCL) — Terraform を動かす言語について深く掘り下げていきます。

📦 Code Examples

Repository: terraform-hcl-tutorial-series 今回の Part: Part 4 - HCL Syntax Examples

動作する例を取得:

git clone https://github.com/khuongdo/terraform-hcl-tutorial-series.git
cd terraform-hcl-tutorial-series
git checkout part-04
cd examples/part-04-hcl-fundamentals/

# 例を試してみる
terraform init
terraform plan

HCL が存在する理由

HCL は JSON ではありません。YAML でもありません。HashiCorp はインフラコード専用に開発しました。この選択は、思っている以上に重要です。

JSON は冗長で、コメントをサポートせず、人間の読者を苦しめます。YAML は見た目はきれいですが、インデント地獄に陥り、目に見えないスペース1つでパイプラインが失敗することになります。HCL はより良いものを提供します:コードのように読めるが、インフラを定義する言語です。

得られるもの:

  • ファイルに実際に残るコメント
  • 静的文字列の代わりに式と関数
  • terraform apply が週末を台無しにする前にエラーをキャッチする強い型付け
  • 動的な設定を可能にする補間
  • 純粋な JSON では不可能なパターンのための条件分岐とループ

重要なポイント: HCL を理解すると、Terraform の書き方が変わります。Stack Overflow からコピー&ペーストするのをやめて、インフラを設計し始めます。

HCL の仕組み

すべての Terraform ファイルは3つの要素から構成されています:blocksargumentsexpressions。これだけです。

Blocks

Block はコンテナです。型があり、ラベルがある場合もあり、本体は中括弧で囲まれます:

<BLOCK_TYPE> "<LABEL>" "<LABEL>" {
  # Arguments がここに入る
  <IDENTIFIER> = <EXPRESSION>
}

実際の例:

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
}

variable "region" {
  type    = string
  default = "us-west-2"
}

output "instance_ip" {
  value = aws_instance.web.public_ip
}

頻繁に使用する block types:

  • resource インフラを作成
  • data 既存のものを読み取る
  • variable 入力を宣言
  • output 値を公開
  • locals 計算された値を定義
  • module 再利用可能なモジュールを呼び出す
  • terraform Terraform 自体を設定
  • provider クラウドプロバイダーの認証情報を設定

Arguments

Arguments は値を割り当てます。左側が名前、右側が式です:

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"  # String
  instance_type = var.instance_type        # Variable reference
  count         = 3                        # Number
}

規約: 1行に1つの argument。diff が読みやすくなります。

Expressions

ここで HCL が便利になります。Expressions は値を計算します:

# リテラル
"hello"
42
true

# 参照
var.instance_type
aws_vpc.main.id

# 算術演算
var.instance_count * 2

# 文字列補間
"Instance: ${var.name}"

# 条件分岐
var.env == "prod" ? "t3.large" : "t3.micro"

# 関数
file("${path.module}/userdata.sh")
jsonencode({ name = "app", port = 8080 })

# ループ
[for s in var.subnets : s.id]

すべてをハードコードする代わりに、コンテキストに基づいて値を計算します。これがパワームーブです。

型システム

HCL には強い型があります。これは良いことです。深夜3時のデプロイ時ではなく、早い段階でエラーをキャッチできるからです。

プリミティブ型

String — テキスト値

variable "region" {
  type    = string
  default = "us-west-2"
}

# 複数行文字列は heredoc を使用
variable "user_data" {
  type    = string
  default = <<-EOF
    #!/bin/bash
    echo "Hello, World!"
  EOF
}

Number — 整数と浮動小数点数 (HCL は区別しません)

variable "instance_count" {
  type    = number
  default = 3
}

variable "disk_size" {
  type    = number
  default = 100.5
}

Bool — true または false

variable "enable_monitoring" {
  type    = bool
  default = true
}

# 一般的なパターン: 条件付きリソース
resource "aws_cloudwatch_alarm" "cpu" {
  count = var.enable_monitoring ? 1 : 0
  # ...
}

複合型

List — 順序付きコレクション、すべての要素が同じ型

variable "availability_zones" {
  type    = list(string)
  default = ["us-west-2a", "us-west-2b", "us-west-2c"]
}

# インデックスでアクセス
locals {
  first_az = var.availability_zones[0]
}

# count でループ
resource "aws_subnet" "public" {
  count             = length(var.availability_zones)
  availability_zone = var.availability_zones[count.index]
  cidr_block        = "10.0.${count.index}.0/24"
}

Map — key-value ペア (キーは常に文字列)

variable "tags" {
  type = map(string)
  default = {
    Environment = "production"
    Owner       = "platform-team"
    CostCenter  = "engineering"
  }
}

# キーでアクセス
locals {
  env = var.tags["Environment"]
}

# マップをマージ
locals {
  all_tags = merge(
    var.tags,
    { ManagedBy = "terraform" }
  )
}

Set — 順序なし、一意の値

variable "allowed_ports" {
  type    = set(number)
  default = [80, 443, 8080]
}

# for_each で使用
resource "aws_security_group_rule" "ingress" {
  for_each = var.allowed_ports

  type        = "ingress"
  from_port   = each.value
  to_port     = each.value
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

Object — 型付き属性を持つ構造化データ

variable "server_config" {
  type = object({
    instance_type = string
    ami_id        = string
    root_volume   = object({
      size = number
      type = string
    })
    tags = map(string)
  })

  default = {
    instance_type = "t3.micro"
    ami_id        = "ami-0c55b159cbfafe1f0"
    root_volume = {
      size = 20
      type = "gp3"
    }
    tags = {
      Name = "web-server"
    }
  }
}

# ネストされた属性にアクセス
locals {
  volume_size = var.server_config.root_volume.size
}

Tuple — 固定長リスト、各位置に特定の型

variable "connection_info" {
  type = tuple([string, number, bool])
  # 正確に: [string, number, bool]
  default = ["db.example.com", 5432, true]
}

locals {
  host    = var.connection_info[0]
  port    = var.connection_info[1]
  use_ssl = var.connection_info[2]
}

Any — 何でも受け入れる (控えめに使用)

variable "custom_config" {
  type    = any
  default = {}
}

Type Safety Tip

本当に必要な場合を除いて any を避けてください。型チェックをバイパスし、デバッグが困難になります。構造化データがある場合は、代わりに object({...}) を使用してください。

文字列補間とディレクティブ

${} で文字列内に式を埋め込みます:

locals {
  region      = "us-west-2"
  environment = "production"

  bucket_name = "${var.project}-${local.environment}-logs"
  # 結果: "myapp-production-logs"
}

文字列ディレクティブ (%{}) で文字列内に制御フローを使用:

locals {
  user_data = <<-EOF
    #!/bin/bash
    %{ for ip in var.server_ips ~}
    echo "Server: ${ip}"
    %{ endfor ~}
  EOF
}

文字列内の条件分岐:

locals {
  greeting = <<-EOT
    Hello, ${var.username}!
    %{ if var.is_admin }
    You have admin privileges.
    %{ else }
    You have standard access.
    %{ endif }
  EOT
}

演算子

算術演算と論理演算はほとんどの言語と同様に機能します:

# 算術演算
var.count + 5
var.price * 1.2
var.total / var.quantity

# 比較
var.count > 10
var.env == "prod"
var.enabled != false

# 論理演算
var.is_prod && var.enable_monitoring
var.is_dev || var.is_staging
!var.disabled

組み込み関数

Terraform は100以上の関数を提供しています。カスタム関数は書けません — 提供されているものを使います。実際に使用するものは以下です。

ファイル関数

file() ファイルを文字列として読み取ります:

locals {
  user_data = file("${path.module}/scripts/init.sh")
}

filebase64() ファイルを base64 として読み取ります:

resource "aws_s3_object" "config" {
  bucket  = aws_s3_bucket.app.id
  key     = "config.json"
  content = filebase64("${path.module}/config.json")
}

templatefile() 変数を使ってテンプレートをレンダリング:

# templates/userdata.tpl
#!/bin/bash
echo "Environment: ${environment}"
echo "Region: ${region}"

# main.tf
locals {
  user_data = templatefile("${path.module}/templates/userdata.tpl", {
    environment = var.environment
    region      = var.region
  })
}

パス参照

path.module   # 現在のモジュールディレクトリ
path.root     # ルートモジュールディレクトリ
path.cwd      # 現在の作業ディレクトリ

エンコーディング関数

jsonencode() HCL 値を JSON に変換:

locals {
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "s3:GetObject"
      Resource = "arn:aws:s3:::my-bucket/*"
    }]
  })
}

jsondecode() JSON 文字列を解析:

locals {
  config_data = jsondecode(file("config.json"))
  api_url     = local.config_data.api.url
}

yamlencode()yamldecode() は YAML に対して同じことをします:

locals {
  deployment = yamldecode(file("k8s/deployment.yaml"))
  replicas   = local.deployment.spec.replicas
}

コレクション関数

length() 要素を数えます:

length(["a", "b", "c"])  # 3
length({ key = "value" })  # 1

concat() リストをマージ:

concat(["a", "b"], ["c", "d"])  # ["a", "b", "c", "d"]

merge() マップを結合:

merge(
  { name = "app" },
  { env = "prod" }
)
# 結果: { name = "app", env = "prod" }

lookup() フォールバック付きでマップ値を取得:

lookup(var.tags, "Environment", "unknown")
# 存在すれば var.tags["Environment"] を返し、なければ "unknown" を返す

keys()values() マップから抽出:

keys({ a = 1, b = 2 })    # ["a", "b"]
values({ a = 1, b = 2 })  # [1, 2]

element() インデックスで要素を取得 (ラップアラウンド):

element(["a", "b", "c"], 0)  # "a"
element(["a", "b", "c"], 5)  # "c" (5 % 3 = 2)

contains() リストに値が含まれているかチェック:

contains(["prod", "staging"], var.env)  # true/false

distinct() 重複を削除:

distinct(["a", "b", "a", "c"])  # ["a", "b", "c"]

flatten() ネストを解除:

flatten([["a", "b"], ["c", "d"]])  # ["a", "b", "c", "d"]

sort() リストをソート:

sort(["c", "a", "b"])  # ["a", "b", "c"]

文字列関数

split()join() 文字列とリスト間で変換:

split(",", "a,b,c")      # ["a", "b", "c"]
join("-", ["a", "b"])    # "a-b"

upper()lower() 大文字小文字を変更:

upper("hello")  # "HELLO"
lower("WORLD")  # "world"

replace() 部分文字列を置換:

replace("hello-world", "-", "_")  # "hello_world"

trimspace() 空白を削除:

trimspace("  hello  ")  # "hello"

format() printf スタイルのフォーマット:

format("Instance %03d in %s", 42, "us-west-2")
# "Instance 042 in us-west-2"

条件分岐

三項演算子は便利です:

condition ? true_value : false_value

実際の使用例:

resource "aws_instance" "web" {
  instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"

  tags = {
    Environment = var.environment == "prod" ? "Production" : "Development"
  }
}

チェーンすることもできますが、すぐに煩雑になります。代わりに locals を使用してください:

locals {
  instance_type = (
    var.environment == "prod" ? "t3.large" :
    var.environment == "staging" ? "t3.medium" :
    "t3.micro"
  )
}

count を使った条件付きリソース:

resource "aws_cloudwatch_alarm" "cpu" {
  count = var.enable_monitoring ? 1 : 0
  # true なら 1 を作成、false なら 0 (リソースなし)
  # ...
}

For 式とループ

For 式はコレクションを変換します。Python のリスト内包表記や JavaScript の map/filter のようなものです。

リストの変換

variable "names" {
  default = ["alice", "bob", "charlie"]
}

locals {
  # 大文字に変換
  upper_names = [for name in var.names : upper(name)]
  # ["ALICE", "BOB", "CHARLIE"]

  # フィルタと変換
  short_names = [for name in var.names : name if length(name) < 6]
  # ["alice", "bob"]
}

マップの変換

variable "instances" {
  default = {
    web1 = "t3.micro"
    web2 = "t3.small"
    db   = "t3.large"
  }
}

locals {
  # 値を変換
  instance_sizes = {
    for name, type in var.instances :
    name => upper(type)
  }
  # { web1 = "T3.MICRO", web2 = "T3.SMALL", db = "T3.LARGE" }

  # マップをフィルタ
  web_instances = {
    for name, type in var.instances :
    name => type
    if substr(name, 0, 3) == "web"
  }
  # { web1 = "t3.micro", web2 = "t3.small" }
}

オブジェクトから抽出

variable "servers" {
  type = list(object({
    name = string
    size = string
  }))

  default = [
    { name = "web", size = "small" },
    { name = "db", size = "large" },
  ]
}

locals {
  # 属性を抽出
  server_names = [for s in var.servers : s.name]
  # ["web", "db"]

  # リストからマップを構築
  server_map = {
    for s in var.servers :
    s.name => s.size
  }
  # { web = "small", db = "large" }
}

for_each によるリソースの反復

セットから複数のリソースを作成:

variable "buckets" {
  type = set(string)
  default = ["logs", "backups", "artifacts"]
}

resource "aws_s3_bucket" "app" {
  for_each = var.buckets

  bucket = "${var.project}-${each.key}"

  tags = {
    Name = each.value
  }
}

# 参照: aws_s3_bucket.app["logs"].id

またはマップから:

variable "environments" {
  type = map(object({
    instance_type = string
    instance_count = number
  }))

  default = {
    dev = {
      instance_type  = "t3.micro"
      instance_count = 1
    }
    prod = {
      instance_type  = "t3.large"
      instance_count = 3
    }
  }
}

module "app" {
  for_each = var.environments
  source   = "./modules/app"

  environment    = each.key
  instance_type  = each.value.instance_type
  instance_count = each.value.instance_count
}

for_each vs count

アイテムに一意の識別子がある場合 (マップキー、セット値) は for_each を使用してください。リソースはキーでアドレス指定されます: aws_instance.web["server1"]

数値インデックスによる単純な繰り返しには count を使用してください。リソースはインデックスでアドレス指定されます: aws_instance.web[0]

可能な限り for_each を優先してください。リストの途中からアイテムを削除する際、リソースが再番号付けされないため、より安全です。

すべてをまとめる

これまでカバーしたすべてを使って、実際のものを構築しましょう:

# variables.tf
variable "environment" {
  type    = string
  default = "dev"
}

variable "allowed_ingress_rules" {
  type = list(object({
    port        = number
    protocol    = string
    cidr_blocks = list(string)
    description = string
  }))

  default = [
    {
      port        = 80
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = "HTTP from anywhere"
    },
    {
      port        = 443
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = "HTTPS from anywhere"
    },
    {
      port        = 22
      protocol    = "tcp"
      cidr_blocks = ["10.0.0.0/8"]
      description = "SSH from internal"
    }
  ]
}

# main.tf
locals {
  # 共通タグ
  common_tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    Project     = "web-app"
    CostCenter  = var.environment == "prod" ? "production" : "development"
  }

  # production で SSH をフィルタ
  ingress_rules = [
    for rule in var.allowed_ingress_rules :
    rule
    if !(var.environment == "prod" && rule.port == 22)
  ]

  # for_each のためのルール名を生成
  rule_names = {
    for idx, rule in local.ingress_rules :
    "${rule.protocol}-${rule.port}" => rule
  }
}

resource "aws_security_group" "web" {
  name        = "${var.environment}-web-sg"
  description = "Security group for ${var.environment} web servers"

  tags = merge(
    local.common_tags,
    { Name = "${var.environment}-web-sg" }
  )
}

resource "aws_security_group_rule" "ingress" {
  for_each = local.rule_names

  security_group_id = aws_security_group.web.id
  type              = "ingress"
  from_port         = each.value.port
  to_port           = each.value.port
  protocol          = each.value.protocol
  cidr_blocks       = each.value.cidr_blocks
  description       = each.value.description
}

# outputs.tf
output "security_group_id" {
  value       = aws_security_group.web.id
  description = "ID of the web security group"
}

output "allowed_ports" {
  value = [for rule in local.ingress_rules : rule.port]
  description = "List of allowed ingress ports"
}

何が起こっているか:

  1. 環境に基づく条件付きタグ
  2. ルールのフィルタリング (production では SSH なし)
  3. for_each のためにリストをマップに変換
  4. データから動的リソースを作成
  5. 最終的なルールセットから計算された出力

これが infrastructure as real code です。設定ファイルではなく — 実際のプログラミングです。

理解度チェック

先に進む前に、以下の質問に答えてください:

Checkpoint!

  1. argument と expression の違いは何ですか?
  2. list(string)set(string) をいつ使い分けますか?
  3. 1行でファイルを読み取って JSON として解析するにはどうしますか?
  4. 複数のリソースを管理する際、for_eachcount より安全な理由は?
  5. キーが存在しない場合、var.tags.Environment にアクセスするとどうなりますか?
  6. 5文字より長いアイテムのみを含むようにリストをフィルタするにはどうしますか?
  7. 条件分岐を含む複数行文字列の構文は何ですか?
  8. HCL でカスタム関数を定義できますか?

答え:

  1. Argument は key = value; expression は値 (右側)
  2. list(string) は順序を保持し、重複を許可; set(string) は順序なしで一意性を強制
  3. jsondecode(file("config.json"))
  4. for_each は安定したキーを使用; count はアイテムが削除されるとシフトするインデックスを使用
  5. エラー。安全のため lookup(var.tags, "Environment", "default") を使用
  6. [for s in var.items : s if length(s) > 5]
  7. heredoc 文字列内で %{ if condition }...%{ endif } を使用
  8. いいえ。HCL は組み込み関数のみを提供。再利用性にはモジュールを使用

次のステップ

HCL の仕組みが理解できました。Part 5 では、この知識を使って variables と outputs で柔軟で再利用可能な設定を構築します。

学ぶこと:

  • 入力変数の検証と制約
  • センシティブな変数の処理
  • 出力の依存関係とデータフロー
  • 変数の優先順位とオーバーライド戦略
  • 実際のシナリオのための複雑な変数構造

違いは何ですか? もう変数の例をコピーするだけではありません。型レベルで正しさを強制する変数スキーマを設計します。


連載ナビゲーション: