JenkinsをTerraformで構築してみた(ECS on EC2 + Amazon FSx for NetApp ONTAP)

2024.04.05

ゲームソリューション部の えがわ です。

本日はJenkinsの実行環境をTerraformで構築してみました。

構成

リソース

コンピューティングリソースはECS on EC2、ストレージはAmazon FSx for NetApp ONTAP(以降FSxN)を使用します。
FargateではFSxNをマウントすることができないためEC2を選択しています。
※2024/04/05現在

構成図

EC2はパブリックサブネット、FSxNはプライベートサブネットに配置します。

FSxNとは

高いパフォーマンスと豊富な機能を提供するフルマネージドなストレージサービスです。

Amazon FSx for NetApp ONTAP は、 NetAppの人気のある ONTAP ファイルシステム上に構築された、信頼性が高く、スケーラブルで、パフォーマンスが高く、機能豊富なファイルストレージを提供するフルマネージドサービスです。

FSx for ONTAP は、 NetApp ファイルシステムの使い慣れた機能、パフォーマンス、機能、および API オペレーションと、フルマネージド型の の俊敏性、スケーラビリティ、およびシンプルさを組み合わせますAWS のサービス。

弊社ブログにも記事が豊富にありますので興味がある方はご確認ください。

構築してみる

構築する順番として以下の順で行います。

  • VPC
  • FSxN
  • ECS on EC2

Terraformファイルは他で再利用する可能性があるため分割しています。

VPCの作成

TerraformでVPCを作成します。

リポジトリはこちら

変数ファイルはそのままでも使用できますが、お好きなように修正してください。

/terraform.tfvars

project_prefix       = "hyper_project"
region               = "us-east-1"
vpc_cidr_block       = "10.0.0.0/16"
public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.3.0/24", "10.0.4.0/24"]

Terraformを実行しVPCを作成します。

terraform init
terraform plan
terraform apply

作成したVPCのID、サブネットのIDは以下のコマンドで取得できます。

# VPC IDの取得
aws ec2 describe-vpcs \
  --query 'Vpcs[*].{VpcId:VpcId,Name:Tags[?Key==`Name`]|[0].Value}'\
  --output text

# サブネットIDの取得
aws ec2 describe-subnets \
  --filters "Name=vpc-id,Values={任意のVPC ID}" \
  --query 'Subnets[*].{SubnetId:SubnetId,Name:Tags[?Key==`Name`]|[0].Value}' \
  --output text

後で使用しますのでIDを保存しておきます。

FSxNの構築

TerraformでFSxNを作成します。

リポジトリはこちら

/modules/fsxn/main.tf

resource "aws_security_group" "nfs_sg" {
  name   = "${var.project_prefix}-nfs-sg"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 2049
    to_port     = 2049
    protocol    = "tcp"
    cidr_blocks = [var.vpc_cidr_block]
  }

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

resource "aws_fsx_ontap_file_system" "file_system" {
  storage_capacity    = 1024 # 最小構成
  subnet_ids          = [var.subnet_id]
  preferred_subnet_id = var.subnet_id
  deployment_type     = "SINGLE_AZ_1"
  throughput_capacity = 128 # 最小構成
  security_group_ids  = [aws_security_group.nfs_sg.id]
  fsx_admin_password  = var.fsx_admin_password

  tags = {
    Name = "${var.project_prefix}-fsxn"
  }

  timeouts {
    create = "60m"
    update = "60m"
  }
}

resource "aws_fsx_ontap_storage_virtual_machine" "svm" {
  file_system_id = aws_fsx_ontap_file_system.file_system.id
  name           = "${var.project_prefix}-svm"
}

resource "aws_fsx_ontap_volume" "volume" {
  name                       = "vol"
  junction_path              = "/vol1"
  size_in_megabytes          = 1048576 # ボリュームのサイズを指定
  storage_efficiency_enabled = true
  storage_virtual_machine_id = aws_fsx_ontap_storage_virtual_machine.svm.id

  lifecycle {
    ignore_changes = [
      tiering_policy[0].cooling_period
    ]
  }
}

変数ファイルはコミットされていません。
以下のファイルを作成し適切な値を設定してください。

/terraform.tfvars

project_prefix     = "hyper_project"
region             = "us-east-1"
vpc_cidr_block     = "10.0.0.0/16"
vpc_id             = "vpc-xxxxxxxxxxxxxxx"
subnet_id          = "subnet-xxxxxxxxxxxxxxx" # プライベートサブネットを選択
fsx_admin_password = "英数文字混合8文字以上"

Terraformを実行しFSxNを作成します。

terraform init
terraform plan
terraform apply

こちらでもありますが、作成には30分ほどかかります。

module.fsxn.aws_fsx_ontap_file_system.file_system: Creating...
module.fsxn.aws_fsx_ontap_file_system.file_system: Still creating... [10s elapsed]
module.fsxn.aws_fsx_ontap_file_system.file_system: Still creating... [20s elapsed]
...中略
module.fsxn.aws_fsx_ontap_file_system.file_system: Still creating... [24m10s elapsed]
module.fsxn.aws_fsx_ontap_file_system.file_system: Still creating... [24m20s elapsed]
module.fsxn.aws_fsx_ontap_file_system.file_system: Creation complete after 24m24s [id=fs-xxxxxxxxxxxxxx]

ECS on EC2を作成

リポジトリはこちら

/modules/ecs/main.tf

resource "aws_ecs_cluster" "this" {
  name = "${var.project_prefix}-cluster"
}

resource "aws_security_group" "ecs_sg" {
  name   = "${var.project_prefix}-ecs-sg"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 8080
    to_port     = 8080
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

data "aws_ssm_parameter" "amzn_ami" {
  name = "/aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_id"
}

resource "aws_launch_template" "ecs_ec2" {
  name                   = "${var.project_prefix}-ecs-ec2-template"
  image_id               = data.aws_ssm_parameter.amzn_ami.value
  instance_type          = var.ecs_instance_type
  vpc_security_group_ids = [aws_security_group.ecs_sg.id]

  iam_instance_profile { arn = var.instance_profile_arn }
  monitoring { enabled = true }

  user_data = base64encode(<<-EOF
      #!/bin/bash
      echo ECS_CLUSTER=${aws_ecs_cluster.this.name} >> /etc/ecs/ecs.config;
      mkdir -p ${var.source_path}
      echo -e "${var.nfs_dns_name}:/${var.junction_path}\t${var.source_path}\tnfs\tnfsvers=4,_netdev,noresvport,defaults\t0\t0" | tee -a /etc/fstab
      systemctl daemon-reload
      mount -a
      chmod 775 ${var.source_path}
      chown 1000:1000 ${var.source_path}
    EOF
  )
}

resource "aws_autoscaling_group" "ecs" {
  name                      = "${var.project_prefix}-ecs-asg"
  vpc_zone_identifier       = var.subnet_ids
  health_check_grace_period = 0
  health_check_type         = "EC2"
  protect_from_scale_in     = false
  desired_capacity          = 1
  min_size                  = 1
  max_size                  = 1

  launch_template {
    id      = aws_launch_template.ecs_ec2.id
    version = "$Latest"
  }

  tag {
    key                 = "Name"
    value               = "${var.project_prefix}-ecs-instance"
    propagate_at_launch = true
  }
}

resource "aws_ecs_capacity_provider" "this" {
  name = "${var.project_prefix}-ecs-ec2-cp"

  auto_scaling_group_provider {
    auto_scaling_group_arn         = aws_autoscaling_group.ecs.arn
    managed_termination_protection = "DISABLED"

    managed_scaling {
      maximum_scaling_step_size = 1
      minimum_scaling_step_size = 1
      status                    = "ENABLED"
      target_capacity           = 100
    }
  }
}

resource "aws_ecs_cluster_capacity_providers" "main" {
  cluster_name       = aws_ecs_cluster.this.name
  capacity_providers = [aws_ecs_capacity_provider.this.name]

  default_capacity_provider_strategy {
    capacity_provider = aws_ecs_capacity_provider.this.name
    base              = 1
    weight            = 100
  }
}

resource "aws_cloudwatch_log_group" "jenkins" {
  name              = "${var.project_prefix}-log"
  retention_in_days = 14
}

resource "aws_ecs_task_definition" "jenkins" {
  family             = "${var.project_prefix}-taskdef"
  network_mode       = "bridge"
  cpu                = 512
  memory             = 512
  execution_role_arn = var.exec_role_arn
  task_role_arn      = var.task_role_arn

  volume {
    name      = var.source_volume
    host_path = var.source_path
  }

  container_definitions = jsonencode([
    {
      name  = "jenkins"
      image = "jenkins/jenkins:lts"
      portMappings = [
        {
          containerPort = 8080
          hostPort      = 8080
        },
        {
          containerPort = 50000
          hostPort      = 50000
        }
      ]
      environment = [
        {
          name  = "JAVA_OPTS"
          value = "-Duser.timezone=Asia/Tokyo -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 -Xmx1524m -Xms256m"
        }
      ]
      mountPoints = [
        {
          sourceVolume  = var.source_volume
          containerPath = var.container_path
        }
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.jenkins.name
          "awslogs-region"        = var.region
          "awslogs-stream-prefix" = "ecs"
        }
      }
    }
  ])
}

resource "aws_ecs_service" "jenkins" {
  name                   = "${var.project_prefix}-ecs-service"
  cluster                = aws_ecs_cluster.this.id
  task_definition        = aws_ecs_task_definition.jenkins.arn
  desired_count          = 1
  enable_execute_command = true

  capacity_provider_strategy {
    capacity_provider = aws_ecs_capacity_provider.this.name
    base              = 1
    weight            = 100
  }

  ordered_placement_strategy {
    type  = "spread"
    field = "attribute:ecs.availability-zone"
  }

  lifecycle {
    ignore_changes = [desired_count]
  }
}

こちらも変数ファイルはリポジトリに含まれていません。
以下のファイルを作成し適切な値を設定してください。

/terraform.tfvars

project_prefix    = "hyper_project"
region            = "us-east-1"
vpc_cidr_block    = "10.0.0.0/16"
vpc_id            = "vpc-xxxxxxxxxxxxxxxx"
subnet_ids        = ["subnet-xxxxxxxxxxxxxxxx", "subnet-xxxxxxxxxxxxxxxx"]
ecs_instance_type = "t3.medium"
source_volume     = "fsxn-vol1"
source_path       = "/mnt/fsxn/vol1"
container_path    = "/var/jenkins_home"
junction_path     = "vol1"
nfs_dns_name      = "svm-xxxxxxxxxxxxxxxx.fs-xxxxxxxxxxxxxxxx.fsx.us-east-1.amazonaws.com"

nfs_dns_nameはマネジメントコンソールから確認できます。

Terraformを実行しECS on EC2環境を作成します。

terraform init
terraform plan
terraform apply

Jenkinsにアクセス

EC2のパブリックIPアドレスを取得します。

aws ec2 describe-instances \
  --filters "Name=instance-state-name,Values=running" \
  --query 'Reservations[*].Instances[*].[Tags[?Key==`Name`].Value|[0],PublicIpAddress]' \
  --output text

ブラウザでアクセスしてみます。

http://{取得したパブリックIP}:8080/

Jenkinsの初期画面が表示されました。

初期パスワードに関しては起動したEC2にSessionManagerで接続し、以下のコマンドで取得できます

sudo cat /mnt/fsxn/vol1/secrets/initialAdminPassword

Jenkinsが利用可能になりました。

最後に

Jenkinsの実行環境をTerraformで構築する過程を紹介しました。
ECSとFSxNを組み合わせることで、高パフォーマンスかつ機能豊富なストレージを活用できるJenkinsの実行環境を構築できます。
本記事がどなたかの参考になれば幸いです。