この記事はRubyアドベントカレンダー2022の15日目の記事です。
以下のような話をお送りします:
- AWS Lambda + API Gatewayを使ってる話
- LFAというWeb frameworkをガッと書いた話
- LFAでLambdaの手元開発が楽になるのではという話
- Ruby 3.1で導入された
Kernel#load
の便利機能が超便利という話
- Lambda関数ごとに環境変数
ENV
の内容を変えたいという話
最近AWS Lambdaべったりな話
最近自分でサービスを作ってるんですが、サーバサイドは全部AWS Lambda + API Gatewayでやっつけてます。
EC2を常時起動して管理するのもやだし、大した規模でもないからゴツいビルドパイプラインをセットアップしてコンテナイメージをビルドするのも面倒だし、みたいなことを考えると、コードをそのまま送り付けるとあとは動きつづけてくれるAWS Lambda + API Gatewayの組み合わせが便利だなってことで。ただしRubyのバージョンが2.7だけなのがちょっとアレですよね。
アーキテクチャは手元がM1 macなのと、なんとなく以前所属した感慨もあってarmにしてるんですが、bundleするライブラリにmysql2を使ったところネイティブビルドされた部分の読み込みが実行時にちょっと理解不能なエラーを起こしてうまく動作しませんでした*1。これを追ったりデプロイ用の環境をガチャガチャやったりというのがちょっと面倒だったんで、pure Rubyなruby-mysql
を使ったりしてます。(4.0に上げるのはこれからだけど)使ってますよ!!! Pure Rubyなライブラリはすばらしい。JITで速くなるともっといいんだけど……。
で、LambdaなのはAPIハンドラの数もリクエスト数も少ない今のうちはいいんだけど、もしサービス当たってアプリケーションが複雑化したら大量にあるLambdaの管理が大変だし、コストも高くなるし、そうなったときのことを考えてるの? みたいな話は出てくると思います。
回答としては「敢えて考えてない」です。超初期のスタートアップ*2では現在の開発効率だけを考えるのが鉄則で、当たったら当たったときに考えりゃいいんです。不確かな先々のことを考えて今の開発を非効率にする理由は全くありません。
とはいえ、いざという時のことを考えてしまう
まあね、とはいえね、自分も前の会社で経験があるんですけど、いざってときにやろうと思ってても、その時はその時で最優先のサービス開発の優先度設定があって、結局その瞬間にプロダクトの価値向上に寄与しない作業はどうしても後回しになるんですよね。で、後回しにされるほどツケが貯まっていって、日々の開発効率はどんどん落ちるし、そうはいっても動いてるサービスは止められないし、みたいなことは起きる。ランニングコストも高止まりする。開発者体験も悪化する。ああ嫌だ。
みたいなことを考えると、数あるLambdaをとりあえずどうにかする方法があるといいよなあ、Lambdaとして書いたコード、そのまま普通のアプリケーションサーバに持っていけないかなあ、ということを妄想します。
LFAを作った
ということで、LambdaをそのままmountしてRackアプリケーションにしてしまえるフレームワークを作ってみました。これがあれば、Lambdaに日々デプロイしているコードをそっくりそのままUnicornやPuma上で動かせます。もちろん複数のLambda functionを1プロセス上で併存させられます。
github.com
YAMLの設定ファイルとして、API Gatewayのリソース設定みたいなやつと、あと使う関数のリストを書きます。
---
resources:
- path: /api
resources:
- path: /country
methods:
GET: myfunc-countries
- path: /data
resources:
- path: /csv
methods:
GET: myfunc-data-csv
- path: /json
methods:
GET: myfunc-data-json
functions:
- name: myfunc-countries
handler: myfunc.Countries.process
env:
DATABASE_HOSTNAME: mydb.local
DATABASE_PASSWORD: this-is-the-top-secret
- name: myfunc-data-csv
handler: myfunc.Data.process
env:
OUTPUT_DATA_TYPE: csv
- name: myfunc-data-json
handler: myfunc.Data.process
env:
OUTPUT_DATA_TYPE: json
あとはそれをRack用のconfig.ru
から、LFAを使って読み込めば、お好きなアプリケーションサーバで起動できるはずです。
require 'LFA'
run LFA.ignition!('config.yaml')
動かすLambda関数のファイルは設定ファイルと同じディレクトリに置いておきます。またその他必要なライブラリはGemfileに書いてBundler経由で起動されていれば普通に使えるはず*3。
構想にAsakusa.rb数週間分、実装は2日でガッ、という感じでなんとなく動くかな? という状況なので、rubygemsにもまだリリースしていません。READMEにもありますが足りない機能もいくつもあるのと、何しろテストが皆無なので、そのへんを毎週のAsakusaなどで少しずつやってからリリースの予定です。
実際使いものになるの?
もちろん現状では駄目です。
いっぽうでAPI Gateway的なルーティングをやるだけなら実はあまり大した機能がいらないので、ちゃんと作りきれば、LambdaなコードをEC2に持っていってリライトの時間ぶんだけ延命、みたいな用途にも十分役立つんじゃないかなと思ってます。大量のLambdaを管理していたのをひとつのサーバあるいはコンテナの管理に集約できるので、それだけでも嬉しい用途がもしかしてあるかもしれません。
あと開発中に気付いたのが、これってAPI Gateway + Lambdaでホストしてるコードの手元開発用にも使えるのではという。現状だとLambdaのコードをまず書き上げて、んでAPI Gatewayでリソースの設定して、それからやっとリクエストを投げるとLambdaのコードのうっかりミスが見付かる、みたいな世界です。それを、まず手元でLFA + puma (+DB)で動作するところまで書いて、それから実際のLambda関数 + API Gatewayリソースを設定、デプロイ、という順序にできる。
普段の開発においても、コード書き換えてLambdaにアップロードしてえいやで試す*4んじゃなくて、手元でちゃんとHTTPリクエスト投げて想定の結果が帰ってくることを確認してからデプロイできるのは、普通に便利なのではという気がしてます。
LFAの実装の話
ここからRubyの面白テクニックの話です。
Kernel#load
を使って複数のLambda関数を読み込む
複数のLambda関数のコードをひとつのRubyプロセスに読み込むとき、まず考える必要があるのは、互いに衝突する(かもしれない)それぞれの関数をどうすれば衝突させずに読み込めるか。また、Lambda関数のコード*5はキャッシュなどのステートを持っている可能性があるので、例えば異なるLambda関数が同じクラス/モジュール/オブジェクトを使用していると、AWS Lambda上ではプロセス*6が異なっていたから問題なくても、1プロセス上で動作するRackアプリケーションとしては困ります。
なので、Lambda関数のハンドラfunc.Modname.method
におけるファイル名func
を、関数ごとに異なる名前空間で読み込めないかな、ということをまず考えます。この方法を、やっつけな方法*7から邪悪な方法*8まであれこれ考えてたんですが、基本に戻って関連しそうなメソッドのドキュメントでも再確認してみるかとeval
系、Kernel#require
、Kernel#load
と見ていくと、見慣れないものをKernel#load
に発見しました。第2引数 wrap
。
load(filename, wrap=false) → true
(snip)
If the optional wrap parameter is true
, the loaded script will be executed under an anonymous module, protecting the calling program’s global namespace. If the optional wrap parameter is a module, the loaded script will be executed under the given module. In no circumstance will any local variables in the loaded file be propagated to the loading environment.
https://docs.ruby-lang.org/en/3.1/Kernel.html#method-i-load
これじゃん! まさに欲しかったものじゃん!! 超便利!!!!! Kernel#load
はrequire
と違って同じファイルでも何度も読み直してくれることはもちろんみなさんご存知のことだと思いますが、まさかこんな機能があったなんて。あったっけ????????*9
ということで以下のようなコードで試してみる*10と、完全に期待通りの動作です。勝った!!!
module Function
def self.process
{statusCode: 200}
end
end
m1 = Module.new
load('func.rb', m1)
m1.const_get(:Function)
m1.const_get(:Function).process
Function
m2 = Module.new
load('func.rb', m2)
m1.const_get(:Function).object_id == m2.const_get(:Function).object_id
しかしこんな機能あったっけ、と調べてみたら、なんと3.1の新機能でした。なんてこった。目にしてたはずなのに、こんな超絶便利機能に気付いていなかったなんて……。
Feature #6210: load should provide a way to specify the top-level module - Ruby master - Ruby Issue Tracking System
プロと読み解く Ruby 3.1 NEWS - クックパッド開発者ブログによると「DSL に使える、のかなぁ?」だそうですが、いやいやいや、こいつは何にでも使えそうな気配がしますよ。だって別空間でload
して主名前空間への影響を避けつつAbstractSyntaxTree.of(anonMod.const_get(:Target).method)
とかできるんでしょ。便利!*11
これで複数のLambdaを別々に1プロセスに読み込める、と言いたいところですが、ちょっと問題があります。load
されたファイル内でrequire
されているファイルの内容は、wrap
しているモジュール以下に読み込まれると同時に主名前空間にも読み込まれてしまいます。また複数のLambda関数で同じファイルをrequire
(またはrequire_relative
)している場合、requireされた結果のモジュール・クラス等は共有されてしまいます。これは昨日のhsbtさんのエントリで「ライブラリを複数バージョン同時にロードする機構」のあたりでも言及されてます。
ただしこれは、LFAの用途に限ってはあまり問題じゃないかなと思います。複数のLambda関数があるといっても、ひとつのディレクトリツリーにあるなら普通はライブラリや自作クラスを共有しているはずだからです。まあそれでもハマるケースとハマらないケースがありますが、多くは問題ないでしょう*12。
Lambda関数ごとに別々のENVを見せる
AWS Lambdaでは多くの場合、環境変数を用いて設定すると思います。これはもちろんLambda関数ごとに別々のものを指定できなければならず、同じLambda関数ハンドラfuncfile.Modname.method
であっても、別関数なら別の環境変数セットを持っている、みたいなこともあると思います。これもどう対応しようかなと思ってましたが、Kernel#load
のwrap
機能を見たらとりあえず以下のような対応が思い付きました。
m = Module.new
dummy_env = {
"VARNAME" => "value",
}
m.const_set(:ENV, dummy_env)
load('funcfile.rb, m)
これでfuncfile.rb
がloadされるときにはトップレベルENV
として、作成しておいたダミーの環境変数が参照されます。解決。……と思ったら、もちろん、load
されたファイル内でrequire
されている側で参照されているENV
には効果が及ばず。どうしようかなと思ったんですが、しょうがないので大元のENV
をゴニョってやることにします。
まず次のように標準添付のdelegate
ライブラリを使ってENV
を置き換えるクラスを作ります。
require 'delegate'
module LFA
class Adapter
class EnvMimic < Delegator
def initialize
@is_active = false
@box = nil
@env = ENV
end
def __getobj__
if @is_active
@box
else
@env
end
end
def __setobj__(obj)
@box = obj
end
def mimic!(env)
@box = env.dup
@is_active = true
yield
ensure
@box = nil
@is_active = false
end
end
end
end
これは普段はすべてのメソッド呼び出しを単に本来ENV
だったものに移譲しますが、mimic!(env) do ... end
メソッドで指定したブロック内では、env
で指定したHashに対して移譲します。これでこのブロック呼び出しを抜けるまでは、ENV
を参照するとダミーの内容が返ってくるという。便利! 環境変数ENV
の内容をオーバーライドしたいとか人類誰でも一度は考えると思うので、これだけ切り出しても便利かもなあ。
で、あとはこれで本来のENV
を置き換えてやります。普通にやると警告が出るので、警告を放置しない良きプログラマのたしなみとして無効化しておきます。
__original_warning_level = $VERBOSE
begin
$VERBOSE = nil
::ENV = LFA::Adapter::EnvMimic.new
ensure
$VERBOSE = __original_warning_level
end
これでだいたい解決するんですが、これでもrequire
対象ファイルのトップレベルやモジュール・クラス宣言部でENV
を参照しているケースだけがカバーできません。load
をENV.mimic!
でくくっても、異なるLambda関数が同じファイルをrequire
している場合には2回目には読み込みが発生しないためです。困ったなあ、ということでこれもとりあえず注意して回避してね、ということになります。
ということで
Lambdaで楽をするぞ! と思っていたら不要不急の(?)OSSがひとつできていました。おっかしいなあ……。いやこれは趣味だから! 仕事でやったわけじゃないから!