Three.jsなWebアプリをCommon Lispで書く話
前書き
Lisp Advent Calendar 2016の20日目の記事です。
13日目の記事「フロントエンドもサーバーサイドもCommon Lispで書く試み - @peccul is peccu」のタイトルを見た瞬間「あっ」と思ったのですが、テーマがダダ被りです。しかも、pecculさんの方はメンテされているjsclをベースにしている一方で、こちらはメンテされていないParenscriptをベースに頑張っ(てしまっ)た記事です。
冒頭から残念感あふれますが、jsclに乗り換えても(未定)考え方は使えると思い、気を取り直して進めます。さて、こんなものを作りました。
斑鳩という素敵STGの3面中ボスである「鴫(シギ)」に関するシミュレータ(絶賛未完成)ですが、Qiitaから来られた方の10割はなんのことか分からないと思うのでこれ自体の話はまた別に記事を起こせたらと思います(ちなみに、Twitterから来られた方の8割はなんのことか分かると思います)。
ポイントはThree.js(WebGLを簡単に利用するためのライブラリ)を利用したWeb2Dゲームアプリであり、サーバサイドからフロントエンドまで*2Lispで記述されているという点です。これを実現するためにどのようなベースを作り、その上でどのような開発サイクルを回していったかが記事の焦点です。
一応動かし方
これを動かすこと自体に興味のある人がどれだけいるか甚だ疑問ですが、一番簡単なのはDockerHubに上げてあるイメージを使うことです。
$ docker pull eshamster/app-cl-shigi-simulator $ docker run -p 5000:8080 -d eshamster/app-cl-shigi-simulator
これでhttp://localhost:5000
でアクセスできるはずです(docker run
コマンドはすぐに返ってきますが、アクセス可能になるまでに10~20秒ほどかかります)。
開発環境にロードする場合は次のような感じです。quicklispに登録していない自作ライブラリに依存しているため、qlot install
でgithubから拾わないとql:quickload
できません…*3。
# bash側 $ ros install eshamster/cl-shigi-simulator $ cd .roswell/local-projects/eshamster/cl-shigi-simulator/ $ ros install qlot $ qlot install ---- ;; REPL側 $ (ql:quickload :cl-shigi-simulator) $ (cl-shigi-simulator:start :port 5000)
概要
次のような階層で実現しています。
- 主に使わせて頂いたもの
- Caveman2:ご存じCommon Lisp製Webフレームワーク
- Parenscript:Common Lisp(サブセット)コードをJavaScriptに変換するライブラリ
- その上に構築したもの
- caveman-skeltons:Common Lispでフロントエンドを書くための準備をしたCaveman2用のスケルトン群
- ps-experiment:Parenscriptそのままでは不足する部分を適当に拡張するライブラリ
- cl-ps-ecs:Entity Component System(後述)もどきを実現するためのライブラリ
- cl-web-2d-game(予定): レンダー部分をThree.jsに依存した2Dゲーム用ライブラリ
- 切り離し予定ですが、現状
cl-shigi-simulator/js-lib
フォルダ配下に内蔵されています
- 切り離し予定ですが、現状
- デプロイ用
- eshamster/cl-base: Alpineベースの(まあまあ)軽量Common Lispコンテナです
それぞれのライブラリ(自作物のみ)
上のライブラリを使ってどのように開発を回してきたかという話を淡々と書いていきます。
caveman-skeltons
Caveman2は非Lisperとの協業を視野に入れており、フロントエンド側は無理にLispにされていません。デフォルトのテンプレートエンジンとしてはDjulaを採用し、JavaScriptについてはそのままであり、必ずしもLispに親しくない人を驚かさないようになっています。
が、一人で書く分には全部Lispでも問題ありません。殊に今回のようにサーバエンドが軽い場合はただのJavaScript開発になってしまうので悲しい限りです。というわけで、Caveman2のスケルトンを下敷きにフロントエンド側もLispで記述するスケルトンを用意したものがcaveman-skeltonsです。
Gitのブランチで複数のスケルトンを管理しています*4。必要なブランチに切り替えたのち、以下のようにしてスケルトンからプロジェクトを作成します(この作成操作は純正のCaveman2と基本同じです)。
> (ql:quickload :caveman-skeltons) > (caveman-skeltons:make-project #p"/path/to/project")
現状3つ(事実上2つ)のブランチがあります*5。
master
: これはCaveman2そのままwith_cl_markup
: Djulaに代わり、CL-Markupをテンプレートエンジンに採用したスケルトンwith_parenscript
:with_cl_markup
をベースに、さらにJavaScript側をParenscriptで書くための準備を施したスケルトン
以下はマニュアルの英語もどきに少し肉付けしたものです。
まず、HTML部分の開発サイクル(新しいページの追加)は次の流れです*6。(with_cl_markup
ブランチ or with_parenscript
ブランチ)
<プロジェクト名>.asd
の編集:"templates"モジュールの下にテンプレート名を追加- Lispファイルの追加と編集:
templates/<テンプレート名>.lisp
を追加- パッケージ定義:
<プロジェクト名>.templates.<テンプレート名>
の名前で作成 render
関数の作成:HTMLコードを文字列として返す関数- このHTMLコード作成のために
CL-Markup
を使う想定 - 引数は任意
- このHTMLコード作成のために
- パッケージ定義:
- テンプレートを利用:Caveman2でルーティングの定義を行う
src/web.lisp
で作業<your project name>.view:render
関数を利用(render :<テンプレート名> <引数(あれば)>)
templates/index.lispがこのサンプルになっています。テンプレートエンジン感を出すため、templates/layouts/default.lispで定義したデフォルトのテンプレートを利用するという形をとっています。このwith-default-layout
はただのマクロなので、必要であれば引数などは好きに追加できます。
;; templates/index.lisp (in-package :cl-user) (defpackage <% @var name %>.templates.index (:use :cl :cl-markup) (:import-from :<% @var name %>.templates.layouts.defaults :with-default-layout)) (in-package :<% @var name %>.templates.index) (defun render () (with-default-layout (:title "Welcome to Caveman2") (:div :id "main" "Welcome to " (:a :href "http://8arrow.org/caveman/" "Caveman2") "!")))
これを使う側(ルーティング側)は次のような感じです(src/web.lisp抜粋)。
(defroute "/" () (render :index))
次はJavaScript側の開発サイクルです。(with_parenscript
ブランチ)
<プロジェクト名>.asd
の編集:"static/js"モジュールの下にファイル名を追加- Lispファイルの追加と編集:
static/js/<name>.lisp
を追加- パッケージ定義:
<プロジェクト名>.static.js.<name>
の名前で作成 js-main
関数の作成:JavaScriptコードを文字列として返す関数- このJavaScriptコード作成のためにParenscriptを使う想定
- 引数はなし
- パッケージ定義:
- テンプレート側での読み出し:
<プロジェクト名>.static.js.utils:load-js
関数を利用する(load-js :<name>)
static/js/_<name>.js
を作成する- 返り値は文字列
'_<name>.js'
static/js/index.lispがJavaScriptコードを作成する側の例です。
(in-package :cl-user) (defpackage <% @var name %>.static.js.index (:use :cl :parenscript)) (in-package :<% @var name %>.static.js.index) (defun js-main () (ps (alert "Hello Parenscript!!")))
これを使う側はtemplates/index.lisp @ with_parenscriptのようになります(load-js
している部分)。
(in-package :cl-user) (defpackage <% @var name %>.templates.index (:use :cl :cl-markup) (:import-from :<% @var name %>.templates.layouts.defaults :with-default-layout) (:import-from :<% @var name %>.static.js.utils :load-js)) (in-package :<% @var name %>.templates.index) (defun render () (with-default-layout (:title "Welcome to Caveman2") (:div :id "main" "Welcome to " (:a :href "http://8arrow.org/caveman/" "Caveman2") "!") (:script :src (load-js :index) nil)))
ちなみに、load-js
はこんな感じです(関連関数は一部のみ抜粋)。
(defun write-to-js-file (name) (with-open-file (out (make-js-full-path name) :direction :output :if-exists :supersede :if-does-not-exist :create) (format t "(re-)load js: ~A" name) (format out (funcall (intern "JS-MAIN" (string-upcase (concatenate 'string "<% @var name %>.static.js." name))))))) (defun load-js (js-name &key (base-path nil)) (check-type js-name keyword) (let ((name (string-downcase (symbol-name js-name)))) (when (or *force-reload-js* (is-js-older name)) (write-to-js-file name)) (make-js-load-path name base-path)))
一応is-js-older
でファイルの新旧を見てコンパイルするか判断などやっているのですが、開発中は*force-reload-js*
をずっとtにしています*7。上記サイクルの2番目でプロジェクト名を指定している理由はwrite-to-js
にあります。export, importの手間を省くためにパッケージが上記の命名に従っていることを仮定してjs-main
を呼び出すということをしています*8。
ps-experiment
ps-experimentはParenscriptの不便だと思ったところを気まぐれに拡張しているライブラリです。
ここまでの開発サイクルに関する話題としては、パッケージもどきシステムを備えている点が重要です。上記のwith_parenscript
テンプレートの利用方法ではParenscriptコードの複数ファイルへの分割方法に言及していませんが、そこを補うものになってきます。
hello
という関数を別のパッケージ(ファイル)で作成して一緒にロードする例を下記に示します。なお、通常のCommon Lisp開発と同じくsome-package
の方も.asd
ファイルに追加しておく必要があります。
;; static/js/some-package.lisp (in-package :cl-user) (defpackage sample.static.js.some-package (:use :cl :parenscript :ps-experiment) (:export :hello)) (in-package :sample.static.js.some-package) ;; 普通のdefunのように関数を定義 (defun.ps+ hello (name) (concatenate 'string "Hello " name "!"))
;; static/js/index.lisp (in-package :cl-user) (defpackage sample.static.js.index (:use :cl :parenscript :ps-experiment :sample.static.js.some-package)) (in-package :sample.static.js.index) (defvar.ps+ *my-name* "eshamster") (defun.ps+ main () (hello *my-name*)) (defun js-main () ;; def~.ps[+]で定義したものも含めてJavaScriptを出力 (pse:with-use-ps-pack (:this) (alert (main))))
主なポイントは次の通りです。
def~.ps+
は同等のCommon Lispマクロdef~
と同じように使えますdef~.ps+
はCommon Lisp用の定義とJavaScript用の定義を同時に行います- 可能な限りこちらを使っておくと、シンボルの参照や関数の引数チェックなどCommon Lisp相当のコンパイル時チェックができて嬉しいです
def~.ps
(+
がない)バージョンはJavaScript用の定義だけを行います- JavaScriptのライブラリに依存している部分や、Parenscriptやps-experimentで未対応であるためにCommon Lispままではコンパイルできない部分は止むを得ずこちらを使う感じです
defun
,defvar
,defmacro
,defstruct
がこの形で利用できます
def~.ps[+]
で定義したものはwith-use-package
でまとめてJavaScriptコードとして出力しますjs-main
は上で解説したものです- 残念ながらJavaScript側では名前空間を分けることができていません…
- 単純にグループ化しているに過ぎないので「パッケージもどき」と言っています
js-main
関数を直接呼んでみると下記のようなJavaScriptコードが(文字列として)出力されます。
function hello(name) { return 'Hello ' + name + '!'; }; var MYNAME = 'eshamster'; function main() { return hello(MYNAME); }; alert(main());
細かい部分の話。
def~.ps[+]
は共通して各単位でのコンパイルが可能です。つまり、SlimeであればC-c C-c
で定義を更新できます- といっても、ブラウザ側へ反映させるためには、さらにブラウザ側でのリロードが必要になってしまいますが
- 出力されるJavaScriptコードを確認する一番手っ取り早い方法は
def~.ps[+]
に対するマクロ全展開(SlimeでC-c M-m
)です。周囲に直接関係のないCommon Lispコードも出てしまいますが、JavaScript部分は文字列としてまとまっているので、目視で見分けるのは簡単です。 - 上記以外も含め
ps-experiment
は全体として以下のような機能を持ちます*10- 上述のパッケージもどき(グループ化)機能
defstruct
サブセットの提供- ドット記法のサポート
- キャメルケース用のリードマクロ(Ex.
#j.div.innerHTML#
) - src/utils:
car
,cdr
,find
その他、Common Lispとしては欲しい関数をParenscriptで使うためのマクロ群 - src/common-macros.lisp: Parenscriptコードを書いていてよく出てくるパターンをマクロ化したもの
ps-experiment
の趣旨と少しずれるので、ps-experiment.common-macros
を明示的にインポートしないと使えないようにしています
cl-ps-ecs
ps-experiment
までは基盤よりのライブラリでしたが、ここからはアプリよりのライブラリです。
cl-ps-ecs
はCommon Lisp兼Parenscript用のEntity Component System(ECS)ライブラリです*11。ECSがどの領域にどの程度知られているか良く分からないのですが、個人的には[GDC 2015]エンジンとツールがないなら自作しよう。「World of Tanks Blitz」ローンチまでの道のりを開発者が振り返る - 4Gamer.net」の記事で名前を知って以来一度作ってみたいと思っていました。
良い解説は調べれば出てくる(Understanding Component-Entity-Systems - Game Programming - Articles - Articles - GameDev.netとか。英語記事ですが図を見るだけでも問題意識は伝わると思います)ので、ECSについては簡単で適当な解説だけします。Unity知っている人はそのイメージで大体良い気がします*12。
パッと見誤解しやすいですが、「EntityとComponentからなるSystem」ではなく「EntityとComponentとSystemからなるアーキテクチャ」です。それぞれ次のようなものです。
- Entity: 識別子と複数のComponentを持つ
- Component: 型とデータを持つ
- System: 特定のComponent(の組み合わせ)を持つEntityを認識して処理を行う
- 例えば、「当たり判定」Systemは「物理」Componentを持ったEntityを処理する
保持するComponentによってEntityが分類される = 適切なSystemに認識されるという点が重要です。ここがクラス継承による型ベースのオブジェクト表現(GameObjectクラスがあって、それを継承したPlayerクラスとEnemyクラスがあって、さらにEnemyを継承したFlyingEnemyクラスがあって…というもの)と大きく異なる点です。多重継承の罠に陥ることなく、必要なComponentを付け外しするだけでEntityに機能を柔軟に追加・削除できる点が、試行錯誤が多く、柔軟性が求められるゲーム開発に向いていると言われています。
さて、このライブラリの使い方ですが、こんな感じになります。まずはecs-component
を継承してComponentを適当に定義します。
(defstruct (vector-2d (:include ecs-component)) (x 0) (y 0)) (defstruct (position-2d (:include vector-2d))) (defstruct (velocity-2d (:include vector-2d)))
次にecs-system
を継承してSystemを定義して登録(register-ecs-system
)します。このmove-system
は位置(point-2d
)と速度(velocity-2d
)を持ったEntityに対して、位置を速度の分だけ更新します。
(defun process-move-system (entity) (with-ecs-components ((pos position-2d) (vel velocity-2d)) entity (incf (position-2d-x pos) (velocity-2d-x vel)) (incf (position-2d-y pos) (velocity-2d-y vel)))) (defstruct (move-system (:include ecs-system ;; どのコンポーネントを持つEntityを処理するか (target-component-types '(position-2d velocity-2d)) ;; 対象Entityに対してどのような処理をするか (process #'process-move-system)))) ;; 第1引数の:moveは単なる識別子なので適当に (register-ecs-system :move (make-move-system))
そして、ecs-entity
型のEntityを生成し、add-ecs-component[-list]
で必要なComponentを追加します。Systemに認識させるためにこれをグローバルに登録(add-ecs-entity
)します。
(let ((entity (make-ecs-entity))) (add-ecs-component-list entity (make-position-2d :x 0 :y 0) (make-velocity-2d :x 1 :y 0)) (add-ecs-entity entity)) (let ((entity (make-ecs-entity))) (add-ecs-component-list entity (make-position-2d :x 0 :y 0) (make-velocity-2d :x 0 :y -1)) (add-ecs-entity entity)) ;; velocity-2dを持たないEntity (let ((entity (make-ecs-entity))) (add-ecs-component-list entity (make-position-2d :x 0 :y 0)) (add-ecs-entity entity))
ecs-main
を呼び出すと登録済みのSystemが一度走ります。
(defun print-all-entities () (do-ecs-entities entity (with-ecs-components (position-2d) entity (format t "ID = ~D, pos = (~A, ~A)~%" (ecs-entity-id entity) (vector-2d-x position-2d) (vector-2d-y position-2d))))) (progn (print-all-entities) (format t "--- Run ecs-main ---~%") ;; ↓これ (ecs-main) (print-all-entities))
出力は次のような感じです。「速度」Componentを持たないEntity(ID = 3)は移動していないことが分かります。
ID = 3, pos = (0, 0) ID = 2, pos = (0, 0) ID = 1, pos = (0, 0) --- Run ecs-main --- ID = 3, pos = (0, 0) ID = 2, pos = (0, -1) ID = 1, pos = (1, 0)
cl-web-2d-game
次のcl-web-2d-game
はアプリ側のライブラリで、Web上で2Dのゲームを作るためのライブラリになる…といいですが、まだ柔らか過ぎて使い方など提示できる感じではありません…。もともとcl-shigi-simulator
と一緒に育てたのち分離する予定だったのですが、まだ間に合っておらず、cl-shigi-simulator/js-lib
フォルダに内蔵されたままです。
主な特徴は次の通りです。
- 描画周りはThree.jsをある程度抽象化する形で書いている
- このため両対応な
cl-ps-ecs
とは異なりあくまでParenscript用ライブラリです
- このため両対応な
cs-ps-ecs
を用いたECSなアーキテクチャである- ベーシックなComponentやSystemを提供している
- その他便利そうな関数群を提供している
まだ柔らかなので紹介しづらいです…。記事の方も力尽き気味なので、気の向いたところだけ説明する形にします(そのうちまた記事にしたい…)。
このキャプチャに写っているのは、cl-shigi-simulator
というタイトルにも入っている中ボス「鴫(シギ)」を模したものです。本体部分2パーツとビット4つの計6パーツからなります。全部は長いので、ビット作成部分とそれらをまとめて鴫を構成する部分から抜粋して見ていきます。全体はstatic/js/shigi.lispです。
まずはビットの作成部分です。以降も含めてですが、必要な定義を全て記載しているわけではないので、コメントから雰囲気を察してください。
;; ビットEntityeを4つ作成し、リストにして返す (defun.ps make-shigi-bits () (let ((result '()) (num-bit 4) (rot-speed (get-param :shigi :bit :rot-speed)) (r (get-param :shigi :bit :r)) (dist (get-param :shigi :bit :dist))) (dotimes (i num-bit) (let* ((bit (make-ecs-entity)) (angle (* 2 PI i (/ 1 num-bit))) (model-offset (make-vector-2d :x (* -1 r) :y (* -1 r) :angle 0)) (point (make-point-2d))) ;; --- 円周上に並ぶように位置調整 --- ;; (adjustf-point-by-rotate point dist angle) ;; --- タグを付与 --- ;; (add-entity-tag bit "shigi-part" "shigi-bit") ;; --- 各種コンポーネントを持たせる部分 --- ;; (add-ecs-component-list bit ;; 描画用コンポーネント(円…を作成する関数を作っていないので、辺の多い正多角形) (make-model-2d :model (make-wired-regular-polygon :r r :n 100 :color (get-param :shigi :color)) :depth (get-param :shigi :depth) :offset model-offset) ;; 当たり判定用コンポーネント (make-physic-circle :r r :on-collision #'toggle-shigi-part-by-mouse :target-tags *shigi-collision-targets*) ;; 位置コンポーネント(ローカル座標) point ;; 回転移動用コンポーネント (make-rotate-2d :speed rot-speed :angle angle :radious dist) ;; Key-Valueパラメータ用コンポーネント (init-entity-params :color (nth i (get-param :color-chip :colors)) :display-name (+ "Bit" (1+ i)) :bit-id i :enable t))) (push bit result)) result))
かいつまんで見ていきます。
まず、一番特徴的なのはやはりECSがベースになっていることです。描画をするのか(model-2d
)や当たり判定をするのか(physic
)はComponentの有無によって変わってきます。ここにはないですが、あらゆる操作に対していちいちSystemを作成することは現実的ではないため、script-2d
という、Entityを引数にとる任意の関数を登録するためのComponentも存在します。
次に、本システムで用意しているパラメータ管理機構には大きくglobalレベルのものとentityレベルのものがあります。globalレベルのものは上記でも所々で利用されているget-param
です。cl-web-2d-game
ライブラリとして提供しているのは下記です。
convert-to-layered-hash
: 階層的なKey-Value構造(ハッシュ)を作るためのDSLget-layered-hash
: 上記で作成した構造から値を取り出す(もしくは関数を実行する)
;; 下記の(+ 10 20)のようにリストを書いた部分は ;; get時に関数として評価 (defvar *hash* (convert-to-layered-hash (:position (:x 12 :y (+ 10 20)) :size (:width (* 2 3) :height 100) :some-list (list 1 2 3)))) (get-layered-hash *hash* :position :x) => 12 (get-layered-hash *hash* :position :y) => 30 (get-layered-hash *hash* :size :width) => 6 (get-layered-hash *hash* :size :height) => 100 (get-layered-hash *hash* :some-list) => (1 2 3)
本アプリではglobalなハッシュはせいぜい一つしか必要でないため、get-param
でget-layered-param
をラップしてハッシュの指定を省略しています。
もう一つのEntityレベルのパラメータはinit-entity-params
でコンポーネントとして持たせています。中身は単なる1階層のハッシュです。抜粋部分にはありませんが、利用はget-entity-param
やset-entity-param
によって行います(setf
には対応できておらず…)。
だいぶおざなりな解説をしている自覚はありますが、ここで鴫全体を構成するためのコードへ向かいます。
(defun.ps make-shigi () ;; centerは全体をまとめる親となるEntityでグラフィックを持たない ;; bodiesは鴫本体の2パーツ (let ((center (make-shigi-center)) (bodies (make-shigi-bodies)) (bit-list (make-shigi-bits))) (add-ecs-entity center) ;; centerを親にしてbodyとbitをadd-ecs-entityしていく (dolist (body bodies) (add-ecs-entity body center) ;; markerは上記画像の各パーツ中心にある四角のこと (add-ecs-entity (make-center-point-marker) body)) (dolist (bit bit-list) (add-ecs-entity bit center) (add-ecs-entity (make-center-point-marker) bit) (when (oddp (get-entity-param bit :bit-id)) (toggle-shigi-part bit)))))
ここで特徴的なのはEntityの親子関係の登録です。cl-ps-ecs:add-ecs-entity
は第2引数で親となるEntityを指定できます。cl-web-2d-game
ではこの親子関係を主に座標の管理に利用しています。具体的には、子は座標データ(位置と回転)を親に対する相対座標として持っています*13。
この方法をとると嬉しいのは、親と子の移動処理を独立して書けるという点です。例えば、一番親となるcenter
から見ると、子供であるbody
やbit
のことを気にせずに移動しても(今回は固定位置ですが)、子供は勝手についてきてくれます。子供、例えばbit
の方から見ると、親の中心点に対して回転するという動作だけ記述しておけば、親がどこにいるかを気にする必要はありません。今回の例で極端なものはmake-center-point-marker
から作っているマーカで、マーカ自身は一切移動の処理(Component)を持ちませんが、勝手に親のbody
やbit
についていっています。
あとはjs-lib
フォルダ配下のファイルレベルで何ができているかをざっと見て終わりにします。
- Three.jsへの依存が強い部分
2d-geometry.lisp
: 描画用のモデル作成。丸や四角形や多角形を作れますdraw-model-system.lisp
: 描画用のSystemcamera.lisp
: 2Dに十分な形で3Dカメラを初期化・管理
- その他のJavaScriptライブラリに依存する部分
input.lisp
: マウス、キーボード、タップといった入力関係の処理gui.lisp
: アプリ右上の操作パネル。dat.GUIのラッパー
- JavaScriptへの依存がない部分
basic-components.lisp
: 2Dのベクタなど基本的なComponent群calc.lisp
: 主にベクタ関連の計算collision.lisp
: 衝突計算。現状、円と円、円と任意凸多角形の判定ができます- 任意凸多角形同士の判定はまだ実装していません
utils.lisp
: その他整理できていない諸々- ゲームの初期化・スタート(ここはThree.jsに依存)
- wtf-trace用のラッパー
- 上記のglobalなパラメータの作成・読み込み(ここは依存なし)
まとめ
見ての通りだいぶ荒削りですが、どうにかオールCommon LispでThree.jsなWebアプリを作れるようになりました。基盤となるライブラリを整えつつでしたが、軌道に乗ってくると全部Common Lisp(もどき)で書けて嬉しいです。また、前々から気になっていたECSを作れたのも良かったです。
今後としては、jsclやClojureScriptといったところをちゃんと調べて反映させていかないとだめですね…。その前に、今回作ったシミュレータはもう少し仕上げておきたいと思います。他、触った方は分かると思いますがまだまだパフォーマンスが悪いのでその改善も…。
最後に、もっと小分けにして少しずつ記事にしておけば良かったと思いました まる
*1:さくらのDockerホスティングサービスArukasのプレビュー版を使っています。仮置きなので、メンテのために適当に止まったり、いつの間にか消滅していたりするかもしれません。
*3:qlotに限らず新しくlocal-project配下にプログラムを置いたときなのですが、自身の環境ではなぜか大本のbashをいったん落とさないとql:quickloadから見えるようになりません…。前掲のDockerイメージを作るDockerfileではそれができないので、ql:*local-project-directories*にcl-shigi-simulator配下にできたquicklispフォルダをpushすることでどうにか回避しています
*4:もっと良いやり方がありそうですが、ベースとなるスケルトンの更新の反映を考えると、他に良い方法が思いつきませんでした…
*5:CSS部分をLisp化したものがないのは単にCSSヘビーなものを書いていないからです…。
*6:ここは以前記事(Caveman2でCL-Markupを使う準備 - eshamster’s diary)にしたものを少し整理してスケルトン化したものです
*7:理由は後述のps-experimentに関連します。1つ目は関数コンパイルだけで定義更新できるので、ファイルの保存を一々したくないこと。もう1つは、load-js関数の定義されたファイルしか見ていないため、ps-experimentで実現するファイル分割に対応できていないことです…。
*8:Dirtyかもしれません
*9:useは汚いのでimportしているシンボルの属するパッケージを探す形の方がまだ良さそうですね…。少し重そうなのが気になりますが。
*10:基本的には以前「Parenscriptで遊んでみる」シリーズで記事にしていた内容です
*11:細かな定義論が分かっていないので「ECSもどき」が適切かもしれません
*12:自身はUnity少ししか触ったことがないですが…。また、Unity社はECSとは似て非なるものだと言っているはずです
*13:割と普通の設計だとは思うのですが、以前書いたライブラリはそうしていなかったため、個人的には印象の強いポイントです
Alpineベースの(多少)軽いCommon Lisp実行用コンテナ
前書き
以前「Common Lisp開発環境 on Docker - eshamster’s diary」で紹介した開発用環境とは別に、Common Lispを実行するためだけの環境を作ってみました*1。が、手元でdocker images
を見ると800MB、Docker Hubで見ても212MBと巨大でした。これをベースにいくつもコンテナ起こすには辛いサイズだろう…と思い、軽量化を試みました。
CentOSをやめて、ベースにもっと軽いOSを使おうと調べてみると、Alpine Linux(紹介記事:「Alpine Linux で Docker イメージを劇的に小さくする - Qiita」)というOSが軽さを武器にシェアを広げているようでした。ということで、そこにCommon Lisp実行用コンテナを乗せ換えてみましたという記事です。
Dockerコンテナ作成
目標
下記が入った状態にします。
- Roswell
- 実行環境入れるのも楽ですし(Quicklispの設定自動でしてくれたり)、デプロイ用のスクリプト(
clackup
とか)のインストール用に欲しいのでひとまず入れておきます
- 実行環境入れるのも楽ですし(Quicklispの設定自動でしてくれたり)、デプロイ用のスクリプト(
- SBCL
- Roswell入った時点で簡単に入れられるのですが、そこそこ処理時間がかかるのでデフォルトで入れてみました
- どの処理系入れるかはこのリポジトリを引き継ぐ側に任せた方がよいのかもしれませんが…
- 少なくともサイズを見たいという目的では、処理系ありのサイズを見ないと意味がないという理由もありますが
Dockerfile
こんな感じのDockerfileになりました。Roswellのインストールに必要なモジュールは一通り掃除してますが、SBCL周りの掃除は甘めです。Roswellのおかげでだいぶ楽ができているので、ポイントらしいポイントもないです。あえて言えば、Alpineの作法に則ってvirtual
でビルド用ライブラリをグループ化してapk del
でまとめて消しているぐらいでしょうか。
結果
一応Docker Hubに上げました(Version 2.0からがAlpine版)。
https://hub.docker.com/r/eshamster/cl-base/
サイズのBefore→After
docker images
での表示:800MB→192MB- Docker Hub上の表示:212MB→52MB
割と満足な結果です。内部を見てみるとやはりSBCL関連のサイズが大きいです。ソースの入った~/.roswell/src
が47.8MBで、バイナリやダンプイメージの入った~/.roswell/impls
配下が99.1MBでした。さらに小さくするのであればこの辺りの整理が必要です。
使ってみる
Webサーバを立てて動かせるとそれっぽい感じがするので、とりあえずCaveman2のテンプレートでサーバを立ててみます。
2ファイル用意します。CMDで直接clackup
しても良いのですが、環境変数を利用できないようなので起動用にシェル(1行)を分けました。
- Dockerfile
FROM eshamster/cl-base:2.1 RUN ros run -e '(ql:quickload :caveman2)' -e '(caveman2:make-project #p"/root/.roswell/local-projects/sample-app")' -e '(ql:quickload :sample-app)' -q RUN ros install clack ENV VIRTUAL_PORT 8080 COPY run_app.sh /root CMD ["/root/run_app.sh"]
- run_app.sh
#!/bin/sh clackup --port ${VIRTUAL_PORT} ${HOME}/.roswell/local-projects/sample-app/app.lisp
後はbuildしてrunするだけです。
$ docker build -t sample-cl . $ docker run --name=sample -p 8888:8080 -d sample-cl
ローカル環境であれば、あとはhttp://localhost:8888/
にアクセスすればWelcome to Caveman2!
の文字が見えるはずです(手元の環境だと起動に10秒ほどかかりました)。
できていないこと
実際の運用に必要なあれこれがまだ分かっていないです。
あと開発環境の方もAlpineベースにして軽くしたいです…そのうち。
後続の関連記事
- 開発環境の方もAlpineベースにしました。
*1:前記の開発環境もこの上に移し換えようかという目論見もあったのですが、面倒でやってません
リーダーマクロで非公開シンボルの参照を簡略化する @ テスト
「#:g1: リーダーマクロでシンボルの略記をする」を読んでいて、こういうリーダーマクロの使い方することあるなと思ったので投稿。大した用途ではないですが…。
こんなことはないでしょうか。
- インタフェースとしては不要なので公開はしたくない補助関数がある
- とはいえ、それなりに面倒な関数なので単体でテストしておかないと不安がある
- しかも、それなりに数があるので、毎回
<package>::<symbol>
と書くのも面倒くさい
Domain Specific Language(DSL)まがいの大き目なマクロを書いていると良くある…のかもしれません。
自身の例ではkv-kneaderというライブラリ*1がありました。src/kneader.lisp
という実質120行程度のファイルがありますが、公開しているのはknead
という15行のマクロひとつで、後はこのマクロのための補助関数(マクロ)です。
こうなってくると補助関数もテストしておかないと落ち着かないのですが、都度kv-kneader.kneader::parse-a-key-description
などと書くと見るのも辛いです。
ようやく本題ですが、こんなときに$:parse-a-key-description
と書けると幸せかと思い、こんなリーダーマクロを書きました。
(eval-when (:compile-toplevel :load-toplevel :execute) (make-dispatch-macro-character #\$) (set-dispatch-macro-character #\$ #\: #'(lambda (stream &rest rest) (declare (ignore rest)) (intern (symbol-name (read stream nil)) (regex-replace "-TEST" (package-name *package*) "")))))
利用にあたっては、<hoge>.<fuga>
というパッケージのテストは<hoge>-test.<fuga>
というパッケージで行うという前提が必要です。このとき、<hoge>-test.<fuga>
パッケージ内で$:piyo
とすると、"-test"がとれて<hoge>.<fuga>::piyo
というシンボルになります*2。
kv-kneader/kneader.lisp at master · eshamster/kv-kneader · GitHubが件のテストファイルですが、これの$:
が全部kv-kneader.kneader::
になっていたら、中々気が狂いそうです。
Common LispでSlack botを作る
チャットbotなるものにも(今さら)手を出してみようと、Slack用のサンプルbotをCommon Lispで書いてみました。
使い方
一応使い方です。
- 上記プロジェクトをquicklispからロード可能な場所にclone
settings.json.in
を参考にsettings.json
(下記参照)を作成- REPL上でサーバを立ち上げる。
(ql:quickload :sample-cl-bot)
(clack:clackup sample-cl-bot:*app* :port 16111)
- SlackのOutgoing hookに
http://<アドレス>:16111/
を登録
基本編:Slackとのやりとり
そもそもSlackとどうやりとりするのか、という話です。この手の解説は既に良いものが沢山あると思いますので、実装を簡単に見る程度にします。
流れだけ言いますと、Slack側ではOutgoing Hookにキーワード(上記の画像では"alien:")とLispサーバのアドレスを設定し、サーバ側ではSlackから受け取ったポストを元に適切なIncoming HookのURLへメッセージ(JSON形式)を投げ返すだけです。
サーバ部分は以下のような感じで、ningleを使ってルーティングしています。
(defvar *app* (make-instance 'ningle:<app>)) (setf (ningle:route *app* "/" :method :POST) #'(lambda (params) (aif (get-incoming-hook-url params) (dex:post it :content (parse-input (extract-posted-text params) params) :headers '(("content-type" . "application/json"))))))
get-incoming-hook-url
とparse-input
について簡単に解説。
まず、get-incoming-hook-url
はその名の通り、投稿すべきIncoming Hookのアドレスを取り出す関数です。Outgoing Hookによるポストはtokenの情報を持っているので、下記の設定ファイルを基にこのtokenと宛先のIncoming Hookの対応をとります。
{ "pairs": [ { "token": "<XXX>" "incoming_hook": "https://hooks.slack.com/services/XXXX/XXXX/XXXX" }, { "token": "<YYY>" "incoming_hook": "https://hooks.slack.com/services/YYYY/YYYY/YYYY" } ] }
次に、parse-input
ですが、これは受け取ったメッセージを解析して、適切なメッセージ(JSON形式)を作り出す関数(の入り口)です。内部では最終的に以下の関数を呼び出してJSONを構成します。
(defun make-post-content (text) (jonathan:to-json (list :|text| text :|icon_url| "http://www.lisperati.com/lisplogo_alien_128.png" :|username| "Lisp Alien")))
応用編:対話式のやりとり
このbotでは下記画像のように対話式のやりとりをサポートしています(画像ではrememberコマンドの後2つの応答。getコマンドは単なる結果確認用)。その実装に関する話です。
実現機構
肝になる部分は下記のように簡単な実装になっています(上記基本編で呼んでいたparse-input
関数の定義がこれ)。なお、slet
, it
はanaphoraライブラリからインポートしたものです*1。
(defvar *continuity-table* (make-hash-table :test 'equalp)) (defun parse-input (text params) (slet (gethash (make-params-hash params) *continuity-table*) (multiple-value-bind (content continuity) (if it (funcall it text params) (parse-command text params)) (setf it continuity) content)))
Slackから受け取ったメッセージをパースしているのは、(if it (funcall it text params) (parse-command text params))
の部分です。ifの分岐方向によらず下記のように動作します。
- 引数
text
:受け取ったメッセージparams
:その他Slackから受け取ったパラメータ一式*2
- 返り値
ポイントとなるのが第2返り値であるcontinuity
です。この関数(またはNIL)は*continuity-table*
に格納されます。このテーブルのキーとなっているのはユーザ識別情報です。したがって、同じユーザから再度メッセージが来た場合、下記のように動作します。
- テーブルにNILが格納されている → 標準のパーサである
parse-command
を呼び出す - テーブルに関数が格納されている → 対話用のパーサであるその関数を呼び出す(
(funcall it text params)
)
結局、普段はparse-command
で標準的な処理を行い、対話処理をしたい場合は後継の対話処理を登録しておき次回はそれを使う、というのが全体像です。
上記画像の例では下記のように対応します。
なお、聞きかじった程度の「継続」に近い気がしたので、continuity
と名付けていますが、違うかもしれません…。
継続して利用する値の受け渡しについて
もう1点、"alien: the best game"とした時点で、ひとつ前の入力内容("斑鳩")がどこかに保存されている必要があります。これをどこに保存しているかという話ですが、クロージャに捕捉しています。
実例を見るのが早いかと思います。下記のmake-asking-value-fn
関数がこの"alien: the best game"とした時点で呼ばれる関数を生成します。前段の"斑鳩"はkey
として捕捉され、後で呼ばれる関数であるlambda
以下から「見る」ことができます。
(defun make-asking-value-fn (key) (lambda (text params) (if (is-empty-string text) (values (make-post-to-mention ;; 「@<ユーザ名> ...」なポストを作る関数 (format nil "What is '~A'?" key) params) (make-asking-value-fn key)) (register-pair-and-make-post key text params))))
(一応)機能紹介
機能紹介…といっても、サンプル用に実装しただけなので、特に実用的な機能はありません。
remember/get/forget
多少実用的なものその1。
見てのとおりのkey, valueストアです。覚えた情報は基本的にtoken単位(≒チャンネル単位)で管理しているので、同じチャンネルであれば他の人が登録した情報もget
で見ることができます。
なお、永続化していないので環境を再起動すると消えます…*3。
rememberコマンドは「応用編」では対話式に覚えさせましたが、この画像のようにremember <key> = <value>
で一発登録もできます。
画像にはありませんが、forget <key>
で覚えた<key>
の情報を削除します。
weather
多少実用的なものその2。
livedoorのお天気WebサービスのAPI*4を利用して、指定した地域の天気予報をとってきます。
なお、上記画像ではweather
のエイリアスであるwf
("Weather Forecasts")を利用しています。
また、地味にremember
コマンドと連係しています。この画像のように、remember
コマンドで覚えたキー(上記のremember/get
の例で覚えさせたもの)を地域名の代わりに利用できます。
その他
純粋なサンプル達
- hello:こんにちは
- echo:そのまま返します
- 余談ですが、下記画像のように、echoした情報にキーワード(
alien:
)が含まれればコマンドとして解釈してしまいます- 必ずキーワードを消費するので無限ループはしない…はず
- 余談ですが、下記画像のように、echoした情報にキーワード(
- number game:対話式インタフェースのサンプルに作りました
- エイリアンの考えている番号を当てるだけのゲームです
- 勝率おおよそ1/7の○○ゲーです
Common Lisp開発環境 on Docker
Docker上でCommon Lisp開発環境(by Emacs+SLIME)を起こしてみました。何が入っているか分からない開発環境だとどうしてもアップデートが億劫になるので、その辺をきっちりコード化したかったというのが動機です。
どちらかと言うとAnsibleやChefのように直接サーバを設定するタイプの方がこの用途ではスタンダードな気がします(単なる印象)が、Dockerの方が試行錯誤で出るゴミ*1が残らないので好みでした。
注意点
執筆時点(2016/08/11)では、CentOS 7のyumではDocker 1.10が入りますが、事実上1.11以上が必須です。1.10ではEmacsの描画が激しく崩れるという問題があるためです(参考の前回記事:Dockerを1.10から1.11へアップデート on CentOS7 - eshamster’s diary)。
また、Dockerfileからビルドする場合にも必要な可能性があります。Emacsのビルド部分で詰まる可能性があるためです(参考の前々回記事:Docker上のEmacsのビルドでハマった話 - eshamster’s diary)。どういう訳か1.11.2では参考記事の問題は起きていないので、1.11にしておくとスムーズ…かもしれません*2。
概説
まずは作ったものへの諸リンク*3。
- DockerHub: https://hub.docker.com/r/eshamster/cl-devel/
- GitHub: https://github.com/eshamster/docker-cl-devel
元々は以前書いた記事「Common Lisp開発環境を新規に作ったのでメモ - eshamster’s diary」をそのまま再現してCentOS 6ベースで作った(1.0~1.3)のですが、Clozure CLの最新版が入らないなど問題しかないので、CentOS 7をベースに作り直しました(2.0~)。やっていることは変わらないので気になった部分だけメモ。
- CentOS 7化周りの話
- 一番困った点はw3mのインストール(emacsからのHyperSpec閲覧用)です。標準リポジトリからなくなったので、ソースからビルドが必要ですが、単純にはできません。「[CentOS7] emacs24にemacs-w3mインストール - Qiita」にあるように細工が必要です
- もうサポートしないということかと思うので、移行先を探すのが正道ですかね…
- roswellのビルドには標準リポジトリのautoconfで十分でした
- roswellのバージョンを固定した方がいいか迷いどころですが、適宜最新化しながら使う予定なので都度対応で良いかなと
- Emacsは24系であればいいので、CentOS 7ならyumでいいはず…と思っていたら、yumで入る24.3では動かないEmacs拡張(どれかは忘れました)がありました。そのため、結局ソースから24.5を入れました
- 一番困った点はw3mのインストール(emacsからのHyperSpec閲覧用)です。標準リポジトリからなくなったので、ソースからビルドが必要ですが、単純にはできません。「[CentOS7] emacs24にemacs-w3mインストール - Qiita」にあるように細工が必要です
- その他の話
実際に使うとき
実際に開発に利用する上ではeshamster/cl-develだけでは不足です。GitHubにpushするためのSSH鍵の設定や、コンテナ終了後もデータを残すためのボリュームの設定等が必要です。とはいえ、こうしたパーソナルな設定を公開Dockerfileに書くのも違う気がします。
そのため、下記の3ファイル(Dockerfile, 設定ファイルのsetenv, 起動用のrun.sh)とSSH鍵*4を用意し、run.shを叩いてコンテナを起こしています。なお、ホストのマウント先に指定した${HOME}/work/lisp
は事前にchown -R 1000:1000 ${HOME}/work/lisp
*5と所有者設定をしておかないとゲスト側から触れません。
run.shでコンテナを起動した後は、emacsを立ち上げ*6、M-x slime
ですぐにslimeが使えます。保存の必要なものは~/work/lisp
以下に保存します。また、コンテナをdetach*7した後は、docker attach cl
で再接続できます(プロセス名cl
はsetenv
で設定)。
- Dockerfile
FROM eshamster/cl-devel:2.0 # --- git settings --- # RUN git config --global user.name "<ユーザ名>" && \ git config --global user.email "<メールアドレス>" # --- ssh settings --- # ARG user=dev ARG SSH_HOME=/home/${user}/.ssh RUN mkdir ${SSH_HOME} && \ chmod 700 ${SSH_HOME} USER root COPY id_rsa ${SSH_HOME} COPY id_rsa.pub ${SSH_HOME} RUN chown ${user}:${user} ${SSH_HOME}/* && \ chmod 600 ${SSH_HOME}/* # --- other settings --- # USER ${user} RUN ros install prove RUN echo "export PATH=${HOME}/.roswell/bin:${PATH}" >> ${HOME}/.bashrc RUN echo "export LANG=ja_JP.UTF-8" >> ${HOME}/.bashrc
- 設定ファイル(setenv)*8
export HOST_PORT=17380 export GUEST_PORT=18616 export RUN_NAME=cl
- 起動用シェル(run.sh)
#!/bin/bash set -eu . "${1:-$(dirname ${0})/setenv}" docker rmi $(docker images | awk '/^<none>/ { print $3 }') || echo "ignore rmi error" docker rm `docker ps -a -q` || echo "ignore rm error" docker build -t cl . docker run --name=${RUN_NAME} -p ${HOST_PORT}:${GUEST_PORT} -e "OPEN_PORT=${GUEST_PORT}" -e "HOST_PORT=${HOST_PORT}" -v ${HOME}/work/lisp:/home/dev/work/lisp -it cl /bin/bash
感想
上記の環境が整ってしまえば、後はrun.shをたたくだけで簡単にフレッシュな環境が得られるので良い感じです(気づいたら環境が壊れて色々動かなくなった…となってもすぐ戻せますし)。しばらく使ってみましたが、Dockerを1.11にアップデートしてEmacsの描画問題が解決してからは快適に利用できています。
後続の関連記事
*1:実はそのゴミ(だと思っていたもの)のおかげで動いていたとか、設定に書いていない元々入っていたもののおかげで動いていたとか、心配性なのでその辺りがどうしても気になります…
*2:曖昧な言い方になっているのは、きちんと調査できていないためです。本当に1.11にアップデートしたことで解消されたのか半信半疑です…
*3:DocekrHubとGitHubの連係機能を後から知ったので連係できていないという…。後からできるのでしょうか
*4:.pubの方は必須ではないですが、一緒に持っておかないと対応が分からなくなりそうなので…
*5:設定すべきID(ここでは1000番)はゲスト側で/etc/passswdを見れば確認できます
*6:最初からrun.shで/usr/bin/emacsを立ち上げても良い気がします。Emacsの走る環境も触れた方が良いかと思い、/bin/bashで立ち上げています
*7:なお、Dockerの初期設定ではdetachがCtrl-P Ctrl-Qという正気とは思えないキーバインドになっているので、「docker-1.10.0からデタッチキーが変更できるようになった - Qiita」あたりを参考に変更しましょう。Ctrl-Pはないよなあ…
*8:portの設定は必須ではないですが、Webアプリケーションを開発するような想定でつけています。ゲスト側ではGUEST_PORTで接続を待ち受け、つなぐ側はホストのHOST_PORTに繋ぎます
Dockerを1.10から1.11へアップデート on CentOS7
メモ記事
前説
前回の記事↓で首尾良く?Docker上でEmacsを動かせるようになったのは良いものの、表示が崩れまくって非常にストレスフルという問題がありました。
Docker上のEmacsのビルドでハマった話 - eshamster’s diary
調べてみると、Dockerの下記のissueが引っ掛かりました。バージョン1.7~1.10では壊れてるけど、1.11で直るとのこと。現時点(2016/7/31)ではCentOSのデフォリポジトリからとれるのは1.10だったので、1.11にアップデートした次第です。
regression terminal drawing on 1.7.1 · Issue #15373 · docker/docker · GitHub
本題
とりあえずサービスを止めます。
$ sudo systemctl stop docker
Docker 1.10をサクッと消します*1。
$ sudo yum remove docker docker-selinux docker-common
Docker 1.11のリポジトリを登録します。
$ sudo cat<<EOF>/etc/yum.repos.d/docker.repo [dockerrepo] name=Docker Repository baseurl=https://packages.docker.com/1.11/yum/repo/main/centos/7 enabled=1 gpgcheck=1 gpgkey=https://yum.dockerproject.org/gpg EOF
インストール&起動(完)。
$ sudo yum install docker-engine # "docker"ではない $ sudo systemctl start docker # サービス名やコマンドは"docker"で変わらず
少し使ってみたところ、Emacsの表示が崩れなくなり大変快適になりました。ただ、pareditの挙動が相変わらずあやしい…。
*1:docker-commonの削除が常に必要かは不明です。自身の環境ではこれも消しておかないと、コンフリクトで1.11のインストールに失敗しました。
Docker上のEmacsのビルドでハマった話
Docker上でEmacsをビルドしようとしてハマったので対処方法と、ついでに簡単に調査したメモです。
現象
環境は次のような感じでした。
まず、確認用に次のDockerfileを用意します。なお、centos:7
としても現象は同じでした(ただし、yumのインストール対象にmakeを追加する必要があります)。
FROM centos:6 ARG emacs=emacs-24.5 RUN yum install -y gcc lcurses-devel wget ncurses-devel ARG work_dir=/tmp/setup RUN mkdir ${work_dir} RUN cd ${work_dir} && \ wget http://mirror.jre655.com/GNU/emacs/${emacs}.tar.gz && \ tar zxf ${emacs}.tar.gz && \ cd ${emacs} && \ ./configure --without-x && \ make
これをビルドしてみると・・・
$ docker build --no-cache -t test-emacs-build . ~略~ Finding pointers to doc strings... Finding pointers to doc strings...done Dumping under the name emacs ************************************************** Warning: Your system has a gap between BSS and the heap (22442648 bytes). This usually means that exec-shield or something similar is in effect. The dump may fail because of this. See the section about exec-shield in etc/PROBLEMS for more information. ************************************************** /bin/sh: line 7: 5952 Segmentation fault (core dumped) ./temacs --batch --load loadup bootstrap make[1]: *** [bootstrap-emacs] Error 1 make[1]: Leaving directory `/tmp/setup/emacs-24.5/src' make: *** [src] Error 2
makeで死にます。
対処方法その1
とりあえず、メッセージに従ってetc/PROBLEMS
(テキストファイル)でexec-shield
を検索してみます。見てみると、Linuxのセキュリティ機構であるExec-shield
(プロセスのメモリ配置のランダム化?)が問題のようです。
解決方法だけ抜粋すると、下記の通りです。
echo 0 > /proc/sys/kernel/exec-shield echo 0 > /proc/sys/kernel/randomize_va_space
ということで以下のようにして、「ホスト側で」一時的にrandomize_va_space
を無効化してからdocker build
すればOKです(exec-shield
の方は無関係でした)。
$ cat /proc/sys/kernel/randomize_va_space 2 $ sudo bash -c "echo 0 > /proc/sys/kernel/randomize_va_space" $ docker build --no-cache -t test-emacs-build .
emacsのビルド部分が通った後はrandomize_va_space
を元に戻して問題ありませんでした。といっても、Emacsのビルド部分を通るたびにこれが必要なのは何かと不便そうです。
少し調査
randomize_va_space
というキーワードが手に入ったのでGoogle先生に聞いてみると、次のissueが引っ掛かりました。
Ubuntu 16.04: Dockerfile cannot build emacs · Issue #22801 · docker/docker · GitHub
これはDocker側のissueですが、ここからEmacs側のスレッド(同じ人?)にもリンクが張られています。
https://debbugs.gnu.org/cgi/bugreport.cgi?bug=23529
で、斜め読みしかしていないのですが、Emacsのスレッドで示されている下記コードのpersonality
というsyscallが問題になっているようです。これで、一時的にrandomize_va_space
に0をセットするのに相当する操作をしているようですが、これがDockerのゲスト上ではうまく働かない(ホスト側を変更する必要があるのにそれができない)ということだと思います。
https://github.com/emacs-mirror/emacs/blob/master/src/emacs.c#L802-819
結局のところ、Docker側がpersonality
が動作するようなオプションを入れるかEmacs側がこの動作を修正するかですが、前者はDockerのポリシー的におそらく難しく、後者は望ましいが技術的に難しいという話になっているようです(斜め読みなので違ったらすみません)。
対処方法その2
最新版の利用や細かいバージョンの指定ができないのは残念ですが、この辺りの融通が効くのであれば大人しくRPM使っとけというのが多くの場合正しいでしょう。
個人的には24系が入ればそれで良かったので、CentOS6系であれば、「centos6.5にemacs24.5をrpmからインストールする | joppot」を参考に以下のようにすればOKでした。なお、CentOS7系であれば単にyum install
するだけで24系のEmacsが入るはずです。
FROM centos:6 RUN yum install -y gcc lcurses-devel wget ncurses-devel gpm-libs alsa-lib perl gnutls-devel RUN cd /etc/yum.repos.d && \ wget https://gist.githubusercontent.com/AaronTheApe/5540012/raw/5782a8d6a95f76daeed6073dc0c90612fefe2fb3/emacs.repo && \ yum --disablerepo="*" --enablerepo="emacs" --nogpgcheck -y install emacs-nox
追記(2016/08/11)
今現在は/proc/sys/kernel/randomize_va_space
が2でも、上記の問題は起きず問題なくビルドできています。
Emacsの描画崩れの問題でDocker側を1.11にアップデートしたのですが(参考:「Dockerを1.10から1.11へアップデート on CentOS7 - eshamster’s diary」)、その影響でしょうか?ただ、上記のissueの時点では1.11.1や当時のmaster(1.12)でも再現しているようですが…。続報もないので(また、改めて1.10に戻して試す気もないので)結局良く分からないです。
一応バージョンはissueの時点よりも少し進んで、1.11.2だという差はあります。
$ docker --version Docker version 1.11.2-cs3, build c81a77d