EatSmartシステム部ブログ

ウェブサイトの開発や運営に関する情報です。

jpegtranを使った画像最適化について

今回は、jpegtranを使用した画像最適化についてまとめていきます。 経緯として、クスパ-料理教室ポータルサイトで使用している先生方のブログ画像 の容量が膨大になっており、ディスク容量を圧迫していたため画像の最適化を検討しました。

jpegtranについて

jpegtranは、jpeg画像のメタ情報を削除することで、画像の圧縮を実現するためのツールになります。 以下のように使うのが基本形です。 jpegtran -copy none -optimize -outfile "出力ファイル" "投入ファイル" ※投入ファイルが最適化したい元画像、出力ファイルが最適化後のファイルになります。

オプションについて
-copy none jpegメタデータを全て削除する。
-optimize ハフマン符号を最適化する。

注意点として、投入ファイルと出力ファイルを同一名にすることが仕様上できないことです。 例えば、投入ファイル名 abc.jpg → 出力ファイル名 abc.jpgという設定はできません。 そのため、出力ファイルを別名とした上で、リネームして投入ファイル名に戻すなどの対応 が必要になります。 ※今回は、拡張子jpgの後に.optを加え元画像と区別した。

画像最適化のために使用したコード例(検証版)

WORK_ROOT=/---/---/---
echo $WORK_ROOT

for TARGET in `find $WORK_ROOT/blog \( -name \*.jpeg -o -name \*.JPEG -o -name \*.JPG -o -name \*.jpg \)
 -and -daystart -mtime +7 | head -n 1000`

do
echo '####target list is '$TARGET
jpegtran -copy none -optimize -outfile "${TARGET}.opt" "${TARGET}"
if [ "${TARGET}.opt" ]; then
find . -name "*.opt" | xargs rename .opt '' * ※1
fi
done

検証段階ではこのような形でシェルスクリプトを作成し、実行していました。 まず、対象となるjpeg画像をfindし、TARGETにセットします。 -daystart -mtime +7としているのは、現在から過去7日目を基準として そこから過去全ての期間を対象とするオプションになります。 負荷対策のため、処理対象をheadで限定し、1000件としました。

$TARGETには、取得した1000件の1件ずつが入り、jpegtranを1件ずつ かけていきます。if文で出力ファイル(.opt)の存在チェックをし、投入ファイル (元画像)を削除するということを行っています。その後、.optファイルを元の 拡張子(jpgなど)に戻すということをしています。

結果的にこのコードを本番でそのまま採用するには至りませんでした。 以下の二点が理由になります。
① if文の存在チェックがスルーされ、jpegtranが失敗した場合に出力ファイル(.optファイル) ができていないのに、元画像を削除しまっていた。
② ※1のfindの処理が不必要な範囲で検索をかけてしまっている。

画像最適化のために使用したコード例(本番)

WORK_ROOT=/---/---/---
echo $WORK_ROOT

count=0

for TARGET in `find $WORK_ROOT/blog \( -name \*.jpeg -o -name \*.JPEG -o -name \*.JPG -o -name \*.jpg \) 
-and -daystart -mtime +7 | head -n 1000`

  echo '####target list is '$TARGET
    jpegtran -copy none -optimize -outfile "${TARGET}.opt" "${TARGET}"
    if [ -f "${TARGET}.opt" ]; then
    rm $TARGET
    mv ${TARGET}.opt $TARGET
    fi

    echo $count
    count=$((count+1))
    if [ $((count%1)) -eq 0 ]; then
       echo 'sleep'
       sleep 0.01
    fi

理由①の改善として、-fオプション(fileが存在し、且つ、通常のファイル) を入れることでファイル存在のチェックが効くようになりました。 理由②の改善は、renameを使った記述ではうまくいかず、moveで対応して 不必要なfindを省略できました。 さらに、処理する際の負荷を一定にするために1件処理する毎にsleep処理を挟むように 改善しました。

結果・まとめ

Blog画像の容量 (before)173G→(after)169G -4Gという結果で当初予想していたより効果が 出なかったので残念ではありますが、一通りの流れと地雷ポイントがわかったことが収穫でした。 元画像を削除してしまうというリスキーな面があるので、バックアップは必須です! 以上となります。

クスパレシピページの有効インデックス数低下について

クスパでは、料理教室の先生が投稿するレシピを掲載していますが、8月下旬辺りからGoogle Search ConsoleのAMP/レシピの有効インデックス数が低下して、本日迄に行った施策を記載したいと思います。

cookingschool.jp

最初に確認したこと

有効インデックス数が低下した原因を調査するに辺り、最初に確認したこととして、AMPページが正しく認識されているかを確認する為に、Google AMPテストを実施しました。

AMPテスト search.google.com

AMPテストでは正常に認識されている事が確認できたので、次に「URLの正規化」が正しく行われているかを改めて確認しました。
クスパの場合、正規(PC)ページ+モバイルページ+AMPページを別のURLで構成している為、以下の様な正規化を行っていました。 f:id:eatsmart:20190918143608j:plain

「URLの正規化」についても特に問題なかったので、次に、構造化データが正しく認識されているかを確認する為にGoogle構造化データテストツールを実施しました。

search.google.com

いずれも、インデックス数低下前と変わらず問題ありませんでした。

モバイルファーストインデック(MFI)

Search Consoleでインデックス数低下の原因を色々調査していたところ、9/1からモバイルファーストインデック(MFI)に切り替わっていることが判明しました。
※MFの移行確認ですが、Search Consoleにアクセスした際にポップアップが出ますので、それで確認できます。その他の確認方法として、Search Consoleの「設定」ページ等でも確認可能です。

www.suzukikenichi.com

MFI対策

「URL正規化」の見直し

MFI対策としてまず対応したこととして、「URLの正規化」の見直しを行いました。具体的には、モバイルページからAMPページへamphtmlを追加しました。 f:id:eatsmart:20190918144331j:plain

その後、1週間程度経過したらAMPの有効インデックス数がほぼ低下前迄回復しました。

構造化データの見直し

クスパでは構造化データにMicroformats(hrecipe)を採用していますが、元々正規(PC)ページには構造化データが設定していましたが、モバイルページにも設定を行いました。

Microformats(hrecipe) microformats.org

その後、3日程度経過したらレシピの有効インデックス数も回復しました。

結論

今回の原因ですが、URLの正規化や構造化データがモバイルページに設定されていない事で正しく認識されずにインデックス数が低下しました。
まだMFIに切り替わっていないサイトについては、事前のMFI対策が必要と思われますので参考にしてみて下さい。

ClamAVのエラーとその解決

イートスマートで提供しているサービスでは、クライアントからファイルをアップロードする機能を提供しているものがあります。 アップロードされたファイルを利用するにあたり、安全に利用するためClamAVを利用してウィルスのチェックを行っています。 ある日、この処理が以下のエラーで失敗していることが判明しました。 手動で実行したところ、以下のようなエラーが出力されました。

# clamdscan /xxx/yyy/zzz.jpg
/xxx/yyy/zzz.jpg: Can't open file or directory ERROR

この内容から、最初は該当ファイルにアクセスする権限が無いのだと思いましたが、読み取り・実行権限がありました。 そもそもその場合は"Permission denied"のようなエラーになるハズです。 いろいろ試すなかで、/tmp以下に置いたファイルは正常に処理出来ます。

# clamdscan /tmp/test.txt
/tmp/test.txt: OK

パーミッションには問題が無く外のバッチ処理は正常に実行出来るので、clamdscanの実行に問題があるのではないかと調査しました。 すると、AppArmorというものがclamdscanの実行を制限していることがわかりました。

ja.wikipedia.org

以下のようにclamdscanの実行に必要な設定を追加しました。

# vim /etc/apparmor.d/local/usr.sbin.clamd
cat /etc/apparmor.d/local/usr.sbin.clamd
# Site-specific additions and overrides for usr.sbin.clamd.
# For more details, please see /etc/apparmor.d/local/README.
/xxx/yyy/    r,
/xxx/yyy/** r,
# apparmor_parser -r -K /etc/apparmor.d/usr.sbin.clamd

以上のことを行うことで、ウィルスのチェック正常に実行することが出来るようになりました。

# clamdscan /xxx/yyy/zzz.jpg
/xxx/yyy/zzz.jpg: OK

re:dashで年対比のグラフを作りました

弊社ではデータを可視化するツールのひとつにre:dashを使っています。 今回、DBをデータソースにしてre:dashを使って年対比のグラフを作ったので、その方法を紹介します。

弊社サービスのもぐナビでのクチコミ投稿数で、年対比のグラフを作ってみました。 過去3年間の週別のクチコミ投稿数を年対比の棒グラフにしてみました。

クエリの作成

まずre:dashの[Create]で[Query]を指定します。 次にソースの所に、T_USER_COMMENTというテーブルに投稿されているクチコミが入っているので、年・週でGroupByして件数を取得するように、下記のようなSQLを書きました。

SELECT COUNT(*) AS CNT,
       TO_CHAR(REGIST_DATE,'IYYY') AS YEAR,
       TO_CHAR(REGIST_DATE,'IW') AS WEEK
  FROM T_USER_COMMENT TUC
 WHERE TO_CHAR(REGIST_DATE,'IYYY')>=
       TO_CHAR(CURRENT_TIMESTAMP+'-2 years','IYYY')
 GROUP BY YEAR,WEEK
 ORDER BY WEEK,YEAR

ここで、日付から年、週の値に変換するのに、TO_CHARの'IW'、'IYYY'というのを使用しました。 これはISO年、ISO週番号というもので、1/1から年が始まるのではなく、最初の木曜日を含む週を第1週として1〜53週の番号をふるものです。

https://www.postgresql.jp/document/9.3/html/functions-formatting.html

これを使うことにより、必ず月曜から週が始まり年対比がしやすくなります。

実際にSQLを実行すると以下のようになります。

f:id:eatsmart:20190907162405j:plain

グラフの作成

次に[+ New Visualization]でグラフを追加し、

  • Chart Type: Bar
  • X Column: weeek
  • Y Column: cnt
  • Group by: year

にします。 さらに[Y Axis]で

  • Scale: Catgory

を選ぶと調子が良いです。 f:id:eatsmart:20190907172814j:plain

さいごに

以上の設定で、年間53週の3カ年対比グラフができました。

f:id:eatsmart:20190907173125j:plain

月次の値で年対比にする場合は、SQLのGROUP BYを月単位にすればすぐできます。

Excelで年対比グラフを作ろうと思うと、データのレイアウトやグラフの作り方に少し手間がありますが、re:dashなら適切にSQLを作れば簡単にグラフにできますし、データを自動リフレッシュできますので、日常的にウォッチするにはとても便利だと思います。

ダッシュボード化して、便利にご活用ください。

rsyncで行ったデータ移行のまとめ

今回は、インフラ再編に伴って行ったクスパの画像データ移行について 内容をまとめていきます。対象となる画像は、クスパ上にあがる先生ブログの画像や各種レシピ、レッスンに使われる 画像などトータルで約500GBの容量でした。

rsyncについて

画像データの移行は、rsyncを使って全て行いました。 まず、rsyncの基本的な説明になりますが異なるホスト間でフォルダやファイルの同期を取ることを目的に使われるコマンドになります。コマンドプロンプト上などで、以下のように使うのが基本形です。
rsync (オプション) 同期元/  同期先/ 
同期元配下のディレクトリを同期先配下へ同期する。

オプション(実際に使用したもの)
-a アーカイブモード。(同期元のパーミッション、グループ情報、シンボリックリンクを保持したまま同期)
-v 進行状況(処理中のファイル名)の詳細を表示する
-z データの圧縮。画像以外のデータ移行に使用した
-n dry-runモード(実際には同期しないでテスト的に動作確認のみ行う)
--exclude 特定のディレクトリを除いて同期。同期不要なファイルに使用した
--bwlimit ファイルの転送速度に制限をかける

実際に新環境への移行に際し、使用したコード例

上記の説明を踏まえて、いくつか使用例になります。 試験的にまずは、検証環境でdry-runしてテストを行いました。以下、比較的容量の大きいblogディレクトリを同期した場合になります。

rsync -avn 同期元IP:/mnt/nfs/cspa/image/upload/blog /mnt/nfs/cspa/image/upload

実行結果

root@同期先:/mnt/nfs/cspa/image/upload
sent 1,429,790 bytes received 12,238,281 bytes 321,601.67 bytes/sec
total size is 18,145,045,475 speedup is 1,327.55 (DRY RUN)

途中の結果を記載していませんがdry-run中にblog配下のどういった画像データがコピーされるのかがログに出力されます。コマンド自体の誤りも、dry-runの際に確認できるので便利でした。

検証環境で問題がなかったので、最終的に複数のジョブに分け、Jenkinsでバッチ処理にしました。 以下のように、ディレクトリ単位でグループ化しています。

ssh -l root 本場移行先IP 'rsync -av 移行元IP:/data/export/cspa/image/upload/blog /var/share/cspa/image/upload'
ssh -l root 本場移行先IP 'rsync -av 移行元IP:/data/export/cspa/image/upload/lesson /var/share/cspa/image/upload'
ssh -l root 本場移行先IP 'rsync -av 移行元IP:/data/export/cspa/image/upload/lesson_information /var/share/cspa/image/upload'

この際の反省点として、転送の容量が大きいものには、同期中の負荷軽減のために転送速度の制限オプションを付加すべきでした。(-avの後に--bwlimit=1024と例えば設定すると、1MB/secの転送速度になります。)

画像の移行確認について

ファイル、ディレクトリの使用容量をduコマンドを使って確認します。

root@本場移行先:/mnt/cspa/image/upload# du -h --max-depth 1

実行結果

171G ./blog
87G ./lesson
67G ./lesson_information
808K ./recommend
608M ./ec
(以下、省略)
オプションの説明
-h(--human-readable) 認識しやすい単位に変換して出力
-s(summarize) ディレクトリの合計サイズを出力
--max-depth 集計するディレクトリの階層を指定する

個別にファイルサイズを確認したい場合には、以下で確認できます。

root@本場移行先:/mnt/cspa/image/upload# du -sh blog

実行結果

171G  blog

振り返り

以上になりますが、普段使用したことがなかったコマンドであったため、色々調べたり、聞きながら新しいことを 知れてよい機会でした。ファイルの階層を間違うと事故になるので、やはりdry-runなどで事前の検証が大事だなと 感じました。検証と本番環境では、転送時間に差がある場合があるので、本番環境で早めに小さい単位で行い、トータルの同期時間を見積るのが吉だと思いました。

apache benchによるWEBサービスの性能測定について

今回はWEBサーバの性能測定について記載します。
インフラ移行を行う際、新環境でWebサービス公開前の性能測定等を行う必要があるかと思います。
そんなときに手軽に利用できるツールとてapache benchがあります。
今回導入~測定(確認ポイント)迄をまとめてみました。

Apache Benchの入手

Apache Benchは、Apache Software Foundationが提供している「Apache HTTP Server」に同封されておりApache HTTP Serverがインストールされている環境なら直ぐに利用可能です。
今回は、Windows版(Ver2.4)を利用しましたが、Windows版はApache HTTP Serverをインストールしなくても、zipを解凍したbinフォルダーにApache Benchのツールが同封されています。

Apache Benchの利用

まず、WindowsApache Benchを選定した理由ですが、前提としてグローバルIP経由での性能測定を行いたかったことと、上記「Apache Benchの入手」で記載した通り導入が手軽だったことです。
次に、Apache Benchの実行方法ですが、Windowsコマンドプロンプトを起動して、httpで測定するの場合 ab.exeを、httpsで測定するの場合 abs.exeを実行します。

基本コマンド
HTTP
C:\Apache24\bin>ab -n 100 -c 5  http://・・・・・・・
HTTPS
C:\Apache24\bin>abs -n 100 -c 5  http://・・・・・・・

Apache Bench基本オプション

基本的には-nと-cオプションで測定可能です。

-n:トータル発行するリクエスト数
-c:同時接続数

その他よく利用するオプション

-t:サーバからのレスポンスの待ち時間(秒)
-A:ベーシック認証が必要な場合ユーザー名:パスワードを指定
-p:ポストデータが必要な場合指定
-X:プロキシ経由でリクエストする場合プロキシサーバ名:ポート番号を指定
-V:バージョンを表示
-h:ヘルプを表示

httpd.apache.org

Apache Benchの確認方法

コマンドを実行すると以下の様な結果が出力されます

c:\Apache24\bin>abs -n 100 -c 5  https://・・・・・・・


Server Software:        nginx
Server Hostname:        ・・・・・・・
Server Port:            443
SSL/TLS Protocol:       ・・・・・・・
Server Temp Key:        ・・・・・・・
TLS Server Name:        ・・・・・・・

Document Path:          ・・・・・・・
Document Length:        20838 bytes

Concurrency Level:      5
Time taken for tests:   3.625 seconds
Complete requests:      100
Failed requests:        50
   (Connect: 0, Receive: 0, Length: 50, Exceptions: 0)
Total transferred:      2134874 bytes
HTML transferred:       2076774 bytes
Requests per second:    27.58 [#/sec] (mean)
Time per request:       181.271 [ms] (mean)
Time per request:       36.254 [ms] (mean, across all concurrent requests)
Transfer rate:          575.06 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:       19   51  49.5     38     374
Processing:    48  110 105.7     78     591
Waiting:       44  104 101.6     73     582
Total:         75  161 118.2    122     625

Percentage of the requests served within a certain time (ms)
  50%    122
  66%    141
  75%    151
  80%    179
  90%    298
  95%    517
  98%    624
  99%    625
 100%    625 (longest request)

出力項目

・Complete requests
 正常に処理したリクエスト数

・Failed requests
 処理に失敗したリクエスト数(基本0)

・Time taken for tests
 テストに要した時間(数値が低いほど良い)

・Requests per second
 1秒で処理できるリクエスト数(数値が高いほど良い)

・Time per request(mean)
 同時実行したリクエストの平均処理時間(数値が低いほど良い)

・Time per request(mean, across all concurrent requests)
 1リクエストの平均処理時間(数値が低いほど良い)

・Transfer rate
 1秒間で受け取ったデータの大きさ(数値が高いほど良い)

・Connection Times (ms)
 リクエストに要した時間(ミリ秒)の内訳。

・Percentage of the requests served within a certain time
 全リクエストの割合(%)に対して、処理が完了した時間(ms)を表す。

主な確認ポイント

リクエストは全部正常に処理されたか?

Complete requests/Failed requests

※今回50リクエスト失敗していますが、レスポンス毎にContent-Lengthのサイズが異なる(動的)ページの場合、失敗したリクエストとして“Length:”と伴にカウントされます。
(1つ前の結果とContent-Lengthを比較し、異なる場合失敗としてカウントされます)
この場合、apacheログ等を参照して、レスポンスが200且つ妥当なContent-Lengthが返却されて いれば問題ありません。

秒間どれくらいのリクエストを捌けるのか?

Requests per second

(例) 月間 1千万PVのサイトの場合、秒間≒3.8リクエストは達成する必要があります。 10,000,000(PV)÷30(日)÷24(時間)÷60(分)÷60(秒) ≒ 3.8

パフォーマンス確認

Time per request(mean, across all concurrent requests)

1リクエストあたりの処理時間を確認します。

何リクエストまで耐えられるのか?

オプション -n -cを増していき、Failed requestsが0でなくなるのが限界点となります。

その他

Apache Benchで性能測定をする際、301、302等リダイレクトを行っていると正しく計測されませんので、一度apacheログ等でレスポンスが200が返却されている事を確認してから、実施すると良いです。
Apache Benchは、手軽に導入できるので是非活用してみてください。

PostgreSQLのパーティショニングを利用する

もぐナビはフォローした情報を閲覧する"タイムライン"の提供を開始しました。 今回は"タイムライン"で利用するイベント記録テーブルに関する工夫を書きたいと思います。

パーティショニングの利用

記録するイベントは、商品へのクチコミやイイネなどのユーザーのアクションや商品の発売、ニュース記事の公開などが該当します。 これらのイベントは毎日大量に発生することが予想されるため、数が増えた場合に性能を落とさない工夫が必要です。 また、タイムラインの提供範囲から外れたイベントは不要となるため、削除することが可能です。

以上のことから、イベント記録テーブルはパーティショニングを利用することにしました。 パーティショニングを利用することで、テーブルのサイズを抑えることが可能です。 また、期間を指定して削除もDELETEではなく該当期間のテーブルをDROPすることが出来ます。

テーブルの作成

イベントを記録するマスタテーブルを作成します。 時系列で分割するためのevent_dateというフィールドを作成しています。

CREATE TABLE v_timeline_event (
    event_id            VARCHAR(256) NOT NULL,
    event               VARCHAR(64) NOT NULL,
    action_user_id      NUMERIC(15,0),
    event_date          TIMESTAMP WITHOUT TIME ZONE NOT NULL
);

次に子テーブルを作成します。このときCHECKという制約を指定します。 2019年以前のデータを格納するため"event_date < DATE '2019-01-01'"といいう制約を指定しています。

CREATE TABLE v_timeline_event_2018 (
    CHECK ( event_date < DATE '2019-01-01' )
) INHERITS (v_timeline_event);
CREATE INDEX idx_v_timeline_event_2018_1 ON v_timeline_event_2018 (event_date DESC, event, event_id);

データの挿入

データの挿入は、マスタテーブルに対して行います。 マスターテーブルにはあらかじめトリガを設定しておき、適切な子テーブルへの挿入するようにします。 このとき、例えば2019年のデータをv_timeline_event_2018へ挿入しようとするとエラーが発生します。

月単位で子テーブルを管理するにあたり、その作成を関数内で行うことにしました。 予めテーブルを作成しておけば良いのですが、不要なテーブルが大量に存在することが嫌なこと、当初の想定を超えて子テーブルが必要になった際にエラーが発生することからこのようにしました。 実装にあたり、既にこの仕組を実現している方の情報が参考にしました。

-- INSERT時に適切なパーティションテーブルへ保存する関数
-- 対象となるパーティションテーブルが無い場合は作成する
CREATE OR REPLACE FUNCTION func_timeline_event_insert() RETURNS TRIGGER AS
'
DECLARE
    part text;
BEGIN
    -- 2019年以前のデータは共通
    IF ( NEW.event_date <  DATE ''2019-01-01'') THEN
        INSERT INTO v_timeline_event_2018 VALUES (NEW.*);
        RETURN NULL;
    END IF;

    -- 日付からパーティションテーブルの名前を決める:v_timeline_event_YYYYMM
    part := ''v_timeline_event_'' || TO_CHAR(new.event_date, ''YYYYMM'');

    BEGIN
        EXECUTE ''INSERT INTO '' || part || '' VALUES(($1).*)'' USING new;
        RETURN NULL;
    EXCEPTION WHEN undefined_table THEN
        -- 対象となるテーブルが無い場合は作成
        RAISE NOTICE ''CREATE TABLE->%'', part;
        EXECUTE ''CREATE TABLE '' || part || '' (''
            || ''    CHECK ( event_date >= DATE '''''' || date_trunc(''MONTH'', NEW.event_date) || '''''' AND event_date < DATE '''''' || (date_trunc(''MONTH'', NEW.event_date) + ''1 MONTH''::INTERVAL) || '''''' )''
            || '') INHERITS (v_timeline_event)'';
        EXECUTE '' CREATE INDEX idx_'' || part || ''_1 ON '' || part || '' (event_date DESC, event, event_id)'';
    END;

    EXECUTE ''INSERT INTO '' || part || '' VALUES(($1).*)'' USING new;
    RETURN NULL;
END;
'
LANGUAGE plpgsql;
-- INSERT時に適切なパーティションテーブルへ保存する関数を実行するためのトリガ
CREATE TRIGGER trg_insert_v_timeline_event
    BEFORE INSERT ON v_timeline_event
    FOR EACH ROW EXECUTE PROCEDURE func_timeline_event_insert();

挿入時のエラーを捕捉して、テーブルを作成するようにしました。 試しに予めテーブルを作成した場合と速度の比較をしてパフォーマンスが落ちることを確認しましたが、挿入はバッチ処理で行うため今回は問題なしとしました。