Terraformで始めるインフラ管理 – 第5回

目次

はじめに

前回の記事では、Terraformを使用してウェブサイトをデプロイする方法について説明しました。今回は、AWS上にVirtual Private Cloud(VPC)を作成する例を通して、Terraformモジュールを使用して効率的にコードを構成する方法について学んでいきます。

Virtual Private Cloudの作成

Virtual Private Cloud(VPC)とは、簡単に言うと閉じたネットワークのことです。

デフォルトでは、AWSの各リージョンに「default」という名前のデフォルトVPCが存在します。新しいVPCを作成するには、Terraformのaws_vpcリソースを使用します。main.tfという名前のファイルを作成しましょう。

...

provider "aws" {
  region = "us-west-2"
}

resource "aws_vpc" "vpc" {
  cidr_block = "10.0.0.0/16"
  enable_dns_hostnames = true

    tags = {
    "Name" = "custom"
  }
}

上記では、CIDRが10.0.0.0/16、名前がcustomの新しいVPCを作成します。VPCのCIDRは以下の範囲内の値を持つ必要があります。

  • 10.0.0.0/16 → 10.0.0.0/28
  • 172.16.0.0/16 → 172.16.0.0/28
  • 192.168.0.0/16 → 192.168.0.0/28

initコマンドを実行する

terraform init

Subnet

サブネットは、VPCをさらに小さなネットワークに分割するものです。各サブネットは1つのアベイラビリティゾーン(AZ)内に配置されます。そして、私たちが作成するAWSサービスはこのサブネット内に作成されます。

Terraformのaws_subnetを使用してサブネットを作成します。

...
resource "aws_subnet" "private_subnet_2a" {
  vpc_id     = aws_vpc.vpc.id
  cidr_block = "10.0.1.0/24"
  availability_zone = "us-west-2a"

  tags = {
    "Name" = "private-subnet"
  }
}

resource "aws_subnet" "private_subnet_2b" {
  vpc_id     = aws_vpc.vpc.id
  cidr_block = "10.0.2.0/24"
  availability_zone = "us-west-2b"

  tags = {
    "Name" = "private-subnet"
  }
}

resource "aws_subnet" "private_subnet_2c" {
  vpc_id     = aws_vpc.vpc.id
  cidr_block = "10.0.3.0/24"
  availability_zone = "us-west-2c"

  tags = {
    "Name" = "private-subnet"
  }
}

上記のコードでは、10.0.1.0/24、10.0.2.0/24、10.0.3.0/24という3つのサブネットを作成し、それぞれAZのa、b、cに配置します。さらに多くのサブネットが必要な場合は、リソースをコピーして追加することもできますが、それではコードが長くなってしまいます。そのため、以下のようにコードを簡潔にまとめることができます。

...
locals {
  private = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  zone   = ["us-west-2a", "us-west-2b", "us-west-2c"]
}

resource "aws_subnet" "private_subnet" {
  count = length(local.private)

  vpc_id            = aws_vpc.vpc.id
  cidr_block        = local.private[count.index]
  availability_zone = local.zone[count.index % length(local.zone)]

  tags = {
    "Name" = "private-subnet"
  }
}

さらに、10.0.4.0/24、10.0.5.0/24、10.0.6.0/24という3つのサブネットを追加します。(これらのサブネットの名前がPublicやPrivateとなっている理由については、後ほど説明します。)

...
locals {
  private  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public = ["10.0.4.0/24", "10.0.5.0/24", "10.0.6.0/24"]
  zone    = ["us-west-2a", "us-west-2b", "us-west-2c"]
}

resource "aws_subnet" "private_subnet" {
  count = length(local.private)

  vpc_id            = aws_vpc.vpc.id
  cidr_block        = local.private[count.index]
  availability_zone = local.zone[count.index % length(local.zone)]

  tags = {
    "Name" = "private-subnet"
  }
}

resource "aws_subnet" "public_subnet" {
  count = length(local.public)

  vpc_id            = aws_vpc.vpc.id
  cidr_block        = local.public[count.index]
  availability_zone = local.zone[count.index % length(local.zone)]

  tags = {
    "Name" = "public-subnet"
  }
}

この時点で、サブネット内に作成されたAWSサービス同士は通信できます。しかし、これらのAWSサービスがインターネット上の他のサービスと通信しようとすると、できません。逆も同様です。

なぜなら、AWSサービスがインターネットと通信できるようにするためのルーター役のものがまだ存在していないからです。

Internet gateway

サブネット内のAWSサービスがインターネットと通信できるようにするためには、インターネットゲートウェイ(IG)というものが必要です。このIGをルートテーブルにアタッチします。そして、そのルートテーブルをインターネットと通信させたいサブネットに関連付けます。

ここから、「パブリックサブネット」と「プライベートサブネット」という概念が出てきます。

パブリックサブネットとは、内部のサービスがインターネットゲートウェイ(IG)を介して外部のインターネットと双方向に通信できるサブネットのことです。

一方、プライベートサブネットでは、内部のサービスは外部と通信できますが、外部から内部への通信はできません。

IGを作成するには、Terraformのaws_internet_gatewayリソースを使用します。

...
resource "aws_internet_gateway" "ig" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    "Name" = "custom"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.vpc.id

  for_each       = { for k, v in aws_subnet.public_subnet : k => v }
  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.ig.id
  }

  tags = {
    "Name" = "public"
  }
}

これで、パブリックサブネット内のサービスは外部と通信できるようになりました。では、プライベートサブネットの場合はどうでしょうか?

現在、プライベートサブネット内のサービスはインターネットと通信できません。しかし、プライベートサブネットにはIG(インターネットゲートウェイ)をアタッチすることはできません。なぜなら、IGは双方向の通信を可能にしてしまうからです。一方、プライベートサブネットでは、内部から外部への通信のみを許可し、外部から内部への通信は許可したくないからです。

NAT gateway

パブリックサブネット上にNATをデプロイし、それをルートテーブルにアタッチします。そして、そのルートテーブルを各プライベートサブネットに関連付けます。

provider "aws" {
  region  = "us-west-2"
}

resource "aws_vpc" "vpc" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = {
    "Name" = "custom"
  }
}

locals {
  private = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public  = ["10.0.4.0/24", "10.0.5.0/24", "10.0.6.0/24"]
  zone    = ["us-west-2a", "us-west-2b", "us-west-2c"]
}

resource "aws_subnet" "private_subnet" {
  count = length(local.private)

  vpc_id            = aws_vpc.vpc.id
  cidr_block        = local.private[count.index]
  availability_zone = local.zone[count.index % length(local.zone)]

  tags = {
    "Name" = "private-subnet"
  }
}

resource "aws_subnet" "public_subnet" {
  count = length(local.public)

  vpc_id            = aws_vpc.vpc.id
  cidr_block        = local.public[count.index]
  availability_zone = local.zone[count.index % length(local.zone)]

  tags = {
    "Name" = "public-subnet"
  }
}

resource "aws_internet_gateway" "ig" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    "Name" = "custom"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.ig.id
  }

  tags = {
    "Name" = "public"
  }
}

resource "aws_route_table_association" "public_association" {
  for_each       = { for k, v in aws_subnet.public_subnet : k => v }
  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

resource "aws_eip" "nat" {
  vpc = true
}

resource "aws_nat_gateway" "public" {
  depends_on = [aws_internet_gateway.ig]

  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public_subnet[0].id

  tags = {
    Name = "Public NAT"
  }
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_nat_gateway.public.id
  }

  tags = {
    "Name" = "private"
  }
}

resource "aws_route_table_association" "public_private" {
  for_each       = { for k, v in aws_subnet.private_subnet : k => v }
  subnet_id      = each.value.id
  route_table_id = aws_route_table.private.id
}

これでコードの記述が完了しました。次に、applyコマンドを実行してインフラを作成します。

terraform apply -auto-approve

ご覧の通り、Terraformを使ってVPCを作成するのは非常に簡単です。しかし、毎回新しいVPCを作成するたびにこのコードをコピーする必要があるのでしょうか?答えは「いいえ」です!

このコードの重複を避けるために、Terraformは「モジュール」という機能を提供しています。モジュールを使うことで、コードを整理し、何度でも再利用できるようになります。

Terraformモジュール

Terraformモジュールとは、コードを一箇所に整理し、さまざまな場所で再利用できるようにするTerraformの機能です。

モジュールについて考えるときは、大きな絵の中の小さなパーツのようなものだとイメージすると分かりやすいでしょう。これらの小さなパーツを組み合わせて最終的な絵を完成させる、まるでLEGOのようなものです。

モジュールの構成

基本的なモジュールは以下の3つのファイルで構成されます。

  • main.tf:コード本体を記述するファイル
  • variables.tf:モジュールへの入力値を定義するファイル
  • outputs.tf:モジュールから出力する値を定義するファイル

モジュールの使用

モジュールを使用するには、moduleというリソースを使用します。

module <module_name> {
  source = <source>
  version = <version>

  input_one = <input_one>
  input_two = <input_two>
}

<source>は自分のローカルマシン上のパス、またはネット上のパスを指定することができます。
<version>はモジュールのバージョンを指定します。
<input_one>は、variables.tfファイルで定義した入力値を指定します。

モジュールの作成

これから、先ほどのコードをモジュールとして再構成していきます。モジュールを作成する前に、モジュール内で動的に変化させたい値をあらかじめ定義しておく必要があります。そうすることで、モジュールを使用する際に値を渡して、異なるリソースを作成できるようになります。

上記の例では、VPCモジュールに渡すべき動的な値は以下のとおりです。

  • vpc_cidr_block
  • subnet_cidr_block および zone

次のような構成でディレクトリを作成します。

モジュールの入力値は、variables.tfファイルの中で定義します。

variable "vpc_cidr_block" {
  type    = string
  default = "10.0.0.0/16"
}

variable "private_subnet" {
  type    = list(string)
}

variable "public_subnet" {
  type    = list(string)
}

variable "availability_zone" {
  type    = list(string)
}

VPCディレクトリ内のmain.tfファイルのコードを更新します。

resource "aws_vpc" "vpc" {
  cidr_block           = var.vpc_cidr_block
  enable_dns_hostnames = true

  tags = {
    "Name" = "custom"
  }
}

resource "aws_subnet" "private_subnet" {
  count = length(var.private_subnet)

  vpc_id            = aws_vpc.vpc.id
  cidr_block        = var.private_subnet[count.index]
  availability_zone = var.availability_zone[count.index % length(var.availability_zone)]

  tags = {
    "Name" = "private-subnet"
  }
}

resource "aws_subnet" "public_subnet" {
  count = length(var.public_subnet)

  vpc_id            = aws_vpc.vpc.id
  cidr_block        = var.public_subnet[count.index]
  availability_zone = var.availability_zone[count.index % length(var.availability_zone)]

  tags = {
    "Name" = "public-subnet"
  }
}

resource "aws_internet_gateway" "ig" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    "Name" = "custom"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.ig.id
  }

  tags = {
    "Name" = "public"
  }
}

resource "aws_route_table_association" "public_association" {
  for_each       = { for k, v in aws_subnet.public_subnet : k => v }
  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

resource "aws_eip" "nat" {
  vpc = true
}

resource "aws_nat_gateway" "public" {
  depends_on = [aws_internet_gateway.ig]

  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public_subnet[0].id

  tags = {
    Name = "Public NAT"
  }
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_nat_gateway.public.id
  }

  tags = {
    "Name" = "private"
  }
}

resource "aws_route_table_association" "public_private" {
  for_each       = { for k, v in aws_subnet.private_subnet : k => v }
  subnet_id      = each.value.id
  route_table_id = aws_route_table.private.id
}

これで、VPCモジュールを使用する際に、異なる入力値を渡すだけで、異なるVPCを作成できるようになりました。
最上位のmain.tfファイル内では、次のようにモジュールを使用します。

...

provider "aws" {
  region = "us-west-2"
}

module "vpc" {
  source = "./vpc"

  vpc_cidr_block    = "10.0.0.0/16"
  private_subnet    = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnet     = ["10.0.4.0/24", "10.0.5.0/24", "10.0.6.0/24"]
  availability_zone = ["us-west-2a", "us-west-2b", "us-west-2c"]
}

一般的なモジュール

実際に本番環境(Production)で作業する場合は、自分で作成するよりも、インターネット上に公開されている既存のモジュールを使用することをおすすめします。既存のモジュールは、私たちが作るものよりも細かく作り込まれており、さまざまなケースにも対応しています。
モジュールは以下のサイトで探すことができます:
https://registry.terraform.io/browse/modules

まとめ

これで、最初からコードを書く方法、コードをモジュールとして整理する方法、モジュールをインターネットに公開する方法、そして既存のモジュールを使用する方法について学びました。
モジュールを使うことで、既存のコードを活用し、同じコードを何度も書く手間を省くことができます。

次回の記事では、引き続きモジュールについて取り上げ、VPC、オートスケーリンググループ、そしてロードバランサーをAWS上に作成する具体的な例を通して、さらに深く学んでいきます。