AWS APIGateway+lambda+S3を使ってダウンロード機能を実装する


背景

AWS使ってサーバーレスで自分用の家計簿的なwebサービスを勉強も兼ねて開発中。大分自分が欲しかった機能は実装出来てきた。
今後の為にもDynamoDBをバックアップをしたい。DynamoDB自体にバックアップ機構はあるものの、間違えてテーブル自体を削除してしまった時(構築スクリプトミスとか)の為に、CSVやjsonファイルなどでローカルPCに置いておきたい。

方法を考察

手法は色々あると思うが、大きく分けて以下の2つになる。

  • どこかサーバー側で、aws-cliなどを使ってファイル出力する
  • web画面にダウンロード機能を追加する

やっぱりここはダウンロード機能。このサービス開発は勉強も兼ねてるし。
ここで、単純にwebページからダウンロードと言ってもまたそこで細かい手法が存在する。

  1. データを取得して、クライアント側で文字列作成してダウンロード処理
  2. サーバー側で生成したファイルストリームをダイレクトレスポンスで返す
  3. S3にアップして、そのアドレスを返し、リダイレクト
  4. S3にアップ。フォルダ一覧ページも用意してそこからリンククリックダウンロード

考察ポイント

  • 1はクライアントのみで処理が完結するもの向け
  • 2,3はサイズが小さいファイル向け、4は比較的大きいファイル向け(大きいファイルは作成にも時間かかるので、ダウンロード失敗などで再実行したくない)。
  • ファイル作成する方法だと、その後処理を考えないと不要なファイルが溜まっていく。
  • 処理はLambdaでやる予定。 AWS Lambda の制限 は気を付ける。

決定した方針

  • 将来的には扱うサイズが大きくなるかもしれないが、3番目のS3にアップしてそのアドレスを返してリダイレクトを選択する。
  • 全データを出力するのでなく、一定期間で指定して出力できるようにして、そもそもデータ量を多くしないようにする。
  • 出力するS3フォルダはログインユーザー毎のフォルダ
  • S3バケットのライフサイクルポリシーを設定して一定期間過ぎたファイルは消す様にする。
  • 関係するテーブル全て(伝票情報と残額情報)が対象
  • パラメーターは開始終了日

実装

以下の技術要素が必要になる。

  • lambdaでファイル作成
  • 複数ファイルをzip圧縮
  • S3のフォルダを決定する為の認証情報をlambdaに渡す
  • lambdaからS3へファイルアップロード
  • 返却されたS3のurlを使ってダウンロード実行

lambdaでファイル作成

今、lambdaで使用している言語はPython。Pythonにはファイル出力機能はもちろんあるが、lambdaでは物理マシンの概念が無く、用意されているのは/tmpフォルダのみ。このフォルダはOS的には一時ディレクトリと呼ばれる場所。一定時間が切れたり再起動されたら消える。反対に言えば放置してもOS的に処理してくれる。
tempfileモジュールで取得したフォルダを使うべきなのか、公式ページ上でlambdaで使用できるフォルダとして指定されてる/tmp表記を直接使うべきか悩むところではあったけど、tempfileモジュールを使う事に決定。
後々圧縮するので、tempfile.TemporaryDirectoryで一時ディレクトリ作って、その中で色々する事にする。

複数ファイルをzip圧縮

ここはそんなに悩む場所ではない。zipfileモジュール使って普通に処理。

S3のフォルダを決定する為の認証情報をlambdaに渡す

最初は認証のjwtトークンをlambdaに渡して色々する必要があると思ったが、S3のフォルダ名に使用するidentityIdはそこからは取得できなさそうだった。その為、クライアント側で取得できるidentityIdを普通にクエリパラメーターでそのまま渡す事にした。

lambdaからS3へファイルアップロード

ここもそんなに悩む場所ではない。boto3モジュールで普通に出来る。

ここまでのlambda側処理は以下の感じ。try句が2重になっているが、圧縮前ファイルをcloseした状態でないとちゃんとファイルに出力されていない為。flushすればいいかもしれないが、ファイルポインタ解放の為にもcloseすべき。そして、S3アップの前に一時ディレクトリをcloseしてしまうと消えてしまう可能性があ。という事で、結果的に2重のtry句になった。

lambda側ファイル関係処理部分(python)
# 宣言部分
import boto3
import os
import tempfile
import zipfile
# 中略
    tmpdir = tempfile.TemporaryDirectory()
    try:
        packed_full_name = os.path.join(tmpdir.name, packed_file_name)
        slip_full_name = os.path.join(tmpdir.name, slip_file_name)
        balance_full_name = os.path.join(tmpdir.name, balance_file_name)
        # 中略
        slip_file = open(slip_full_name, 'w')
        balance_file = open(balance_full_name, 'w')
        try:
            # 出力処理部分
        finally:
            slip_file.close()
            balance_file.close()

        # 出力ファイルをまとめてzip圧縮
        with zipfile.ZipFile(packed_full_name, 'w', compression=zipfile.ZIP_DEFLATED) as new_zip:
            new_zip.write(slip_full_name, arcname=slip_file_name)
            new_zip.write(balance_full_name, arcname=balance_file_name)

        # S3へアップロード。event['identityid'] がクエリパラメータで渡されたCognitoのidentityid
        s3 = boto3.resource('s3')
        bucket = s3.Bucket('myapp-userdata')
        bucket.upload_file(packed_full_name, 'cognito/myhome-account/' + event['identityid'] + '/' + packed_file_name)
    finally:
        tmpdir.cleanup()

返却されたS3のurlを使ってダウンロード実行

出力先のS3バケットが、publicならそのurlを直接使えるが、privateの場合、AccessDeniedが出てしまう。s3.getSignedUrl で一時ダウンロードURLを生成し、それを使わなければならない。生成出来たらあとはlocation.hrefに指定するだけ。

クライアント側javascriptダウンロード部分
    download: function () {
      const cognitoUser = this.$cognito.userPool.getCurrentUser()
      var that = this
      cognitoUser.getSession((err, session) => {
        if (!err && session.isValid()) {
          const itoken = session.getIdToken().getJwtToken()
          // Initialize the Amazon Cognito credentials provider
          AWS.config.region = awsconfig.Region
          AWS.config.credentials = new AWS.CognitoIdentityCredentials({
            IdentityPoolId: awsconfig.IdentityPoolId,
            Logins: {
              [PROVIDER_KEY]: itoken
            }
          })
          var identityId = AWS.config.credentials.identityId

          //・・中略・・

          that.$axios.get(that.apienv.baseendpoint + 'download?' + prmstr, config).then(
            response => {
              var s3 = new AWS.S3({
                params: { Bucket: S3_USERBACKETNAME }
              })
              var getUrlparams = {
                // バケット名
                Bucket: S3_USERBACKETNAME,
                // S3に格納済みのファイル名(ファイル名がサーバー側から返ってくる)
                Key: 'cognito/myhome-account/' + identityId + '/' + response.data,
                // 期限(秒数)
                Expires: 900
              }
              // URL発行
              s3.getSignedUrl('getObject', getUrlparams, that.execdownload)
            }
          ).catch(err => {
            that.$message({message: err, type: 'error'})
          })
        }
      })
    },
    execdownload: function (dummy, url) {
      location.href = url
    },

学んだ事

  • ファイル出力を確定する前に、そのファイルに対して別の処理してはいけない。
  • jwtから取得できる情報にはidentityId(s3などのユーザー毎キーになる)は含まれてない(?)
  • オープンでないS3バケットに上げたものは、getObjectで直接取得できない。
  • s3.getSignedUrlで一時URLを発行し、location.href すべし。

実際のソース(lambda側)

参考にさせてもらったページ

AWS Lambda の制限
Lambda + API Gateway入門。CSVやCORS
API Gateway + Lambdaでバイナリダウンロード
DynamoDBからデータをCSVにエクスポートする方法4つ
【備忘録・まとめ】AWS Lambda 開発者ガイド
【JavaScript入門】ファイルダウンロード処理を実装する方法とは?
Amazon Cognitoの認証情報を取得してみる~API Gateway+Lambda編~
【vue.js】AWS-S3へ簡単にファイルをアップロードする方法