GAミント至上主義

Web Monomaniacal Developer.

ヘッドレスChrome + Node.js + express + DockerでPDF生成サーバーを作る

URLを渡すとChromeがPDFを作って返してくれるサーバーを作るお話。


業務用ソフトではよくPDFの帳票が必要になる。今作っているサービスでも必要になった。

WEB系だとHTMLは生成しやすいので、それをPDFに変換するwkhtmltopdfなどのコマンドラインツールや、Ruby製だとThinreportsなどのソフトウェアを使う必要があった。

あと選択肢としては、Google SpreadSheetやDriveのAPIを組み合わせてもできなくはないけど、やってみるとAPIが遅いのでかなり待たされるし、細かい調整は望めない。

wkhtmltopdf

オープンソース PDF 帳票ツール for Ruby, Rails | Thinreports

でも、今年4月に公開されたChrome59以降は、コマンドラインChromeを起動し、PDFを生成することもできるHeadless Chromeという機能が追加された。

ヘッドレス Chrome ことはじめ  |  Web  |  Google Developers


これの何がうれしいかというと、普段使っているChromeの印刷→PDFと同じ出力が得られるし、js、CSSの挙動も普段のChromeと同じになるはずので、それぞれのライブラリの癖を覚える必要もなければ、細かい差異にいら立つこともない。

Chromeなので改善、メンテナンスも継続的に行われるし、Linuxでも安定するだろうと予想できる。

もし、何か表示とかに変なところがあっても、普段お世話になっているChromeがこうなら仕方ないよねと諦められる。

他のHTML→PDF変換ソリューションだとこんな条件はそろわない。



今回はやらないけど、スクリーンショットも取れるので、昔だったら大変だったWEBサイトのサムネイル生成サーバーもすぐ作れそう。


ということで、Dockerに入れてchromeコマンドを使ってみると、問題が多発。

まずDocker内での起動には--no-sandboxが必要で、下記を参考にした。

bufferings.hatenablog.com

次に日本語が表示できないので、Dockerに日本語フォントをインストールした。フォントはGoogleフォントからかき集めた。WEBフォントのままでも使えないことはないが、毎回ダウンロードしてしまうので、サーバーにダウンロードしてしまった方が表示は数百ミリ秒早い。

Chrome自体は、GoogleからDebian用のdebファイルをダウンロードしてきて依存を解決するためにgdebiを使ってインストールした。最初gdebiを使わずに深い沼にはまりかけた。デスクトップ版Ubuntuなどでは特に問題にならないが、DockerだとGUI系のいろんなライブラリが入ってないのでハマる。gdebiすごい。

www.google.co.jp


Dockefileから抜粋すると下記。後でexpressのサーバーを使うのでnodeをベースにしている。抜粋なのでこれだけだと何にもおきないはず。

FROM node:8.6

# Install Chrome
RUN apt-get update && \
    apt-get install -y gdebi

COPY google-chrome-stable_current_amd64.deb ./
RUN gdebi --non-interactive google-chrome-stable_current_amd64.deb

# Install fonts
COPY fonts /usr/share/fonts

やっと動いたと思ったら、今度は上下に日付、タイトルなどの余計なヘッダー、フッターが入ってしまう。

chromeコマンドラインフラグを見ると、そこは現時点では変えられないらしい。

使えるコマンドラインフラグは下記ソースを読めとのこと。

https://cs.chromium.org/chromium/src/headless/app/headless_shell_switches.cc

どうにかならんのかと思ったら、検索していると同じ悩みの人がすでに作ってくれていた。

Nodeを使ってプログラム側から操作すれば、そのオプションも使えるらしい。

www.npmjs.com

Motivation

google-chrome currently have option to render pdf files when used with headless option. But this option contains hardcoded adding header and footer to page rendering it unusable for pdf generation. This module allows to generate it without those elements.

まさに同じ。


これを入れたところ、Dockerには対応していないので一部書き換える。


node_modules/chrome-headless-render-pdf/index.js

async spawnChrome() {
        const chromeExec = this.options.chromeBinary || await this.detectChrome();
        this.log('Using', chromeExec);
        const commandLineOptions = [
             '--headless',
             `--remote-debugging-port=${this.port}`,
             '--disable-gpu',
             '--no-sandbox'
            ];

上記した--no-sandboxを追加

node_modules/chrome-headless-render-pdf/index.js

    generatePdfOptions() {
        const options = {};
        if (this.options.landscape !== undefined) {
            options.landscape = !!this.options.landscape;
        }

        if (this.options.noMargins) {
            options.marginTop = 0;
            options.marginBottom = 0;
            options.marginLeft = 0;
            options.marginRight = 0;
        }

        if (this.options.includeBackground !== undefined) {
            options.printBackground = !!this.options.includeBackground;
        }
        options.paperHeight = 11.70;
        options.paperWidth = 8.26772;

        return options;
    }

デフォルトの出力サイズがアメリカンなレターサイズなのでoptions.paperHeight、options.paperWidthを単位インチのA4縦サイズを指定。

他のオプションは下記ページで確認できる。

Chrome DevTools Protocol Viewer - Page

とりあえず動いたから書き換えて使ってるけど、もうちょっといい感じにしてchrome-headless-render-pdfにプルリクエストを送ってみたいもの。

あとはこいつをexpressから叩いて、一時ファイルに書き出して、それを返しているけど、chromeの初回起動が重いからどうするかとか、たまに止まるとか、まだ非同期の処理の塊であるNodeの開発にも慣れていないし、ぐちゃぐちゃなので落ち着いたらまとめるつもり。


Node.js超入門

Node.js超入門

node.jsで作るWebサーバー

node.jsで作るWebサーバー

Google Cloud DatalabでPython3が使えるようになってた

自社では、BigQueryのデータの分析とかには主にGoogle Cloud Datalabを使っている。

cloud.google.com

現在のドキュメントでは、GCEを使う方法しかのっていないが、公開当初はローカルのdockerで動かせる方法が書いてあって、現在も下記のようなコマンドで利用可能です。

sudo docker run -d -p "8080:8080" \
    -v "/path/to/content:/content" \
    gcr.io/cloud-datalab/datalab:local-20170928

他のイメージも下記から確認できる。要ログイン?

https://console.cloud.google.com/gcr/images/cloud-datalab/GLOBAL

ローカル(実際には社内サーバー)とGCE版の使い分けは、軽いやつはローカル、大量のメモリ等のリソースを使う場合はGCEとしています。

メモリ50GB以上のマシンもちょっと使うだけなら月数千円で使えるので、買うより便利。


Python2,3の切り替えは下記で可能です
f:id:uyamazak:20170929133240p:plain

GoogleのCloud Pub/SubをやめてRedisのPub/Subに戻した話

要点を言えば、公式Pythonライブラリでメモリの問題が2回発生した上に、バージョンアップでさらにコントロールできないものになったので使うのをやめた。


自社用データ収集のプロジェクトoceanusでは、データをBigQueryに保存するだけでなく、データのリアルタイム処理、ストリーミング処理用にPub/Subにも送信している。

github.com

例えば、コンバージョンとか特定のイベントが来たらメールするとか、Googleスプレッドシートに書き込むとか。


開発してからしばらくは同一ネットワーク内(GKE)に自分で立てたRedisのPub/Subで行っていたが、Google Cloud Pub/Subの方が、自分でスケーリングとか考えなくていいし、どこからでもアクセスできたり、他のGCPサービス(Cloud Functions)と連携もしやすいので移した。

しかし、Cloud Pub/Subの公式Pythonライブラリは曲者(当時はバージョン0.25、2017/9/27現在0.28.3)で、メモリリークはしまくるし、特殊なライブラリが必要なようで、Dockerを使っているがイメージサイズの小さいalpine系のPythonでは動かくことができず、Debianベースのイメージを使う必要もあった。

メモリの使用量は同じマシンで稼働できるコンテナの数と直結するので、コスト面での影響もかなり大きい。


google-cloud-python/pubsub at master · GoogleCloudPlatform/google-cloud-python · GitHub


メモリリークは、別プロセスを立ち上げてしばらくしたら消して再起動する方法でなんとかなったけど、精神的に大分消耗した。


uyamazak.hatenablog.com



しばらくして、公式のライブラリのアップデート(0.28)があり、イメージ新しくした際に動かなくなったので、書き直すことにした。

これが大幅な変更で、理解するのにも時間がかかった。コミットメッセージは「Pub/Sub API Redesign」となっている。


Pub/Sub API Redesign (#3859) · GoogleCloudPlatform/google-cloud-python@4a8e155 · GitHub


特に大きいのが、メッセージを受け取る処理のSubscribeで、これまでは1件ずつメッセージ取り出してこちらで処理ができたが、コールバック関数を渡して処理させる形となった。

# Define the callback.
# Note that the callback is defined *before* the subscription is opened.
def callback(message):
    do_something_with(message)  # Replace this with your actual logic.
    message.ack()

# Open the subscription, passing the callback.
subscription.open(callback)

その処理は非同期(non-blocking)のため、そのあとにスリープを行うことで、続けてメッセージを処理することができるとなっている。
サンプルだと下記のように、最後でsleepの無限ループを行っている。

最初スリーブしているのに処理は半永久的に進むのが不思議だった。

def receive_messages(project, subscription_name):
    """Receives messages from a pull subscription."""
    subscriber = pubsub_v1.SubscriberClient()
    subscription_path = subscriber.subscription_path(
        project, subscription_name)

    def callback(message):
        print('Received message: {}'.format(message))
        message.ack()

    subscriber.subscribe(subscription_path, callback=callback)

    # The subscriber is non-blocking, so we must keep the main thread from
    # exiting to allow it to process messages in the background.
    print('Listening for messages on {}'.format(subscription_path))
    while True:
        time.sleep(60)

python-docs-samples/subscriber.py at master · GoogleCloudPlatform/python-docs-samples · GitHub

私の書いているoceanus/revelationでほぼ同じように処理を行ってみると、社内サーバーでは問題なく動いたが、本番環境のGKEに乗せるとメモリを1G以上も使い果たし、他のプロセスも停止させてしまった。

FlowControlという処理制御用のオブジェクトがあったので、max_messagesは1にしてみたが、変わらない。

FlowControl.__new__.__defaults__ = (
    psutil.virtual_memory().total * 0.2,  # max_bytes: 20% of total RAM
    float('inf'),                         # max_messages: no limit
    0.8,                                  # resume_threshold: 80%
)

デフォルトでメモリ制限っぽい項目もあるようだが、3GB以下のマシンで最大2GBにも達していたから機能していなさそうだ。

google-cloud-python/types.py at master · GoogleCloudPlatform/google-cloud-python · GitHub


多い時で20/秒程度のメッセージが来ているので、おそらく大量のスレッドが立ち上がったのが原因だと思われる。が、そこの制御方法は見つからず、サービスを止めとくのも問題なので、調査はやめて、過去のRedis版に切り戻した。

大量のメッセージを受け取って、フィルターしてほとんど捨てる的な使い方は想定されていないのかもしれない。

Goで書いたPublisher側は特に問題がないので、Pythonクライアント特有なのかもしれないが、1年以内でメモリの問題が複数発生し、またクライアントライブラリも破壊的な変更が入るなど安定していないので、しばらくこの用途で使うのはやめようと思った。

Google Pub/Sub自体あまりユーザーがいないのかもしれない。

Googleも普通にRedisをホスティングしてくれればいいのに。