ENECHANGE Developer Blog

ENECHANGE開発者ブログ

セルフホストClairでECRイメージの脆弱性スキャン環境を構築した話

こんにちは、ENECHANGEエンジニアの藤巻です。

コンテナイメージの脆弱性スキャン、皆さんはどのように運用していますか?

私たちのチームでは毎月ECRイメージをスキャンし、新たに検出された脆弱性に見解を付けて報告する運用をしています。

ところが、ECRのスキャン方式の移行に伴いスキャン環境を更新したところ、前回結果との差分(「新規」として見解を付けるべき脆弱性)が大量に発生し、報告運用が立ち行かなくなるという問題に直面しました。

この記事では、別のスキャン方式を検討した結果、OSSの脆弱性スキャナー「Clair」をローカルにセルフホストしてECRイメージをスキャンできる環境を構築した話を紹介します。

なお本記事は、ローカルに Clair を立てて ECR イメージをスキャンし、月次報告に使えるレポートを作るところまでを対象としています。Amazon ECR やコンテナの脆弱性スキャンに一度は触れたことがある方を想定しており、手元で試す場合は Docker と AWS CLI が必要です。

背景:ECRスキャン方式の移行で差分が大量に発生

冒頭で触れた月次の脆弱性報告では、ECRのイメージスキャンに「ベーシックスキャン」を利用していました。AWS はこのベーシックスキャンについて、2024年8月に AWS ネイティブ技術を使う新バージョンを一般提供開始し、2026年2月には Clair ベースの旧バージョンを非推奨としました。

  • 旧方式: Clair ベース
  • 新方式: AWSネイティブのスキャン技術

参考: Document history - Amazon ECR

旧方式が非推奨となったため、新方式へ移行しました。移行自体は必要なものと受け止めていましたが、移行後最初のスキャンで検出結果が一変しました。

問題になったのは、検出された総数そのものよりも前回結果との差分です。スキャンエンジンの判定基準が変わったことで、前回までのレポートに存在しなかった脆弱性が一斉に「新規」として大量に出現し、月次報告で1件ずつ見解を付ける運用が現実的でなくなってしまいました。

増加分の大半は Linux カーネルパッケージに対する検出でした。コンテナはホストのカーネルを共有して動作するため、イメージ内に含まれる Linux カーネルパッケージの脆弱性は実質的に影響がないケースがほとんどです。

とはいえ「おそらく影響ないだろう」で済ませるわけにはいきません。検出された脆弱性に対して、根拠を持って「問題ない」と説明できることが報告には必要でした。

そこで、AWSネイティブのスキャンとは別の方式で、従来と同等の基準でスキャン・報告を継続できる方法がないかを検討することにしました。

なぜ結果が変わったのか:検出するデータソースの違い

別の方式を検討するにあたり、まず新旧のスキャン方式で何が違うのかを調べました。差を生んでいたのは、両者が「どのデータソースを脆弱性の検出基準にしているか」の違いでした。

新方式(AWSネイティブ) は、AWS公式ドキュメントによると50以上のデータフィードを統合しており、その中にはベンダーのセキュリティアドバイザリに加えて NVD(National Vulnerability Database)や MITRE も含まれます。重要度は、利用可能であれば upstream(ディストリビューション)の評価が使われ、なければ CVSS スコアが使われます。

Basic scanning provides vulnerability detection across a broad set of popular operating systems, sourcing more than 50 data feeds... These sources include vendor security advisories, data feeds, threat intelligence feeds, as well as the National Vulnerability Database (NVD) and MITRE.

Amazon ECR basic scanning より)

Clair V4 は、Alpine・Debian・Ubuntu・RHEL などディストリビューション固有のセキュリティトラッカーを検出基準とします。私たちのように Debian ベースのイメージを対象にする場合、検出と重要度は Debian Security Tracker が提供する情報がもとになり、NVD は脆弱性を見つけるためではなく CVSS スコアなどの補足(enrichment)に使われます(ClairCore: Severity Mapping)。

この違いが端的に表れたのが Linux カーネルの脆弱性でした。新方式では NVD 由来のCVEが多数検出されていましたが、それらの一部は Debian Security Tracker には登録がなく、Clair V4 では検出対象になりません。これは「どのデータソースを基準にするか」の違いによるものです。

私たちが Clair を選んだのは、検出された脆弱性ひとつひとつを Debian セキュリティチームの評価を根拠に判断できるからです。私たちが使っているのは Debian ベースのイメージなので、その評価をそのまま自分たちの環境の判断材料にできます。

加えて、従来も Clair ベースで運用してきたため、同系統の Clair に戻すことで評価基準の連続性を保てる点も大きな理由でした。

今回の問題はそもそも、スキャン方式が変わって判定基準が切り替わったことで差分が大量に出たことに起因しています。別の基準に乗り換えれば、また一から見解を付け直すことになり、同じ混乱を繰り返しかねません。同系統の Clair に戻すことで、これまで積み上げてきた見解を活かしつつ、根拠を持って報告を続けられると考えました。

そこで、OSSとして開発が継続されている Clair V4 をローカルにセルフホストして、以前と同等の基準でスキャンできる環境を構築することにしました。

参考: quay/clair: Vulnerability Static Analysis for Containers

AWSがやめたClairを、使い続けてよいのか

ここで当然湧いてくるのが、「AWS がベーシックスキャンで Clair をやめた以上、自分たちで Clair を使い続けることにリスクはないのか」という疑問です。運用上考慮すべき点として、次の3つを検討しました。

1. 対象範囲

ベーシックスキャンが見るのは OS パッケージが中心で、アプリケーションの言語パッケージは対象外です。ただしこれは Clair に固有の話ではなくベーシックスキャン共通の性質で、今回のエンジン移行で変わるものでもありません。

2. 脆弱性DB更新の遅延

Clair は OSS の脆弱性DBの更新ペースに依存します。ただし私たちは月次でスキャンする運用のため、検知の即時性はもともと求めていません。旧ベーシック → 新ベーシックへ移行しても、月次運用である以上この点は変わらないため、Clair が特段不利になるわけではありません。

3. OSS依存のリスク

外部 OSS プロジェクトである以上、メンテナンス状況に左右されるリスクは常にあります。とはいえ Clairは現在も開発が続いており、セルフホストであれば使うバージョンや切り替えの判断を自分たちでコントロールできるメリットがあります。

これらを踏まえても、従来と同じ基準で差分を追えること、そしてDebian の評価を根拠に「対応する/しない」を説明できることのメリットが上回ると判断し、Clair V4 を採用しました。

Clairをローカルで動かす最小構成

まずは「とりあえずローカルでECRイメージをスキャンしてみる」ための最小構成を紹介します。必要なのは Docker Compose の定義と Clair の設定ファイルの2つだけです。構築にあたっては公式ドキュメントの Getting Started を参考にしました。

参考: Getting Started With ClairV4 - Clair Documentation

Docker Compose:Clair + PostgreSQL

Clair はメタデータの保存に PostgreSQL を必要とするため、2コンテナ構成にします。

# docker-compose.clair.yml
services:
  clair-db:
    image: postgres:15
    platform: linux/amd64
    environment:
      POSTGRES_DB: clair
      POSTGRES_USER: clair
      POSTGRES_PASSWORD: clair  # ローカル専用の使い捨て環境のため固定値
    volumes:
      - clair-db-data:/var/lib/postgresql/data
    networks:
      - clair-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U clair -d clair"]
      interval: 5s
      timeout: 5s
      retries: 10

  clair:
    image: quay.io/projectquay/clair:4.7.4
    platform: linux/amd64  # Clairイメージはamd64のみ提供のため、Apple Silicon環境では指定推奨
    ports:
      - "6060:6060"
    volumes:
      - ./clair-config.yaml:/etc/clair/config.yaml:ro
      - ./tmp/docker-config:/root/.docker:ro  # ECR認証情報をマウント
    networks:
      - clair-net
    depends_on:
      clair-db:
        condition: service_healthy
    command: -conf /etc/clair/config.yaml

networks:
  clair-net:
    name: clair-net  # clairctlコンテナを同じネットワークに接続するため名前を固定

volumes:
  clair-db-data:

Clair設定

# clair-config.yaml
http_listen_addr: "0.0.0.0:6060"
log_level: info

indexer:
  connstring: host=clair-db port=5432 user=clair dbname=clair password=clair sslmode=disable
  scanlock_retry: 10
  layer_scan_concurrency: 5
  migrations: true

matcher:
  connstring: host=clair-db port=5432 user=clair dbname=clair password=clair sslmode=disable
  max_conn_pool: 100
  migrations: true

updaters:
  sets:
    - debian   # スキャン対象がDebianベースのため、Debianのみに限定

notifier:
  connstring: host=clair-db port=5432 user=clair dbname=clair password=clair sslmode=disable
  migrations: true
  delivery_interval: 1m

設定上、気をつけておきたいのが updaters.sets です。

Clair はデフォルトで全ディストリビューション(Alpine、RHEL、Ubuntu、SUSE…)の脆弱性データベースを取得しようとします。最初は設定なしで起動したところ、初回のDB更新でメモリを使い果たしてコンテナが OOM(Exit 137)で落ちてしまいました。

スキャン対象のイメージが Debian ベースであれば、updaters.setsdebian のみに絞ることで、メモリ消費と初期化時間を大幅に削減できます。

また、notifier 機能を使わない場合でも notifier セクションは省略できません。

mode を指定せずに起動すると、Clair は indexer / matcher / notifier をすべて1プロセスで動かす combo モードになります。この場合、notifier の接続文字列がないと起動時に fatal error になりました。

スキャンの実行

ECRのイメージをスキャンするには、まず認証情報を Docker の config.json 形式で用意し、Clair にマウントします(Clair がスキャン対象イメージのレイヤーをECRから直接取得するためです)。

# ECR認証情報を config.json 形式で生成
ECR_TOKEN=$(aws ecr get-login-password --region <リージョン>)
mkdir -p tmp/docker-config
cat > tmp/docker-config/config.json <<EOF
{
  "auths": {
    "<アカウントID>.dkr.ecr.<リージョン>.amazonaws.com": {
      "auth": "$(echo -n "AWS:${ECR_TOKEN}" | base64)"
    }
  }
}
EOF

# Clair起動(初回は脆弱性DBの取得に数分かかります)
docker compose -f docker-compose.clair.yml up -d

なお、生成した config.json には ECR の認証トークンが含まれます。リポジトリにコミットしたり共有したりしないよう注意してください(後述の運用スクリプトでは、処理の最後に自動で削除しています)。

スキャンには Clair 公式のCLIクライアント clairctl を使います。ローカルにインストールしてもよいですが、Goのコンテナから実行することもできます。

docker run --rm \
  --network clair-net \
  -v "$(pwd)/tmp/docker-config:/root/.docker:ro" \
  golang:1.22 sh -c '
    go install github.com/quay/clair/v4/cmd/clairctl@v4.7.4 &&
    clairctl report --host http://clair:6060 -o json <イメージURI>'

--network clair-net で Clair と同じネットワークに接続することで、http://clair:6060 というサービス名でアクセスできます。これで脆弱性レポートがJSON(-o で text / xml も指定可能)で得られます。

私たちの運用構成

最小構成のままでも使えますが、毎月の報告運用に組み込むには「コマンド一発で完結すること」と「報告に使えるレポートが出ること」が必要でした。そこで上記の流れをシェルスクリプトにまとめ、コマンド一発で実行できるようにしています。

このスクリプトの裏側では、次の処理が自動で実行されます。

  1. ECR の認証情報を取得し、一時ファイルに保存
  2. Clair(+ PostgreSQL)の起動と、脆弱性DBの初期化待機
  3. clairctl でECRイメージをスキャン
  4. スキャン結果(JSON)を報告用のレポートに変換
  5. Clair 環境の停止と認証情報のクリーンアップ

スクリプト化にあたって意識した点は次のとおりです。

  • 初期化完了の検知: Clair は起動後も脆弱性DBの初回取得が完了するまでスキャンを受け付けないため、初期化完了を待ってからスキャンを開始(詳細は後述のハマりポイント参照)
  • clairctl のキャッシュ: 毎回ビルドすると時間がかかるため、ビルド結果を Docker ボリュームにキャッシュ

レポート生成の工夫

スキャン結果のJSONはそのままでは報告に使えないため、報告用レポートへの変換もスクリプトに組み込みました。運用上効果が大きかった工夫をいくつか紹介します。

レポート生成の工夫(クリックで展開)

新規/既存の判定

レポートは日付フォルダ(tmp/clair-reports/<YYYY-MM-DD>/)に出力し、前回の日付フォルダのCSVと照合して各脆弱性に「新規/既存」のフラグを付けます。これにより、月次報告で「今月新たに検出されたもの」だけに注目できます。

照合キーは (CVE名, パッケージ名) です。当初はパッケージのバージョンも含めていたのですが、ベースイメージの更新でパッケージのマイナーバージョンが上がるたびに同じCVEがすべて「新規」扱いになってしまったため、バージョンを除外しました。

見解の自動設定

各脆弱性への見解(対応方針コメント)を、以下の優先順で自動設定します。

  1. 既存の脆弱性 → 前回レポートの見解を引き継ぎ
  2. パッケージ別の定型見解(例: linux → 「コンテナ環境ではホストカーネルが使われるため実質的な影響なし」)
  3. 説明文ベースの判定(DoS系、ローカルアクセス前提、など)
  4. 重要度別のデフォルト見解

ステータスの取得

CVEのステータス(PUBLISHED/REJECTED)はCVE.org のAPIから毎回取得します。前回レポートから引き継ぐ方式だと、後からREJECTEDに変更されたCVEに追従できないためです。

結果:報告可能な運用に戻った

Clair V4 に切り替えた結果、検出されるのは Debian Security Tracker が評価した脆弱性が中心になりました。旧方式と同系統(Clairベース)の判定基準に戻ったことで前回との差分も落ち着き、新規検出分に1件ずつ見解を付ける月次運用を無理なく続けられています。

ただし「危険な脆弱性が検出されなかったから安心」「差分が出なくなったから問題ない」と考えているわけではありません。報告に使えると判断できた理由は、Clair V4 が検出するのは Debian Security Tracker によって評価された脆弱性であり、その一つひとつを Debian の評価を根拠に、自分たちの構成を踏まえて確認できるからです。

脆弱性が挙がれば Debian の評価をもとに見解を付けられますし、挙がらないものは Debian が評価対象としていないということで、いずれにせよ判断の拠りどころがはっきりしています。「差分の有無」ではなく「評価された脆弱性を根拠を持って確認できること」を、移行後の結果を報告に使える根拠としました。

ハマりポイントまとめ

構築中にハマったポイントを改めてまとめます。これから構築する方の参考になれば幸いです。

問題 原因と対策
起動直後に fatal error(failed to initialize notifier mode を指定せず起動すると combo モードになり、notifier を使わなくても notifier.connstring が必須。設定ファイルに notifier セクションを記載する
コンテナがOOM(Exit 137)で落ちる デフォルトで全ディストリビューションの脆弱性DBを取得するため。updaters.sets で必要なディストリビューションのみに限定
updaters設定が効かない 現行の正しい書き方はトップレベルの updaters.sets。旧バージョン(v4.0系)の公式サンプルでは matcher.updater_sets 形式だったため、検索で見つかる古い設定例を参考にすると旧形式で書いてしまう。旧形式で書いても起動エラーにならず、黙って無視される(結果、全ディストリビューションの取得が走りOOMの原因にもなる)
スキャン時に connection refused HTTPサーバーの起動=初期化完了ではない。脆弱性DBの初回取得完了(ログの starting background updates)を待つ

おわりに

ECRのスキャン方式変更をきっかけに、セルフホストClairによるスキャン環境を構築した話を紹介しました。

脆弱性スキャンの運用で大切なのは、検出件数の多寡や差分の有無ではなく、結果のひとつひとつに対して根拠を持って「対応する/しない」を説明できる状態を作ることだと感じています。

今回は、スキャン基準が変わったことで生じた混乱を、従来と同じ Clair ベースの基準に戻し、Debian セキュリティチームの評価を根拠に報告を続けられる状態にすることで収束させました。

これまで積み上げてきた評価基準との連続性も含め、運用の目的に合わせてツールを選ぶという視点が参考になれば幸いです。

最後までお読みいただき、ありがとうございました。


弊社では現在、エンジニア採用のカジュアル面談を実施しています。 ご興味があれば、ぜひ以下の求人ページをご覧いただき、一度お話しできると嬉しいです!

herp.careers