概要
案件で、AWS SAM(Serverless Application Model、以後SAMと表記)を使用して、Boxにファイルを定期的にアップロードするLambdaを作成する機会がありました。
BoxAPIが使用される部分で苦戦したので、備忘のために残しておきます。
※SAMの基本的な仕組みについての説明は割愛します。
前提/やりたいこと
前提
前提として検証を行った環境は下記です。
- AWS CLI version 2.13.7 (AWS CLIをインストール済みであること)
- 認証情報を設定済み
- SAM CLI version 1.95.0(SAM CLIをインストール済みであること)
- pyenv version 2.4.11 (仮想環境 pyenvを使用)
- python version 3.11.9
- クライアント資格情報許可形式のBoxアプリケーションを作成済み(※詳細は後述)
やりたいこと
- RDSに格納されているデータを取得し、それをcsvに書き出し、日次(毎日深夜1時)でboxのファイルにアップロードを行う
簡素ですが、構成図としては以下のような仕組みになります。
Boxの認証形式の違い
Boxでカスタムアプリケーションを作成する際、下記画像のように認証方法を選択する部分があります。記載のようにサーバー認証(JWT使用)・ユーザー認証(OAuth2.0)・サーバー認証(クライアント資格情報許可)の3つがあります。
それぞれの違いについて、表にまとめてみました。
認証方式 | 概要 | 利用ケース | 認証の粒度 | ユーザー関与 (ユーザーが認証プロセスに関与する必要性) |
---|---|---|---|---|
サーバー認証 (JWT使用) | 外部コラボレーターやアプリの統合に最適。JSON Webトークンを使用し、公開キーと秘密キーで認証 | サーバー間通信や外部統合アプリの認証 | サーバー単位 (APIクライアント) | 不要 |
ユーザー認証 (OAuth 2.0) | ユーザーがBoxにログインして認証を行う形式。 | ユーザーによるログインが必要なアプリ(例: モバイルアプリ、Webアプリ) | ユーザー単位 | 必要 |
サーバー認証 (クライアント資格情報許可) | アプリがクライアントIDとクライアントシークレットで認証を行う形式。 | バックオフィスやサーバー間の統合、スクリプト処理 | クライアント単位 | 不要 |
今回の実装では、Lambdaの定期実行・・・つまりスクリプト処理になるので、サーバー認証(クライアント資格情報許可)を使用する、という形になります。
サーバー認証(クライアント資格情報許可)アプリケーションを作成する際は、クライアントID・クライアントシークレット・EnterpriseID
この3つが必要になります。
※Enterpriseアプリケーションでは、boxの管理者に作成したカスタムアプリを承認していただく必要がありますので注意が必要です。
フォルダ構成
一部抜粋して記載
. └── プロジェクトのルートフォルダ ├── layers │ └── common_layer │ ├── common_utils.py │ ├── samconfig.toml ├── src │ └── upload_csv │ ├── __init__.py │ ├── app.py │ └── requirements.txt ├── template.yaml
実装に使用したコード
template.yaml
下記のような形でリソース定義をしています。BoxUploadFunction
でLambda関数を、 ScheduledRuleForBoxUpload
でEventBridgeルールを定義しています。その他の設定周りの解説は省略します。また、認証情報について、大部分は設定値を伏せています。
Parameters: Env: Type: String AllowedValues: - dev Default: dev App: Type: String Default: XXXXX Mappings: EnvMap: dev: TargetEnv: "dev" VPCID: "XXXXXX" VPCSubnet1:"XXXXXX" VPCSubnet2: "XXXXXX" VPCSecurityGroup: "XXXXXX" DBHostname: "XXXXXX" DBDatabase: "XXXXXX" DBUsername: "XXXXXX" DBPassword:"XXXXXX" BOXCLIENTID:"XXXXXX" # BOXアプリの認証情報 BOXCLIENTSECRET: "XXXXXX" BOXGRANTTYPE : "client_credentials" BOXSUBJECTTYPE: "enterprise" BOXSUBJECTID : "XXXXXX" # BoxアプリのEnterpriseIDを指定 Resources: CommonLayer: Type: AWS::Serverless::LayerVersion Properties: LayerName: common_layer Description: レイヤー ContentUri: "layers/common_layer/" CompatibleRuntimes: - python3.11 Metadata: BuildMethod: python3.11 # Boxにアップロードを行う関数 BoxUploadFunction: Type: AWS::Serverless::Function Properties: CodeUri: src/upload_csv Handler: app.lambda_handler Timeout: 30 Runtime: python3.11 Architectures: - x86_64 Layers: - !Ref CommonLayer Environment: Variables: BOXCLIENTID: !FindInMap [ EnvMap, !Ref Env, BOXCLIENTID ] BOXCLIENTSECRET: !FindInMap [ EnvMap, !Ref Env, BOXCLIENTSECRET ] BOXGRANTTYPE: !FindInMap [ EnvMap, !Ref Env, BOXGRANTTYPE ] BOXSUBJECTTYPE: !FindInMap [ EnvMap, !Ref Env, BOXSUBJECTTYPE ] BOXSUBJECTID: !FindInMap [ EnvMap, !Ref Env, BOXSUBJECTID] Role: !GetAtt SamLambdaExecutionRole.Arn PermissionForEventsToBoxUploadFunction: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref BoxUploadFunction Action: "lambda:InvokeFunction" Principal: "events.amazonaws.com" SourceArn: !GetAtt ScheduledRuleForBoxUpload.Arn # Lambdaを定期的に実行するためのEventBridgeのルールを定義 ScheduledRuleForBoxUpload: Type: AWS::Events::Rule Properties: Description: "Schedule for Lambda Function to upload csv to box" ScheduleExpression: "cron(0 16 * * ? *)" # JSTの毎日深夜1時実行 State: "ENABLED" Targets: - Arn: !GetAtt BoxUploadFunction.Arn Id: "BoxUploadFunction" SamLambdaExecutionRole: Type: AWS::IAM::Role Properties: RoleName: !Sub ${App}-${Env}-backend-lambda-role AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: "sts:AssumeRole" Policies: - PolicyName: !Sub ${App}-${Env}-backend-lambda-policy PolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Action: - "logs:CreateLogGroup" - "logs:CreateLogStream" - "logs:PutLogEvents" - "logs:DescribeLogStreams" Resource: "arn:aws:logs:*:*" Effect: "Allow" Action: - "secretsmanager:GetSecretValue" - "secretsmanager:PutResourcePolicy" - "secretsmanager:PutSecretValue" - "secretsmanager:DeleteSecret" - "secretsmanager:DescribeSecret" - "secretsmanager:TagResource" Resource: "arn:aws:secretsmanager:*:*:secret:rds-db-credentials/*" - Effect: "Allow" Action: - "secretsmanager:GetSecretValue" - "secretsmanager:CreateSecret" - "secretsmanager:ListSecrets" - "secretsmanager:GetRandomPassword" - "tag:GetResources" - "rds-data:BatchExecuteStatement" - "rds-data:BeginTransaction" - "rds-data:CommitTransaction" - "rds-data:ExecuteStatement" - "rds-data:RollbackTransaction" - "lambda:InvokeAsync" - "lambda:InvokeFunction" - "ec2:CreateNetworkInterface" - "ec2:DescribeNetworkInterfaces" - "ec2:DeleteNetworkInterface" Resource: "*"
その他ファイル
samconfig.toml
[dev.deploy.parameters] stack_name = "XXXXXX(スタック名)" s3_bucket = "XXXXXX(バケット名)" s3_prefix = "XXXXXX" region = "XXXXXX(リージョン名)" capabilities = "CAPABILITY_IAM" parameter_overrides = "Env=dev"
common_utils.py
import logging import os logger_ = logging.getLogger() logLevelTable_ = { 'NOTSET': logging.NOTSET, 'DEBUG': logging.DEBUG, 'INFO': logging.INFO, 'WARNING': logging.WARNING, 'ERROR': logging.ERROR, 'CRITICAL': logging.CRITICAL, } logLevel_ = logging.INFO if 'LOGGING_LEVEL' in os.environ and os.environ['LOGGING_LEVEL'] in logLevelTable_: logLevel_ = logLevelTable_[os.environ['LOGGING_LEVEL']] logger_.setLevel(logLevel_) def getLogger(): return logger_
requirements.txt
以下の外部ライブラリを使用するため記載しています。
pymysql boxsdk pandas boto3
app.py
boxのアップロードに、boxSDK for pythonを使用しています。
ファイルのアップロードにあたってはupload_streamメソッドを使用しています。(こちら参照)
client.folder(folder_id).upload_stream(file_stream, file_name)
という形でファイルのアップロードを行うことができます。
処理のおおまかな流れとしては
データベースからデータを取得→認可処理を行いアクセストークンを取得しboxクライアントを作成→取得データをboxにアップロードする
という形になります。
import os import json from io import StringIO import common_utils import logging import datetime from boxsdk import OAuth2, Client import boto3 import pymysql import pandas as pd import requests # ログ出力のためのlogger取得 logger = common_utils.getLogger() # DB接続情報 db_host = os.environ['DB_HOSTNAME'] # DBホスト名 db_user = os.environ['DB_USERNAME'] # DBユーザー名 db_pass = os.environ['DB_PASSWORD'] # DBパスワード db_data = os.environ['DB_DATABASE'] # DBデータベース # DBへ接続 db_connection = pymysql.connect( host=db_host, user=db_user, password=db_pass, database=db_data, cursorclass=pymysql.cursors.DictCursor # 結果を辞書形式で取得 ) def lambda_handler(event, context): """BoxUpload データベースからデータを取得し、csvに変換してboxにアップロードする Parameters ---------- None. Returns ------ StatusCode : ステータスコード body: 処理完了を示す文言 """ #1.データベースからデータを取得してCSVファイルに変換 csv_data = get_data_and_create_csv() logger.info(csv_data) #2.Boxクライアントを初期化 client = initialize_box_client() logger.info(client) #3.データをBOXにアップロード upload_csv_to_box(client, csv_data) return { 'statusCode': 200, 'body': json.dumps('Process completed successfully') } def get_data_and_create_csv(): """テーブルからレコードを取得して、CSVに保存する関数 Parameters ---------- None. Returns ------ csv_data : CSV出力用のデータ """ try: logger.info('DB Connection started.') with db_connection.cursor() as cursors: # SQL文 sql = """ SELECT column_1(カラム1), column_2(カラム2), ..... FROM sample_table(テーブル名) """ # 実行 cursors.execute(sql) # SQLクエリの結果を取得 query_result = cursors.fetchall() logger.info(query_result) # データベース接続を閉じる db_connection.close() logger.info("DB Connection closed.") # カラム名の取得(CSV出力の際のヘッダに利用) columns = [] if cursors.description: columns = [desc[0] for desc in cursors.description] # レコードが空の場合も、ヘッダを含むデータフレームを作成 # 取得したレコードをpandasのデータフレームに変換 if not query_result: if not columns: raise ValueError("No columns found to generate CSV header.") # カラム名を使って空のデータフレームを作成 df = pd.DataFrame(columns=columns) else: # クエリの結果があれば通常通りデータフレームを作成 df = pd.DataFrame(query_result, columns=columns) logger.info(df) # CSVを文字列として保存 csv_buffer = StringIO() df.to_csv(csv_buffer, index=False, header=True, encoding='utf-8') return csv_buffer.getvalue() # アップロード先のBoxのフォルダID (簡略化のために直書きしています。実際は定数ファイルや # 環境変数に登録などしてください) folder_id = "XXXXXX" # アップロードAPIのURL(簡略化のために直書きしています。実際は定数ファイルや # 環境変数に登録などしてください) url = "https://upload.box.com/api/2.0/files/content" # box接続のための認可処理 # アクセストークンを取得 def get_access_token(): url = const.BOX_OAUTH2_TOKEN_URL data = { 'client_id': os.environ['BOXCLIENTID'], 'client_secret': os.environ['BOXCLIENTSECRET'], 'grant_type': os.environ['BOXGRANTTYPE'], 'box_subject_type': os.environ['BOXSUBJECTTYPE'], 'box_subject_id': os.environ['BOXSUBJECTID'], } response = requests.post(url, data=data, verify=True) logger.info(response) if response.status_code == 200: token_data = response.json() logger.info(token_data) return token_data['access_token'] else: raise Exception( f"Failed to get access token: {response.status_code} - {response.text}") # Boxクライアントを初期化する関数 def initialize_box_client(): access_token = get_access_token() oauth2 = OAuth2(client_id=os.environ['BOXCLIENTID'], client_secret=os.environ['BOXCLIENTSECRET'], access_token=access_token) client = Client(oauth2) return client # Boxにファイルをアップロードする関数 def upload_csv_to_box(client, csv_data): """Boxにファイルをアップロードする関数 Parameters ---------- client: Boxクライアント csv_data アップロード対象のcsvデータ Returns ------ None. """ try: # 現在の時間でファイル名を生成 current_time = datetime.now().strftime('%Y%m%d_%H%M%S') file_name = f'{"(任意のファイル名)"}_{current_time}.csv' # メモリ上のCSVデータをBoxにアップロード file_stream = StringIO(csv_data) # ファイルをBoxフォルダにアップロード uploaded_file = client.folder( folder_id).upload_stream(file_stream, file_name) logger.info( f"File '{file_name}' uploaded successfully to Box (ID: {uploaded_file.id}).") except Exception as e: logger.error(f"Error during file upload: {e}")
ビルド&デプロイ
デプロイするユーザーのprofile(認証情報)を引数に指定してあげます。--config-env dev
は、Mappingsセクションで定義した以下の部分の情報を取得することを指しています
Mappings: EnvMap: dev: TargetEnv: "dev" VPCID: "XXXXXX" VPCSubnet1:"XXXXXX" VPCSubnet2: "XXXXXX" VPCSecurityGroup: "XXXXXX" DBHostname: "XXXXXX" DBDatabase: "XXXXXX" DBUsername: "XXXXXX" DBPassword:"XXXXXX" BOXCLIENTID:"XXXXXX" # BOXアプリの認証情報 BOXCLIENTSECRET: "XXXXXX" BOXGRANTTYPE : "client_credentials" BOXSUBJECTTYPE: "enterprise" BOXSUBJECTID : "XXXXXX" # BoxアプリのEnterpriseIDを指定
あとは、下記コマンドでビルド、デプロイを実施します。
sam build --config-env dev --profile XXXX
sam deploy --config-env dev --profile XXXX
詰まった点(今回一番時間をかけてしまった部分)
アップロード先のフォルダIDは正しいのに”Not Found”エラーが出る
上記設定を踏まえて、
実際にデプロイしてLambdaを実行してみたところ、下記のようなエラーが出ていました。
[ERROR] 2024-09-18T07:38:10.640Z 4899f23a-634e-43f4-8a62-954f21f95a0e An unexpected error occurred: Message: Not Found Status: 404 Code: not_found Request ID: 2g0wj0htrwrhmfol Headers: {'date': 'Wed, 18 Sep 2024 07:38:10 GMT', 'content-type': 'application/json', 'x-envoy-upstream-service-time': '181', 'box-request-id': 'XXXXXX', 'cache-control': 'no-cache, no-store', 'strict-transport-security': 'max-age=31536000', 'via': '1.1 google', 'Alt-Svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', 'Transfer-Encoding': 'chunked'} URL: https://api.box.com/2.0/folders/XXXXXX(フォルダID)/items Method: GET Context Info: {'errors': [{'reason': 'invalid_parameter', 'name': 'folder', 'message': "Invalid value 'd_XXXXXX(フォルダID)'. 'folder' with value 'd_XXXXXX(フォルダID)' not found"}]}
エラー内容からして、アクセストークンを取得する関数は実行することができているようでしたが、
その先のファイルアップロードの関数でエラーが出ているようでした。
ローカルでcurlを叩いてみても、結果は同様でした。
curl -X POST https://api.box.com/2.0/folders \ -H "Authorization: Bearer XXXXXX(アクセストークン)" \ -H "Content-Type: application/json" \ -d '{"name": "XXXXXX(アプリ名)", "parent": {"id": "XXXXX(フォルダID)X"}}' {"type":"error","status":404,"code":"not_found","context_info":{"errors":[{"reason":"invalid_parameter","name":"parent","message":"Invalid value 'd_XXXXXX(フォルダID)'. 'parent' with value 'd_XXXXXX(フォルダID)' not found"}]},"help_url":"http:\/\/developers.box.com\/docs\/#errors","message":"Not Found","request_id":"XXXXXX"}
正しいフォルダIDを指定しているはずなのにエラーが出ている状況でした。
どう解決したか
原因が全くわからなかったため、とりあえず開発者用のアクセストークンを用いて、アップロードAPIを叩いてみたところ、正常にアップロードAPIが実行されました。
※開発者トークンとは、開発およびテスト中に開発者が利用できるアクセストークンです。(あくまでも開発/テスト環境で使用するためのものなので、利用範囲についてはご注意ください。)
(参照)
そのため、原因はこのアクセストークンの権限の範囲が問題なのか?とも考えました。
しかしながら、結果は全然違いました。
アプリに関する設定をよくよく見返してみたところ、サービスアカウント情報
の部分の説明文に、下記の説明がありました。
このアプリが承認されると、サービスアカウントが自動的に生成されます。このアカウントは、あなたのアプリのユーザーとして使用されます。デフォルトでは、アプリはこのユーザーとしてAPIコールを行います。
この記述から、「サービスアカウントのユーザーをフォルダに追加する必要がある?」と予想しました。
つまり、アップロードAPIを叩くユーザーがフォルダにコラボレーターとして追加されていなければ、当然そのユーザーから見ればフォルダの存在を認識することはできません。
そのため404 Not Found
エラーが出ていた、というわけでした。
予想通り、アップロード対象のフォルダに、アプリ作成時のサービスアカウントをアップロード対象のフォルダに追加してあげることで、指定のフォルダにcsvファイルをアップロードすることができました。
CloudWatchのログを見ると、定期実行も問題なく行われているようでした。
【CloudWatchログ】
まとめ
BoxAPIを使用するにあたって、今回行った実装内容に関連する日本語の記事があまり少なく、若干実装に苦戦しました。
結果としては、公式ドキュメントに書いてあることや注意書きなどをしっかり見ることで解決につながったので、原点に立ち返って、初歩的な部分でミスをしていないかを確認することが改めて大事だなと感じました。また、Boxアプリの認証認可の仕組みや、Boxアプリの構造を十分理解して開発を行うことが重要だと感じたので、引き続き学習していければと思います。
参照資料
【BoxDev】
【Box Support】