ハトネコエ Web がくしゅうちょう

プログラミングやサーバー・Web制作、チームマネジメントなど得た技術のまとめ

AWS S3 内の画像をすべてリサイズする方法( goofys 使用)

S3 にある大量の画像をリサイズしたい

ページ内に表示する画像の容量が大きすぎると、ページ表示に時間がかかってユーザー体験を損ねるばかりか、
AWS の通信料金も多くかかってしまい、サービス提供側の金銭にも直接ダメージを与えます。

以前 shrine への移行について書いたように、
Rails などの Web アプリケーションでは、
がんばってサムネを作り直してコードを書き換えて……で、(めちゃくちゃ大変だけど)なんとかなります。

一方で、Wordpress のような CMS では、
記事内のみならずウィジェットやユーザーアイコンなど、様々な目的のためにアップロードされた画像を
すべて一括でリサイズするというのは、なかなか簡単なことではありません。

そこで、今回は S3 にある画像自体を書き換えることとしました。

処理に失敗した場合を考え、必ず事前にバックアップを用意しておきましょう。
aws s3 sync コマンドを活用し別バケットにバックアップを取っておくのがおすすめです。( https://docs.aws.amazon.com/cli/latest/reference/s3/sync.html )

1. EC2 インスタンスの用意

今回は、

の EC2 インスタンスを用意し、作業しました。

S3 へのアクセスが必要なので、 AmazonS3FullAccess の権限を持つ IAM Role を作成し、
EC2 インスタンスにアタッチ
しています。

IAM Role の作成についてよくわからない方は、これなどを参考にするといいでしょう: https://www.ritolab.com/entry/16

また、セキュリティグループは自身のIPアドレスからのSSHログインのみ許すよう、しっかり最低限の設定だけしましょう。
それ以外に必要なインバウンドのポートはありません。

2. ImageMagick, pngquant のインストール

EC2インスタンスを作成し、SSHログインしましたら、
今回画像圧縮で用いる ImageMagickpngquant をインストールします。

ImageMagick のインストールはとても簡単です。

sudo yum install -y ImageMagick ImageMagick-devel

次に pngquant のインストールです。

sudo yum install -y gcc git
git clone --recursive https://github.com/kornelski/pngquant.git
cd pngquant
sudo make install

コンパイルのために gcc が必要なのでインストールしています。
特に configure の設定することなく make install すれば、
通常は /usr/local/bin/pngquant に pngquant がインストールされるかと思います。

3. goofys のダウンロード

S3バケットを mount することができる すごいアプリケーション goofys を使います。
同様のツール s3fs をよりパフォーマンス向上させたもののようです。

goofys は go 言語で作られているだけあって、バイナリも配布されています。
それを活用しましょう。

wget https://github.com/kahing/goofys/releases/latest/download/goofys
chmod +x goofys

これで現在のディレクトリに実行可能な goofys が用意されました。
お好みで /usr/local/bin 下への移動などおこなってもよいでしょう。

4. S3 バケットのマウント

goofys を使って S3 バケットをマウントする前に、マウントするためのディレクトリを作成します。
今回は /s3-data という名前にしました。

sudo mkdir /s3-data

必要なパッケージのインストールをします。

sudo yum install -y fuse mailcap

fuse がないと、 goofys でマウントしようとした際に
main.FATAL Unable to mount file system, see syslog for details
とエラーを吐きます。

エラーにある通り syslog を
sudo less /var/log/messages で確認すると
main.FATAL Mounting file system: Mount: mount: running fusermount: exec: "fusermount": executable file not found in $PATH#012#012stderr:
というエラーメッセージが見つかります。fuse がないために fusermount の実行ができないわけですね。

mailcap をインストールする理由は少し変わっていて、mailcap 自体が必要というよりも、
インストールによって /etc/mime.types を作成するのが目的です。

goofys が MIME TYPE を設定するための --use-content-type というオプションがあるのですが、
その際に MIME TYPE を推定するために必要なのが /etc/mime.types なのです。

こうして準備が整ったらマウントをします。
マウントする S3 のバケット名は test-s3-bucket としました。
ここは S3 ですでに作っている実際のバケット名に合わせて変更してください。

sudo ./goofys --use-content-type --acl public-read test-s3-bucket /s3-data

特にメッセージが出ることがなければ成功です
sudo ls -alh /s3-data で、S3 バケットディレクトリが見られるようになっているか確認しましょう。

URL直打ちで画像ファイルを見られるようにしたいので --acl public-read のオプションを付けているところにも注目です。

ちなみに goofys の実行時に sudo を付け忘れると
main.FATAL Mounting file system: Mount: mount: running fusermount: exit status 1#012#012stderr:#012fusermount: user has no write access to mountpoint /s3-data
というエラーが syslog に出力されつつ、マウントに失敗します。
root user でなければ権限が足りないわけですね。

5. 画像圧縮をおこなう

一度 root ユーザーになっておいた方がいろいろおこないやすいです。

sudo su
cd /s3-data

今回は JPEG, PNG それぞれの画像圧縮のために、以下のようなコマンドを打つことにしました。

convertimagemagick のコマンド)のオプションについては、
この間書いた記事をご参照ください。

find /s3-data -type f -name "*.jpg" | xargs -I {} convert {} -colorspace sRGB -resize 1280x1280\> -quality 80 -density 72 {}
find /s3-data -type f -name "*.jpeg" | xargs -I {} convert {} -colorspace sRGB -resize 1280x1280\> -quality 80 -density 72 {}
find /s3-data -type f -name "*.png" | xargs -I {} /usr/local/bin/pngquant --quality 65-80 --force --output {} -- {}
find /s3-data -type f -name "*.png" | xargs -I {} convert {} -resize 1280x1280\> {}

mogrify コマンドはサブディレクトリを掘っていって一括変換、ということが出来ないので、
find コマンドでファイルを探索したのちに xargs で
convert コマンドもしくは pngquant コマンドに渡してあげているのがポイントです。

なお、pngquant の処理後では mime type が goofys デフォルトの application/octet-stream に変わってしまいます。
今回の場合は、その後に imagemagick の処理をやったあとで mime type が image/png に戻ってくれるので気にしていませんが、
pngquant の処理のみおこなう方は、この点ご注意ください。(そしてどうやったら解決できたか教えてください…)

上のコマンド例ではバケット全体にかけていますが、
時間のかかる処理なので、ある程度ディレクトリごとに区切って実施するのが良いかと思います。

6. CDN のキャッシュリセット

CloudFront を使っている場合は、キャッシュをリセットしておきましょう。

Invalidations からおこなえます。(参考: https://dev.classmethod.jp/articles/aws-amazon-cloudfront-deleting-cache-by-invalidation/

おわりに

最初は AWS lambda でやろうとしていたんですが、
慣れない lambda の設定をしてるうちに「1度しかやらない時間のかかる処理を lambda でやらせるのって正しいのか……?」と気になってきたときに、
このStackOverflowの回答を見つけ、「こんなツールあるの! 便利じゃん!」と大きく舵を切り直して、結果的に速く作業を終えることが出来ました。

テスト用のバケットを作って検証を重ねていたので、失敗することなく済み、よかったです。

おまけ:Amazon Linux 2 への libvips のインストール

今回、最初は imagemagick でなく処理がより軽量で高速と聞いている libvips を使おうと思っていたのですが、
リサイズした画像ファイルを上書きする方法がどうしてもわからず結局 imagemagick を使いました。

というわけで、結局使っていないんですが libvips のインストール手順をメモっていたので
ここに残しておきます。

# libvips のインストール
# 参考: 
#   https://github.com/libvips/libvips/issues/1184#issuecomment-447973135
#   https://qiita.com/t-kigi/items/f6850abaaee1db2df5a4

sudo yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
sudo yum install -y http://rpms.remirepo.net/enterprise/remi-release-7.rpm
sudo yum install -y yum-utils
sudo yum-config-manager --enable remi
sudo rpm -ivh http://mirror.centos.org/centos/7/os/x86_64/Packages/LibRaw-0.19.4-1.el7.x86_64.rpm
sudo yum install -y vips vips-devel vips-tools

Next.js に sequelize 導入(メモ)

手順をあとで振り返れるように自分用メモ。

1. clone

を起点とする。リポジトリを clone したのち npm i

2. create database

Docker のこと考えると面倒なので、まずはローカルの MySQL を使うようにするところから。
mysql -u root -p で入った後に

create database next_js_mysql;

3. npm i sequelize mysql2

npm i sequelize mysql2

Ref: https://sequelize.org/master/manual/getting-started.html

4. npm i -D sequelize-cli

Ref: https://sequelize.org/master/manual/migrations.html

npm i -D sequelize-cli
npx sequelize-cli init

config/config.json , models/index.js が作成される。

5. index.js の TS 化

いちおう元の index.js を尊重した上で TypeScript 化するとこんな感じ?

import fs from 'fs';
import path from 'path';
import Sequelize from 'sequelize';

const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.json')[env];
let db = {};

let sequelize: Sequelize.Sequelize;
if (config.use_env_variable) {
  sequelize = new Sequelize.Sequelize(process.env[config.use_env_variable], config);
} else {
  sequelize = new Sequelize.Sequelize(config.database, config.username, config.password, config);
}

fs
  .readdirSync(__dirname)
  .filter(file => {
    return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
  })
  .forEach(file => {
    const model = sequelize['import'](path.join(__dirname, file));
    db[model.name] = model;
  });

Object.keys(db).forEach(modelName => {
  if (db[modelName].associate) {
    db[modelName].associate(db);
  }
});

const sequelizeHash = {
  sequelize: sequelize,
  Sequelize: Sequelize,
}

db = { ...db, ...sequelizeHash }

module.exports = db;

6. config.json の更新

development の password と database を更新。

後でわかることだが、 operatorsAliases オプションは deprecated らしいので削除。

7. Article.ts の追加

Ref:

冗長かもしれないが models/Article.ts をこう記述した。

import { Sequelize, Model, DataTypes } from 'sequelize';

export default class Article extends Model {
  public id!: number;
  public title!: string;
  public body!: string;

  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;

  public static initialize(sequelize: Sequelize) {
    this.init(
      {
        id: {
          type: DataTypes.BIGINT.UNSIGNED,
          primaryKey: true,
          autoIncrement: true,
          allowNull: false,
        },
        title: {
          type: DataTypes.STRING(100),
          allowNull: false,
          defaultValue: '',
        },
        body: {
          type: DataTypes.TEXT,
          allowNull: false,
          defaultValue: '',
        }
      }, {
        tableName: 'article',
        sequelize: sequelize,
      }
    );
    return this;
  }
}

8. index.ts の更新

https://karuta-kayituka.hatenablog.com/entry/2019/07/21/132814 を参考にしながら以下のように更新。

import Sequelize from 'sequelize';
import Article from './Article';

const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.json')[env];

let sequelize: Sequelize.Sequelize;
if (config.use_env_variable) {
  sequelize = new Sequelize.Sequelize(process.env[config.use_env_variable], config);
} else {
  sequelize = new Sequelize.Sequelize(config.database, config.username, config.password, config);
}

const db = {
  Article: Article.initialize(sequelize),
};

Object.keys(db).forEach(modelName => {
  if (db[modelName].associate) {
    db[modelName].associate(db);
  }
});

export default db;

9. migration file の作成

npx sequelize-cli model:generate --name Article --attributes title:string,body:text で最初作ってみたが、
思っていたのと違うものが出来てしまったので、
migrations/20200502224547-create-article.js は大きく変えて以下のようにした。
collate を指定してるところが地味に大事)

'use strict';
module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.createTable('article', {
      id: {
        type: Sequelize.BIGINT.UNSIGNED,
        primaryKey: true,
        allowNull: false,
        autoIncrement: true,
      },
      title: {
        type: Sequelize.STRING(100),
        allowNull: false,
        defaultValue: '',
      },
      body: {
        type: Sequelize.TEXT,
        allowNull: false,
        defaultValue: '',
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
      }
    }, {
      charset: 'utf8mb4',
      collate: 'utf8mb4_bin',
    });
  },
  down: (queryInterface) => {
    return queryInterface.dropTable('article');
  }
};

migrate は以下のコマンドで実行。

npx sequelize-cli db:migrate

dry-run できなくてどんなテーブルが作られるか実行後までわからないのは、なかなかひどい仕様だと思う。
migrate だけ ridgepole なり sqldef なり使うのはアリな気がする。

10. API の作成

テーブルが用意できたのであとは API の実装。

pages/api/article/list.ts に以下のように書いた。

import { NextApiRequest, NextApiResponse } from 'next'
import models from '../../../models';

export default (_: NextApiRequest, res: NextApiResponse) => {
  const listArticles = async () => {
    return models.Article.findAll();
  }

  listArticles()
    .then(articles => res.status(200).json({ articles: articles }))
    .catch(() => res.status(500).json({ error: '500: Exception caught' }));
}

pages/api/article/create.ts は以下。

import { NextApiRequest, NextApiResponse } from 'next'
import models from '../../../models';

export default (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === 'POST' && req.headers["content-type"] == 'application/json') {
    const request = req.body;

    const createArticle = async () => {
      return await models.Article.create({
        title: request.title,
        body: request.body,
      });
    }

    if (!request.title || !request.body) {
      return res.status(400).json({ error: '400: Missing parameter' });
    }

    if (request.title.length > 100) {
      return res.status(400).json({ error: '400: title length must be lower than 100 chars' });
    }

    createArticle()
      .then(article => res.status(200).json({ article: article }))
      .catch(() => res.status(500).json({ error: '500: Exception caught' }));
  }
}

create したあとの id が取得できないのが課題。

これで

curl -X POST -H 'Content-Type: application/json' http://localhost:8013/api/article/create -d '{"title": "タイトル", "body": "これが本文です"}'

のような形で作成ができ、

curl http://localhost:8013/api/article/list

で一覧取得できる。

> curl -X POST -H 'Content-Type: application/json' http://localhost:8013/api/article/create -d '{"title": "タイトル", "body": "これが本文です"}'
{"article":{"id":null,"title":"タイトル","body":"これが本文です","updatedAt":"2020-05-03T01:21:34.264Z","createdAt":"2020-05-03T01:21:34.264Z"}}
> curl -X POST -H 'Content-Type: application/json' http://localhost:8013/api/article/create -d '{"title": "タイトル2", "body": "これが本文です2"}'
{"article":{"id":null,"title":"タイトル2","body":"これが本文です2","updatedAt":"2020-05-03T01:21:38.361Z","createdAt":"2020-05-03T01:21:38.361Z"}}
> curl http://localhost:8013/api/article/list
{"articles":[{"id":1,"title":"タイトル","body":"これが本文です","createdAt":"2020-05-03T01:21:34.000Z","updatedAt":"2020-05-03T01:21:34.000Z"},{"id":2,"title":"タイトル2","body":"これが本文です2","createdAt":"2020-05-03T01:21:38.000Z","updatedAt":"2020-05-03T01:21:38.000Z"}]}

11. 感想

db migrate 周りがきつい。
Rails の Schemafile と違ってけっこう文字数多くて打ちにくいし、
migration file と Article.ts で似たようなことを書かないといけないところがなかなかにきつい。

sequelize を使うよりも、db migrate は ridgepole なり sqldef を使うとして、
ふつうにSQL書いたほうが悩むこと少ないのでは、って気分がしてきた。

mogrify コマンドで画像を一括変換しよう

imagemagick というと、
Rails 書いてる人にとっては minimagick (or rmagick) を使うために必要なやつでしょ?
ってイメージのほうが強いと思うけれど、もちろん CLI でも力を発揮してくれる。

1. くすんでしまったレナさん

f:id:nekonenene:20200426152720j:plain
いつもの画像解析でおなじみレナさん……と思いきや?

この画像、ウェブで見ると一見ふつうに見えるのだけれど、
なんとダウンロードして Mac や一部の画像編集ソフトで見ると変色する。

f:id:nekonenene:20200426153857p:plain
変色してしまったレナさん

さらにこのダウンロードした画像を画像編集ソフト「Affinity Photo」png 画像として保存すると、
以下のようにさらに変色する。

f:id:nekonenene:20200426154217p:plain
白く霞んでしまったレナさん

これと同じ現象が関わってる会社でも起こって、
「なんでだろう? でも色味が CMYK っぽいよな」と思って確認したところ、
まさにその通りで、Web向けの画像なのに Color model (色空間の指定)が CMYK になってしまっていた

f:id:nekonenene:20200426154733p:plain
Color model: CMYK (色空間がCMYK

つまり、ウェブブラウザは色空間が CMYK という情報を読み取れないので RGB として描画するのだが、
Mac や高性能な画像編集ソフトの場合は CMYK を理解できるために、CMYK として描画してしまい、
くすんだ色合いになってしまったわけだ。

さらに png で保存すると、png は RGB 形式での保存しか出来ないので、
画像編集ソフトがいい感じに CMYK → RGB の変換をおこなおうとして、結果として白くくすんでしまっていた。

おそらくこの最初のような画像が生まれた背景は、デザイナーさんが書き出し設定を印刷用設定にしたままJPEG出力をしたのだろう。
このような、ウェブ用なのに CMYK 指定が入っているという歪(いびつ)な画像を、今回大量に修正する必要があった。

2. convert コマンド

imagemagick をすでにインストールしてあるとして(Mac なら brew install imagemagick)、
convert コマンドで1枚ごとの変換が可能だ。

convert lenna.jpg -colorspace RGB lenna.jpg

変換前の画像 lenna.jpg の色空間を RGB に変更して上書きできる。

出力後のファイル名を変える場合には

convert lenna.jpg -colorspace RGB lenna_converted.jpg

などとおこなう。

3. mogrify コマンド

上書きの場合であれば、 convert コマンドを使わずに以下のように mogrify コマンドでも可能だ。

mogrify -colorspace RGB lenna.jpg

そしてこのコマンドではワイルドカードを扱えるので、
大量の画像を一括処理したい場合に有効活用できる。

# 注意:以下のコマンドを実行するとディレクトリ内のすべての .jpg 画像に影響が出るのでご注意ください
mogrify -colorspace RGB *.jpg

これで、問題の画像全てを1つのディレクトリにまとめて置いた上で、
そのディレクトリ内でこのコマンドを実行すれば、すべての .jpg 画像の色空間指定が修正される。

4. mogrify コマンドで画像ファイルを一括サイズ縮小

上記のような特殊なケースに限らず、 mogrify コマンドは活用できる。

例えば、「写真素材をWebサイトで使う前に、ユーザーがページ表示に時間かからないよう全部縮小しちゃいたいな」という場合。

以下のコマンドで、
アスペクト比を維持しつつ縦・横のどちらかの最大を 1200px、JPEG品質は 90% にして、72dpi で保存
ということが出来てしまう。

今回は上書きにならないよう、 resized ディレクトリを先に作って、
そこに変換後の画像を出力することとした。

mkdir resized
mogrify -resize 1200x1200\> -quality 90 -density 72 -path resized *.jpg

なお、PNG 画像も同様におこなえる。(ただし JPEG と異なり、quality のパラメーターが小さいほど容量が小さくなるわけではない点に注意)

mogrify -resize 1200x1200\> -quality 90 -density 72 -path resized *.png

他にも出来る画像処理はあるので、公式ドキュメントでオプション一覧を見ると良い。
https://imagemagick.org/script/mogrify.php

Windows だと画像リサイズソフトいっぱいあるのに
Mac だとつらいな〜、と思っていた人にはぜひ試してもらいたい。