0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

boto3 x S3をほりほり(Lambda)Part 2

Posted at

大容量ファイル(5GB制限・multipart)

S3のput_objectには5GBのアップロード制限があり
Lambdaで生成したファイルが5GBを超えると100%失敗する

さらにメモリの制約(最大10GB)が加わるとバッファに乗らないサイズのファイル処理は破綻...

1. multipart_uploadの構造

S3 multipart uploadは3段階でアップロードする。
1回のupload_partは5MB以上が必要。

  • create_multipart_upload
  • upload_part(複数回)
  • complete_multipart_upload

➤サンプル

・・・・

:pencil2: 結論、サンプルを書こうかと思いましたが、そもそもGBファイルをLambdaで捌こうとすることがいかがなものかというのかです。

AWS CLI、S3コンソールから大容量ファイルをアップロードする際multipartは自動でやってくれるので、意識しないです。またupload_file()の場合も自動で対応してくれます。
ではLambdaでbytes場合には必要となるが、その場合の要件としてはまれなレアケース。
ケース1:Lambdaで「大きめのバイナリ」を生成してS3に配置
ケース2:外部APIからのストリームから読み込みS3にデータを中継したい

考えられるケースもあるが、どちらもわざわざLambdaでやる必要ある?にあたるのでそんな要件が出てきた際にはLambda以外でアーキ構成することを考えるべきです。


S3イベント(Lambdaトリガー)eventの読み方

S3 → Lambdaのイベントは、見た目は同じように見えても複数の形式がある
S3イベントは"すべて同じ形式だと思うと事故る
どんなイベントでも落ちない安全なパース方法を整理していく。

1.S3イベント解析

def parse_s3_event(event):
    records = event.get("Records") or []
    result = []

    for r in records:
        s3_info = r.get("s3", {})
        bucket = s3_info.get("bucket", {}).get("name")
        key = s3_info.get("object", {}).get("key")

        if bucket and key:
            result.append((bucket, key))

    return result
  • r.get("s3", {})
    → S3があればその値を返す
    → S3がなければ{}(空の辞書)を返す

  • s3_info.get("bucket", {}).get("name")
    → bucketがあればそのbucketに対し、nameを取りいき、なければNoneを返す。
    → もしs3_infoにobjectbucketが無くてもLambdaがクラッシュせず使える。

  • Recordsがない場合、object.keyが無い、s3情報がないといった場合対応可能

アンチパターン:1レコード前提

bucket = event["Records"][0]["s3"]["bucket"]["name"] 
  • イベントが1件だけという保証は全くない
  • Lambdaが一気に複数ファイルを処理する場合バグ

2.URLエンコードされているkeyの扱い

S3 → Lambdaのeventは、必ずURLエンコードされたkeyを渡してくる。

➤サンプル

s3.copy_object(
    Bucket=dst,
    Key=key,               # ここがURLエンコードされている
    CopySource=f"{src}/{key}"
)

- S3内のkey
folder name/file 1.txt

- eventのkey
folder%20name/file%201.txt

URLエンコードされたキーのフィルは存在しないためエラーとなる。

回避策としてunquote_plusとする

from urllib.parse import unquote_plus

key = unquote_plus(key)

:bulb: ポイント

  • 日本語ファイル名はさらに複雑になるため解析や他サービスとの結合時に困る。→ 極力使わない
  • prefix マッチングの前に必ず unquote_plus すること

S3の例外処理(ClientErrorの扱い方)

S3の ClientError は「全部同じ例外クラス」に見えるけれど、内部のエラーコードが全然違う。
扱いを間違えると、正常系すらバグになる。

例として以下のようなコードがあったとする。

try:
    return s3.get_object(Bucket=bucket, Key=key)
except:
    return None

このコードでは、すべてが区別できない。

発生したエラー 本来の意味 あなたのコードの扱い
NoSuchKey ファイルが無い(正常ケース) None(OK)
AccessDenied 権限がない(重大事故) None(握りつぶし)
NoSuchBucket バケット名間違い None(握りつぶし)
InvalidObjectState Glacier アーカイブ中 None(握りつぶし)

➤サンプル(どうするべきか)

from botocore.exceptions import ClientError

def safe_get(bucket, key):
    try:
        return s3.get_object(Bucket=bucket, Key=key)
    except ClientError as e:
        code = e.response["Error"]["Code"]
        if code == "NoSuchKey":
            return None       # これは正常
        else:
            raise             # 異常 → Lambda失敗

この場合、NoSuchKeyだけ握りつぶしてます。

  • ファイルがまだアップロードされてない
  • 非稼働で処理対象データがない

「存在しないのは正常パターン」というのは実務でもあり得るのでそうしてます。
逆にそれ以外のケースをつぶしてしまうと何が起きたか不明となり、調査不能

2. ファイルサイズの取得

空ファイルやファイルの破損を事前に検証することで以下のようなケースを防ぐことができる

  • 前工程のバッチが失敗して空ファイルを出力
  • アップロードが不完全終了

サンプル - 空ファイルや破損ファイルを Lambda で先に弾く

def validate_file(bucket, key):
    try:
        resp = s3.head_object(Bucket=bucket, Key=key)
    except ClientError as e:
        if e.response["Error"]["Code"] == "NoSuchKey":
            raise ValueError(f"ファイルが存在しません: {key}")
        raise

    size = resp["ContentLength"]
    if size == 0:
        raise ValueError(f"空のファイルです: {key}")

    if size < 100:  # 任意の閾値
        raise ValueError(f"不完全なファイルです(100バイト未満): {key}")

    return size

Appendix

head_object が “存在確認・サイズ確認” に最適な理由

resp = s3.head_object(Bucket=bucket, Key=key)
size = resp["ContentLength"]
  • Body を返さないため高速(通信量が最小)
  • サイズだけ取得できるため、空ファイル判定が軽量
  • 存在しないキーなら404例外
  • get_object でも判定可能だが、head_object のほうが最適

boto3のコレクション再利用

Lambdaは関数外のコード(グローバルスコープ)をウォームスタート時に再利用します。
Lambdaコンテナが破棄されるまで、再作成されず、キャッシュされ続ける。

高速化パターン

s3 = boto3.client("s3")  # ← 関数外

def lambda_handler(event, context):
    ...

アンチパターン:json.loads()にbytesを直接渡す

def handler(event, context):
    s3 = boto3.client("s3")  # 毎回生成
  • 接続時のオーバーヘッド、不必要な通信増加

実行環境

コールドスタート:新しい環境
ウォームスタート:前回の環境を再利用

ウォーム時には以下が維持される。

  • Pythonモジュールのロード済み状態
  • boto3 Clientの生成済みキャッシュ
  • SNS/SQS/S3各クライアントの再利用
  • 外部ライブラリのインポート済み状態

ウォーム or コールドはAWS側の制御でAWSが勝手に管理している。
直前の実行や実行がスケールされていない場合などはウォームスタートするだろ。。。

S3 IAM最小権限

権限を広く設定し、少し間違えると、全バケット操作がLambdaで可能になり
誤って重要データ削除などリスクを抱えてしまう。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::my-bucket/path/to/prefix/*"
    }
  ]
}

アンチパターン:bucket全体を許可

"Resource": "arn:aws:s3:::my-bucket/*"

:no_entry:仮にこの状態で、prefixをしてし忘れた瞬間
削除対象はバケット内の全オブジェクトに拡大し、血の気が引いていきます

resp = s3.list_objects_v2(Bucket="my-bucket")  # ← Prefixなし(致命的)
for obj in resp.get("Contents", []):
    s3.delete_object(Bucket="my-bucket", Key=obj["Key"])
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?