openresty-minifiers は、OpenResty Inc. が開発した高性能なプライベートミニファイアライブラリです。これは Nginx の出力フィルターモジュールとして機能し、JS、CSS、HTML の 3 種類のアセットに対して、ランタイムでストリーミング形式の圧縮を実行します。特に、ビルドプロセスに介入できないシナリオ——例えば、レガシーシステムのリバースプロキシ、マルチテナントゲートウェイ、エッジノードでの透過的な最適化などに適しています。当社のテスト環境では、シングルコアで 120+ MB/s のスループットを達成しています。

CI/CD パイプラインにおいて、ミニフィケーションはすっかり当たり前の工程になっていることでしょう。

Webpack 設定の TerserPlugin、Vite でデフォルトで有効化されている esbuild の minify、PostCSS チェインで静かに実行される cssnano——これらのツールはすでに成熟しきっており、その存在を意識することはほとんどありません。一度設定すれば、何千回でも確実に実行され、決して間違うことはないのです。この問題は、エンジニアリングの世界では 10 年以上も前に解決されています。

しかし、本記事で取り上げるのはビルド時のミニフィケーションではありません。ビルドプロセスにアクセスできない状況では、一体どうなるのでしょうか。

「解決済み」が通用しない 3 つの現場

3 つのシナリオを想像してみてください。

シナリオ 1:レガシーシステムのリバースプロキシ。 10 年前に作られた Java のモノリシックアプリケーションを引き継いだとします。そのフロントエンドアセットは一切圧縮されていません。しかし、ビルドプロセスを変更する権限はなく、ソースコードすら入手できない状況です。唯一介入できるポイントは、手前にある OpenResty ゲートウェイのみです。

シナリオ 2:マルチテナント SaaS ゲートウェイ。 プラットフォームが数百のテナントのトラフィックを中継しており、各テナントのアプリケーションはそれぞれ独自にメンテナンスされています。ゲートウェイレイヤーで統一的にアウトバウンド帯域を最適化する必要がありますが、各テナントにビルドツールチェインの改修を強制することはできません。

シナリオ 3:エッジノードでの透過的な最適化。 CDN の PoP ノードに OpenResty をデプロイし、そこを通過するすべての静的アセットに対してリアルタイムで圧縮を行いたいと考えています。しかも、アップストリーム側の設定変更には一切依存しない形で実現する必要があります。

これら 3 つのシナリオには共通の特徴があります。それは、ミニフィケーションをランタイムで、Nginx フィルターレイヤーにおいて、レスポンスボディに対してストリーミング処理として実行しなければならない、という点です。

この制約によって、一度は解決されたはずの問題に、ゼロから向き合う必要が出てきたのです。

なぜ「正規表現で置換すればいい」では通用しないのか

多くのエンジニアがこの種の要件に初めて直面したとき、「これは単なる文字列処理の問題ではないか?正規表現でコメントと余分な空白を置換すれば済むだろう」と直感的に考えがちです。

しかし、その直感は誤っており、そしてそれは非常に典型的な誤解でもあります。

Minification は構文を意識した操作

JavaScript を例に、次のようなコードを考えてみます。

var result = a / b / c;
var regex = /pattern/g;
var str = "remove // this comment? no";
// this is a real comment

このコードでは、本物のコメント(4 行目)は削除する必要があります。その一方で、文字列リテラル内の // this comment? や、正規表現リテラル /pattern/g 内のスラッシュ、そして除算 a / b / c をコメントの開始点として誤認識し、これらを変更してはなりません。

これこそが、JavaScript の字句解析において有名なスラッシュの曖昧性問題です。/ という文字は、文脈によって除算演算子、正規表現リテラルの開始、あるいはコメントの開始となり得ます。そのため、部分的な特徴だけを見て区別することは不可能であり、完全な解析状態を維持する必要があります。

CSS にも同様の問題があります。例えば、url() 関数内のコンテンツは通常のテキストとして処理すべきではなく、calc() 内の空白は calc(100% - 20px) のように意味を持つため削除できません。HTML はさらに複雑で、<script> タグや <style> タグが現れると、全く異なる解析モードに切り替える必要があります。そして、これら 2 つのモードは、まさに JavaScript と CSS の解析モードそのものなのです。

このような構文コンテキストを考慮せずに実装された minifier は、本番環境において追跡困難なバグを引き起こすリスクを避けられません。

バッファ境界をまたぐとき、パーサーは何を失うか

ビルド時に使用される minifier は、ファイル全体を扱います。そのため、まず完全な AST(抽象構文木)を解析し、その AST に基づいて変換と出力を行うことができます。このプロセス全体は、非常にシンプルかつ確定的です。

しかし、Nginx の filter 層で扱われるのは、個々のデータストリームの断片です。HTTP レスポンスボディは複数の buffer chain に分割されて filter モジュールに渡されますが、各 buffer のサイズは upstream とカーネルによって決定されるため、filter 側では予測不可能です。

つまり、あるコメントが 2 つの buffer にまたがってしまう状況が起こり得ます。例えば、前半の buffer が /* で終わり、後半の buffer が */ で始まるといったケースです。ある文字列リテラルが、buffer の境界で「未完了」の状態になることもあります。同様に、</script> タグが 2 つのチャンクに分割されることも考えられます。

ナイーブな実装では、こうした境界部分で静かに問題が発生します。エラーを報告することなく、結果としてブラウザで時折パースに失敗するような JS ファイルを生成してしまうのです。

レスポンス全体をバッファリングしてはいけない理由

最も直接的な解決策は、レスポンスボディ全体を一度メモリにバッファリングし、その上で実績のあるオフラインの minifier を使って処理するというものでしょう。

問題は、この方法が Nginx のストリーミング処理モデルを損ない、深刻なエンジニアリング上のリスクをもたらす点にあります。

  • メモリ使用量が制御不能に:2 MB の JS ファイル 1 つに対し、最低でも 2 MB の追加ヒープメモリが必要となり、これを同時接続数で乗じると、その量は数十 GB にも達します。
  • TTFB の遅延が増加:レスポンス全体の受信が完了するまで処理を開始できないため、クライアントが最初の 1 バイトを受け取るまでの時間(TTFB)が直接的に増加します。
  • 単一の巨大なファイルで worker がダウンする危険性:上限なくメモリを確保するバッファリングは、それ自体が DoS 攻撃の脆弱性(DoS ベクトル)となり得ます。

この問題を解決する実行可能なアプローチは、固定サイズのバッファ(例:8 KB)内でインクリメンタルな処理を完結させる必要があります。さらに、構文構造がバッファの境界をまたいだとしても、処理結果の正しさが保証されなければなりません。

したがって、ここで求められるのは単なる minifier ではありません。それは、**有限状態マシン(FSM)に基づいたストリーミング字句解析器(lexical analyzer)**です。この解析器は、O(1) のメモリ使用量で、バッファをまたいで解析状態を正しく維持する能力を持つ必要があります。もはや、これは完全に別次元のエンジニアリング課題と言えるでしょう。

Lua で正規表現を書いた、その先に待っていたもの

専用のツールがない場合、エンジニアリングチームは通常、次のようなアプローチを試みます。

アプローチ 1:body_filter_by_lua + Lua 正規表現。 OpenResty の Lua API でレスポンスボディ全体を受け取り、文字列処理を行う方法です。このアプローチはファイルサイズが小さい場合には機能しますが、レスポンスボディ全体をバッファリングするという暗黙の要件があります。レスポンスボディが数十 KB を超えると、メモリへの負荷と GC による停止時間が無視できなくなります。さらに根本的な問題として、Lua の文字列操作は、本来シンタックスアウェアなストリーミング処理向けには設計されていません。

アプローチ 2:sub_filter ディレクティブ。 Nginx ネイティブの ngx_http_sub_module は文字列置換機能を提供しますが、これは単純な文字列一致であり、構文構造を一切解釈しません。この方法でコメントを削除しようとすると、コード内の同名の文字列まで誤って削除してしまうため、実用的ではありません。

アプローチ 3:アップストリームのアプリケーション層での処理。 バックエンドサービスに軽量化のためのミドルウェアを追加する方法です。これは、軽量化(minification)の責務をアプリケーション層に押し戻すことになり、「アップストリームは変更できない」という中核的な制約を迂回してしまいます。同時に、新たなサービス依存関係や遅延も発生させます。

これらのアプローチは、決して不適切な技術判断ではなく、与えられた制約下における合理的な試みです。しかし、そのいずれもが「ストリーミング」「シンタックスアウェア」「O(1) メモリ」という、トレードオフの関係にある 3 つの要件を真に解決するには至っていません。

もし皆様が上述のいずれかの状況に直面している、あるいは body_filter_by_lua を用いたアプローチで既に課題を経験されているのであれば、弊社がどのようにこの問題を解決したかをぜひご覧ください。

openresty-minifiers の設計:3 つの制約をどう同時に満たすか

問題の構造を正しく理解することで、どのようなアプローチが真の解決策となるかが見えてきます。

これら 3 つの要件(構文の正確性、ストリーミング処理、固定メモリ使用量)は、個別に最適化できるものではなく、互いにトレードオフの関係にあります。

  • 構文の正確性を高めるには、より多くのコンテキスト情報を保持する必要があり、これは O(1) メモリ(固定メモリ使用量)の要件と相反します。
  • ストリーミング処理はインクリメンタルな出力を基本としますが、一部の構文変換(相対パスの書き換えなど)では前方参照(先読み)が必要となり、これは純粋なストリーミングの原則と矛盾します。
  • 各言語の完全な解析状態を維持すること自体、専門的なエンジニアリングリソースの投入を必要とします。

これら 3 つの要件をすべて満たす真の解決策は、JS、CSS、HTML それぞれに対して、専用のストリーミング対応・有限状態マシンを設計し、それをネイティブコード(Nginx フィルターモジュール)としてコンパイルする必要があります。これにより、処理速度がリクエスト処理におけるボトルネックになることを防ぎます。 これは、既存のツールを組み合わせて構築できるようなソリューションではなく、ゼロから設計することが求められるアプローチなのです。

単一コア 120 MB/s、O(1) メモリ――この数字の現場的な意味

openresty-minifiers は、OpenResty Inc. がこのようなユースケース向けに開発した専用のプライベートライブラリです。JS、CSS、HTML の 3 つの独立した minifier モジュールが含まれており、すべて Nginx の output filter として実装されています。

主要なパフォーマンス指標:

  • JS minifier シングルコアスループット:120+ MB/s(テスト環境:Core i9-13900K)
  • 時間計算量:O(n)(n はレスポンスボディ長)
  • 空間計算量:O(1)(デフォルトで 8 KB の固定バッファを使用)

公開ベンチマークデータ:https://openresty.org/misc/re/bench/

これらの数値がエンジニアリングにおいて持つ意味は、次の通りです。

120 MB/s という処理速度は、シングルコアあたり約 960 Mbps の minification 能力に相当します。一般的なギガビットネットワーク環境において、シングルコアの処理能力はすでに帯域幅に匹敵するため、minification がリクエスト処理のパフォーマンスボトルネックになることはありません。10 Gbps 以上の広帯域環境では、マルチコアによる並列処理を通じて、処理能力をリニアにスケールさせることが可能です。

O(1) のメモリ使用量であることは、このモジュールがレスポンスボディのサイズに関わらず安全に動作することを意味します。つまり、巨大なファイルのためにホワイトリストを設定したり、単一の極端に大きなファイルによってワーカープロセスがクラッシュしたりする心配がありません。 メモリ使用量が予測可能であるため、キャパシティプランニングが非常に容易になります。

本ライブラリは、内部で OpenResty Inc. が自社開発した DFA 最適化アルゴリズムに基づく正規表現コンパイラ(or-regex)を使用しています。これこそが、O(1) のメモリ使用量を維持しつつ、120+ MB/s のスループットを達成する鍵となっています。お客様の環境でこれらの数値を検証したい場合や、お使いのトラフィック規模への適合性を評価したい場合は、弊社技術チームまでお問い合わせください

5 分で動かす:最小構成の nginx 設定例

本ライブラリはプライベートパッケージとして配布されており、利用には有効なサブスクリプショントークンが必要です。依存関係である replace-filter-plus モジュールも、パッケージマネージャーによって同時にインストールされます。

# apt (Ubuntu/Debian)
sudo apt-get install -y openresty-minifiers replace-filter-plus-nginx-module-1.21.4

# yum (RHEL/CentOS)
sudo yum install -y openresty-minifiers replace-filter-plus-nginx-module-1.21.4

設定は、グローバルな事前読み込みhttp ブロック内)と、location ごとの有効化(特定の location ブロック内)の 2 つの階層で構成されます。

JS minifier を例に説明します。

http {
    # フィルタモジュールを読み込む
    load_module /usr/local/openresty/nginx/modules/ngx_http_replace_filter_module.so;

    # minification プログラムを事前コンパイルする。init フェーズで完了するため、リクエストのレイテンシに影響はない
    replace_filter_preload /usr/local/openresty-minifiers/lib/min-js.so
                           /usr/local/openresty-minifiers/tpls/min-js.tpl;

    init_by_lua_block { require "resty.replace" }

    server {
        location ~ \.js$ {
            replace_filter_types application/javascript;
            replace_filter_max_buffered_size 8k;

            access_by_lua_block {
                local ok, err = require "resty.replace".pick("min-js")
                if not ok then
                    error("failed to pick replace prog: " .. err)
                end
            }
        }
    }
}

CSS および HTML minifier の設定構造はまったく同じです。.so.tpl のパス、および pick() の引数を、それぞれ対応する min-css / min-html に置き換えるだけです。3 種類の minifier はそれぞれ独立して読み込み、必要に応じて組み合わせることが可能です。

本番公開前に知っておくべき 4 つのこと

MIME タイプはアップストリームと一致させる必要があります。 replace_filter_types は、デフォルトでは text/html のみを処理します。アップストリームが標準の application/javascript ではなく text/javascript を返す場合は、設定ファイルで次のように明示的に宣言する必要があります。

replace_filter_types application/javascript text/javascript;

curl -I を使用してアップストリームが実際に返す Content-Type を確認した上で、対応する MIME タイプを設定することを推奨します。

replace_filter_max_buffered_size は、通常調整する必要はありません。 デフォルト値の 8 KB で、ほとんどのユースケースにおいて十分です。このパラメータは、フィルターモジュールが複数のバッファにまたがる構文を処理する際に使用する最大バッファ量を制御するものであり、レスポンスボディ全体の最大処理サイズを定義するものではありません。つまり、たとえ 10 MB の JavaScript ファイルであっても、使用されるメモリは一定の 8 KB のみです。

Last-Modified ヘッダはデフォルトで削除されます。 圧縮によってレスポンスボディの内容が変更されるため、replace_filter_last_modified のデフォルト設定は clear となっています。CDN のキャッシュ戦略が Last-Modified ヘッダに依存した条件付きリクエスト(conditional request)を行っている場合、このデフォルトの挙動がキャッシュヒット率に与える影響を評価する必要があります。ヘッダを保持したい場合は、次のように設定します。

replace_filter_last_modified keep;

まず低リスクな location で段階的に有効化することを推奨します。 本番環境で実績のあるツールであっても、新たに追加するフィルターモジュールは、まずクリティカルではないパスで数日間トラフィックを流し、アップストリームのコンテンツとの間に特殊な互換性の問題がないことを確認した上で、全体に展開すべきです。

結び

まとめると、コード圧縮のようなタスクを Nginx のランタイムで処理することは、多大なメリットをもたらす一方で、基盤となる最適化に関する高度な能力が求められます。

本稿で紹介した効率的な圧縮ソリューションは、現在 openresty-minifiers モジュールとしてパッケージ化されており、OpenResty XRay のプライベートライブラリコレクションに収録されています。同シリーズには他にも、Redis、HTTP、Kafka 向けの高性能な C 実装クライアントライブラリ、最適化された LuaJIT エンジン、ロックフリーアーキテクチャを採用した動的メトリクス収集モジュールなどが含まれます。これらはすべて、OpenResty のランタイムにおいて、オープンソースのソリューションでは到達困難なレベルのパフォーマンスを実現するという、共通の課題を解決するものです。全リストはプライベートライブラリコレクションにてご確認いただけます。

本稿で解説したような課題に直面されている場合は、右下の「お問い合わせ」より弊社エンジニアチームにご連絡ください。導入プランやサブスクリプションに関する情報をご案内いたします。

著者について

章亦春(Zhang Yichun)は、オープンソースの OpenResty® プロジェクトの創始者であり、OpenResty Inc. の CEO および創業者です。

章亦春(GitHub ID: agentzh)は中国江蘇省生まれで、現在は米国ベイエリアに在住しております。彼は中国における初期のオープンソース技術と文化の提唱者およびリーダーの一人であり、Cloudflare、Yahoo!、Alibaba など、国際的に有名なハイテク企業に勤務した経験があります。「エッジコンピューティング」、「動的トレーシング」、「機械プログラミング」 の先駆者であり、22 年以上のプログラミング経験と 16 年以上のオープンソース経験を持っております。世界中で 4000 万以上のドメイン名を持つユーザーを抱えるオープンソースプロジェクトのリーダーとして、彼は OpenResty® オープンソースプロジェクトをベースに、米国シリコンバレーの中心部にハイテク企業 OpenResty Inc. を設立いたしました。同社の主力製品である OpenResty XRay動的トレーシング技術を利用した非侵襲的な障害分析および排除ツール)と OpenResty XRay(マイクロサービスおよび分散トラフィックに最適化された多機能ゲートウェイソフトウェア)は、世界中の多くの上場企業および大企業から高い評価を得ております。OpenResty 以外にも、章亦春は Linux カーネル、Nginx、LuaJITGDBSystemTapLLVM、Perl など、複数のオープンソースプロジェクトに累計 100 万行以上のコードを寄与し、60 以上のオープンソースソフトウェアライブラリを執筆しております。

翻訳

英語版の原文と日本語訳版(本文)をご用意しております。読者の皆様による他の言語への翻訳版も歓迎いたします。全文翻訳で省略がなければ、採用を検討させていただきます。心より感謝申し上げます!