Common Lisp (Roswell)とDockerで実行可能ファイルをビルド

 この記事はLispアドベントカレンダーの7日目の記事です。

ざっくり

 Common Lispの処理系マネージャ・スクリプティング環境であるRoswellとライブラリバージョン固定化ツールQlotを用いて、

  1. Common Lispのプロジェクト(システム)の実行可能バイナリを作成し、
  2. Common Lisp環境が入っていない人でも実行できるようDockerコンテナ化し、
    • 推し進め、コードに変更があったときでも高速にビルド
  3. Common Lispプロジェクトのビルド環境としてDockerコンテナを利用

します。そしてその結果がこのリポジトリ(のsolsticeブランチ)です。

github.com

背景・動機

 おしごとで書けたり、あとは自分でつくっているプログラムであったりを、ひとつの実行可能ファイルにまとめて、さっと配布したりデプロイしたりしたいなあ、という動機です。

 自分でつくっているプログラムについてはこの記事のやつです。

octahedron.hatenablog.jp

 次のバージョンをめざしていろいろ実験中です。ビューのHTMLや画像をメモリに持ってみたり、テンプレートエンジンをLSXにしてみたり(問題の ツイート)、シングルバイナリ化もそうだし、あと、Dockerコンテナ内高速ビルド(二回目以降)など。

 Dockerコンテナ化には、もともとはおしごとで、パートナーがMac使いだったために発生したものです(ぼくはGNU/Linux使い)。そこでマルチステージビルドによるコンテナイメージの作成過程をキャッシュする方法などを覚え、そこそこストレスのない感じになっています。

RoswellとQlotについて

 去年の拙作のこの記事にも初心者向けな感じで書いていますが、いま一度。

qiita.com

Roswell

 Common Lispには処理系が複数あり、マイナーな言語であるため最新のバージョンがaptのようなパッケージリポジトリに入っていないので、常用している場合にCommon Lisp環境をさっと導入するのはあまり簡単ではありません。

 では、RubyのrbenvやPythonのpyenvのようなものがほしくなるのですが、あります。それがRoswellです。

 Roswellは基本的には処理系マネージャなのですが、そのほかにも以下のような機能が提供されています(公式のREADMEより):

 Common Lisp各処理系のコマンドラインインターフェースはばらばらであり、元来処理系ポータブルなスクリプトを書くことはめんどくさいものでした。Roswellのスクリプティング機能は、処理系ポータブルに設計されており、ひとつのスクリプトを複数の処理系で動かすことができます。非常に便利です。また、Roswellではメモリのダンプイメージ作成機能を(一部の処理系で)備えており、Roswellのスクリプトを読み込んで実行可能ファイルとして吐き出すことも可能です。

 なので、ここではそれらを組み合わせて、実行可能ファイルのコマンドラインインターフェースとしてRoswellを利用しました。

Qlot

 Common Lispでは「システム」(他言語でいうパッケージ)の取得にQuicklispを利用することが多いです。でも、システムのバージョンを個々に指定する機能はないです。そのため依存するシステムのバージョンが導入のタイミングが異なるために変わってしまう、という状況があり得ます。

 そんな状況でお困りのあなたに、Qlotです。Qlotを利用すると、システムの依存システムを固定してしまうことができるので、過去につくったプログラムが依存システムの仕様変更などなどで動かない、ということを防ぐことができます。

 Node.jsのnpmみたいな感じです。

 これまではライブラリ的なもの(inquisitor)や、小さなもの(rosa、依存システムなし)ばかりつくっていたので必要性をあまり感じなかったのですが、依存ライブラリが増えてくると、その必要性を感じはじめて、導入しました。

配布・デプロイまでの道

1. プログラムの実行可能ファイルを作成する

 まず、システムの構成を考えます。どのようなシステムであれ、Common Lispにおける開発にはきっと対話的な環境を利用することでしょう(GNU EmacsとかLemとか)。なので、基本の構造は対話的に呼べるようにします。コマンドラインから呼ぶときには、

  1. Roswellスクリプトの中でシステムをql:quickloadし、
  2. コマンドライン引数をパースして、
  3. 対応した関数を呼ぶ、

という流れにします。ざっくりとしたフォルダ構成はこんな感じです(細部は省略しています)。

.
|-- Dockerfile
|-- LICENSE
|-- README.md
|-- main.lisp
|-- niko.asd         # 通常のASDFシステム
|-- qlfile
|-- qlfile.lock
|-- release.sh       # コンテナビルド用(後述)
|-- roswell
|   `-- niko.ros     # CLIで起動するRoswellスクリプト
`-- setup.sh         # コンテナビルド用(後述)

 Roswellスクリプトの中では、REPLでも叩けるように用意した種々の関数を、パースしたコマンドライン引数に応じて呼び出すだけです。つまりだいたいこんな感じです:

;; (中略)
(progn ;;init forms
  (ros:ensure-asdf)
  #+quicklisp(ql:quickload '(:niko) :silent t)
  )

(defpackage :ros.script.niko.3752905480
  (:use :cl))
(in-package :ros.script.niko.3752905480)

;; (中略)

(defun main (&rest argv)
  (if (= (length argv) 0)
      (format *error-output* +usage+)
      (case (intern (string-upcase (string (first argv))) :keyword)
        (:help (format *error-output* +usage+))
        (:version (format *error-output* "~a~%" (niko/util:version)))
        ;; (中略)
        (t (format *error-output* "Unknown command '~a'~%" (first argv))
           (format *error-output* +usage+)))))
;;; vim: set ft=lisp lisp:

 これで、まずは実行できるようになりました。動作確認すると、

$ qlot exec roswell/niko.ros version
0.9.0

よさげです。これをビルドするには、こうやります。

# ビルド
$ qlot exec ros build roswell/niko.ros

# ビルドした実行可能ファイルで動作確認
$ ./roswell/niko version
0.9.0

 こうして生成した実行可能ファイルを他の環境に持っていくときに気をつけることは、依存するシステムを、qlfile.asdファイルに可能な限り書いておくことです。

 Qlotはqlfileに書かれたシステムのみをプロジェクトのルートディレクトリに導入しますが、そのシステムたちがさらに依存するシステムは、グローバルな(Roswell環境下の)Quicklisp環境を利用するようです。そのため、動的にロードするシステムを切り替えるようなシステムに依存している場合にそれをqlfileに書かずにいると、動的なロードに失敗します。また、.asdファイルに書かずにいるとql:quickload時にASDFがロードしてくれず、実行時に探しに行って存在しなくエラーとなったりもします。

2. Dockerコンテナ上でビルド&実行

 プログラムを引き渡す相手がDocker使いだったり、異なるOS使いだったり、プログラム実行のためだけに環境を入れるのはなあ、という場合は、Dockerfileをつくってそれを渡してあげるとよいでしょう。

 Dockerコンテナのビルドは、OSのapt update && apt installからRoswellのビルド、Qlotのqlot install、そしてros buildまでを一発で実行してもよいのですが、毎度それらを実行していたらストレスで発狂しそうになります。

 なので、Dockerのマルチステージビルド

docs.docker.com

を用いてコンテナのビルド過程を細分化し、各過程をキャッシュしてしまいました(Dockerすごい)。多少ディスク容量を食いますが、ビルド時間が長いよりはだいぶマシです。

 これがDockerfileです。

#### This is a Dockerfile for portable building and executing Common Lisp program.
#### This requires some preliminalies to the program which built with this:
####
#### - The program shall be executed as a single executable file
#### - Entry point of the program is written as a roswell script
####     - More details, see [Roswell](https://github.com/roswell/roswell)
####
#### This Dockerfile can mainly used in two cases:
####
#### - To build the executable file
#### - To run the program by **not Common Lisp user**


### Base image for building
#
# If you want to build only once, you should atatch a tag individually like this:
# `$ sudo docker build --target niko-cl-base -t niko-cl-base .`
FROM ubuntu:18.04 as niko-cl-base

# Builder requires some dependent not-Common-Lisp library, because of `ql:quickload`.
RUN apt update && apt install -y libev-dev build-essential libcurl4-gnutls-dev autoconf git wget unzip
RUN wget https://github.com/roswell/roswell/archive/master.zip && unzip master.zip
RUN cd roswell-master && ./bootstrap && ./configure && make && \
    make install && ros setup && ros install qlot


### Execution environment
#
# If you want to build only once, do this:
# `$ sudo docker build --target niko-runner -t niko-runner .`
FROM ubuntu:18.04 as niko-runner

RUN apt update
RUN apt install -y libev-dev libcurl4-gnutls-dev autoconf git


### Dependency installed environment (to reduce build speed)
#
# If you want to build only once, do this:
# `$ sudo docker build --target niko-deps -t niko-deps .`
FROM niko-cl-base as niko-deps

ADD ./qlfile /app/
ADD ./qlfile.lock /app/
RUN cd /app && $HOME/.roswell/bin/qlot install


### Build environment
FROM niko-deps as niko-builder

ADD ./ /app/
RUN cd /app && $HOME/.roswell/bin/qlot exec ros build roswell/niko.ros


### Execution environment
#
# You can use this container to run the program or copy executable file built
FROM niko-runner

COPY --from=niko-builder /app/roswell/niko /usr/bin/niko
CMD [ "/usr/bin/niko", "version" ]
EXPOSE 5000

 FROMが現れるたびにビルドステージが変わります(イメージが分かれる)。前の段階のイメージは、一度つくってしまえば次はつくる必要がありません。Roswellのインストールなどがそうです。なので、そのようにしてみました。asの後の名前でタグをつけておけば、それが次回より利用されるようになります。タグ付けビルドをさっと実行できるよう、setup.shを用意してあります。

$ sudo ./setup.sh
$ sudo docker build .
0.9.0

 コンテナのビルド過程で、qlfile.asdに追加すべきだった隠れた依存ライブラリを発見できるという点で、Dockerコンテナ内でのクリーンなビルドはかなり意味があるなと感じました。

3. ビルド成果物を取り出し

 ところで、リリース成果物を作成するのに、このDockerコンテナ使えないかしら……。

 使えるんです。そう、Dockerならね。

 一度走らせたコンテナであれば、中からファイルを取り出したり、その逆にファイルを入れたりすることができます。なので、リリース環境とDocker環境のOSの種類やバージョンを合わせておけば、あらふしぎ、リリース成果物が自動でつくれちゃうんです。そしてそれを行うのがrelease.shです。

#!/bin/sh

VERSION=$( qlot exec ros run -e "(format t \"~a\" (slot-value (asdf:find-system :niko) 'asdf:version))" -q)
RELEASE_DIR="Niko_v$VERSION"

mkdir $RELEASE_DIR
cp ./README.md $RELEASE_DIR

sudo docker build -t niko .
sudo docker run -t niko:latest
sudo docker ps -af ancestor=niko:latest -q
sudo docker cp "$(sudo docker ps -af ancestor=niko:latest -q):/usr/bin/niko" $RELEASE_DIR
sudo docker ps -f ancestor=niko -q | xargs sudo docker rm

tar cf - $RELEASE_DIR | gzip > "$RELEASE_DIR.tar.gz"

ls $RELEASE_DIR/

rm -rf $RELEASE_DIR

 こいつを実行すると、3分だけ待ってやるだけで配布物を固めた.tar.gzができています。

 ここで注意する点としては、あたりまえですが実行環境とビルド環境の種類・バージョンを一致させることです。debian:stretchでビルドしたものをubuntu:18.04で実行したりなんかすると、たとえば「libssl.so.1.0.0がないよー」などといってプログラムが落ちます。CFFIがダイナミックリンクする対象を決めているのはそのシステムがロードされたとき(つまりビルド時)なので、ビルド時の環境と実行時の環境で.soのファイル名やバージョンが異なっているときなどにこの問題が発生します。

まとめ

 RoswellとQlotを用いて、Common LispプログラムをDockerでどっかーんとビルドした。

 Dockerfileがあると、Docker内でプログラムを実行させることによって、Common Lisp環境をつくることなしにプログラムを実行してもらうことができた。

 また、コンテナのビルド結果をキャッシュし、高速にビルドが完了するDockerfileを書くことができた。

 そして、コンテナでのCommon Lispプログラムのビルド成果物を用いて、さくっとデプロイ準備を完了するこができた。

 Dockerってすごいね。