Undertowで、コンテキストパスを読み分けて違うリモートサーバーに処理を委譲するリバースプロキシを書く

モチベーション

趣味アプリのAPIサーバーとWEBサーバーが別で、こいつらを普通に運用するとWEBサーバーが認証機能まで飲み込んでしまって辛い気持ちになる予感がしたので、認証基盤を切り離すことにした。

とはいえいきなりログイン・ログアウト・会員登録や招待、ソーシャルログインや権限管理まで全部は飲み込めないので、バックエンドから特定のレスポンスヘッダーが返ってきたらセッションを発行し、削除リクエストが来たらセッションを破棄するだけの薄いものに。

これを実現するために必要なのは以下の機能。

  • セッションストア(Redisと通信するだけなのでOK)
  • リクエストヘッダーの書き換え(Undertowの機能で「こう動けばいいな」と思いながら書けばそのとおりになるのでOK)
  • レスポンスヘッダーの書き換え(ひとつ前の記事で公開済み)
  • コンテキストパスを読み分けて宛先がWEBサーバーなのかAPIサーバーなのか調べる

コンテキストパス?

URIの最初の方のやつのことを指す... と思う。 要は

  • /api ならAPIサーバーに
  • `/' ならWEBサーバーに

みたいなことがしたい。

できるよ、PathHandlerを使えばね

できた。

HttpHandler pathHandler = new PathHandler()
                .addPrefixPath("/api", new RewriteHeaderHandler(apiProxyHandler))
                .addPrefixPath("/", new RewriteHeaderHandler(webProxyHandler));

渡してる apiProxyHandlerwebProxyHandler はバックエンドアプリケーションに通信をプロキシするハンドラ。 ここらへんも基本「動け〜」と念じながら書けば動く。

ソース

ちょっとコメントも書いたから何かの助けになれば幸い。

examples/PredicateProxyHandlers.java at master · blackawa/examples · GitHub

Undertowで書いたリバースプロキシで、リモートサーバーからのレスポンスヘッダーを読む

なぜこの記事を書いた?

3日くらいハマって絶望の淵に立ったから。 とにかく情報がない。と思いきや最終的には数年前のメーリスでさがしてたものが見つかるから、実は自分の検索能力が足りてないだけの気もする。

結論: HttpServerExchange.addResponseCommitListener を使おう

使おう。 ここで課題解決した人はお帰りください。

以下経緯とか。

リバースプロキシ?

Undertowにはリバースプロキシ機能があって、それはProxyHandlerってクラスで提供される。 HandlerってのはUndertowにおけるリクエストをさばく業務ロジックを書く場所。 ProxyHandlerは業務ロジックとしてリバースプロキシを提供する。

ProxyHandlerはProxyClientという接続先サーバーとのコネクションを確保するクラスを受け取る。こいつが自前実装可能な拡張ポイントで、必要なら自分で書くことができる。

レスポンスヘッダーを読む?

UndertowのHandlerは

class XxxHandler implements HttpHandler {
    @Override
    public void handleRequest(HttpServerExchange exchange) {
        // ...
    }
}

みたいな形をしてて、こいつに別のHandlerを渡すとHandlerのチェインを実装できる。たとえば以下のように。

class XxxHandler implements HttpHandler {
    private HttpHandler next;

    public XxxHandler(HttpHandler next) {
        this.next = next;
    }

    @Override
    public void handleRequest(HttpServerExchange exchange) {
        // ...
        next.handleRequest(exchange); // <- これによって次のハンドラを呼ぶ
    }
}

で、こう書くと普通、 next.handleRequest の後に書いたロジックは次のハンドラーの処理終了後の HttpRequest / Response の状態を受け取って行えるという使い方を想像できると思う。 たしかにどちらも普通のHandlerならそれでいける。 でもProxyHandlerはダメ。理由は謎。

ProxyHandlerの中身を読むに exchange.dispatch って関数を読んでて、こいつは今の処理をワーカースレッドに譲渡する関数なんだけど、普通のHandlerを実装してみても普通にレスポンスヘッダーを読み書きできてしまう。

そこで ResponseCommitListener ですよ

[undertow-dev] Rewrite response headers in ProxyHandler

いやー、そりゃみんな同じこと考えるよね。 まじでありがとう過去のコミュニティ参加者の人。

コード

一応以下。

examples/MutlipleHandlers.java at master · blackawa/examples · GitHub

Undertowでリバースプロキシを書く

APIサーバーとWEBサーバーを分けて、その手前に認証用のリバースプロキシを建てたくなったのでやってみた。

Undertow?

さいきんのTomcatJBossがつくったTomcat

くらいの理解しかしてない。 組み込みができる。あとリバースプロキシになれる。

## まずシンプルなやつ 実は公式が @Deprecated な超絶シンプルなリバースプロキシの実装を置いておいてくれている。

undertow/SimpleProxyClientProvider.java at master · undertow-io/undertow · GitHub

これを見ると、どうやら僕は以下のものを実装しなければいけないらしいということがわかる。

  • ProxyClientの実装クラス
  • ClientCallbackの実装クラス

まずProxyClientは2つのメソッドを持ってて、それぞれ

  • findTarget: プロキシ先を選定する。既存のProxyClientだとまともに使われてないので本当の役割は違うのかも。
  • getConnection: プロキシ先とのコネクションを確立する。

をやれば良いらしいことがわかる。 ProxyClientを使うクラスであるProxyHandlerの中身を見るにURIとかクエリストリングとかはなんかよしなにやってくれるっぽい。実際よしなにやってくれた。(ref: https://github.com/undertow-io/undertow/blob/946e98e4e46b31cfe7eb1d290a9596f76698c567/core/src/main/java/io/undertow/server/handlers/proxy/ProxyHandler.java#L412-L423)

とりあえず真似して書いたら、一つのプロキシ先に通信を転送することはできた。

2つに振り分ける

今回は findTarget 内で振り分けることにした。これで良いのかは正直わからん。 で、 getConnection では振分先に流すだけ。

わりとさっくりイケた。 ただ、コネクションがすでにあれば流用する、みたいなロジックが挟まってるとAPIに流れてほしいものがWEB側に流れたりしててうまくなかったので一旦消した。

すると、10秒くらいアクセスが途切れると次のアクセスがクソ雑魚パフォーマンスになる事象が発生した。多分内部で持ってるコネクションを手放すんだと思うけどわからん。現時点で完全に雰囲気で書いている。

でもできた。書いたものは以下にあげてあるので随意に見てくれ。

examples/undertowreverseproxy at master · blackawa/examples · GitHub

次やること

  • Clojureで書き直す
  • DBを見て特定のリクエストヘッダーを埋め込んだ状態でバックエンドアプリケーションに転送する
  • 今書いてる趣味アプリに組み込んでデプロイしてみる
  • コネクションを再利用する

Coursera Machine Learning 受講ノート 6

f:id:blackawa:20171218110601p:plain

Week2 / Normal Equision

正規方程式。最急降下法で漸近的に得ていた最適値を一発で出すための方程式。

偏微分解析学わからないとわからない領域に入ってきたが、後でフォローしてもらえるらしい。

訓練データとその答えを別の行列とベクトルとして扱って、それをある方法で解くと最適なθが手に入るらしい。

具体的には

pinv(x' * x) * x' * y

この式を使う場合、前に説明したFeature Scaling(すべてのパラメータをだいたい同じ値に揃えること)は必要ない。

正規方程式と最急降下法の使い分け

最急降下法では、学習レートを選んで何度もイテレーションを回す必要があるが、特徴が何個あってもちゃんと動く。 正規方程式では特徴の行列をその転置行列と掛け算して逆行列を求める必要があるので、特徴が増えるとめちゃめちゃ遅くなる。 具体的には、10,000個の特徴を計算するくらいまでは正規方程式でも良い。

正規方程式と非可逆性

Octavepinv関数は、x' * x が非可逆でもθを計算できる。 非可逆な時は

  • 冗長な特徴がある
  • 特徴が多すぎる

ことを疑うと良い。

課題提出システム

すごい。 Octaveって ファイル名()すると中身を実行できるのか。

submit() で提出できるのクールだな。

添字つきの変数が混乱してきた。

右上と右下にそれぞれ変数がつくやつ、最初分かってたけど読み方忘れてきた。

Week2 / Octave/Matlab Tutorial

Refs

Coursera Machine Learning 受講ノート 5

f:id:blackawa:20171218110601p:plain

Week 2 / Multivariate Linear Regression

多変量線形回帰について取り扱う。多分ここからOctaveをガツガツつかっていくんだろうなー。

ここまでで取り扱ってきた線形回帰は、ひとつの入力値にくわえてθ(0)とθ(1)を扱うことで予測を行ってきた。 しかし入力値をもっと増やしたら?

これからx(i)は、i行目の入力値のベクトルとする。

すると今まで使っていた

hθ(x) = θ(0) + θ(1)x

の式は

hθ(x) = θ(0) + θ(1)x(1) + θ(2)x(2) + θ(3)x(3) ...

転じて

hθ(x) = θ(0)x(0) + θ(1)x(1) + θ(2)x(2) + θ(3)x(3) ...
// ただし x(0) = 1

と表現でき、これはつまり

hθ(x) = θTx
// θTはθを転置した行列
// x = [x(0) x(1) x(2)...]というベクトル

と表現できる。

多変量の線形回帰を解くには、これまでに扱った1変数の線形回帰と同じような式が出てくる。 値を少しずつ変えて最適解に近づく式もそのひとつで、今まで1つのパラメーターについてやればよかったことを複数のパラメーターについてやれば良い。

実践的なテクニック

入力値の値の範囲を調節して収束を早める

すべての変数を同じような値の範囲になるように調整する。 つまり、等高線が真円に近づくような値にするとより早く収束する。

だいたい-1から1の間らへんになるようにすると良い。

  • -3 < x(i) < 2 -> OK
  • 0 < x(i) < 2 -> OK
  • -100 < x(i) < 100 -> NG
  • -0.0001 < x(i) < 0.0001 -> NG

具体的には、すべての訓練データの入力値に適切な値を加えて、平均値がだいたい0になるようにする。 ただしx(0)は必ず1なのでそれをやらない。

mean normalizationは

x(i) - μ(i) / s(i)
// μ(i) = 訓練データxの平均
// s(i) = 値の範囲。max(x) - min(x) もしくはxの標準偏差

で表現できる。

適切な学習レートを見つけ出す

話変わって、今度はどうやって学習レートを調整するか?の話。 学習レートは、学習を繰り返すごとにコスト関数の値が0に近づいていくような値を設定すべき。

何回くらいイテレーションを繰り返せばよいかは予測が難しいので、コスト関数が常に収束に向かうことで学習レートの正しさを推測する。

予測に使う変数を選出する

必要なら、与えられた値セットを加工して新しい特徴を作り出してそれを予測に使用しても良い。 あるいは、1つの変数しかないがそれを3次関数としたい(上昇し続けるグラフになる)場合、1つの変数を累乗した値を2つめ以降のパラメータに使っても構わない。 累乗でなく平方根をとったりしても良い。

Coursera Machine Learning 受講ノート 4

f:id:blackawa:20171218110601p:plain

Week 1 / Linear Algebra Reviewの続きから

行列の掛け算は、行の要素と列の要素をそれぞれ掛け算して結果をすべて足した値を結合した行列になる。

[[1 2]
 [3 4]
 [5 6]]

かける

[7 8]

[23 53 83]

となる。

これをふまえて、複数の値x(10, 15, 20)に対する以下の式のyを求めたい時、

y = b + ax
[[1 10]
 [1 15]
 [1 20]]

[b a]

をかけると、答えのセットが手に入る。

行列と行列の掛け算は、行列とベクトルの掛け算の拡張版。

ただし行列の掛け算は、Commutativeではない。つまり、A * BB * A は同じ値にならないことがある。

Identity Matrix(単位行列)っていうのもある。こいつと任意の行列の掛け算は、結合則(I * A = A * I)を満たす。

行列の逆数を取るとどうなる?つまり、A * inverse of A = Identity Matrixとなるような行列は何だろう。 逆行列を持てる m x m の行列Aにおいて、 A * A(-1) = Identity Matrix が成り立つ。 これはOctaveでは pinv(A) と表現できる。

また行列は転置できる。 f:id:blackawa:20171218110601p:plain

Week 1 / Linear Algebra Reviewの続きから

行列の掛け算は、行の要素と列の要素をそれぞれ掛け算して結果をすべて足した値を結合した行列になる。

[[1 2]
 [3 4]
 [5 6]]

かける

[7 8]

[23 53 83]

となる。

これをふまえて、複数の値x(10, 15, 20)に対する以下の式のyを求めたい時、

y = b + ax
[[1 10]
 [1 15]
 [1 20]]

[b a]

をかけると、答えのセットが手に入る。

行列と行列の掛け算は、行列とベクトルの掛け算の拡張版。

ただし行列の掛け算は、Commutativeではない。つまり、A * BB * A は同じ値にならないことがある。

Identity Matrix(単位行列)っていうのもある。こいつと任意の行列の掛け算は、結合則(I * A = A * I)を満たす。

行列の逆数を取るとどうなる?つまり、A * inverse of A = Identity Matrixとなるような行列は何だろう。 逆行列を持てる m x m の行列Aにおいて、 A * A(-1) = Identity Matrix が成り立つ。 これはOctaveでは pinv(A) と表現できる。

また行列は転置できる。 f:id:blackawa:20171218110601p:plain

Week 1 / Linear Algebra Reviewの続きから

行列の掛け算は、行の要素と列の要素をそれぞれ掛け算して結果をすべて足した値を結合した行列になる。

[[1 2]
 [3 4]
 [5 6]]

かける

[7 8]

[23 53 83]

となる。

これをふまえて、複数の値x(10, 15, 20)に対する以下の式のyを求めたい時、

y = b + ax
[[1 10]
 [1 15]
 [1 20]]

[b a]

をかけると、答えのセットが手に入る。

行列と行列の掛け算は、行列とベクトルの掛け算の拡張版。

ただし行列の掛け算は、Commutativeではない。つまり、A * BB * A は同じ値にならないことがある。

Identity Matrix(単位行列)っていうのもある。こいつと任意の行列の掛け算は、結合則(I * A = A * I)を満たす。

行列の逆数を取るとどうなる?つまり、A * inverse of A = Identity Matrixとなるような行列は何だろう。 逆行列を持てる m x m の行列Aにおいて、 A * A(-1) = Identity Matrix が成り立つ。 これはOctaveでは pinv(A) と表現できる。

また行列は転置できる。

生IntegrantでHTTPサーバーを立てる

Integrantといえばductのバックエンドにある状態管理ライブラリだが、ductを触っているとチラ見えしてくるので、一度生で触ってなんとなく雰囲気が分かるようにしておこうと思う。

Explicity vs Implicity(magic)

Java界に君臨するSpringBootを触ったことがある人は、「これ(だけしか書いてないのに)、なんで動くんだ?」と思ったことがあるはず。なぜなら、SpringBootは裏にあるSpringFrameworkをImplicitにうまいこと設定してくれる仕組みだから。

@SpringBootApplication とか @Controller って書けばWebアプリケーションが起動するって普通に考えてやばい。

HTTPリクエストに応答できるWebアプリケーションを立てるなら、まず確実にTomcatなりJettyが必要だし、DBにつなぐならConnectionインスタンスを生成しておかないと接続ができないはずなのに、起動できてしまう。 やばく便利だし、やばい学習コストがかかる。

いっぽうIntegrantやその他のClojure製状態管理ライブラリはExplicityに重心を置いているものが多いように思う。 つまり、明示的に指定しないとアプリケーション内で使えるようにならないし、そこには明示的に渡した引数しか渡せない。

Getting Started of Integrant

というわけでIntegrantを使ってみる。

lein new app gs-integrant

IntegrantはClojure 1.9.0が必要なのでClojureのバージョンを上げる。 そしてIntegrantと、HTTPリクエストに応答できるようにringを依存関係に追加する。

(defproject gs-integrant "0.1.0-SNAPSHOT"
  ;; ...
  :dependencies [[org.clojure/clojure "1.9.0"]
                 [integrant "0.6.2"]
                 [ring "1.6.3"]]
  ;; ...
  )

REPLを起動してエディターから接続する。

GitHub - weavejester/integrant: Micro-framework for data-driven architecture

公式のREADMEに書いてあるとおりにdefmethodする。

(ns gs-integrant.core
  (:gen-class)
  (:require [integrant.core :as ig]
            [ring.adapter.jetty :as jetty]
            [ring.util.response :as resp]))

(defn -main
  "I don't do a whole lot ... yet."
  [& args]
  (println "Hello, World!"))

(def config
  {:adapter/jetty {:port 8080 :handler (ig/ref :handler/greet)}
   :handler/greet {:name "alice"}})

(defmethod ig/init-key :adapter/jetty [_ {:keys [handler] :as opts}]
  (jetty/run-jetty handler (-> opts (dissoc :handler) (assoc :join? false))))

(defmethod ig/halt-key! :adapter/jetty [_ server]
  (.stop server))

(defmethod ig/init-key :handler/greet [_ {:keys [name]}]
  (fn [_] (resp/response (str "Hello " name))))

gs-integrant.core名前空間を評価して、REPLで移動して

(def system
  (ig/init config))

を評価するとHTTPサーバーが起動し、localhost:8080 にアクセスすると Hello alice と表示される。 同じ名前空間(ig/halt! system) すると終了する。

ここではinit-keyとhalt-keyしか書いていないが、それ以外にもsuspend-key!とresume-keyがあって、それぞれ中断時の振る舞い、restart時の振る舞いを定義できる。特に中断時は、引数を一度atomを経由して持つことで、サーバーの再起動なしにその内容を変更することができたりする。やばい。

これでhandlerにrouter機能をもつcompojureなりbidiなりを放り込めばルーティングができるんだろうし、cljsのコンパイルタスクを追加すればrestartするたびにコンパイルされるんだろうし、なんてシンプルなんだろう。

小さなモジュールがシーケンスを通して連携し合う世界観を活用しているし、またその世界観を拡張もしているクールなライブラリ。