Common Lisp (Roswell)とDockerで実行可能ファイルをビルド
この記事はLispアドベントカレンダーの7日目の記事です。
ざっくり
Common Lispの処理系マネージャ・スクリプティング環境であるRoswellとライブラリバージョン固定化ツールQlotを用いて、
- Common Lispのプロジェクト(システム)の実行可能バイナリを作成し、
- Common Lisp環境が入っていない人でも実行できるようDockerコンテナ化し、
- 推し進め、コードに変更があったときでも高速にビルド
- Common Lispプロジェクトのビルド環境としてDockerコンテナを利用
します。そしてその結果がこのリポジトリ(のsolstice
ブランチ)です。
背景・動機
おしごとで書けたり、あとは自分でつくっているプログラムであったりを、ひとつの実行可能ファイルにまとめて、さっと配布したりデプロイしたりしたいなあ、という動機です。
自分でつくっているプログラムについてはこの記事のやつです。
次のバージョンをめざしていろいろ実験中です。ビューのHTMLや画像をメモリに持ってみたり、テンプレートエンジンをLSXにしてみたり(問題の ツイート)、シングルバイナリ化もそうだし、あと、Dockerコンテナ内高速ビルド(二回目以降)など。
Dockerコンテナ化には、もともとはおしごとで、パートナーがMac使いだったために発生したものです(ぼくはGNU/Linux使い)。そこでマルチステージビルドによるコンテナイメージの作成過程をキャッシュする方法などを覚え、そこそこストレスのない感じになっています。
RoswellとQlotについて
去年の拙作のこの記事にも初心者向けな感じで書いていますが、いま一度。
Roswell
Common Lispには処理系が複数あり、マイナーな言語であるため最新のバージョンがaptのようなパッケージリポジトリに入っていないので、常用している場合にCommon Lisp環境をさっと導入するのはあまり簡単ではありません。
では、RubyのrbenvやPythonのpyenvのようなものがほしくなるのですが、あります。それがRoswellです。
Roswellは基本的には処理系マネージャなのですが、そのほかにも以下のような機能が提供されています(公式のREADMEより):
- 処理系のインストール管理
- Common Lispによるスクリプティング(Roswellスクリプト)
- Roswellスクリプトの実行可能形式へのビルド
- スクリプトのセットアップの簡単化
- CLIとの高い親和性
- CLIによる、QuicklispやGitHubリポジトリからのシステムのインストール
- Windowsのサポート
- CIでの利用
Common Lisp各処理系のコマンドラインインターフェースはばらばらであり、元来処理系ポータブルなスクリプトを書くことはめんどくさいものでした。Roswellのスクリプティング機能は、処理系ポータブルに設計されており、ひとつのスクリプトを複数の処理系で動かすことができます。非常に便利です。また、Roswellではメモリのダンプイメージ作成機能を(一部の処理系で)備えており、Roswellのスクリプトを読み込んで実行可能ファイルとして吐き出すことも可能です。
なので、ここではそれらを組み合わせて、実行可能ファイルのコマンドラインインターフェースとしてRoswellを利用しました。
Qlot
Common Lispでは「システム」(他言語でいうパッケージ)の取得にQuicklispを利用することが多いです。でも、システムのバージョンを個々に指定する機能はないです。そのため依存するシステムのバージョンが導入のタイミングが異なるために変わってしまう、という状況があり得ます。
そんな状況でお困りのあなたに、Qlotです。Qlotを利用すると、システムの依存システムを固定してしまうことができるので、過去につくったプログラムが依存システムの仕様変更などなどで動かない、ということを防ぐことができます。
Node.jsのnpm
みたいな感じです。
これまではライブラリ的なもの(inquisitor)や、小さなもの(rosa、依存システムなし)ばかりつくっていたので必要性をあまり感じなかったのですが、依存ライブラリが増えてくると、その必要性を感じはじめて、導入しました。
配布・デプロイまでの道
1. プログラムの実行可能ファイルを作成する
まず、システムの構成を考えます。どのようなシステムであれ、Common Lispにおける開発にはきっと対話的な環境を利用することでしょう(GNU EmacsとかLemとか)。なので、基本の構造は対話的に呼べるようにします。コマンドラインから呼ぶときには、
という流れにします。ざっくりとしたフォルダ構成はこんな感じです(細部は省略しています)。
. |-- 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のマルチステージビルド
を用いてコンテナのビルド過程を細分化し、各過程をキャッシュしてしまいました(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環境の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ってすごいね。