こんにちは。サイボウズ Officeの開発を担当しています、佐野です。
みなさんよくご存知のZipファイル。ユーザーとして展開や圧縮の方法はよくわかっていても、プログラムとしてどうやって作られているのかを知る人はそう多くないと思います。今回は、この「どうやって」について開発経験をもとにお話します。具体的には、弊社がクラウドサービスとして提供しているサイボウズ Office on cybozu.comでの実現の過程を取り上げます。
今回扱う内容は広範囲に渡っていて、設計、実装、パフォーマンスチューニングなどに触れます。開発当初の期待とは裏腹に一筋縄ではいきませんでした。実際のソフトウェア開発ではよくあることですね。その点についても後ほど詳しく紹介します(実はここが一番面白い所です)。内容は全般的にプログラマー向けですが、プログラマーでない方も、開発過程の雰囲気だけでも楽しんでいただければ幸いです。
それでは、実現までの過程を見ていきましょう。
実現したい機能
まず、今回実現したい機能を簡単に説明します。
サイボウズOfficeには、ユーザー間でファイルを共有する機能があります。扱うファイルが多くなってくると、複数のファイルを一緒にダウンロードしたいケースが出てきます。そのような場合、Zipファイルとしてファイルをまとめてダウンロードできると便利そうです。このまとめてダウンロードする機能が実現したい機能です。
ユーザーはダウンロードを要求した時に即座にダウンロードがはじまることを期待します。そうでないと、単純に不便だからです。ということは、前提として、次のアプローチは取れません。
- Zipファイルの生成結果を一時ファイルに書き出していき、Zipファイルが完成したらダウンロードを開始する。
Zipファイルの生成が終わるまでレスポンスを返せないためです。対象ファイルのサイズが大きくなればなるほどレスポンスが遅くなるのは目に見えています。
また、当然、事前にZipファイルを作っておくことはできません。ユーザーは任意の組み合わせのファイルをZipファイルとしてダウンロードします。組み合わせは無数にあるため、事前にZipファイルを作成しておくのは現実的ではありません。
つまり、ユーザーの要求時にその場でZipファイルを生成する必要があります。
cybozu.comのインフラ
実現したい機能のイメージが見えてきたので、早速実装したくなるところですが、アプリケーションを動かすことになるインフラについて少し考えを巡らせました。というのも、cybozu.comのインフラには特有の癖があり、今回のように比較的容量の大きいファイルを扱う機能を実装する場合、注意を必要とするためです。
サイボウズ Officeから見たcybozu.comのインフラの特徴は次の通りです。
マルチテナント: アプリケーションはマルチテナント環境上で動作します。つまり、マシンのリソース(CPU、メモリー等)を複数のテナント(企業)で共有します。複数の企業間でリソースを共有しても性能上の問題が出ないように、リソースは適切に割り当てられています。
分離構成: Webサーバとストレージは別のサーバに分かれています。WebサーバにはサイボウズOfficeのプログラム(CGI)が配置されており、データベースや冒頭で説明したサイボウズOfficeのファイル共有機能が管理するファイル群はストレージにあります。Webサーバとストレージはネットワークで接続されています。すなわち、Webサーバとストレージ間のネットワーク帯域は非常に貴重なリソースと言えます。
ページキャッシュ: サイボウズOfficeのデータベースのデータが、Webサーバのページキャッシュ上にあるかどうかで、性能が大きく左右されることがこれまでの運用経験からわかっています。そのため、データベースのデータがページキャッシュ上に乗るようにする、逆に言うと、追い出されないようにすることが、アプリケーションの動作の快適性を維持する上で重要な要因となります。先ほど説明した分離構成からも、ページキャッシュのヒットミスはペナルティが大きいそうだと理解いただけると思います。
ここで挙げた特徴は、特にサイボウズOfficeからの視点にフォーカスしており、特徴のごく一部を説明したに過ぎません。cybozu.comのインフラを含めた仕組みに興味を持たれた方はこちらをご覧ください。cybozu.comを支えるエンジニアのインタビュー記事で、cybozu.comのクラウド基盤を紹介しています。
課題
さて、もうお気づきかもしれませんが、何も対策をしない場合、次のような課題があります。
- Zipファイルの生成時に、ページキャッシュの汚染量が多ければ多くなるほど、自分のテナントのみならず、他のテナントにも性能上の影響が出る可能性がある。
設計方針
ここまでから、次の設計方針で対応することにしました。
ストリーミング生成: ユーザーのダウンロード要求と同時にZipファイルのダウンロードが開始されるようにする。
メモリー消費量の配慮: メモリーの圧迫を防止し、既存のページキャッシュを温存する。
ページキャッシュの制御: Zipファイルの書き出し対象のファイルを読み込む際にページキャッシュを汚さないようにする。
失敗...
設計方針が明確になったので、プロトタイプの実装に取りかかりました。
サイボウズOfficeではある処理における圧縮データの展開にzlibを使用しています。zlibにはminizipというZipファイルを作成するプログラムが同梱されています。これを使わない手はありません。
プロトタイプのサイボウズOfficeにminizipを組み込むことにしました。
しかし、残念ながら、minizipを組み込んだサイボウズ OfficeでZipファイルを生成させてみると、いくら試しても解凍ソフトで解凍できませんでした。
この問題を解決するには、Zipファイルをブラックボックスとしてとらえるのではなく、Zipファイルの仕様に踏み込んで理解する必要がありました。
Zipファイルの仕様
Zipファイルの仕様はこちらで公開されています。誰でも自由にアクセスできます。この仕様に従うことでZipファイルを作成できます。先ほど説明したminizipももちろんこの仕様に準拠しています。では何が悪かったのでしょうか。
minizipの問題
Windowsに搭載されているZipファイルの作成機能や先ほどのminizipなどでZipファイルを作成すると図のような構造になります。ここでは問題の説明のため、不必要なものは省略しています。(また、Zipファイルの仕様書を読まれた時にわかりやすいように仕様書での用語(英語)をそのまま使用しました。)
一番左がファイルの先頭で、まず、圧縮ファイルの各種情報が格納されるブロック(Local file header)があり、それに続いて圧縮ファイルのデータ(File data)が続きます。ここで重要なポイントは、この図からわかるように、圧縮対象のファイル(File data)よりも前に圧縮後のサイズ(Compressed size)が配置されなければならないことです。
このような構造で出力するには、出力先がハードディスクのようなランダムアクセス可能デバイスでなければ不可能です。なぜなら、圧縮後のサイズは、実際に圧縮してみないと確定できないにも関わらず、圧縮データの前の位置に配置されることを要求しているからです。
全く不可能かというとそうではなく、無理をすれば可能です。例えば、圧縮したファイルデータ(File data)が完成するまでメモリーに一時的に記録しておいて、圧縮後のサイズが確定した後に、圧縮後のサイズを先に出力してから、圧縮データを書き出せばフォーマットに従った構造になります。ただし、そうすると、圧縮対象のファイルサイズに比例してメモリーを消費してしまいます。これは先ほどの方針の2つ目(メモリー消費量の配慮)に反するため採用できません。
minizipでのストリーミング出力に失敗した原因は、このランダムアクセスに起因するものでした。つまり、minizipはランダムアクセス可能な出力先を前提として作りこまれていて、ストリーミングでの出力と相性が悪いからでした。具体的には、はじめは圧縮後のサイズの領域に詰め物を入れておいて、Zipファイルの末端まで書き出した後に、その詰め物までシークし、確定した圧縮サイズを書き込む実装になっていました。
ストリーミング実現への光
Zipファイルの仕様書を注意深く読むと、解決方法が載っていました。ストリーミング出力に適した出力形式が用意されていました。
以下にその部分を引用します。
4.3.5 File data MAY be followed by a "data descriptor" for the file. Data descriptors are used to facilitate ZIP file streaming.
(省略)
4.3.9 Data descriptor:
crc-32 4 bytes compressed size 4 bytes uncompressed size 4 bytes
4.3.9.1 This descriptor MUST exist if bit 3 of the general purpose bit flag is set (see below). It is byte aligned and immediately follows the last byte of compressed data. This descriptor SHOULD be used only when it was not possible to seek in the output .ZIP file, e.g., when the output .ZIP file was standard output or a non-seekable device.
この説明を図にすると、このようになります。
ポイントはminizipの問題で示した図と順序が逆転しているところです。つまり、以前の図では、圧縮データサイズ、圧縮ファイルだった順が、その逆になっています。
この形式をとることで、ファイルの圧縮データ(File data)の前にあった「Local file header」には圧縮後のサイズ(Compressed size)を書き出す必要が無くなり、圧縮データの後に続く「Data descriptor」と呼ばれるブロックの中に圧縮後のサイズを書き出せば良い、ということになります。
これなら効率的なストリーミング出力が実現できそうです。
圧縮アルゴリズム
圧縮データはどうやって作るのでしょうか。仕様書を読んでみます。
4.1.3 Data compression MAY be used to reduce the size of files placed into a ZIP file, but is not required. This format supports the use of multiple data compression algorithms. When compression is used, one of the documented compression algorithms MUST be used. Implementors are advised to experiment with their data to determine which of the available algorithms provides the best compression for their needs. Compression method 8 (Deflate) is the method used by default by most ZIP compatible application programs.
要約すると、圧縮アルゴリズムはいくつかサポートされているが、多くの場合、Deflateが用いられる、とあります。また、無圧縮のZipファイルが作れることもこの仕様から読み取れます。今まで無圧縮のZipファイルが作れるとは知りませんでした。
Deflateの特徴で見逃せない点はストリーミング出力との相性です。もし、ストリーミング出力に不適であれば、今までの苦労は水の泡です。
結論としては、Deflateはストリーミング出力と相性は良いです。今回はじめて知ったのですが、Deflateは全データを読み込まなくても、圧縮結果を出力しはじめられる圧縮アルゴリズムだからです。
これで効率的なストリーミング出力の実現の見通しが立ちました。
仕切り直し
結局、他のZip生成ライブラリを探すことはせず、独自に実装することにしました。というのも、Zipの仕様書を参考にプロトタイプ実装を進めると、Zipファイル生成の処理自体は割とコンパクトに実装できる見通し(C++で500行程度)が立ったためです。(ただし、Deflate圧縮のためにzlibは利用しました) また、開発着手からリリースまで半年の期間があったため、品質面での安定化は可能と判断し、独自実装に踏み切りました。
実装の際には、Zipファイルの仕様書に加えて、こちらやこちらの解説もとても参考になりました。
性能チューニング: ページキャッシュの制御
ストリーミング出力の見通しが立ったので、あとは製品への搭載に向けて完成度を高めるのみです。しかし、ここではもう一歩快適性を追及することを考えます。
OSは、アプリケーションが明示的に指示しない限り、ファイルを読み込むだけでファイル内容を自動的にページキャッシュにキャッシュしようとします。しかし、今回実装する機能のように、ファイルをまとめてダウンロードする場合、再度同じファイルが参照される可能性は低いと考えられ、キャッシュしても無駄になる確率が高いです。cybozu.comのインフラについて説明した通り、cybozu.comのインフラ上ではページキャッシュはサイボウズOfficeのパフォーマンスに大きなインパクトを与えます。従って、ページキャッシュの意図的な制御が重要になると考えました。
ページキャッシュは、posix_fadvise
で制御できます。最近のLinuxであれば利用できます。サイボウズOfficeでは、Zipファイルの同梱対象のファイルを読み込んだ後に、posix_fadvise
を実行し、必要無くなったページキャッシュを明示的に解放するようにしました。
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
Webアプリケーションでここまでページキャッシュを制御しているのは珍しい方だと思います。
実はこの手法は、社内向けのLinuxシステムプログラミングの勉強会で紹介されていたテクニックが発端になっています。
勉強会の内容を少し余談で続けます。cybozu.comでは独自のバックアップ機構があるのですが、バックアップ時間の短縮を狙って、ファイルシステムを迂回して、ストレージのディスクイメージを直接コピーするように実装されています。つまり、ストレージのデバイスファイルを直接操作しています。そのようなシーンにおいて、ページキャッシュがバックアップ時間にインパクトを与えるということがわかっていて、ページキャッシュの明示的な制御が重要とのことでした。これがヒントになって、ここで紹介したアイデアを思いつきました。
おわりに
長くなりましたが、実際の製品開発を例にZipファイルの生成を実現するまでの過程を紹介しました。Zipファイルの生成の仕組みとソフトウェア開発の過程を少しでも身近に感じていただけたなら幸いです。