Three.jsなWebアプリをCommon Lispで書く話

前書き

Lisp Advent Calendar 2016の20日目の記事です。

13日目の記事「フロントエンドもサーバーサイドもCommon Lispで書く試み - @peccul is peccu」のタイトルを見た瞬間「あっ」と思ったのですが、テーマがダダ被りです。しかも、pecculさんの方はメンテされているjsclをベースにしている一方で、こちらはメンテされていないParenscriptをベースに頑張っ(てしまっ)た記事です。

冒頭から残念感あふれますが、jsclに乗り換えても(未定)考え方は使えると思い、気を取り直して進めます。さて、こんなものを作りました。

f:id:eshamster:20161218174621p:plain

斑鳩という素敵STGの3面中ボスである「鴫(シギ)」に関するシミュレータ(絶賛未完成)ですが、Qiitaから来られた方の10割はなんのことか分からないと思うのでこれ自体の話はまた別に記事を起こせたらと思います(ちなみに、Twitterから来られた方の8割はなんのことか分かると思います)。

ポイントはThree.js(WebGLを簡単に利用するためのライブラリ)を利用したWeb2Dゲームアプリであり、サーバサイドからフロントエンドまで*2Lispで記述されているという点です。これを実現するためにどのようなベースを作り、その上でどのような開発サイクルを回していったかが記事の焦点です。

一応動かし方

github.com

これを動かすこと自体に興味のある人がどれだけいるか甚だ疑問ですが、一番簡単なのは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 installgithubから拾わないと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)

概要

次のような階層で実現しています。

それぞれのライブラリ(自作物のみ)

上のライブラリを使ってどのように開発を回してきたかという話を淡々と書いていきます。

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ブランチ)

  1. <プロジェクト名>.asdの編集:"templates"モジュールの下にテンプレート名を追加
  2. Lispファイルの追加と編集:templates/<テンプレート名>.lispを追加
    • パッケージ定義:<プロジェクト名>.templates.<テンプレート名>の名前で作成
    • render関数の作成:HTMLコードを文字列として返す関数
      • このHTMLコード作成のためにCL-Markupを使う想定
      • 引数は任意
  3. テンプレートを利用: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ブランチ)

  1. <プロジェクト名>.asdの編集:"static/js"モジュールの下にファイル名を追加
  2. Lispファイルの追加と編集:static/js/<name>.lispを追加
    • パッケージ定義:<プロジェクト名>.static.js.<name>の名前で作成
    • js-main関数の作成:JavaScriptコードを文字列として返す関数
      • このJavaScriptコード作成のためにParenscriptを使う想定
      • 引数はなし
  3. テンプレート側での読み出し:<プロジェクト名>.static.js.utils:load-js関数を利用する
    • (load-js :<name>)
      • static/js/_<name>.jsを作成する
      • 返り値は文字列'_<name>.js'

static/js/index.lispJavaScriptコードを作成する側の例です。

(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コードとして出力します
    • 第1引数で指定したパッケージに属する定義が対象です
    • 指定がなくとも、useしているパッケージは再帰的に探して定義を出力します(上記で:sample.static.js.some-packageを省略できるのはそのため)*9
    • また、:thisは自身(上記では:sample.static.js.index)のエイリアスです
  • 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-ecsCommon 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を提供している
  • その他便利そうな関数群を提供している

まだ柔らかなので紹介しづらいです…。記事の方も力尽き気味なので、気の向いたところだけ説明する形にします(そのうちまた記事にしたい…)。

f:id:eshamster:20161218185622p:plain

このキャプチャに写っているのは、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構造(ハッシュ)を作るためのDSL
  • get-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-paramget-layered-paramをラップしてハッシュの指定を省略しています。

もう一つのEntityレベルのパラメータはinit-entity-paramsコンポーネントとして持たせています。中身は単なる1階層のハッシュです。抜粋部分にはありませんが、利用はget-entity-paramset-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から見ると、子供であるbodybitのことを気にせずに移動しても(今回は固定位置ですが)、子供は勝手についてきてくれます。子供、例えばbitの方から見ると、親の中心点に対して回転するという動作だけ記述しておけば、親がどこにいるかを気にする必要はありません。今回の例で極端なものはmake-center-point-markerから作っているマーカで、マーカ自身は一切移動の処理(Component)を持ちませんが、勝手に親のbodybitについていっています。

あとはjs-libフォルダ配下のファイルレベルで何ができているかをざっと見て終わりにします。

  • Three.jsへの依存が強い部分
    • 2d-geometry.lisp: 描画用のモデル作成。丸や四角形や多角形を作れます
    • draw-model-system.lisp: 描画用のSystem
    • camera.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のプレビュー版を使っています。仮置きなので、メンテのために適当に止まったり、いつの間にか消滅していたりするかもしれません。

*2:CSSを除く

*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とか)のインストール用に欲しいのでひとまず入れておきます
  • 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ベースにしました。

eshamster.hatenablog.com

*1:前記の開発環境もこの上に移し換えようかという目論見もあったのですが、面倒でやってません

リーダーマクロで非公開シンボルの参照を簡略化する @ テスト

#:g1: リーダーマクロでシンボルの略記をする」を読んでいて、こういうリーダーマクロの使い方することあるなと思ったので投稿。大した用途ではないですが…。

こんなことはないでしょうか。

  • インタフェースとしては不要なので公開はしたくない補助関数がある
  • とはいえ、それなりに面倒な関数なので単体でテストしておかないと不安がある
  • しかも、それなりに数があるので、毎回<package>::<symbol>と書くのも面倒くさい

Domain Specific LanguageDSL)まがいの大き目なマクロを書いていると良くある…のかもしれません。

自身の例では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::になっていたら、中々気が狂いそうです。

*1:詳細略。key-valueなデータをゴネゴネするためのライブラリです。そのうち記事にしようと思って忘れていました…

*2:正規表現が手抜きなので、<hoge>の中に"-test"が含まれるとそちらが取れてしまいますが、目をつむっています

Common LispでSlack botを作る

チャットbotなるものにも(今さら)手を出してみようと、Slack用のサンプルbotCommon Lispで書いてみました。

github.com

使い方

一応使い方です。

  1. 上記プロジェクトをquicklispからロード可能な場所にclone
  2. settings.json.inを参考にsettings.json(下記参照)を作成
  3. REPL上でサーバを立ち上げる。
    1. (ql:quickload :sample-cl-bot)
    2. (clack:clackup sample-cl-bot:*app* :port 16111)
  4. SlackのOutgoing hookにhttp://<アドレス>:16111/を登録

基本編:Slackとのやりとり

f:id:eshamster:20160812001309p:plain

そもそも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-urlparse-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コマンドは単なる結果確認用)。その実装に関する話です。

f:id:eshamster:20160812000035p:plain

実現機構

肝になる部分は下記のように簡単な実装になっています(上記基本編で呼んでいたparse-input関数の定義がこれ)。なお、slet, itanaphoraライブラリからインポートしたものです*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
  • 返り値
    • 第1(content):Slackに投げるJSON
    • 第2(continuity):なし(= NIL)もしくは、textparamsを受け取り、content(とcontinuity)を返す関数

ポイントとなるのが第2返り値であるcontinuityです。この関数(またはNIL)は*continuity-table*に格納されます。このテーブルのキーとなっているのはユーザ識別情報です。したがって、同じユーザから再度メッセージが来た場合、下記のように動作します。

  1. テーブルにNILが格納されている → 標準のパーサであるparse-commandを呼び出す
  2. テーブルに関数が格納されている → 対話用のパーサであるその関数を呼び出す((funcall it text params)

結局、普段はparse-commandで標準的な処理を行い、対話処理をしたい場合は後継の対話処理を登録しておき次回はそれを使う、というのが全体像です。

上記画像の例では下記のように対応します。

  1. "alien: remember", "alien: get 斑鳩"(標準パーサ利用)
  2. "alien: 斑鳩", "alien: the best game"(対話用パーサ利用)

なお、聞きかじった程度の「継続」に近い気がしたので、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。

f:id:eshamster:20160814182852p:plain

見てのとおりのkey, valueストアです。覚えた情報は基本的にtoken単位(≒チャンネル単位)で管理しているので、同じチャンネルであれば他の人が登録した情報もgetで見ることができます。

なお、永続化していないので環境を再起動すると消えます…*3

rememberコマンドは「応用編」では対話式に覚えさせましたが、この画像のようにremember <key> = <value>で一発登録もできます。

画像にはありませんが、forget <key>で覚えた<key>の情報を削除します。

weather

多少実用的なものその2。

f:id:eshamster:20160814182902p:plain

livedoorお天気WebサービスAPI*4を利用して、指定した地域の天気予報をとってきます。

なお、上記画像ではweatherエイリアスであるwf("Weather Forecasts")を利用しています。

f:id:eshamster:20160814182909p:plain

また、地味にrememberコマンドと連係しています。この画像のように、rememberコマンドで覚えたキー(上記のremember/getの例で覚えさせたもの)を地域名の代わりに利用できます。

その他

純粋なサンプル達

  • hello:こんにちは

f:id:eshamster:20160814182944p:plain

  • echo:そのまま返します
    • 余談ですが、下記画像のように、echoした情報にキーワード(alien:)が含まれればコマンドとして解釈してしまいます
      • 必ずキーワードを消費するので無限ループはしない…はず

f:id:eshamster:20160814183000p:plain

  • number game:対話式インタフェースのサンプルに作りました
    • エイリアンの考えている番号を当てるだけのゲームです
    • 勝率おおよそ1/7の○○ゲーです

f:id:eshamster:20160814183015p:plain


*1:記事を書いていて気づきましたが、itの中身が全て定義で置き換えられることを考えると、恐ろしく無駄な処理をしていますね…。気が向いたら直します

*2:textはこのparamsから生成できますが、少し加工(キーワードと空白のトリミング)が必要で、かつメインの処理対象なので加工済みのものを渡しています

*3:一応、src/kv-storage.lispさえ対応すれば永続化対応できる作りです。面倒なのはテストですね…。

*4:登録不要で使えたのでこれにしました。

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

元々は以前書いた記事「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を入れました
      • 探した範囲ではCentOS 7用の24.5のrpmは見つからなかったので、ソースからのビルドという手段を塞がれた場合CentOS 6より厄介かもしれません…
  • その他の話
    • ビルド時点でsbcl, sbcl-bin, ccl-binを入れていますが、コンテナの容量が膨らむので微妙かもしれません…
    • 同じく容量が膨らむのでEmacs拡張をインストールするためのRUN emacs --batch --load .emacs.d/init.elも…

実際に使うとき

実際に開発に利用する上では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を立ち上げ*6M-x slimeですぐにslimeが使えます。保存の必要なものは~/work/lisp以下に保存します。また、コンテナをdetach*7した後は、docker attach clで再接続できます(プロセス名clsetenvで設定)。

  • 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の描画問題が解決してからは快適に利用できています。

後続の関連記事

eshamster.hatenablog.com

eshamster.hatenablog.com


*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をビルドしようとしてハマったので対処方法と、ついでに簡単に調査したメモです。

現象

環境は次のような感じでした。

  • ホスト: CentOS 7.2 (Conoha VPSのデフォルトイメージ利用)
  • Docker 1.10.3

まず、確認用に次の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