Serverless環境は600倍以上遅い? ~GoとNode.jsとPythonでベンチマークとってみた!~

  • 2
    いいね
  • 0
    コメント

Serverlesssは遅い?

以前の記事を書いたときに,自前で建てたサーバーレス環境のレスポンスが遅いなぁ.という肌感覚がありました.内部的にDockerを利用して,コンテナを起動している分,一般的なサーバーより遅いだろうという想像はしていました.しかし,どれくらいレスポンス速度が劣化するのか?あまり比較記事というものはないので,実際に計測してみることにしました.

AWS Lambdaはどれくらいの速度?

サーバーレスアーキテクチャという技術分野についての簡単な調査

という記事によると,

利用できるデータがほかに見当たらなかったので、ここではtaka4sato氏5の短いコメントを参照しておく。それによればAPI Gateway経由のLambdaファンクションの応答時間は「早いと250 msec、遅いと8000 msecぐらい」だそうだ。

と書かれています.ということは,類似したServerless環境では同じくらいのレスポンス時間,あるいはもう少し遅いくらいで返せるのではないかと予測できます.

実験対象のServerless環境

IronFunctionsというオンプレミスで動くServerless環境を使います.これに関しては,過去に導入記事を書きましたので,そちらをどうぞ.

1時間で作れる!かんたん自宅Serverless環境! ~はじめてのServerless Application入門~

ざっくりというと,AWS Lambdaのようなサーバーレス環境を簡単に導入できる便利なプロダクトです.

ベンチマーク

今回,ベンチマークに使う言語はGo,Node.js,Pythonの3種類で行います.それぞれの言語で,ほぼ同じ動作をするコードを書きます.それらをServerlessで動作させた場合,それぞれの言語のビルドインのHTTPサーバーで動作させてみた場合(Native),どれだけ差が出るのかを見ます.

  • Go Serverless (IronFunctions + Go)
  • Go Native (Go 1.6.2)
  • Node.js Serverless (IronFunctions + Node.js)
  • Node.js Native (Node.js v8.1.2)
  • Python Serverless (IronFunctions + Python)
  • Python Native (Python 3.5.2)

Go Serverless

package main

import (
    "encoding/json"
    "fmt"
    "os"
)

type Person struct {
    Name string
}

func main() {
    p := &Person{Name: "World"}
    json.NewDecoder(os.Stdin).Decode(p)
    fmt.Printf("Hello %v!", p.Name)
}

Go Native

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
)

type Person struct {
    Name string
}

func handler(w http.ResponseWriter, r *http.Request) {
    p := &Person{Name: "World"}
    json.NewDecoder(r.Body).Decode(p)
    fmt.Fprintf(w, "Hello %v!", p.Name)
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":2000", nil)
}

Node.js Serverless

name = "World";
fs = require('fs');
try {
    obj = JSON.parse(fs.readFileSync('/dev/stdin').toString())
    if (obj.name != "") {
        name = obj.name
    }
} catch(e) {}
console.log("Hello", name, "from Node!");

Node.js Native

const http = require('http');
name = "World";

http.createServer(
    (req, res) => {
         var body = "";
         req.on(
            "data",
            (chunk) => { body+=chunk; }
         );

         req.on(
            "end",
            () => {
                 obj = JSON.parse(body);
                 res.writeHead(200, {'Content-Type': 'text/plain'});
                 res.end('Hello ' + obj.name + " from Node Native!");
            }
         );

    }
).listen(6000);

Python Serverless

import sys
sys.path.append("packages")
import os
import json

name = "World"
if not os.isatty(sys.stdin.fileno()):
    obj = json.loads(sys.stdin.read())
    if obj["name"] != "":
        name = obj["name"]

print "Hello", name, "!!!"

Python Native

from http.server import BaseHTTPRequestHandler,HTTPServer
from json import loads
from io   import TextIOWrapper

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        content_length = int(self.headers.get('content-length'))
        text  = TextIOWrapper(self.rfile).read(content_length)

        self.send_response(200)
        self.send_header('Content-type','text/plain')
        self.end_headers()

        obj = loads(text)
        self.wfile.write("Hello {name} !! Welcome to Native Python World!!".format(name=obj["name"]).encode("utf-8"))

PORT = 1000
server =  HTTPServer(("127.0.0.1", PORT), Handler)

print("serving at port", PORT)
server.serve_forever()

ベンチマーク手法

それぞれのベンチマーク用のサーバーは同じマシンで動かしてます.仮想マシン上のUbuntu16.04で1コア メモリ2GBです.
負荷をかけるサーバーと負荷を受けるサーバーは同じで,Apache Benchを使っています.
以下のようなjsonを用意し,

johnny.json
{
    "name":"Johnny"
}

Apache BenchでPost処理を投げることによって,負荷をかけます.リクエスト数は100,並列数は5です.(リクエスト数が少ないのは後述します)
その際にサーバーからレスポンスが返ってくるまでの時間(Response Time)を計測します.

#XXXX/XXXXは適宜補完
ab -n 100 -c 5 -p johnny.json -T "Content-Type: application/json" http://localhost:XXXXX/XXXXX

ベンチマーク結果

Response Time min[ms] mean[ms] std[ms] median[ms] max[ms] Native比(mean)
Go Serverless 3951 6579 1010 6512 8692 1644.75
Go Native 0 4 5 2 37 -
Node Serverless 5335 14917 3147 15594 20542 621.54
Node Native 5 24 45 12 235 -
Python Serverless 5036 13455 4875 14214 29971 840.94
Python Native 6 16 4 16 26 -

以下の図は縦軸が対数スケールであることに注意してください(大小関係が分かりやすいように対数で表しています)

image.png

考察

表を見ると分かるように,Goであれば,Serverlessの環境はNativeの環境より1600倍以上遅いことがわかります.他のNode.jsでも600倍,Pythonも800倍程度遅いことが分かります.
PythonとNode.jsの結果を比べた場合,Pythonのほうが速いことに違和感を感じられる方もいらっしゃると思います.Native環境で追試をしてみたところ,リクエスト数が少ない,並列数が少ない場合は,Pythonのほうが速い場合がありました.リクエストを1万以上にすると,Node.jsのほうが安定して処理することが可能で,Pythonより速く処理が終わります.また,PythonのNative実装はエラーになって,リクエストが正常に処理できないこともありました.おそらく,この程度のリクエスト数で速度差が出るGoのほうが異常にチューニングされてるんだと思います.
 ここで,先述した「AWS Lambdaは250ms~8000msの処理時間」ということと比較してみようと思います.今回のベンチマークの結果は,個人的には違和感の少ない結果でした.自分でIronFunctionsに対し,Curlでリクエストをしてみたときに,「遅い.」と感じてましたし,Dockerをリクエストごとに作るのであれば,これくらいは仕方がないと思います.その一方でAWS Lambdaの250msは非常に速いという印象がありました.

Tune Up AWS Lambda

を見ると,AWS Lambdaはコールドスタートとウォームスタートの2種類の起動方法があるようです.前者は直感的なサーバーレスと差はなく,「リクエストごとにコンテナが作成される」イメージで,後者は,「一度作られたコンテナが,再利用される」らしいです.このようにLambdaの実装のほうは,コンテナを作らないこともあるので動作が速い.と思われます.それが,最速で250msでレスポンス出来てる理由だと思います.一方でIronFunctionsのほうは,おそらくコールドスタートしか実装していないため,それほどの速度はでていないのだと思います.しかし,自前で作った環境で,GoをServerlessで動かした場合,Maxが8600ms程度というのは,健闘している処理速度ではないかと思います.もちろん処理してるクライアント数は雲泥の差がありますが,コンテナの生成・廃棄の速度は実はそれほど変わらないのでは?と思った次第です.

AWS Lambdaのホストは良いマシン?

以下は,AWS Lambdaの料金表のリンクです.

AWS Lambdaの料金表
https://aws.amazon.com/jp/lambda/pricing/

料金プランは使用時間*メモリ使用量で課金されるようで,CPUは選べないようです.では,CPUはどのように割り当てらるのか?というとヘルプに書いてありました.

Q: コンピューティングリソースはどのように AWS Lambda に割り当てられるのですか?
AWS Lambda のリソースモデルでは、お客様が関数に必要なメモリ量を指定するとそれに比例した CPU パワーとその他のリソースが割り当てられます。たとえば、256 MB のメモリを指定すると約 2 倍の CPU パワーが Lambda 関数に割り当てられます。128 MB のメモリを指定した場合と比較すると CPU パワーは倍となり、512 MB のメモリを指定した場合と比較すると半分になります。メモリは 128 MB から 1.5 GB まで、64 MB ごとに増加できます。

そしてもう1つ,以下の記事なのですが,

Serverless Frameworkを本番導入した話

今回は30回という極端に少ない数で計算を行ったので差が大きく差が開きましたが、この条件であれば、1日あたり約十万回以上叩かれるAPIであればEC2を用いた構成の方が安上がりになりました。

という内容でした.何度かサーバーレスの環境テストをしましたが,かなりホストマシンのCPU使用率が上がります.そのため,どうすればホスト側(Amazon)がペイできるのか考えていたのですが,Amazon側としては時間単価が高いように設定されているようですね.また,サーバーレスは,コンテナの起動・廃棄してからでないと基本的にレスポンスが返せない作りなので,高速なレスポンスをするにはCPUをイイものに設定してあるのかなぁ.と思った次第です.

ベンチマークの追補

普通ベンチマークの回数は1万回だったり,もしくは,いくつかのパターンを行ったりすると思います.しかし,今回の実験では100リクエスト程度に抑えています.その理由は2つ.1つは「遅い」からです.Serverlessで動かした場合,速くても4000ms程度かかります.そのため,大規模なリクエストのベンチマークは現実的でなかったため行いませんでした.2つめは「不安定」なためです.IronFunctionsは動作が不安定な面があります.そのため,100回程度のリクエストでも10回程度失敗することが,割とあります.そのため,並列数を上げたり,リクエスト回数を多くすると,かなりの確率で処理がさばけなくなります.これもIronFunctionsのDocekrコンテナのライフサイクルに依存するようで,同じリクエストを送ってもタイムアウトしたり,もしくはしなかったり,正確な値をとることが難しいプロダクトでした.そのため,この記事に書かれているデータは,処理時間の値自体を追う.というより,Nativeと比較して,処理時間のオーダーがあってる程度の認識のほうが正確だと思います.
 また,負荷をかけるマシンと,負荷を受けるマシンが同じなのも,すこし正確でないベンチマークかもしれません.これは単純に私が環境を2つ用意しなかっただけですが,Native実装とServerless実装の速度差を見るのであれば,サーバーの状態だけ同じにしておけば,それほど問題ではないのではないか.と思ってます.

感想

 今回のベンチマークは非常に時間がかかった.たかだか600回程度のリクエストに3,4時間かかったためにかなり投稿が遅れてしまった.そして,Serverlessの遅さが本当に際立った結果になってしまった.使い込んでみようかな.と思ったが,ちょっとやめておこうかな・・・その一方でAWS Lambdaってのは優秀なんだなぁ・・・・あとGoのhttpサーバーの速さは凄い.これほど小さなベンチでも速いとは思ってもみなかった.
あとIronFunctionsは日本語のノウハウがほとんどないので辛い.実はfnlbというリバースプロキシものあり,それによりクラスタリングをする方法も公式で用意されている.そうするとスケーリングもしやすいのだろう.とは,思ってはいるものの,あまりにも単体での動作が遅すぎるので,もっとチューニングだったり,ボトルネック改善をしてやるのは必須かもしれない.そもそもIronFunctions自体がGoで書かれているので,そんなに遅いわけではないはずなんだけれども・・・やっぱりdockerコンテナ周りなのかな・・・うーむ.サーバーレスの道のりは遠い.