DockerHubのコンテナイメージは出自が分からないものも多く、特に企業が利用するには少し不安が残る。
またいつ公開停止になるかも分からないし、自前のリポジトリに移して利用したいケースは多いと思う。
ここではコンテナイメージ利用者はDockerHubのイメージは直接参照せず、脆弱性が見つからなかったイメージのみをECRから使ってもらうようなユースケースを想定し、以下を実現するGitHub Actionsのフローを作成する。
なお、ECRではリポジトリに対してscanOnPush=true
を設定することでPush時に自動的にスキャンさせることも可能だが、設定されていることは期待せずに手動でスキャンを実行する形にする。
前提
以下を前提とする
- CI/CDはGitHub Actionsを利用し、Runnerは起動済み
- 以下の環境変数は作業端末内で設定済みとする。
export AWS_REGION=us-east-1
export AWS_ACCESS_KEY_ID="ASIAV..."
export AWS_SECRET_ACCESS_KEY="Fh4E..."
export AWS_SESSION_TOKEN="IQoJ..."
- GitHub CLIをインストール済み
ECRのリポジトリの準備
ECRのリポジトリを作成する。
REPOSITORY_NAME=dockerhub-migration/kong-gateway
aws ecr create-repository --repository-name $REPOSITORY_NAME --region $AWS_REGION
今回はイメージの対象をkong/kong-gatewayとし、tag:3.4.0.0と3.4.3.18で動作検証する。
パイプラインの作成
カレントディレクトリにgitのcloneがあることを前提として進める。
最初にGitリポジトリにAWSを操作するためのSecretを作成する。
gh secret set AWS_ACCESS_KEY_ID --body $AWS_ACCESS_KEY_ID
gh secret set AWS_SECRET_ACCESS_KEY --body $AWS_SECRET_ACCESS_KEY
gh secret set AWS_SESSION_TOKEN --body $AWS_SESSION_TOKEN
また、コンテナリポジトリに関する設定を同様にVariablesとして設定する。
gh variable set AWS_REGION --body "us-east-1"
gh variable set REPO_NAME_DOCKER --body "kong/kong-gateway"
gh variable set IMAGE_TAG_DOCKER --body "3.4.0.0"
gh variable set REPO_NAME_ECR --body "dockerhub-migration/kong-gateway"
gh variable set IMAGE_TAG_ECR --body "latest"
ECR側のTagをlatest
にしているが、セキュリティ観点からは同じTag名にした方がいいと思う。
今回は利便性の都合上latest
にしている。
これらはパイプラインからECRにアクセスする際に利用される。
次にGitHub Actionsで使うファイルを作成するためにディレクトリを作成する。
mkdir -p .github/workflows
パイプラインを作成してpushする。
cat <<'EOF' > ./.github/workflows/dockerhub2ecr.yaml
name: Push and Scan ECR Image
on:
workflow_dispatch:
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Log in to Amazon ECR
id: login-ecr
run: |
set -x
ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --output text)
echo "ACCOUNT_ID=$ACCOUNT_ID" >> $GITHUB_ENV
ECR_URI="${ACCOUNT_ID}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com/${{ vars.REPO_NAME_ECR }}"
echo "ECR_URI=$ECR_URI" >> $GITHUB_ENV
aws ecr get-login-password --region ${{ vars.AWS_REGION }} | docker login --username AWS --password-stdin "$ECR_URI"
- name: Pull and Tag Docker image
run: |
set -x
docker pull ${{ vars.REPO_NAME_DOCKER }}:${{ vars.IMAGE_TAG_DOCKER }}
docker tag ${{ vars.REPO_NAME_DOCKER }}:${{ vars.IMAGE_TAG_DOCKER }} $ECR_URI:${{ vars.IMAGE_TAG_ECR }}
- name: Push Docker image to ECR
run: |
docker push $ECR_URI:${{ vars.IMAGE_TAG_ECR }}
- name: Start ECR image scan
run: |
aws ecr start-image-scan \
--repository-name ${{ vars.REPO_NAME_ECR }} \
--image-id imageTag=${{ vars.IMAGE_TAG_ECR }}
- name: Wait and check scan results
run: |
for i in {1..10}; do
result=$(aws ecr describe-image-scan-findings \
--repository-name ${{ vars.REPO_NAME_ECR }} \
--image-id imageTag=${{ vars.IMAGE_TAG_ECR }})
status=$(echo "$result" | jq -r '.imageScanStatus.status')
if [[ "$status" == "COMPLETE" ]]; then
echo "Scan complete."
echo "$result" > scan-results.json
break
fi
echo "Waiting for scan to complete... ($i)"
sleep 10
done
CRITICALS=$(jq '[.imageScanFindings.findings[] | select(.severity == "CRITICAL")] | length' scan-results.json)
if [ "$CRITICALS" -gt 0 ]; then
echo "Vulnerabilities found: CRITICAL=$CRITICALS"
echo "Deleting image..."
aws ecr batch-delete-image --repository-name ${{ vars.REPO_NAME_ECR }} \
--image-ids imageTag=${{ vars.IMAGE_TAG_ECR }}
exit 1
else
echo "No critical vulnerabilities found."
fi
- name: Upload scan results as artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: scan-results
path: scan-results.json
EOF
git add -A
git commit -m "Initial Commit"
git push origin main
以下、GitHub Actionsのフローについて説明する。
on:
workflow_dispatch:
実行契機について指定する箇所だが、対象コードなどを含まない構造にしているので手動実行であるworkflow_dispatch
のみ設定している。
- name: Checkout code
uses: actions/checkout@v4
上記はRunnerがソースコードを引っ張る処理である。
通常はビルドのためのコードなどを引っ張るために利用するが、今回に関しては不要。
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
aws-region: ${{ vars.AWS_REGION }}
上記はAWSを操作するための環境変数を設定する箇所となる。
詳細は以下の記事が参考になる。
- configure-aws-credentialsを読み解く
- name: Log in to Amazon ECR
id: login-ecr
run: |
set -x
ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --output text)
echo "ACCOUNT_ID=$ACCOUNT_ID" >> $GITHUB_ENV
ECR_URI="${ACCOUNT_ID}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com/${{ vars.REPO_NAME_ECR }}"
echo "ECR_URI=$ECR_URI" >> $GITHUB_ENV
aws ecr get-login-password --region ${{ vars.AWS_REGION }} | docker login --username AWS --password-stdin "$ECR_URI"
上記はECRへのログインである。最初にECRのURLを組み立てるためにaws sts get-caller-identity
でアカウントIDを取得し、ECR_URI
という環境変数にECRのアクセス先を定義する。
次に aws ecr get-login-password
でECRにログインするためのパスワードを取得し、docker login
でログインしてpush出来る状態にしている。
なお、ECRのURIはこの先も利用するが、毎回アカウントIDを取得するのは大変なので、"ECR_URI=$ECR_URI" >> $GITHUB_ENV
でGITHUB_ENV
に環境変数を追加することで別のstepでも参照できるようにしている。
- name: Push Docker image to ECR
run: |
docker push $ECR_URI:${{ vars.IMAGE_TAG_ECR }}
- name: Start ECR image scan
run: |
aws ecr start-image-scan \
--repository-name ${{ vars.REPO_NAME_ECR }} \
--image-id imageTag=${{ vars.IMAGE_TAG_ECR }}
上記でイメージをPushし、スキャンを開始している。
aws ecr start-image-scan
ではスキャンの開始の指示のみを行い、コマンドはスキャンの完了を待たない。
待つオプションもなさそうなので、次のstepで状態を監視する。
なお、AWS的なベストプラクティスとしてはスキャン結果の通知をEventEventBridge経由で受け取り、その後ゴニョゴニョするのが良さそうなのだが、個人的には同一のパイプラインの処理の中で完結させたかったのと、ベンダロックインを多少は避ける意味でも採用しないこととした。
- name: Wait and check scan results
run: |
for i in {1..10}; do
result=$(aws ecr describe-image-scan-findings \
--repository-name ${{ vars.REPO_NAME_ECR }} \
--image-id imageTag=${{ vars.IMAGE_TAG_ECR }})
status=$(echo "$result" | jq -r '.imageScanStatus.status')
if [[ "$status" == "COMPLETE" ]]; then
echo "Scan complete."
echo "$result" > scan-results.json
break
fi
echo "Waiting for scan to complete... ($i)"
sleep 10
done
上記ではaws ecr describe-image-scan-findings
の結果からスキャンの状態をチェックし、COMPLETE
なら結果をscan-results.json
に保存し、そうでない場合は10秒待って再チェック(最大10回)する処理となっている。
ここの待ち時間等はお好みで調整すると良い。
CRITICALS=$(jq '[.imageScanFindings.findings[] | select(.severity == "CRITICAL")] | length' scan-results.json)
if [ "$CRITICALS" -gt 0 ]; then
echo "Vulnerabilities found: CRITICAL=$CRITICALS"
echo "Deleting image..."
aws ecr batch-delete-image --repository-name ${{ vars.REPO_NAME_ECR }} \
--image-ids imageTag=${{ vars.IMAGE_TAG_ECR }}
exit 1
else
echo "No critical vulnerabilities found."
fi
上記ではCRITICALがあればエラーに倒してパイプラインも終了するようにしている。
またその際にPushしたイメージも誤って利用されるのを避けるために削除するようにしている。
HIGHでも引っ掛けたい場合は同じような書き方でHIGHも引っ掛けてif文の条件に追加すると良い。
- name: Upload scan results as artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: scan-results
path: scan-results.json
最後に結果をArtifactとしてアップロードして終了する。
動作確認
今の設定はkong/kong-gateway:3.4.0.0
に対してスキャンする設定となっている。
一旦この設定で動かしてみる。
GitHubのActions
からRun workflow
をクリックして実行する。
実行するとパイプラインは失敗する。
エラーを見ると以下のようになっている。
CRITICALの脆弱性が23個検出されたため、パイプラインが停止したことが分かる。
ECR上にもイメージは存在しない。
$ aws ecr describe-images \
--repository-name dockerhub-migration/kong-gateway
{
"imageDetails": []
}
イメージのバージョンが古かったため脆弱性が検出されたと考えられるので、次にイメージのバージョンを最新にして試してみる。
リポジトリ内のVariablesを変更する。
gh variable set IMAGE_TAG_DOCKER --body "3.4.3.18"
変更後、Actionsのジョブを再実行する。
次は問題なく成功する。
スキャン結果はArtifactとしてアップロードされたJSONからも確認できるし、ECRのUI上からも確認できる。
余談
スキャンを何度か実行すると以下のエラーが出るようになる。
An error occurred (LimitExceededException) when calling the StartImageScan operation (reached max retries: 2): The scan quota per image has been exceeded. Wait and try again.
こちらを見る感じ、1イメージあたり24時間につき1回、全体で24時間につき10万回スキャンが可能であり、1イメージを短時間で何度もスキャンするのは許されていない模様。
Quotaを気にするのが嫌な人は毎回イメージを削除してPushし直すか、イメージのPush前にTrivyでスキャンするようにし、そこで問題なかったらPushするような形に書き換えるとよい。
(ECRはイメージの格納のみでスキャンは利用しない)