仕事中の問題と解決メモ。

最近はPythonとGoogle Cloud Platformがメイン。株式会社ビズオーシャンで企画と開発運用、データ活用とか。 http://mstdn.bizocean.co.jp/@uyamazak https://github.com/uyamazak/

ヘッドレス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がこうなら仕方ないよねと諦められる。

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


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

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

bufferings.hatenablog.com

次に日本語が表示できないので、Dockerに日本語フォントをインストールした。フォントはGoogleフォントからかき集めた。

Chrome自体は、GoogleからDebian用のdebファイルをダウンロードしてきて依存を解決するためにgdebiを使ってインストールした。最初gdebiを使わずに深い沼にはまりかけた。特にDockerだといろんなライブラリがないのでハマる。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をホスティングしてくれればいいのに。

早速App Engineのmanaged SSLを有効化してみる

Let's Encryptから大きくなったSSL自動化の流れから、いつか来るだろうと思ってたのがついに来た。さっそく有効にした。

cloudplatform.googleblog.com

既存のプロジェクトはCloud Consoleで設定が必要。簡単だった。

f:id:uyamazak:20170919101409p:plain

証明書は思っていた通りLet's Encrypt発行のものだった。


今度は、HTTPロードバランサーの方も対応してくれるのを待つ。

Raspberry Piで既存のコードを利用して複数のLEDチカチカさせたり、ブザーを鳴らしたりする

研究開発という建前で経費でRaspberry pi 3を買った。

いろいろ揃えるのが面倒なので下記のセットを購入。


Google Assistant SDKを入れていろいろやろうとしたけど、まだ英語しか使えず勇気を振り絞って「Ok,Google」といっても、私の発音では反応してくれないのが辛すぎるので、まずはLEDをチカチカさせるLチカから始めようと入門書と、スターターキットを買った。


本の通り、まずは単体でLEDを光らせるのは30分もかからず終わってしまったので、せっかくなので、他のシステムと組み合わせてたくさんチカチカさせようと思った。

そして考えた。データ収集を行っているoceanusではbizoceanの各種イベント(ページビューとか、書式ダウンロードとか)をGoogle Cloud Pub/Subに流しているので、それを受信して種類によっていろんなLEDを光らせればいいと。

github.com

Google Cloud Pub/Sub とは  |  Cloud Pub/Sub  |  Google Cloud Platform


と、やろうとしたときにはRaspbery pi 3は新人の勉強用になってしまったので、以前いた人が買って引き出しに封印されていた古いRaspbery pi Model B+でやることにした。GPIOは全く一緒なので処理がめっちゃ遅い以外は特に不都合がなかった。

で、できたやつがこれ。

ページビューで白、書式ダウンロードで緑、会員登録で黄色、会員退会で赤とブザーが鳴る。

f:id:uyamazak:20170914105254j:plain

f:id:uyamazak:20170914105300j:plain

f:id:uyamazak:20170914105304j:plain


コードは基本的にはrevelationを使ってるけど、一部下記のように書きたしたりしている。

ラズパイ操作用

from RPi import GPIO

# 前回が異常終了だと起動時にエラーで起動できないので最初にやってしまう。それはそれでエラーが出るけど気にしない
GPIO.cleanup()


# 使うGPIOのポートと繋げたLEDの色の頭文字でdictにして、指定しやすくした
GPIO_PORTS = {"w": 22,
              "y": 23,
              "g": 24,
              "r": 25,
              }
# 起動時のセットアップ
for key, num in GPIO_PORTS.items():
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(num, GPIO.OUT)

# 光らせる関数を作る
def flash_led(key, time=0.01):
    GPIO.output(GPIO_PORTS[key], GPIO.HIGH)
    sleep(time)
    GPIO.output(GPIO_PORTS[key], GPIO.LOW)

こっちが光らせる関数を呼び出す部分を抜粋。少ないしめんどいからif文。
イベントの重要度によって光る時間も変えている。

if data.get("evt") == "pageview":
    logger.info("{}:pageview".format(data.get("dt")))
    flash_led("w")
    if "/download/complete" in data.get("url"):
        logger.info("{}:download".format(data.get("dt")))
        flash_led("g", 1)
    if "/entry/complete" in data.get("url"):
        logger.info("{}:entry".format(data.get("dt")))
        flash_led("y", 1)
    if "/quit/complete" in data.get("url"):
        logger.info("{}:quit".format(data.get("dt")))
        flash_led("r", 0.5)

問題としては、Googleの公式Pub/Subクライアントが重いせいか、ラズパイB+では処理が追い付かずどんどん溜まってしまい、しばらくするとスレッドが起動できずに死亡するところ。

おそらく3で動かせば大丈夫なはず。

1日もかからない作業だったけど、GPIOで3VのON、OFFができることは感覚的に理解できた。

次はスターターキットの各種センサーを使った何かをやってみる。

pythonのslackbotを使ってJSONを整形するボットを作る

新しく入ってきた人にPythonでSlack上の会話を翻訳するbotを作るにあたり、まずは自分でも作ってみた。

yfp5521.hatenablog.com


使ったライブラリはこれ。普通にpipで入れられてすぐ使える。
GitHub - lins05/slackbot: A chat bot for Slack (https://slack.com).


もう一つGoogle Cloud Natural Language APIの結果を返す動作確認用のボットも作ったけど、ぐちゃぐちゃなので公開しづらい。



下記をjson.pyとして、slackbotのインストール時に作ったpluginsに置く。

# coding: utf-8
from slackbot.bot import respond_to     # @botname: で反応するデコーダ
# from slackbot.bot import listen_to      # チャネル内発言で反応するデコーダ
# from slackbot.bot import default_reply    # 該当する応答がない場合に反応するデコーダ
import json
import logging
logger = logging.getLogger(__name__)


@respond_to('^[\{\[].*[\}\]]$')
def json_listen_func(message):
    json_text = message._body.get("text")
    logger.debug(json_text)
    try:
        json_obj = json.loads(json_text)
    except Exception as e:
        logger.debug(e)
    else:
        text = json.dumps(json_obj,
                          ensure_ascii=False,
                          indent=2,
                          sort_keys=True)
        users = message._client.users
        attachments = [{"text": text,
                        "author_name": users.get(message._body["user"], "none"),
                        
        message.reply_webapi("",
                             attachments=attachments,
                             as_user=True,
                             in_thread=False)

slackbot_settings.pyは下記のような感じ。

# coding: utf-8
# botアカウントのトークンを指定
API_TOKEN = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# このbot宛のメッセージで、どの応答にも当てはまらない場合の応答文字列
DEFAULT_REPLY = "ルームに招待してくれたら自然言語解析、ダイレクトメッセージでJSONの変換ができるよ"

# プラグインスクリプトを置いてあるサブディレクトリ名のリスト
PLUGINS = ['plugins.json']

ERRORS_TO = "uyamazak"

DEBUG = True

ざっくりと条件として、@respond_toで最初と最後が{}、[]だけのときに反応するようにしている。

どっかのサーバーで動かすと下記のようになる

f:id:uyamazak:20170912135633p:plain

よくデータをBigQueryにJSONでぶち込んでるけど、その中身をさっと確認したいとき、今まではWEBサービスをググって使ってたけどslackでもできるようになった。


新しく入ってきた人にLinux、vim、Dockerなど開発環境を叩き込む1週間

詳しくは本人のブログだけど


yfp5521.hatenablog.com


大きめのSlerに1年ちょっといた人がビズオーシャンに入ってきてくれた。


bizocean本体はクラウドではなく、データーセンターにあったり、CentOS6だったりPHPだったり、いろいろと退屈なので、私がoceanusの開発でやっている環境をとりあえず叩き込んだ。

WEB系の開発がやりたいとのことだったので、まずは全体像を理解してもらうという目的。


具体的には

  • 作業はすべて自分のPC(Windows)から社内のサーバー(Ubuntu)にSSHでつなげて行う。
  • 効率化のためにzsh使う
  • エディタはvimIDEなんぞいらん市ね。
  • tmux使う
  • 開発はすべてDockerのコンテナを使って構築、動作までさせることで、サーバー構成の明文化、本番まで一気通貫を目指す。


と、Linuxすらあんまり触ったことない人にとって、いたるところに無理がある内容だったけど、実際にDockerコンテナを立てるのを何回かやってもらって、全体のイメージはつかんでくれたっぽい。


5日目ぐらいに、部長から社内に英語しかしゃべれない外国人がいるので、翻訳bot作ってよと来たので、ちょうどいいと思い作ってもらった。


横から口出しをしながら、Google Translate APIを使うことで、多言語に対応したボットを1日半ぐらいで完了し、その外国人との会話テストまでやってもらえて、1週間で小さい成功体験ができたのはよかったと思う。


PyQも試しに契約して進めてもらってるけど、実際に役立つものが作れるなら作った方が面白いと思った。



次は、Linuxの勉強もかねて、会社に転がっているRaspbery pi 3の初期インストールから、Dockerの動作環境づくりまで行い、作ったbotをそこで動かしてもらおうと思う。

買ったのは下記のフルセット。SDカードは彼用に新しく買った。


早速インストールしたけど動かないと言われ、もしかしたらSDカードの相性問題かと思ったので、私が試しにやったら問題なく動いた。

Linux教材としてはラズパイの当初の目的だけあって、かなりいいと思う。安いから1人1台買ってもいいし、SDを買い足すことで使いまわしも簡単。

SDはこれ買った。もちろん使えるかどうか私は保証できない。