Terraform で ECS 環境を構築する① 〜ネットワーク編〜

以前の記事で、Terraform の基本的な使い方を紹介しました。ここから何回かに分けて、ECS を使ってアプリを動かす環境を Terraform で構築する方法を紹介していきます。

blog.linkode.co.jp

はじめに:最終的なゴールと今回の目標

  • 最終的なゴールは下の図のような環境を構築することです。
    • 作成したコンテナアプリを Fargate を利用した ECS で動かします。
    • 外部からのリクエストを受け付けられるように ALB を利用します。
    • 独自ドメインを設定し、 HTTPS でリクエストを受けられるようにするため、 Route 53 と ACM を利用します。
    • コンテナアプリ自体のソースコードは GitLab で管理し、 GitLab のリポジトリにコードの変更が Push されたら CodeBuild が動き出し、 ECR のイメージを更新するようにします。

f:id:linkode-okazaki:20200917092159p:plain

  • 今回は、ネットワーク部分構築を行います。以下の図の構成を目標に構築します。

f:id:linkode-okazaki:20200917092150p:plain

main.tf

  • 以前の記事で説明した、プロバイダの設定などを記載する main.tf を以下のように作成しておきます。
provider "aws" {
  version = "~> 2.0"
  profile = "terraformer"
  region  = "ap-northeast-1"
}

terraform {
  required_version = "0.12.29"
}

VPC

  • VPC (Virtual Private Cloud) は他のネットワークから論理的に切り離された仮想ネットワークを提供するサービスです。
  • 以下のように定義します。
    • cidr_block (必須): VPCIPv4 アドレスの範囲を CIDR 形式で指定
    • enable_dns_support : true を指定すると AWSDNS サーバーによる名前解決を有効にする
    • enable_dns_hostnames : true を指定すると VPC 内のリソースにパブリック DNS ホスト名を自動的に割り当てる
  • なお、リソースによっては、 Name タグでリソースの名前を決められます。
    • VPC の場合、名前が空欄だと、何の用途の VPC なのかがわからなくなるため、 Name タグを付与しておいたほうが安全です。
    • 以下、Name タグがあるリソースは同様の理由で付与してあります。
resource "aws_vpc" "vpc" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true
  
  tags = {
    Name = "example"
  }
}
  • 以下、VPC を更に分割してサブネットを作成していきます。

パブリックネットワーク

パブリックサブネット

  • インターネットからアクセス可能なパブリックサブネットを以下のように定義します。
    • cidr_block (必須): IPv4 アドレスの範囲を CIDR 形式で指定
    • 特にこだわりがなければ、VPC では /16 単位、サブネットでは /24 単位にするとわかりやすい
    • vpc_id(必須): サブネットを作成する VPC の ID を指定
      • 先程作成した VPC の ID を参照します
      • Terraform では「TYPE.NAME.ATTRIBUTE」の形式で書けば、他のリソースの値を参照できます。
    • map_public_ip_on_launch : true にすると、そのサブネットで起動したインスタンスにパブリック IP を自動的に割り当ててくれる
    • availability_zone : サブネットを作成するアベイラビリティゾーンを指定する
      • アベイラビリティゾーンをまたがったサブネットは作成できない
      • 可用性を向上させるために、複数のアベイラビリティゾーンで構成されたネットワーク(マルチ AZ)は下のように設定することで実現できる
resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.example.id
  cidr_block              = "10.0.0.0/24"
  availability_zone       = "ap-northeast-1a"  
  map_public_ip_on_launch = true
  
  tags = {
    Name = "sn-pub-a"
  }
}

resource "aws_subnet" "public_c" {
  vpc_id                  = aws_vpc.example.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "ap-northeast-1c"  
  map_public_ip_on_launch = true
  
  tags = {
    Name = "sn-pub-c"
  }
}

インターネットゲートウェイ

  • VPC とインターネットの間で通信ができるように、インターネットゲートウェイを作成します。
  • 以下のように、VPC の ID を指定するだけでインターネットゲートウェイを作成できます。
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.example.id
  
  tags = {
    Name = "example-igw"
  }
}

ルートテーブル

  • ネットワークにデータを流すために、ルーティング情報を管理するルートテーブルを以下のように定義します。
  • こちらも、VPC の ID を指定するだけです。
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.example.id
  
  tags = {
    Name = "example-rt-pub"
  }
}
  • ルートテーブルを作成すると、 VPC 内の通信を有効にするため、ローカルルートが自動的に作成されるという特殊な仕様があるため注意が必要です。

ルート

  • ルートテーブルの 1 レコードに該当するルートを定義します。
    • VPC 以外への通信をインターネットゲートウェイ経由でインターネットへデータを流すために、destination_cidr_block にデフォルトルート 0.0.0.0/0 を指定します。
resource "aws_route" "public" {
  route_table_id         = aws_route_table.public.id
  gateway_id             = aws_internet_gateway.igw.id
  destination_cidr_block = "0.0.0.0/0"
}

ルートテーブルの関連付け

  • どのルートテーブルを使ってルーティングするかはサブネット単位で判断するため、以下のようにルートテーブルとサブネットを関連付けます。
  • 関連付けを忘れた場合、デフォルトルートテーブルが自動的に使用されます。ただし、デフォルトルートテーブルの利用はアンチパターンであるため、関連付けは忘れないよう注意します。
resource "aws_route_table_association" "public_a" {
  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public_a.id
}

resource "aws_route_table_association" "public_c" {
  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public_c.id
}

プライベートネットワーク

  • インターネットから隔離されたプライベートネットワークには、データベースサーバのようなインターネットからアクセスしないリソースを配置します。
    • システムをセキュアにするため、パブリックネットワークには必要最低限のリソースのみ配置し、それ以外はプライベートネットワークに置くのが定石

プライベートサブネット

  • パブリックサブネットと同様にプライベートサブネットを定義します。
  • パブリックサブネットとは異なる CIDR ブロックを指定します。
  • パブリック IP アドレスは不要なため、 map_public_ip_on_launchfalse に設定します。
resource "aws_subnet" "private_a" {
  vpc_id                  = aws_vpc.example.id
  cidr_block              = "10.0.128.0/24"
  availability_zone       = "ap-northeast-1a"
  map_public_ip_on_launch = false
  
  tags = {
    Name = "eks-sn-pri-a"
  }
}

resource "aws_subnet" "private_c" {
  vpc_id                  = aws_vpc.example.id
  cidr_block              = "10.0.129.0/24"
  availability_zone       = "ap-northeast-1c" 
  map_public_ip_on_launch = false
  
  tags = {
    Name = "eks-sn-pri-c"
  }
}

ルートテーブルとルートテーブルの関連付け

  • プライベートネットワーク用のルートテーブルとその関連付けを以下のように実装します。
    • インターネットゲートウェイに対するルーティング定義は不要です。
resource "aws_route_table" "private_a" {
  vpc_id = aws_vpc.example.id
  
  tags = {
    Name = "example-rt-pri-a"
  }
}

resource "aws_route_table" "private_c" {
  vpc_id = aws_vpc.example.id
  
  tags = {
    Name = "example-rt-pri-c"
  }
}

resource "aws_route_table_association" "private_a" {
  route_table_id = aws_route_table.private_a.id
  subnet_id      = aws_subnet.private_a.id
}

resource "aws_route_table_association" "private_c" {
  route_table_id = aws_route_table.private_c.id
  subnet_id      = aws_subnet.private_c.id
}

NAT ゲートウェイ

  • NAT (Network Address Translation) サーバは、プライベートネットワークからインターネットへアクセス出来るようにする役割を担います。
  • AWS では、 NAT ゲートウェイを利用することで、自力で NAT サーバを構築せずともプライベートネットワークからインターネットへアクセスできるようになります。

EIP (Elastic IP Address)

  • EIP は静的なパブリック IP アドレスを付与するサービスです。
  • AWS では、インスタンスを起動するたびに異なる IP アドレスが動的に割り当てられますが、 EIP を使うとパブリック IP アドレスが固定できます。
  • EIP は以下のように定義します。
    • EIP は暗黙的にインターネットゲートウェイに依存しているため、depends_on を使って依存を明示します。これにより、インターネットゲートウェイ作成後に EIP が作成されるよう保証されます。
    • 暗黙の依存関係については、 Terraform のドキュメントに以下のように記載があるため、初めて使うリソースの場合は、ドキュメントを確認しておくと良いです。

f:id:linkode-okazaki:20200917092154p:plain

resource "aws_eip" "nat_a" {
  vpc        = true
  depends_on = [aws_internet_gateway.igw]
  
  tags = {
    Name = "example-eip-a"
  }
}
resource "aws_eip" "nat_c" {
  vpc        = true
  depends_on = [aws_internet_gateway.igw]
  
  tags = {
    Name = "example-eip-c"
  }
}

NAT ゲートウェイ

  • NAT ゲートウェイは以下のように定義します。
    • allocation_id : 先ほど作成した EIP を指定
    • subnet_id : NAT ゲートウェイを配置するパブリックサブネットを指定(プライベートサブネットではない点に注意)
  • NAT ゲートウェイも暗黙的にインターネットゲートウェイに依存するため、 depends_on に明示します。
resource "aws_nat_gateway" "nat_a" {
  allocation_id = aws_eip.nat_a.id
  subnet_id     = aws_subnet.public_a.id
  depends_on    = [aws_internet_gateway.igw]
  
  tags = {
    Name = "example-nat-gw-a"
  }
}

resource "aws_nat_gateway" "nat_c" {
  allocation_id = aws_eip.nat_c.id
  subnet_id     = aws_subnet.public_c.id
  depends_on    = [aws_internet_gateway.igw]
  
  tags = {
    Name = "example-nat-gw-c"
  }
}

ルート

  • プライベートネットワークからインターネットに通信を行うためのルートを定義します。
  • 以下のように、プライベートサブネットのルートテーブルに追加します。
    • destination_cidr_block : デフォルトルートを指定
    • nat_gateway_id : ルーティングする NAT ゲートウェイを指定
resource "aws_route" "private_a" {
  route_table_id         = aws_route_table.private_a.id
  nat_gateway_id         = aws_nat_gateway.nat_a.id
  destination_cidr_block = "0.0.0.0/0"
}

resource "aws_route" "private_c" {
  route_table_id         = aws_route_table.private_c.id
  nat_gateway_id         = aws_nat_gateway.nat_c.id
  destination_cidr_block = "0.0.0.0/0"
}
  • ここまでで、必要なリソースは揃いました。

ここまでのまとめ

  • ここまでのリソースをまとめて、network.tf に記載しておきます。

network.tf の内容(クリックで展開)

# -----------------------------------------
# VPC
# -----------------------------------------
resource "aws_vpc" "example" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  instance_tenancy     = "default"
  tags = {
    Name = "example-vpc"
  }
}

# -----------------------------------------
# Public Network
# -----------------------------------------

# Subnet
resource "aws_subnet" "public_a" {
  availability_zone       = "ap-northeast-1a"
  cidr_block              = "10.0.0.0/24"
  vpc_id                  = aws_vpc.example.id
  map_public_ip_on_launch = true
  tags = {
    Name = "sn-pub-a"
  }
}

resource "aws_subnet" "public_c" {
  availability_zone       = "ap-northeast-1c"
  cidr_block              = "10.0.1.0/24"
  vpc_id                  = aws_vpc.example.id
  map_public_ip_on_launch = true
  tags = {
    Name = "sn-pub-c"
  }
}

# Internet Gateway
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.example.id
  tags = {
    Name = "example-igw"
  }
}

# Route Table
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.example.id
  tags = {
    Name = "example-rt-pub"
  }
}

# Route
resource "aws_route" "public" {
  route_table_id         = aws_route_table.public.id
  gateway_id             = aws_internet_gateway.igw.id
  destination_cidr_block = "0.0.0.0/0"
}

# Route Table Association
resource "aws_route_table_association" "public_a" {
  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public_a.id
}

resource "aws_route_table_association" "public_c" {
  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public_c.id
}

# -----------------------------------------
# Private Network
# -----------------------------------------

# Subnet
resource "aws_subnet" "private_a" {
  availability_zone       = "ap-northeast-1a"
  cidr_block              = "10.0.128.0/24"
  vpc_id                  = aws_vpc.example.id
  map_public_ip_on_launch = false

  tags = {
    Name = "eks-sn-pri-a"
  }
}

resource "aws_subnet" "private_c" {
  availability_zone       = "ap-northeast-1c"
  cidr_block              = "10.0.129.0/24"
  vpc_id                  = aws_vpc.example.id
  map_public_ip_on_launch = false

  tags = {
    Name = "eks-sn-pri-c"
  }
}

# Route Table
resource "aws_route_table" "private_a" {
  vpc_id = aws_vpc.example.id
  tags = {
    Name = "example-rt-pri-a"
  }
}

resource "aws_route_table" "private_c" {
  vpc_id = aws_vpc.example.id
  tags = {
    Name = "example-rt-pri-c"
  }
}

# Route
resource "aws_route" "private_a" {
  route_table_id         = aws_route_table.private_a.id
  nat_gateway_id         = aws_nat_gateway.nat_a.id
  destination_cidr_block = "0.0.0.0/0"
}

resource "aws_route" "private_c" {
  route_table_id         = aws_route_table.private_c.id
  nat_gateway_id         = aws_nat_gateway.nat_c.id
  destination_cidr_block = "0.0.0.0/0"
}

# Route Table Association
resource "aws_route_table_association" "private_a" {
  route_table_id = aws_route_table.private_a.id
  subnet_id      = aws_subnet.private_a.id
}

resource "aws_route_table_association" "private_c" {
  route_table_id = aws_route_table.private_c.id
  subnet_id      = aws_subnet.private_c.id
}

# Elastic IP Address
resource "aws_eip" "nat_a" {
  vpc        = true
  depends_on = [aws_internet_gateway.igw]
  tags = {
    Name = "example-eip-a"
  }
}
resource "aws_eip" "nat_c" {
  vpc        = true
  depends_on = [aws_internet_gateway.igw]
  tags = {
    Name = "example-eip-c"
  }
}

# Nat Gateway
resource "aws_nat_gateway" "nat_a" {
  allocation_id = aws_eip.nat_a.id
  subnet_id     = aws_subnet.public_a.id
  depends_on    = [aws_internet_gateway.igw]
  tags = {
    Name = "example-nat-gw-a"
  }
}
resource "aws_nat_gateway" "nat_c" {
  allocation_id = aws_eip.nat_c.id
  subnet_id     = aws_subnet.public_c.id
  depends_on    = [aws_internet_gateway.igw]
  tags = {
    Name = "example-nat-gw-c"
  }
}

セキュリティグループの設定とモジュール化

  • AWSファイヤーウォールとしては、以下の 2 つがあります:
    • ネットワーク ACL : サブネットレベルで動作する
    • セキュリティグループ : インスタンスレベルで動作する
  • ネットワーク関連の設定として、この後頻繁に登場するセキュリティグループの設定方法の確認とモジュール化をしておきます。

セキュリティグループの定義

  • セキュリティグループ本体は以下のように定義します
resource "aws_security_group" "example" {
  name   = "example"
  vpc_id = aws_vpc.example.id
}

セキュリティグループルール:インバウンド

  • 外からのアクセスの許可・不許可のルールを設定するインバウンドルールは以下のように設定します
    • typeingress にすることでインバウンドルールになります。
  • 今回は HTTP 通信ができるように 80 番ポートを許可します。
resource "aws_security_group_rule" "ingress" {
  type              = "ingress"
  from_port         = "80"
  to_port           = "80"
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.default.id
}

セキュリティグループルール:アウトバウンド

  • 外へのアクセスの許可・不許可のルールを設定するアウトバウンドルールは以下のように設定します
    • typeegress にすることでインバウンドルールになります。
  • 今回は、すべての通信を許可しています。
resource "aws_security_group_rule" "egress" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "all"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.default.id
}

ここまでの内容適用と確認

  • ここまでの作業が完了したら、プロジェクトのルートディレクトリで、 terraform apply を実行します。
  • 完了後、マネジメントコンソールで VPC やサブネット、ルートテーブルが作成されていれば成功です。
$ terraform fmt
$ terraform validate
$ terraform plan
$ terraform apply

セキュリティグループのモジュール化

  • 頻繁に利用するものはモジュール化しておくと便利です。セキュリティグループをモジュール化します。
  • モジュールを定義するファイルは、別ディレクトリ配下に置く必要があるため、security_group ディレクトリを作成し、その中に main.tf, variable.tf, output.tf の 3 つを作成します。
.
|-- main.tf
|-- network.tf
`-- security_group   // 新しく作成したディレクトリ
    |-- main.tf
    |-- output.tf
    `-- variable.tf
  • 作成したファイルにはそれぞれ以下の設定を記載します:
    • 入力パラメータは以下のとおりです:
      • name : セキュリティグループ名
      • vpc_id : VPC の ID
      • port : 通信を許可するポート番号
      • cidr_blocks : 通信を許可する CIDR ブロック

main.tf の内容(クリックで展開)

resource "aws_security_group" "default" {
  name   = var.name
  vpc_id = var.vpc_id
}

resource "aws_security_group_rule" "ingress" {
  type              = "ingress"
  from_port         = var.port
  to_port           = var.port
  protocol          = "tcp"
  cidr_blocks       = var.cidr_blocks
  security_group_id = aws_security_group.default.id
}

resource "aws_security_group_rule" "egress" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "all"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.default.id
}

variable.tf の内容(クリックで展開)

variable "name" {
  type = string
}

variable "vpc_id" {
  type = string
}

variable "port" {
  type = string
}

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

output.tf の内容(クリックで展開)

output "security_group_id" {
  value = aws_security_group.default.id
}

  • このモジュールは以下のようにして利用します。
module "example_sg" {
  source      = "./security_group"
  name        = "module-sg"
  vpc_id      = aws_vpc.example.id
  port        = 80
  cidr_blocks = ["0.0.0.0/0"]
}
  • このモジュール自体は次回以降利用することになります。
  • 定義内容の適用は、Terraform プロジェクトのルートディレクトリで行います。
    • apply をする前に、 terraform get を実行してモジュールを事前に取得することで、適用可能になります。

まとめ

  • ここから何回かに分けて、ECS のサービス環境を Terraform で構築していきます。
  • 今回は AWS のネットワーク環境の構築を行いました。
    • ECS での利用を想定していますが、EKS や EC2 でも利用できる内容です。
  • 今後も利用するモジュールの定義と、簡単に使い方を説明しました。
    • 次回以降で利用する予定です。

参考資料