EBSデフォルト暗号化をカスタムリソースで実施〜Node.js編〜

# EBSデフォルト暗号化をカスタムリソースで実施〜Node.js編〜

以下の業務要件があったため、EBSデフォルト暗号化をカスタムリソース（Node.js）で実装することになりました。
- リソースの作成や設定値の変更は、コンソールやCLIからを行わずIaCで管理する
- ランタイムには、Node.jsを使用する

Pythonを用いた実装は、以下の記事が参考になります。

https://dev.classmethod.jp/articles/lambda-backed-custom-resources-enable-encryption-ebs/

早速、ソースコードの記載から始めていきます。
後半では、詰まったポイントも記載するので、興味がある方は一読してください。

# ソースコード

```yaml:EnableEBSDefaultEncryption.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Template to enable EBS default encryption across all regions.

Resources:
  #  Lambda-backed custom resources
  EnableEBSDefaultEncryption:
    Type: Custom::EnableEBSDefaultEncryptionFunction
    Properties:
      ServiceToken: !GetAtt EnableEBSDefaultEncryptionFunction.Arn

  #  Lambda 関数
  EnableEBSDefaultEncryptionFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: enable-ebs-default-encryption-function
      Description: Lambda for enable EBS default encryption
      Role: !GetAtt EnableEBSDefaultEncryptionRole.Arn
      Runtime: nodejs18.x
      Handler: index.handler
      Timeout: 900
      Code:
        ZipFile: |
          const {
              EC2Client,
              EnableEbsEncryptionByDefaultCommand,
              DescribeRegionsCommand
          } = require("@aws-sdk/client-ec2");
          const response = require("cfn-response");

          exports.handler = async function(event, context) {
              console.log('[START] enable_encryption_ebs');
              const ec2 = new EC2Client({});

              if (event.RequestType ==='Delete') {
                  await response.send(event, context, response.SUCCESS);
                  return
              }

              try {
                  const regionsResponse = await ec2.send(new DescribeRegionsCommand({}));
                  const regions = regionsResponse.Regions.map(region => region.RegionName);

                  await Promise.all(regions.map(async (region) => {
                      const regionalEC2 = new EC2Client({ region });
                      await regionalEC2.send(new EnableEbsEncryptionByDefaultCommand({}));
                      console.log(`${region}: EBS default encryption enabled`);
                  }))

                  await response.send(event, context, response.SUCCESS);
                  console.log(`[END] enable_encryption_ebs`);
              } catch (error) {
                  console.error(error);
                  await response.send(event, context, response.FAILED);
              }
          };
      Tags:
        - Key: Name
          Value: enable-ebs-default-encryption-function

  #  Lambda 関数用の IAM ロール
  EnableEBSDefaultEncryptionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: enable-ebs-default-encryption-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: "lambda.amazonaws.com"
            Action: "sts:AssumeRole"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: EbsEncryptionByDefaultPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "ec2:GetEbsEncryptionByDefault"
                  - "ec2:EnableEbsEncryptionByDefault"
                  - "ec2:DescribeRegions"
                Resource: "*"
```

# 詰まったポイント
### CommonJSとES Modulesの違い
そもそもNode.jsにはCommonJSとES Modulesといったものがあります（Node.js初心者の筆者は全く知りませんでした）。

※筆者と同様にCommonJSとES Modulesについてよくわかってない方は以下の記事がお勧めです！

https://zenn.dev/yodaka/articles/596f441acf1cf3

LambdaでランタイムをNode.jsバージョン18以降を選択した場合、
デフォルトでindex.mjs（ES Modules）となります。

そのことを知らずにCommonJS仕様でコードを記載していると以下のエラーが発生

```
{
  "errorType": "ReferenceError",
  "errorMessage": "require is not defined in ES module scope, you can use import instead",
  "trace": [
    "ReferenceError: require is not defined in ES module scope, you can use import instead",
    "    at file:///var/task/index.mjs:1:22",
    "    at ModuleJob.run (node:internal/modules/esm/module_job:218:25)",
    "    at async ModuleLoader.import (node:internal/modules/esm/loader:329:24)",
    "    at async _tryAwaitImport (file:///var/runtime/index.mjs:1008:16)",
    "    at async _tryRequire (file:///var/runtime/index.mjs:1057:86)",
    "    at async _loadUserApp (file:///var/runtime/index.mjs:1081:16)",
    "    at async UserFunction.js.module.exports.load (file:///var/runtime/index.mjs:1119:21)",
    "    at async start (file:///var/runtime/index.mjs:1282:23)",
    "    at async file:///var/runtime/index.mjs:1288:1"
  ]
}
```

要は、「CommonJSモジュールシステムの機能である"require"をESモジュール（ECMAScriptモジュール）で使用しているぞ！」って怒られているわけです。
そこで筆者は、ES Module仕様でコードを書き直しました。
その結果、Lambdaで問題なくコードが動き、喜びながらCloudFormationに乗せて実行すると何故かエラーに。。。

調べていくとCloudFormaitonで書いたコードはindex.js（CommonJS）として生成されるため、うまく動いてくれなかったみたいです（Node.js初心者には厳しすぎる仕様。。。）

さらに厄介だったのが、Lambdaのコードによるエラーが発生した場合、CloudFormationの処理が終わらない＆スタックを削除できないといった事象が発生しました。
（この時、死ぬほど焦っていました）

### カスタムリソースのエラー時はスタックを削除できるまで2時間かかる
スタック自体の書き方に問題がある場合は、すぐにロールバック→削除処理が実行されますが、カスタムリソース（Lambdaのコード）に問題がある場合、「CREATE_IN_PROGRES」から動きません。

これは、**Lambdaからcfn-responseでカスタムリソースの応答オブジェクトを正常に返せていないことが原因です**

CloudFormationのデフォルトタイムアウト時間である1時間、「CREATE_IN_PROGRES」の状態が続きました。
1時間経ち、ようやく削除されると思いきや
次は、「DELETE_IN_PROGRESS」の状態が1時間続きました。。。
（これも先ほどと同様の理由です）

以下に公式が対処法について記載していますので、同様のエラーにハマった方はご参照ください。

https://repost.aws/ja/knowledge-center/cloudformation-lambda-resource-delete

# まとめ
- 実装工数はかかりますが、カスタムリソースとして実装するため、環境構築に安定感が生まれます
- Node.jsでカスタムリソースを実装する場合は、CommonJSかES Modulesについて意識する必要があります
- スタックの作成中にLambdaの中身でエラーが発生した場合は落ち着いて以下の対応を取ってください
    - 2時間待ってLambdaを削除 -> スタックの削除
    - スタックの処理を待っている間に別のリソース名に書き換えて、別のスタックを作成