文系プログラマによるTIPSブログ

文系プログラマ脳の私が開発現場で学んだ事やプログラミングのTIPSをまとめています。

s3への画像配置でAWS LambdaのPython3.6+Pillowで複数サムネイル生成する

しょうもないメモ書きみたいな記事です〜


f:id:treeapps:20171029033317p:plain

私は業務で結構長く続く負債満載プロジェクトを担当していたのですが、ようやくAWS移行する事になり、絶賛デスマ中なのです。

※ 私はインフラエンジニアではなく、片手間でインフラもやるサーバサイド・フロントエンド?エンジニアです。

AWS LambdaとPillowで画像変換する

要件

  • s3のバケットに画像が配置されたらAWS Lambdaを起動して画像変換する。
  • 変換元画像はjpgのみとし、jpgからjpgを生成する。
  • lambdaのランタイムはPython v3.6とする。
  • 画像変換にはPython Pillowを使用する。
  • 変換元となるオリジナル画像からアスペクト比を保ったサムネイルを2種類生成し、画質を調整して別のバケットに画像をアップロードする。
  • オリジナル画像はサイズは変えず、画質の調整のみを行う。
  • サムネイルの縦横サイズ、画質のクオリティ等をパラメータ化し、環境変数のみで挙動を変える事ができる。
  • ステージング・本番環境などの環境毎にサムネイル生成ロジックを用意せず、環境を変えるだけで済むようにして全環境同一のコードを利用する。

現状だと画像変換に関してはnode.jsよりpythonの方が速いようですね。

qiita.com

そして更なる高速化を図るため、ImageMagickではなくPython Pillowを使います。

環境整備

今回はImageMagickではなくPython v3.6でPython Pillowを使って画像変換します。そのためには最低限以下が必要です。

  • python v3.6のインストール。
  • pipのインストール。

lambdaにはImageMagickが最初からインストールされていますが、Pillowはインストールされていません。

従って、lambdaの登録時にzip圧縮する際に一緒にPillowを内包する必要があります。Pillowを内包するために今回pipを使うので、pipを使えるようにしておきます。

python自体のインストールはpyenvを使うと、v2.7系とv3.6系を共存できるので、それらで行うといいと思います。

pyenv install -lでv3.6系が列挙されない場合はpyenv自体のバージョンが古いので、バージョンアップして下さい。

pipが無い場合は、以下のようにインストールする事ができます。

sudo yum install python-setuptools
sudo easy_install pip

検証していませんが、mac上でこれらを行うとlinux版と異なるモジュールの場合がある可能性があるため、この作業はlinuxサーバ上で行った方が安全です。

labmdaにアップロードするzipを生成する

まずはPillowをローカルに展開する準備です。

setup.cfg
[install]
install-purelib=$base/lib64/python

これを配置しておく事で、zipに内包したライブラリを、lambda上で利用できるようになります。

docs.aws.amazon.com

Pillowをローカルに展開する

システムにインストールするのではなく、カレントディレクトリにファイル・フォルダを展開するイメージです。

pip install Pillow -t .

これでpipでインストールされるPillow関連ファイル群が、カレントディレクトリに展開されます。

サムネイル生成ファイル

以下を「thumbnail_util.py」というファイル名で保存します。ファイル名はlambda設定時に使います。

# -*- coding: utf-8 -*-
from PIL import Image , JpegImagePlugin
import os, re, boto3

s3 = boto3.client('s3')
SMALL_IMAGE_SIZE = os.environ['SMALL_IMAGE_SIZE']
TINY_IMAGE_SIZE = os.environ['TINY_IMAGE_SIZE']
IMAGE_QUALITY = os.environ['IMAGE_QUALITY']

def convert_image(event, context):
    srcBucket = event['Records'][0]['s3']['bucket']['name']
    destBucket = 'dest-image'
    key = event['Records'][0]['s3']['object']['key']
    originalImagePath = '/tmp/' + os.path.basename(key)

    try:
        # 対象バケットから画像をlambda内にダウンロード
        s3.download_file(Bucket=srcBucket, Key=key, Filename=originalImagePath)
        # JPEGがMPOと判定されてしまう問題の回避
        # https://qiita.com/csakatoku/items/f5d1d5e91077cf8a3650
        JpegImagePlugin._getmp = lambda x: None

        originalImage = Image.open(originalImagePath, 'r')
        originalImage.save(originalImagePath, 'JPEG', quality=IMAGE_QUALITY, optimize=True)
        destOriginalImagePath = re.sub(r'^input/', 'original/', key)
        s3.upload_file(Filename=originalImagePath, Bucket=destBucket, Key=destOriginalImagePath)

        image1 = Image.open(originalImagePath, 'r')
        image1.thumbnail((SMALL_IMAGE_SIZE, SMALL_IMAGE_SIZE), Image.LANCZOS)
        image1.save(originalImagePath, 'JPEG', quality=IMAGE_QUALITY, optimize=True)
        image1DestPath = re.sub(r'^input/', 'small/', key)
        s3.upload_file(Filename=originalImagePath, Bucket=destBucket, Key=image1DestPath)

        image2 = Image.open(originalImagePath, 'r')
        image2.thumbnail((TINY_IMAGE_SIZE, TINY_IMAGE_SIZE), Image.LANCZOS)
        image2.save(originalImagePath, 'JPEG', quality=IMAGE_QUALITY, optimize=True)
        image2DestPath = re.sub(r'^input/', 'tiny/', key)
        s3.upload_file(Filename=originalImagePath, Bucket=destBucket, Key=image2DestPath)
        return
    except Exception as e:
        print(e)
        raise e

os.environの部分が、lambda側で設定する環境変数から値を取得しています。

lambdaは恐らくdocker的なコンテナで、普通にOSが起動しています。なので、lambdaのコンテナからオリジナル画像があるs3のバケットから目的の画像をダウンロードし、lambda内で画像変換し、変換したものを別のs3にアップロードします。

s3に画像が配置されるとこれが起動します。つまり1画像につき1 lambdaが起動する事になり、複数画像を配置すれば、その数だけlambda(コンテナ)が並列でガンガン起動していきます。

フォルダ構造
.
├── □ PIL
├── □ Pillow-4.3.0.dist-info
├── □ __pycache__
├── □ olefile
├── □ olefile-0.44-py3.6.egg-info
├── OleFileIO_PL.py
├── setup.cfg
└── thumbnail_util.py

□ はディレクトリです。

成果物をzip化する

zip時の注意点ですが、zipを解凍した際にファイルが展開されないといけません。

つまり、zipを解凍した際に以下の構造になっている場合はNGです。

lambda
├── □ PIL
├── □ Pillow-4.3.0.dist-info
├── □ __pycache__
├── □ olefile
├── □ olefile-0.44-py3.6.egg-info
├── OleFileIO_PL.py
├── setup.cfg
└── thumbnail_util.py

以下のようになっていればOKです。

□ PIL
□ Pillow-4.3.0.dist-info
□ __pycache__
□ olefile
□ olefile-0.44-py3.6.egg-info
OleFileIO_PL.py
setup.cfg
thumbnail_util.py

zipファイル名は何でもOKです。自由に命名して下さい。

lambdaの設定

f:id:treeapps:20171029021949p:plain

f:id:treeapps:20171029022250p:plain

f:id:treeapps:20171029022305p:plain

f:id:treeapps:20171029022332p:plain

「ハンドラ」ですが、どの関数を呼ぶかを記述します。lambdaから見るとどのファイル名のどの関数なのかが解らないので、「ファイル名.関数名」という指定をしてあげる必要があります。

ここで登場する「環境変数」ですが、ここに指定した値を、関数から「os.environ」で取得する事ができます。

f:id:treeapps:20171029022345p:plain

このままだとlambdaは自動起動してくれないので、「何をした時にlambdaを起動するか」という「トリガー」を指定します。

f:id:treeapps:20171029022404p:plain

イベントソースにS3を指定したいのですが、プルダウンの下の方に合って探すのが大変なので、s3とフィルタした方が速いです。

f:id:treeapps:20171029022417p:plain

「イベントタイプ」には、「オブジェクトの作成(全て)」を指定しました。細かく指定できますが、ファイルを新規アップロードした時だけでなく、上書きされた場合等を考慮して全てを選択しました。ここは要件によって任意に変更するといいと思います。

「プレフィックス」で「バケット内のどのパスに配置されたらイベントを起動するか」を設定します。

「サフィックス」ではファイルの拡張子等の条件を付ける事ができます。

f:id:treeapps:20171029022436p:plain

イベントの設定ができたら、保存ボタンをクリックして完了です!

後はs3のinputフォルダに画像を配置すると、別のバケットに画像がアップロードされます。

TIPS

出力先フォルダは作成しておかなくてもよい

例えば「output」というフォルダに画像を出力するとして、outputフォルダを事前に生成しなくていいのか?と思うかもしれませんが、不要でした。自動で生成されます。

というか、S3は実はフォルダという概念が無いので、フォルダの生成という事自体が不要なのです。

dev.classmethod.jp

ソース画像パスを維持したまま別バケットに出力したい

thumbnail_util.pyに以下のコードがあったと思います。

key = event['Records'][0]['s3']['object']['key']

これは、アップロードされたバケット内の画像ファイルパスが取得されます。

これが文字列で取得できるので、後は以下のように普通に文字列操作して出力先を加工できます。(reはregexの略のようです)

destOriginalImagePath = re.sub(r'^input/', 'original/', key)
lambdaのメモリ割り当て量

今回検証した感じだと、最低の128MByteで十分なようです。

もし速度が遅かったりOutOfMemoryするようなら任意に増やすといいと思います。

おまけ1:私と画像変換の変遷

おまけが実は本編的なやつです。

私が担当している業務での画像変換の変遷です。

javaのawtによる直列画像変換。

最初はjavaでやっていたようです。(私は担当してません)

たまに使われるawtによる画像変換ですが、これがもう異常な程遅く、画質も良くはありません

余りにも処理が遅く、なんと次の日の定時バッチ実行までに終わらない事がある程でした

ImageMagickによる並列画像変換。

そして私は任命されました。

オマエ、コノジョウキョウ、ナントカシタマエ!

と。

という事で、できるだけ時間をかけず、且つできるだけ簡単に解決する方法を考えました。

そこで私はjavaのawtの画像変換ではなく、ImageMagicのconvertコマンドによる画像変換を試し、相当速度アップ(詳細に速度比較してません)し、画質も改善した事を確認します。

最後の問題はトータルの実行時間です。何十時間もかかっている実行時間を何とかしなくてはなりません。簡単な解決策は直列実行を並列実行に変えるというものでした。

bashだけで並列数を管理するのが(インフラの都合上)ちょっと難しかったので、javaでスレッド数を管理する事にしました。

  1. cronが起動し、cronで画像変換javaバッチを起動。
  2. javaバッチからbashのconvertコマンドを呼び出し、並列8スレッドで画像変換を実行。
  3. 全てのスレッドが実行完了するのをawait的に待ち、完了したらログ出力+メールで通知。

こんな感じに直しました。並列実行の効果は抜群で、awt時代は1秒間に数枚程度しか画像変換できませんでしたが、変更後は1秒間に約60〜70枚画像変換できるようになりました。

最終的に画像変換は30分〜1時間で完了するまでに高速化する事ができました

その新画像変換プログラムで数年運用し、何とか業務は回っていましたが、問題はありました。

問題点

画像を変換しているサーバを画像サーバと呼びますが、画像サーバは当然冗長化され、複数台で構成されています。更に、画像サーバといいつつメールサーバも兼任していたり、webサーバがインストールされていたり、もはや役割が曖昧なサーバです。

  • 画像サーバに配置された画像を全サーバに同期させるのにGlusterFSを使ってますが、くっそ重く、CPU使用率が上がりやすい。
  • GlusterFSを管理できる人が全員退職し、もはや誰も触れず、秘伝のタレとすら呼べない、ロストテクノロジー状態です。
  • GlusterFSの同期のせいか、ImageMagicの並列画像変換でCPU使用率100%張り付き+LoadAverageが20〜30で推移する負荷が毎日発生。
  • 並列処理というのは難しいものなので、画像変換プログラムを触れる人が私以外いない。
  • 画像変換負荷に引きづられて他の処理(webサーバへのアクセスやメール送信処理)が遅い・失敗する事が多々有った。

まあそうですよね。色々兼任させつつ並列処理を回し、それをディスク同期してるんですもの。教科書に載せてもいいくらいの悪い例ですね。


そしてAWS化する事になり、色々とインフラを変えます。

そしてAWS環境による画像変換へ

オンプレ AWS
画像サーバ 撤廃してAmazon S3へ。
GlusterFSのディスク同期 Amazon S3なのでサーバ間のディスク同期要らず。
ImageMagick AWS LambdaからPython Pillowを実行。
画像サーバの総合負荷 サーバ自体が無くなった
夜間バッチによる一括画像変換 AWS Lambdaによるリアルタイム並列画像変換。

こんな感じに変更し、以下の負債を解消する事ができました。

  • 物理サーバが壊れた場合にインフラベンダのディスク交換に1ヶ月を要する、という負債を解消。
  • GlusterFSで問題が起きたら実は誰も修復できません、という負債を解消。
  • 画像サーバの負荷による他プログラムへの影響、という負債を解消。
  • 並列処理の難しさ、という負債を解消。
  • 画像サーバに画像を配置する別開発ベンダが、公開終了したデータの画像を削除してくれず、延々と画像が増え続ける、という負債を解消。

サーバは無くなり、負荷は全部S3・lambda側が吸収してくれるようになりました。

画像を削除してくれない問題も、S3に延々と蓄積していく事になりますが、ライフサイクルを設定できたり、物理サーバと構造が異なる事によって増えてもまあ問題無い(多分)ので、安心感があります。

問題点

やはりAWS移行後も問題点はあると思います。

お客さんは従量課金に慣れていない

オンプレ環境では無料でできていた画像変換が、AWS移行後は従量課金となります。

普通に考えれば、オンプレで無料で実現していた件は、裏で膨大な人件費がかかっている事は想像付くと思いますが、お客さんはそれが理解できません。

なので「なんでお金かかるの!?お金かえして!!」みたいな反応をします。

ここは落ち着いて、無料だと思っていた今までの画像変換は、裏で開発ベンダがせっせと超高額な人件費を消費し、それを開発費として請求していたのでコストが見えて無かっただけですよ〜、的な説明をするしかありません。

クラウドに不慣れなメンバーは一杯いる

問、今の時代、AWS・GCP・Azureのどれか触った事あるでしょ???

答、そんな事はありません。パートナー社員はインフラ未経験者は多く、「私はインフラはさっぱりです」という人は本当に多いです。

はい。開発メンバーはプロパー社員が1人(アーキテクト兼インフラ兼PM担当)、パートナー社員が複数人、という構成は世の中に一杯あると思いますが、そういう場合に「今後これチームメンバーに任せられるかな?」という不安は拭い去れません。

こればっかりはチームメンバーに根気よくレクチャーし、勉強して貰うしかありませんね。

おまけ2:s3にアップロードする前に画像変換したい

例えば既存プロジェクトで、構成を変えずに画像変換したいぞ!という要望です。

処理フロー的には以下をイメージします。

  1. CIでgit pull
  2. 取得したソースコード内から画像を探索し、画像変換。
  3. 各サーバに配布してデプロイ。

こんな感じでpullとデプロイの間に画像変換を挟みます。

私の担当業務ではこれをdockerで行っています。

  1. alpine linuxベースの画像変換コンテナを起動。
  2. コンテナが起動すると、ImageMagickが起動し、コンテナ内で並列に画像変換が行われる。
  3. 画像ファイルはvolumeマウントされているので、コンテナ内の変換結果はそのままホスト側に配置されている。

その処理の一部ですが、以下のような形で行っています。

https://hub.docker.com/r/treetips/alpine-imagemagick-mogrify-example/

ImageMagicには、フォルダ内の画像ファイルを一括変換するmogrifyというコマンドがあります。

Command-line Tools: Mogrify @ ImageMagick

これを利用しつつ、parallelというコマンドを使って、複数フォルダに対してmogrifyを実行しています。並列処理を並列処理している感じです。

dockerで画像変換する際の注意点として、コンテナ内のowner:groupと、ホスト側のowner:groupのマッピングです。何も考えずにコンテナ側で画像変換しても、owner:groupは恐らくgidの数字が表示されてしまっていると思います。コンテナとホストでowner:groupが共通でないので、gid・uidが揃っていないので、こうなってしまいます。

なので、コンテナで作業ユーザを追加する際に、ホスト側で予めuid・gidを取得し、そのidを使ってコンテナ内の作業ユーザを作成、その作業ユーザでmogrifyすると、コンテナ内で変換した画像のowner:groupはホスト側と同一になります。

その作業が面倒で、且つsudo権限をもっていて、且つ簡単にsudoコマンドをスクリプトから叩ける場合は、「sudo chown -R hoge:hoge」しちゃってもいいかなと思います。

今はImageMagickで行っていますが、もしかしたらこっちもPython Pillowで行ったらもっと速くなるかもしれませんね。

雑感

案の定おまけが本編化してしまいました。

lambdaを使って画像変換する例はネット上でよく見かけますが、手段が目的にならないように注意したいですね。「こういう問題があるからs3+lambdaで解決しよう!」はとても良いのですが、「s3+lambdaを使いたい、だから画像変換で使ってみよう」だと、思わぬ部分で要件を満たせなかったりします。

クラウドでは「あれが使ってみたい!」という気持ちが先行して、手段が目的になってしまい、炎上するケースが後を絶たないので、そこは本当に注意していきたいものです。