CORBIT/BOOPS: プロトタイプベースオブジェクト指向を実現する拡張ライブラリ

 ひょんなことから見つけた*1のでちょっと遊んでみました。

CORBITとBOOPSについて

 CORBIT (CommonORBIT) はCommon Lisp用のプロトタイプベースのオブジェクト指向ライブラリです。

Package: lang/lisp/oop/non_clos/corbit/

 どうも、Orbitというプロトタイプベースのオブジェクト指向のシステムがあり*2、それをCommon Lisp向けに移植・再実装したものがCORBITであるようです。また、BOOPS (Beginner's Object-Oriented Programming System) はふるまいや仕組みが初学者にもわかりやすいようCORBITのコア要素を抽出し、小さくしたものです。

 CLOSと異なる点は、CLOSがクラスベースのオブジェクトシステムであるのに対し、CORBIT/BOOPSはプロトタイプベースのオブジェクトシステムであるところです。クラスベースのオブジェクトシステムでは各オブジェクトがクラスに属し、多態性が必要な際はクラス間の関係に従ってふるまいを決定します。この「クラス」は、実行時には変更できない言語(JavaとかC++とか)と実行時に変更できる言語(SmalltalkCommon Lisp)がありますが、いずれせよある時点ではなにか「クラス」に属している点では共通しています。一方プロトタイプベースのオブジェクトシステムでは、あるオブジェクトが多態的にふるまう際に「オブジェクトの属するクラス」ではなく、関係する別のオブジェクトに処理を移譲する点が異なります。 (え、これ、あってるのかしら…。書いてみたら理解が雑だった…。)

 現在のCLOSになるまでにいくつかのオブジェクト指向実装があったというのは聞いていましたが、CLOSっぽいものだけでなく、JavaScriptのようなプロトタイプベースのオブジェクト指向システムも存在したんですね。

 ちなみにCommon Lispに詳しくない人のために説明すると、CORBITはCommon LispANSI仕様に含まれているCLOS (Common Lisp Object System)とは別物です。CORBITはCLOSを用いずライブラリとして実現されています。

BOOPS使ってみる

 BOOPSは1ファイルにパッケージが1つ定義されているだけのとても小さな実装です。使うにはお手元のCommon Lisp処理系でファイルをロードするだけで済みます。BOOPSパッケージ内で作業すると若干楽です。

CL-USER> (in-package :boops)
#<Package "BOOPS">

 BOOPSではオブジェクトはシンボルで表現し、アスペクト (aspect; 側面? CLOSでいうスロット、Javaとかだとメンバ/フィールド)をリストで表現します。アスペクトには:value:functionの種別があり、:functionだとオブジェクトが引数に渡されてきます。

 まずはとりあえず、哺乳類をいくつか定義して鳴かせてみます。

;; 哺乳類を定義 (鳴くコマンドつき)
BOOPS> (defobject marmal object
           (toot :function (o) (format t "toot!~%")))
;Compiler warnings :
;   In an anonymous lambda form: Unused lexical variable O
MARMAL

;; わんこを定義 (鳴くコマンド上書き)
BOOPS> (defobject doggo marmal
         (toot :function (o)
               (format t "bow wow!~%")))
;Compiler warnings :
;   In an anonymous lambda form: Unused lexical variable O
DOGGO

;; 鳴く
BOOPS> (message 'marmal 'toot)
toot!
NIL
BOOPS> (message 'doggo 'toot)
bow wow!
NIL

 継承できた!

 次に、アドホックにメソッドを追加してみます。これには匿名オブジェクト(というかgensym)を作ります。このように。

;; 匿名オブジェクトとしてジョンを定義しアスペクトを追加を追加
BOOPS> (let ((john (a doggo (introduce :function (o)
                                       (format t "I'm John! ")
                                       (message o 'toot)))))
         (message john 'introduce))
I'm John! bow wow!
NIL

 静的なクラスに依存しないので、このようなこともできます(ただしCLOSは静的ではないので同じようなことできそう)。

BOOPS> (defobject human marmal
         (toot :function (o)
               (format t "hello!~%")))
;Compiler warnings :
;   In an anonymous lambda form: Unused lexical variable O
HUMAN
BOOPS> (let ((tom (a human (introduce :function (o)
                                      (format t "I'm Tom! ")
                                      (message o 'toot)))))
         (message tom 'introduce))
I'm Tom! hello!
NIL

 これでアドホックなインターフェースっぽいサムシングが実現できました。

中身について

 だいぶシンプルです。BOOPSではオブジェクトはシンボルだと言いました。BOOPSの内部的には、オブジェクトのアスペクトは、シンボルのpslitスロットにplistの形で入っています。

;; BOOPSで定義されているオブジェクトのアスペクト
BOOPS> (symbol-plist 'object)
(ASPECTS ((SET-VALUE :FUNCTION . #<Anonymous Function #x302000C8FF7F>) (SHOW :FUNCTION . #<Anonymous Function #x302000C900DF>)) ISA NIL)

;; わんこのアスペクト
BOOPS> (symbol-plist 'doggo)
(ASPECTS ((TOOT :FUNCTION . #<Anonymous Function #x302000C9835F>)) ISA MARMAL)
BOOPS>

 匿名オブジェクトもgensymであるただのシンボルなのでplistスロットがあり、おなじようにアスペクトが入っています。

BOOPS> (let ((tom (a human (introduce :function (o)
                                      (format t "I'm Tom! ")
                                      (message o 'toot)))))
         (symbol-plist tom))
(ASPECTS ((INTRODUCE :FUNCTION . #<Anonymous Function #x302000E3B3AF>)) ISA HUMAN)

 おしまい。

*1:Common Lisp Musicについて調べていたら"Compact LISP Machine"なる論文を見つけ、論文に出てくるTexas InstrumentsExplorerというLISPマシンを調べていたらCMUアーカイブに流れ着き、そこを眺めていたらあった。

*2:論文情報はこちら(読めぬ): https://ai.vub.ac.be/publications/236 。著者の方はGuy. L. SteeleさんではなくファーストネームがSteelsさん。

Linux版OneShotの終盤でファイルが開けない(ネタバレ注意)

 この記事は以下の記事の続きです。

octahedron.hatenablog.jp

 上の記事の内容も利用しているので、適宜参照ください。

 ストーリー終盤のギミックについての不具合対処記事なのでネタバレを含みます。ご注意を!!

続きを読む

OneShot (ゲーム)をUbuntu 18.04で動かす

あらまし

 先日、チャンスは一度きりなゲームOneShotGNU/Linux版がリリースされました。ふだんUbuntuのLTS版を利用しているので、いままでWindows 10に切り替えなければ遊べなかったOneShotをいつもの環境で起動できるのはとてもうれしいです。

steamcommunity.com

 しかし要求してくるライブラリのバージョンがとても新しく、LTS版では起動できませんでした。この記事ではそれをなんとかしてみます。

発生現象

 Steam GNU/Linux版でOneShotをダウンロード後に起動しようとすると起動中っぽい動きを一瞬したあとそのままなにも起こりません。これはプログラムの起動に失敗して無言で落ちているためであり、ターミナルで直に起動しようとしてみると以下のような出力が得られます。

# OneShotがインストールされたディレクトリに移動
$ cd ~/.local/share/Steam/steamapps/common/OneShot
# 起動 (失敗)
$ ./oneshot
./oneshot: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.28' not found (required by /home/grey/.local/share/Steam/steamapps/common/OneShot/libgio-2.0.so.0)
./oneshot: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.28' not found (required by /home/grey/.local/share/Steam/steamapps/common/OneShot/libglib-2.0.so.0)
./oneshot: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.28' not found (required by /home/grey/.local/share/Steam/steamapps/common/OneShot/libsystemd.so.0)

 これは、システムにインストールされたglibcライブラリのバージョンが古いと言っています。システムの情報とglibcのバージョンを確認してみると、以下のようでした。

# 環境情報の確認
$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.2 LTS"
grey@timberwolf:~$ /lib/x86_64-linux-gnu/libc
libc-2.27.so  libc.so.6

# glibcのバージョン確認
$ /lib/x86_64-linux-gnu/libc.so.6
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1) stable release version 2.27.
Copyright (C) 2018 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 7.3.0.
libc ABIs: UNIQUE IFUNC
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.

 おしい! 1つだけ足りない! それにしてもglibclsといった各コマンド自体も依存している動的ライブラリですので、ディストリビューションが提供するアップデートなしには容易に差し替えられません。ちなみに「OneShot用のglibcコンパイルしてゲーム本体のディレクトリに置けばいいじゃん」とやってみたところダメでした。差し替えたglibcを向くようにLD_PRELOAD環境変数で指定しても.so中のオブジェクト等の位置が異なるのかセグフォになります。むずかしいですね。

対処方法

 このスレッドにも書かれているのですが、glibcを差し替えて解決するのではなく新しいglibcに依存しているライブラリを使わないようにするのが楽かつスマートです。

steamcommunity.com

 つまり、OneShotディレクトリにある新しいglibcに依存する.soは削除もしくはリネームしてしまい、システムにapt等で対応するライブラリを入れましょう。

$ cd ~/.local/share/Steam/steamapps/common/OneShot
$ rm libgio-2.0.so.0 libglib-2.0.so.0 libsystemd.so.0

 これでglibcのエラーはでなくなります。

そのあと

 ぼくの環境では、この後Error initializing SDL: Could not initialize UDEVというエラーがでてまだ起動できませんでした。udevとはユーザ空間でデバイスドライバを動的にロードしたりするライブラリっぽいのですが、きっとシステムのものではないudevを用いているせいかもしれない(libudev.so.1が実際ある)のでこれも消してしまいましょう。

 そうするとエラーの内容がCould not initialize Steamworks APIに変わります。きっとStreamが起動していて認証されてないと動かないのでしょうね。そこで、今まで./oneshotを起動していたのですが同じディレクトリにある./steamshimを起動してみましょう。Steamへの認証っぽいログがでたあと…。

f:id:t-sin:20190502000343p:plain
OneShot起動成功!

 やったー!!

 あとは遊びましょう。

Clozure CLでプロファイラを利用する

2019-05-02 19:51追記

プロファイル取得用のイメージを作成する手順がありますが手順を間違えると処理の実行のたびにfaslのダンプが行われて計測結果を汚すので注意してください。イメージ作成手順にも追記します。

TL;DR

 Clozure CLの以下ドキュメントに書いてある通りです(本記事では素のCCLではなくRoswell上での利用方法が書かれています)。

ccl.clozure.com

動機

 PortAudio/cl-portaudioを用いてCommon Lispサウンドプログラミング実験をしています(どんなことをしているのかはざっくりこちらのスライド参照。いつか記事かなにかにしたい)。

github.com

 ある程度音が鳴らせるようになってきたので、ちょっと複雑な音を鳴らしてみようと思いました。が、プログラムの実行速度が遅いのかバッファアンダーランが発生して音がぶちぶちに切れてしまい、これでは音楽つくるどころではありません。しかしここまで大きく育ったプログラムのどこがボトルネックか、ぼくにはパッとわかりませんでした。

 なのでプロファイラを用いてどの処理が重いのかとても知りたいです。

 ちなみに普段使っている処理系はClozure CLなので、SBCLに組込みのプロファイラは使えません(さらに言うと何かがおかしいのかpukunuiをSBCLでロードすると型エラーになったような気がする)。

環境や準備

環境

使うもの

手順

 ここではサンプル用のコードを説明用に用意せず、pukunuiをそのまま使って手順を説明します。しかし使うだけならそんなに難しくないはず…。

pukunui関係準備

 Pukunuiではcl-portaudioを通してlibportaudio2を利用しますので、aptでインストールしてください。Pukunui本体も.roswell/local-projectsとかに置いている前提です。

プロファイルを取るための準備

 Oprofileでは起動前・起動後の対象を指定してプロファイラを起動し、その実行中の情報をOSを介して取得するしくみです。対象に指定できるのは以下です:

  • システム全体
  • 指定したpidのプロセス
  • 引数に指定した実行可能ファイル(とその引数)を起動したプロセス

 今回はpidを指定してプロファイルを取得します。まずCCLでしておくべきことは

  1. プロセス内のシンボル一覧をファイルに保存
  2. 上記シンボル一覧と関数が対応する処理系のイメージを作成

です。ros use ccl-binが済んでいれば簡単なのでやってみましょう。

$ ros run
Clozure Common Lisp Version 1.11.5/v1.11.5  (LinuxX8664)

For more information about CCL, please see http://ccl.clozure.com.

CCL is free software.  It is distributed under the terms of the Apache
Licence, Version 2.0.

;; シンボルテーブルを保存
? (require :elf)  ; 必要なパッケージをインポート
ELF
? (ccl::write-elf-symbols-to-file "elf-symbols")  ; `elf-symbols`にシンボルが書き出される

;; `elf-symbols`に関数が対応した処理系イメージを作成し終了
? (save-application "pukunui-prof-image" :prepend-kernel "elf-symbols")
$ 

2019-05-02 19:54追記

ちなみにライブラリのasdf:load-systemや計測に必要なLispプログラムのloadccl:save-application前にやっておきましょう。「ライブラリロードはイメージ作成前に、プログラムのロードは計測直前に」などとするとClozure CLではロードしたプログラムのfaslへのダンプ処理が計測対象実行の度に走るようです。これによりCCL::FASL-DUMP実際のコードでは使っていない処理が計測結果に登場してプロファイリングを困難にします。ご注意ください。

プロファイルを取る

 ここからはターミナルを複数立ち上げて作業します。

 まずCCLの処理系を先程のイメージで起動します。Roswell越しにCCLにオプションを渡し、イメージを指定します。その後、プロファイルを取得する前の準備(プログラムのロードや初期化)をします。 2019-05-02 19:54追記 プログラムのロード等はイメージ作成前に行ってください

$ ros run -- -I ./pukunui-prof-image
...

;; プログラムの初期化 (ここではpukunuiのロードと音を鳴らすプログラムの準備)
? (ql:quickload :pukunui)
PUKUNUI
? (load (asdf:system-related-pathname :pukunui "examples/04-seq.lisp"))
#P"pukunui/examples/04-seq.lisp"

 これでpukunuiの初期化は完了です。スタートする前に、プロファイラを別ターミナルで起動しておきます。

$ ps aux | grep lx86cl64   # 上で起動したCCLのpidを調べる
$ sudo operf --pid xxxx
...

 プロファイルを取得するためにCCLのREPLで(pukunui:start)を打ちます。カービィの音が鳴り終わったら、プロファイラのほうでCtrl-xを押してプロファイラを止めます。プロファイラを止めるとoprofile_dataというディレクトリができており、そこにプロファイル結果が入っています。サマリを見てみましょう。このとき、実行したCCLのイメージを指定すると内部のシンボル名が何回呼ばれたかが表示されます。

$ opreport -l pukunui-prof-image  | head
Using /home/grey/Dropbox/code/pukunui/examples/oprofile_data/samples/ for samples directory.

WARNING! Some of the events were throttled. Throttling occurs when
the initial sample rate is too high, causing an excessive number of
interrupts.  Decrease the sampling frequency. Check the directory
/home/grey/Dropbox/code/pukunui/examples/oprofile_data/samples/current/stats/throttled
for the throttled event names.


WARNING: Lost samples detected! See /home/grey/Dropbox/code/pukunui/examples/oprofile_data/samples/operf.log for details.
CPU: Intel Ivy Bridge microarchitecture, speed 3900 MHz (estimated)
Counted CPU_CLK_UNHALTED events (Clock cycles when not halted) with a unit mask of 0x00 (No unit mask) count 100000
samples  %        image name               symbol name
4434      7.9773  pukunui-image            CCL::FASL-DUMP-EPUSH
4358      7.8405  pukunui-image            RANDOM
2882      5.1850  pukunui-image            CCL::FASL-DUMP-DISPATCH
2780      5.0015  pukunui-image            CCL::%BIGNUM-RANDOM
2553      4.5931  pukunui-image            CCL::%STRING-HASH-FOLDING-CASE
1948      3.5047  pukunui-image            <Compiled-function.(:INTERNAL.CCL::GET-LOCAL-VALUE.CCL::ARGUMENTS-AND-LOCALS).(Non-Global)..0x300000A0412F>
1815      3.2654  pukunui-image            FLOAT-SIGN

 なにか警告がでていますが、全イベントが取得できなかったみたいです。まだよくわかってないですが、改善の余地があるということなのでしょう。

所感

 プロファイルを取得するときは本当に計測したい部分でのみプロファイラを起動(=プロファイルデータを集める)しましょう。pukunuiは例えば信号処理プログラムですが、最初はライブラリの初期化を含めて計測してしまったため、信号処理には関係ない(本来の計測対象ではない)処理が上位に出てきてしまいます。

 結果を見ている感じ、Pukunuiに関してはたぶん自前実装オブジェクトシステムもどきのあたりがボトルネックになってそうでした(ccl:%string-hash-folding-caseとかあるので。自前オブジェクトは型とメソッドの対応表がハッシュテーブルに入っている)。あとRANDOMってなんじゃい(上のプロファイル結果のプログラム 06-applications.lispでは使っていない)。Pukunuiが変な挙動をしているのかもしれません。

 やはりKiwi Lightweight Object Systemが必要か…。

所感追記 (2019-05-02 19:54)

 上のプロファイル結果にCCL::FASL-DUMP-EPUSHなどが現れていますが、これはpukunuiのコードで行われている処理ではありません。上で述べたようにプロファイリング用イメージの作成手順を誤ると走ってしまうようです(詳細な原因は不明)。

 また、randomexamples/06-applications.lispを用いていれば登場しないはずですので、計測用イメージの作成ミスかもしれません。計測の準備は注意ぶかく行いましょう。

2018年振り返り

2018年振り返り

 今年はいろいろあったので、振り返りしてみようという気になった。触発されたというのもある。

働いた

 2017年末にはなんか落ち込みが発生して自信をかなり損なっていた時期があったけど、2018年1月からは働きはじめた。対話AIをやっている会社で、それまでSIer (Java)やWeb屋さん(Python)としてやってきたぼくとしては新たなチャレンジだった。とはいえWebまわりをやっているけれど、いろいろなことを学んだし、(Lisperなのに)(遅いけど)対話AI、というか人工知能に興味を持つことができた。神戸大学LISPマシンを見にいったときのことも大きなきっかけである(第7回関西Lispユーザ会に行ってきた)。

読書

 あんま読んでない…。スループットは悪く、年に24冊だった。内訳としては、小説多め。今年は『エコープラクシア』『最後にして最初のアイドル』『リスの生態学』『貨幣の新世界史』『エル・アレフ』『死刑 その哲学的考察』『ペンギンの島』『トランスヒューマンガンマ線バースト童話集』、などなど。SFは収穫多かった。就職祝い(セルフ)に買ったイーガンの直交三部作は本棚をしっかりと支えています。読みたい。あとは、後述するけど音声信号処理関連の読んだけど、まだわからん…。あと、ついに『LET OVER LAMBDA』を読んだよ!! マクロって書いていいんだね!!

太った

 なぜだかわからないけど太った。きっと運動を控えめに、食事を盛大に行ったためだと思われる。怖いので体重を計測していない。帰省しているので、戻ったらまずは計測して、そして、運動をしてやせたい。

プログラミング

 いくつかトピックがあるので分けて書く。

シンセサイザーづくり

 つくってみたいもののひとつだったので、えいやっとつくりはじめてみた。とりあえずノリで実装してみてたけど、平行して書籍を読むこともした。『サウンドプログラミング入門――音響合成の基本とC言語による実装』で基本を押さえ、『サウンドエフェクトのプログラミング―Cによる音の加工と音源合成』でエフェクトのつくりかたを…わからなかったけど…。音を鳴らすのは簡単(でもない)けど、シーケンサを生やすためのイベントスケジューラを書けなくて、実力の不足を実感した。シーケンサ、生えろ。

Nim -> Rust

 興味があったのでNimを去年からいじっていた。初めてのLispをつくってみたり、上のシンセサイザーつくりに利用してみたりした。ただ、なぜか処理系の不具合を踏み抜いたようで、シンセづくりが完全にストップしてしまったのであきらめた。そのままRustに入門して、絶賛勉強中。ちなみにシンセはCommon Lispでもつくりはじめてみた。シーケンサがやっぱり鬼門。

言語を処理でき…そう…?

 去年末はNimでLispをつくっていたけど、仕組みがわからないので引き続き勉強していた。パタヘネ読んだし、仮想機械のサーベイをしたり、実際にCPUエミュレータVMを実装してみたりした。そしてForthとの出会い。そのあたりはこちら『プログラミング言語Forthに魅せられて。』にしたためておいた。定数を扱える、規格の動作をすこしずつ再現できてきているので、もしかしたらいわゆる「ちゃんとした言語」というやつになるかもしれない。

来年は

 いろいろ結実して、生えてきてほしいなあ。

セルオートマトン暗号(未完)

遅刻してますが、この記事はセルオートマトンアドベントカレンダーの23日目の記事です。

あらまし

 離散的な力学系であるところのセルオートマトンは、カオス的なふるまいをすることで知られています。カオス的であるなら、そのふるまいを暗号学的なアレとかソレとかに利用できるのでは…、とはもう研究があるようですがここでは割愛。いっぽうで、セルオートマトンの中には可逆性をもつものがあります(ここらへんも研究がありますが割愛)。

 あとは…わかるね?

 ということでとりあえずElementary Cellular Automataを用いてガッとメッセージの暗号化・複合化を実装しようとしたけど3時間ではだめでした、という残念な記事です。ざんねん。

ソースコード

文字をつらつら書くほど内容はないのでさっさとソースコードを示します。ひさしぶりにビット演算をキメた気がする。以下のことはできています:

  • 局所遷移関数をルール番号から生成(コンパイル時に)
  • 周期的境界条件(キアイで)
  • なんとなく様相を指定回数ぶん遷移
  • ファイルをビットベクタにしてECAの様相に変換しブンまわす

gist.github.com

使用感

 なんとなくこんな感じになります。

CL-USER> (let ((vec (vector 0 0 0 1 0 0 0 1 0)))
           (run 10 vec (make-array (length vec))
                (make-local-transition-fn-from-rule 150)))
#*000100010
#*001110111
#*110100010
#*000110110
#*001000001
#*111100011
#*111010101
#*110010100
#*001110111
#*110100010
#(0 0 0 1 1 0 1 1 0)

 なんとなく大域遷移はできていそう。Roswellスクリプトにしておいたので、引数にファイル名を与えるとファイルの内容で遷移してくれます。しかしビットベクタをバイトベクタとして書き出してしまっているので出力はおかしいですがもうつかれました。

まとめ

 このセルオートマトンという分野、人工生命というだけではなく暗号学の方面の研究もあり、さらに情報の圧縮や、果ては様相の遷移を群と見なして解析する純粋数学的な分野もあり、とてもたのしそうです。やりたい。

参考文献

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ってすごいね。