AWS CDK で Lambda 経由で EFS から S3 にデータを転送する方法

 Introduction

先月 Lambda から EFS へのアクセスが可能になったことは皆さんご存知でしょうか?これまでは別途 EC2 を立てて Cron をごにゃごにゃ動いて EFS にアクセスしたり、S3 経由のデータの読み書きを主に採用していたと思うんですが、今回のリリースのおかげで Lambda によるサーバーレスの選択肢も選べるようになりました。ちょうど 2週間前に CDK もサポートリリースが出て来たし、マッチするタスクをもらったのでチャレンジした経験を共有します。

プレスリリース

https://aws.amazon.com/jp/about-aws/whats-new/2020/06/aws-lambda-support-for-amazon-elastic-file-system-now-generally-

必須条件

  • 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 を取得

lib/vpc-stack.ts

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 を生成

lib/efs-stack.ts

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 を生成し定期的な実行を設定

lib/lambda-stack.ts

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 できる権限を追加

lib/lambda-stack.ts

...
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 を設定

lib/lambda-stack.ts

...
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 の実装

lambda/copy-efs-to-s3.ts

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 一覧

cdk.json

{
  "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 に該当する情報を記載する必要があります。

bin/cdk.ts

#!/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

lib/lambda-stack.ts

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) でした。

Reference