見出し画像

【IT】個人開発DjangoをHerokuで本番運用へ(第1回(AWS編①):S3/ライフサイクル/“落穂拾い”Lambda)

皆さま、こんにちは

前回に引き続き、
個人開発したDjangoアプリをHerokuで本番運用に向けて
環境構築していきます。

今回は、HerokuとAWSで二次バックアップまでを構築しています。

この回はAWS側(S3/Lambda/EventBridge)だけを扱います。HerokuやIAMの作業は後続回です。
KMSは使いません(S3暗号化は SSE-S3/AES-256)。S3バージョニングは無効
実行場所は基本コンソール、最後に付録としてCLIを載せてあります。(未検証ですのでご参考までに)。


連載ナビ(5回構成+序章)

※連載回数や内容を変更することがございます。予めご了承ください。




先ず「Heroku Postgres Essential  プラン」での前提

  • Rollback(連続保護のユーザー操作)Standard 以上のみ。Essential(0/1) プラン では 不可

  • PGBackups(論理バックアップ)手動/毎日スケジュールが可能(Essential でも利用可、保管先は US)。
    → 本連載は PGBackups の“毎日”外部 S3(二次バックアップ)を重ねる二段構えで設計します。


何故“二次バックアップ”が必要か?

  • 単一クラウド依存はリスク。同一サービス内だけでバックアップしていると、ハード障害/ストレージ事故/運用ミス/アカウント凍結などで元データとバックアップが同時に失われる可能性があります。

「クラウドよ神話になれ?」→ クラウドは壊れない神話など無い。

  • 壊れるから、だからこそ、別クラウドへ日次退避(オフサイト)する二次バックアップが必要。共倒れリスクを物理・運用の両面で分離できます。

  • 可搬性:S3へ退避したダンプは `pg_restore` 等でローカル/他環境へ即リストア復旧訓練もしやすい。

  • 保持とコストの主導権:保存日数や保管クラス(Standard→IA→Glacier)を自分のルールで決められる。

  • ソースコードは別系統:アプリのコードは GitHubローカルにある一方、DBが“唯一の真実”になりがち。DBこそ毎日バックアップし、他クラウドへ退避する方針です。


この回でやること/やらないこと

  • S3 バケットを正しく作る(汎用/ACL 無効/バージョニング無効/SSE-S3)

  • ライフサイクル「5日で削除」+「未完了 MPU を 7日で中止」1つのルールにまとめる)

  • “落穂拾い”Lambda7 日超の完成済みオブジェクトを確実に削除(削除権限は Lambda のみに付与)

  • アップロード用 IAM(Put のみ)Heroku 側は次回以降で扱います。


実行場所(この回で使うもの)

  • 🌐 AWSマネジメントコンソール(S3/Lambda/IAM/EventBridge)


命名ルール(置換して使ってください)

  • バケット名:`<YOUR_S3_BUCKET>`(世界一意/小文字/ハイフン可)

  • 保存プレフィックス:`<YOUR_PREFIX>`(例:`heroku/<APP_NAME>`)

  • アプリ名:`<APP_NAME>`


Step 1:S3 バケットを作る(🌐 コンソール)

  1. バケットタイプ:汎用(General purpose)

  2. バケット名:`<YOUR_S3_BUCKET>`

  3. リージョン:例 `ap-northeast-1`(東京)

  4. パブリックアクセスをすべてブロック:ON

  5. オブジェクト所有者ACL 無効(推奨)

  6. バージョニング:無効

  7. デフォルト暗号化SSE-S3(AES-256)

  8. 作成

ヒント:この設計では バケットポリシーは不要(非公開/後続で IAM 最小権限で絞る)。Directory bucket(S3 Express 系)は選ばない。汎用と機能が異なり、バックアップ運用の互換性が落ちます。

(任意)暗号化の確認メモ

コンソール:バケット → [プロパティ]デフォルトの暗号化SSE-S3(AES-256) になっていればOK。
CLI派の確認例は本文末尾の「付録」を参照(`aws s3api get-bucket-encryption`)。


Step 2:ライフサイクル(1 ルールで「5日削除」+「未完了 MPU 7日中止」)

  • 目的:完成済みバックアップを 5 日で自動削除未完了 MPU7 日で自動中止

  • フィルターは必ず `<YOUR_PREFIX>/` に限定(例:`heroku/<APP_NAME>/`)。最下層を限定するのが事故防止のコツ。

設定ガイド(概要)

前提:汎用バケット/ACL無効/バージョニング無効/SSE-S3。

  1. S3 → 対象バケット → [管理]ライフサイクルルール[ルールを作成]

  2. ルール名:`lc-delete-5days`

  3. フィルタープレフィックス`<YOUR_PREFIX>/` を指定

  4. アクション(最新バージョン)

    • 5日オブジェクトの有効期限切れ(削除)

  5. 有効期限切れのオブジェクト削除マーカーまたは未完了のマルチパートアップロードを削除

    • 未完了のマルチパートアップロード7日 で削除(中止)

  6. 作成 → 有効化

なぜMPU中止が必要?
MPUは「完成済みオブジェクトではない」ため、後述のLambda(`list_objects_v2`で列挙)には見えません。放置すると“見えないのに課金”される可能性があるため、S3のライフサイクルで自動中止します。
5日削除で取りこぼした完成済みオブジェクトは、Step 3 のLambdaが7日超で確実
に除去します。


Step 3:“落穂拾い”Lambda(7 日超の完成済みオブジェクトを削除)

  • 狙い:ライフサイクルのバッチ幅で消し残る可能性のある完成済みバックアップを、7 日超確実に削除

  • 設計削除権限は Lambda ロールのみに付与(アップロード用 IAM には Delete 付与しない)=権限分離で事故耐性を上げる。

3-1. Lambda 実行ロール(🌐 IAM)

  • 付与ポリシー:`AWSLambdaBasicExecutionRole`(ログ用)+S3 削除の最小権限(カスタムポリシー)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBucketLimitedToPrefix",
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": "arn:aws:s3:::<YOUR_S3_BUCKET>",
      "Condition": { "StringLike": { "s3:prefix": ["<YOUR_PREFIX>/*"] } }
    },
    {
      "Sid": "DeleteOnlyWithinPrefix",
      "Effect": "Allow",
      "Action": ["s3:DeleteObject", "s3:DeleteObjectVersion"],
      "Resource": "arn:aws:s3:::<YOUR_S3_BUCKET>/<YOUR_PREFIX>/*"
    }
  ]
}

ポイント:アップロード用 IAM に Delete を付与しない削除は Lambda だけ

3-2. Lambda関数の作成(🌐 Lambda)

  • ランタイム:Python 3.12 / ハンドラ:`lambda_function.lambda_handler`

  • 環境変数

    • `BUCKETS` または `BUCKET`(カンマ区切り可)

    • `PREFIXES` または `PREFIX`(省略可・`BUCKETS`と1対1で指定可。末尾 `/` 推奨)

    • `RETENTION_DAYS` = `7`(“7日超”を削除)

    • `DRY_RUN` = `true`(最初は確認用→OKなら`false`に変更)

関数コード
(※最初は `DRY_RUN=true` で実行→ CloudWatch Logs で対象確認→ OK なら `false` に変更)

import os, re
from datetime import datetime, timezone, timedelta
import boto3

s3 = boto3.client("s3")

def split_env(val: str) -> list[str]:
    """カンマ/空白/改行/全角カンマなどで分割してクリーンに"""
    parts = re.split(r"[,\s\u3000\uFF0C\u3001]+", (val or "").strip())
    return [p for p in (x.strip() for x in parts) if p]

# 互換: BUCKETS が無ければ BUCKET を使う
BUCKETS = split_env(os.getenv("BUCKETS", os.getenv("BUCKET", "")))
# 任意: 全バケット共通の1つ、またはバケット数と同数で対応付け
PREFIXES = split_env(os.getenv("PREFIXES", os.getenv("PREFIX", "")))

RETENTION_DAYS = int(os.getenv("RETENTION_DAYS", "7"))
DRY_RUN = os.getenv("DRY_RUN", "true").lower() == "true"

def lambda_handler(event, context):
    if not BUCKETS:
        raise RuntimeError("BUCKETS か BUCKET を設定してください")

    now = datetime.now(timezone.utc)
    total_listed = 0
    total_deleted = 0
    paginator = s3.get_paginator("list_objects_v2")

    for i, bucket in enumerate(BUCKETS):
        # プレフィックスの割り当て(1個なら全バケットに適用、個数一致なら対応付け、無ければ全体)
        if not PREFIXES:
            prefixes = [""]
        elif len(PREFIXES) == 1:
            prefixes = PREFIXES
        elif len(PREFIXES) == len(BUCKETS):
            prefixes = [PREFIXES[i] or ""]
        else:
            prefixes = [""]  # 不整合時は全体

        cutoff = now - timedelta(days=RETENTION_DAYS)
        print(f"== Bucket: {bucket} | prefixes={prefixes} | cutoff={cutoff.isoformat()} | DRY_RUN={DRY_RUN}")

        for prefix in prefixes:
            for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
                contents = page.get("Contents", [])
                if not contents:
                    continue

                batch = []
                for obj in contents:
                    key = obj["Key"]
                    lm = obj["LastModified"]
                    total_listed += 1

                    # 疑似フォルダ(サイズ0で末尾/)は除外
                    if key.endswith("/") and obj.get("Size", 0) == 0:
                        continue

                    if lm <= cutoff:
                        batch.append({"Key": key})

                    if len(batch) == 1000:
                        total_deleted += delete_batch(bucket, batch)
                        batch = []

                if batch:
                    total_deleted += delete_batch(bucket, batch)

    msg = f"Listed={total_listed}, Deleted={total_deleted}, Days={RETENTION_DAYS}, DRY_RUN={DRY_RUN}"
    print(msg)
    return {"status": "ok", "message": msg}

def delete_batch(bucket: str, batch: list[dict]) -> int:
    if DRY_RUN:
        print(f"[DRY_RUN] {bucket}: would delete {len(batch)} objects")
        return 0
    resp = s3.delete_objects(Bucket=bucket, Delete={"Objects": batch, "Quiet": True})
    return len(resp.get("Deleted", []))

安全運用
・ 初回は `DRY_RUN=true` でテスト実行→ CloudWatch Logs で対象を確認 → 問題なければ `false` に変更。
・ `PREFIXES` の末尾 `/` 付け忘れに注意(階層を巻き込む事故を防止)。

3-3. 毎日スケジュール(🌐 EventBridge)

  1. EventBridge → スケジュールスケジュールを作成

  2. 名前:`rule-s3-glean-7days`

  3. cron で毎日実行(UTC)。例:JST 03:00 なら 前日18:00 UTC → `cron(0 18 * * ? *)`

  4. ターゲット:上で作成した Lambda 関数

  5. 作成


Heroku 側へ“引き継ぐ値”

次回以降で Heroku 側のスクリプトや Scheduler を組む際に以下を使用します。

S3_BUCKET=<YOUR_S3_BUCKET>
S3_PREFIX=<YOUR_PREFIX>    # 例: heroku/<APP_NAME>

付録(CLI|任意)

CLIは参考として掲載します。
シェルは半角英数で入力し、`--` は半角ダッシュ2本にしてください。

A. 暗号化(SSE-S3)の確認

# 例
aws s3api get-bucket-encryption --bucket=<YOUR_S3_BUCKET>

期待値(SSE-S3が有効)

{"ServerSideEncryptionConfiguration":{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}}

エラー例と対処

  • `aws: error: the following arguments are required: --bucket`
    → 全角スペース/全角ダッシュ混入の可能性。`--bucket=<NAME>` 形式で再実行。

  • `ServerSideEncryptionConfigurationNotFoundError`
    → デフォルト暗号化が未設定。コンソールで SSE-S3 を有効化。

B. ライフサイクル(1 ルールに統合)

aws s3api put-bucket-lifecycle-configuration \
  --bucket <YOUR_S3_BUCKET> \
  --lifecycle-configuration '{
    "Rules": [
      {
        "ID": "lc-delete-5days",
        "Status": "Enabled",
        "Filter": { "Prefix": "<YOUR_PREFIX>/" },
        "Expiration": { "Days": 5 },
        "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
      }
    ]
  }'

aws s3api get-bucket-lifecycle-configuration --bucket <YOUR_S3_BUCKET>

C. 未完了MPUの有無を確認(参考)

aws s3api list-multipart-uploads --bucket <YOUR_S3_BUCKET> --prefix <YOUR_PREFIX>/

よくある勘所(今回の追記)

  • まず DRY_RUN=true で実行 → CloudWatch Logs を確認してから本番化。対象キーの“巻き込み”を未然に防ぎます。

  • `<YOUR_PREFIX>/` の末尾スラッシュを忘れない(階層巻き込み防止)。

  • 未完了 MPU はライフサイクルで止める(Lambda には見えないため)。

  • S3 オブジェクトの `LastModified` は後から変更できません。テストで“時刻を変えて確認”したいときは再アップロード(またはコピーで新規オブジェクト)にします。

  • JST/UTC の取り違えに注意(EventBridge は UTC)。


まとめ(第1回 改訂)

  • S3:汎用バケット/ACL 無効/バージョニング無効/SSE-S3 で非公開運用。

  • ライフサイクル1 ルール「5 日で削除」+「未完了 MPU は 7 日で中止」

  • Lambda7 日超の完成済みオブジェクトを毎日確実に削除(削除権限は Lambda ロールのみ)。

  • 次回はアップロード専用 IAM(Put のみ/Delete 無)を作って疎通テスト、Heroku 側に接続していきます。


では
次回に!


前回の記事

過去の記事



いいなと思ったら応援しよう!