AMI の脆弱性をスキャンしてオートスケーリング起動インスタンスの脆弱性を可視化した話

おばんでがす。

技術戦略ディビジョン 第一ユニット所属 リードインフラエンジニアの田口です。

今回はオートスケーリング起動する EC2 インスタンスの脆弱性状況を可視化するシステムを作成しましたので、それについてお話させていただければと思います。

EC2 の脆弱性状況の可視化をしている、だけどオートスケーリング起動と Inspector のスキャンタイミングが合わなくて検出結果がおかしくなる、そんな悩みを抱える方々をお助けできれば幸いです。

背景

我々のユニットはアドウェイズのシステムから脆弱性を即時検知するため、脆弱性状況の可視化を行っています。

その中で私は EC2 インスタンスの OS・パッケージの脆弱性を Amazon Inspector で取得し検出結果を BigQuery へ送信し Dataform でテーブルを整形、最終的に Looker Studio で可視化するというシステムを作成しました。

しかしこのシステムは稼働当初からオートスケーリングで起動する EC2 インスタンスの情報が取れたり取れなかったりする、という問題を抱えていました。

この問題の原因は Amazon Inspector は基本的に一日一回しか EC2 インスタンスをスキャンしないという特性にあります。 (EC2 サーバーの起動時や、ソフトウェアのインストールなどを行ったタイミングは除きます)

Amazon Inspector がスキャンを行うタイミングで起動・停止を行っているオートスケーリング起動インスタンスは、脆弱性スキャンを試行したものの状態が変わったり終了したりするため、検出結果が取れたり取れなかったりします。

しかもオートスケーリング起動のタイミングとの兼ね合いで、再現したりしなかったりするのでとても難しい状況です。

この「オートスケーリング起動インスタンスの脆弱性を正しく取得したい」という課題を解決するため SA さんに質問したところ、「オートスケーリング起動で使われる AMI 自体をスキャンするのがおすすめです」とのことだったので、今回 AMI スキャンシステムを作成しました。

AMIスキャンシステムについて

構成図と動作の流れ

では AMI スキャンシステムの構成図と動作の流れを紹介します。

構成図 全体

システムの主眼とならないリソースを削ってシンプルに作ったのですが、かなりゴチャついています。構成と流れを図に起こすのは難しい。

動作の流れと合わせて、動作と関係してくる部分だけ順番に見ていきましょう。

前提ですが、本システムは GitLab CI 上で実行されるため、以下の CI のステージやジョブの流れに沿って説明を行います。

  • 調査対象 AMI 取得ステージ
  • 脆弱性スキャン実行ステージ
    • before_script
      • terraform apply
    • script
      • ImagePipeline 実行 Python
    • after_script
      • terraform destroy

初期状態

実行前の状態は以下の terraform 管理外のリソースのみ AWS 上に存在しています。

構成図 開始前

調査対象 AMI 取得ステージ

Image Builder の構成要素として Image Pipeline というものが存在します。

これは AMI と一対一で必要となるリソースであるため、作成前に調査対象となる AMI がいくつあるかの確認と、紐づけのため AMI ID の取得が必要となります。

これを Python スクリプトで実行するのが「調査対象 AMI 取得ステージ」です。

取得した AMI ID は json 形式で保存し artifacts として脆弱性スキャン実行ステージに渡します。

構成図 AMI ID 取得

terraform でリソース作成

脆弱性スキャン実行ステージでは大きく分けて「リソース作成」「Image Builder 実行&監視」「リソース削除」の三段階の動作を行います。

最初のリソース作成では、「調査対象 AMI 取得ステージ」で取得した調査対象 AMI ID を用いて Image Builder 関連のリソース(Image Pipeline 等)と、通信のための VPC Endpoint を作成します。

(実際は EC2 インスタンスにアタッチするインスタンスプロファイル等も作成しますが、ここでは記載を省略しています。)

Image Builder の実行中に内部的に AWS サービス側の S3 からシェルスクリプトをダウンロードする等、通信が必要な場面が発生します。

しかし今回はプライベートサブネット内に EC2 を作成しているため AWS サービスとの通信は AWS Private Link を用います。

VPC Endpoint の作成は AWS Private Link の利用のためですので、パブリックサブネットでの動作や、通信が必要ない場合は作成しなくても問題有りません。

構成図 デプロイ

Image Builder について

簡単にですが Image Builder について説明します。

Image Builder (正式名称 EC2 Image Builder) とは「EC2 Image Builder はフルマネージド AWS のサービス 型で、カスタマイズされ、安全で、 up-to-date サーバーイメージの作成、管理、デプロイを自動化するのに役立ちます。(引用 : EC2 Image Builder とは何ですか)」とのことです。

要するに AMI の作成・管理・デプロイを自動的に行なってくる AWS のマネージドサービスです。

特によく使われるのは「AMI の作成」だと思いますし、実際今回も AMI の作成機能を利用します。

Image Builder はサービスの名称で、実際には内部に インフラストラクチャ設定・イメージレシピ・イメージパイプライン等の構成要素を持ちます。

他にも色々構成要素はあるのですが、 Image Builder の説明が主題では無いことと、これらを抑えておけば内部で何をやっているのかは理解できると思うので上記の三要素について説明します。

インフラストラクチャ設定

Image Builder での AMI の作成は Packer 等と同じく指定された既存の AMI から EC2 インスタンスを起動し、設定を加えてそのイメージを AMI として保存するという流れです。

インフラストラクチャ設定では上記の AMI を作成するための VPC やサブネット等インフラストラクチャの設定を行います。

イメージレシピ

既存の AMI から EC2 インスタンスを起動した後の設定までの設定を行うものです。

大きな設定項目としては新規作成する AMI の素となる AMI の指定と設定用コンポーネント(yml形式で記載された設定書)の指定です。

特にコンポーネントに関しては、ビルドコンポーネントという AMI の設定自体を行うものと、テストコンポーネントという作成後の AMI のテストを行うものを設定します。

イメージパイプライン

インフラストラクチャ設定・イメージレシピ等の設定をまとめて実際に AMI の作成を行うリソースです。

インフラストラクチャ設定・イメージレシピ等の設定は使い回し可能なモジュールの様な小さい設定で、イメージパイプラインはそれらを組み合わせたものというイメージだと思います。

今回の使い方についての補足

補足ですが、今回の AMI 脆弱性スキャンシステムは Image Builder の使い方としては少し特殊であり本来の用途とは異なります。

本来は「既存の AMI にビルドコンポーネントに変更を加えて新しい AMI を作成する」というのが Image Builder の想定した使い方で、脆弱性スキャンはオマケの機能であると思われます。

しかし今回は脆弱性スキャンのみ行いたいため、ビルドコンポーネントによる変更は必要ありません。

なので今回 Terraform からデプロイしているビルドコンポーネントは AMI に影響を与えない最小限の実装ですし、変更を加えないためテストも必要ないのでテストコンポーネントはデプロイしていません。

実行開始&状況監視

  1. GitLab CI から Python スクリプトを用いて Image Builder (正確には Image Pipeline)をトリガー
  2. Image Pipelineが調査対象 AMI から EC2 インスタンスを起動し、ビルドコンポーネントを実行して変更を加え、新しい AMI を作成
  3. 新しく作成された AMI から再度 EC2 インスタンスを起動
  4. Inspectorが脆弱性スキャン

上記の流れが Image Builder を利用した AMI スキャンの実態です。

その後 Image Builder に脆弱性スキャンの結果が共有され、一覧画面で脆弱性状況が確認できるようになります。

構成図 動作

Image Pipeline のトリガーから脆弱性スキャンが完了するまで20~30分程度時間がかかり、動作状況は都度確認しなければ判別できないため、Python スクリプトから動作状況を確認しています。

その後 Image Pipeline のトリガーから脆弱性スキャンまでが完了したら、作成された AMI を Python スクリプトから削除します。

Image Builder で作成した AMI は Terraform の管理外であるため、Terraform 外から削除する必要があるからです。

構成図 動作完了後

terraform destroy でリソース削除

後は作ったものを片付けるだけです。

Image Builder 関連リソースと VPC Endpoint を削除します。

構成図 デストロイ

完了状態

これにより動作完了後は動作開始前と同じ状況に戻ることになります。

構成図 完了後

取り組んだこと

実際にやったこと

オートスケーリングインスタンスのスキャン方法の調査

開始時点では課題があるだけで解決方法は全く見えていませんでした。

インターネットで検索しても同様の課題を解決している記事は見当たらず、自分の頭で考えたりユニット内で相談するぐらいしか糸口がなかったのですが、前述の通り AWS の SA さんに「こういった課題があるのですが…」という質問をした所「AMI 自体の脆弱性をスキャンするのがおすすめです」との回答をいただけました。

我々は EC2 インスタンスの脆弱性調査を行っていたので「EC2インスタンスをどうスキャンするか?」というしか考えていませんでしたが、オートスケーリング起動ということは基本的に AMI に保管されている状況がそのまま動くわけです。

ならば AMI に存在する脆弱性がわかれば、それが起動した EC2 インスタンスの脆弱性と言えるわけですね。

完全に盲点でした。やっぱりプロだ。皆も結ぼう Enterprise Support。

aws.amazon.com

AMI スキャンの手段の選定

AMI の脆弱性を調査するという方針は決まりました。

しかし「どうやって脆弱性を見つけるか?」という問題は残っています。

ここで脆弱性を見つけるシステムの候補として挙がったのは以下の2つです。

  • Image Builder
  • trivy

さらに要件として以下の事に考慮する必要がありました。

  1. すでに Amazon Inspector から取得した情報を利用し可視化しているため、同じ様な形式にできる必要がある
  2. オートスケーリング起動インスタンスの起動時に他リソースに影響が発生する可能性があるため、閉じたネットワークで動作させる等、影響を発生させない考慮が必要
  3. 複数アカウントで同様に運用できることを前提とする

3に関しては弊ユニットでは Terraform を利用しているため、AWS アカウント上で完結するなら基本的には問題ないという前提でした。

Image Builder

今回採用したのはこの Image Builder です。

Image Builder の本来の用途は AMI にビルドコンポーネントで定義した変更を加えて新しい AMI を作成することですが、なんと作成した AMI の脆弱性をスキャンしてくれます。

なので何も変更を加えないビルドコンポーネントを指定すれば生成元の AMI の脆弱性情報がそのまま検出できてしまいます。すばらしい。

また、要件として考慮が必要なことが3つありましたが、Image Builder はこれら全てをクリアしています。

情報の形式に関しては Image Builder は脆弱性スキャンの際に Amazon Inspector を利用しているらしく、取得した脆弱性情報は Amazon Inspector でスキャンした形式で保管されます。

これのお陰で脆弱性情報の形式を合わせる必要が有りませんでした。

他リソースに影響を出さないようにすることに関しても、独立した VPC を作成し AWS Private Link を利用して通信を行うことで VPC 外に影響を発生させることなく脆弱性スキャンを行うことができます。

aws.amazon.com

trivy

言わずとしれたセキュリティ関係 OSS の大御所 trivy です。

aquasecurity.github.io

最近 VM Image scannig という機能で AMI の脆弱性スキャンにも対応したらしく、やりたいこととしてはドンピシャでした。(ただし EXPERIMENTAL であるため正式リリースではない)

しかし Image Builder とは違い AWS のマネージドシステムではないため、以下の点が懸念となりました。

  1. trivy を動作させるための EC2 インスタンスの管理が必要になる
  2. VPC を分けた際に情報のやり取り方法を考える必要がある
  3. json 形式でのデータ出力が可能だが、既存のシステムに取り込むにはデータの整形が必要になる

これらの懸念事項を解決するより Image Builder を利用するほうが早そうということで今回は Image Builder を採用しました。

ただし trivy の方が劣っているというわけではありません。

Amazon Inspector をすでに利用しているか否かという点で trivy と Image Builder のどちらを採用するかは変わってくると思います。

我々の場合は脆弱性のスキャン結果の形式という点で Image Builder に優位性がありましたが、形式に拘らない場合は trivy でも全く問題がないです。

設計

使うものの選定が終わったら設計を行いました。

Image Builder を使ったことがなかったので、手組みでどういう動きになるのかを確認しつつ、Terraform で実装する場合はどうするかに着目しました。

また、このタイミングでは Image Builder の設置・動作場所について以下の案を採用するかを決定していなかったので、今後の変更がし易いような設計としていました。

  • AWS を管理している部署のみアクセスできる AWS アカウントに作成し、 AMI を他アカウントからコピーしてくる(最終的にこちらに決定)
  • 各アカウントに作成し、そのアカウントに存在する AMI を対象とする

実装

Terraform 化

メインとなる Image Builder や AWS リソースは設計段階で検証のために作成したものを terraform import でゴリゴリ取り込んで大まかな形を作りました。

その後不要な設定や依存関係・ハードコーディングとなってしまっている部分の整頓を行い、一旦 Terraform 化を完了させました。

モジュール化

Terraform のコードに落とし込んだ後、複数アカウントでの利用をし易いように、Image Builder 関連とネットワーク関連のコードをモジュール化しました。

モジュールリポジトリは以下の様な構成となっています。

.
├── README.md
├── component.yml                 # Image Builder 用ビルドコンポーネント
├── gitlab-ci
│   ├── ci_stages.yml             # gitlab-ci 用ステージ定義
│   ├── ci_terraform_format.yml   # terraform fmt など format ステージ定義
│   ├── ci_terraform_lint.yml     # terraform lint など lint ステージ定義
│   ├── ci_terraform_tfsec.yml    # tfsec 用ステージ定義
│   ├── ci_terraform_tftest.yml   # モジュールテスト用ステージ定義
│   ├── ci_terraform_validate.yml # terraform validate 用ステージ定義
│   ├── ci_upload.yml             # gitlab へモジュールをアップロードするステージ定義
│   ├── make_vars.sh              # ci_upload.yml, ci_terraform_tftest.yml などで利用する環境変数定義スクリプト
│   └── set_credentials.sh        # AWS の認証情報作成スクリプト
├── image_builder.tf              # Image Builder 関連リソースを定義する tf ファイル
├── locals.tf                     # for_each で回す用の配列を定義した tf ファイル
├── network.tf                    # subnet, security_group などネットワーク関連リソースを定義する tf ファイル
├── output.tf                     # 作成した重複防止用乱数・Image Pipeline の ARN の出力定義ファイル
├── providers.tf
├── terraform.tf
├── test                          # モジュールの正常性を確認するためのテストスクリプト用ディレクトリ
│   ├── Pipfile
│   ├── Pipfile.lock
│   ├── make_ami_list.sh
│   ├── terraform
│   │   ├── ami_list.json
│   │   ├── backend.tf
│   │   ├── gitlab_tfstate.tfvars
│   │   ├── main.tf
│   │   ├── providers.tf
│   │   ├── terraform.tf -> ../../terraform.tf
│   │   ├── variables.tf
│   │   └── vars.tfvars
│   └── test_main.py
└── variables.tf                  # VPC_ID, VPC_IP, スキャン対象 AMI の ID 等、すでに作成されているが関連付けが必要な情報を定義するファイル

この内モジュールとしてアップロードしているのは以下のファイルです。

リソースの定義を行っているファイルについては、定義しているリソースについても記載します。

  • image_builder.tf
    • aws_key_pair
      • AMI 作成時に起動する EC2 インスタンスのキーペアとして利用する
    • aws_imagebuilder_distribution_configuration
      • Image Builder のディストリビューション設定の定義
    • aws_imagebuilder_component
      • Image Builder のビルドコンポーネントを定義
      • echo コマンドのみの実行を定義した component.yml を利用する設定とする
    • aws_imagebuilder_image_recipe
      • Image Builder のイメージレシピを定義する
      • ami_list.json (後述)に記載されている AMI ID に対して for_each を回し、個別に作成する
    • aws_iam_role
      • AMI 作成時に起動する EC2 インスタンス用 IAM ロール
    • aws_iam_instance_profile
      • IAM ロールと EC2 インスタンスを紐づける
    • aws_imagebuilder_infrastructure_configuration
      • Image Builder のインフラストラクチャ構成を定義
    • aws_imagebuilder_image_pipeline
      • Image Builder の構成要素からイメージパイプラインを定義
  • network.tf
    • aws_vpc
      • Terraform 管理外で作成済みであるため data として取り込む
    • aws_vpc_endpoint
      • プライベートネットワークでの実行であるため VPC Endpoint 経由での通信になるため
      • locals.tf で定義したリソース( ssm ssmmessages ec2 ec3messages s3 )分 VPC Endpoint を作成する
    • aws_route_table
    • aws_subnet
    • aws_security_group
  • locals.tf
  • output.tf
  • variables.tf
  • terraform.tf
  • component.yml
    • ビルドコンポーネントを定義するファイル。今回は AMI の作成が目的ではないため echo コマンドのみ実行し AMI には影響を与えないものとした。

敷設用Terraform

Terraform モジュールを呼び出し、 AWS アカウントに配布する Terraform リポジトリです。

このリポジトリで行っているのは、主に以下の 2 つの動作です。

  • 各アカウントの情報の収集
  • 定期実行による敷設 (Terraform モジュールの実行)

また、AMI スキャンシステムの敷設時のリポジトリ構成は以下の様になっています。

.
├── README.md
├── ami_list.json
├── backend.tf
├── gitlab-ci
│   ├── ci_stages.yml
│   ├── ci_terraform_exec.yml
│   ├── ci_terraform_format.yml
│   ├── ci_terraform_lint.yml
│   ├── ci_terraform_plan.yml
│   ├── ci_terraform_tfsec.yml
│   ├── ci_terraform_validate.yml
│   ├── make_ami_list.yml
│   ├── make_vars.sh
│   ├── set_credentials.sh
│   └── terraform_version.yml
├── locals.tf
├── main.tf
├── output.tf
├── providers.tf
├── variables.tf
└── vars.tfvars

Python スクリプト

今回の AMI スキャンシステムの本題ではありませんが、実行のトリガーや動的な情報の収集のため、 Terraform でのリソース定義前後に以下の 2 つのPython スクリプトを実行しています。

  1. スキャン対象 AMI 取得スクリプト
  2. スキャン実行&後処理スクリプト

スキャン対象 AMI 取得スクリプト

スキャン対象 AMI 取得スクリプトについては、非常に単純で特定のタグが設定されている AMI の AMI ID を ami_list.json という名前でファイルに出力するだけです。

この ami_list.json の内容を terraform apply 時に var として渡すことで、aws_imagebuilder_image_recipe が AMI 毎に作成されます。

スキャン実行&後処理スクリプト

健全な脆弱性状況の可視化と金銭的コストを抑えるために、以下の要望が発生します。

  • 脆弱性スキャンを実施していないときはリソースを作成せず課金を抑えたい。
  • スキャン対象 AMI は定期的に更新したい。

これを元にすると理想的な流れとしては下記の流れとなります。

  1. 一日一回スキャン対象 AMI を取得
  2. 直後に Terraform でリソースが作成
  3. 作成完了後 Image Builder による脆弱性スキャンが実行
  4. Image Builder のイメージパイプラインを実行
  5. Image Builder のイメージパイプラインの完了を待機
  6. 全てのパイプラインが完了した後 AMI スキャン関連のリソースが削除される

4,5番の動作を行うのに手っ取り早いのは Python から イメージパイプラインの実行を行い、状況を取得することでした。

これによって Terraform によるリソース作成直後に Python からイメージパイプラインを実行し、実行完了直後にリソースの削除ができるわけです。

また、イメージパイプライン実行完了後に作成される AMI は Terraform 管理外のため Python から削除しています。

これを忘れると一回の実行ごとにスキャン対象の数だけ AMI が増えるので、塵も積もればで課金が増えます。

モジュール呼び出し側 Terraform の GitLab CI パイプライン

本システムによる AMI の脆弱性スキャンは一日一回の実行を前提としていますが、スケジューラとして GitLab CI を利用しています。

CI の大まかな流れは「スキャン実行&後処理スクリプト」に記載しましたが、ここでは各ステージの構成について書いていきます。

各ステージの構成

  1. format
    コードのフォーマット
  2. vaidate
    コードの文法・構文チェック
  3. lint
    静的構文チェック
  4. tfsec
    静的構文チェック(セキュリティ)
  5. make_ami_list
    スキャン対象 AMI を取得するために Python を実行
  6. plan
    問題なくリソースを作成できるか確認
  7. vuln_scan_exec
    AMI 脆弱性スキャンを実行

最後の vuln_scan_exec ステージですが、恐らくこのワークフローの中で一番良くわからないのはここだと思います。

このステージはリソース作成・AMI スキャンの実行&完了待機・リソース削除を行うステージですが、

AMI スキャンの実行&完了待機が何らかの原因で失敗するとパイプラインが終了し、Terraform で作成したリソースが残存したままになる懸念がありました。

これを before_script でリソース作成、 script で AMI スキャンの実行&完了待機、 after_script でリソースの削除とすることで、 AMI スキャンが異常終了した場合でもリソースの削除が行われ、かつジョブも失敗となるので通知を発火することが可能です。

大変だったこと + 解決策

理論の理解から実装まで行ったこと

Image Builder を用いて AMI の脆弱性スキャンを行う方法は、インターネットで調べると「やってみた」系の記事は見つかりますが、実装まで落とし込みシステムとして稼働させるものは有りませんでした。

なのでどの様な要件を考慮するべきか、どの様な設計にするか等は手探りの状況で進めていました。

Image Builder の利用経験がなかった

本件の主題となる Image Builder ですが、弊ユニットでは AMI の作成は Ansible と Packer で行っていたため知見がほとんどない状況でした。

なので設計と並行して手組みで Image Builder を触る作業が発生したのですが、イメージパイプライン・レシピ・コンポーネント・インフラストラクチャ設定等様々な要素の理解が必要で、色々混乱したり勘違いが発生したりしました

ただ、これは触ってみたらすんなり理解できたので、同じ様に構成要素の理解で困っている方はとりあえず触ってみることをおすすめします。

複数アカウントを対象に利用できるようになっている

弊社では現在 50 近い AWS アカウントを管理しています。多すぎ。

現状まだ開発完了段階で、開発用以外のアカウントへの配布には至っていませんが、最終的には複数アカウントへの配布を予定しています。

複数アカウントに配布する方法はいくつか考えられますが、恐らく Terraform モジュールにメイン機能を持たせて、アカウント毎の設定を持たせた呼び出し用 Terraform リポジトリから利用するというのが、修正と配布の効率が良いと思われます。

料金削減

Python スクリプトの項でも書きましたが、 AMI スキャンに必要なリソースを 24 時間 365 日で稼働させると、スキャンを実施していないタイミングでも VPC エンドポイント等の稼働料金がかかってしまいます。

これを GitLab CI から稼働状況の確認とリソースの削除を行うことによって必要なタイミングでの課金のみに抑えられるようにしました。

当初は料金についての計算が甘く 24 時間 365 日での稼働を前提としていたのですが、一つの AMI の脆弱性を可視化するために月額 8,000~10,000 円程度かかると流石にコスパが悪すぎるだろうと言うことで急遽対応しました。

自分は料金の観点が甘いということの理解、やろうと思えば結構削減できるということがわかったいい経験でした。

良かったこと

オートスケーリング起動インスタンスの脆弱性が正しく取得できるようになったことで、より正確な脆弱性状況がわかるようになりました。

また、残存する課題として、長期間稼働していないが削除できないインスタンスの脆弱性状況がわからないという課題がありますが、停止しているインスタンスは AMI を作成できるので、このシステムを流用して脆弱性状況の取得ができそうです。

改善点

AMI スキャンが失敗した場合 Terraform で作成したリソースの削除が行われますが、イメージパイプラインが稼働中である場合すべてのリソースが削除されるという保証がありません。

現状状況を確認して手動削除などの対応が必要になると考えていますが、どうにかして自動化したいところです。

まとめ

オートスケーリング起動インスタンスの脆弱性状況を可視化するため、AMI の脆弱性を取得するシステムを構築しました。

開発期間として2ヶ月半程度かかってしまいましたが、技術選定・要件定義・設計・実装をカバーできる良い題材だったと思います。

解決したかった問題に対応できましたし、残存課題への解決にも繋がりそうなので作ってよかったと思います。

インターネットで検索しても同じ様なことをやっている記事が見つからなかったのがこの記事を書いた一番のモチベーションでした。

同じ様なことをしている方がいらっしゃいましたら是非ブクマコメント等で教えていただけると嬉しいです。