Fn (OCI Functions) の Function を FDK を使わずに作成する


はじめに

掲題に関するコードをGitHubに公開していますが、そもそも素直にFDKを使っておけばいいのに、何でこんな一見無駄に思えることをやってみたのかという動機から、実際にやってみて分かった Fn コンテナの通信の仕組みなどについて、諸々お話をしたいと思います。

動機

Fn には各種言語用の FDK (Function Development Kit) があって、これを使えばあまり難しいことを考える必要もなく、シンプルに入力を受け取って処理をして出力を返すことだけを実装すれば済みます。単純な処理であれば実際 FDK だけで十分でしょう。
しかしなから逆に、何で実装が FDK に縛られないといけないのかという疑問の方が強くなってきました。世の中には様々な言語の優れたフレームワークがあるので、それを流用できた方がいいことが多くあるのではないか? 例えば (Javaの例ですいません...) JAX-RS 準拠のフレームワークで Fn を実装できたなら、既存の JAX-RS ベースで書いたアプリケーションをほとんど修正することなく FaaS 化できます。OCI Functions は最近トレーシングの機能が提供されましたが、じゃあトレーシングの機能をアプリケーションにベタ書きするかって、皆したくないに決まっている。そういう場合は Interceptor を使ってメソッドに Annotation をつけて、アプリケーション・ロジックの邪魔にならないようにすべきですが、だったらそういうことができる優秀なフレームワークを Fn に持ってきたくなる訳です。

Fn アプリケーション・コンテナの通信の仕組み

Fn アプリケーションを一言でいうと、
Unix Domain Socket 経由でリクエストを処理する HTTP サーバである
となります。
ファイル・ソケットを使う(プロセス間通信を行う)ということは、HTTPリクエストを送信するFnの親玉がコンテナと同じホスト上のどこかにいるということですね。通常のTCPポート経由で通信する仕様であれば、FDK 以外にも選択するフレームワークの自由度がもっと上がったと思うのですが、ここはパフォーマンスを優先したのでしょう。

Fn Project の FDK の実装方法を調べてまとめると、こんな感じです。

  • HTTP Server over Unix Domain Socket を実装する
  • POST /call で呼び出される、これをハンドルする
  • bind する ファイルのパスは環境変数 FN_LISTENER で渡される
    例: /tmp/iofs/lsnr.sock

    • 直接 bind せずに 指定されたパスと同一ディレクトリの別のファイル名で bind する
      例: /tmp/iofs/YvzDu6m9_lsnr.sock
    • このファイルに rw-rw-rw- のアクセス権限を設定する
    • これに相対パスのシンボリックリンクを張る
    • 結果、実行時にはこんなファイル構成となっている
    $ ls -l /tmp/iofs
    lrwxrwxrwx. 1 root root 17 Mar  3 17:45 lsnr.sock -> YvzDu6m9_lsnr.sock
    srw-rw-rw-. 1 root root  0 Mar  3 17:45 YvzDu6m9_lsnr.sock
    

つまり、「Unix Domain Socket に対応した HTTPサーバ・フレームワークであれば動作する、ただし最初のUnix Domain Socketまわりのお世話だけは必要」というのが結論です。

コンテナ・イメージをビルド&デプロイする

コンテナの通信の仕組みが分かったところで、今度は実際に動くサーバを実装して、コンテナ・イメージをビルド&デプロイする必要がありますが、この作業の手順自体は簡単です。CLI (fn build コマンド) を実行するカレント・ディレクトリに Dockerfile があると、CLI はそれを使ってコンテナ・イメージを作成します。つまり Dockerfile の記述次第でどんなイメージでもビルドできるしデプロイもできます。もちろん Fn コンテナ通信のお作法に従っていなければ動作はしませんが。

Python だと Dockerfile はこんな感じになります。

FROM python:3.8-slim-buster

WORKDIR /opt/app

COPY requirements.txt /opt/app
RUN pip3 install -r requirements.txt

COPY *.py /opt/app/

CMD ["python", "fn-fastapi.py"]

Java 編 (https://github.com/tkote/fn-netty)

普段から使い慣れている Java で、さらに使い慣れているフレームワークで実装できればと思ったのですが、根本的な問題が... Java の Unix Domain Socket 対応は Java 16からだと。道理で Unix Domain Socket に対応したフレームワークは多く存在しません。Fn の Java FDK も C で書いたソケット周りの native library と JNI (Java Native Interface) で実装されています。で、色々調査した結果 Netty は対応していました(流石!)。Nettyも同じく専用の native library を提供しています。Netty 自体は多くのサーバ・フレームワークのベースとなっているので、Unix Domain Socket 対応はフレームワーク側の心意気次第なのですが、結局目ぼしいものを見つけられませんでした。ということで、若干APIがローレベル過ぎて実用性は実は疑問なのですが、Nettyによる実装と、若干抽象度の増したレイヤを提供している Reactor Netty の二種類で Fn アプリケーション を作りました。
Spring Boot は Reactor Netty に対応しているので、Unix Domain Socket を使うようにカスタマイズできそうですが、今のところ実現方法を導き出せず。Helidon も ベースは Netty なので、実装を見てみましたが、TCPポート接続決め打ちのかなりのハードコーディングなので諦めました。

Python 編 (https://github.com/tkote/fn-fastapi)

Python は言語自体が Unix Domain Socket に対応しているので、フレームワークも素直に対応できることが多いと思われます。迷わず Fast API を選択しました。フレームワークらしく関数デコレータを使って、綺麗にアプリケーションを実装できます。

from fastapi import FastAPI, Request, Response
from fastapi.responses import PlainTextResponse

app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)

@app.post('/call')
async def post_call(request: Request):
    L = []

    L.append('[REQUEST HEADERS]\n')
    for key in request.headers.keys():
        L.append(f'{key}: {request.headers[key]}\n')

    body = await request.body()
    L.append('\n[REQUEST BODY]\n')
    L.append(body.decode())
    L.append('\n')

    return PlainTextResponse(''.join(L))

通常のTCPポートでのリクエスト処理と変わらないですね、素敵です。「Unix Domain Socketまわりのお世話」部分もアプリケーション開発者から隠すことができます。

Fn HTTPリクエストの内容

では、作成した fn-netty サーバを使って、Fn アプリケーションに送られてくる HTTPリクエストをダンプしてみましょう。FDKが介在しているとフィルタされてしまって見えないものが多いので、参考になると思います。

次の例は、curl → OCI API Gateway → OCI Functions という経路で送られてきた HTTP POST リクエストです。

$ curl -X POST -H "Content-Type: application/json" -d "[]" \
  https://aaaaaa.apigateway.us-ashburn-1.oci.customer-oci.com/fn-netty/

FN-NETTY SERVER
===================================
ENV: FN_FN_NAME=fn-netty
ENV: PATH=/usr/local/openjdk-8/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ENV: OCI_RESOURCE_PRINCIPAL_RPST=/.oci-credentials/rpst
ENV: JAVA_HOME=/usr/local/openjdk-8
ENV: FN_CPUS=125m
ENV: FN_APP_ID=ocid1.fnapp.oc1.iad.xxxxxx
ENV: LANG=C.UTF-8
ENV: OCI_TRACE_COLLECTOR_URL=https://zzzzzz.apm-agt.us-ashburn-1.oci.oraclecloud.com/20200101/observations/public-span?dataFormat=zipkin&dataFormatVersion=2&dataKey=XXXXXX
ENV: FN_MEMORY=256
ENV: FN_LOGFRAME_NAME=01FDGRJ96S0000000000000EVN
ENV: PRINT_ENV=true
ENV: JAVA_VERSION=8u282
ENV: FN_LOGFRAME_HDR=Opc-Request-Id
ENV: OCI_REGION_METADATA={"realmDomainComponent":"oraclecloud.com","realmKey":"oc1","regionIdentifier":"us-ashburn-1","regionKey":"IAD"}
ENV: FN_TYPE=sync
ENV: OCI_TRACING_ENABLED=1
ENV: OCI_RESOURCE_PRINCIPAL_VERSION=2.2
ENV: OCI_RESOURCE_PRINCIPAL_REGION=us-ashburn-1
ENV: OCI_RESOURCE_PRINCIPAL_PRIVATE_PEM=/.oci-credentials/private.pem
ENV: FN_FN_ID=ocid1.fnfunc.oc1.iad.xxxxxx
ENV: FN_APP_NAME=funcapp
ENV: HOSTNAME=e6e8c2a391f4
ENV: FN_FORMAT=http-stream
ENV: FN_LISTENER=unix:/tmp/iofs/lsnr.sock
ENV: HOME=/

VERSION: HTTP/1.1
HOSTNAME: localhost
REQUEST_URI: /call

HEADER: Host = localhost
HEADER: User-Agent = lua-resty-http/0.14 (Lua) ngx_lua/10019
HEADER: Transfer-Encoding = chunked
HEADER: Content-Type = application/json
HEADER: Date = Fri, 20 Aug 2021 03:13:56 GMT
HEADER: Fn-Call-Id = 01FDGRT0FQ1BT0C20ZJ0007256
HEADER: Fn-Deadline = 2021-08-20T03:18:55Z
HEADER: Fn-Http-H-Accept = */*
HEADER: Fn-Http-H-Cdn-Loop = fdJfCZhy618AGmi5huTgzQ
HEADER: Fn-Http-H-Content-Length = 2
HEADER: Fn-Http-H-Content-Type = application/json
HEADER: Fn-Http-H-Forwarded = for=xxx.xxx.xxx.xxx
HEADER: Fn-Http-H-Host = aaaaaa.apigateway.us-ashburn-1.oci.customer-oci.com
HEADER: Fn-Http-H-User-Agent = curl/7.29.0
HEADER: Fn-Http-H-X-Forwarded-For = xxx.xxx.xxx.xxx
HEADER: Fn-Http-H-X-Real-Ip = zzz.zzz.zzz.zzz
HEADER: Fn-Http-Method = POST
HEADER: Fn-Http-Request-Url = /fn-netty/
HEADER: Fn-Intent = httprequest
HEADER: Fn-Invoke-Type = sync
HEADER: Oci-Subject-Compartment-Id = ocid1.compartment.oc1..xxxxxx
HEADER: Oci-Subject-Id = ocid1.apigateway.oc1.iad.xxxxxx
HEADER: Oci-Subject-Tenancy-Id = ocid1.tenancy.oc1..xxxxxx
HEADER: Oci-Subject-Type = resource
HEADER: Opc-Request-Id = /260DBEA0EB7222EE8739452F1C39A783/01FDGRT0FA000000000000CQSJ
HEADER: X-B3-Spanid = 7b242b0382e0ea83
HEADER: X-B3-Traceid = 7b242b0382e0ea83
HEADER: X-Content-Sha256 = T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU=
HEADER: Accept-Encoding = gzip

CONTENT: []
END OF CONTENT

Function はリクエストがあるとプロセスが起動し、HTTP リクエストは同時に1つだけ送られ(結果マルチスレッドで処理が実行されることはありません、複数のリクエストを同時に処理する必要がある場合、コンテナが新たに起動されてリクエストがそちらに送られます)、一定時間リクエストが無いとプロセスが停止します。したがって、プロセス単位で受け取る情報は環境変数で、リクエスト単位で受け取る情報はHTTPのリクエスト・ヘッダで渡されているのが分かると思います。
HTTP ヘッダのうち、Fn-Http- で始まるものがありますが、これはクライアントから送られたオリジナルの HTTPリクエストの情報や API Gateway が新たに付加したリクエスト・ヘッダの情報を Fn 自体のヘッダ情報と区別して伝達するための仕組みです。
また、OCI Functions 側の設定でトレース=ON にしているので、環境変数やリクエスト・ヘッダにトレーシング関連の情報が設定されています。
Java FDK では流石にこの辺りの対応はきちんとされていて、RuntimeContext、HTTPGatewayContext、TracingContext というコンテキスト・オブジェクトに簡単にアクセスできるようになっています。FDKを使わないとなると、このあたりの実装を考慮すべきというか、最終的に綺麗なアプリ・コードが書けるフレームワークに仕立てないといけないですね。

まとめ

VM系ソリューションだとアプリケーションが bind すべき TCP ポートを環境変数で知らせてリクエストはマルチスレッド処理前提で多重で送りつけるのが一般的だと思うのですが、コンテナ系ソリューションだとその特性からまた違ったアプローチになるというのが面白いです。
願わくば、いずれかの JAX-RS 系フレームワークが native に Fn 対応(いや、Unix Domain Socket 対応でとりあえず十分!)してくれるとすごくうれしいのですが...。"FaaSアプリは軽量でなくてはならない" みたいな呪縛があるような気がしますが、もはやそういうステレオタイプから脱してもいい時期なんじゃないかと感じています。エンタープライズなガチな処理を安定的にやるための仕組みを考えたら Fn アプリがそこそこ重くなるのは必然だし、併せて軽量さより開発生産性の方が断然優先度が高くなる場面が増えてくるのも間違い無いでしょう。

参考情報