Techfirm Cloud Architect Blog

テックファーム株式会社クラウドインフラグループのブログ

Terraformで作る初めてのEC2 Image Builderパイプライン

サーバレス構成が多くなりましたが、久しぶりにAutoScalingGroupを構成することがありました。
AutoScalingGroupから起動するためのAMIを作成するため、いったんインスタンスを起動して、構築して、AMIを保存しないといけません。久しぶりだととくに面倒に感じます。
TerraformでAMI作成環境を作成して使いまわしたいです。

そこで、今回はTerraformでEC2 Image Builderを構築してみました。

今回の目次です。

EC2 Image Builderとは

EC2 Image BuilderはAMIやコンテナイメージの作成や検証をするサービスです。
今回はコンテナイメージは無視してEC2のAMIのみに焦点を当てます。

EC2 Image Builderは、AMI作成用のインスタンスを起動し、決められたコマンドを実行してサーバを設定し、AMIに保存します。
さらに、作成されたAMIから起動してテストコマンドの実行、AMIの公開設定、起動テンプレートの更新も実施できます。
(AutoScalingGroupのインスタンスの更新まではやってくれないようですが……)

一方で、他のAWSサービスとの統合という面は弱そうに見えます。
CodePipelineから呼び出せたら便利そうですが、直接呼び出すことはできません。
また、EventBridgeからEC2 Image Builderのパイプラインを呼び出し可能ですが、逆にEC2 Image Builderはイベントを発行しないようです。
(パイプライン完了時にSNSトピックへ通知を行う機能はあります)
CI/CDの途中でEC2 Image Builderを動かすためにはLambda等による実装が必要そうです。

Terraformリソース

では、実際にEC2 Image Builderを動かすためのTerraformリソースを紹介します。

ImageBuilderリソース関連図

図中表記 Terraformリソース 概要
pipeline image_pipeline 実際にAMIを作成するために実行するパイプライン
infrastructure infrastructure_configuration AMI作成のための環境に関する設定
recipe image_recipe どのイメージをベースに何を構築するか、構築内容
component component 実際にサーバ構築するための具体的なコマンド
distribution distribution_configuration 出力先AMIや更新する起動テンプレートに関する設定

(Terraformリソースは先頭のaws_imagebuilder_を省略しています)

この他にaws_imagebuilder_imageaws_imagebuilder_container_recipeといったリソースもありますが、今回は利用しません。

image_pipelineは他の設定リソースを作成したあとに、それを参照するように最後に作成します。
そのため、上記の表の下から順に紹介します。

distribution_configuration

出力先や配布の設定です。以下が例になります。

resource "aws_imagebuilder_distribution_configuration" "foobar_server" {
  name = "${var.env}-foobar-server"
  distribution {
    region = data.aws_region.current.name

    ami_distribution_configuration {
      name = "${var.env}-foobar-server-{{ imagebuilder:buildDate }}"
      ami_tags = {
        Environment = var.env
        Name        = "${var.env}-foobar-server"
      }
      kms_key_id = aws_kms_key.image.arn
      launch_permission {
        user_ids = ["123456789012"]
      }
    }

    launch_template_configuration {
      launch_template_id = aws_launch_template.foobar_server.id
      account_id         = data.aws_caller_identity.current.account_id
      default            = true
    }
  }
}

設定はすべてdistributionブロックで行います。
distributionブロックをリージョンごとに作成し、複数リージョンに同時にAMIを保存することも可能です。
distributionブロックの中について簡単に説明します。

ami_distribution_configurationブロックでは出力先AMIの設定を行います。
nameはAMI名です。ここにある{{ imagebuilder:buildDate }}2023-10-23T23-24-10.128Zのようなビルド日時に置き換えられます。
nameには{{ imagebuilder:buildDate }}が必ず含まれていないといけないので注意が必要です。

他にも、AMIに設定するタグ(ami_tags)やAMI共有の設定(launch_permissionブロック)を定義しています。
暗号化されたAMIを他のAWSアカウントと共有する場合、カスタマーキーを明示的に指定(kms_key_id)する必要があるので注意してください。 暗号化キーを指定していない場合、レシピで指定された暗号化キーで暗号化されます。
違うリージョンに暗号化したAMIを出力する場合、暗号化キーを指定しないとそのリージョンのaws/ebsになるようです。
(ただし、そもそもレシピで暗号化の指定をしていなければ暗号化されていないAMIが作成されます)

次に、launch_template_configurationでは起動テンプレートの更新を行うことができます。
起動テンプレートもTerraformで管理している場合は、対象起動テンプレートの定義にignore_changesを設定しておく必要があります。

resource "aws_launch_template" "fwproxy_server" {
  ....省略....
  lifecycle {
    ignore_changes = [
      image_id,
      description,
      tags["CreatedBy"]
    ]
  }
}

しかし、EC2 Image BuilderはAutoScalingGroupのインスタンス更新までは実施してくれません。
launch_template_configurationは定義せず、Terraformで最新のAMIを取得して起動テンプレートを書き換えてもいいかもしれません。
(制約はありますが、aws_autoscaling_groupinstance_refreshインスタンス更新を作成できます)
どのように更新していくか、検証から本番適用までの運用フローも含めて考える必要があるかと思います。

今回の例に記載した以外にもディストリビューション設定の設定項目は多くあります。
詳細はawsプロバイダーのドキュメントを参照してください。

component

EC2 Image Builderのコンポーネントでは、実際に構築するためのコマンド等を定義します。
amazon-cloudwatch-agent-linuxやamazon-corretto-11等、Amazon管理のコンポーネントがすでに用意されているので作成は必須ではありません。
しかし、設定ファイルをカスタマイズしたりするためには作成する必要が出てくるかと思います。

Terraformでの定義と実際のコマンドを定義するyamlファイルについてそれぞれ簡単に紹介します。

componentのTerraform定義

Terraformでの定義は以下のようになります。

resource "aws_imagebuilder_component" "foobar_os_setup" {
  name                   = "${var.env}-foobar-os-setup"
  version                = "1.0.0"
  platform               = "Linux"
  supported_os_versions  = ["Amazon Linux 2023"]
  data                   = templatefile("./foobar_os_setup/component.yaml", {})
}

コンポーネントには必ずバージョンを振りますが、これは<メジャー>.<マイナー>.<パッチ>という書式と決まっています。
作成するときは数字を明示する必要がありますが、参照時はワイルドカードで最新バージョンを取得可能です。
バージョンについて詳細はAWSのドキュメントを参照してください。

dataに、次に説明するcomponent.yamlの内容が入ります。
dataに直接記述せずにuriでS3にあるファイルを参照させることも可能です。

また、コンポーネントは暗号化されますが、kms_key_idでKMSキーを指定することも可能です。
他にもコンポーネントの設定項目はあるため、詳細はawsプロバイダーのドキュメントを参照してください。

component.yaml

実際に構築するためのコマンドを定義します。
前述のTerraform定義でdataの中に記述する内容です。

name: foobar-os-setup-al2023
description: OS Base Setup
schemaVersion: 1
parameters:
  - LogRotateSaveDays:
      type: string
      default: "366"
phases:
  - name: build
    steps:
    - name: UpdateOS
      action: UpdateOS
    - name: SetupLogrotate
      action: ExecuteBash
      onFailure: Abort
      inputs:
        commands:
          - "sudo sed -i.org 's|weekly|daily|; s|rotate 4|rotate {{LogRotateSaveDays}}|; s|#compress|compress\\ndelaycompress|' /etc/logrotate.conf"
  - name: validate
    steps:
    - name: SetupLogrotate
      action: ExecuteBash
      onFailure: Abort
      inputs:
        commands:
          - sudo logrotate -d /etc/logrotate.conf
  - name: test
    steps:
    - name: SSHDBoot
      action: ExecuteBash
      onFailure: Abort
      inputs:
        commands:
          - sudo systemctl restart sshd

parametersでレシピへ設定するときに指定するパラメーターを設定できます。残念ながらtypestring固定です。
上の例だとLogRotateSaveDaysというパラメーターを定義しています。
その後で参照するときは{{LogRotateSaveDays}}のように記述します。

その後のphasesがこの定義のメインです。
フェーズはbuildvalidatetestの3種類です。
最初にbuildフェーズが実行されて構築を行い、validateフェーズで構築後の確認を行います。
ここまではAMI化の前に実行され、作成されたAMIからテスト用インスタンスが起動されて最後にtestフェーズが実行されます。

それぞれのフェーズのstepsで実行コマンドを定義します。
利用できるアクションは以下のドキュメントを参照してください。

LinuxサーバならExecuteBashですべて書くこともできると思いますが、S3DownloadUpdateOS等の便利なアクションが用意されています。

image_recipe

レシピでは、構築のためベースイメージや実際に利用するコンポーネントを定義します。

まず、先にコンポーネントのARNについて説明します。
作成したコンポーネントのARNは以下のようになります。

arn:aws:imagebuilder:ap-northeast-1:012345678901:component/dev-foobar-os-setup/1.0.0/1

component/の後に、コンポーネント名、バージョン、ビルド番号が/区切りで続いています。
一方で、レシピでコンポーネントを指定するときのARNは以下のようにしたいと思います。

arn:aws:imagebuilder:ap-northeast-1:012345678901:component/dev-foobar-os-setup/1.x.x

ビルド番号を無くし、バージョンにワイルドカードxを使用します。
これにより、メジャーバージョン1の最新コンポーネントを指定できます。
バージョンについて詳細はAWSのドキュメントを参照してください。

元のARNを正規表現で分解してからレシピで指定するARNを組み立て直す場合は以下のようにできます。
(読みやすいように2つの変数で段階的に組み立てています)

locals {
  foobar_os_setup_basearn = regex( "^(.*)/([0-9x.]+)/([0-9]+)$", aws_imagebuilder_component.foobar_os_setup.arn)[0]
  foobar_os_setup_arn     = "${local.foobar_os_setup_basearn}/1.x.x"
}

このコンポーネントを利用するレシピを定義します。

resource "aws_imagebuilder_image_recipe" "foobar_server" {
  name         = "${var.env}-foobar-server"
  version      = "1.0.0"
  parent_image = "arn:aws:imagebuilder:ap-northeast-1:aws:image/amazon-linux-2023-arm64/x.x.x"

  block_device_mapping {
    device_name = "/dev/xvda"
    ebs {
      delete_on_termination = true
      volume_size           = 20
      volume_type           = "gp3"
      iops                  = 3000
      throughput            = 125
      encrypted             = true
    }
  }

  component {
    component_arn = local.foobar_os_setup_arn
    parameter {
      name  = "LogRotateSaveDays"
      value = "1096"
    }
  }

  component {
    component_arn = "arn:aws:imagebuilder:${data.aws_region.current.name}:aws:component/amazon-cloudwatch-agent-linux/x.x.x"
  }

}

versionの指定はコンポーネントと同じです。
parent_imageには構築するベースとなるイメージをEC2 Image BuilderのイメージARNかAMI IDで指定します。
今回の例ではEC2 Image BuilderのイメージからAmazon Linux 2023の最新を指定しています。
EC2 Image Builderで最初から用意されているイメージは、AWSコンソールからEC2 Image Builderのイメージページを開き、所有者のフィルターにAmazon管理を指定することで確認可能です。

block_device_mappingでは、そのままEBSボリュームの設定を行います。
ルートボリュームのサイズ変更等は忘れずに実施しましょう。

componentブロックは実際に構築する手順を示しています。
この項目は順序にも気を付けてください。
component_arnには先に説明したコンポーネントARNを指定します。
コンポーネントにparamtersが定義されている場合はparameterブロックで値を設定することが可能です。

この他にも、最後にSystemsManagerAgentを削除するよう指示する(systems_manager_agentブロック)ことも可能です。
(EC2 Image BuilderはSystemsManagerを利用して構築するため、ベースイメージにはSystemsManagerAgentが入っています)
設定の詳細はawsプロバイダーのドキュメントを参照してください。

infrastructure_configuration

インフラストラクチャ設定では、VPC等のビルド環境に関する定義をします。

resource "aws_imagebuilder_infrastructure_configuration" "default" {
  name                          = "${var.env}-default"
  subnet_id                     = aws_subnet.image_build.id
  security_group_ids            = [aws_security_group.image_builder.id]
  instance_profile_name         = aws_iam_instance_profile.image_builde.name
  instance_types                = ["t4g.small","t3.small"]
  terminate_instance_on_failure = true

  instance_metadata_options {
    http_tokens = "required"
  }

  logging {
    s3_logs {
      s3_bucket_name = local.logs_bucket
      s3_key_prefix  = "imagebuild/${each.key}"
    }
  }
}

EC2 Image BuilderはSystems Mangerを利用して構築するため、SystemsManagerエンドポイントへ接続できる必要があります。

subnet_idで指定しているサブネットは、インターネットゲートウェイと接続した専用のサブネットにしました。
(NAT GatewayやVPCエンドポイントでコストを発生させたくなかったため)
この場合、サブネットはパブリックIPアドレスの自動割り当てが有効になっている必要があります。 (Terraformのaws_subnetリソースではmap_public_ip_on_launch=trueの設定が必要です)

外部からの接続を受け入れるわけではないので、セキュリティグループはアウトバウンドの許可だけです。
インスタンスプロファイルで割り当てるロールでは、EC2InstanceProfileForImageBuilderAmazonSSMManagedInstanceCoreというAWS管理のポリシーを割り当てます。
前者はImageBuilderのS3バケットへのアクセスやCloudWatchLogsへの出力の許可があり、後者はSystemsManagerAgentのための権限があります。
この他に、構築に必要な資材を配置したS3バケットへのアクセス許可、KMSのカスタマーキーへのアクセス許可、S3ログ出力のアクセス許可等も必要です。

instance_typesでは起動するときのインスタンスタイプを設定しています。この中からインスタンスタイプが選択されます。

instance_metadata_optionsブロックでIMDSv2の強制を設定できるようですが、残念ながらドキュメントを見る限りメタデータのタグを許可する設定は無さそうでした。

CloudWatch Logsへビルドやテストの基本的なログが出力されますが、loggingブロックでS3への出力も設定できます。
S3への出力は、CloudWatch Logsの出力より多くの情報が入っています。

他にも、SNSトピックへビルド終了を通知する設定等もあります。
設定項目について詳細はawsプロバイダーのドキュメントを参照してください。

image_pipeline

これまで説明した設定を組み合わせて、実際にAMI作成の自動化を行います。

resource "aws_imagebuilder_image_pipeline" "foobar_server" {
  name = "${var.env}-foobar-server"

  infrastructure_configuration_arn = aws_imagebuilder_infrastructure_configuration.default.arn
  distribution_configuration_arn = aws_imagebuilder_distribution_configuration.foobar_server.arn
  image_recipe_arn = aws_imagebuilder_image_recipe.foobar_server.arn
}

パイプラインの設定は単純で、ここまでに作った設定の組み合わせを指定するだけです。
パイプラインを作成したら、AWSコンソール上からパイプラインを実行して実際にAMIを作成できます。

ここにはないですが、scheduleブロックで定期的に実行することもできます。
その際、pipeline_execution_start_conditionのデフォルトがEXPRESSION_MATCH_AND_DEPENDENCY_UPDATES_AVAILABLEになっていることに注意してください。 時間になっても、ベースイメージやコンポーネントのバージョンが更新されないとビルドは実行されません。
(バージョンにx.x.x等のワイルドカードで指定しておく必要があります)

対応OS

EC2 Image Builderには対応OSが定義されています。
ここに記載のあるOSは多くありません。(確認時点でRedHat Enterprise Linux 9も入っていません)
EC2 Image BuilderのコンポーネントはAWSTOEというツールで実行されるようですが、この対応OSと同じのようです。

実際には、対応していないAlmaLinux 9をベースイメージに指定してもエラーになりませんでした。(SSMAgentインストール済みのAMIで確認しました)
ただし、エラーが出なくても想定する処理が実行されていないケースはあるかもしれません。
(コンポーネントの設定以外にも、シェルスクリプトで自動的にクリーニング等を行っているようです)

最後に

EC2 Image BuilderはAWSサービスなので、AMI作成の自動化環境自体をIaCに取り込むことが可能です。
そこで、今回はTerraformで構築する方法を紹介しました。
これにより、AMI作成環境自体を使いまわすことが容易になります。
安全で効率的な環境構築の選択肢になりそうです。