AWS環境構築でTerraformによるIaCを導入してみた

最近運動不足なのが気がかりなインターンの髙橋です。rexcornuではインフラやセキュリティを担当しています。

今回はrexcornuで開発中の新規プロダクトのインフラを構築するにあたり、IaC（Infrastructure as a Code）を導入しました。

その際に意識したこと、難しかったことに触れたいと思います。

# はじめに：IaCとは何か？
IaCとはInfrastructure as a Codeの略です。一言で言うとインフラの管理とプロビジョニングをコードで行うことを指しています。IaC導入のメリットとして以下の点が挙げられます。

- 環境構築が容易
- ヒューマンエラーの低減
- CI/CD（継続的インテグレーションと継続的デプロイ）に組み込むことでインフラのプロビジョニングの自動化が可能


# なぜIaCを導入するのか？
今後、新規にいくつもプロダクトが作られていく可能性を考えた際に、もし今のうちにIaCを導入しておかないと、今後の開発において以下のような問題が発生すると考えたためです。

- 環境を増やす際に迅速に対応できない
- ​人為的ミスによる障害
- 誰も把握していない環境ができやすい（環境の属人化）

# 利用したIaCツールとクラウドサービス
IaCツールはTerraform、インフラはrexcornuの既存のサービスでも利用しているAWSです。

# 意識したこと
## モジュール化
作成するリソースをモジュール化することで再利用することができます。一般的にプロダクトの開発・運用においては、本番環境だけではなく、本番環境に反映させる前のテスト環境を構築します。場合によっては、ステージング環境、デモ環境などもっと多くの環境の作成が必要になるかもしれません。その際にモジュールを使用しないと、各環境でリソースの定義をする必要があり、コードの品質低下を招く恐れがあります。

実環境の情報の記載が難しいため、以下にモジュールを用いたS3バケットのリソースの作成例を記載します。

サンプルの構成

Terraformのリポジトリ：InfraRepo

モジュール化するS3：InfraRepo/modules/s3

```
.
├── InfraRepo
    ├── main.tf
    ├── terraform.tf
    ├── aws.tf
    └── modules/
        └── s3/
            └── main.tf
```

モジュール化したS3のリソース定義
```
InfraRepo/modules/s3/main.tf

# S3バケットを作成
resource "aws_s3_bucket" "test" {
  bucket = "test"
}
# S3バケットのバージョニングを有効化
resource "aws_s3_bucket_versioning" "test" {
  bucket = aws_s3_bucket.test.id

  versioning_configuration {
    status = "Enabled"
  }
}
# S3バケットの暗号化
resource "aws_s3_bucket_server_side_encryption_configuration" "test" {
  bucket = aws_s3_bucket.test.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}
# S3バケットのパブリックアクセスブロックを有効化
resource "aws_s3_bucket_public_access_block" "test" {
  bucket                  = aws_s3_bucket.test.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
```

定義したS3のモジュールの呼び出し
```
InfraRepo/main.tf

module "s3" {
  source = "./modules/s3"
}
```

InfraRepoディレクトリ直下で以下のterraformコマンドを実行してS3バケットを作成。
```
cd [InfraRepoディレクトリ]

#Terraformのワークスペースの初期化
terraform init

#環境に適用される内容の確認
terraform plan

#環境への適用
(planの結果が問題なければ) terraform apply
```

またモジュール化する際にその粒度も意識しました。

モジュールの単位としては、いろいろな記事を見てみると、network、application、securityといった単位にする例も多く見られますが、今回の構築では、アプリケーションエンジニア目線で、インフラの構成を容易にイメージできるようにすることを重視して、以下のように粒度を大きくして機能毎にモジュールを作成することにしました。

```
.
├── InfraRepo
├── ・
├── ・
└── modules/
    ├── frontend 
    ├── backend 
    ├── ecr 
    ├── vpc
    ├── ・
    └── ・
```

## デプロイメントに影響するリソースを最小にする
インフラをデプロイする際に反映するリソースを最小限にとどめたいと考えました。
理由は以下の2点です

- 反映するリソースを最小限にできれば、万が一障害が起きたとしても影響を最小限にとどめられる
- 何度も変更する必要のないリソース（例えばECR、ACM）までデプロイを行いたくない

以下のような構成を取り何度も変更が行われないリソースと変更を行うリソースでディレクトリを分割しました。

:::note info
initial/env/{環境名}：初期設定リソースのディレクトリ（何度も変更されない）
env/{環境名}：定期的に変更が行われるリソースのディレクトリ
:::

```
.
├── InfraRepo
├── env/
│   ├── stg
│   ├── production
│   ├── ・
│   └── ・
├── initial/
│   └── env/
│       ├── stg
│       ├── production
│       ├── ・
│       └── ・
└── modules/
    ├── ・
    └── ・
```

CI/CDのパイプラインではenv/{環境名}ディレクトリのみをデプロイの対象とし、initial/env/{環境名}ディレクトリは対象外とすることで、env/{環境名}ディレクトリ直下のリソースのみデプロイメントに影響を与えるようにしました。

## 公式ドキュメントを細かく確認
技術ブログに出回っている記事で非常に勉強になるものが多くありますが、その中にはアップデートにより使えなかったり、非推奨の書きかたをしているものがありました。
そのため公式ドキュメントを適宜確認し、最新の記法で書くことを意識しました。

# 結果
無事インフラのIaC導入はできました。記事ではS3のリソース作成のサンプルを示しましたが、実際にTerraformを使って作成されたAWS環境の構成は以下の通りです。

フロントエンド：Cloudfront+S3

バックエンド ：ALB+ECS(+RDS） 

※ WAFはALBにアタッチしています。

![Screenshot from 2024-03-02 16-18-42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3719990/84a8f4ec-4e44-c2af-a93f-0921c78d8713.png)

Terraformのコードを以下に示します。

:::note info
既存システムのフロントエンド、バックエンドはEC2で動作しています。
今回は
- フロントエンド　EC2 → Cloudfront + S3
- バックエンド　　EC2 → ECS

に置き換える対応を行ったので、上記の3リソースの部分を記載します。
:::

- フロントエンド

```
../modules/frontend/cloudfront.tf
# 留意点
# コードに度々出てくるvar.shared_prefixに環境ごとのプレフィックスが入ります。

# Cloudfront OACの設定（アクセス制御）
resource "aws_cloudfront_origin_access_control" "frontend" {
  name                              = "${var.shared_prefix}-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}
# Cloudfrontディストリビューションの作成
resource "aws_cloudfront_distribution" "frontend" {

  origin {
    domain_name              = aws_s3_bucket.frontend.bucket_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
    origin_id                = aws_s3_bucket.frontend.id
  }

  enabled         = true
  is_ipv6_enabled = true

  aliases = [var.frontend_domain_name]

  default_root_object = "index.html"

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = aws_s3_bucket.frontend.id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "https-only"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
    compress               = true
  }

  restrictions {
    geo_restriction {
      restriction_type = "whitelist"
      locations        = ["JP"]
    }
  }

  logging_config {
    include_cookies = false
    bucket          = aws_s3_bucket.log.bucket_domain_name
    prefix          = "${var.shared_prefix}-cf-log"
  }

  tags = {
    Name = "${var.shared_prefix}-cf"
  }

  viewer_certificate {
    acm_certificate_arn      = var.acm_cert_arn
    minimum_protocol_version = "TLSv1.2_2021"
    ssl_support_method       = "sni-only"
  }
}
```

```
../modules/frontend/s3.tf
# 留意点
# コードに度々出てくるvar.shared_prefixに環境ごとのプレフィックスが入ります。

# フロントエンドのアプリをビルドしたものを入れておくS3バケットの作成
resource "aws_s3_bucket" "frontend" {
  bucket = "${var.shared_prefix}-frontend"

  tags = {
    Name = "${var.shared_prefix}-frontend"
  }
}
# S3バケットのバージョニングを有効化
resource "aws_s3_bucket_versioning" "frontend" {
  bucket = aws_s3_bucket.frontend.id

  versioning_configuration {
    status = "Enabled"
  }
}
# S3バケットの暗号化
resource "aws_s3_bucket_server_side_encryption_configuration" "frontend" {
  bucket = aws_s3_bucket.frontend.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}
# S3バケットのパブリックアクセスブロックを有効化
resource "aws_s3_bucket_public_access_block" "frontend" {
  bucket                  = aws_s3_bucket.frontend.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
# S3バケットのポリシーの作成
resource "aws_s3_bucket_policy" "frontend" {
  bucket = aws_s3_bucket.frontend.id
  policy = data.aws_iam_policy_document.frontend.json
}
# S3バケットのポリシーの設定
data "aws_iam_policy_document" "frontend" {
  statement {
    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.frontend.arn}/*"]

    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [aws_cloudfront_distribution.frontend.arn]
    }
  }
}

# Cloudfrontに対してのアクセスログを入れるS3バケットの作成
resource "aws_s3_bucket" "log" {
  bucket = "${var.shared_prefix}-cf-log"

  tags = {
    Name = "${var.shared_prefix}-cf-log"
  }
}
# S3バケットの所有権に関しての設定（これをしないとCloudfrontからログが送られません）
resource "aws_s3_bucket_ownership_controls" "log" {
  bucket = aws_s3_bucket.log.id
  rule {
    object_ownership = "ObjectWriter"
  }
}
# S3バケットのアクセスコントロールリストを作成
resource "aws_s3_bucket_acl" "log" {
  depends_on = [aws_s3_bucket_ownership_controls.log]

  bucket = aws_s3_bucket.log.id
  acl    = "private"
}
# S3バケットのパブリックアクセスブロックを有効化
resource "aws_s3_bucket_public_access_block" "log" {
  bucket                  = aws_s3_bucket.log.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
```

- バックエンド

```
../modules/backend/ecs.tf
# 留意点
# コードに度々出てくるvar.shared_prefixに環境ごとのプレフィックスが入ります。

# ECRリポジトリの情報を参照
data "aws_ecr_repository" "backend" {
  name = var.shared_prefix
}
# ECRリポジトリのイメージの情報を参照
data "aws_ecr_image" "backend" {
  repository_name = data.aws_ecr_repository.backend.name
  most_recent     = true
}
# SSMパラメータストアに格納したDBのパスワードを参照
data "aws_ssm_parameter" "db_password" {
  name = "/${var.shared_prefix}/db-password"
}
# AWSリージョンの参照
data "aws_region" "current" {}
# ECSクラスターの作成
resource "aws_ecs_cluster" "backend" {
  name = "${var.shared_prefix}-backend"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}
# ECSの実行に用いるロールを作成
resource "aws_iam_role" "backend" {
  name = "${var.shared_prefix}-backend"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Service = "ecs-tasks.amazonaws.com" }
        Action    = "sts:AssumeRole"
      },
    ]
  })
  managed_policy_arns = [
    "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
    aws_iam_policy.exec.arn,
    aws_iam_policy.get_parameter.arn,
  ]
}
# ECS Execを行うためのポリシー
resource "aws_iam_policy" "exec" {
  name = "${var.shared_prefix}-exec"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "ssmmessages:CreateControlChannel",
          "ssmmessages:CreateDataChannel",
          "ssmmessages:OpenControlChannel",
          "ssmmessages:OpenDataChannel"
        ]
        Effect   = "Allow"
        Resource = "*"
      },
    ]
  })
}

# SSMパラメータを取得するためのポリシー
resource "aws_iam_policy" "get_parameter" {
  name = "${var.shared_prefix}-get-parameter"

  policy = jsonencode(
    {
      "Version" : "2012-10-17",
      "Statement" : [
        {
          "Effect" : "Allow",
          "Action" : [
            "ssm:DescribeParameters"
          ],
          "Resource" : "*"
        },
        {
          "Effect" : "Allow",
          "Action" : [
            "ssm:GetParameters",
          ],
          "Resource" : "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${var.shared_prefix}/*"
        }
      ]
    }
  )
}
# ECSタスク定義の作成
resource "aws_ecs_task_definition" "backend" {
  family                   = "${var.shared_prefix}-backend"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = *** # セキュリティ上の観点から伏せます
  memory                   = *** # セキュリティ上の観点から伏せます
  execution_role_arn       = aws_iam_role.backend.arn
  container_definitions = jsonencode([
    {
      name  = "${var.shared_prefix}-backend"
      image = "${data.aws_ecr_repository.backend.repository_url}:${data.aws_ecr_image.backend.image_tags[0]}"
      secrets = [
        {
          name      = "ENDPOINT",
          valueFrom = aws_ssm_parameter.db_endpoint.arn
        },
        {
          name = "PASSWORD",
          valueFrom = data.aws_ssm_parameter.db_password.arn
        }
      ]
      essential = true
      # セキュリティ上の観点からポート番号は伏せます
      portMappings = [
        {
          protocol      = "tcp",
          containerPort = ***,
          hostPort      = ***
        }
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          awslogs-region : "ap-northeast-1"
          awslogs-group : aws_cloudwatch_log_group.backend.name
          awslogs-stream-prefix : "${var.shared_prefix}-ecs"
        }
      }
    }
  ])
}
# ECSサービスの作成
resource "aws_ecs_service" "backend" {
  name                               = "${var.shared_prefix}-backend"
  cluster                            = aws_ecs_cluster.backend.id
  platform_version                   = "1.4.0"
  task_definition                    = aws_ecs_task_definition.backend.arn
  desired_count                      = 2
  deployment_minimum_healthy_percent = 100
  deployment_maximum_percent         = 200
  enable_execute_command             = true
  launch_type                        = "FARGATE"
  health_check_grace_period_seconds  = 3600

  network_configuration {
    assign_public_ip = false
    subnets          = [for subnet in var.private_subnets : subnet.id]
    security_groups = [
      aws_security_group.ecs.id
    ]
  }
  # セキュリティ上の観点からポート番号は伏せます
  load_balancer {
    target_group_arn = aws_lb_target_group.alb.arn
    container_name   = "${var.shared_prefix}-backend"
    container_port   = ***
  }
}

resource "aws_cloudwatch_log_group" "backend" {
  name              = "/${var.shared_prefix}/backend-ecs"
  retention_in_days = 30

  tags = {
    "Name" = "${var.shared_prefix}-backend"
  }
}
```

# 難しかったこと
## インフラアセスメント
AWSリソースには必須の設定項目だけでなく任意の設定項目も多く存在しています。
作成する各リソースに対して、どの程度の最適化およびセキュリティ対策を必要とするのかを決めるのが難しいと感じることがありました。

# 今後の課題
## テンプレートの作成
共通的に必要な非機能要件が最低限盛り込まれたTerraformテンプレートの整備を今後していきたいと考えています。そうすることで今後発生するプロジェクトにおいて個々のアプリ要件を確認すれば、堅牢なインフラがクイックに構築できると考えています。

# おわりに
今回はIaCの記事をご紹介しましたが、次回はAWSインフラやセキュリティ関連で何か取り組んだことを紹介していきたいと思います。

rexcornuでは、これからの会社の成長を共にしてくれる仲間を募集しています。

https://www.rexcornu.com/career

https://www.wantedly.com/companies/company_9295588/projects

https://www.rexcornu.com/

https://c.tech-match.net/