🔨

AWS CDK へのコントリビュート~ECRの bug fix~

2023/06/12に公開

はじめに

AWS CDK への Pull Request がマージされ、このマージが取り込まれた最新バージョン v2.83.0 がリリースされました。

https://github.com/aws/aws-cdk/pull/25789

Pull Request の内容は「autoDeleteImages: true に設定した ECR リポジトリにマルチアーキテクチャコンテナイメージをプッシュした状態で cdk destroy してもリポジトリの削除に失敗する」というものでこちらの issue で報告されていたものです。この記事ではそもそもマルチアーキテクチャのコンテナイメージとは何か、CDK でのバグはなぜ起きたのか、どのように修正したのかを解説します。

マルチアーキテクチャコンテナイメージとは

その前に: コンテナイメージとは

コンテナイメージはベースイメージにレイヤーが積み重なったものです。例えば Docker の getting started にある例で言うと Node.js のベースイメージがあって、現在のディレクトリを COPY でコピーしたもの、yarn install でパッケージをインストールしたものが積み重なって一つのコンテナイメージとなります。

FROM node:18-alpine
WORKDIR /app
COPY . .
RUN yarn install --production
CMD ["node", "src/index.js"]
EXPOSE 3000

コンテナ標準は OCI (Open Container Initiative) によって仕様策定が進められていて、コンテナイメージの構造も仕様が定められています(OCI Image Manifest Specification)。コンテナイメージをビルドするとそのコンテナイメージの構造が Manifest ファイルの中で定義されます。例えば以下のような感じ。

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7",
    "size": 7023
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d09af107ee8f0",
      "size": 32654
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b",
      "size": 16724
    },
  ],
  "subject": {
    "mediaType": "application/vnd.oci.image.manifest.v1+json",
    "digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
    "size": 7682
  },
}

ここでコンテナイメージの config の中身を詳しく見てみましょう。OCI の仕様はこちらに書かれています。これをよくみてみると architecture という項目がありここで CPU アーキテクチャが指定されます。

つまりコンテナイメージの構造を定義するマニフェストのなかに config という項目があり、この config のなかで CPU アーキテクチャを指定するので、コンテナイメージとそこから実行されるコンテナが動く CPU アーキテクチャは一対一で対応していると言えます。

マニフェストを使う場合のコンテナイメージダウンロードと実行の流れは以下のようになります。AWS ブログの画像を借りています。

  1. コンテナホストはマニフェスト(hoge:amd64)をダウンロードする
  2. マニフェストの各レイヤーを次々にダウンロードしていく
  3. コンテナを実行する。この時ホストが amd64 ならマニフェストの config と合うので問題なく動くが、ホストが arm64 など異なるアーキテクチャの場合動かない

複数の CPU アーキテクチャでコンテナを動かしたい場合

複数の CPU アーキテクチャでコンテナを動かしたいケースがあると思います。例えば AWS でいうと Graviton というプロセッサを使うことでコスト効率よくアプリケーションを実行することができます。Graviton は Arm アーキテクチャなので Intel(x86 アーキテクチャ)とは CPU アーキテクチャが異なります。

前節に書いたようにコンテナイメージと CPU アーキテクチャは一対一で対応しています。つまり hoge-image:v1-arm コンテナイメージが Arm 用のコンテナイメージだとすると、同じコンテナイメージからは x86 アーキテクチャのホスト上でコンテナを実行できないわけです(エミュレーションなどを駆使すればできるかもしれません)。やるとすれば同じアプリケーションコードから x86 アーキテクチャ用にコンテナイメージを別にビルドして別のタグをつける (hoge-image:v1-x86 など)ことが考えられます。しかしこのように CPU アーキテクチャごとにタグを分けたり、ホストの CPU アーキテクチャごとに Kubernetes のマニフェストや ECS のタスク定義などを複数用意するのは手間だと思います。そこでマルチアーキテクチャのコンテナイメージというアイデアが生まれます。

マルチアーキテクチャコンテナイメージ

マルチアーキテクチャコンテナイメージのアイデアは複数のイメージマニフェストを参照するマニフェストリストを導入し、ホストは一旦このマニフェストリストをダウンロードして、その次にホストの CPU アーキテクチャに対応するイメージマニフェストがあればそれをダウンロードするようにしようというものです。OCI の仕様だと image index と呼ばれています。

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 7143,
      "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f",
      "platform": {
        "architecture": "arm64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 7682,
      "digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    }
  ],
}

上記の例のようにマニフェストリストは manifests の中で複数のイメージマニフェストを指し示します。マニフェストリストを使う場合のコンテナイメージダウンロードの流れは以下のようになります。先ほどと同じ AWS ブログの画像を借りています。

  1. コンテナホストはマニフェストリスト(hoge:v1)をダウンロードする
  2. コンテナホストの CPU アーキテクチャと同じアーキテクチャ(例えば amd64)、OS (例えば Linux)のイメージマニフェスト(hoge-image:some-id)をダウンロードする
  3. 以降は同じように各レイヤーをダウンロードしていく

このような流れだと最初にダウンロードするのはマニフェストリストなのでコンテナホストの CPU アーキテクチャは関係ありません。Amd64 でも Arm64 でもよくて同じマニフェストリストをまずダウンロードし、その後裏でホストと同じアーキテクチャのマニフェストをダウンロードします。CPU アーキテクチャごとにタグを分けて Kubernetes のマニフェストもわけて...とやらなくて良くなりました。

マルチアーキテクチャコンテナのビルド法

実際にコンテナをビルドする方法は例えばこの Docker のブログで紹介されています。docker manifest create コマンドでマニフェストリストを作成してプッシュする方法と、Docker buildxを使う方法があります。手軽にやるなら buildx を使うのが楽ですが、おそらく QEMU でエミュレーションする都合からかホストと異なるアーキテクチャをターゲットにビルドする時に結構時間がかかったので、ビルド時間を短くしたいなら複数ホストでコンテナイメージをビルドして最後にマニフェストリストを作成した方が早いかもしれません。

CDK での bug fix

バグに気づいたきっかけ

2023/05/25 にセミナーに登壇していてその準備がきっかけでした。セミナーの内容は Graviton ×コンテナで Graviton を使うことでコスパ良くコンテナワークロードを動かしましょうというものでした。自分の担当範囲はマルチアーキテクチャの仕組みとマルチアーキテクチャ用にコンテナをビルドする方法、そして ECS Fargate でマルチアーキテクチャのコンテナを動かすデモで登壇準備のためにマルチアーキテクチャコンテナイメージについて調べていました。デモも無事動いて準備できたからリソースを削除しようと cdk destroy を実行したところエラーが出ました。

1:18:24 PM | DELETE_FAILED        | AWS::ECR::Repository        | MultiArchitectureRepositoryE8EC151A
Resource handler returned message: "The repository with name 'demostack-multiarchitecturerepositorye8ec151a-5ngwzcsg0lmd' in registry with id '903
779448426' cannot be deleted because it still contains images (Service: Ecr, Status Code: 400, Request ID: 935246f5-f9a6-421c-8cdb-65996a3bd717)"
(RequestToken: 44dd6e4d-6cea-5797-6470-b4e0e111fd59, HandlerErrorCode: GeneralServiceException)

エラーメッセージを読むと ECR リポジトリを削除しようとした時にイメージが含まれていたので削除できなかったのが原因のようです。なぜこのエラーが出たのか掘り下げていきます。

autoDeleteImages の仕組み

CDK で ECR リポジトリを作成するときは autoDeleteImages というプロパティを指定することができます。これを true に設定するとドキュメントにあるようにスタックを削除するときに ECR リポジトリを削除してくれます( removalPolicy を DESTROY に設定しておく必要はあります)。なぜこのプロパティがあるかというと ECR リポジトリを削除するときにそこに含まれるイメージが全て削除されている必要があるからです。

If the repository contains images, you must either delete all images in the repository or use the force option to delete the repository.

autoDeleteImages: true にセットしておくと CloudFormation スタック削除時に ECR リポジトリのイメージを全て削除した後に DeleteRepository が行われます。どのような仕組みかというと CloudFormation の custom resource が使われています。今回は AWS Lambda-backend custom resource が使われていて、CloudFormation のスタック削除というイベントをトリガーに AWS Lambda が発火し、Lambda の中で ECR に保存されたイメージの削除を行います。つまり以下のような処理の流れになります。

  1. CloudFormation スタック削除開始
  2. スタック削除イベントをトリガーに AWS Lambda が発火
  3. Lambda の中で ECR に保存されたイメージを全て削除
  4. 3 の後に DeleteRepository を実行(このとき 3 でイメージが削除されているので ECR リポジトリは空っぽになっていて DeleteRepository が成功する)

Lambda handler のコードはこちらです(bug fix 以前のバージョンのコードを参照しています)。関係するところだけ抜粋しました。

// eslint-disable-next-line import/no-extraneous-dependencies
import { ECR } from 'aws-sdk';

const AUTO_DELETE_IMAGES_TAG = 'aws-cdk:auto-delete-images';

const ecr = new ECR();

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
  switch (event.RequestType) {
    case 'Delete':
      return onDelete(event.ResourceProperties?.RepositoryName);
  }
}

/**
 * Recursively delete all images in the repository
 *
 * @param ECR.ListImagesRequest the repositoryName & nextToken if presented
 */
async function emptyRepository(params: ECR.ListImagesRequest) {
  const listedImages = await ecr.listImages(params).promise();

  const imageIds = listedImages?.imageIds ?? [];
  const nextToken = listedImages.nextToken ?? null;
  if (imageIds.length === 0) {
    return;
  }

  await ecr.batchDeleteImage({
    repositoryName: params.repositoryName,
    imageIds,
  }).promise();

  if (nextToken) {
    await emptyRepository({
      ...params,
      nextToken,
    });
  }
}

async function onDelete(repositoryName: string) {
  if (!repositoryName) {
    throw new Error('No RepositoryName was provided.');
  }

  const response = await ecr.describeRepositories({ repositoryNames: [repositoryName] }).promise();
  const repository = response.repositories?.find(repo => repo.repositoryName === repositoryName);

  if (!await isRepositoryTaggedForDeletion(repository?.repositoryArn!)) {
    process.stdout.write(`Repository does not have '${AUTO_DELETE_IMAGES_TAG}' tag, skipping cleaning.\n`);
    return;
  }
  try {
    await emptyRepository({ repositoryName });
  } catch (e: any) {
    if (e.name !== 'RepositoryNotFoundException') {
      throw e;
    }
    // Repository doesn't exist. Ignoring
  }
}

async function isRepositoryTaggedForDeletion(repositoryArn: string) {
  const response = await ecr.listTagsForResource({ resourceArn: repositoryArn }).promise();
  return response.tags?.some(tag => tag.Key === AUTO_DELETE_IMAGES_TAG && tag.Value === 'true');
}

handler 関数はスタック削除イベントの際に onDelete 関数を呼び出し、emptyRepository 関数の中でリポジトリに保存されたイメージを全て削除します。emptyRepository 関数は単純にリポジトリに含まれるイメージを listImages で取得して batchDeleteImages に渡して削除を行なっています。

みたところ問題なくイメージが削除されてリポジトリも削除できそうですがなぜエラーが出たのでしょうか?答えはマルチアーキテクチャイメージにあります。

マルチアーキテクチャコンテナイメージを保存していた場合

マルチアーキテクチャコンテナイメージが何だったかを思い出すと、 CPU アーキテクチャとコンテナイメージが一対一で対応している時に複数のコンテナイメージを指し示すマニフェストリストが登場したのでした。

マルチアーキテクチャコンテナイメージを ECR に保存するとマニフェストリスト・イメージマニフェストたち・それぞれのマニフェストで定義されたレイヤーたちが ECR に保存されることになります。こちらの Docker buildx の issue コメントにあるようにマニフェストリストにタグが打たれていてイメージマニフェストを指し示している時にはマニフェストリストより先にイメージマニフェストは削除されません(明示しているドキュメントや OCI の仕様は見つけられていないですが手元で 2023/05 に動かした限りだとそのような挙動になっていました)。もしイメージマニフェストが削除されてしまうとクライアントから見たときにマニフェストリストはダウンロードできてクライアントホストと同じ CPU アーキテクチャのイメージマニフェストを次にダウンロードしようとしたら失敗して困るからだろうと考えています。

そのためマルチアーキテクチャコンテナイメージを ECR に保存しているときに autoDeleteImages: true にセットした ECR リポジトリを cdk destory でスタックごと削除しようとすると、custom resource の Lambda が発火してイメージを全て削除しようと試みるものの削除し切ることができずにイメージが残ってしまい、イメージが残った状態のまま DeleteRepository が行われてエラーが出たのだと考えられます。

ではどう直すか

Lambda の中の処理を直せば良さそうです。

https://github.com/aws/aws-cdk/blob/v2.83.0/packages/aws-cdk-lib/aws-ecr/lib/auto-delete-images-handler/index.ts#L39-L77

タグがついたイメージとタグがついていないイメージで分けて、まずタグがついたイメージだけ削除するようにしました。こうするとマルチアーキテクチャイメージであってもマニフェストリストが先に削除されます。その後でタグがついていないイメージの削除が行われ、このときはマニフェストリストから参照されていないので問題なく削除され、これを繰り返していけば ECR の中は空になるはずです。その後 DeleteRepository で ECR リポジトリを削除すればリポジトリが空っぽなのでエラーが出ることなく削除されます。

ちなみに: CDK 開発について

CONTRIBUTING.md にコントリビュートの仕方(ローカル開発環境のセットアップ法とか、単体テストの実行法とか、統合テストの実行法とか)がまとまっているのでこれを読めば雰囲気は掴めると思います。端末に Node 諸々をインストールしたくない方は Amazon CodeCatalyst がおすすめです。プロジェクトを作成して GitHub のリポジトリと接続した上で dev environment を立ち上げるとお使いのエディタ(VS Code や JetBrains 系の IDE )から開発用のインスタンスに ssh で接続してコーディングできます。

開発の tips として aws-cdk-lib をビルドする時などに heap out of memory が出ることがちょくちょくあったので export NODE_OPTIONS=--max-old-space-size=16384 としておくと開発しやすい気がします。

おわりに

CDK の bug fix とその原因だったマルチアーキテクチャコンテナイメージについて紹介しました。現在の CDK バージョン (v2.83.0 以降)だと Docker Buildx でビルドしたマルチアーキテクチャコンテナイメージなら ECR に保存しても cdk destroy でリポジトリをエラーなく削除できることを手元で動かして確認していますが、もしまだエラーが出ることがあればお知らせください。

Discussion