はじめに
AWSでリソースをコードとして管理する方法には、CloudFormation、SAM、CDK、Terraformなどいくつかの選択肢があります。それぞれのサービスが持つ特徴と、どのような場面で使うのがベストなのかを実践を通じて学んでいきたいと思います!今回は第3回目ということで、AWS CDKを使ってみました。
第1回、第2回とCloudFormation、SAMで実践してきました。これらがYAMLやJSON形式でAWSリソースを宣言的に定義するのに対して、CDKは使い慣れたプログラミング言語で命令的に定義することができます。これまでと異なるアプローチになるかと思いますので、その違いを見ていきたいと思っています。
- 前回の記事:実践して学ぶAWSのIaC【SAM編】
- 前々回の記事:実践して学ぶAWSのIaC【CloudFormation編】
構築するシステム
ユーザーがアップロードしたファイルを、拡張子に基づいて振り分け、適切なS3バケットに保存する処理を行うものです。
処理フロー:
- API Gatewayを通じてファイルをアップロード
- Lambda関数がファイルを受け取り、拡張子を基にファイル種別を判定
- 適切なS3バケットにファイルを保存
- 画像ファイル(jpg, png, gif)→ images-bucket
- ドキュメント(pdf, docx, txt)→ documents-bucket
- ログファイル(log, csv)→ logs-bucket
AWS CDKとは
AWS CDKは、プログラミング言語を使用してクラウドインフラストラクチャを定義できるフレームワークです。TypeScript、JavaScript、Python、Java、C#、Goなどの言語でAWSリソースを定義することができます。CDKで記述したコードは、最終的にCloudFormationテンプレートに変換されてデプロイされます。
CDKの特徴
- プログラマティック:条件分岐、ループ、継承などのプログラミングの要素を活用
- IDE支援:シンタックスハイライトやコード補完による開発効率向上
このような機能が使えるのは助かりますね!
CDKプロジェクトの構造
file-sorter-cdk/
├── bin/
│ └── file-sorter-cdk.ts
├── lib/
│ └── file-sorter-stack.ts
├── test/
│ └── file-sorter-stack.test.ts # ユニットテスト(省略)
├── package.json
├── tsconfig.json
├── cdk.json
└── README.md
簡単に各ファイルについて触れておきます。AWSリソースを定義するのは太字のfile-sorter-stack.tsです。このファイルについて以降で紹介します。
- file-sorter-cdk.ts:CDKアプリの起動ファイル。環境設定とスタック初期化
- file-sorter-stack.ts:AWSリソース定義。S3、Lambda、API Gateway等を定義
- package.json:NPM依存関係とビルド・デプロイスクリプト
- tsconfig.json:TypeScriptコンパイル設定
- cdk.json:CDK CLI設定ファイル
CDKのコード
今回作成したコードはこちらです!
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import { Construct } from 'constructs';
interface FileSorterStackProps extends cdk.StackProps {
environment: string;
}
export class FileSorterStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: FileSorterStackProps) {
super(scope, id, props);
const { environment } = props;
const accountId = cdk.Stack.of(this).account;
// 環境による条件設定
const isProdOrStaging = environment === 'prod' || environment === 'staging';
// ===== S3 Buckets =====
// 画像ファイル用のバケット
const imagesBucket = new s3.Bucket(this, 'ImagesBucket', {
bucketName: `file-sorter-images-${environment}-${accountId}`,
});
// ドキュメントファイル用のバケット
const documentsBucket = new s3.Bucket(this, 'DocumentsBucket', {
bucketName: `file-sorter-documents-${environment}-${accountId}`,
});
// ログファイル用のバケット
const logsBucket = new s3.Bucket(this, 'LogsBucket', {
bucketName: `file-sorter-logs-${environment}-${accountId}`,
});
// ===== Lambda Function =====
const fileSorterFunction = new lambda.Function(this, 'FileSorterFunction', {
functionName: `file-sorter-${environment}`,
runtime: lambda.Runtime.PYTHON_3_9,
handler: 'index.lambda_handler',
timeout: cdk.Duration.seconds(isProdOrStaging ? 15 : 10),
memorySize: isProdOrStaging ? 256 : 128,
environment: {
IMAGES_BUCKET: imagesBucket.bucketName,
DOCUMENTS_BUCKET: documentsBucket.bucketName,
LOGS_BUCKET: logsBucket.bucketName,
},
code: lambda.Code.fromInline(`
import json
import boto3
import base64
import os
def lambda_handler(event, context):
try:
# ファイルデータの取得
if event.get('isBase64Encoded', False):
file_content = base64.b64decode(event['body'])
else:
file_content = event['body'].encode()
# ファイル名の取得
file_name = event['headers'].get('x-file-name', 'unknown.txt')
# 拡張子で振り分け
extension = file_name.lower().split('.')[-1]
if extension in ['jpg', 'png', 'gif']:
bucket = os.environ['IMAGES_BUCKET']
elif extension in ['pdf', 'docx', 'txt']:
bucket = os.environ['DOCUMENTS_BUCKET']
else:
bucket = os.environ['LOGS_BUCKET']
# S3に保存
s3 = boto3.client('s3')
s3.put_object(Bucket=bucket, Key=file_name, Body=file_content)
return {
'statusCode': 200,
'body': json.dumps(f'File saved to {bucket}')
}
except Exception as e:
return {
'statusCode': 500,
'body': json.dumps(f'Error: {str(e)}')
}
`),
});
// S3バケットへの書き込み権限をLambda関数に付与
imagesBucket.grantWrite(fileSorterFunction);
documentsBucket.grantWrite(fileSorterFunction);
logsBucket.grantWrite(fileSorterFunction);
// ===== API Gateway =====
const api = new apigateway.RestApi(this, 'FileSorterAPI', {
restApiName: `file-sorter-api-${environment}`,
binaryMediaTypes: [
'application/octet-stream',
'image/*',
'application/pdf'
],
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: ['POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'x-file-name'],
},
});
// API Gateway統合の設定
const lambdaIntegration = new apigateway.LambdaIntegration(fileSorterFunction);
// /upload エンドポイントの作成
const uploadResource = api.root.addResource('upload');
uploadResource.addMethod('POST', lambdaIntegration);
// ===== Outputs =====
new cdk.CfnOutput(this, 'ApiGatewayURL', {
description: 'URL of the API Gateway',
value: `https://${api.restApiId}.execute-api.${this.region}.amazonaws.com/${api.deploymentStage.stageName}`,
});
}
}
CDKの特徴とメリット
実際にシステムを構築してみて感じた特徴やメリットについて紹介します!
1.記述量の削減
FileSorterLambdaRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: S3AccessPolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- s3:PutObject
Resource:
- !Sub 'arn:aws:s3:::${ImagesBucket}/*'
- !Sub 'arn:aws:s3:::${DocumentsBucket}/*'
- !Sub 'arn:aws:s3:::${LogsBucket}/*'
imagesBucket.grantWrite(fileSorterFunction);
documentsBucket.grantWrite(fileSorterFunction);
logsBucket.grantWrite(fileSorterFunction);
こちらはLambda実行用のIAMロール作成とS3権限付与に関しての記述です。見て頂いてわかる通り、CloudFormationよりもCDKの方が記述する量が少なくて済みます。CDKではgrantWrite()で必要最小限の権限が自動的に設定され、セキュリティのベストプラクティスが適用されます。このあたりはSAMと同じで便利ですね!
2.自動依存関係管理
ApiDeployment:
Type: AWS::ApiGateway::Deployment
DependsOn: [UploadMethod, UploadOptionsMethod]
Properties:
RestApiId: !Ref FileSorterAPI
StageName: !Ref Environment
const uploadResource = api.root.addResource('upload');
uploadResource.addMethod('POST', lambdaIntegration);
CDKは依存関係を自動で解決するため、CloudFormationのDependsOnのように明示的に記述する必要がありません。依存関係を気にしないで済むのはSAMと同じですね。
3.条件分岐の柔軟性
Conditions:
IsProdOrStaging: !Or [!Equals [!Ref Environment, prod], !Equals [!Ref Environment, staging]]
FileSorterFunction:
Properties:
Timeout: !If [IsProdOrStaging, 15, 10]
MemorySize: !If [IsProdOrStaging, 256, 128]
const isProdOrStaging = environment === 'prod' || environment === 'staging';
timeout: cdk.Duration.seconds(isProdOrStaging ? 15 : 10),
memorySize: isProdOrStaging ? 256 : 128,
SAMでもCloudFormationと同じような記述をしています。YAMLで書くよりも、Typescriptの記述の方が自然で読みやすい記述になっていると思います。
4.繰り返し処理への対応
今回のシステムでは恩恵があまり得られないので、3つのS3バケットを個別に定義していますが、同じリソースを大量にデプロイする必要がある場合はfor文でループ処理を書くことで、効率的に記述することができると思います。
デプロイ手順と実行時間
cdk deploy --context environment=dev
npm run build、npm run synthコマンド実行後、上記のコマンドの実行時間を計測した結果、1分16.8秒でデプロイが完了しました。
CloudFormationの1分7.4秒、SAMの1分13.8秒と比較して約9秒長くかかりました。これはCDKからCloudFormationへの変換処理や、リソース間の依存関係の処理に時間を要しているからだと思います。
エンドエンドの試験
簡単に試験手順にも触れておきます。
API_URL="https://<api-id>.execute-api.ap-northeast-1.amazonaws.com/prod"
curl -X POST $API_URL/upload \
-H "x-file-name: test.jpg" \
-H "Content-Type: application/octet-stream" \
--data-binary @test.jpg
aws s3 ls s3://file-sorter-images-dev-<account-id>/
このようにして、test.jpgが画像ファイル用のバケットに保存されることを確認しています。
まとめ
今回はCDKでファイル振り分けシステムを構築してみました!プログラミング言語でインフラを定義することで、自然な条件分岐や自動的な依存関係の管理といったCDK特有の恩恵を実感できました。YAMLやJSONで複雑なシステムを記述するのはちょっと辛いと思うので、CDKの方が向いているかと思います。
次回は同じシステムをTerraformで構築して、AWSネイティブツール以外の選択肢も比較してみたいと思います!
