CDKでPull through cacheを経由してECSにDocker公式コンテナをデプロイする

2023.11.17

こんにちは。CX事業本部Delivery部のakkyです。

Amazon ECS Fargateをプライベートサブネットに配置するとき、NATゲートウェイなどでインターネットへ通信ができないと、Dockerコンテナをダウンロードすることができません。

こんなときはVPCゲートウェイを使うのが王道です。 ただ、自分でビルドしたコンテナであればECRにプッシュするだけで問題はないのですが、パブリックに公開されているコンテナを使いたい場面も多々あると思います。特に変更することがない場合、Docker Hubからプルして自分のECRにプッシュするのは少々面倒です。

そこで、ECRのPull through cache機能を使い、VPCエンドポイント経由でDocker hubにあるコンテナをデプロイできるようにする構成をCDKで試してみました。

Pull through cacheとは?

プルスルーキャッシュはECR Public RepositoryやKubernetes、Quayなどのリポジトリの内容をECRにプロキシ・キャッシュしてくれる機能です。これを使うと、ECRに直接アップロードしたのと同じようにコンテナを使うことができます。

また、ECR Public RepositoryからはDocker Official Imageも利用することができるので、ECR Public Repositoryにないイメージも使うことができます。

2023/11/20追記:2023/11/18のアップデートで、アップストリームレジストリが追加され、プルスルーキャッシュがDocker Hubに直接対応しました。Docker Official Image以外のコンテナもダウンロードできるようになったようです。ただしDocker Hubのアクセストークンを登録する必要があります。

注意点としては、プルスルーキャッシュではDocker Hubにあるすべてのコンテナがプルできるわけではなく、「Docker Official Image」と書かれているコンテナのみが対象となります。SponsordOSSやVerified Publisherやその他のアカウントでプッシュしたものなどは使えません。

VPCエンドポイント経由でECRを使うには?

今回はECSインスタンスをプライベートサブネットに配置し、NATではなくVPCエンドポイント経由でコンテナをダウンロードしたかったので、以下のVPCエンドポイントを用意しておきました。

  • ECR API
  • ECR DKR
  • S3(ゲートウェイ型)
  • Cloudwatch Logs(ログを使う場合)

必要なエンドポイントへアクセスできないと、デプロイが延々とリトライされcdk deployが終わらなくなります。

次の記事に詳しく説明されていますのでご覧ください。

CDKコード

httpd(Apache)をデプロイしてみました。VPC、セキュリティーグループ、VPCゲートウェイ、ALB関連のコードは除いてあります。

プルスルーキャッシュ

L1コンストラクトで作ります。リポジトリプレフィックスをecr-publicとしていますが、この名前がコンテナのpull時に指定する名前のプレフィックスとなります。

new ecr.CfnPullThroughCacheRule(this, `pullthroughcacherule`, {
  ecrRepositoryPrefix: "ecr-public",
  upstreamRegistryUrl: "public.ecr.aws"
});

IAMロール

ECS実行ロール(デプロイ時に使うロール)は通常CDKでは自動的に生成されますが、プルスルーキャッシュを使う際には権限を追加する必要があります。

resourcesに指定するリポジトリ名は、プルスルーキャッシュのプレフィックスを使って指定しています。

なお、マネジメントコンソールには、レジストリポリシー(CDKではecr.CfnRegistryPolicyで作れるもの)と呼ばれるものがありますが、こちらはリソースベースのポリシーとなり、IAMユーザーしか指定できないので今回は使いません。

まずはポリシー全体を自分で定義する例です。service-role/AmazonECSTaskExecutionRolePolicyを元にインラインポリシーを追加します。

const role = new iam.Role(this, 'ECSTaskExecutionRole', {
  assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
  managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AmazonECSTaskExecutionRolePolicy")],
  inlinePolicies: {
    PullThroughCachePolicy: new iam.PolicyDocument({
      statements: [
		      new iam.PolicyStatement({
          actions: [
            "ecr:CreateRepository",
            "ecr:BatchImportUpstreamImage"
          ],
          resources: [`arn:aws:ecr:${region}:${account}:repository/ecr-public/*`]
        })
      ]
    })
  },    
});

別の方法として、CDKが自動生成するポリシーにインラインポリシーを追加するには、次のようにします。obtainExecutionRole()経由で自動生成されるロールを取得できます。 こちらのほうが権限が狭まって望ましいかもしれません。

httpdTask.obtainExecutionRole().attachInlinePolicy(new iam.Policy(this, 'PullThroughCachePolicy', {
  statements: [ new iam.PolicyStatement({
    actions: [
      "ecr:CreateRepository",
      "ecr:BatchImportUpstreamImage"
    ],
    resources: [`arn:aws:ecr:${region}:${account}:repository/ecr-public/*`]
  })],
}));

なお、いずれもresourcesはパススルーキャッシュ全体にしていますが、方針によってはさらに範囲を絞ったほうがいいでしょう。

コンテナ定義

imageでリポジトリを指定するには、以下のようにリポジトリ名を「ecr-public/docker/library/コンテナ名」とします。(ecr-publicはプルスルーキャッシュを作成したときに指定したプレフィックスです。)

なお、自動生成されるポリシーにインラインポリシーを追加する手法を取る場合は9行目のroleを指定する必要はありません。ただしecs.FargateTaskDefinitionのうしろにインラインポリシーの追加コードを記載する必要があります。

const cluster = new ecs.Cluster(this, `FargateCluster`, { vpc: vpc });
const httpdTask = new ecs.FargateTaskDefinition(this, `HttpdTask`, {
  cpu: 256,
  memoryLimitMiB: 512,
  runtimePlatform: {
    cpuArchitecture: ecs.CpuArchitecture.X86_64,
    operatingSystemFamily: ecs.OperatingSystemFamily.LINUX,
  },
  executionRole: role,
});
const httpdContainer = httpdTask.addContainer(`HttpdContainer`, {
  image: ecs.ContainerImage.fromEcrRepository(ecr.Repository.fromRepositoryName(this, `ecr-public-repo`,"ecr-public/docker/library/httpd"), "2.4.58-alpine"),
  portMappings: [
    {
      containerPort: 80,
      hostPort: 80,
      protocol: ecs.Protocol.TCP,
    },
  ],
  logging: ecs.LogDriver.awsLogs({
    streamPrefix: 'httpd',
    logGroup,
  }),
});
const httpdService = new ecs.FargateService(this, `HttpdService`, {
  cluster,
  taskDefinition: httpdTask,
  platformVersion: ecs.FargatePlatformVersion.LATEST,
  vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
  assignPublicIp: false,
  securityGroups: [ecsSecurityGroup],
  desiredCount: 1,
});

以上