Box SDK PythonでLambda関数を構築するために、AWS CDKを使用する
データ事業本部の笠原です。
Box Python SDKを使って、Lambda関数からBoxを操作する必要がありましたので、その構築方法をまとめてみました。Lambda関数はAWS CDKでリソース定義しました。
AWS CDKはTypeScript、Lambda関数はPythonランタイムを利用しています。
構成図
簡単な構成図を示します。
Box認証はJWTとし、EventBridgeで時間駆動でLambda関数を動かして、Boxの所定ディレクトリ配下にあるファイルを、S3バケットに配置しようと思います。
また、Box認証情報はSSM Parameter Storeに格納します。
環境
- Node.js 20.13.1
- AWS CDK 2.148.0
- TypeScript 5.5.3
- @aws-cdk/aws-lambda-python-alpha 2.148.0-alpha.0
- Python 3.12.3
- box-sdk-gen[jwt] 1.0.0
以前の Box Python SDK が Deprecation となっていましたので、新しい Box Python SDK を利用しました。
Boxアプリケーションの設定
Boxアプリケーションを設定します。
今回のBoxアプリケーションは、サーバ側から「Boxにあるファイルをダウンロードする」作業を自動化するイメージなので、「カスタムアプリ」で「JWT」の認証で実施していこうと思います。
1. Box開発者コンソールからBoxアプリケーション作成
Box開発者コンソール から「マイアプリ」内の「アプリの新規作成」をクリックします。
アプリタイプは「カスタムアプリ」をクリックし、アプリ名などを適宜入力します。
認証方法は「サーバー認証 (JWT使用)」を選択して、アプリの作成を行います。
2. Boxアプリケーション設定
今回はファイルのダウンロードを行うので、「構成」タブの「アプリケーションスコープ」にて、「Boxに格納されているすべてのファイルとフォルダへの書き込み」にチェックを入れます。
3. Boxアプリケーション申請と承認
一通り設定したら、「承認」タブにて該当Boxアプリケーションを申請します。「確認して送信」ボタンをクリックすると申請できます。
申請後は、Box管理者の承認をしてもらう必要があります。
なお、承認後にBoxアプリケーションの設定を変更した場合は、再度申請・承認が必要になりますので、ご注意ください。
4. サービスアカウントへBoxフォルダを共有
Boxアプリケーションが承認されたら、「一般構成」タブ内に「サービスアカウント」が自動生成されていると思います。
このサービスアカウントを、Boxアプリケーションで操作したいBoxフォルダに対して共有設定をします。
「ユーザを招待」欄にサービスアカウントのメールアドレスを貼り付けて、編集者として招待しましょう。
これで、Boxアプリケーションのサービスアカウントが該当フォルダにアクセスすることが可能になりました。
5. 接続確認
認証に必要な秘密鍵を生成します。
「構成」タブ内にある「公開キーの追加と管理」にて、「公開/秘密キーペアを生成」ボタンをクリックします。
これによって、BoxアプリケーションのJWTリクエストに署名して認証するためのRSAキーペアを生成することができます。
認証情報はjsonファイルになっており、中身に秘密鍵を含んでいます。
このjsonファイルの内容は、この後のAWSの設定にて、SSM Parameter Storeに格納します。
AWS CDKとLambda関数の設定
CDKアプリケーションの初期化からLambda関数の作成、デプロイまでを簡単にまとめました。
元のソースコードは以下のリポジトリにありますので、ご参考ください。
1. CDKアプリケーションの初期化
mkdir box-etl-sample
cd box-etl-sample
cdk init app --language typescript
npm i -D @aws-cdk/aws-lambda-python-alpha ## 追加ライブラリインストール
## AWSアカウント上で初めてcdk使う場合に実行
cdk bootstrap
2. Lambda関数の作成
Lambda関数は以下のようにします。
2.1. Box認証
最初にBox認証情報を取得します。
## Main Hander
def lambda_handler(event, context):
auth: BoxJWTAuth = boxAuth()
box_client: BoxClient = BoxClient(auth=auth)
## <省略>
SSMパラメータストアにBox認証情報のJSON文字列を格納しておき、
その内容を取得して設定します。
## Box Authentication
def boxAuth() -> Authentication:
box_param_key = os.environ.get('BOX_PARAM_KEY')
ssm_client = boto3.client('ssm')
## Get SSM Parameter Store
ssm_param = ssm_client.get_parameter(
Name=box_param_key,
WithDecryption=True,
)
jwt_key_config = ssm_param["Parameter"]["Value"]
## Set Box Auth Config
config = JWTConfig.from_config_json_string(jwt_key_config)
auth = BoxJWTAuth(config)
return auth
2.2. Boxファイルダウンロード
以下のメソッドを利用しています。
box_client.folders.get_folder_items(box_folder_id).entries
- 該当のBoxフォルダID内のアイテム(ファイルやフォルダ)の一覧を取得する
box_client.downloads.download_file(file_id=item.id)
- 該当のBoxファイルIDのファイルをダウンロードする
ダウンロードの際は、 BufferedIOBase
のストリーム型になっているので、
ファイルで保存する場合は、 shutil.copyfileobj()
メソッドでファイル書き込みをしています。
## get files
filelist = []
items = box_client.folders.get_folder_items(box_folder_id).entries
for item in items:
if item.type == 'file':
## ファイルだけダウンロード対象とする
file_name = item.name
file_path = os.path.join(tmp_dir, file_name)
## Write the Box file contents to tmp storage
file_content_stream: BufferedIOBase = box_client.downloads.download_file(file_id=item.id)
with open(file_path, 'wb') as f:
shutil.copyfileobj(file_content_stream, f)
print(f"Downloaded File: '{file_name}'")
filelist.append({
'id': item.id,
'name': item.name,
'download_name': file_name,
'download_path': os.path.abspath(file_path),
})
2.3. Lambda関数全体
Lambda関数全体は以下のように実装してみました。
一旦 /tmp
にファイルをダウンロードしてからS3にアップロードしているので、
あまりファイル数や容量が多いと失敗するかもしれません。
box_to_s3.py
from box_sdk_gen import BoxClient, BoxJWTAuth, JWTConfig, Authentication
import boto3
import os
import shutil
from io import BufferedIOBase
from pathlib import Path
## Box Authentication
def boxAuth() -> Authentication:
box_param_key = os.environ.get('BOX_PARAM_KEY')
ssm_client = boto3.client('ssm')
## Get SSM Parameter Store
ssm_param = ssm_client.get_parameter(
Name=box_param_key,
WithDecryption=True,
)
jwt_key_config = ssm_param["Parameter"]["Value"]
## Set Box Auth Config
config = JWTConfig.from_config_json_string(jwt_key_config)
auth = BoxJWTAuth(config)
return auth
## Get bucket name and key name from s3 url
def split_s3_path(s3_path: str) -> tuple[str, str]:
path_parts = s3_path.replace("s3://", "").split("/")
bucket = path_parts.pop(0)
key = "/".join(path_parts)
return bucket, key
## Main Hander
def lambda_handler(event, context):
auth: BoxJWTAuth = boxAuth()
box_client: BoxClient = BoxClient(auth=auth)
## configure
box_folder_id = str(event.get('input_box_folder_id'))
s3_url = event.get('output_s3_url')
s3_bucket, s3_objpath = split_s3_path(s3_url)
## init tmp dir
tmp_dir = os.path.join("/tmp", "etl_sample")
if os.path.exists(tmp_dir):
shutil.rmtree(tmp_dir)
os.makedirs(tmp_dir)
## get files
filelist = []
items = box_client.folders.get_folder_items(box_folder_id).entries
for item in items:
if item.type == 'file':
## ファイルだけダウンロード対象とする
file_name = item.name
file_path = os.path.join(tmp_dir, file_name)
## Write the Box file contents to tmp storage
file_content_stream: BufferedIOBase = box_client.downloads.download_file(file_id=item.id)
with open(file_path, 'wb') as f:
shutil.copyfileobj(file_content_stream, f)
print(f"Downloaded File: '{file_name}'")
filelist.append({
'id': item.id,
'name': item.name,
'download_name': file_name,
'download_path': os.path.abspath(file_path),
})
## upload to s3
s3 = boto3.resource('s3')
bucket = s3.Bucket(s3_bucket)
for file in filelist:
download_filepath = file.get('download_path')
download_filename = file.get('download_name')
objkey = os.path.join(s3_objpath, download_filename)
bucket.upload_file(download_filepath, objkey)
print(f"Uploaded File: 's3://{s3_bucket}/{objkey}'")
## remove tmp
if os.path.exists(tmp_dir):
shutil.rmtree(tmp_dir)
return {
'statusCode': 200,
}
if __name__ == '__main__':
event = {
'input_box_folder_id': '0',
'output_s3_url': 's3://box-file-bucket-test/box/etl-sample/',
}
lambda_handler(event, None)
3. CDKスタックの定義
CDKスタックは以下のようにしました。
box-etl-sample-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { ServicePrincipal, ManagedPolicy } from 'aws-cdk-lib/aws-iam';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { PythonFunction } from '@aws-cdk/aws-lambda-python-alpha';
import { PythonLayerVersion } from "@aws-cdk/aws-lambda-python-alpha";
import * as targets from 'aws-cdk-lib/aws-events-targets';
import { Rule, Schedule, RuleTargetInput } from 'aws-cdk-lib/aws-events';
export class BoxEtlSampleStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const inputBoxFolderId = '<Box Folder ID>';
const boxParameterKey = '/box/sample/key_config';
// IAM Role
const lambdaRole = new cdk.aws_iam.Role(this, 'BoxToS3LambdaRole', {
roleName: 'BoxToS3LambdaRole',
assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
});
lambdaRole.addManagedPolicy(
ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaBasicExecutionRole"
)
);
lambdaRole.addManagedPolicy(
ManagedPolicy.fromAwsManagedPolicyName(
"AmazonSSMReadOnlyAccess"
)
);
lambdaRole.addManagedPolicy(
ManagedPolicy.fromAwsManagedPolicyName(
"AmazonS3FullAccess"
)
);
// S3 Bucket
const bucket = new Bucket(this, 'FileBucket', {
bucketName: "box-file-bucket-test"
});
// Lambda Layer
const boxSdkLambdaLayer = new PythonLayerVersion(this, 'BoxToS3LambdaLayer', {
layerVersionName: 'boxSdkLayer',
entry: 'src/lambda/layer/box-sdk-layer',
compatibleRuntimes: [cdk.aws_lambda.Runtime.PYTHON_3_12]
});
// Lambda Function
const lambdaFunction = new PythonFunction(this, 'BoxToS3LambdaFunction', {
functionName: 'boxToS3',
runtime: cdk.aws_lambda.Runtime.PYTHON_3_12,
entry: "src/lambda/handler",
index: "box_to_s3.py",
handler: "lambda_handler",
role: lambdaRole,
memorySize: 512,
timeout: cdk.Duration.minutes(15),
layers: [boxSdkLambdaLayer],
environment: {
BOX_PARAM_KEY: boxParameterKey,
BUCKET_NAME: bucket.bucketName,
},
});
// EventBridge Rule
const ebrule = new Rule(this, 'boxFileDownloadExecRule', {
// invoke function AM5:00(JST) everyday.
schedule: Schedule.cron({minute: "0", hour: "20"}),
targets: [
new targets.LambdaFunction(lambdaFunction, {
retryAttempts: 3,
event: RuleTargetInput.fromObject({
input_box_folder_id: inputBoxFolderId, // Box Folder ID
output_s3_url: `s3://${bucket.bucketName}/box/etl-sample/` // S3 URL
})
})
]
});
}
}
4. SSMパラメータストアにBox認証情報を格納
Box認証情報のjsonファイルの内容をAWS Systems Manager(SSM)のパラメータストアに格納します。
パラメータストアには、AWS CLIで登録しました。
認証情報ですので、SecureStringで保存しましょう。
Box認証情報のjsonファイルを box_key_config.json
とし、
このファイルがあるディレクトリ上で以下のようなコマンドを実行します。
aws ssm put-parameter \
--name "/box/sample/key_config" \
--type "SecureString" \
--value file://./box_key_config.json
5. デプロイ
以下のコマンドを実行すれば、AWSにデプロイされます。
cdk deploy
6. 動作確認
朝5時に自動起動しますが、とりあえずLambda関数の動作確認をしたい場合は、
Lambdaのテストイベントにて以下のようなJSONを入力値として与えてあげてテスト実行すれば確認できます。
JSONの値は仮の値なので、実際の値を設定して実行してください。
{
"input_box_folder_id": "0",
"output_s3_url": "s3://outputBucket/box/etl-sample/"
}
実行後、Boxの該当フォルダID配下にあるファイルがS3バケットにファイルが格納されていれば成功です。
まとめ
今回はサーバーサイドで動かすBoxアプリケーションの設定とBox Python SDKを利用したLambda関数およびCDKによる定義とデプロイを簡単にまとめました。
Box Python SDKは以前のSDKと比べると、Box APIに近い書き方になっている感じがしました。
今後はこのSDKを使うことになると思いますので、
Boxアプリケーションを作成する際のご参考になれば、幸いです。