AWS CDK で Lambda 経由で EFS から S3 にデータを転送する方法
Introduction
先月 Lambda から EFS へのアクセスが可能になったことは皆さんご存知でしょうか?これまでは別途 EC2 を立てて Cron をごにゃごにゃ動いて EFS にアクセスしたり、S3 経由のデータの読み書きを主に採用していたと思うんですが、今回のリリースのおかげで Lambda によるサーバーレスの選択肢も選べるようになりました。ちょうど 2週間前に CDK もサポートリリースが出て来たし、マッチするタスクをもらったのでチャレンジした経験を共有します。
プレスリリース
必須条件
- AWS CDK
- v1.50.0 or later
関連プルリクエスト
https://github.com/aws/aws-cdk/pull/8602
Goal
毎日夜の2時に EFS から S3 へ hello-world.json
をアップロード
登場人物
- VPC
- EFS
- Security Group
- Access Point
- Lambda
- Role
- Event
- S3
- Grant
- CloudWatch
- Logs
- Alarm
- SNS
- Topic
前提条件
プロジェクトが IaC として Terraform を採用していたので、CDK から Terraform で作られたリソース(VPC / EFS / S3 / SNS Topic)を参照する必要があります。
Getting Started
- バージョン
- Node.js: v12.12.0
- TypeScript: v3.7.5
- AWS CDK: v1.50.0
既存 VPC を取得
import * as cdk from '@aws-cdk/core'; import { IVpc, Vpc } from '@aws-cdk/aws-ec2'; export class VpcStack extends cdk.Stack { public readonly vpc: IVpc constructor(scope: cdk.Construct, id: string, props: cdk.StackProps) { super(scope, id, props); this.vpc = Vpc.fromLookup(this, 'vpc', { isDefault: false, vpcName: 'xxx-vpc', vpcId: 'vpc-xxxxxxxxxxxxxxxxx' }) } }
- 既存 VPC を取得するためには VPC の名前と ID が必要です。こちらの要素は環境ごとに異なるはずなので、context 管理ファイル(cdk.json)に登録して置いて参照するのがオススメです。
既存 EFS を取得し Access Point を生成
import * as cdk from '@aws-cdk/core'; import { ISecurityGroup, SecurityGroup } from '@aws-cdk/aws-ec2'; import { AccessPoint, FileSystem } from '@aws-cdk/aws-efs'; export interface FileSystemAttributes { securityGroup: ISecurityGroup, fileSystemId: string } export class EfsStack extends cdk.Stack { public readonly efsSecurityGroup: ISecurityGroup public readonly efsAccessPoint: AccessPoint constructor(scope: cdk.Construct, id: string, props: cdk.StackProps) { super(scope, id, props); this.efsSecurityGroup = SecurityGroup.fromSecurityGroupId(this, 'efs-sg', 'sg-xxxxxxxxxxxxxxxxx') const efsAttributes: FileSystemAttributes = { securityGroup: efsSecurityGroup, fileSystemId: 'fs-xxxxxxxx' } const efs = FileSystem.fromFileSystemAttributes(this, 'efs', efsAttributes) this.efsAccessPoint = new AccessPoint(this, 'efs-access-point', { fileSystem: efs, path: '/', posixUser: { uid: '1000', gid: '1000' }, createAcl: { ownerUid: '1000', ownerGid: '1000', permissions: '755' } }) }
- 既存 EFS を取得するためにはセキュリティグループ ID とファイルシステムの ID が必要です。そして、Lambda との結合に利用する Access Point を生成する必要があります。既に生成した Access Point も使えるのか調べてみたんですが、Lambda と Access Point を結合してくれるメソッドが Interface 型を受け入れなかったため不可能でした。
- Access Point の生成には既存 EFS を取得した
IFileSystem
とアクセスユーザーを示すposixUser
、既に directory が存在しない場合 directory 生成に必要な情報であるcreateAcl
が必要です。path
の場合、省略可能でデフォルトが/
なんですが、特定な directory 下からのアクセスのみを許可したい時に調整ができます。
Lambda を生成し定期的な実行を設定
import * as cdk from '@aws-cdk/core'; import { IVpc, ISecurityGroup } from '@aws-cdk/aws-ec2'; import { AccessPoint } from '@aws-cdk/aws-efs'; import { AssetCode, FileSystem, Function, Runtime } from '@aws-cdk/aws-lambda'; import { ManagedPolicy, Role, ServicePrincipal } from '@aws-cdk/aws-iam'; export interface LambdaStackProps extends cdk.StackProps { vpc: IVpc, efsSecurityGroup: ISecurityGroup, efsAccessPoint: AccessPoint } export class LambdaStack extends cdk.Stack { public readonly copyEfsToS3Lambda: Function constructor(scope: cdk.Construct, id: string, props: LambdaStackProps) { super(scope, id, props); this.copyEfsToS3LambdaRole = new Role(this, 'copy-efs-to-s3-lambda-role', { roleName: 'copyEfsToS3LambdaRole', assumedBy: new ServicePrincipal('lambda.amazonaws.com'), managedPolicies: [ ManagedPolicy.fromAwsManagedPolicyName('CloudWatchLogsFullAccess'), ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'), ManagedPolicy.fromAwsManagedPolicyName('AmazonElasticFileSystemClientReadWriteAccess') ] }) this.copyEfsToS3Lambda = new Function(this, 'copy-efs-to-s3-lambda', { runtime: Runtime.NODEJS_12_X, code: new AssetCode('lambda'), handler: 'copy-efs-to-s3.handler', functionName: 'copy-efs-to-s3', vpc: props.vpc, securityGroup: props.efsSecurityGroup, filesystem: FileSystem.fromEfsAccessPoint(props.efsAccessPoint, '/mnt/access'), role: copyEfsToS3LambdaRole, timeout: cdk.Duration.seconds(5), retryAttempts: 1 }) } }
- Lambda には CloudWatch Logs 権限に加えて VPC Lambda 実行権限と EFS への読み書き権限が必要です。そして、VPC Lambda として動くので、先ほど取得した
IVpc
、セキュリティグループは EFS と同様なものを設定したらオッケです。 - ついに、Lambda と EFS を結合する箇所なんですが、Access Point と Lambda 内部でのマウント経路の指定が要ります。マウント経路は
/mnt
で始まらないと regex で引っ掛かりデプロイできないので気をつけてください。 - タイムアウトは適当に 5秒、リトライは念のため 1回を設定して置きました。
Lambda から特定な S3 Bucket に Read・Write できる権限を追加
... import { Bucket } from '@aws-cdk/aws-s3'; export class LambdaStack extends cdk.Stack { public readonly copyEfsToS3Lambda: Function constructor(scope: cdk.Construct, id: string, props: LambdaStackProps) { super(scope, id, props); ... const cmInseoKimBucket = Bucket.fromBucketName(this, 'cm-inseo-kim', 'cm-inseo-kim'); cmInseoKimBucket.grantReadWrite(this.copyEfsToS3Lambda); } }
- Lambda から特定な S3 Bucket への読み書きを許可します。Bucket 単位まで権限の制御ができるのは素晴らしいですね。
Lambda に Cron を設定
... import { Rule, Schedule } from '@aws-cdk/aws-events'; import { LambdaFunction } from '@aws-cdk/aws-events-targets'; export class LambdaStack extends cdk.Stack { public readonly copyEfsToS3Lambda: Function constructor(scope: cdk.Construct, id: string, props: LambdaStackProps) { super(scope, id, props); ... new Rule(this, 'copy-efs-to-s3-cron', { schedule: Schedule.cron({ hour: '17', minutes: '00' }), // UTC targets: [ new LambdaFunction(this.copyEfsToS3Lambda) ] }) } }
- 前提条件が毎日夜 2時起動だったので、UTC を考慮して設定します。ちなみに、CloudWatch は timezone として UTC のみサポートしているっぽいので、中々ミスしやすい箇所だと思いますがちゃんと意識して設定しましょう。
Lambda の実装
import * as fs from 'fs'; import { S3 } from 'aws-sdk'; const s3 = new S3(); export const handler = async (event: any = {}): Promise<any> => { const BUCKET_NAME = 'cm-kim-inseo'; const MOUNT_PATH = '/mnt/access'; const FILE_NAME = 'hello-world.json'; const FILE_CONTENT = fs.readFileSync(MOUNT_PATH + '/' + FILE_NAME); const params = { Bucket: BUCKET_NAME, Key: FILE_NAME, Body: FILE_CONTENT } return await s3.upload(params).promise() .then((res) => console.log(res.Location)) .catch((e) => Promise.reject(e)) };
- Lambda 内部でマウントされた経路を指定し該当するファイルを AWS SDK の S3 モジュールを使ってアップロードする簡単な処理です。
Stack 一覧
{ "app": "npx ts-node bin/cdk.ts", "context": { "project": "hello", "dev": { "account": "xxxxxxxxxxxx", "region": "ap-northeast-1" }, "stg": { "account": "yyyyyyyyyyyy", "region": "ap-northeast-1" } } }
cdk.ts
から app.node.tryGetContext()
でアカウント情報を取得するためには cdk.json
に該当する情報を記載する必要があります。
#!/usr/bin/env node import 'source-map-support/register'; import * as cdk from '@aws-cdk/core'; import { VpcStack } from '../lib/vpc-stack'; import { EfsStack } from '../lib/efs-stack'; import { LambdaStack } from '../lib/lambda-stack'; const app = new cdk.App(); const env = app.node.tryGetContext('dev'); const vpcStack = new VpcStack(app, 'vpc', { stackName: 'vpc', env }) const efsStack = new EfsStack(app, 'efs', { stackName: 'efs', env }) const lambdaStack = new LambdaStack(app, 'lambda', { stackName: 'lambda', env, vpc: vpcStack.vpc, efsSecurityGroup: efsStack.efsSecurityGroup, efsAccessPoint: efsStack.efsAccessPoint })
- リソースのライフサイクルによって適当に分離した stacks を定義します。
Deploy & Confirm
cdk deploy \* --require-approval never
複数の Stack を一気にデプロイしたい場合は \*
が使えます。その後の --require-approval never
オプションはリソース生成にセキュリティ周りの承認段階をスキップしてくれるので、今回みたいなサンプルコードに使うのは問題ないですが、開発環境や本番環境にリソースを反映する時には気をつけて使いましょう。
2020-07-19T10:53:11.712Z ccbc5190-04cb-4bcb-8184-5d7968164bbf INFO https://cm-kim-inseo.s3.ap-northeast-1.amazonaws.com/hello-world.json
Lambda の GUI からテスト機能を使って挙動確認を行いましょうー CloudWatch Logs でちゃんとアップロードされた経路が出力されたことが分かります。
Bonus
import { Topic } from '@aws-cdk/aws-sns'; import { SnsAction } from '@aws-cdk/aws-cloudwatch-actions' export class LambdaStack extends cdk.Stack { public readonly copyEfsToS3Lambda: Function constructor(scope: cdk.Construct, id: string, props: LambdaStackProps) { super(scope, id, props); ... const testTopic = Topic.fromTopicArn(this, 'topic', 'arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:test-topic'); const copyEfsToS3alarm = new Alarm(this, 'copy-efs-to-s3-alarm', { metric: this.copyEfsToS3Lambda.metricErrors(), period: cdk.Duration.minutes(1), threshold: 1, evaluationPeriods: 1, alarmName: 'copy-efs-to-s3-alarm' }) copyEfsToS3alarm.addAlarmAction(new SnsAction(testTopic)) } }
- Lambda が失敗したら CloudWatch Alarm 経由で特定な Topic に通知して欲しいっていう依頼が追加されたので対応しました。Lambda が失敗したら
copy-efs-to-s3-alarm
が鳴ってtest-topic
に通知する処理になります。
import { EmailSubscription } from '@aws-cdk/aws-sns-subscriptions' const testTopic = Topic.fromTopicArn(this, 'topic', 'arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:test-topic'); testTopic.addSubscription(new EmailSubscription('email@gmail.com');
- もちろん、CDK から Topic の通知先追加も可能ですので、ご自由にカスタムができます。
Summary
クラスメソッドに入ってから真面目に AWS を経験する結構初心者レベルなんですが、今回のタスクのおかげでいくつかの AWS コンポナントの理解や CDK の思想、書き方、Context の概念、Stack の分け方などたくさんのインプットができて嬉しいです。特に CDK から 既存リソースを参考するところであんまり情報がないうちに、メンバーとコミュニケーションしたり、直接 CDK のコードを読んだりしたのはかなり良い経験でした。CDK for Terraform (https://github.com/hashicorp/terraform-cdk) も出て来たので、またチャレンジできるタスクが来るのであれば、CDK の理解をもっと深めたいと思います。
この記事が誰かのお役に立てれば幸いです。
以上、CX事業本部 MADチーム、キム (@sano3071) でした。