booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

Reader StoreでInfrastructure as Codeしてみた

アイキャッチ

こんにちは。プロダクト開発部でクラウドインフラエンジニアとして業務を行っている饂飩です。

今回は、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のOS EOL対応の一環として行った、「Infrastructure as Code(以降IaC)」についてお伝えします。

IaC化検討の背景

  1. Elastic Beanstalk platforms based on Amazon Linux AMI (AL1)のEOL
    https://docs.aws.amazon.com/elasticbeanstalk/latest/relnotes/release-2022-07-18-linux-al1-retire.html

  2. Amazon Linux AMI (2018.03) のEOL
    https://aws.amazon.com/jp/amazon-linux-ami/faqs/

この2つのEOLを対応する上で最大の課題として、以下が存在していました。

  • 設計書含む過去資料がない
    手順書もないため、再構築すらできない

  • 構築担当した者が既に離任してしまっている
    既存で動いているものが正で誰も把握できていない状態になっていた

  • 手順書があるものの、荒く、環境再現できない
    そもそも手順が複雑であった

結果、一から新規構築するとなるため将来性を考え、これまで困難だった環境複製や同様のものを再構築できるようにこれを機にIaC化する運びとなりました。

IaCとは

IaC (Infrastructure as Code) は、手動のプロセスではなく、コードを使用してインフラストラクチャの管理とプロビジョニングを行うことを言います。

引用:https://www.redhat.com/ja/topics/automation/what-is-infrastructure-as-code-iac

簡単に説明すると、AWSなどのクラウド環境やOS・ミドルウェアの構築・管理・運用を私のようなインフラエンジニアが手作業で行なうのではなく、コードで管理することです。

IaCを何で行うか

まずは、IaC化を実現する上でツールを何にするかを検討しました。
今回のターゲットとしてはAWS周りがメインだったので主流としては以下があります。

  • CloudFormation
    AWSが提供している、プロビジョニングにおける自動化および構成管理のためのサービスです。
    JSONまたはYAML形式でリソースの設定を記述し、リソースの作成や設定の変更ができます。
    https://aws.amazon.com/jp/cloudformation/
  • CDK
    AWSが提供している、プログラミング言語(Python/Java等)を使用してアプリケーションをモデル化し、AWS CloudFormation を通じてプロビジョニングするツールです。
    https://aws.amazon.com/jp/cdk/
  • Terraform
    HashiCorp社によって提供されているオープンソースのツールです。
    クラウドのみならずSaaS/PaaS、オンプレミス(VMwareなど)との連携にも対応しています。
    https://www.terraform.io/

どれでも可能ではありましたが、対応期間も迫ってきていたこともあり、私が前職でも利用していたTerraformを採用しました。
なお、OS・ミドルウェアをコード化する主流なツールとしては、AnsibleやChefがあります。
Reader StoreではAnsibleでOS・ミドルウェアはコード化されていました。

IaC化までの流れ

今回私が対応した流れとしては以下となります。

コード管理

IaC化するにあたり必須になるコード管理は、アプリケーション開発で利用しているコード管理ツールにインフラ用のリポジトリーを準備しました。

設計

これが一番大変でした。
まず既存で動いているAWSリソースから次期構成図を作成しました。
そこから各種リソースで既存流用するもの、新規構築するものを選定していきました。
既存の命名規則が不明だったので、リソース命名規則も定義しました。
各種リソース作成のソースはできるだけ1モジュール1リソースにし、実際にリソース作成するモジュールは機能単位でソースをラッピングするような形をとりました。
環境単位で変わるものは環境変数を利用する方針にして、環境変数ファイルは環境単位で環境ファイルとして準備しました。
またAWSアカウントを本番と検証開発で分離するという案件も並行して実施することになったのでアカウント単位の環境変数もファイル化。
ディレクトリー構成も悩みましたが以下のような構成にしました。

├── <account>.tfvars          # <-- AWSアカウントの共通環境変数ファイル
├── <environment>             # <-- 環境ごとの機能リソース集格納
|      └─ <environment>.tfvars # <-- 環境個別の環境変数ファイル
|      └─ <function>           # <-- 機能毎のリソース作成用モジュール格納。モジュールはsourceをラッピング
└── <modules>                 # <-- リソース作成モジュールソース集格納
        └─ <source>             # <-- リソース作成モジュールソース格納
            └─ <prebuild>         # <-- 対象リソースで事前に作成しておく必要があるモジュールソース格納
            └─ <postbuild>        # <-- 対象リソースで事後に作成する必要があるモジュールソース格納

コーディング

Terraformコードをコーディングしていきます。
検証で事前構築したリソースがあればTerraformerを使って既存リソースからコードを書き出すことも可能でした。
環境変数化など手直しが必要になりますがベース作りには有効でした。

コードテスト

コードテストはterraform planを実施しコードに問題がないか、作成するリソースの予想結果を確認します。
今回はコードテスト+コード実行してテスト構築し意図したリソースが構築できるかの評価も実施しました。
1環境をベース環境として必要なコード全てテスト用に利用しReader Store全機能分のリソースをコード化していきました。

インフラ構築

ベース環境のテスト構築ができたので、環境単位でディレクトリーを準備し環境に応じた環境変数ファイルを準備しました。
あとは構築していく環境単位でterraform applyを順次実施していく形をとりました。
手作業でやると工数はそれなりにかかりますが、コード実行だけなので構築工数はだいぶ削減されました。

具体的な例でEC2構築を少しだけ紹介します。

functionコードサンプル

locals {
  configs = merge(var.acccount_configs, var.environment_configs)
}

module "batch_instance" {
  source = "../../../modules/components/ec2-single"

  configs = local.configs
  instance_configs = {
    service                       = "batch"
    instance_type                 = "t3.medium"
    root_block_device_volume_size = 30
    vpc_security_group_ids        = [local.configs.batch.security_group.id]
    private_ip                    = "${local.configs.batch.private_ip}"
  }
}

module "batch_postbuild" {
  source = "../../../modules/batch/postbuild"

  configs = local.configs
  batch_configs = {
    instance_role                  = module.batch_instance.instance_role
    instance                       = module.batch_instance.instance
    extra_block_device_volume_size = 100
    dynamodb_table_name  = "dynamo_table"
  }
}

moduleコードサンプル

#
#IAM Role
#
resource "aws_iam_role" "instance_role" {
  name               = "${var.configs.environment_name}-${var.instance_configs.service}-role"
  path               = "/"
  assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
POLICY
}

resource "aws_iam_instance_profile" "instance_profile" {
  name = "${var.configs.environment_name}-${var.instance_configs.service}-role"
  path = "/"
  role = "${var.configs.environment_name}-${var.instance_configs.service}-role"
}


resource "aws_iam_role_policy_attachment" "attach" {
  role       = aws_iam_role.instance_role.name
  policy_arn = "arn:aws:iam::${var.configs.aws_account_id}:policy/${var.configs.common.log_bucket_policy.name}"
}

resource "aws_iam_role_policy_attachment" "attach_ssm_managed_instance_policy" {
  role       = aws_iam_role.instance_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}


#
#EC2
#
resource "aws_instance" "instance" {
  ami                         = var.configs.ec2.plain_ami
  availability_zone           = "${var.configs.region}${var.configs.main_az}" # e.g. "ap-northeast-1a"
  ebs_optimized               = true
  instance_type               = var.instance_configs.instance_type
  monitoring                  = false
  key_name                    = var.configs.ec2.key_name
  subnet_id                   = var.configs.subnets["${var.configs.main_az}_private"].id
  vpc_security_group_ids      = var.instance_configs.vpc_security_group_ids
  associate_public_ip_address = false
  source_dest_check           = true
  iam_instance_profile        = aws_iam_instance_profile.instance_profile.name
  private_ip                  = var.instance_configs.private_ip


  root_block_device {
    volume_type           = "gp3"
    volume_size           = var.instance_configs.root_block_device_volume_size
    delete_on_termination = true
  }

  tags = {
    "Name" = "${var.configs.environment_name}-${var.instance_configs.service}"
    "AutoStopStart" = "true"
  }
}

postbuildコードサンプル

#
# IAM Policyの追加
#

resource "aws_iam_policy" "batch_policy" {
  name        = "${var.configs.environment_name}-batch-policy"
  path        = "/"
  description = "for batch instances."
  policy      = <<POLICY
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": ["arn:aws:s3:::codepipeline-${var.configs.region}-*/*",
                         "arn:aws:s3:::codepipeline-${var.configs.region}-*"]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                        "arn:aws:s3:::${var.configs.s3.main_bucket_name}", 
                        "arn:aws:s3:::${var.configs.s3.main_bucket_name}/*",

                        "arn:aws:s3:::${var.configs.s3.srel_bucket_name}", 
                        "arn:aws:s3:::${var.configs.s3.srel_bucket_name}/*",

                        "arn:aws:s3:::${var.configs.s3.cms_bucket_pre_name}", 
                        "arn:aws:s3:::${var.configs.s3.cms_bucket_pre_name}/*"

                        ]
        },
        {
            "Effect": "Allow",
            "Action": "dynamodb:GetItem",
            "Resource": "arn:aws:dynamodb:${var.configs.region}:${var.configs.aws_account_id}:table/${var.batch_configs.dynamodb_mediainfo_table_name}"
        },
        {
            "Sid":"",
            "Effect":"Allow",
            "Action":[
                "logs:PutLogEvents",
                "logs:CreateLogStream"
            ],
            "Resource":"arn:aws:logs:${var.configs.region}:${var.configs.aws_account_id}:log-group:${var.configs.environment_name}/batch:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "SQS:ChangeMessageVisibility",
                "SQS:DeleteMessage",
                "SQS:ReceiveMessage",
                "SQS:SendMessage"
            ],
            "Resource": "arn:aws:sqs:${var.configs.region}:${var.configs.aws_account_id}:voucher-customer-linking-${var.configs.environment_name}"
        }
    ]
}
POLICY
}


resource "aws_iam_role_policy_attachment" "attach" {
  role       = var.batch_configs.instance_role.name
  policy_arn = aws_iam_policy.batch_policy.arn
}


resource "aws_ebs_volume" "extra_volume" {
  availability_zone = "${var.configs.region}${var.configs.main_az}"
  size              = var.batch_configs.extra_block_device_volume_size
  type              = "gp3"
}

resource "aws_volume_attachment" "ebs_attach" {
  device_name = "/dev/xvdf"
  volume_id   = aws_ebs_volume.extra_volume.id
  instance_id = var.batch_configs.instance.id
}


#
# CodeBuild
#

# バイナリビルド用
resource "aws_codebuild_project" "batch_code_build" {
  artifacts {
    encryption_disabled    = false
    name                   = "${var.configs.environment_name}-batch-build"
    override_artifact_name = false
    packaging              = "NONE"
    type                   = "CODEPIPELINE"
  }

  badge_enabled = false
  build_timeout = 60

  cache {
    type = "NO_CACHE"
  }

  concurrent_build_limit = 1
  description            = "for build batch"
  encryption_key         = "arn:aws:kms:${var.configs.region}:${var.configs.aws_account_id}:alias/aws/s3"



  environment {
    compute_type = "BUILD_GENERAL1_MEDIUM"

    image                       = "${var.configs.aws_account_id}.dkr.ecr.${var.configs.region}.amazonaws.com/java-gradle:5.0"
    image_pull_credentials_type = "SERVICE_ROLE"
    privileged_mode             = false
    type                        = "LINUX_CONTAINER"
  }

  logs_config {
    cloudwatch_logs {
      group_name  = "CodeBuild"
      status      = "ENABLED"
      stream_name = "${var.configs.environment_name}-batch-build-log"
    }

    s3_logs {
      encryption_disabled = false
      status              = "DISABLED"
    }
  }

  name           = "${var.configs.environment_name}-batch-build"
  queued_timeout = 480
  service_role   = "arn:aws:iam::${var.configs.aws_account_id}:role/${var.configs.environment_name}-build-role"

  source {
    buildspec           = "container/batch/buildspec.yml"
    git_clone_depth     = 0
    insecure_ssl        = false
    report_build_status = false
    type                = "CODEPIPELINE"
  }
}

#
# CodeDeploy
#

resource "aws_codedeploy_app" "batch_deploy" {
  compute_platform = "Server"
  name             = "${var.configs.environment_name}-batch-deploy"
}


resource "aws_codedeploy_deployment_group" "batch_deploy_group" {
  app_name              = aws_codedeploy_app.batch_deploy.name
  deployment_group_name = "${var.configs.environment_name}-batch-deploy-group"
  service_role_arn      = var.configs.ci.code_deploy_role_arn

  ec2_tag_set {
    ec2_tag_filter {
      type  = "KEY_AND_VALUE"
      key   = "Name"
      value = "${var.configs.environment_name}-batch"
    }
  }

  trigger_configuration {
    trigger_events     = ["DeploymentFailure"]
    trigger_name       = "failure-notice-trigger"
    trigger_target_arn = var.configs.sns.notice_topic_arn
  }

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

  alarm_configuration {
    alarms  = ["${var.configs.environment_name}-batch-deploy-alarm"]
    enabled = true
  }
}


#
# CodePipeline
#

data "template_file" "codebuild_environment_data" {
  template = file("${path.module}/templates/environment-variables.json")
  vars = {
    environment_short_name = var.configs.environment_short_name,
    environment_name       = var.configs.environment_name
  }
}


resource "aws_codepipeline" "batch_pipeline" {
  artifact_store {
    location = "codepipeline-${var.configs.region}-524718723338"
    type     = "S3"
  }

  name     = "${var.configs.environment_name}-batch-pipeline"
  role_arn = "arn:aws:iam::${var.configs.aws_account_id}:role/service-role/common-code-pipeline-role"

  stage {
    action {
      category = "Source"

      configuration = {
        BranchName           = var.configs.batch.branch_name
        ConnectionArn        = var.configs.ci.connection_arn
        FullRepositoryId     = var.configs.ci.full_repository_id
        OutputArtifactFormat = "CODE_ZIP"
      }

      name             = "Source"
      namespace        = "SourceVariables"
      output_artifacts = ["SourceArtifact"]
      owner            = "AWS"
      provider         = "CodeStarSourceConnection"
      region           = var.configs.region
      run_order        = 1
      version          = 1
    }

    name = "Source"
  }

  stage {
    action {
      category = "Build"
      configuration = {
        EnvironmentVariables = data.template_file.codebuild_environment_data.rendered
        ProjectName          = aws_codebuild_project.batch_code_build.name
      }

      input_artifacts  = ["SourceArtifact"]
      name             = "Build"
      namespace        = "BuildVariables"
      output_artifacts = ["BuildArtifact"]
      owner            = "AWS"
      provider         = "CodeBuild"
      region           = var.configs.region
      run_order        = 1
      version          = 1
    }

    name = "Build"
  }

  stage {
    action {
      category = "Deploy"

      configuration = {
        ApplicationName     = aws_codedeploy_app.batch_deploy.name
        DeploymentGroupName = "${var.configs.environment_name}-batch-deploy-group"
      }

      input_artifacts = ["BuildArtifact"]
      name            = "Deploy"
      namespace       = "DeployVariables"
      owner           = "AWS"
      provider        = "CodeDeploy"
      region          = var.configs.region
      run_order       = 1
      version         = 1
    }

    name = "Deploy"
  }
}


構築コマンドは以下になります。

  • terraform init実行
    tfファイルを格納しているフォルダーを初期化させます。
    フォルダー内直下のtfファイルが読み込まれ、必要なpluginがインターネットからフォルダーにダウンロードされます。
Initializing modules...
- batch_instance in ../../../modules/components/ec2-single
- batch_postbuild in ../../../modules/batch/postbuild

Initializing the backend...

Initializing provider plugins...
(略)

Terraform has been successfully initialized!


  • terraform plan実行
    構築前にどのようなリソースが作成(変更)されるかを事前に確認できます。
    基本的にはmodulesにコーティングしてあるリソースしか表示されませんが、念の為確認しておきます。
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create

Terraform will perform the following actions:

(略)

Plan: 5 to add, 0 to change, 0 to destroy.

--- 

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run
"terraform apply" now.


  • terraform apply実行
    Enter a value:が表示され止まるので、yesを入力してEnterキーを押下すると、リソースの構築が開始されます。
    構築進捗状況が出力されるので数分ほど待ち、Apply complete!が出力されれば構築が完了しています。
    Apply complete!の後の文言は、terraform plan時の出力結果と一緒になります。
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create

Terraform will perform the following actions:

(略)

Plan: 5 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: <yes>

module.batch_instance.aws_iam_instance_profile.instance_profile: Creating...
module.batch_instance.aws_iam_role.instance_role: Creating...

(略)

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.


terraform apply中にインスタンスIDが表示されるので、AWSコンソールのEC2画面にてインスタンスIDでフィルターし、該当するインスタンスが表示され作成されているか確認します。
EC2

インフラテスト

テストの内容としては、コードから構築されたリソースがちゃんと作成できているか、リソース設定が環境変数で指定した内容になっているか、リソース間疎通確認などを実施しました。

アプリケーション実装評価

ここからは実際にアプリケーションを作成した環境に乗せてみて画面が確認できるかを確認しました。
CI/CDテストもここで実施しました。

システムテスト

このテストはAurora1からAurora3へアップグレードした際に、全機能テスト項目をまとめていたためそちらを実施しました。
またシステムリプレイスのため性能負荷試験も実施し問題がないかも確認しました。

直面した課題・反省点

Beanstalk EOLとOS EOLでモジュール配置のディレクトリー構成を変えてしまった

ディレクトリー構成を決めたもののその配下まで詳細に決めきれてなかったため、Beanstalk EOL時とOS EOL時でモジュール配置を安易に変えてしまいました。
そのため先行リリースしたBeanstalk用のモジュール群が動作しない状態に陥ってしまいました。
結果Beanstalk用とOS用でECSリソースモジュールは分離した状態になりました。

Terraformローカル実行前提だったのでMac M1(ARM)考慮できてなかった

Terraform運用まで検討しておらずローカル実行をするようにしていました。
当初ARM環境がなかったため参画してきたメンバーがTerraform実行環境を整備したときに発覚しました。
結果としてTerraform実行環境をAWS上に準備しTerraform実行はその環境で実行する運用としました。

tfstateを外部参照できるように考慮すればよかった

tfstate(Terraformがリソースの現在の状態を管理するファイル)も、コード管理する必要がありました。
terraform実行後にマージするしかなく、コード修正しながら構築可能な状態となってしまいました。
結果ブランチのままterraform実行することになってしまっていてプルリクエストが構築後になってしまいました。
今後の検討でtfstateはS3保存できるような形にする予定です。

モジュールは1つにつき1リソース作成するものにできなかった

設計でできるだけ1モジュール1リソースとしていたものの関連するリソースを含む形になってしまいました。
構築だけを考えれば現在のままでも問題はないですが、運用を考えると他リソースまで変更される可能性もあるため徹底すればよかったなということが反省点です。
運用に関しては今後対応していく中で少しずつ改善していく予定です。

まとめ

今回EOL対応におけるIaC化についてお話ししました。

本EOL対応は私が入社してまもない頃から今まで対応してきた内容にもなります。
他にもEOL対応の中で実施した内容はあるのでどこかでまたお話しできたらと思っています。