1. 実践の教訓:スマホからの15MB画像でサーバーがダウン
約6か月前、自社のECサイトで大規模なセールキャンペーンを実施した際のことです。トラフィックは普段の4倍に跳ね上がり、その直後、ファイルアップロードの悪夢が始まりました。
出品者がiPhone 14 Proや一眼レフカメラで撮影した商品写真をそのままWebサイトに直接アップロードしていたのです。画像1枚あたりのファイルサイズは12MBから18MB近くもあり、解像度は最大6000×4000ピクセルに達していました。
システムは持ちこたえられませんでした。日曜のゴールデンタイム、4台のCeleryワーカークラスタのメモリ使用率が100%に達し、LinuxのOOM Killerが発動してプロセスを強制終了させたのです。その結果、ユーザー側には502 Bad Gatewayエラーが頻発しました。
さらに悪いことに、未処理のオリジナル画像が数百ギガバイトも蓄積されたことで、その月のAWS S3の請求額はほぼ倍増しました。4G回線のユーザーは商品画像が表示されるまでに5〜7秒も待たされることになり、競合サイトには高解像度のオリジナル画像を丸ごとスクレイピングされて無断転載される始末でした。
2. 画像の展開が膨大なメモリを消費する理由
当初、チームメンバーの多くは「ディスク上のファイルサイズが5MBなら、開いたときのメモリ消費もせいぜい5MB程度だろう」と軽く考えていましたが、これは完全に誤りです。
アプリケーションが画像を処理するためにロードすると、ライブラリは生のピクセルマトリックス(ビットマップ)全体をメモリ上に展開します。実際のメモリ消費量を計算する式は次のとおりです。
消費メモリ ≈ 幅 (px) × 高さ (px) × カラーチャンネル数 (Bytes)
RGBA(各8ビット)の4チャンネルを持つ6000×4000pxの画像を例に挙げると、必要なメモリ容量は以下のようになります。
6000 × 4000 × 4 = 96.000.000 bytes (~91.5 MB RAM)
この91.5MBという数値は、たった1枚の画像を展開するだけで必要なサイズです。10個のワーカーが同時に画像処理を行えば、約1GBのメモリが一瞬で消え去ります。
EXIFメタデータによる画像の回転・反転問題
スマートフォンで撮影された写真には、撮影時の向きがEXIFメタデータのOrientationタグに記録されます。PCのブラウザではこのタグに従って自動回転されないことが多く、縦向きの写真がWeb上で横向きや逆さまに表示されてしまいます。
Web環境において時代遅れになりつつあるJPEGとPNG
PNG形式は可逆圧縮(ロスレス)で画質を維持できるものの、ファイルサイズが重すぎます。一方、JPEG形式は適度に圧縮されるものの、同等の視覚的品質においてWebPと比べると30%〜40%も容量が大きくなります。システム全体をWebPへ全面的に移行すべき時が来ていました。
3. 画像処理における4つのアプローチ比較
実装に入る前に、以下の選択肢を比較・検討しました。
- Cloudinary / Imgix:URLを変更するだけで非常に素早く導入できます。しかし、月間300万PVを超えるとAPIコストが数百ドルに跳ね上がり、外部ベンダーへの強い依存(ベンダーロックイン)も発生します。
- ImageMagick(CLI経由):非常に強力な処理能力を持ちます。しかし、Pythonからsubprocessで呼び出す場合、ファイル名のサニタイズを徹底しないとコマンドインジェクションの脆弱性を招く危険があります。また、DockerベースイメージにCライブラリを組み込む構成も肥大化しがちです。
- OpenCV(cv2):コンピュータビジョンやAI処理には最適ですが、単なる画像のリサイズ目的で導入するにはDockerイメージが300MB〜400MBも膨らんでしまい、リソースの無駄になります。
- Pillow(PIL Fork):Pythonにおける画像処理の標準ライブラリです。軽量で高速なC拡張で書かれており、WebPを標準サポートしている上、適切に実装すればメモリ効率も非常に優れています。
4. Pillowを使った自動化パイプラインの構築
以下は、実際に本番環境で運用しているパイプラインの実装です。処理フローは「EXIFの正規化」「アスペクト比を維持したリサイズ」「半透明ロゴの透かし合成」「WebP形式への圧縮」の4ステップで構成されています。
ステップ1:ライブラリのインストール
pip install Pillow
ステップ2:ファイル名の安全なサニタイズ
ユーザーがアップロードしたファイル名をそのまま信用してはいけません。Linux環境でのパスエラーを防ぐため、特殊文字やスペースを除去する必要があります。ソースコードに組み込む前に、toolcraft.app/ja/tools/developer/regex-tester などのツールで正規表現パターンを検証しておくと安全です。
ステップ3:画像処理パイプラインの詳細実装
画像処理モジュールの完全なソースコードは以下のとおりです。
import os
import re
from PIL import Image, ImageOps, ImageEnhance
def sanitize_filename(filename: str) -> str:
# 英数字、ハイフン、アンダースコアのみを保持
name, _ = os.path.splitext(filename)
clean_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
return f"{clean_name}.webp"
def add_watermark(base_image: Image.Image, watermark_path: str, opacity: float = 0.35) -> Image.Image:
if not os.path.exists(watermark_path):
return base_image
watermark = Image.open(watermark_path).convert("RGBA")
# 透かしの最大幅を元画像の20%に制限
max_wm_width = int(base_image.width * 0.2)
if watermark.width > max_wm_width:
scale = max_wm_width / watermark.width
new_size = (max_wm_width, int(watermark.height * scale))
watermark = watermark.resize(new_size, Image.Resampling.LANCZOS)
# 透かしの不透明度(アルファ値)を調整
alpha = watermark.split()[3]
alpha = ImageEnhance.Brightness(alpha).enhance(opacity)
watermark.putalpha(alpha)
# 透かしを右下にマージン20pxで配置
margin = 20
position = (
base_image.width - watermark.width - margin,
base_image.height - watermark.height - margin
)
# アルファコンポジットで透かしをベース画像に合成
watermark_layer = Image.new("RGBA", base_image.size, (0, 0, 0, 0))
watermark_layer.paste(watermark, position, mask=watermark)
base_rgba = base_image.convert("RGBA")
combined = Image.alpha_composite(base_rgba, watermark_layer)
return combined.convert("RGB")
def process_image(
input_path: str,
output_dir: str,
watermark_path: str = None,
max_width: int = 1600,
quality: int = 82
) -> str:
os.makedirs(output_dir, exist_ok=True)
with Image.open(input_path) as img:
# 1. EXIFタグに従って画像の向きを自動補正
img = ImageOps.exif_transpose(img)
# 2. 画像が大きすぎる場合、アスペクト比を維持してリサイズ
if img.width > max_width:
scale = max_width / float(img.width)
new_height = int(float(img.height) * scale)
img = img.resize((max_width, new_height), Image.Resampling.LANCZOS)
# 3. 著作権保護のための透かしを合成
if watermark_path:
img = add_watermark(img, watermark_path, opacity=0.35)
else:
img = img.convert("RGB")
# 4. 容量を最適化したWebPファイルとして出力
filename = os.path.basename(input_path)
output_filename = sanitize_filename(filename)
output_path = os.path.join(output_dir, output_filename)
img.save(
output_path,
format="WEBP",
quality=quality,
method=6 # WebPエンコーダの最高圧縮アルゴリズム
)
return output_path
ステップ4:ディレクトリ単位の一括処理とCelery連携
以下のスクリプトは、入力ディレクトリ内のすべての画像を走査し、一括で最適化処理を実行します。
def batch_process(source_folder: str, destination_folder: str, watermark_file: str):
supported_exts = (".jpg", ".jpeg", ".png", ".bmp")
files = [f for f in os.listdir(source_folder) if f.lower().endswith(supported_exts)]
print(f"処理対象の画像を {len(files)} 件検出しました。")
for idx, filename in enumerate(files, start=1):
src = os.path.join(source_folder, filename)
try:
dst = process_image(src, destination_folder, watermark_path=watermark_file)
print(f"[{idx}/{len(files)}] 完了: {filename} -> {os.path.basename(dst)}")
except Exception as e:
print(f"{filename} の処理に失敗しました: {e}")
if __name__ == "__main__":
batch_process(
source_folder="./raw_uploads",
destination_folder="./optimized_images",
watermark_file="./assets/logo_watermark.png"
)
5. 本番運用で得た3つの重要な教訓
- 必ず
with Image.open(...)を使用する:ファイルを開いたまま放置してはいけません。コンテキストマネージャを利用することでファイル記述子(ファイルディスクリプタ)が即座に解放され、Too many open filesによるサーバーダウンを防ぐことができます。 - EXIFタグを手動でパースしない:常に
ImageOps.exif_transposeを活用してください。この関数は長年にわたるPillowコミュニティの改善によって洗練されており、ヘッダーが破損した画像でも堅牢に処理してくれます。 - Quality(品質設定)は80〜85に設定する:画質とファイルサイズの理想的なバランスです。iPhoneで撮影された14MBのオリジナル画像も、このパイプラインを通すことで約180KB〜240KBまで削減されます。容量を80%〜85%削減しつつ、肉眼では劣化をほとんど認識できません。
このパイプラインを本番環境へ導入した結果、メモリ枯渇の問題は完全に解消されました。4G回線における商品詳細ページの読み込み時間も、5秒から0.9秒未満へと大幅に高速化されました。
