[AWS CDK] S3 Bucket class には、バケット削除時にオブジェクト自動削除もしてくれるオプション autoDeleteObjects がある!

2023.07.18

こんにちは、CX事業本部 Delivery部の若槻です。

最近、AWS CDK の S3 Bucket class にautoDeleteObjectsというオプションを見つけました。

このオプションを有効にすると、スタック削除時にバケット内のオブジェクトも自動削除してくれるとのことです。

この画期的なオプションを早速(※と言いつつ 2021 年から既に実装されていたようです)試してみました。

試してみた

環境準備

S3 Bucket コンストラクトで autoDeleteObjects を有効にします。その際に removalPolicy はDESTROYにする必要があります。(でないとそもそもバケット削除が試行されませんからね)

lib/cdk-sample-stack.ts

import { aws_s3, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class CdkSampleStack extends Stack {
  public readonly myFileObjectKey: string;

  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    new aws_s3.Bucket(this, 'MyBucket', {
      bucketName: `my-bucket-${this.account}-${this.region}`,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });
  }
}

バケットにオブジェクトをアップロードします。

touch test.txt
aws s3 cp test.txt s3://${bucketName}/test.txt

動作確認

S3 Bucket のコンストラクト定義の記述を削除して、デプロイします。

lib/cdk-sample-stack.ts

  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    // new aws_s3.Bucket(this, 'MyBucket', {
    //   bucketName: `my-bucket-${this.account}-${this.region}`,
    //   removalPolicy: RemovalPolicy.DESTROY,
    //   autoDeleteObjects: true,
    // });
  }

するとすんなりと削除できました。便利ですね!

$ cdk deploy

  Synthesis time: 3.88s

CdkSampleStack: deploying... [1/1]
CdkSampleStack: creating CloudFormation changeset...

   CdkSampleStack

  Deployment time: 33.03s

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:XXXXXXXXXXXX:stack/CdkSampleStack/e68b6900-f01c-11ed-8538-06bbc29e5367

  Total time: 36.91s

autoDeleteObjects による削除の仕組み

スタックのリソース一覧を見ると、Custom::S3AutoDeleteObjectsCustomResourceProviderというカスタムリソースが追加されています。このカスタムリソースは Lambda 関数と IAM Role から成っています。

Lambda 関数の内容を見てみます。

長いので折りたたみ

index.js

'use strict';
var C = Object.create,
  c = Object.defineProperty,
  f = Object.getOwnPropertyDescriptor,
  w = Object.getOwnPropertyNames,
  A = Object.getPrototypeOf,
  P = Object.prototype.hasOwnProperty,
  L = (t, e) => {
    for (var s in e) c(t, s, { get: e[s], enumerable: !0 });
  },
  d = (t, e, s, r) => {
    if ((e && typeof e == 'object') || typeof e == 'function')
      for (let o of w(e))
        !P.call(t, o) &&
          o !== s &&
          c(t, o, {
            get: () => e[o],
            enumerable: !(r = f(e, o)) || r.enumerable,
          });
    return t;
  },
  l = (t, e, s) => (
    (s = t != null ? C(A(t)) : {}),
    d(
      e || !t || !t.__esModule
        ? c(s, 'default', { value: t, enumerable: !0 })
        : s,
      t
    )
  ),
  k = (t) => d(c({}, '__esModule', { value: !0 }), t),
  _ = {};
L(_, { autoDeleteHandler: () => g, handler: () => O }), (module.exports = k(_));
var h = require('@aws-sdk/client-s3'),
  m = l(require('https')),
  R = l(require('url')),
  a = {
    sendHttpRequest: T,
    log: B,
    includeStackTraces: !0,
    userHandlerIndex: './index',
  },
  p = 'AWSCDK::CustomResourceProviderFramework::CREATE_FAILED',
  D = 'AWSCDK::CustomResourceProviderFramework::MISSING_PHYSICAL_ID';
function y(t) {
  return async (e, s) => {
    let r = { ...e, ResponseURL: '...' };
    if (
      (a.log(JSON.stringify(r, void 0, 2)),
      e.RequestType === 'Delete' && e.PhysicalResourceId === p)
    ) {
      a.log('ignoring DELETE event caused by a failed CREATE event'),
        await i('SUCCESS', e);
      return;
    }
    try {
      let o = await t(r, s),
        n = b(e, o);
      await i('SUCCESS', n);
    } catch (o) {
      let n = { ...e, Reason: a.includeStackTraces ? o.stack : o.message };
      n.PhysicalResourceId ||
        (e.RequestType === 'Create'
          ? (a.log(
              'CREATE failed, responding with a marker physical resource id so that the subsequent DELETE will be ignored'
            ),
            (n.PhysicalResourceId = p))
          : a.log(
              `ERROR: Malformed event. "PhysicalResourceId" is required: ${JSON.stringify(
                e
              )}`
            )),
        await i('FAILED', n);
    }
  };
}
function b(t, e = {}) {
  let s = e.PhysicalResourceId ?? t.PhysicalResourceId ?? t.RequestId;
  if (t.RequestType === 'Delete' && s !== t.PhysicalResourceId)
    throw new Error(
      `DELETE: cannot change the physical resource ID from "${t.PhysicalResourceId}" to "${e.PhysicalResourceId}" during deletion`
    );
  return { ...t, ...e, PhysicalResourceId: s };
}
async function i(t, e) {
  let s = {
    Status: t,
    Reason: e.Reason ?? t,
    StackId: e.StackId,
    RequestId: e.RequestId,
    PhysicalResourceId: e.PhysicalResourceId || D,
    LogicalResourceId: e.LogicalResourceId,
    NoEcho: e.NoEcho,
    Data: e.Data,
  };
  a.log('submit response to cloudformation', s);
  let r = JSON.stringify(s),
    o = R.parse(e.ResponseURL),
    n = {
      hostname: o.hostname,
      path: o.path,
      method: 'PUT',
      headers: {
        'content-type': '',
        'content-length': Buffer.byteLength(r, 'utf8'),
      },
    };
  await x({ attempts: 5, sleep: 1e3 }, a.sendHttpRequest)(n, r);
}
async function T(t, e) {
  return new Promise((s, r) => {
    try {
      let o = m.request(t, (n) => s());
      o.on('error', r), o.write(e), o.end();
    } catch (o) {
      r(o);
    }
  });
}
function B(t, ...e) {
  console.log(t, ...e);
}
function x(t, e) {
  return async (...s) => {
    let r = t.attempts,
      o = t.sleep;
    for (;;)
      try {
        return await e(...s);
      } catch (n) {
        if (r-- <= 0) throw n;
        await H(Math.floor(Math.random() * o)), (o *= 2);
      }
  };
}
async function H(t) {
  return new Promise((e) => setTimeout(e, t));
}
var E = 'aws-cdk:auto-delete-objects',
  u = new h.S3({}),
  O = y(g);
async function g(t) {
  switch (t.RequestType) {
    case 'Create':
      return;
    case 'Update':
      return F(t);
    case 'Delete':
      return S(t.ResourceProperties?.BucketName);
  }
}
async function F(t) {
  let e = t,
    s = e.OldResourceProperties?.BucketName,
    r = e.ResourceProperties?.BucketName;
  if (r != null && s != null && r !== s) return S(s);
}
async function I(t) {
  let e = await u.listObjectVersions({ Bucket: t }),
    s = [...(e.Versions ?? []), ...(e.DeleteMarkers ?? [])];
  if (s.length === 0) return;
  let r = s.map((o) => ({ Key: o.Key, VersionId: o.VersionId }));
  await u.deleteObjects({ Bucket: t, Delete: { Objects: r } }),
    e?.IsTruncated && (await I(t));
}
async function S(t) {
  if (!t) throw new Error('No BucketName was provided.');
  if (!(await N(t))) {
    process.stdout.write(`Bucket does not have '${E}' tag, skipping cleaning.
`);
    return;
  }
  try {
    await I(t);
  } catch (e) {
    if (e.code !== 'NoSuchBucket') throw e;
  }
}
async function N(t) {
  return (await u.getBucketTagging({ Bucket: t })).TagSet?.some(
    (s) => s.Key === E && s.Value === 'true'
  );
}

上記によるとaws-cdk:auto-delete-objectsというタグが付与されているバケットのみオブジェクトを削除するようになっています。

ここで試しに autoDeleteObjects を有効にしたバケットを2つ作成します。

lib/cdk-sample-stack.ts

  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    new aws_s3.Bucket(this, 'MyBucket', {
      bucketName: `my-bucket-${this.account}-${this.region}`,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    new aws_s3.Bucket(this, 'MyBucket2', {
      bucketName: `my-bucket-2-${this.account}-${this.region}`,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });
  }

CDK デプロイすると、バケット2つに対してカスタムリソースは1つのみしか作成されていません。同じスタック内で削除用 Lambda 関数を共有するようです。

autoDeleteObjects オプションはいつから実装されていた?

autoDeleteObjects オプションは 2021/10 にv1.126.0およびv2.0.0-rc.24から実装されていたようです。

そんなことをついぞ知らずに 2022 年に次のような記事を書いたりしていました。

おわりに

AWS CDK の S3 Bucket class のautoDeleteObjectsオプションを試してみました。

プロダクション環境などでは使いにくいと思いますが、開発環境や検証時には重宝しそうです。

以上