はじめに
HobbyHubは、趣味のレッスンをマッチングするマーケットプレイス型のWebアプリケーションです([プロジェクト詳細ページへのリンク])。本記事では、その中でも特に苦労したプロフィール画像アップロード機能について取り上げます。画像処理をサーバーと切り離すために、S3・Lambda・Djangoを連携させた非同期パイプラインを構築しました。詳しい設計判断は後述しますが、まずは全体像についてお話します。
全体のアーキテクチャ
HobbyHubのプロフィール画像アップロード機能は、S3・Lambda・Djangoの3者が連携する非同期パイプラインとして構築しています。全体の流れは以下の図の通りです。

処理の流れを簡単に説明します。
-
ブラウザはPresigned URLを使い、画像を直接S3の
staging/プレフィックスへアップロードします。 -
ユーザーがフォームを送信すると、Djangoが該当ファイルを
processing/プレフィックスへコピーし、staging/プレフィックスから削除します。 -
processing/への書き込みイベントをトリガーに、Lambdaが起動します。 -
Lambdaは以下の処理を行います。
- ファイルサイズの検証(S3イベントの
ContentLengthが6MB超、または0バイトの場合は処理を中断) - 用途別の複数サイズ生成(アバター用のsmall、一覧表示用のmedium、フルサイズ表示用のoriginal)
- WebP形式への変換によるファイルサイズ削減
- プライバシー保護のためのEXIFメタデータ(位置情報など)の削除
- 変換後の画像を
processed/プレフィックスへ保存し、元のprocessing/内のファイルを削除
- ファイルサイズの検証(S3イベントの
-
処理が完了すると、LambdaはDjangoへWebhook経由で通知します。この際、共有シークレットを使ったヘッダー認証(
X-Webhook-Secret)で、正規のLambdaからの通知であることを検証しています。 -
通知を受け取ったDjangoは、データベースの
profile_photoフィールドに処理済み画像のパスを保存します。
ブラウザ側のアップロード完了通知に頼らず、Lambda自身がDjangoに完了を伝える設計にしたことで、「アップロードは成功したが処理が失敗した」という状態を防ぎ、DBには常に処理済みの画像だけを参照させる構成にしています。
なぜ同期処理ではなく非同期処理にしたか
プロフィール画像のアップロードは、当初はDjango側でアップロードと同時にリサイズ・変換処理を行う同期的な実装も選択肢にありました。しかし、以下の理由から非同期処理を選びました。
1つ目は、リクエスト/レスポンスを画像処理でブロックしたくなかったことです。
画像の検証やリサイズは決して軽い処理ではなく、これをリクエストの中で同期的に行うと、ユーザーの待ち時間が伸びるだけでなく、Djangoのワーカーが画像処理の間占有されてしまいます。
2つ目は、サーバーの役割を明確に分離したかったことです。
Djangoは「リクエストに応答すること」に専念させ、画像の検証・変換という重い処理はAWS Lambdaへ切り出すことで、アプリケーションサーバーへの負荷を減らす設計にしました。
3つ目は、将来的なスケーラビリティです。
Lambdaは呼び出し数に応じて自動的にスケールするため、仮にアップロード数が急増しても、Djangoサーバー側のリソースを圧迫することなく処理をさばける構成にできます。
またS3のプレフィックスもstaging/(仮置き)→processing/(処理待ち)→processed/(処理済み)の3段階に分け、処理途中や不要な画像を溜め込まない設計にしました。この一手間により、トラブルシューティングが必要になった時も「今どの段階で失敗しているか」を追いやすくなります。
実装で詰まった4つのポイント
1. Windows環境でビルドしたPillowがLambda上で動かない
Lambda関数の実装は普段通りWindowsのローカル環境で行っていましたが、デプロイ後に以下のエラーで動作しませんでした。
Unable to import module 'handler': cannot import name '_imaging' from 'PIL'
原因は、画像処理ライブラリのPillowが内部に持つOS依存のコンパイル済みコード(_imagingモジュール)が、開発環境(Windows)とLambdaの実行環境(Amazon Linux)との間で互換性を持っていなかったことでした。普段の開発では意識することのない部分ですが、Pillowのような画像・数値計算系のライブラリは、環境ごとに異なるバイナリを必要とすることがあります。
解決策として、Lambdaの公式コンテナイメージをDockerで起動し、その中で依存ライブラリをインストールしてデプロイパッケージを作成することで、実行環境と完全に一致したバイナリを用意できました。
docker run --rm -it -v ${PWD}:/var/task public.ecr.aws/lambda/python:3.11 bash
pip install -r requirements.txt -t build
2. IAM権限漏れによる「無言の」失敗
Lambdaでの画像処理自体は成功していたにも関わらず、Django側で画像が表示されないという状態に直面しました。原因を追うと、Lambdaが処理完了をDjangoへ通知するWebhookの認証に使うDJANGO_WEBHOOK_SECRETという環境変数が、SSM Parameter Storeには格納されていたものの、ECSのタスク定義には反映されておらず、コンテナ起動時に読み込まれていませんでした。
タスク定義でSSMから値を注入するよう設定を修正し、加えて実行ロールにssm:GetParametersの権限を付与することで解決しました。
{
"name": "DJANGO_WEBHOOK_SECRET",
"valueFrom": "arn:aws:ssm:ap-northeast-1:<ACCOUNT_ID>:parameter/hobbyhub/prod/DJANGO_WEBHOOK_SECRET"
}
エラーログにも例外が出ない「静かな失敗」だったため、原因特定に時間がかかった点が最大の学びでした。
3. 非同期処理ゆえの表示タイミング問題
設計上当然の挙動ではあるのですが、ユーザーが画像をアップロードした直後はまだLambdaの処理が終わっていないため、ページを読み込んでも画像が表示されない、という状態が発生しました。ブラウザをリフレッシュすれば表示されるものの、それではUXとして不十分だと思い、解決したいと思いました。
そこで、フロントエンドのJavaScriptで一定間隔(2秒ごと、最大16秒程度)で処理完了を確認するポーリング処理を実装し、画像URLが返ってきた時点で自動的に<img>タグを更新するようにしました。
4. CloudFrontのキャッシュでJSの修正が反映されない
ポーリング処理を実装した後も、なぜか本番環境に変更が反映されない問題に直面しました。原因はブラウザキャッシュではなく、CloudFrontのエッジキャッシュが古いJSファイルを配信し続けていたことでした。一時的にCloudFrontのキャッシュを手動で無効化(Invalidation)して解決しましたが、根本対策としてビルド時にファイル名へハッシュ値を付与し、更新のたびに自動でキャッシュが切り替わるようにしました。
振り返り ― もう一度作るなら変えたい点
今回、最も時間を要したのは技術的な難易度そのものよりも、「失敗が起きても気づけない」という構成上の弱点でした。IAM権限の設定漏れによってWebhook通知が届かなかった際も、エラーログには明確な形で現れず、原因特定に時間がかかりました。
現状の構成は、S3イベントがLambdaを直接トリガーする形になっていますが、これだと処理の失敗を検知する仕組みが弱く、可視性を改善する余地があるように思います。
その改善案の一つとして、S3イベントとLambdaの間にSQSを挟む方法があり、リトライ制御やデッドレターキュー(DLQ)による失敗の可視化がしやすくなります。
しかし、これは現状の個人開発規模のトラフィックに対しては過剰な対策であるように思い、今回はコストと複雑性のバランスを考慮し、今後の課題にしました。
今回のトラブルの根本原因は環境変数の設定漏れであり、SQS自体が直接それを防げるものではありませんが、失敗にすぐ気づける仕組みを備えておくことの重要性は実感したので、本番環境でアクセス数が増えた場合は優先的に着手したいと考えています。
まとめ
今回の実装を通じて、単一のサービスだけでは完結しない、複数のAWSサービスを跨いだ設計・権限管理・デバッグの難しさを実感しました。特にIAM権限やキャッシュ起因の問題は、エラーメッセージが分かりやすく出ないことも多く、原因の切り分け方自体が学びになりました。今後もHobbyHubや他のプロジェクトで得た知見を、この場で発信していきたいと思います。