[zenpix] そのAVIF変換、サーバーを重くしていませんか?
この記事に出てくる用語
画像フォーマットの比較
| フォーマット | 圧縮方式 | 透過 | 特徴 |
|---|---|---|---|
| JPEG | 非可逆 | ❌ | 写真向け。1992年から使われる枯れた規格 |
| PNG | 可逆 | ✅ | ファイルサイズが大きい。透過が必要なグラフィック向け |
| WebP | 非可逆/可逆 | ✅ | Google製。JPEGより約30%小さく、PNGの代替にもなる |
| AVIF | 非可逆/可逆 | ✅ | AV1動画コーデック技術ベース。WebPよりさらに高圧縮・高画質 |
AVIF(AV1 Image File Format)のブラウザ対応
| ブラウザ | 対応バージョン |
|---|---|
| Chrome | 85+(2020年) |
| Firefox | 93+(2021年) |
| Safari | 16+(2022年、iOS 16+ / macOS Ventura+) |
| Edge | 121+(2024年) |
2026年時点で主要ブラウザの現行バージョンはすべて対応済み。ただし古いiOS(15以前)やSafari 15以前は非対応のため、対象ユーザーが古いデバイスを使う場合はフォールバックを用意するのが安全。
zenpixはWebPにも対応しているため、AVIF・WebPの両方をサーバー側で生成できる。
import { decode, resize, encodeAvif, encodeWebp } from "zenpix";
import { readFileSync } from "fs";
const img = decode(readFileSync("input.png"));
const resized = resize(img, { width: 960, height: 540 });
const avif = encodeAvif(resized, { quality: 60, speed: 6 }); // メイン
const webp = encodeWebp(resized, { quality: 80 }); // フォールバック
HTMLでは <picture> 要素でブラウザに選ばせる。
<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="...">
</picture>
AVIFのトレードオフ
| AVIF | WebP | JPEG | |
|---|---|---|---|
| ファイルサイズ(同画質) | 最小 | 中 | 大 |
| エンコード速度 | 遅い(JPEGの数十倍のCPU時間) | 中 | 速い |
| デコード速度(ブラウザ) | やや遅い | 速い | 速い |
エンコードが遅いことがAVIFの最大のデメリット。低スペックなサーバーでAVIF変換リクエストが重なるとCPUを食い潰す——これがこの記事のテーマだ。
FFI(Foreign Function Interface)
異なる言語間でコードを呼び出す仕組み。この記事では「TypeScriptからCのネイティブライブラリ関数を直接呼び出す」ことを指す。Node.js/BunはFFI経由でOSのネイティブバイナリ(.dylib/.so/.dll)を呼べる。
libavif / libaom
libavifはAVIF形式の読み書きを担うC製ライブラリ、libaomはAV1コーデックの実際のエンコード・デコードを担うC製ライブラリ。libavifがlibaomを内部で呼び出す構成になっている。
libvips
画像処理C製ライブラリ。Sharpが内部で使っており、ストリーミング処理とマルチスレッド並列化を自動で行う。1変換で複数スレッドを立ち上げるため、VPSのようなコア数が少ない環境ではCPU使用率が高くなりやすい。
SIMD(Single Instruction, Multiple Data)
1命令で複数のデータを同時処理するCPU命令セット。ARMではNEON、x86ではSSE2/AVXがこれに相当する。画像処理の内積・色変換・フィルタ計算など「同じ演算を大量のピクセルに繰り返す」処理で大幅な速度向上が得られる。
ICCプロファイル
画像に埋め込まれた色域の定義データ。sRGB・Display P3など、どの色空間で撮影・編集されたかを記録する。変換時にプロファイルを引き継がないと、色味が変わって見える場合がある。
はじめに
自分のポートフォリオサイト tsukasa-art.com では、アップロードした画像をAVIFに変換して配信しています。変換には Sharp を使っていたのですが、低スペックなVPS(2vCPU / 2GB RAM)で複数の変換リクエストが重なると、サーバーが重くなる問題に気づきました。
「Sharpが遅い」わけではありません。でも低スペックVPSでは詰まる。この記事ではその理由を掘り下げ、C製の画像処理ライブラリ zenpix を自作して解決した話を書きます。
Sharpで「詰まる」とはどういうことか
wall-clockとCPU user時間の違い
処理時間の指標には2種類あります。
- wall-clock:処理の開始から終了まで、時計で測った時間
- CPU user時間:その処理がCPUを実際に消費した時間
Sharpは内部でlibvipsを使い、マルチスレッドで処理を並列化します。1回の変換のwall-clockが短くても、複数のスレッドが同時にCPUを使うため、CPU user時間はwall-clockを大きく上回ります。
2vCPUのVPSで何が起きているか
2vCPUのVPSで動かせるスレッドは同時に2本だけです。Sharpが1変換で複数スレッドを使い倒すと、他のリクエストはCPUが空くまで待たされます。
今回の計測(Ubuntu / 2vCPU / 2GB RAM)では、キャラ絵1枚のFHD変換でSharpは約1.8秒のCPU時間を消費します(詳細は後述)。4リクエストが同時に来ると合計で約7.4秒のCPU時間が必要になり、2vCPUで捌くには実時間で約3.7秒かかります。
この間、他のリクエストはCPUを待ち続けます。画像変換以外のAPIリクエストも巻き込まれる。これが「詰まる」の正体です。
zenpixを作った
既存の代替ライブラリより自作を選んだのには理由があります。
なぜCか
画像処理の実績があるCライブラリ(libavif、libaom、libpng、libjpeg-turbo、libwebp)をそのまま活かしたかったためです。これらはすべてC製なので、C で書けば API を直接叩けます。
CMake + vcpkg で依存関係を管理し、クロスコンパイルも素直に書けます。SIMD(NEON/SSE2)を直接記述できる点も C を選んだ理由のひとつです。
// avif_encode.c:libavif を直接呼び出す例
avifImage *image = avifImageCreate(width, height, 8, AVIF_PIXEL_FORMAT_YUV444);
avifEncoder *encoder = avifEncoderCreate();
encoder->quality = quality;
encoder->maxThreads = threads;
avifEncoderWrite(encoder, image, &output);
アーキテクチャ
C (src/) → libpict.{dylib,so,dll} → Node.js / Bun / Deno(FFI)
→ CLI(npx zenpix)
TypeScript 層から FFI 経由で C のネイティブライブラリを呼び出す構成です。メモリ管理は C 側で完結させ、JS 側にはポインタのみ渡します。
C + TypeScript の2層構成がこのライブラリの特徴です。実績ある C ライブラリを直接束ねてネイティブバイナリに仕上げ、TypeScript から型安全に呼び出せます。
デフォルトはシングルスレッド
Sharpが1変換で複数スレッドを使うのに対し、zenpixはデフォルト threads=1(シングルスレッド)で動きます。1変換あたりのCPU消費が1コア分に収まるため、低スペックVPSで並列リクエストが来てもCPUの取り合いが起きません。これが前述のCPU user時間の差の正体です。
Sharpはlibvipsがスレッド数を自動で決めるため、呼び出し側から制御できません。zenpixは呼び出しごとに threads を指定でき、サーバー・バッチ・その中間と、用途に合わせてスレッド数を明示的に制御できるのが特徴です:
// サーバー(デフォルト、threads=1)
const avif = encodeAvif(image, { quality: 60, speed: 6 });
import os from "os";
// バッチ処理(コア数フルで速度優先)
const avif = encodeAvif(image, { quality: 60, speed: 6, threads: os.cpus().length });
import os from "os";
// 中間(他の処理にCPUを残しつつバッチ処理したい場合)
const avif = encodeAvif(image, { quality: 60, speed: 6, threads: Math.floor(os.cpus().length / 2) });
画質:YUV 4:4:4 で色情報を完全保持
zenpix の AVIF エンコードは YUV 4:4:4(クロマサブサンプリングなし)を使用しています。Sharp のデフォルトは YUV 4:2:0 で、色差成分を水平・垂直それぞれ 1/2 に間引くため、色情報の 75% が失われます。
同じ quality=60 でも、彩度の高い色やパステルのグラデーションが多いイラストでは出力の見た目に差が出ます。アルファチャンネルは常にロスレスエンコードです。
この記事のメインテーマはサーバー負荷の話ですが、「品質も落とさずに速くなる」という点は強調しておきたいポイントです。
カラープロファイルを保持したまま変換
decode() 時にJPEG / PNG / WebPの埋め込みICCプロファイルを抽出し、resize() を経由してエンコード時にAVIF・WebP・PNGチャンクとして再埋め込みします。色域が変換前後で正確に保たれるため、イラストや写真の色味がずれません。
npm install一発で動く
npm install zenpix
# または
bun add zenpix
インストール時にOS/アーキテクチャに対応した libpict バイナリ(optional dependency)が自動で入ります。libavifやlibaomを別途インストールする必要はありません(静的リンク済み)。
対応環境:Node.js / Bun / Deno、macOS(arm64/x64)、Linux x64、Windows x64
ブラウザ・エッジ環境向けには zenpix-wasm も提供しています。
計測方法
環境
- VPS:Ubuntu / 2vCPU / 2GB RAM
- AVIF設定:quality=60 / speed=6
テスト画像
自作イラスト6種類を使用しました。キャラ絵2枚・風景画3枚・合成ノイズ画像1枚です。
この記事では代表として キャラ絵(FHDシナリオ:1920×1080 → 960×540) の結果を中心に示します。画像の種類によって結果が変わるため、後述のnoteで補足します。
ベンチマーク設計
Sequential bench(従来の計測)
1変換ずつ順番に実行。warmup 2回 + 計測10回の中央値。VPS上で3回走らせてその中央値を採用。
Concurrent bench(今回新規)
N個の変換プロセスを同時起動し、全プロセスが終わるまでのwall-clock時間を計測。各Nにつき3回走らせて中央値を採用。CPU user時間はN個のプロセスの合計値。
計測結果
Sequential bench(1変換ずつ)
VPS実測・3run中央値。キャラ絵でのwall-clock比較:
| シナリオ | zenpix | Sharp | ratio |
|---|---|---|---|
| FHD(960×540出力) | 1,307ms | 1,770ms | 1.35× |
| WQHD(1280×720出力) | 2,158ms | 2,717ms | 1.26× |
| 4K(1920×1080出力) | 4,366ms | 5,174ms | 1.19× |
Concurrent bench(N並列)
VPS実測・3run中央値。キャラ絵FHDシナリオ:
| N | zenpix wall | Sharp wall | wall ratio | zenpix CPU合計 | Sharp CPU合計 | CPU ratio |
|---|---|---|---|---|---|---|
| 1 | 1,418ms | 1,912ms | 1.35× | 1,343ms | 1,849ms | 1.38× |
| 2 | 1,435ms | 1,918ms | 1.34× | 2,689ms | 3,659ms | 1.36× |
| 4 | 2,975ms | 3,983ms | 1.34× | 5,517ms | 7,605ms | 1.38× |
| 8 | 5,933ms | 8,197ms | 1.38× | 11,068ms | 15,679ms | 1.42× |
wall ratio / CPU ratio = Sharp ÷ zenpix(>1でzenpixが有利)
数字の読み方
N=1 ≈ N=2 のwall-clock(1,418ms vs 1,435ms):2vCPUで2ジョブが真に並列実行されている証拠です。N=4でほぼ2倍、N=8でほぼ4倍と、2vCPUの上限通りにスケールしています。
ratioがN=1〜8で1.34〜1.38×で安定:並列数が増えてもzenpixの優位性が崩れません。スループット改善がリクエスト数に比例して効いています。
N=8のCPU合計:Sharpは15,679ms、zenpixは11,068ms。差分の4,611msが「他のリクエストに使えたはずのCPU時間」です。2vCPUのVPSでは約2秒分の余裕に相当します。
使い方
import { decode, resize, encodeAvif } from "zenpix";
import { readFileSync } from "fs";
const input = readFileSync("input.png");
const img = decode(input);
const small = resize(img, { width: 960, height: 540 });
const avif = encodeAvif(small, { quality: 60, speed: 6 });
// avif は Uint8Array
AVIFだけでなく、WebP・PNGも同じAPIで扱えます:
import { decode, resize, encodeWebp } from "zenpix";
const img = decode(readFileSync("input.png"));
const small = resize(img, { width: 960, height: 540 });
const webp = encodeWebp(small, { quality: 80 });
convert() を使えばワンライナーで書けます:
import { convert } from "zenpix";
const result = convert(readFileSync("input.jpg"), {
resize: { width: 960, height: 540, fit: "cover" },
encode: { format: "avif", quality: 60 }, // "webp" / "png" も指定可
});
CLIも使えます:
npx zenpix input.jpg output.avif --quality 60
npx zenpix *.jpg --out-dir ./avif/ # バッチ変換
Denoの場合(--allow-ffi フラグが必要です):
import { decode, resize, encodeAvif } from "npm:zenpix/deno";
ブラウザで試したい方は、ベンチマークページで自分の画像を使って変換を体験できます(zenpix-wasm による変換と、ブラウザ Canvas API との比較も確認できます)。
まとめ
- Sharpが「遅い」のではなく、CPU user時間が多いことが低スペックVPSのボトルネックになる
- zenpixはキャラ絵・風景画でwall-clockも約35%速く、CPU消費は約27〜29%少ない
- 並列リクエストが増えても比率が安定しており、スループット改善がスケールする
- デフォルトシングルスレッドでCPU消費を抑制、
threadsオプションでバッチ処理にも対応 - AVIF エンコードに YUV 4:4:4 を採用。Sharp(YUV 4:2:0)より色情報を完全保持
- ICCプロファイルをデコード→リサイズ→エンコードを通じて保持
- Node.js / Bun / Deno 対応、npm install一発で動く
リポジトリ:https://github.com/tsukasa-art/zenpix
npm(サーバー):https://www.npmjs.com/package/zenpix
npm(WASM):https://www.npmjs.com/package/zenpix-wasm
Discussion