八発白中

技術ブログ、改め雑記

Quickdocs.org で学ぶCommon LispのWebアプリ運用ノウハウ

先日、QuickdocsというWebサービスを作りました。

アプリ部分はほとんどCommon Lispで書かれています。今までもいくつかCommon LispでWebアプリを書いたことはありますが、公開されたWebサービスはこれが初めてです。

公開当初は半日に1回落ちたり、表示が変になったりしていました。そこで2週間ほどかけて、不具合の原因を突き止めたり、それを修正して堅牢にしたり、レスポンス速度を改善したりしてきました。


試行錯誤を繰り返してきており、伝えたいことも多いです。書いてみるとかなり雑然としてしまったのですべての人向けにおすすめできるものではなくなってしまいましたが、少しでも他にCommon Lispの運用をしている人や後に続く人の助けになれば良いと思います。

ちなみに以前にも似たような記事を書きました。いくつかやり方が変わっていますが、少しは参考になるかもしれません。

Overview

Quickdocsの構成はシンプルです。

Webフレームワークとしてningleを使い、ClackFastCGIバックエンドを使っています。フロントサーバにはNginxを置いています。

レスポンスのキャッシュにSquidの導入も考えましたが、Nginx自身にキャッシュの機能がついていたのでそれを使っています。

Quickdocsの特殊なところではあるのですが、DBはありません。ユーザからの投稿を受け付ける機能がなく、トランザクションが不要なので、必要なデータはすべてS式でファイルに記録しておいて、サーバ起動時にロードするようにしています。

サーバはさくらVPSのメモリ1Gを使っています。1台でJenkinsやQuickdocsのステージング環境、バッチ処理なども行なっています。まだリソースには余裕はありますが、1台でなんでもこなすので、お互いの処理が干渉しないように注意する必要があります。この方法は後ほど紹介します。

以下では主にこの構成を前提に話を書きます。

サーバを起動

Common Lispでプロセスをdaemon化する方法はいくつかありますが、僕は使っていません。単純に--evalをつけてワンライナーでサーバを立ちあげています。

毎回起動するのを楽にするために、Makefileを書きました。

Makefile

以下はQuickdocsで使っているMakefileの抜粋です。sbclというマクロを作って、ワンライナーを書きやすくしています。

SERVER_PORT=8080
SWANK_PORT=4005
PROJECT_ROOT=$(abspath $(dir $(lastword $(MAKEFILE_LIST))))

define sbcl
	sbcl --noinform --disable-debugger \
		--eval '(pushnew #P"$(PROJECT_ROOT)/" asdf:*central-registry*)' \
		--eval '(progn $1)' \
		--eval '(progn $2)'
endef

start:
	$(call sbcl, \
		(ql:quickload :quickdocs) (ql:quickload :swank), \
		(quickdocs.server:start-server :mode :production :debug nil :server :fcgi :port $(SERVER_PORT)) \
		(swank:create-server :port $(SWANK_PORT) :style :spawn :dont-close t))

swank:create-serverは必須ではありませんが、デバッグや、後述するホットデプロイに必要です。

これで以下のように実行すればサーバが起動できます。

make -f /srv/www/quickdocs/Makefile start SERVER_PORT=10080 SWANK_PORT=4025

どのディレクトリにいるときでも大丈夫なように、PROJECT_ROOTはカレントディレクトリではなくMakefileがあるディレクトリにする必要があります。

もしHunchentootを使いたいなら、:serverの部分を:fcgiではなく:hunchentootなどにしないといけません。

Nginx

Hunchentootを使って直接リクエストを捌く気なら、SERVER_PORT=80を指定すればNginxなどは必要ありません。別ポートで起動して、リバースプロキシを使ったり、上記のようにFastCGIバックエンドを使うならNginxやApacheが必要です。

以下はNginxの設定の抜粋です。

server {
    listen 80;
    server_name quickdocs.org;
    access_log  /var/log/nginx/quickdocs_access.log;
    error_log   /var/log/nginx/quickdocs_error.log;

    error_page 500 502 503 504 /html/50x.html;

    location = /html/50x.html {
        root /srv/www/quickdocs/static/;
    }
    location ~* ^/(css|images|js)/(.+)$ {
        root /srv/www/quickdocs/static/;
        access_log off;
        expires max;
        break;
    }
    location ~* ^/(favicon.ico|robots.txt|apple-touch-icon) {
        log_not_found off;
        access_log off;
        root /srv/www/quickdocs_stage/static;
    }

    location / {
        fastcgi_pass 127.0.0.1:10080;
        fastcgi_connect_timeout 300;
        include fastcgi_params;
    }
}

ここでは以下のことを行なっています。

  • 50x系エラーが出たときに静的なエラーページを表示
  • 静的ファイルは直接配信
  • それ以外はFastCGIサーバに投げる

server_nameには自分が持っているドメインを指定し、DNSレコードを適切に設定しておきます。

上記のファイルを/etc/nginx/sites-available/quickdocsに置いてsites-enabledにシンボリックリンクを貼り、Nginxを再起動します。

これでサーバの80番ポートにアクセスしてサイトが表示されれば成功です。

死活監視

そのまま起動しておくと、いくらエラーハンドリングをしていても、メモリ不足などの致命的なエラーが出ればサービスが落ちてしまいます。そのための死活監視です。

僕は Supervisord を使いましたが、daemontoolsなど自分が慣れたものを使えばいいと思います。

Supervisorでは以下のようにcommandのところでmake startするようにすれば良いです。

[program:quickdocs]
command=make -f /srv/www/quickdocs/Makefile start SERVER_PORT=10080 SWANK_PORT=4025
numprocs=1
autostart=true
autorestart=true
user=quickdocs
redirect_stderr=true
stdout_logfile=/var/log/supervisor/quickdocs.log

良いことなのかどうかわかりませんが、僕はSupervisorでサーバの再起動も行なっています。

sudo supervisorctl restart quickdocs

起動時にプロセスIDとか記録しておいてkillするの面倒ですからね。

デプロイ

デプロイするのに毎回サーバにSSHするのは面倒なので、途中からデプロイツールを導入しました。

Fabric

僕はFabricを使いましたが、Rubyが好きな人はCapistranoが良いかもしれません。

以下はfabfile.pyの抜粋です。

from fabric.api import sudo, run, env, cd
env.hosts = ['yourquickdocs.org']
env.user = 'quickdocs'
env.directory = '/srv/www/quickdocs'
env.project_name = 'quickdocs'

def update():
    with cd(env.directory):
        run('git pull')

def restart():
    sudo('supervisorctl restart %s' % env.project_name, shell=False)

def deploy():
    update()
    restart()

def tail_access():
    run('tail -f /var/log/nginx/%s_access.log' % env.project_name)

def tail_error():
    run('tail -n 100 -f /var/log/apps/%s_error.log' % env.project_name)

これで fab deploy などとすればデプロイできます。

ホットデプロイ

始めの頃はデプロイするとき、素直にプロセスを再度立ちあげていたため、デプロイするたびに5秒くらいダウンタイムがありました。

けれど、せっかくのLispなのでサーバを無停止でデプロイしたいです。

サーバ起動時にswankサーバを立ち上げるようにしていれば、swank-clientというライブラリを使ってサーバに繋いで任意のコードを実行させられます。これを利用して以下のようにMakefileとfabfile.pyに追記します。

hot_deploy:
	$(call $(LISP), \
		(ql:quickload :swank-client), \
		(swank-client:with-slime-connection (conn "localhost" $(SWANK_PORT)) \
			(swank-client:slime-eval (quote (handler-bind ((error (function continue))) (ql:quickload :quickdocs))) conn)) \
		(sb-ext:quit))
def hot_deploy():
    with cd(env.directory):
        run('make hot_deploy SWANK_PORT=%s' % env.swank_port)

fab update hot_deploy などとすればホットデプロイできます。

サーバにテスト環境を立てる

ローカルでは動いたのにデプロイすると動かないみたいなことがあります。Lisp処理系の違いだったり、OSの違いだったり、依存パッケージが足りなかったり。そこで同じサーバか似たサーバにテスト環境を置いて一通りテストします。

このとき、テスト環境用に新しくユーザを作る (たとえばquickdocs_dev) か、Quicklispのdistを分けるほうが賢明です。これはテスト環境だけQuicklispの新しいバージョンを試してちゃんと動くかをチェックしたりするのに使います。

同じユーザでQuicklispのディレクトリだけを分けたいというときは、.sbclrcを複数用意して、--userinitの指定を変更すれば実現できます。

試してはいませんが、quicklispのディレクトリ自身をリポジトリに含めればRubyBundlerPerlCartonのような使い方ができるかもしれません。

パフォーマンス改善

NginxでFastCGIキャッシュを使えばアプリ側にビューキャッシュの機構を作ったりSquidなどキャッシュサーバを導入せずに済みます。

ただ、ページ単位でのキャッシュの削除機能がデフォルトでついていないため、ngx_cache_purgeというプラグインを含めてNginxをビルドすると扱いやすくなります。

以下はキャッシュを利用するときのNginxの設定の抜粋です。

    # HEADアクセスはキャッシュを使わない
    if ($request_method = "HEAD") {
        set $do_not_cache 1;
    }

    location / {
        fastcgi_pass 127.0.0.1:9080;
        fastcgi_connect_timeout 30;
        fastcgi_cache qd_cache;
        fastcgi_no_cache $do_not_cache;
        fastcgi_cache_bypass $do_not_cache;
        fastcgi_cache_key $scheme://$host$request_uri;
        fastcgi_cache_valid  200 1d;
        fastcgi_cache_valid  301 1d;
        fastcgi_cache_valid  any 15m;
        include fastcgi_params;
    }

Quickdocsの場合は内容が変更されることが基本的にないので、キャッシュの期間を長めにしています。アプリによって3hなどに変更すると良いと思います。

LispとShell

シェルコマンドを使う

Common Lisperなら堂々とすべてのコードをCommon Lispで完結すべきだ、という意見もあると思いますが、実際にアプリケーションを書いていると、やはり理想論だと感じます。

Common Lispのライブラリは有名なスクリプト言語と比べればずっと少ないですし、無いものを自前で一から作ってそれなりのクオリティで保守し続けられるほどLispコミュニティは成熟していません。必要な部分はうまく他の言語の力を借りたほうが現状ではうまくいきます。

具体例をあげます。たとえば、Common LispImageMagickを使いたい、と思ったときにlisp-magickというImageMagickのCLバインディングを使おうとしたことがあります。

けれど、Quicklispでインストールして試してみるも、素直に動かず、調べてみると長くメンテナンスもされていないようなので使うのを諦めました。そんな怪しいライブラリを使うよりは、標準でついてるconvertコマンドをasdf:run-shell-commandで叩くほうがずっと良いです。

Quickdocsでは他にMarkdownからHTMLへの変換にHaskellPandocを使っています。CL-Markdownはよく出来たライブラリですが、GitHub Flavored Markdownに対応していなかったので使うのをやめました。

Lisp内でシェルを叩くときの注意

Common Lispでシェルコマンドを実行するときに注意すべき点ももちろんあります。もしそのシェルコマンドの実行にかなり時間がかかるケースがある場合は、適切にタイムアウト処理をしなければいけません。

trivial-timeoutというライブラリがありますが、使い物になりません。タイムアウトはしますが、例外を投げるだけでシェルプロセスがそのままゾンビ化してしまうからです。

これは普通にtimeoutコマンドを使えば良いです。

timeout 5 pandoc /path/to/README.markdown

また、別プロセスで実行されたシェルコマンドがリソースを食いつぶしてしまって、Webサーバのレスポンスに影響が出ることがあります。これを防ぐためにulimitcpulimitを適切に使うと良いです。

まあ、毎回実行する必要もないものはキャッシュしておいたり、あらかじめバッチ処理で生成しておくほうが確実とは思います。

おわりに

いくつか抜粋して紹介しましたが、Quickdocsの全ソースコードはGitHubに上がっています。

Clackを使っていて実際に公開され動いているWebアプリケーションとしては初めてかもしれません。参考にしていただければと思います。

長々と書きましたが、僕はCommon LispでのWebアプリの運用に長けているわけではありませんし、もちろん運用を長く経験された方からみると拙い解決方法が多いと思います。

とはいえ、Common Lispのような小さい言語コミュニティでは、開発も運用もとりあえずやってみて、まず道を作っていくことが重要だと思いました。

何かわからないことがあれば遠慮なくTwitterやE-mail (e.arrows@gmail.com) などで聞いてください。

逆に、他にもCommon Lispのアプリケーションの運用をしている方はぜひノウハウを教えてください!