AWS lambdaでprivate subnetにあるRDS(MySQL)のdumpをする

## やりたいこと
VPCを跨いでRDSのデータをコピーしたいというモチベーションがあり、RDSをprivate subnetに配置しながらdb dump/restoreを行うためにlambdaから実行していきました。
dump結果はS3に配置しています。

lambdaの開発は不慣れなところがあるのでもっとこうしたほうがいいなどのコメントがありましたらいただけるととてもありがたいです。

※ lambdaにはタイムアウトが15分までという制約があるので、大規模なデータがある場合は今回の手段は適当ではありません。

## 対応の全体像
* DB Dump用のlambdaを用意。VPCのprivate subnetにアタッチしてdump結果をS3にアップロードする
    * lambdaからmysqldumpを動かせるようにlambda layerを用意
    * Nodejsのランタイムからdb dumpを実行
    * 結果をS3にアップロードする
* DB Restore用のlambdaを用意。VPCのprivate subnetにアタッチしてS3からダウンロードしたdump結果をRDSに反映する
    * lambdaからmysqlを動かせるようにlambda layerを用意
    * S3からdump結果をダウンロードする
    * Nodejsのランタイムからmysqlコマンドにてdump結果をRDSに反映
* 上記をgithub actionでピタゴラスイッチする

今回はサッと作りたいであったり、後々個人情報マスク化のために文字列操作を行なったりする可能性があったためNodejsを利用しましたが
カスタムランタイムにてシェルを実行できるということでこちらの方が簡単かもしれないです
https://dev.classmethod.jp/articles/tutorial-lambda-custom-runtime-with-shellscript/
なおサッと作る背景からエラーハンドリングはそんなにしていないのとピタゴラスイッチはStepFunctionではなく手慣れているgithub action側でのハンドリングにしています。


## mysqldump用のlambda
### M2 Macにてlambdaで実行可能なmysqldumpのバイナリファイルを取得

lambdaではx86_64アーキテクチャ用のバイナリが必要なのでdocker内でmysqldumpコマンドをダウンロードする

```sh
$ docker run --platform linux/amd64 -v $(pwd):/output -it amazonlinux:2 bash
$ yum install -y mysql
$ cp /usr/bin/mysqldump /output/
$ exit
```

実行したディレクトリにx86_64アーキテクチャのmysqldumpバイナリがダウンロードできます。

```sh
$ file mysqldump
mysqldump: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=70a59c13fe6970a653ee28b11a4dbea04f779ea0, stripped
```

### mysqldump実行可能なlambda layerを作成
lambda layerにアップロードするようのzipファイルを作成します。
lambda layerでは `/opt` 配下にzipファイルが展開され、 `/opt` 以下では `/opt/bin` にパスが通っているので　`bin` フォルダを作成してその中に `mysqldump` を移動してzip化します
```sh
$ mkdir bin
$ mv mysqldump bin/mysqldump
$ zip -r mysqldump-layer.zip bin/
```

terraformでlambda layerを作成するサンプルです
```tf
resource "aws_lambda_layer_version" "mysqldump_layer" {
  filename   = "mysqldump-layer.zip"
  layer_name = "mysqldump-layer"

  compatible_runtimes = ["nodejs18.x"]
}
```

### mysqldumpを実行するlambdaの用意
今度は実際にDB dumpを行うlambdaを用意していきます
(細かい権限周りは書くと膨大になってメインのところが煩雑になるので省略してます)

lambda_db_dumpディレクトリ内にnodeのコードを作成していきます
```lambda_db_dump/package.json
{
  "dependencies": {
    "@aws-sdk/client-s3": "^3.433.0"
  }
}
```
npm installを適当に実行 (エラーハンドリングは適当です)
```lambda_db_dump/index.js
const { execSync } = require("child_process");
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");

const s3Client = new S3Client({ region: "ap-northeast-1" });

exports.handler = async (event) => {
  const bucketName = process.env.S3_BUCKET;
  const dbName = process.env.DB_NAME;
  const dbHost = process.env.DB_HOST;
  const dbUser = process.env.DB_USER;
  const dbPassword = process.env.DB_PASS;

  const dumpCommand = `mysqldump -h ${dbHost} -u ${dbUser} --password=${dbPassword} ${dbName}`;

  try {
    console.log('start db dump.');

    const output = execSync(dumpCommand);

    console.log('success db dump.');

    try {
      const uploadParams = {
        Bucket: bucketName,
        Key: event.filePath,
        Body: output.toString()
      };

      const result = await s3Client.send(new PutObjectCommand(uploadParams));

      console.log('uploaded dump sql file.');

      return {
        statusCode: 200,
        body: JSON.stringify(result)
      };
    } catch (s3Error) {
      console.error(s3Error);
      return {
        statusCode: 500,
        body: "Error uploading dump to S3"
      };
    }
  } catch (error) {
    console.error(`Error executing mysqldump: ${error}`);
  }
};
```

lambda用のコードをzip化
```sh
$ zip -r lambda_db_dump.zip lambda_db_dump
```

terraformにてAWSに反映
セキュリティ周りは適宜読み替えてください(IAM Roleは後述)
```tf
resource "aws_lambda_function" "dump_db" {
  function_name = "db_dump"
  role          = aws_iam_role.dump_and_restore_db.arn
  handler       = "index.handler"
  runtime       = "nodejs18.x"
  filename      = "lambda_db_dump.zip"

  /* dump自体に時間がかかるため、実際に実行してみて調整 */
  timeout = 30

  layers = [
    aws_lambda_layer_version.mysqldump_layer.arn
  ]

  vpc_config {
    subnet_ids = ["{{VPCのprivate subnetのidを指定}}"]
    security_group_ids = ["{{RDSへの接続とS3に接続ができるようにSecurity Groupを設定}}"]
  }

  environment {
    variables = {
      DB_HOST   = "{{your db host}}"
      DB_USER   = "{{your db username}}"
      DB_PASS   = "{{your db password}}"
      DB_NAME   = "{{your db name}}"
      S3_BUCKET = "{{SQL dump結果保存用のS3}}"
    }
  }

  tracing_config {
    mode = "Active"
  }
}
```

IAM Roleを作成 (よくはないですが、簡略化のためdump/restore両方とも同じroleを利用しました)
Policyも分けた方がいい感じはしつつ
```tf
resource "aws_iam_role" "dump_and_restore_db" {
  name = "IamRoleForDumpAndRestoreDbOnLambda"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": ["lambda.amazonaws.com"]
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}

resource "aws_iam_policy" "dump_and_restore_db" {
  name        = "IamPolicyForDumpAndRestoreDbOnLambda"
  path        = "/"
  description = "something."

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject"
        ]
        Resource = "{{SQL dump結果保存用のS3}}/*"
      },
      {
        Effect = "Allow"
        Action = [
          "kms:GenerateDataKey",
          "kms:Decrypt"
        ]
        Resource = "{{S3を暗号化している場合必要}}"
      },
      # VPCアタッチする際に必要になる
      # https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html#vpc-permissions
      {
        Action = [
          "ec2:CreateNetworkInterface",
          "ec2:DescribeNetworkInterfaces",
          "ec2:DeleteNetworkInterface"
        ]
        Effect   = "Allow"
        Resource = "*" # ここをもうちょっと絞りたかったがうまくいかなかったので一旦*としています
      },
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        # 適宜function名などは変更してください
        Resource = [
          "arn:aws:logs:{{YOUR REGION}}:{{YOUR AWS ACCOUNT ID}}:log-group:/aws/lambda/db_dump:*",
          "arn:aws:logs:{{YOUR REGION}}:{{YOUR AWS ACCOUNT ID}}:log-group:/aws/lambda/db_restore:*",
        ]
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "dump_and_restore_db" {
  role       = aws_iam_role.dump_and_restore_db.name
  policy_arn = aws_iam_policy.dump_and_restore_db.arn
}
```

## mysql restore用のlambda
ほとんどmysqldumpと同じなので適宜割愛

### mysql実行可能なlambda layerを作成
```sh
$ mkdir bin
$ mv mysql bin/mysql
$ zip -r mysql-layer.zip bin/
```

### mysqldump実行可能なlambda layerを作成
```sh
$ mkdir bin
$ mv mysql bin/mysql
$ zip -r mysql-layer.zip bin/
```

```tf
resource "aws_lambda_layer_version" "mysql_layer" {
  filename   = "mysql-layer.zip"
  layer_name = "mysql-layer"

  compatible_runtimes = ["nodejs18.x"]
}
```

### mysql restoreを実行するlambdaの用意
```lambda_db_restore/package.json
{
  "dependencies": {
    "@aws-sdk/client-s3": "^3.433.0"
  }
}
```

mysqldumpのタイミングでなるべくエラーが出ないような順番でSQLを構築してくれているようですが、外部キーなどによってうまくリストアできないケースがあったのでいくつか工夫を入れています
* foreign_key_checksを一時的にオフにすることで外部キーチェックを見ないようにする
* PROCEDUREにて全てのテーブルを先に削除するDropAllTables関数を用意して実行
* 全体をtransactionで囲むことでrestoreの途中でエラーになった時に中途半端にデータが飛ばないようにする
    * ただし全体をtransactionで囲むためロックの関係でrestore実行中にエラーが起こる可能性が高くなります。検証環境などへのデータコピーを前提とした条件にはなっているので注意を
```lambda_db_restore/index.js
const { execSync } = require("child_process");
const { S3Client, GetObjectCommand } = require("@aws-sdk/client-s3");

const s3Client = new S3Client({ region: "ap-northeast-1" });

// Helper function to convert a stream to string
function streamToString(stream) {
  return new Promise((resolve, reject) => {
      const chunks = [];
      stream.on('data', (chunk) => chunks.push(chunk));
      stream.on('error', reject);
      stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
  });
}

exports.handler = async (event) => {
    const bucketName = process.env.S3_BUCKET;
    const dbName = process.env.DB_NAME;
    const dbHost = process.env.DB_HOST;
    const dbUser = process.env.DB_USER;
    const dbPassword = process.env.DB_PASS;

    const downloadPath = `/tmp/dump.sql`;

    const downloadParams = {
      Bucket: bucketName,
      Key: event.filePath
    };
    const result = await s3Client.send(new GetObjectCommand(downloadParams))
    if (result.Body) {
      // 外部キー制約によってrestoreがうまくいかないケースがあるので一時的に外部キーチェックを無視する
      const dumppedSql = await streamToString(result.Body);
      const restoredSql = `
      START TRANSACTION;
      SET foreign_key_checks = 0;
      -- restore時のエラーを最小限にするために先に全てのテーブルを削除
      DELIMITER //
      CREATE PROCEDURE DropAllTables()
      BEGIN
          DECLARE _tablename VARCHAR(255);
          DECLARE _done INT DEFAULT 0;
          DECLARE _cur CURSOR FOR
              SELECT table_name
              FROM information_schema.TABLES
              WHERE table_schema = SCHEMA();
          DECLARE CONTINUE HANDLER FOR NOT FOUND SET _done = 1;
          SET foreign_key_checks = 0;
          OPEN _cur;
          REPEAT
              FETCH _cur INTO _tablename;
              IF NOT _done THEN
                  SET @s = CONCAT('DROP TABLE IF EXISTS \`', _tablename, '\`;');
                  PREPARE stmt FROM @s;
                  EXECUTE stmt;
                  DEALLOCATE PREPARE stmt;
              END IF;
          UNTIL _done END REPEAT;
          CLOSE _cur;
          SET foreign_key_checks = 1;
      END //
      DELIMITER ;
      CALL DropAllTables();
      DROP PROCEDURE IF EXISTS DropAllTables;
      ${dumppedSql}
      SET foreign_key_checks = 1;
      COMMIT;
      `;

      require('fs').writeFileSync(downloadPath, restoredSql);

      try {
        const restoreCommand = `mysql -h ${dbHost} -u ${dbUser} --password=${dbPassword} ${dbName} < ${downloadPath}`;

        try {
          console.log('start db restore.');

          execSync(restoreCommand);

          console.log('success db restore.');
        } catch (error) {
          console.error(`Error executing mysql restore: ${error}`);
        }
      } catch (dbError) {
          console.error(dbError);
          return {
              statusCode: 500,
              body: "Error connecting to database"
          };
      }
    } else {
      console.error("No data found in the object");
    }
};
```

lambda用のコードをzip化
```sh
$ zip -r lambda_db_restore.zip lambda_db_restore
```

terraformでAWSに反映
```tf
resource "aws_lambda_function" "restore_db" {
  function_name = "db_restore"
  role          = aws_iam_role.dump_and_restore_db.arn
  handler       = "index.handler"
  runtime       = "nodejs18.x"
  filename      = "lambda_db_restore.zip"

  /* restore自体に時間がかかるため、実際に実行してみて調整 */
  timeout = 30

  layers = [
    aws_lambda_layer_version.mysqldump_layer.arn
  ]

  vpc_config {
    subnet_ids = ["{{VPCのprivate subnetのidを指定}}"]
    security_group_ids = ["{{RDSへの接続とS3に接続ができるようにSecurity Groupを設定}}"]
  }

  environment {
    variables = {
      DB_HOST   = "{{your db host}}"
      DB_USER   = "{{your db username}}"
      DB_PASS   = "{{your db password}}"
      DB_NAME   = "{{your db name}}"
      S3_BUCKET = "{{SQL dump結果保存用のS3}}"
    }
  }

  tracing_config {
    mode = "Active"
  }
}
```

## github actionでピタゴラスイッチ
GitHub　ActionsからOIDC連携でAssume Roleしてlambdaを実行します
細かいことは「GitHub　Actions OIDC」などで検索すればそれっぽい記事が見つかると思いますので割愛します
https://dev.classmethod.jp/articles/github-actions-oidc-202307/

雰囲気github actionの設定です
参考程度に
```.github/workflows/db-dump-restore.yml
jobs:
  db_dump_and_restore:
    name: DB Dump and Restore
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set variable
        shell: bash
        run: |
          TIMESTAMP=$(date +'%Y%m%d%H%M%S')
          FILENAME="${FROM_ENV}/${TIMESTAMP}.sql"
          echo "FILE_PATH=$FILENAME" >> $GITHUB_ENV
      - uses: aws-actions/configure-aws-credentials@v2
      with:
        role-to-assume: {{GitHub Actions用のIAM Role}}
        aws-region: ap-northeast-1
      - name: Invoke DB Dump Lambda
      shell: bash
      run: |
        aws lambda invoke \
          --function-name db_dump \
          --invocation-type RequestResponse \
          --payload $(echo '{ "filePath": "${{ env.FILE_PATH }}" }' | base64) \
          dump_output.txt
        cat dump_output.txt
      - name: Invoke DB Restore Lambda
      shell: bash
      run: |
        aws lambda invoke \
          --function-name db_restore \
          --invocation-type RequestResponse \
          --payload $(echo '{ "filePath": "${{ env.FILE_PATH }}" }' | base64) \
          restore_output.txt
        cat restore_output.txt
```