LoginSignup
6
4

More than 1 year has passed since last update.

AWS EC2環境でPull Request毎に開発環境構築するCI/CDパイプラインをJenkins+Terraformで実装した話

Last updated at Posted at 2021-12-23

この記事はTerraform Advent Calendar 2021の24日目です:rocket:

タイトルながああああああ!!!!!
どうも、@Tocyukiこと、としゆきです。
実は今年の8月に神奈川県は川崎市から長野県は佐久市に移住したんですよ。

で、ですよ、川崎に住んでいた頃に足繁く通った二郎系インスパイアの雄、麺でる川崎808ismが食べたくて食べたくてしょうがなかったんですけど、最近通販を始めたみたいで長野でもおうち麺でる出来ることが判明してぶち上がってます:ramen:

ニンニクいれますか?開発環境足りてますか?

最近はやれECSだ、やれk8sだ、などとコンテナ環境がだいぶ浸透してきてますが、EC2などのVM環境で動いているシステムもまだまだ多いのではないでしょうか?

コード化などもしていないと多くの場合、Nginx等のVirtualHostでVM1台に複数環境を建てられている、などということも日常茶飯事かと思います。
そして開発が活発になると、環境が足りなくなり、VirtualHostによる環境追加が繰り返されて、VMのリソースが枯渇し無事全環境死亡・・・、なんてこともあったりなかったりしてもう困っちゃいますよね?

というわけで(?)、TerraformとJenkinsを使った夢の専用開発環境自動構築CI/CDパイプラインを作ったのでどのように実装したかを書いていきたいと思いますー!

今回お話しすること

以下赤枠部分について掻い摘んでお話します。
仕組みの簡単な説明としてはPRをOpen&Closeした際に、ブランチ名をサブドメインとした専用の開発環境の自動構築&削除をTerraformとJenkinsを使って実現します。

feature-ci-cdのコピー.jpeg

今回お話ししないこと

  • TerraformおよびTerraformのディレクトリ設計についての説明
  • Jenkins、Jenkinsfileについての説明
  • 各コードの説明

前提

  • AMI、Database、NWリソース、ACM、tfstate用S3バケット等は別途用意されている
  • PR毎に構築される環境の名前はfeature環境

Terraformの実装

すでにある開発環境のリソースとは別に以下のようなAWSリソースを作成するtfファイルを用意します。
ちなみにDNSにはCloudflareを利用しています。

Terraformディレクトリツリー
terraform
├── data_source.tf
├── main.tf
├── modules
│   ├── aws
│   │   └── app
│   │       ├── alb.tf
│   │       ├── ami.tf
│   │       ├── codedeploy.tf
│   │       ├── ec2.tf
│   │       ├── iam.tf
│   │       ├── s3.tf
│   │       ├── scripts
│   │       │   └── user_data.sh
│   │       ├── security_group.tf
│   │       └── variables.tf
│   └── cloudflare
│       └── domain
│           ├── dns.tf
│           ├── provider.tf
│           └── variables.tf
└── variables.tf

data_source.tf

feature環境で利用するNWリソース、DB、ACM等の既存開発環境リソースを定義します。

こういう取り組みをしようとした場合、命名規則がきちんと設計されて運用できていると実装が楽になるのでAWSリソースの命名規則はしっかりしておきましょう!

data_source.tf
data "aws_vpc" "vpc" {
  filter {
    name   = "tag:Name"
    values = ["${var.name}-dev-vpc"]
  }
}

data "aws_subnet" "public" {
  for_each = var.azs

  filter {
    name   = "tag:Name"
    values = ["${var.name}-dev-public-subnet-${each.key}"]
  }
}

data "aws_subnet" "private_app" {
  for_each = var.azs

  filter {
    name   = "tag:Name"
    values = ["${var.name}-dev-private-app-subnet-${each.key}"]
  }
}

data "aws_security_group" "db" {
  filter {
    name   = "tag:Name"
    values = ["${var.name}-dev-db-sg"]
  }
}

data "aws_acm_certificate" "app" {
  domain = "*.${var.domain[var.service]}"
}

main.tf

Jenkinsから実行するときにtfstateファイル名を-backend-configで渡すため、S3バックエンドのtfstate定義は記載しないようにします。
前述の通り、すでにあるtfstate用のS3バケットを利用します。

main.tf
terraform {
  required_version = "~>0.15"

  backend "s3" {
    bucket = "example-dev-tfstate-XXXXXXXXXXXX"
    region = "ap-northeast-1"
  }

  required_providers {
    aws = {
      version = ">=3.44.0"
      source  = "hashicorp/aws"
    }

    cloudflare = {
      version = ">=2.21.0"
      source  = "cloudflare/cloudflare"
    }
  }
}

module "dns_cloudflare" {
  source  = "./modules/cloudflare/domain"
  name    = var.name
  env     = replace(split("/", lower(var.branch))[1], "_", "-")
  service = var.service

  zone_apex    = var.domain
  alb_dns_name = module.app.alb_dns_name
}

module "app" {
  source      = "./modules/aws/app"
  name        = var.name
  env         = replace(split("/", lower(var.branch))[1], "_", "-")
  service     = var.service
  domain      = var.domain[var.service]
  common_tags = local.common_tags

  vpc_id              = data.aws_vpc.vpc.id
  az                  = "aza"
  public_subnets      = data.aws_subnet.public
  private_subnets_app = data.aws_subnet.private_app
  acm_arn             = data.aws_acm_certificate.app.arn

  aws_account_id              = data.aws_caller_identity.current.id
  aws_elb_service_account_arn = data.aws_elb_service_account.alb.arn
  instance_type               = "t3a.micro"
  instance_volume_size        = 30
  db_sg                      = data.aws_security_group.db
}

以下の部分ですが、ブランチ毎に環境が立ち上がるため、Jenkinsから引数で渡ってくるブランチがfeature/create_typoであれば各リソース定義に利用される命名規則のenv部分が、create-typoとなります。

env = replace(split("/", lower(var.branch))[1], "_", "-")

variables.tf

servicebranchを未定義にして、Jenkinsで実行する時の引数として渡せるようにしておきます。

variables.tf
data "aws_caller_identity" "current" {}
data "aws_canonical_user_id" "current_user" {}
data "aws_region" "current" {}
data "aws_elb_service_account" "alb" {}
variable "service" {}
variable "branch" {}

locals {
  common_tags = {
    ServiceName = "my-service-name"
    Env         = "feature"
  }
}

variable "name" {
  type    = string
  default = "example"
}

variable "azs" {
  type = map(string)
  default = {
    aza = "ap-northeast-1a"
    azd = "ap-northeast-1d"
  }
}

variable "domain" {
  type = map(string)
  default = {
    system1 = "system1-example.com"
    system2 = "system2-example.com"
  }
}

app module

alb.tf

modules/aws/app/alb.tf
resource "aws_alb" "app" {
  name               = "${var.env}-${var.service}"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = values(var.public_subnets)[*].id
  ip_address_type    = "ipv4"
  enable_http2       = true

  tags = merge(var.common_tags, {
    SystemName = var.service
    Name       = "${var.name}-${var.env}-${var.service}-app-alb"
    Role       = "ALB"
  })
}

resource "aws_alb_target_group" "app_http" {
  name                 = "${var.env}-${var.service}"
  port                 = 80
  protocol             = "HTTP"
  vpc_id               = var.vpc_id
  target_type          = "instance"
  deregistration_delay = 0

  lifecycle {
    create_before_destroy = true
  }

  health_check {
    interval            = 30
    path                = "/"
    protocol            = "HTTP"
    timeout             = 5
    unhealthy_threshold = 2
    matcher             = 200
  }

  tags = merge(var.common_tags, {
    SystemName = var.service
    Name       = "${var.name}-${var.env}-${var.service}-app-alb-tg"
    Role       = "ALB"
  })
}

resource "aws_alb_listener" "app_http" {
  load_balancer_arn = aws_alb.app.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"

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

resource "aws_alb_listener" "app_https" {
  load_balancer_arn = aws_alb.app.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = var.acm_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_alb_target_group.app_http.arn
  }
}

ami.tf

前提としてAMIは作成されているので、そのAMIを使う。

modules/aws/ami.tf
data "aws_ami" "app" {
  most_recent = true
  owners      = ["self"]

  filter {
    name   = "name"
    values = ["${var.name}-dev-${var.service}-ami-*"]
  }

  filter {
    name   = "state"
    values = ["available"]
  }
}

ec2.tf

modules/aws/app/ec2.tf
data "template_file" "user_data" {
  template = file("${path.module}/scripts/user_data.sh")

  vars = {
    name              = var.name
    env               = var.env
    service           = var.service
    domain            = var.domain
    shared_account_id = var.shared_account_id
  }
}

resource "aws_instance" "app" {
  ami                    = data.aws_ami.app.id
  instance_type          = var.instance_type
  iam_instance_profile   = aws_iam_instance_profile.app.name
  user_data              = base64encode(data.template_file.user_data.rendered)
  vpc_security_group_ids = [aws_security_group.app.id]
  subnet_id              = var.private_subnets_app[var.az].id

  root_block_device {
    volume_size = var.instance_volume_size

    tags = merge(var.common_tags, {
      SystemName = var.service
      Name       = "${var.name}-${var.env}-${var.service}-app"
      Role       = "EBS"
    })
  }

  tags = merge(var.common_tags, {
    SystemName = var.service
    Name       = "${var.name}-${var.env}-${var.service}-app"
    Role       = "EC2"
  })
}

resource "aws_alb_target_group_attachment" "app" {
  target_group_arn = aws_alb_target_group.app_http.arn
  target_id        = aws_instance.app.id
}

user_data.sh

起動時に実行される処理を書いたスクリプト。
今回はホスト名をユニークにするための処理しか入れてないが、ここで色々しようと思えばできるので夢膨らみます。

modules/aws/app/scripts/user_data.sh
#!/usr/bin/env bash
set -xe
exec >>(tee /var/log/user-data.log|logger -t user-data -s 2>/dev/console) 2>&1

IP=$(hostname -I |awk '{print $1}'|awk -F. '{OFS="-"}{print $3,$4}')
BASE_NAME="${name}-${env}-${service}"
HOSTNAME="$BASE_NAME-app-$IP"

hostnamectl set-hostname "$HOSTNAME"

codedeploy.tf

Terraformを使えば使い捨てのCodeDeploy定義も楽勝です。

modules/aws/app/codedeploy.tf
resource "aws_codedeploy_app" "app" {
  compute_platform = "Server"
  name             = "${var.name}-${var.env}-${var.service}-app"
}

resource "aws_codedeploy_deployment_group" "app" {
  app_name               = aws_codedeploy_app.app.name
  deployment_group_name  = "${var.name}-${var.env}-${var.service}-app-deployment-group"
  deployment_config_name = "CodeDeployDefault.OneAtATime"
  service_role_arn       = aws_iam_role.codedeploy.arn

  ec2_tag_set {
    ec2_tag_filter {
      key = "Name"
      type = "KEY_AND_VALUE"
      value = "${var.name}-${var.env}-${var.service}-app"
    }
  }

  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE"]
  }

  alarm_configuration {
    alarms  = ["my-alarm-name"]
    enabled = true
  }
}

iam.tf

EC2、SSM、CodeDeployのIAMを定義します。
EC2に鍵は置かずにSessionManagerでのみ接続できるようにします。

modules/aws/app/iam.tf
resource "aws_iam_instance_profile" "app" {
  name = "${var.name}-${var.env}-${var.service}-app-iam-instance-profile"
  role = aws_iam_role.app.name
}

data "aws_iam_policy_document" "app" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      identifiers = ["ec2.amazonaws.com"]
      type        = "Service"
    }
  }
}

resource "aws_iam_role" "app" {
  name               = "${var.name}-${var.env}-${var.service}-app-iam-role"
  path               = "/system/"
  assume_role_policy = data.aws_iam_policy_document.app.json
}

resource "aws_iam_role_policy_attachment" "ssm_for_app" {
  role       = aws_iam_role.app.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM"
}

resource "aws_iam_role_policy_attachment" "s3_for_app" {
  role       = aws_iam_role.app.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}

data "aws_iam_policy_document" "codedeploy" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      identifiers = ["codedeploy.amazonaws.com"]
      type        = "Service"
    }
  }
}

resource "aws_iam_role" "codedeploy" {
  name = "${var.name}-${var.env}-${var.service}-app-codedeploy"

  assume_role_policy = data.aws_iam_policy_document.codedeploy.json
}

resource "aws_iam_role_policy_attachment" "codedeploy" {
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole"
  role       = aws_iam_role.codedeploy.name
}

security_group.tf

modules/aws/app/security_group.tf
resource "aws_security_group" "alb" {
  name        = "${var.name}-${var.env}-${var.service}-app-alb-sg"
  description = "Controls access to the ALB for ${var.name}-${var.env}"
  vpc_id      = var.vpc_id

  tags = merge(var.common_tags, {
    Name = "${var.name}-${var.env}-${var.service}-app-alb-sg"
    Role = "Security Group"
  })
}

resource "aws_security_group_rule" "alb_http_ingress" {
  from_port         = 80
  protocol          = "tcp"
  security_group_id = aws_security_group.alb.id
  to_port           = 80
  type              = "ingress"
  cidr_blocks       = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "alb_https_ingress" {
  from_port         = 443
  protocol          = "tcp"
  security_group_id = aws_security_group.alb.id
  to_port           = 443
  type              = "ingress"
  cidr_blocks       = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "alb_all_egress" {
  from_port         = 0
  protocol          = "-1"
  security_group_id = aws_security_group.alb.id
  to_port           = 0
  type              = "egress"
  cidr_blocks       = ["0.0.0.0/0"]
}

resource "aws_security_group" "app" {
  name        = "${var.name}-${var.env}-${var.service}-app-sg"
  description = "Controls access to the App for ${var.name}-${var.env}"
  vpc_id      = var.vpc_id

  tags = merge(var.common_tags, {
    Name = "${var.name}-${var.env}-${var.service}-app-sg"
    Role = "Security Group"
  })
}

resource "aws_security_group_rule" "app_ingress_http" {
  from_port                = 80
  protocol                 = "tcp"
  security_group_id        = aws_security_group.app.id
  to_port                  = 80
  type                     = "ingress"
  source_security_group_id = aws_security_group.alb.id
}

resource "aws_security_group_rule" "app_egress_all" {
  from_port         = 0
  protocol          = "-1"
  security_group_id = aws_security_group.app.id
  to_port           = 0
  type              = "egress"
  cidr_blocks       = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "db_ingress_from_app" {
  from_port                = 3306
  protocol                 = "tcp"
  security_group_id        = var.db_sg.id
  to_port                  = 3306
  type                     = "ingress"
  source_security_group_id = aws_security_group.app.id
}

s3.tf

CodeDeploy用の成果物置き場のS3バケットを作ります。

modules/aws/app/s3.tf
resource "aws_s3_bucket" "artifact" {
  bucket        = "${var.name}-${var.env}-${var.service}-app-artifact-${var.aws_account_id}"
  acl           = "private"
  force_destroy = true

  versioning {
    enabled = true
  }

  server_side_encryption_configuration {
    rule {
      apply_server_side_encryption_by_default {
        sse_algorithm = "AES256"
      }
    }
  }

  tags = merge(var.common_tags, {
    SystemName = var.service
    Name       = "${var.name}-${var.env}-${var.service}-app-artifact-bucket"
    Role       = "S3"
  })
}

data "aws_iam_policy_document" "artifact" {
  statement {
    effect    = "Allow"
    actions   = ["s3:PutObject"]
    resources = ["${aws_s3_bucket.artifact.arn}/*"]

    principals {
      identifiers = ["codedeploy.amazonaws.com"]
      type        = "Service"
    }
  }

  statement {
    effect    = "Allow"
    actions   = ["s3:GetBucketAcl"]
    resources = [aws_s3_bucket.artifact.arn]

    principals {
      identifiers = ["codedeploy.amazonaws.com"]
      type        = "Service"
    }
  }
}

resource "aws_s3_bucket_policy" "artifact" {
  bucket     = aws_s3_bucket.artifact.id
  policy     = data.aws_iam_policy_document.artifact.json
  depends_on = [aws_s3_bucket_public_access_block.artifact]
}

resource "aws_s3_bucket_public_access_block" "artifact" {
  bucket                  = aws_s3_bucket.artifact.id
  block_public_acls       = true
  ignore_public_acls      = true
  block_public_policy     = true
  restrict_public_buckets = true
}

variables.tf

aws/app moduleで利用するvariablesを定義。

modules/aws/app/variables.tf
variable "name" {}
variable "env" {}
variable "service" {}
variable "domain" {}
variable "common_tags" {}
variable "az" {}
variable "vpc_id" {}
variable "public_subnets" {}
variable "private_subnets_app" {}
variable "aws_account_id" {}
variable "aws_elb_service_account_arn" {}
variable "instance_type" {}
variable "instance_volume_size" {}
variable "db_sg" {}
variable "acm_arn" {}

cloudflare module

構築した環境へすぐにアクセスしたいので、ドメイン設定もTerraformで定義します。
今回はDNSにCloudflareを利用します。

dns.tf

aws/app moduleで作成したALBのFQDNをCNAMEとしてJenkinsの引数で渡されるシステムのドメインを登録する。

modules/cloudflare/domain/dns.tf
data "cloudflare_zones" "apex_zone" {
  for_each = var.zone_apex

  filter {
    name   = var.zone_apex[each.key]
    status = "active"
  }
}

resource "cloudflare_record" "app" {
  name    = "${var.name}-${var.env}"
  type    = "CNAME"
  proxied = false
  value   = var.alb_dns_name
  zone_id = data.cloudflare_zones.apex_zone[var.service].zones[0].id
}

provider.tf

今回のディレクトリ構成だとここにこれを定義しないとエラーになる。

modules/cloudflare/domain/provider.tf
terraform {
  required_providers {
    cloudflare = {
      source = "cloudflare/cloudflare"
    }
  }
}

variables.tf

cloudflare/domain moduleで利用するvariablesを定義。

modules/cloudflare/domain.variables.tf
variable "name" {}
variable "env" {}
variable "service" {}
variable "zone_apex" {}
variable "alb_dns_name" {}

Jenkinsの実装

Jenkinsでは以下の処理を実行するビルドジョブを作成し、それらをBlue Oceanプラグインを利用してパイプラインを構築します。

  • 前述のTerraformでapplyおよびdestroyができるジョブ
  • CodeDeployでアプリケーションをデプロイするジョブ
  • コスト削減のための日次での環境削除バッチジョブ
    • 今回実装については割愛します:pray:

ビルドジョブ

Terraformでapplyおよびdestroyができるジョブ

仮にfeature-devというビルドジョブ名としておきます。
実装のポイントとしては以下です。

  • Jenkinsのソースコード管理で前述のTerraformリポジトリを登録
  • ビルドではシェルの実行を選択し、下記スクリプトを実行させる
  • SERVICE, BRANCH, MOTIONをビルドのパラメータとして可変可能にしておく
  • 既存環境のtfstate-lock用のDynamoDBを利用する
    • コミットを連続で実施された場合などに排他制御が入り、環境が意図せず壊れるということがなくなります。
  • destroy後はaws cliでDynamoDBのlockファイルと、tfstateファイルを削除しておく
#!/usr/bin/env bash

set -e

export AWS_DEFAULT_REGION='ap-northeast-1'
export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
export CLOUDFLARE_EMAIL=$CLOUDFLARE_EMAIL
export CLOUDFLARE_API_KEY=$CLOUDFLARE_API_KEY
TFSTATE_BUCKET="example-dev-tfstate-XXXXXXXXXXXX"

cd ./terraform

terraform init -reconfigure -var service=$SERVICE -var branch=$BRANCH -backend-config="key=$SERVICE/$BRANCH.tfstate" -backend-config="dynamodb_table=example-dev-tfstate-lock"

if [ $MOTION = "create" ]; then
  terraform apply -var service=$SERVICE -var branch=$BRANCH --auto-approve
elif [ $MOTION = "destroy" ]; then
  terraform destroy -var service=$SERVICE -var branch=$BRANCH --auto-approve
  aws dynamodb delete-item --table-name example-dev-tfstate-lock --key '{ "LockID": { "S": "'$TFSTATE_BUCKET'/'$SERVICE'/'$BRANCH'.tfstate-md5" }}'
  aws s3 rm s3://$TFSTATE_BUCKET/$SERVICE/$BRANCH.tfstate
fi

CodeDeployでアプリケーションをデプロイするジョブ

仮にdeploy-applicationというビルドジョブ名としておきます。
ポイントとしては以下です。
ビルド処理が必要な場合はスクリプト内の適当な場所に書けばOKです:ok_hand:

  • Jenkinsのソースコード管理でデプロイしたいアプリケーションのリポジトリを登録
  • ビルドではシェルの実行を選択し、下記スクリプトを実行させる
  • Terraform同様、ブランチ名をENVとして定義しサブドメインとして利用するためちょっといじる
  • 例としてsystem1というアプリケーションのビルドジョブとしているが、同様の内容でsystem2などのアプリケーションのビルドジョブを作成することで、複数アプリケーションに対応可能
#!/usr/bin/env bash

set -e

export AWS_DEFAULT_REGION='ap-northeast-1'
export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
aws_account_id="XXXXXXXXXXXX"
ENV=$(echo $BRANCH | awk -F/ '{print $2}' | tr 'A-Z' 'a-z' | tr '[/_]' '-')

base_name="example-${ENV}-system1"

aws deploy push \
          --application-name ${base_name}-app \
          --description "This is a revision for the ${base_name}-app" \
          --ignore-hidden-files \
          --s3-location s3://${base_name}-app-artifact-${aws_account_id}/${base_name}-app.zip \
          --source .

deployment_id=$(aws deploy create-deployment \
           --application-name ${base_name}-app \
           --deployment-group-name ${base_name}-app-deployment-group \
           --file-exists-behavior OVERWRITE \
           --s3-location bucket=${base_name}-app-artifact-${aws_account_id},key=${base_name}-app.zip,bundleType=zip \
           --query "deploymentId" --output text)

while :
do
  status=$(aws deploy get-deployment --deployment-id ${deployment_id} --query "deploymentInfo.[status, creator]" --output text | awk '{print $1}')

  if [ ${status} = "Failed" ]; then
    echo "Deployment ${deployment_id} is ${status} !!!!!"
    exit 1
    break
  elif [ ${status} = "Succeeded" ]; then
    echo "Deployment ${deployment_id} is ${status} !!!!!"
    break
  else
    echo "Deployment ${deployment_id} is ${status} ....."
    sleep 10
  fi
done

Blue Oceanプラグインを利用してマルチパイプラインを構築

JenkinsにBlue Oceanプラグインを導入したら、Jenkinsfileをアプリケーションリポジトリに配置します!
ちなみにマルチパイプラインは同じSREチームの@butadoraが実装してくれて、無事Jenkinsおじさんへジョブチェンジしました!

実装のポイントとしては以下です。
(今回は記載していないですが、実際の運用ではPRに構築された環境のアクセスURLをコメントしてあげるようにしています。)

  • PRオープン、コミット追加時に、terraform applyとCodeDeployによるアプリケーションデプロイを実行する。
  • PRマージ時にterraform destroyを実行する
Jenkinsfile
pipeline {
  agent any
  environment {
    // Branch pattern regexp
    FEATURE_BRANCH_REGEX = "^feature/"
    RELEASE_BRANCH_REGEX = "^release/"
    HOTFIX_BRANCH_REGEX = "^hotfix/"
    DESTROY_BRANCH_PATTERN = "master|^(feature|release)/"
  }

  stages {
    stage('Create resouces') {
      when {
        anyOf {
          changeRequest comparator: 'REGEXP',  // feature -> any
                        branch: env.FEATURE_BRANCH_REGEX
          changeRequest comparator: 'REGEXP',  // hotfix or release -> any
                        branch: "(${env.HOTFIX_BRANCH_REGEX}|${env.RELEASE_BRANCH_REGEX})"
        }
      }
      steps {
        build job: 'feature-dev', parameters: [
          string(name: 'SERVICE', value: '{実際のサービス名}'),
          string(name: 'BRANCH', value: env.CHANGE_BRANCH),
          string(name: 'MOTION', value: 'create')
        ]
      }
    }

    stage('Deploy application') {
      when {
        anyOf {
          changeRequest comparator: 'REGEXP',  // feature -> any
                        branch: env.FEATURE_BRANCH_REGEX
          changeRequest comparator: 'REGEXP',  // hotfix or release -> any
                        branch: "(${env.HOTFIX_BRANCH_REGEX}|${env.RELEASE_BRANCH_REGEX})"
        }
      }
      steps {
        build job: 'deploy-application', parameters: [
          string(name: 'ENV', value: 'feature'),
          string(name: 'BRANCH', value: env.CHANGE_BRANCH),
          booleanParam(name: 'CHECK', value: true)
        ]
      }
    }

    stage('Destroy feature dev resources') {
      when {
        branch pattern: env.DESTROY_BRANCH_PATTERN, comparator: 'REGEXP'
      }
      steps {
        script {
          // HEADコミットメッセージを取得
          def commit_message = sh(returnStdout: true, script: 'git log -1 --pretty="%s"').trim()

          // feature, release, hotfixブランチからのPRマージだった場合に、マージ元のブランチ名をパース
          def parse_cmd = $/eval "git log -1 --pretty=%s | sed -re 's/^Merge pull request #[0-9]* from (org名)\/(feature|release|hotfix)(\/.*)/\1\2/'"/$
          env.DESTROY_TARGET_BRANCH = sh(script: "${parse_cmd}", returnStdout: true).trim()

          // HEADがPRマージコミットの場合のみdestroy
          if (env.DESTROY_TARGET_BRANCH == commit_message) {
            echo 'HEAD is not PR merge commit.'
          } else if ((env.BRANCH_NAME ==~ /^release\/.*/ && env.DESTROY_TARGET_BRANCH ==~ /^feature\/.*/)
            || (env.BRANCH_NAME ==~ /^feature\/.*/ && env.DESTROY_TARGET_BRANCH ==~ /^${env.BRANCH_NAME}.+/)
            || env.BRANCH_NAME == 'master') {
            build job: 'feature-dev', parameters: [
              string(name: 'SERVICE', value: '{実際のサービス名}'),
              string(name: 'BRANCH', value: env.DESTROY_TARGET_BRANCH),
              string(name: 'MOTION', value: 'destroy')
            ]
          } else {
            echo 'Do not need destroy.'
          }
        }
      }
    }
  }
  post {
    always {
      // 終わったらworkspaceをclean
      cleanWs()
    }
  }
}

ちなみに、実際の運用ではコストの問題や長期間オープンされているPRもあるのでJenkinsの日次バッチで環境をdestroyする処理を入れています:ok_hand:
翌日以降に再度環境を立ち上げたい場合はJenkinsのバッチを実行するかコミットを追加すればOKです!

おわりに

こういう仕組みはどうしてもピタゴラスイッチちっくになってしまうのは仕方がない部分もあるかもしれないですが、命名規則などをしっかり決めて運用し、全体での設計に矛盾がないか確認することで大きな問題を発生させることなく運用できるのかなと思います。

弊社でこの仕組を導入して数ヶ月立ちますが、大きな問題が起こることなく運用されていて、デプロイと開発環境の改善がされて評判は上々です!

今後は、Jenkinsfileで実装している部分をGitHub Actionsへ変えたり、基盤もECSに変えたりとまだまだ改善したいことがたくさんあるので引き続き頑張っていきたいと思います−\(^o^)/

会社のアドベントカレンダーで本記事の内容も含むこの1年SREとしてやってきたことを書いたので是非見てみてくださいー!

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4