Vim
AWS
AWSLambda

Vim scriptでAWS Lambdaも書ける!

本記事は Vim その2 アドベントカレンダー 20日目の記事です。

先日、AWS re:Invent 2018 にてAWS Lambda上でCustom Runtimeが発表されました。

弊社でも早速Rustの公式ランタイムを使ってみたりして、いろいろな言語で使える展望を感じてワクワクしていました。

でも、せっかくいろいろなものが動かせるのなら、どうせならあんまり意図されていないようなものに挑戦してみたほうがCustom Runtimeへの理解も深まるかなぁと思い、今回はVimをCustom Runtime上に設置して、Vim scriptでRuntimeのハンドラーを書けるようにならないかどうか挑戦してみて、一応Vim scriptでハンドラーをかいてLambdaのレスポンスとして返せるようになりました。

成果物:

https://github.com/pocket7878/aws-lambda-vim-layer

サンプルコード:

https://github.com/pocket7878/aws-lambda-vim-layer-example


Custom Runtimeのざっくりした構造

Custom Runtimeは、適切なディレクトリに実行したいバイナリやライブラリとbootstrapというエントリの実行ファイルを配置したzipファイルで、bootstrapファイルがLambdaの起動時に自動的に呼び出されるという仕組みのものです。

このboostrap内で、今回発表されたRuntime APIからイベントをGETして、結果を結果投稿用のAPIにPOSTしてやるようなループ処理ができればどんなものでもLambdaのRuntimeとして利用できます。


Vimと依存ライブラリの取得

今回はVimをLambdaの環境で動かしたいため、lambci/lambdaのDocker Imageを利用してAWS Lambdaの内部環境を再現した環境で動くVim及びVimの実行に必要なライブラリの取得を行っています。

仕組みとしては単純にlambciベースののDocker Imageの中でvimをパッケージ経由でインストールしてやり、そのvimのバイナリとvimの依存しているライブラリ等を起動時にマウントしておいたホストのディレクトリにコピーしてきています。

(ここはvimのソースを実際にダウンロードしてきてコンパイルしてくる形にして、外部からビルドオプション等を指定できるようにしたいと思っていますが、今回はまずは完動を目指して後回しにしています)


vimrcの作成

vimをコピーしてきただけではまだvimのバイナリが入ったLambda Layerにしかならないので、

bootsrapファイルを作成して、指定されたVim scriptファイルを読み込んで、ハンドラー関数からの結果を返せるようにしなければなりません。そこで今回は、LambdaのRuntimeAPIを叩くループを行う関数を起動直後に呼び出すvimrcファイルを作成し、そのvimrcを指定してvimを起動する処理をbootstrapファイルに書いています。

vim -u /opt/vim/vimrc


function! LambdaHandler()

AWS Lambdaのハンドラ指定は<ハンドラファイル名>.<関数名>という形式になっているため、

ディレクトリ内の<ハンドラファイル名>.vimというディレクトリをsourceしてやって、

whileループでひたすらRuntime APIのイベントGETのエンドポイントを叩いてはイベントデータとコンテキストを関数に渡してやって、

返ってきた結果をまたRuntime APIの結果を返すエンドポイントにPOSTしてやるという関数を定義しています。

function! LambdaHandler()

let V = vital#lambda_vim_layer#new()
let HTTP = V.import('Web.HTTP')
let JSON = V.import('Web.JSON')
let handlerInfo = split($_HANDLER, '\.')
let handlerFile = $LAMBDA_TASK_ROOT . "/" . handlerInfo[0] . ".vim"
let handlerMethod = handlerInfo[1]
if filereadable(expand(handlerFile))
execute 'source ' . fnameescape(handlerFile)
endif
while 1
let nextEvent = HTTP.get("http://".$AWS_LAMBDA_RUNTIME_API."/2018-06-01/runtime/invocation/next")
let eventData = JSON.decode(nextEvent.content)
let eventHeader = HTTP.parseHeader(nextEvent.header)
let eventContext = {
\'memoryLimitInMb' : $AWS_LAMBDA_FUNCTION_MEMORY_SIZE,
\'functionName' : $AWS_LAMBDA_FUNCTION_NAME,
\'functionVersion' : $AWS_LAMBDA_FUNCTION_VERSION,
\'invokedFunctionArm' : eventHeader['Lambda-Runtime-Invoked-Function-Arn'],
\'xrayTraceId' : eventHeader['Lambda-Runtime-Trace-Id'],
\'awsRequestId' : eventHeader['Lambda-Runtime-Aws-Request-Id'],
\'logStreamName' : $AWS_LAMBDA_LOG_STREAM_NAME,
\'logGroupName' : $AWS_LAMBDA_LOG_GROUP_NAME,
\'clientContext' : JSON.decode(get(eventHeader, 'Lambda-Runtime-Client-Context', '{}')),
\'identity' : JSON.decode(get(eventHeader, 'Lambda-Runtime-Cognito-Identity', '{}')),
\}
let requestId = eventHeader['Lambda-Runtime-Aws-Request-Id']
let result = function(handlerMethod)(eventData, eventContext)
call HTTP.post("http://".$AWS_LAMBDA_RUNTIME_API."/2018-06-01/runtime/invocation/".requestId."/response", result)
endwhile
endfunction

LambdaHandler()ではWebのAPIを叩くことになるのでvital.vimを使いたかったので、

zipのなかにvital.vimを適用したプラグインの入ったbundleディレクトリを置いておいてやり

execute 'set rtp+=' . expand('<sfile>:p:h') . '/bundle'

のようにしてruntimepathに追加しています。

この関数がvimの起動直後に呼び出されるようにしたいので、

autocmd VimEnter * call LambdaHandler()

で登録して一通り初期化処理がおわったらLambdaHandler()が呼び出されるように指定しています。


vimの$VIMRUNTIMEディレクトリのコピー

バイナリと上記vimrcでも一応vimとしては動くのですが、標準で付属してくる$VIMRUNTIMEも同梱しなくては気持ちよくVim scriptでハンドラファイルが書けません。

VIM_VIMRUNTIME=`vim -e -T dumb --cmd 'exe "set t_cm=\<C-M>"|echo $VIMRUNTIME|quit' | tr -d '\015' `

VIM_RUNTIMEPATH=`vim -e -T dumb --cmd 'exe "set t_cm=\<C-M>"|echo &runtimepath|quit' | tr -d '\015' `

のようにして、インストールされたvimの$VIMRUNTIME及びruntimepathを取得します。

runtimepathのディレクトリを一つ一つホストのディレクトリにコピーしていきます。

このとき、コピーしたディレクトリ一つ一つをruntimepathに追加するようなコードをvimrcに追記していきます。そして、もしコピーしたディレクトリが$VIMRUNTIMEに指定されているものだったら、そのコピー先を$VIMRUNTIMEに指定するようなコードも合わせて追記していきます。


ちょっとした苦労

runtimepathはコンマで区切られたディレクトリが入っていますが、<runtime_dir>,<runtime_dir>/afterのように親子関係になったディレクトリも入っていますので、順番に単純にコピーしてきてしまうと重複してコピーされてしまいますので、処理しながら他の<runtime_dir>をコピーしたタイミングで一緒にコピーされることになるものは無視する必要があります。そのあたりをなるべくシェルスクリプトだけでやりたかったので、ナイーブに正規表現で判定していますが、もう少し良い方法がありそうです..


ユーザーもプラグインを同梱できるように

これで、基本的なVIMRUNTIMEに入っているものは扱えるレイヤーが出来上がりますが、これだけではレイヤーを使う側のユーザーが独自で追加したいプラグイン等を追加することができません。

なので、最後に決め打ちでハンドラファイルが入っているのと同じディレクトリ内のvim/bundleというディレクトリもruntimepathに含めるようにしてやります。

execute 'set rtp+=' . expand($LAMBDA_TASK_ROOT . "/vim/bundle")

このようにしておくと、レイヤーを使う側でvim/bundle内にautoload等のフォルダを置いておくと通常のvimと同様にautoloadの対象に含まれるようにできます。

これで、最終的にランタイム等の設定についても書かれたvimrcファイルが無事できあがります。

それらをまとめてzipファイルにして、レイヤーとしては完成です。


利用例(TOHtml)

たとえばハンドラー関数としてhandler.Methodを指定しておいて

以下のように、ユーザーからきたイベントのcontentの内容をバッファに展開して、

指定されたカラースキームとファイルタイプを適用した状態でTOHtmlした結果のバッファを読み込んでやって

結果として返す関数を定義しておきます。


handler.vim

function! Method(event, context)

let content = a:event.content
enew!
put =content
1delete _
execute 'syntax on'
execute 'set ft='.a:event.filetype
execute 'colorscheme '.a:event.colorscheme
runtime syntax/2html.vim
execute bufwinnr(bufnr('Untitled.xhtml'))'.wincmd w'
let line_ending = "\n"
let text = join(getline(1, '$'), line_ending).line_ending
q!
return text
endfunction

すると、たとえばイベントとして以下のようなものを送ってやると


event.json

{

"filetype": "c",
"colorscheme": "blue",
"content": "#include <stdio.h>\n\nint main(int argc, char **argv) {\n\tprintf(\"Hello, world\\n\");\n\treturn 0;\n}"
}

このように、Lambdaから無事TOHtmlした結果が帰ってくるようになります:

スクリーンショット 2018-12-20 2.18.51.png

たとえばここでvim/bundle/colors内にお好みのカラースキームを同梱しておけば、標準でないカラースキームを適用した結果を得ることもできるようになると思います。


まとめ

Vim scriptでAWS Lambdaも書ける!