AWS LambdaのCustom RuntimeでCommon Lisp(ECL)を動かしてみた #reinvent

こちらはAWS Lambda Custom Runtimes芸人 Advent Calendar 2018の18日目の記事です。

はじめに

先日のre:Invent 2018で発表されたAWS Custom Lambda Runtimesですが、Lambdaといえば思い出すのはLISPです。すでにSchemeの記事が公開されていますが、今回はLISPを試してみることにします。

emacs-lispは普段使いしていますが、Common Lispに触れるのは20年振りくらいです。昨今のCommon Lisp事情をほとんど知らないので、少し調べてみました。こちらにまとまっていますが、処理系はいろいろ選べるようです。その中でもECLという実装が目を引きました。ECLは、Embeddable Common Lispの略称で、埋め込めるCommon Lispということのようです。ECLは、昔懐かしいKCL(Kyoto Common Lisp)の系譜にあるなんだそうです。

いつの時代にもLISPといえばイメージサイズが超巨大というのが課題でしたが、ECLはコンパクトに動作させることができるようです。特にコンパイル済みバイナリはとても小さくなるようです。

ECLを動かしてみる

まずは感触をつかむために、MacOSXで試してみました。ECL用のbrewのフォーミュラが提供されていますので、インストールは簡単です。

$ brew install ecl
Updating Homebrew...
==> Installing dependencies for ecl: bdw-gc
==> Installing ecl dependency: bdw-gc
==> Downloading https://homebrew.bintray.com/bottles/bdw-gc-8.0.0.sierra.bottle.tar.gz
######################################################################## 100.0%
==> Pouring bdw-gc-8.0.0.sierra.bottle.tar.gz
  /usr/local/Cellar/bdw-gc/8.0.0: 65 files, 1.2MB
==> Installing ecl
==> Downloading https://homebrew.bintray.com/bottles/ecl-16.1.3_3.sierra.bottle.tar.gz
######################################################################## 100.0%
==> Pouring ecl-16.1.3_3.sierra.bottle.tar.gz
  /usr/local/Cellar/ecl/16.1.3_3: 304 files, 10.9MB

さっそく動かしてみます。

$ ecl
ECL (Embeddable Common-Lisp) 16.1.3 (git:UNKNOWN)
Copyright (C) 1984 Taiichi Yuasa and Masami Hagiya
Copyright (C) 1993 Giuseppe Attardi
Copyright (C) 2000 Juan J. Garcia-Ripoll
Copyright (C) 2016 Daniel Kochmanski
ECL is free software, and you are welcome to redistribute it
under certain conditions; see file 'Copyright' for details.
Type :h for Help.
Top level in: #<process TOP-LEVEL>.
> 'Hello

HELLO

こちらを参考に、HelloWorldをコンパイルしてみます。 次のようなソースファイルを用意します(シンタックスハイライトがLisp非対応なのがちょっと残念w)。

(defun hello ()
  (write-line "Hello, World!!"))

(hello)
(quit 0)

Hello Worldを表示する関数定義だけではなくて、その関数をトップレベルで呼び出し、そしてquitでシステムを抜けています。

これをECLでコンパイルします。.oファイルができるので、さらにリンクする必要があるようです。

$ ecl
> (compile-file "hello.lisp" :system-p t)

;;; Loading #P"/usr/local/Cellar/ecl/16.1.3_3/lib/ecl-16.1.3/cmp.fas"
;;;
;;; Compiling hello.lisp.
;;; OPTIMIZE levels: Safety=2, Space=0, Speed=3, Debug=0
;;;
;;; Compiling (DEFUN HELLO ...).
;;; End of Pass 1.
;;; Emitting code for HELLO.
;;; Finished compiling hello.lisp.
;;;
#P"/Users/yourname/ecl-lambda/hello.o"
NIL
NIL
> (c:build-program "a.out" :lisp-files '("hello.o"))
> ^D

できたバイナリを動かしてみます。

$ ./a.out
Hello, World!!

ちゃんと動きました!

AWS Lambda用の環境を作る

さて、ECLの簡単な使い方がわかったので、AWS Lambdaで動かすコンパイル環境を作成します。

AWS Lambdaは、「Amazon Linux 1」相当の環境とのことですので、Dockerで環境を作ることにします。 タグ名にamazonlinux:1を指定するとAmazon Linux 1相当の環境を引っ張ってくることができます。ただこれにはツールが何も入っていないので、まずはyumでインストール、そしてECLのソースを引っ張ってきてビルドを行います。途中curlがエラーになったので、--http1.1オプションを足しています。

FROM amazonlinux:1

MAINTAINER TT

RUN yum -y update && \
    yum -y groupinstall "Development tools" && \
    rm -rf /var/cache/yum/* && \
    yum clean all

WORKDIR /usr/local/src

RUN curl --http1.1 -O https://common-lisp.net/project/ecl/static/files/release/ecl-16.1.3.tgz && \
    tar xfz ecl-16.1.3.tgz && \
    cd ecl-16.1.3 && \
    ./configure --prefix=/usr/local && \
    make && \
    make install && \
    make clean && \
    cd .. && \
    rm -rf ecl-16.1.3*

WORKDIR /root
CMD /usr/local/bin/ecl

上記のDockerfileでイメージをビルドします。ビルドが完了するまでしばらく時間がかかります。

$ docker build -t ecl .

Docker上のECLでコンパイル

Dockerイメージがビルドできたら動かしてみます。ローカルのカレントディレクトリを/rootにマッピングしておきます。プロセスは使い捨てにするので--rmオプションを付けておきます。

デフォルトではECLが起動しますが、ここではbash経由で作業します。カレントディレクトリにはhello.lispが見えている前提です。bashからECLを立ち上げて、まずは先ほどと同じようにコンパイルします。実行ファイルはmainという名称にしておきます。コンパイルが終わったら、ECLを抜けてmainを実行してみます。

$ docker run -it -v $PWD:/root --rm ecl bash
bash-4.2# ecl
> (compile-file "hello.lisp" :system-p t)

#P"/root/hello.o"
NIL
NIL
> (c:build-program "main" :lisp-files '("hello.o"))

#P"main"
> ^D
bash-4.2# ./main
Hello, World!!

実行ファイルのサイズと、リンクされる共有ライブラリをチェックしておきます。

bash-4.2# ls -l main
-rwxr-xr-x 1 root root 44224 Dec 17 18:15 main
bash-4.2# ldd main
    linux-vdso.so.1 =>  (0x00007ffd86ff0000)
    libecl.so.16.1 => /usr/local/lib/libecl.so.16.1 (0x00007f7de8694000)
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f7de8478000)
    libdl.so.2 => /lib64/libdl.so.2 (0x00007f7de8274000)
    libm.so.6 => /lib64/libm.so.6 (0x00007f7de7f72000)
    libc.so.6 => /lib64/libc.so.6 (0x00007f7de7ba5000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f7de8d1f000)

このうち/usr/local/lib/libecl.so.16.1が追加で必要なECLのランタイムで、実行ファイルと一緒にAWS Lambdaにアップロードする必要があります。カレントディレクトリにコピーしておきます。ここでDocker上での作業はおしまいですので、Ctrl-Dで抜けます。

bash-4.2# cp /usr/local/lib/libecl.so.16.1 .
bash-4.2# ^D

AWS Lambda関数を作成する

あとは、ホスト(OSX)側でzipでまとめます。コンパイル済みバイナリmainと合わせて、シェルスクリプトbootstrapと、先ほどコピーしたlibecl.so.16.1を一緒のzipに入れます。

$ zip lambda.zip bootstrap main libecl.so.16.1
updating: bootstrap (deflated 36%)
updating: main (deflated 57%)
updating: libecl.so.16.1 (deflated 67%)

こちらのシェルスクリプトbootstrapは、チュートリアルとまったく同じものです。chmod +xしておくのを忘れないようにします。

#!/bin/sh

set -euo pipefail

EXEC="$LAMBDA_TASK_ROOT/$_HANDLER"

# Processing
while true
do
  HEADERS="$(mktemp)"
  # Get an event
  EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
  REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)

  # Execute the handler function from the script
  RESPONSE=$(echo "$EVENT_DATA" | $EXEC)

  # Send the response
  curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response"  -d "$RESPONSE"
done

そしてlambdaにアップロードします。

$ aws lambda create-function --function-name "lambda-ecl" \
    --zip-file "fileb://lambda.zip" \
    --handler "main" \
    --runtime provided \
    --role arn:aws:iam::XXXXXXXXXXXX:role/your-lambda-role

2回目以後でzipファイルを更新する場合は、update-functionを使います。

$ aws lambda update-function-code --function-name "lambda-ecl" --zip-file "fileb://lambda.zip"

さあ、AWS Lambda関数を実行してみましょう。マネージメントコンソールからTestボタンをクリックしてみます。

ちゃんと動いたようですね!

使ったファイル

以上の実験に使ったファイルはこちらに置いておきました。

cm-tt/ecl-lambda

まとめ

Common Lisp実装の一つであるECLをコンパイラとして使用し、できたバイナリをAWS Lambda環境で動作させることができました。

本当はもうちょっとLispらしいコードを動かしてみたかったのですが、実行環境を用意するので精一杯でした。インタープリタを直接動かす、またはECLのランタイムをLambda Layerにするなどしても面白いのではと思います。今後の宿題とさせてください。

参考