Public IPv4を持たないデュアルスタック構成でWebサーバー環境をTerraformで構築してみた

# はじめに
こんにちは、こんばんは、どうもamaebiと申します。
今回は、Public IPv4を持たないデュアルスタック構成でWebサーバー環境をTerraformで構築しました。
本ブログの想定読者として、「Public IPv4が有料化したし、Public IPv4を使用しない構成をぱぱっと作りたい」という方向けになっておりますので、必要に応じてカスタマイズしていただけますと幸いです。

https://aws.amazon.com/jp/blogs/news/new-aws-public-ipv4-address-charge-public-ip-insights/

# 考慮していない点
本ブログで以下の内容は考慮しておりません。
こちらは、後日ブログにする予定です。

- バックアップ設定
- ログ情報 の保存
- 運用・保守 (CloudWatch等)

# 構成図
![lamp構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/73345a04-bd1a-8d17-3352-02038f7d4b4c.png)

# ディレクトリ構成
ディレクトリの構成内容は以下のようになっています。
```
.
├── providers.tf
├── vpc.tf
├── sg.tf
├── ec2.tf
├── alb.tf
├── route53.tf
├── acm.tf
├── key_pair
│   ├── private_key.pem
│   └── public_key.pub
├── terraform.tfstate
├── terraform.tfstate.backup
└── terraform.tfvars
```

# Terraformコード
`vpc.tf`ファイルについては、過去に自分が書いたブログから引用しております。
※Nat Gateway は不要でしたので、今回は作成しておりません。必要に応じて、下記ブログから参照してください。

https://techblog.nhn-techorus.com/archives/32897

```terraform:vpc.tf
#--------------------------------------
# VPC
#--------------------------------------
 
resource "aws_vpc" "vpc" {
  cidr_block           = "10.0.0.0/16"
  instance_tenancy     = "default"
  enable_dns_support   = true
  enable_dns_hostnames = true
 
  assign_generated_ipv6_cidr_block = true
 
  tags = {
    Name = "example-vpc"
  }
}
 
#--------------------------------------
# Subnet
#--------------------------------------
 
resource "aws_subnet" "public_subnet_1a" {
  vpc_id            = aws_vpc.vpc.id
  availability_zone = "ap-northeast-1a"
 
  map_public_ip_on_launch = true
  cidr_block              = cidrsubnet(aws_vpc.vpc.cidr_block, 8, 1)
 
  assign_ipv6_address_on_creation = true
  ipv6_cidr_block                 = cidrsubnet(aws_vpc.vpc.ipv6_cidr_block, 8, 0)
 
  tags = {
    Name = "example-public-subnet-1a"
  }
}
 
resource "aws_subnet" "private_subnet_1a" {
  vpc_id            = aws_vpc.vpc.id
  availability_zone = "ap-northeast-1a"
 
  map_public_ip_on_launch = true
  cidr_block              = cidrsubnet(aws_vpc.vpc.cidr_block, 8, 2)
 
  assign_ipv6_address_on_creation = true
  ipv6_cidr_block                 = cidrsubnet(aws_vpc.vpc.ipv6_cidr_block, 8, 1)
 
  tags = {
    Name = "example-private-subnet-1a"
  }
}
 
resource "aws_subnet" "public_subnet_1c" {
  vpc_id            = aws_vpc.vpc.id
  availability_zone = "ap-northeast-1c"
 
  map_public_ip_on_launch = true
  cidr_block              = cidrsubnet(aws_vpc.vpc.cidr_block, 8, 3)
 
  assign_ipv6_address_on_creation = true
  ipv6_cidr_block                 = cidrsubnet(aws_vpc.vpc.ipv6_cidr_block, 8, 2)
 
  tags = {
    Name = "example-public-subnet-1c"
  }
}
 
resource "aws_subnet" "private_subnet_1c" {
  vpc_id            = aws_vpc.vpc.id
  availability_zone = "ap-northeast-1c"
 
  map_public_ip_on_launch = true
  cidr_block              = cidrsubnet(aws_vpc.vpc.cidr_block, 8, 4)
 
  assign_ipv6_address_on_creation = true
  ipv6_cidr_block                 = cidrsubnet(aws_vpc.vpc.ipv6_cidr_block, 8, 3)
 
  tags = {
    Name = "example-private-subnet-1c"
  }
}
 
#--------------------------------------
# Route table
#--------------------------------------
 
resource "aws_route_table" "public_rt_1a" {
  vpc_id = aws_vpc.vpc.id
 
  tags = {
    Name = "example-rt-public-1a"
  }
}
 
resource "aws_route_table_association" "public_rt_1a" {
  route_table_id = aws_route_table.public_rt_1a.id
  subnet_id      = aws_subnet.public_subnet_1a.id
}
 
resource "aws_route_table" "private_rt_1a" {
  vpc_id = aws_vpc.vpc.id
 
  tags = {
    Name = "example-rt-private-1a"
  }
}
 
resource "aws_route_table_association" "private_rt_1a" {
  route_table_id = aws_route_table.private_rt_1a.id
  subnet_id      = aws_subnet.private_subnet_1a.id
}
 
resource "aws_route_table" "public_rt_1c" {
  vpc_id = aws_vpc.vpc.id
 
  tags = {
    Name = "example-rt-public-1c"
  }
}
 
resource "aws_route_table_association" "public_rt_1c" {
  route_table_id = aws_route_table.public_rt_1c.id
  subnet_id      = aws_subnet.public_subnet_1c.id
}
 
resource "aws_route_table" "private_rt_1c" {
  vpc_id = aws_vpc.vpc.id
 
  tags = {
    Name = "example-rt-private-1c"
  }
}
 
resource "aws_route_table_association" "private_rt_1c" {
  route_table_id = aws_route_table.private_rt_1c.id
  subnet_id      = aws_subnet.private_subnet_1c.id
}
 
#--------------------------------------
# Internet Gateway
#--------------------------------------
 
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.vpc.id
 
  tags = {
    Name = "example-igw"
  }
}
 
resource "aws_route" "public_rt_igw_1a_ipv4" {
  route_table_id         = aws_route_table.public_rt_1a.id
  gateway_id             = aws_internet_gateway.igw.id
  destination_cidr_block = "0.0.0.0/0"
}
 
resource "aws_route" "public_rt_igw_1a_ipv6" {
  route_table_id              = aws_route_table.public_rt_1a.id
  gateway_id                  = aws_internet_gateway.igw.id
  destination_ipv6_cidr_block = "::/0"
}
 
resource "aws_route" "public_rt_igw_1c_ipv4" {
  route_table_id         = aws_route_table.public_rt_1c.id
  gateway_id             = aws_internet_gateway.igw.id
  destination_cidr_block = "0.0.0.0/0"
}
 
resource "aws_route" "public_rt_igw_1c_ipv6" {
  route_table_id              = aws_route_table.public_rt_1c.id
  gateway_id                  = aws_internet_gateway.igw.id
  destination_ipv6_cidr_block = "::/0"
}
 
#--------------------------------------
# Egress-Only Internet Gateway
#--------------------------------------
 
resource "aws_egress_only_internet_gateway" "egress_only_igw" {
  vpc_id = aws_vpc.vpc.id
 
  tags = {
    Name = "example-egress-only-igw"
  }
}
 
resource "aws_route" "private_rt_egress_only_igw_1a" {
  route_table_id              = aws_route_table.private_rt_1a.id
  egress_only_gateway_id      = aws_egress_only_internet_gateway.egress_only_igw.id
  destination_ipv6_cidr_block = "::/0"
}
 
resource "aws_route" "private_rt_egress_only_igw_1c" {
  route_table_id              = aws_route_table.private_rt_1c.id
  egress_only_gateway_id      = aws_egress_only_internet_gateway.egress_only_igw.id
  destination_ipv6_cidr_block = "::/0"
}
```

```terraform:sg.tf
locals {
  my_ipv6 = ["<My IPv6アドレス>"]
}

#--------------------------------------
# Security Group : EC2 (Web)
#--------------------------------------

resource "aws_security_group" "web_ec2_sg" {
  name   = "web-ec2-sg"
  vpc_id = aws_vpc.vpc.id

  ingress {
    from_port       = 22
    to_port         = 22
    protocol        = "tcp"
    security_groups = [aws_security_group.bastion_ec2_sg.id]
  }

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb_sg.id]
  }

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Name = "web-ec2-sg"
  }
}

#--------------------------------------
# Security Group : EC2 (Bastion)
#--------------------------------------

resource "aws_security_group" "bastion_ec2_sg" {
  name   = "bastion-ec2-sg"
  vpc_id = aws_vpc.vpc.id

  ingress {
    from_port        = 22
    to_port          = 22
    protocol         = "tcp"
    ipv6_cidr_blocks = local.my_ipv6
  }

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Name = "bastion-ec2-sg"
  }
}

#--------------------------------------
# Security Group : ALB
#--------------------------------------

resource "aws_security_group" "alb_sg" {
  name   = "alb-sg"
  vpc_id = aws_vpc.vpc.id

  ingress {
    from_port        = 80
    to_port          = 80
    protocol         = "tcp"
    ipv6_cidr_blocks = ["::/0"]
  }

  ingress {
    from_port        = 443
    to_port          = 443
    protocol         = "tcp"
    ipv6_cidr_blocks = ["::/0"]
  }

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Name = "alb-sg"
  }
}
```

```terraform:ec2.tf
# インスタンスタイプ
locals {
  dastion_instance_type = "t2.micro"
  web_1a_instance_type  = "t2.micro"
  web_1c_instance_type  = "t2.micro"
}

#--------------------------------------
# AMI : AmazonLinux2023 (x86_64)
#--------------------------------------

data "aws_ami" "amazon_linux_2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-kernel-6.1-x86_64"]
  }
}

#--------------------------------------
# key_pair
#--------------------------------------

resource "aws_key_pair" "key_pair" {
  key_name   = "example-key"
  public_key = file("./key_pair/public_key.pub")

  tags = {
    Name = "example-key"
  }
}

#--------------------------------------
# EC2 (Bastion)
#--------------------------------------

resource "aws_instance" "bastion_ec2" {
  ami           = data.aws_ami.amazon_linux_2023.id
  instance_type = local.dastion_instance_type
  subnet_id     = aws_subnet.public_subnet_1a.id

  vpc_security_group_ids      = [aws_security_group.bastion_ec2_sg.id]
  associate_public_ip_address = false
  ipv6_address_count          = 1

  key_name = aws_key_pair.key_pair.key_name

  root_block_device {
    volume_size           = 8
    volume_type           = "gp3"
    iops                  = 3000
    throughput            = 125
    delete_on_termination = true

    tags = {
      Name = "bastion-ebs"
    }
  }

  tags = {
    Name = "bastion-ec2"
  }
}

#--------------------------------------
# EC2 (Web/App)
#--------------------------------------

resource "aws_instance" "web_1a_ec2" {

  ami           = data.aws_ami.amazon_linux_2023.id
  instance_type = local.web_1a_instance_type
  subnet_id     = aws_subnet.private_subnet_1a.id

  vpc_security_group_ids      = [aws_security_group.web_ec2_sg.id]
  associate_public_ip_address = false
  ipv6_address_count          = 1

  key_name = aws_key_pair.key_pair.key_name

  root_block_device {
    volume_size           = 8
    volume_type           = "gp3"
    iops                  = 3000
    throughput            = 125
    delete_on_termination = true

    tags = {
      Name = "web-1a-ebs"
    }
  }

  tags = {
    Name = "web-1a-ec2"
  }
}

resource "aws_instance" "web_1c_ec2" {
  ami           = data.aws_ami.amazon_linux_2023.id
  instance_type = local.web_1c_instance_type
  subnet_id     = aws_subnet.private_subnet_1c.id

  vpc_security_group_ids      = [aws_security_group.web_ec2_sg.id]
  associate_public_ip_address = false
  ipv6_address_count          = 1

  key_name = aws_key_pair.key_pair.key_name

  root_block_device {
    volume_size           = 8
    volume_type           = "gp3"
    iops                  = 3000
    throughput            = 125
    delete_on_termination = true

    tags = {
      Name = "web-1c-ebs"
    }
  }

  tags = {
    Name = "web-1c-ec2"
  }
}
```

```terraform:alb.tf
#--------------------------------------
# ALB
#--------------------------------------

resource "aws_lb" "alb" {
  name               = "example-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb_sg.id]
  subnets = [
    aws_subnet.public_subnet_1a.id,
    aws_subnet.public_subnet_1c.id
  ]
  ip_address_type = "dualstack-without-public-ipv4"

  enable_http2               = false # http2を有効化するかどうか
  enable_deletion_protection = false # 削除保護

  # アクセスログの保存設定(必要に応じて設定してください)
  # access_logs {
  #    bucket = ""
  #     prefix = ""
  #     enable = ""
  # }

  tags = {
    Name = "example-alb"
  }
}

#--------------------------------------
# Listener Rule
#--------------------------------------

# リダイレクト設定
resource "aws_lb_listener" "alb_listener_http" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = 443
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

resource "aws_lb_listener" "alb_listener_https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 443
  protocol          = "HTTPS"

  ssl_policy      = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn = aws_acm_certificate.tokyo_cert.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.alb_target_group.arn
  }

  depends_on = [
    aws_acm_certificate_validation.cert_valid
  ]
}

#--------------------------------------
# Target Group
#--------------------------------------

resource "aws_lb_target_group" "alb_target_group" {
  name     = "example-web-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.vpc.id

  # ヘルスチェック先
  health_check {
    path = "/index.html"
  }
}

resource "aws_lb_target_group_attachment" "instance1" {
  target_group_arn = aws_lb_target_group.alb_target_group.arn
  target_id        = aws_instance.web_1a_ec2.id
  port             = 80
}

resource "aws_lb_target_group_attachment" "instance2" {
  target_group_arn = aws_lb_target_group.alb_target_group.arn
  target_id        = aws_instance.web_1c_ec2.id
  port             = 80
}
```

```terraform:route53
locals {
  domain = "<ドメイン名>"
}

#--------------------------------------
# Route53 : Host Zone
#--------------------------------------

resource "aws_route53_zone" "public_zone" {
  name = local.domain
}

#--------------------------------------
# Route53 : Record (AAAA)
#--------------------------------------

resource "aws_route53_record" "ipv6_record" {
  zone_id = aws_route53_zone.public_zone.zone_id
  name    = local.domain
  type    = "AAAA"

  alias {
    name                   = aws_lb.alb.dns_name
    zone_id                = aws_lb.alb.zone_id
    evaluate_target_health = true
  }
}

#--------------------------------------
# Route53 : Record (DNS検証用レコード)
#--------------------------------------

resource "aws_route53_record" "route53_acm_dns_resolve" {
  for_each = {
    for dvo in aws_acm_certificate.tokyo_cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 600
  type            = each.value.type
  zone_id         = aws_route53_zone.public_zone.id
}
```

```terraform:acm.tf
#--------------------------------------
# ACM
#--------------------------------------

resource "aws_acm_certificate" "tokyo_cert" {
  domain_name       = local.domain
  validation_method = "DNS"

  tags = {
    Name = "wildecard-sslcert"
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_acm_certificate_validation" "cert_valid" {
  certificate_arn         = aws_acm_certificate.tokyo_cert.arn
  validation_record_fqdns = [for record in aws_route53_record.route53_acm_dns_resolve : record.fqdn]
}
```
# インスタンスの確認
実際に作成されたインスタンス情報を確認していきます。
※VPCに関しては、過去の記事で動作確認も行っているため、本ブログでは取り上げません。

### EC2
すべてのEC2インスタンスがPublic IPv4を持たないデュアルスタックのインスタンスとして作成できました。

![picture1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/3c675236-3369-0cba-c52b-955678e69e7f.png)
![picture2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/97a7026d-623f-84ef-7a97-f20fb7b231d7.png)
![picture3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/b1d9a6f6-930f-8c0e-509e-66bc7bab705d.png)

### ALB
こちらもpublic IPv4を持たないデュアルスタックのロードバランサーとして作成できました。
この時ALB名をつけることを忘れておりました...:sweat_smile:

![picture4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/6be83a0f-2eae-e559-f962-69c0a42b180b.png)

### Route53
こちらも問題なく作成されています。

![picture5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/7dfc0cba-3895-09cc-edd7-4d08be22e957.png)

### ACM
証明書も問題なく発行できました。

![picture6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/abad198d-cd66-94df-bdb1-78f2f47de504.png)

# 動作確認
ドメイン名からHTTPS経由でサイトが見られるか確認します。
その際、ALBが各インスタンスに対してトラフィックを分散できているかどうか確認します。

踏み台サーバーから各webサーバーに対して、sshでログインし、テストデータを挿入します。

- web-1a-ec2
```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  web-1a-ec2
</body>
</html>
```

- web-1c-ec2
```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  web-1c-ec2
</body>
</html>
```

踏み台サーバーにwebサーバー用の秘密鍵を入れ、テスト用のapacheをインストールします。
```sh
$ touch /home/ec2-user/.ssh/example-key
$ vi /home/ec2-user/.ssh/example-key　←秘密鍵を入力する
$ cat /home/ec2-user/.ssh/example-key
$ chmod 600 /home/ec2-user/.ssh/example-key
$ ssh -i /home/ec2-user/.ssh/example-key ec2-user@<IPv6アドレス>
```

```
$ sudo su -
# yum update -y
# yum install -y httpd
# touch /var/www/html/index.html
# vi /var/www/html/index.html　←各webサーバー用のテストデータを挿入する。
# systemctl start httpd
# systemctl status httpd
```

では、実際にドメイン名からHTTPS経由でサイトが見られるか確認していきましょう。

- web-1a-ec2

![picture7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/8b38a1ce-0ffd-e37f-c5b7-ad836f1701db.png)

![picture9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/39f7b61d-6373-e8ab-1ed4-0260c1495c19.png)

- web-1c-ec2

![picture8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/0f75483c-7299-8547-0fb4-1aad0fdfd429.png)

![picture10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765414/c4f71194-9b3b-1943-1fdd-58988b0b7faf.png)

問題なく、各webを確認、疎通確認することができました。

# さいごに
以上で、「Public IPv4を持たないデュアルスタック構成でWebサーバー環境をTerraformで構築してみた」でした。
Public IPv4を持たないAWSサービスが少なく、より複雑な構成を作成するのには少し限界があるかなと感じました。
今後保守・運用を考慮した構成も考えておりますので、ブログ更新を気長にお待ちしていただけますと幸いです。

# 参考資料
- [【ハンズオン】AWSで実用的なWordPressを構築してみる！](https://techblog.nhn-techorus.com/archives/20380)
- [Terraformの道6：ACMを導入しHTTPS対応](https://zenn.dev/kou_kawa/articles/20-terraform-aws-ssl)
- [【小ネタ】EC2作成時にユーザーデータでApacheをインストールしてドキュメントルートにindex.htmlを生成する方法](https://dev.classmethod.jp/articles/ec2-userdata-apache-documentroot/)