Posted on :: 3703 Words :: Tags: , ,

Variables, Outputs & State

初めての Terraform resource を書きました。コードは動きます。でも、すべてがハードコードされています。

同じインフラを staging と production にデプロイしたい?すべてをコピー&ペーストして手動で値を変更します。アプリの database endpoint が必要?インスタンスに SSH して探し回ります。クラウドアカウントに実際に何が存在するか知りたい?state ファイルがまだ残っていることを祈りましょう。

これではスケールしません。

この Part では、variables、outputs、state management を使って、柔軟で再利用可能なインフラを構築する方法を学びます。同じコードベースで複数の環境を管理。IP アドレスを探し回る必要はありません。「これ、もうデプロイしたっけ?」と悩むこともありません。

📦 Code Examples

Repository: terraform-hcl-tutorial-series 今回の Part: Part 5 - Variables and State

動作する例を取得:

git clone https://github.com/khuongdo/terraform-hcl-tutorial-series.git
cd terraform-hcl-tutorial-series
git checkout part-05
cd examples/part-05-variables-state/

# variable patterns を試してみる
terraform init
terraform plan -var-file="dev.tfvars"

問題点: すべてがハードコード

初心者の Terraform はこんな感じです:

# main.tf
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"  # staging で別の AMI を使いたい場合は?
  instance_type = "t3.medium"               # dev 環境で production サイズ?

  tags = {
    Name        = "prod-web-server"        # dev コードに "prod" をハードコード?
    Environment = "production"
    Owner       = "alice@company.com"      # Bob がデプロイする場合は?
  }
}

resource "aws_db_instance" "main" {
  engine         = "postgres"
  instance_class = "db.t3.large"           # dev と prod で同じサイズ?
  username       = "admin"
  password       = "supersecret123"        # これを Git にコミットしてませんよね?
}

何が問題なのでしょうか?

  1. 再利用性ゼロ (すべてを複製しないと staging にデプロイできない)
  2. 環境固有の値がコードに埋め込まれている
  3. インスタンスサイズを変更するにはコード変更が必要
  4. Git にパスワードが含まれている (セキュリティインシデントを楽しんでください)
  5. デプロイ後に database endpoint を取得する方法がない

実際のチームは数十の環境を管理します。このアプローチはすぐに破綻します。

解決策: Variables、Outputs、State

3つの概念がこの混乱を解決します:

  1. Input Variables: コードを柔軟にする
  2. Outputs: デプロイ後に重要な値を取得
  3. State: 実際に存在するものを追跡

このハードコードの災害を修正しましょう。

Input Variables: ハードコードをやめる

Variables はインフラをテンプレート化します。一度定義すれば、どこにでもデプロイできます。

基本的な Variables

# variables.tf
variable "instance_type" {
  type        = string
  description = "EC2 instance type for web servers"
  default     = "t3.small"
}

variable "environment" {
  type        = string
  description = "Deployment environment (dev, staging, prod)"
}

variable "enable_monitoring" {
  type        = bool
  description = "Enable CloudWatch detailed monitoring"
  default     = false
}

それでは使ってみましょう:

# main.tf
resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type
  monitoring    = var.enable_monitoring

  tags = {
    Name        = "${var.environment}-web-server"
    Environment = var.environment
  }
}

同じコード。環境ごとに異なる値。それがポイントです。

String 以外の Variable Types

Terraform は複雑な設定のために豊富な型をサポートしています:

# String
variable "region" {
  type    = string
  default = "us-east-1"
}

# Number
variable "instance_count" {
  type    = number
  default = 2
}

# Boolean
variable "enable_backups" {
  type    = bool
  default = true
}

# List (順序付きコレクション)
variable "availability_zones" {
  type    = list(string)
  default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

# Map (キー・バリュー ペア)
variable "instance_types" {
  type = map(string)
  default = {
    dev     = "t3.micro"
    staging = "t3.small"
    prod    = "t3.large"
  }
}

# Object (構造化データ)
variable "database_config" {
  type = object({
    engine         = string
    engine_version = string
    instance_class = string
    storage_gb     = number
    multi_az       = bool
  })

  default = {
    engine         = "postgres"
    engine_version = "15.4"
    instance_class = "db.t3.small"
    storage_gb     = 20
    multi_az       = false
  }
}

目的に応じて適切な型を使用します。順序付きデータには Lists、ルックアップには maps、構造化設定には objects を使います。

複雑な Variables の使用

# 環境ごとに異なる instance types
resource "aws_instance" "web" {
  instance_type = var.instance_types[var.environment]
  # dev -> t3.micro, prod -> t3.large
}

# availability zones にインスタンスを分散
resource "aws_subnet" "public" {
  count             = length(var.availability_zones)
  availability_zone = var.availability_zones[count.index]
}

# 構造化された database configuration を使用
resource "aws_db_instance" "main" {
  engine               = var.database_config.engine
  engine_version       = var.database_config.engine_version
  instance_class       = var.database_config.instance_class
  allocated_storage    = var.database_config.storage_gb
  multi_az             = var.database_config.multi_az
}

Validation: デプロイ前にエラーをキャッチ

validation のない variables は危険です。誰かが誤って environment = "production123" と設定するでしょう。validation はクラウドアカウントに到達する前にそれをキャッチします。

基本的な Validation

variable "environment" {
  type        = string
  description = "Deployment environment"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

environment = "production"terraform plan を実行:

Error: Invalid value for variable

  on variables.tf line 2:
   2: variable "environment" {

Environment must be dev, staging, or prod.

デプロイ前にタイプミスをキャッチしました。それが目的です。

高度な Validation

# region format を検証
variable "region" {
  type = string

  validation {
    condition     = can(regex("^[a-z]{2}-[a-z]+-[0-9]$", var.region))
    error_message = "Region must match format: us-east-1, eu-west-2, etc."
  }
}

# instance count の範囲を検証
variable "instance_count" {
  type = number

  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 10
    error_message = "Instance count must be between 1 and 10."
  }
}

# CIDR block format を検証
variable "vpc_cidr" {
  type = string

  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "Must be a valid IPv4 CIDR block."
  }
}

# クロスフィールド validation
variable "database_config" {
  type = object({
    instance_class = string
    multi_az       = bool
    storage_gb     = number
  })

  validation {
    condition = (
      var.database_config.multi_az == false ||
      var.database_config.storage_gb >= 100
    )
    error_message = "Multi-AZ databases require at least 100GB storage."
  }
}

Validation はドキュメントです。ユーザーにどの値が許容され、なぜそうなのかを正確に伝えます。

Variable Precedence: 値がどこから来るか

Terraform は複数のソースから variable 値を読み込みます。precedence を理解することで混乱を防ぎます。

Precedence チェーン (低から高)

  1. variable blocks のデフォルト値
  2. 環境変数 (TF_VAR_name)
  3. terraform.tfvars ファイル
  4. terraform.tfvars.json ファイル
  5. *.auto.tfvars ファイル (アルファベット順)
  6. -var-file=... コマンドラインフラグ
  7. -var=... コマンドラインフラグ

高い precedence が低いものを上書きします。CLI フラグが常に勝ちます。

Multi-Environment セットアップ

# ディレクトリ構造
terraform/
├── main.tf
├── variables.tf
├── terraform.tfvars         # デフォルト値
├── environments/
   ├── dev.tfvars          # Development overrides
   ├── staging.tfvars      # Staging overrides
   └── prod.tfvars         # Production overrides

terraform.tfvars (デフォルト):

region                = "us-east-1"
enable_monitoring     = false
backup_retention_days = 7

environments/prod.tfvars (production overrides):

instance_type         = "t3.large"
enable_monitoring     = true
backup_retention_days = 30
multi_az              = true

production にデプロイ:

terraform apply -var-file="environments/prod.tfvars"

同じコードベース、異なる設定。Netflix はこのパターンを使って 190 以上の国にデプロイしています。

Environment Variables

# environment 経由で variables を設定 (CI/CD で便利)
export TF_VAR_region="eu-west-1"
export TF_VAR_instance_count=5

terraform apply
# -var フラグを渡す必要なし

Command-Line Variables

# 個別の値を上書き
terraform apply \
  -var="environment=staging" \
  -var="instance_count=3"

CI/CD パイプラインでは environment variables を使用します。永続的な設定には .tfvars ファイルを使用します。

Outputs: 情報を探し回るのをやめる

インフラをデプロイした後、情報が必要です: database endpoints、load balancer URLs、IP addresses。Outputs がこれを解決します。

基本的な Outputs

# outputs.tf
output "instance_public_ip" {
  description = "Public IP address of web server"
  value       = aws_instance.web.public_ip
}

output "database_endpoint" {
  description = "RDS database connection endpoint"
  value       = aws_db_instance.main.endpoint
}

output "load_balancer_url" {
  description = "Application load balancer URL"
  value       = "https://${aws_lb.main.dns_name}"
}

terraform apply 後:

Outputs:

instance_public_ip = "54.123.45.67"
database_endpoint  = "mydb.abc123.us-east-1.rds.amazonaws.com:5432"
load_balancer_url  = "https://my-alb-123456.us-east-1.elb.amazonaws.com"

コピー&ペースト準備完了。AWS Console で探し回る必要はありません。

Sensitive Outputs

output "database_password" {
  description = "Master password for RDS instance"
  value       = aws_db_instance.main.password
  sensitive   = true
}

sensitive = true の場合:

terraform apply

Outputs:

database_password = <sensitive>

パスワードがログに漏れません。明示的に表示:

terraform output database_password
# 実際のパスワードを表示

常に secrets を sensitive としてマークします。CI/CD ログでの偶発的な露出を防ぎます。

構造化された Outputs

# 関連する outputs をグループ化
output "database_connection" {
  description = "Complete database connection information"
  value = {
    endpoint = aws_db_instance.main.endpoint
    port     = aws_db_instance.main.port
    database = aws_db_instance.main.db_name
    username = aws_db_instance.main.username
  }
  sensitive = true
}

スクリプトでのアクセス:

terraform output -json database_connection | jq '.endpoint'

Module Composition のための Outputs

modules を構築する際 (Part 7)、outputs が module のインターフェースになります:

# networking module outputs
output "vpc_id" {
  value = aws_vpc.main.id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

application module で使用:

module "networking" {
  source = "./modules/networking"
}

module "application" {
  source     = "./modules/application"
  vpc_id     = module.networking.vpc_id
  subnet_ids = module.networking.public_subnet_ids
}

Outputs が modules を接続します。インフラレイヤー間の API です。

State Management: Terraform のメモリ

Terraform は state ファイルでインフラを追跡します。これにより、何が存在し、何が変更され、何を更新する必要があるかを把握します。

State とは?

terraform apply を実行すると、Terraform は:

  1. .tf ファイルから desired state を読み取る
  2. terraform.tfstate から current state を読み取る
  3. それらを比較して plan を生成
  4. current = desired になるように変更を実行

state ファイルは Terraform のメモリです。これを失うと、Terraform はインフラの存在を忘れます。

State File の構造

EC2 インスタンスをデプロイした後、terraform.tfstate には以下が含まれます:

{
  "version": 4,
  "terraform_version": "1.7.0",
  "serial": 12,
  "lineage": "a1b2c3d4-e5f6-7890-abcd-1234567890ab",
  "outputs": {
    "instance_ip": {
      "value": "54.123.45.67",
      "type": "string"
    }
  },
  "resources": [
    {
      "mode": "managed",
      "type": "aws_instance",
      "name": "web",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
      "instances": [
        {
          "schema_version": 1,
          "attributes": {
            "id": "i-0abcd1234efgh5678",
            "ami": "ami-0c55b159cbfafe1f0",
            "instance_type": "t3.medium",
            "public_ip": "54.123.45.67",
            "private_ip": "10.0.1.42"
          }
        }
      ]
    }
  ]
}

主要な要素:

  • version: State ファイル形式のバージョン
  • serial: 変更ごとに増加 (並行変更を検出)
  • lineage: state ファイルの混同を防ぐユニーク ID
  • outputs: キャッシュされた output 値
  • resources: 完全な resource 属性 (IDs、IPs、ARNs)

Local vs Remote State

Local state (デフォルト):

# state は current directory の terraform.tfstate に保存
terraform apply
# ./terraform.tfstate を作成/更新

local state の問題点:

  1. コラボレーションなし (チームメンバーが state を共有できない)
  2. ロックなし (並行 terraform apply が破損を引き起こす)
  3. バックアップなし (ファイルを削除 = インフラ追跡を失う)
  4. secrets が平文 (state にパスワード、キーが含まれる)

Remote state (production アプローチ):

# backend.tf
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"  # State locking
  }
}

メリット:

  • 共有アクセス (チームが同じ state を使用)
  • ロック (DynamoDB が並行変更を防止)
  • 暗号化 (state が保存時に暗号化)
  • バージョニング (S3 バージョニングでロールバック可能)

Part 9 で remote state backends をカバーします。今のところ: production では local state を使用しないでください。

State Locking

2人のエンジニアが同時に terraform apply を実行したとします:

  1. 両方が current state を読む: "2 instances が存在"
  2. 両方が 1 つ追加の instance を作成する計画
  3. 両方が変更を適用
  4. 結果: 3 ではなく 4 instances (競合状態)

state locking がこれを防ぎます:

# Engineer 1
terraform apply
Acquiring state lock. This may take a few moments...
Lock acquired (ID: abc-123)

# Engineer 2 (同時に)
terraform apply
Acquiring state lock. This may take a few moments...
Error: Error locking state: ConditionalCheckFailedException
Lock Info:
  ID:        abc-123
  Operation: OperationTypeApply
  Who:       engineer1@laptop
  Created:   2026-03-12 14:32:15 UTC

Engineer 2 の apply は Engineer 1 が終了するまでブロックされます。競合状態はありません。

Sensitive Data: パスワードを漏らさない

Terraform state にはインフラに関するすべてが含まれ、secrets も含まれます。慎重に取り扱ってください。

問題

resource "aws_db_instance" "main" {
  password = "supersecret123"  # これはやめましょう
}

terraform apply 後、terraform.tfstate には以下が含まれます:

{
  "resources": [{
    "instances": [{
      "attributes": {
        "password": "supersecret123"  # state に平文
      }
    }]
  }]
}

state ファイルにアクセスできる人は誰でもデータベースパスワードを持っています。

解決策 1: Variables を Sensitive としてマーク

variable "db_password" {
  type      = string
  sensitive = true
}

Terraform はログの sensitive 値を隠します:

terraform plan

  # aws_db_instance.main will be created
  + resource "aws_db_instance" "main" {
      + password = (sensitive value)
    }

しかし state には平文が含まれます。これはログ漏洩を防ぐだけです。

解決策 2: 暗号化された Remote State

terraform {
  backend "s3" {
    bucket  = "my-state-bucket"
    key     = "terraform.tfstate"
    encrypt = true  # state を保存時に暗号化

    # 追加のセキュリティのための KMS 暗号化
    kms_key_id = "arn:aws:kms:us-east-1:123456789:key/abc-123"
  }
}

state は S3 で暗号化されます。読み取るには AWS KMS 権限が必要です。

解決策 3: 外部 Secrets Management (ベストプラクティス)

Terraform に secrets を保存しないでください。実行時に取得します:

# AWS Secrets Manager からパスワードを取得
data "aws_secretsmanager_secret_version" "db_password" {
  secret_id = "prod/database/master-password"
}

resource "aws_db_instance" "main" {
  password = data.aws_secretsmanager_secret_version.db_password.secret_string
}

secret は Secrets Manager に保存され、Git にはありません。state にはまだ含まれますが、保存時に暗号化されます。

Part 11 で HashiCorp Vault 統合をカバーします。今のところ: secrets は secret stores に属し、コードには属しません。

Real-World Pattern: Multi-Environment セットアップ

すべてをまとめてみましょう:

terraform/
├── main.tf                    # Core infrastructure
├── variables.tf               # Variable declarations
├── outputs.tf                 # Output definitions
├── backend.tf                 # Remote state configuration
├── terraform.tfvars           # Default values
└── environments/
    ├── dev.tfvars            # Development overrides
    ├── staging.tfvars        # Staging overrides
    └── prod.tfvars           # Production overrides

variables.tf:

variable "environment" {
  type        = string
  description = "Deployment environment"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Must be dev, staging, or prod."
  }
}

variable "instance_config" {
  type = object({
    type  = string
    count = number
  })
  description = "EC2 instance configuration"

  validation {
    condition     = var.instance_config.count >= 1 && var.instance_config.count <= 20
    error_message = "Instance count must be 1-20."
  }
}

variable "enable_multi_az" {
  type        = bool
  description = "Enable multi-AZ deployment for high availability"
  default     = false
}

main.tf:

resource "aws_instance" "web" {
  count         = var.instance_config.count
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_config.type

  tags = {
    Name        = "${var.environment}-web-${count.index + 1}"
    Environment = var.environment
  }
}

resource "aws_db_instance" "main" {
  engine            = "postgres"
  instance_class    = var.environment == "prod" ? "db.t3.large" : "db.t3.small"
  multi_az          = var.enable_multi_az

  # Secrets Manager からのパスワード
  password = data.aws_secretsmanager_secret_version.db_password.secret_string
}

outputs.tf:

output "web_server_ips" {
  description = "Public IPs of web servers"
  value       = aws_instance.web[*].public_ip
}

output "database_endpoint" {
  description = "Database connection endpoint"
  value       = aws_db_instance.main.endpoint
  sensitive   = true
}

environments/prod.tfvars:

environment = "prod"

instance_config = {
  type  = "t3.large"
  count = 5
}

enable_multi_az = true

production にデプロイ:

terraform apply -var-file="environments/prod.tfvars"

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

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

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

Outputs:

web_server_ips = [
  "54.123.45.67",
  "54.123.45.68",
  "54.123.45.69",
  "54.123.45.70",
  "54.123.45.71"
]
database_endpoint = <sensitive>

dev、staging、prod で同じコード。variables だけが異なります。

Quick Check: 理解できましたか?

Part 6 に進む前に、これらの質問に答えられますか?

  1. list(string)map(string) の違いは?

    • Lists は順序付き、インデックスでアクセス ([0][1])。Maps はキー・バリューペア、キーでアクセス (["dev"]["prod"])。
  2. どちらが勝つ: terraform.tfvars vs -var CLI フラグ?

    • CLI フラグが最高の precedence を持ちます。-var はすべてを上書きします。
  3. なぜ outputs を sensitive = true としてマーク?

    • secrets がコンソール出力と CI/CD ログに表示されるのを防ぎます。表示するには明示的に terraform output <name> を強制します。
  4. state ファイルを失った場合どうなる?

    • Terraform はインフラの存在を忘れます。次の apply はすべてを再作成しようとし、競合を引き起こします。
  5. production secrets はどこに保存すべき?

    • 外部 secret stores (AWS Secrets Manager、HashiCorp Vault)。実行時に data sources 経由で参照します。

これらの答えに自信があれば、Part 6 の準備ができています。

次は何?

Part 6: Core Terraform Workflow では、開発サイクルをマスターします:

  • terraform init: providers と modules を初期化
  • terraform plan: apply 前に変更をプレビュー
  • terraform apply: インフラを安全にデプロイ
  • terraform destroy: リソースをクリーンに削除
  • terraform refresh: state を現実と同期
  • terraform taint: リソースの再作成を強制

さらに、drift の処理、障害からの回復、plan のデバッグのワークフローも。

Terraform 開発ループを学ぶ準備はできましたか? Part 6 へ続く → (coming soon)


Resources

連載ナビゲーション:


この記事は "Terraform from Fundamentals to Production" シリーズの一部です。