🍏

入門 Terraform Module

2022/08/14に公開

概要

個人の備忘録としてTerraformのModuleについてまとめていきます。

参考
https://www.terraform.io/language/modules

https://learn.hashicorp.com/tutorials/terraform/module?in=terraform/modules

Module導入前の課題

Terraformでインフラを管理していくにつれて、管理するインフラが増えてTerraform構成ファイルが複雑になっていきます。
固定の規則もなく一つの構成ファイルにて管理することで次のような課題につき当たります。

  • 構成ファイルの理解とナビゲーションが難しくなる点
  • 構成ファイルの更新がよりリスキーになる点。1つのセクションの更新が意図せず他の部分に影響を及ぼす恐れがあります
  • 構成ファイル内に似たようなブロックの重複が増えていく点。例えば、dev/staging/productionの各環境を区分けする場合、これら環境の更新作業が負荷になります
  • チームやプロジェクト間で構成ファイルの部分を共有しようと思い、構成ファイルをカッティングしたりペースティングした際にエラーが発生しメンテナンスが難しくなります

Module導入の利点

Moduleの導入により全セクションで紹介した課題が次のように解決することが見込めます

  • 構成ファイルの組織化:関連のあるリソースを一つにまとめることで構成ファイルのナビゲーションや理解、そして更新作業が容易になります。モジュールを使うことで構成ファイルを論理的なコンポーネントに組織化して管理することができます。
  • 構成ファイルの再利用:モジュールによる再利用化により時間の節約とエラーの発生を低減させることができます。
  • 一貫性を提供しベストプラクティスを保証化してくれる点:一貫性が複雑な構成ファイルの理解を容易にしてくれるだけでなく、構成ファイル全体にわたってベストプラクティスが適用されることを高めてくれます。

Terraform Moduleについて

Terraform Moduleは1つのディレクトリにおけるTerraform構成ファイルの一連の集まりです。

Root Module

Terraformコマンドをあるディレクトリ内で実行した際にそのModuleが root moduleとみなされます。
例えば次のような最小構成の1つのディレクトリでTerraformコマンドを実行している場合、そのディレクトリがroot moduleになります。

.
├── LICENSE
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf

Moduleの呼び出し

前節のRoot Moduleはただ1つのModuleだけですが、そのModuleから別のModuleを参照することもできます。この呼び出される側のModuleのことはchild moduleと呼ばれたりします。

localとremote module

Moduleはローカルのファイルシステムや外部ソースから参照することができます。
Terrafromは多くのremote moduleを提供しており、その1つにTerraform Registryがあります。

Terraform Registryのmodule利用

https://learn.hashicorp.com/tutorials/terraform/module-use?in=terraform/modules

ここでは下記のaws vpc moduleを利用します。

https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/3.14.0

ページに記載の通り、構成ファイル例では二つの引数sourceargumentが記載されています。

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "3.14.0"
}

1: source:この例ではTerraform Registry内で指定されたパスに合致するものをソースとします。そのほかにURLやローカルmoduleを指定できます。

https://www.terraform.io/language/modules/sources

2: version: moduleのversionを指定します。未指定の場合は最新バージョンのmoduleを読み込みます。

構成ファイル例

main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.4.0"
    }
  }
  required_version = ">= 1.1.0"
}

provider "aws" {
  region = "ap-northeast-1"

  default_tags {
    tags = {
      hashicorp-learn = "module-use"
    }
  }
}
resource.tf
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "3.14.0"

  name = var.vpc_name
  cidr = var.vpc_cidr

  azs             = var.vpc_azs
  private_subnets = var.vpc_private_subnets
  public_subnets  = var.vpc_public_subnets

  enable_nat_gateway = var.vpc_enable_nat_gateway

  tags = var.vpc_tags
}

module "ec2_instances" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "3.5.0"
  count   = 2

  name = "my-ec2-cluster"

  ami                    = "ami-0ecb2a61303230c9d"
  instance_type          = "t2.micro"
  vpc_security_group_ids = [module.vpc.default_security_group_id]
  subnet_id              = module.vpc.public_subnets[0]

  tags = {
    Terraform   = "true"
    Environment = "dev"
  }
}
variables.tf
variable "vpc_name" {
  description = "Name of VPC"
  type        = string
  default     = "example-vpc"
}

variable "vpc_cidr" {
  description = "CIDR block for VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "vpc_azs" {
  description = "Availability zones for VPC"
  type        = list(string)
  default     = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"]
}

variable "vpc_private_subnets" {
  description = "Private subnets for VPC"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "vpc_public_subnets" {
  description = "Public subnets for VPC"
  type        = list(string)
  default     = ["10.0.101.0/24", "10.0.102.0/24"]
}

variable "vpc_enable_nat_gateway" {
  description = "Enable NAT gateway for VPC"
  type        = bool
  default     = true
}

variable "vpc_tags" {
  description = "Tags to apply to resources created by VPC module"
  type        = map(string)
  default = {
    Terraform   = "true"
    Environment = "dev"
  }
}
outputs.tf
output "vpc_public_subnets" {
  description = "IDs of the VPC's public subnets"
  value       = module.vpc.public_subnets
}

output "ec2_instance_public_ips" {
  description = "Public IP addresses of EC2 instances"
  value       = module.ec2_instances[*].public_ip
}

次のコマンドでterraformリソースの初期化、反映を行っていきます。

# 初期化
terraform init

# 差分確認
terraform plan

# 環境反映
terraform apply

反映後、リソースの作成を確認できたら削除を実行します。

terraform destroy

local moduleの作成

全セクションの remote registryを使った例に加えて、
下記の構成ファイルを作成していきます。

modules/s3/main.tf
resource "aws_s3_bucket" "s3_bucket" {
  bucket = var.bucket_name

  tags = var.tags
}

resource "aws_s3_bucket_website_configuration" "s3_bucket" {
  bucket = aws_s3_bucket.s3_bucket.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "error.html"
  }
}

resource "aws_s3_bucket_acl" "s3_bucket" {
  bucket = aws_s3_bucket.s3_bucket.id

  acl = "public-read"
}

resource "aws_s3_bucket_policy" "s3_bucket" {
  bucket = aws_s3_bucket.s3_bucket.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "PublicReadGetObject"
        Effect    = "Allow"
        Principal = "*"
        Action    = "s3:GetObject"
        Resource = [
          aws_s3_bucket.s3_bucket.arn,
          "${aws_s3_bucket.s3_bucket.arn}/*",
        ]
      },
    ]
  })
}
modules/s3/variables.tf
variable "bucket_name" {
  description = "Name of the s3 bucket. Must be unique."
  type        = string
}

variable "tags" {
  description = "Tags to set on the bucket."
  type        = map(string)
  default     = {}
}
modules/s3/outputs.tf
output "arn" {
  description = "ARN of the bucket"
  value       = aws_s3_bucket.s3_bucket.arn
}

output "name" {
  description = "Name (id) of the bucket"
  value       = aws_s3_bucket.s3_bucket.id
}

output "domain" {
  description = "Domain name of the bucket"
  value       = aws_s3_bucket_website_configuration.s3_bucket.website_domain
}

その後、ルートの resoruce.tfで作成したmodules/s3を次のように呼び出します(child module)

resource.tf
...
# 下記を追記

module "website_s3_bucket" {
  source = "./modules/aws-s3-static-website-bucket"

  bucket_name = "robin-test-dec-17-2019"

  tags = {
    Terraform   = "true"
    Environment = "dev"
  }
}

その後、全セクションと同様にterraform plan,terraform applyを実行して環境に適用します。

resourceのmove

movedブロックを利用する構成ファイル内でのリソースの移動をトラックすることができます。
movdeブロックによりリソースの移動をplan,プレビュー、そしてバリデートできるようになり、リファクタリングがより安全に行えます。

変更前構成ファイル

https://learn.hashicorp.com/tutorials/terraform/move-config?in=terraform/modules を参考に、下記リポジトリの内容を少し変更したものを利用します。

https://github.com/hashicorp/learn-terraform-move

main.tf
terraform {
  required_version = ">= 1.1.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.25.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
  default_tags {
    tags = {
      hashicorp-learn = "move-config"
    }
  } 
}
resource.tf
data "aws_availability_zones" "available" {
  state = "available"
}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "3.14.0"

  name = var.vpc_name
  cidr = var.vpc_cidr

  azs             = data.aws_availability_zones.available.names
  private_subnets = var.vpc_private_subnets
  public_subnets  = var.vpc_public_subnets

  enable_nat_gateway      = var.vpc_enable_nat_gateway
  map_public_ip_on_launch = true

  tags = var.vpc_tags
}

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] # Canonical
}


resource "aws_instance" "example" {
  ami                         = data.aws_ami.ubuntu.id
  subnet_id                   = module.vpc.public_subnets[0]
  instance_type               = "t2.micro"
  vpc_security_group_ids      = [aws_security_group.sg_8080.id]
  associate_public_ip_address = true
  
  user_data                   = <<-EOF
              #!/bin/bash
              apt-get update
              apt-get install -y apache2
              sed -i -e 's/80/8080/' /etc/apache2/ports.conf
              echo "Hello World" > /var/www/html/index.html
              systemctl restart apache2
              EOF
  tags = {
    Name = "terraform-learn-move-ec2"
  }
}

resource "aws_security_group" "sg_8080" {
  vpc_id = module.vpc.vpc_id
  name   = "terraform-learn-move-sg"
  ingress {
    from_port   = "8080"
    to_port     = "8080"
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  // connectivity to ubuntu mirrors is required to run `apt-get update` and `apt-get install apache2`
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
variables.tf
variable "region" {
  default     = "us-east-2"
  description = "The AWS region your resources will be deployed"
}

variable "vpc_name" {
  description = "Name of VPC"
  type        = string
  default     = "learn-vpc"
}

variable "vpc_cidr" {
  description = "CIDR block for VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "vpc_private_subnets" {
  description = "Private subnets for VPC"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "vpc_public_subnets" {
  description = "Public subnets for VPC"
  type        = list(string)
  default     = ["10.0.101.0/24", "10.0.102.0/24"]
}

variable "vpc_enable_nat_gateway" {
  description = "Enable NAT gateway for VPC"
  type        = bool
  default     = true
}

variable "vpc_tags" {
  description = "Tags to apply to resources created by VPC module"
  type        = map(string)
  default = {
    Terraform   = "true"
    Environment = "dev"
  }
}
outputs.tf
output "public_ip" {
  description = "The Public IP address used to access the instance"
  value       = aws_instance.example.public_ip
}
# plan実行
% terraform plan
Plan: 22 to add, 0 to change, 0 to destroy.

# 環境への適用
% terraform apply
Apply complete! Resources: 22 added, 0 changed, 0 destroyed.

Outputs:
public_ip = "xxx.xxx.xxx.xx"

構成ファイルのリファクタリング

ここで 計算機リソース用のmoduleと、さらに後続で security group用のmoduleとに分割していきます。
まずは計算機リソースのmodule化で、次のようにmodule用のディレクトリを作成します。

mkdir -p modules/compute
modules/compute/main.tf
data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] # Canonical
}


resource "aws_instance" "example" {
  ami                         = data.aws_ami.ubuntu.id
  subnet_id                   = var.public_subnets[0]
  instance_type               = "t2.micro"
  vpc_security_group_ids      = [var.security_group]
  associate_public_ip_address = true
  
  user_data                   = <<-EOF
              #!/bin/bash
              apt-get update
              apt-get install -y apache2
              sed -i -e 's/80/8080/' /etc/apache2/ports.conf
              echo "Hello World" > /var/www/html/index.html
              systemctl restart apache2
              EOF
  tags = {
    Name = "terraform-learn-move-ec2"
  }
}
modules/compute/variables.tf
variable "security_group" {
 description = "The security groups assigned to this instance"
}

variable "public_subnets" {
  description = "The public subnet IDs assigned to this instance for IP address assignment"
}
modules/compute/outputs.tf
output "public_ip" {
  description = "The Public IP address used to access the instance"
  value       = aws_instance.example.public_ip
}

続けてsecurity groupのmoduelも同様に作成していきます。

mkdir -p modules/security_group
modules/security_group/main.tf
resource "aws_security_group" "sg_8080" {
  vpc_id      = var.vpc_id
  name = "terraform-learn-move-sg"
  ingress {
    from_port   = "8080"
    to_port     = "8080"
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
modules/security_group/variables.tf
variable "vpc_id" {
  description = "ID of the VPC where to create security group"
  type        = string
}
modules/security_group/outputs.tf
output "sg_id" {
  description = "The security group ID passed from this module"
  value       = aws_security_group.sg_8080.id
}

module作成後、root moduleのresource.tfのdata sourceaws_availability_zonesとmodulevpc以外のcomputeとsecurity groupに関して作成したmoduleを呼び出すように次のように修正します。

resource.tf
module "ec2_instance" {
  source          = "./modules/compute"
  security_group = module.security_group.sg_id
  public_subnets  = module.vpc.public_subnets
}

module "security_group" {
  source = "./modules/security_group"
  vpc_id    = module.vpc.vpc_id
}

root moduleのoutputs.tfも次のように修正します

output "public_ip" {
  description = "The Public IP address used to access the instance"
  value       = module.ec2_instance.public_ip
}

変更のreview・plan

ここで変更の差分をみていきます。

% terraform init
Initializing modules...
- ec2_instance in modules/compute
- security_group in modules/security_group
...
% terraform plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
  - destroy
Terraform will perform the following actions:

  # aws_instance.example will be destroyed
  # (because aws_instance.example is not in configuration)
  - resource "aws_instance" "example" {
  ...

  # aws_security_group.sg_8080 will be destroyed
  # (because aws_security_group.sg_8080 is not in configuration)
  - resource "aws_security_group" "sg_8080" {
  ...

  # module.ec2_instance.aws_instance.example will be created
  + resource "aws_instance" "example" {
  ...

  # module.security_group.aws_security_group.sg_8080 will be created
  + resource "aws_security_group" "sg_8080" {
  Plan: 2 to add, 13 to change, 2 to destroy.

Changes to Outputs:
  ~ public_ip = "xxx.xxx.xxx.xxx" -> (known after apply)

上記plan結果からもわかるように、今回の差分をそのまま適用すると一度既存のリソースを削除して再度作成されます。この間サービスが一時停止する影響が出てしまいます。
このような場合にterraformのmovedブロックを利用することでリソースの再作成なしに構成ファイルをリファクタリングできるようになります。

movedブロックを利用した構成ファイルのリファクタリング

movedブロックを利用することでTerraformに構成ファイルに変更があることを知らせることができます。またこれによりTerraformはplan実行時に安全に変更をレビューすることができます。

root moduleのresorce.tfファイルに次のようにmovedブロックを追加します。
この状態でplanを実行すると先ほどのリソースの削除と再作成がなくなっている事がわかります。

% terraform plan
...
Plan: 0 to add, 13 to change, 0 to destroy.

リソースのリネームとmove

既存リソースのリネームにもmovedブロックを利用できます。
root moduleのresource.tf

resource.tf
-module "vpc" {
+module "learn_vpc" {
   source  = "terraform-aws-modules/vpc/aws"
 
 ...

 module "ec2_instance" {
   source         = "./modules/compute"
   security_group = module.security_group.sg_id
-  public_subnets = module.vpc.public_subnets
+  public_subnets = module.learn_vpc.public_subnets
 }
 
 module "security_group" {
   source = "./modules/security_group"
-  vpc_id = module.vpc.vpc_id
+  vpc_id = module.learn_vpc.vpc_id
 }

続けて同ファイルの末尾にmovedブロックを次のように追記します。

resource.tf
moved {
  from = module.vpc
  to   = module.learn_vpc
}

そして以上の変更をplan, そしてapplyしていきます。

% terraform init
Initializing modules...
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 3.14.0 for learn_vpc...
- learn_vpc in .terraform/modules/learn_vpc

動作確認が完了したらリソースを削除して終了です。

% terraform plan
Plan: 0 to add, 13 to change, 0 to destroy.

# 適用
% terrafrom apply
# resourceの削除
% terraform destroy

Discussion