AWS CDKを使ってVPC LambdaからEC2のMySQLにアクセスする方法

11 min読了の目安(約10300字TECH技術記事

はじめに

こんにちは、yuyake0084 です。
最近「Lambda(Node.js)から EC2 内の MySQL に接続してごにょごにょする」という機会があり、その際に初めて AWS CDK というツールを利用してみました。
その結果想像以上にいい体験ができたのでその概要の紹介と、表題のことをやるにあたって日本語の参考文献が執筆時点では無かったので、同じようなことをやる方に向けて資料を残したいと思います。

前提

  • 構成管理ツールは AWS CDK を利用
  • 既に作成済みの VPC と EC2 があり、そのインスタンス内部で MySQL サーバーが立っていること

AWS CDK とは?

まずはじめに、今回利用してみた AWS CDK というものについて紹介します。

AWS CDK(Cloud Development Kit)とは Amazon が提供しているインフラ構成管理ツールです。
AWS の構成管理ツールというとTerraformであったりAWS CloudFormationとありますが、AWS CDK の最大の特徴としてはプログラミング言語でインフラ構成管理が可能という点にあります。
※2021 年 2 月時点での対応言語としては TypeScript, Python, Java, .NET

更に驚きなのがこの CDK はボイラープレート的な役割も担ってくれていて、コマンド一つで指定した言語ですぐにプロビジョニングできる状態のテンプレートを生成してくれます。

自分としてはただ「TypeScript で Lambda 書きたいから何かいい方法ないかな〜」くらいの気持ちでググっていただけなのに、まさかこういうツールに出会うとは思っていませんでした。
ということで今回は TypeScript ベースで進行していきます。

なお、ツールの導入等に関してはクラスメソッドさんがとても丁寧にまとめてくださっていたので、初めて CDK を導入される方は以下資料から環境構築をしてみてください。
【コードでインフラ定義】CDK という異次元体験をさくっとやるのに便利な AWS 公式 Workshop の紹介

全体像

まずはじめにフォルダとコードの全体像を見て頂いた上で、hogeという Lambda 関数から EC2 内の MySQL にアクセスする為に何が必要なのかというのを各サービス単位で解説していきます。
※Lambda の処理は実行セクションで紹介します

なお、フォルダ構成については以下資料を参考にしています。
yarn workspaces と CDK で Lambda Layers を管理する

フォルダ構成

├── .build # 実際にリモートのLambdaにデプロイされる予定のパッケージ(tscでjsにトランスパイル済)
│   ├── @lambda
│   │   └── hoge
│   ├── bin
│   │   ├── index.d.ts
│   │   └── index.js
│   ├── lib
│   │   ├── hoge.d.ts
│   │   └── hoge.js
│   └── nodejs
├── .env # DBへ接続するにあたって必要な環境変数を定義
├── .gitignore
├── .npmignore
├── @lambda # Lambdaのスクリプト郡
│   └── hoge
│       ├── index.ts
│       └── package.json
├── README.md
├── bin
│   ├── index.d.ts
│   ├── index.js
│   └── index.ts
├── cdk.context.json
├── cdk.json
├── jest.config.js
├── lib # 構成管理をコードで書く場所(今回はここをメインに見ていきます)
│   └── hoge.ts
├── package.json
├── tsconfig.json
├── types
│   └── global
│       └── index.d.ts
└── yarn.lock

環境変数

.env
CDK_DEFAULT_ACCOUNT= # AWSのアカウント
CDK_DEFAULT_REGION= # 利用しているリージョン
SECURITY_GROUP_ID= # 接続先となるEC2のセキュリティグループID
VPC_ID= # 接続先となるEC2を囲っているVPCのID
DB_HOST= # EC2インスタンスのプライベートIP
DB_PORT= # MySQLのポート
DB_NAME= # DB名
DB_USER= # ユーザー名
DB_PASS= # パスワード

構成管理をしているコード

lib/hoge.ts
import * as lambda from '@aws-cdk/aws-lambda';
import * as iam from '@aws-cdk/aws-iam';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as cdk from '@aws-cdk/core';

import * as dotenv from 'dotenv';
import * as path from 'path';

// .envファイルから環境変数の読み込み
dotenv.config();

export class HogeStack extends cdk.StackProps {
  constructor(scope: cdk.Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);

    // VPC
    const vpc = ec2.Vpc.fromLookup(this, 'VPC', {
      vpcId: process.env.VPC_ID,
    });

    // IAM Role
    const role = new iam.Role(this, 'HogeRole', {
      roleName: 'hoge-role',
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          'service-role/AWSLambdaVPCAccessExecutionRole'
        ),
      ],
    });

    // Lambda Layer
    const nodeModulesLayer = new lambda.LayerVersion(this, 'NodeModulesLayer', {
      code: lambda.AssetCode.fromAsset(path.join(__dirname, '../.build')),
      compatibleRuntimes: [lambda.Runtime.NODEJS_12_X],
    });

    // Lambda Function
    const lambdaFunction = new lambda.Function(this, 'HogeFunction', {
      functionName: 'hoge',
      code: lambda.Code.fromAsset(
        path.join(__dirname, `../.build/@lambda/hoge`)
      ),
      handler: 'index.handler',
      runtime: lambda.Runtime.NODEJS_12_X,
      timeout: cdk.Duration.minutes(1),
      role,
      vpc,
      allowPublicSubnet: true,
      securityGroups: [
        ec2.SecurityGroup.fromSecurityGroupId(
          this,
          'SG',
          process.env.SECURITY_GROUP_ID
        ),
      ],
      environment: {
        DB_HOST: process.env.DB_HOST,
        DB_PORT: process.env.DB_PORT,
        DB_NAME: process.env.DB_NAME,
        DB_USER: process.env.DB_USER,
        DB_PASS: process.env.DB_PASS,
      },
      layers: [nodeModulesLayer],
    });
  }
}

const app = new cdk.App();

new HogeStack(app, 'HogeStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  }
});

各サービスの説明

VPC の設定

ここでは既に稼働している VPC に接続をする為にfromLookupを使用します。

static fromLookup(scope, id, options)

const vpc = ec2.Vpc.fromLookup(this, 'VPC', {
  vpcId: process.env.VPC_ID,
});

IAM Role の設定

Lambda 用の権限設定を行っている処理。
ここではservice-role/AWSLambdaVPCAccessExecutionRoleというものが重要で、Lambda から VPC への接続をするにあたって、Elastic Network Interface というものの作成が必要なので、その作成権限を付与しています。

実行ロールとユーザーアクセス許可

const role = new iam.Role(this, 'HogeRole', {
  roleName: 'hoge-role',
  assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
  managedPolicies: [
    iam.ManagedPolicy.fromAwsManagedPolicyName(
      'service-role/AWSLambdaVPCAccessExecutionRole'
    ),
  ],
});

Lambda Layer の設定

僕は今回の実装で初めて Lambda Layer なる存在を知ったのですが、これは Lambda 内で使用する node_modules をこの Layer という別の場所に zip 形式で格納しておき、Lambda と紐付けをすることで Lambda のパッケージの中に node_modules を含めなくてもよくする、というもののようです。

これを採用するメリットとしては関数を管理する Lambda Function と、node_modules を管理する Lambda Layers が分離されたことで、Lambda Function のインスタンスの生成が高速化し、関数実行までの所要時間が短くなるという点にあるそうです。

ただ、デメリットとしてはデプロイするのに時間がかかるみたいなのですが、クラスメソッドさんが取っていたベンチマークを見る 2.8 秒ほどの差しかないので、個人的にはストレスを抱えるほどの変化があるわけではないのかなと捉えています。

Lambda Layers を使うとデプロイは遅くなり、コールドスタートは高速化する?!Lambda Layers を使って巨大な Lambda Function を分割した場合の挙動の変化

const nodeModulesLayer = new lambda.LayerVersion(this, 'NodeModulesLayer', {
  code: lambda.AssetCode.fromAsset(path.join(__dirname, '../.build')),
  compatibleRuntimes: [lambda.Runtime.NODEJS_12_X],
});

Lambda の設定

さて、本命の Lambda の設定についてです。
ここでは基本的にこれまで定義してきた各サービスの設定との紐付けを行っているだけではあるのですが、ポイントとなる既存の VPC との紐付けについて補足しておきます。

今回は VPC 環境内にある MySQL に接続するわけですが、通常 Lambda からは VPC 内のプライベートリソースに対してアクセスすることはできません。
なので、VPC 事前に role で設定した service-role/AWSLambdaVPCAccessExecutionRoleを用いて指定した vpc に対して接続をする、ということをしています。

const lambdaFunction = new lambda.Function(this, 'HogeFunction', {
  functionName: 'hoge',
  code: lambda.Code.fromAsset(path.join(__dirname, `../.build/@lambda/hoge`)),
  handler: 'index.handler',
  runtime: lambda.Runtime.NODEJS_12_X,
  timeout: cdk.Duration.minutes(1),
  // 事前に定義したIAM Roleの設定との紐付け
  role,
  // 事前に定義したVPCの設定との紐付け
  vpc,
  allowPublicSubnet: true,
  securityGroups: [
    ec2.SecurityGroup.fromSecurityGroupId(
      this,
      'SG',
      process.env.SECURITY_GROUP_ID
    ),
  ],
  // DBとの接続をするにあたって関数に環境変数を適用させる
  environment: {
    DB_HOST: process.env.DB_HOST,
    DB_PORT: process.env.DB_PORT,
    DB_NAME: process.env.DB_NAME,
    DB_USER: process.env.DB_USER,
    DB_PASS: process.env.DB_PASS,
  },
  layers: [nodeModulesLayer],
});

おまけ

VPC_ID を使う都合上だと思いますが、その VPC を管理しているアカウントの ID とリージョンが必要になるみたいなのでこれも忘れずに。

export class HogeStack extends cdk.StackProps {
  ...
}

const app = new cdk.App();

new HogeStack(app, 'HogeStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

Lambda Function

最後に Lambda Function の中身について紹介です。
とはいっても何の変哲のない users テーブルの一覧取得を行ってるだけの処理です。
※ここではusersテーブルを利用していますが、実際に接続先の DB にあるテーブル名に適宜置き換えてみてください。

@lambda/hoge/index.ts
import { APIGatewayProxyEvent, APIGatewayEventRequestContext } from 'aws-lambda';

import * as mysql from 'mysql2/promise';

export const handler = async (event: APIGatewayProxyEvent, context: APIGatewayEventRequestContext) => {
  const connection = await mysql.createConnection({
    host: process.env.DB_HOST,
    port: parseInt(process.env.DB_PORT, 10),
    database: process.env.DB_NAME,
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
  });

  connection.connect();

  const [usersRows] = await connection.query('SELECT * from users');
  const users = JSON.parse(JSON.stringify(usersRows))

  console.log(users);

  return {
    status: 200,
    headers: event.headers,
    body: {
      name: 'hoge'
    }
  }
}

EC2 インスタンスで Lambda から MySQL へのアクセスを許可する

EC2 > セキュリティグループ > {接続先のセキュリティグループ}から設定できるので確認してみましょう!

デプロイ

さあここまで来たらあとはデプロイして動作確認してみるだけ!
トランスパイルやコードのパッケージングをしてデプロイしてみましょう!💪

※例によってコマンドはクラスメソッドさんの記事で紹介されているものを利用しています。

$ yarn @lambda:build
$ yarn layer:build
$ cdk deploy

Lambda Function の実行

無事上記のデプロイが完了していたら Lambda > 関数のページを見てみると hoge という名前の関数があるはずなのでそれをクリックして画面右上のテストボタンを押してみましょう!
Execution results に無事対象のテーブル情報が表示されたら成功です!👏✨

さいごに

最後に振り返ってみると Lambda から MySQL に繋ぐやり方というよりも AWS CDK の書き方という側面の方が強くなってしまった気がしますね 😅 笑

でも個人的にはこの AWS CDK はかなり使い勝手がいいなって思いました!
以前少しだけ Lambda の構成管理ツールとして CloudFormation で YAML を使って構成管理を書いたことがあって、管理する関数が増えていく度に YAML が膨れ上がって辛くなるよねみたいな話をチームメンバーとしたことがあったんですけれど、AWS CDK みたく共通化できる構成とかはテンプレートの class で持たせて、必要に応じて処理を分割したりそれを継承する、という構成管理の世界にオブジェクト指向の考え方を取り入れられるのはとてもおもしろいなと思いました。

あと AWS CDK においては TS のビルド環境を整える作業が発生せずそのままローカルで TS での開発ができるので、Lambda で TS を使いたくてプロビジョニングツールの選定されている方にとっては最適なのかなと!

まとめに、 Lambda を TS で書けるの最高ですね!笑
各所で幾度となく言われてることだと思いますけど、型はソースコードに秩序をもたらすのでみなさん積極的に取り入れていきましょ。
ではではー ✋

参考にさせて頂いた資料