たごもりすメモ

コードとかその他の話とか。

LFAというAWS API Gateway用Lambdaをそのまま動かすRuby用Web frameworkを作った

この記事は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 Rubyruby-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のリソース設定みたいなやつと、あと使う関数のリストを書きます。

# config.yaml
---
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#requireKernel#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#loadrequireと違って同じファイルでも何度も読み直してくれることはもちろんみなさんご存知のことだと思いますが、まさかこんな機能があったなんて。あったっけ????????*9

ということで以下のようなコードで試してみる*10と、完全に期待通りの動作です。勝った!!!

# func.rb
module Function
  def self.process
    {statusCode: 200}
  end
end

# test.rb
m1 = Module.new
load('func.rb', m1)

m1.const_get(:Function) #=> Function
m1.const_get(:Function).process #=> {statusCode: 200}

Function #=> NameError (想定通り)

m2 = Module.new
load('func.rb', m2)

m1.const_get(:Function).object_id == m2.const_get(:Function).object_id
# => false
# つまりDataがふたつ、異なるインスタンスとして読み込まれている

しかしこんな機能あったっけ、と調べてみたら、なんと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#loadwrap機能を見たらとりあえず以下のような対応が思い付きました。

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を参照しているケースだけがカバーできません。loadENV.mimic!でくくっても、異なるLambda関数が同じファイルをrequireしている場合には2回目には読み込みが発生しないためです。困ったなあ、ということでこれもとりあえず注意して回避してね、ということになります。

ということで

Lambdaで楽をするぞ! と思っていたら不要不急の(?)OSSがひとつできていました。おっかしいなあ……。いやこれは趣味だから! 仕事でやったわけじゃないから!

*1:多分libmysqlclientまわりの話だと思う

*2:とも言えない個人開発なのが現状だけど

*3:自信がない書き方にちゃんとしたテストがされていないことが明らか

*4:まあstageとかblue-greenとかいろいろ使うんでしょうけど

*5:モジュールもしくはその他オブジェクト

*6:実際にはコンテナ単位での分離なのでもっと強いですが

*7:ファイルを文字列として読み込んだ上で外側をmodule Anon1; ...; end でくくってevalする

*8:TracePointを使って何とかしてゴニョゴニョする、マクロ的な方法でなんかやる、etc

*9:後述しますが3.1の新機能でした

*10:どうでもいいけど、おれ、なんかいっつもModule.newしてるなってLFA書きながら思いましたね

*11:正確には、第2引数をtrueにして無名空間に対してloadすること自体は3.0以前でもできていたようで、その場合でもObjectSpaceを経由してloadされたものを取得して……とかできなくはなかったみたいだけど

*12:あとでREADMEにLimitationセクションを追加する予定です