LoginSignup
32
13

More than 5 years have passed since last update.

nginx on AWS Lambdaでサーバレスサーバ

Posted at

気が付けば2016年も末…サーバレスがブームだった時代は過ぎ、なんだかサーバレスが当たり前の時代になりましたね。じゃあnginxもいい加減サーバレスになってもいいと思いませんか?

なにが必要だったか

nginxにはOpenRestyという、いい感じにモジュールが追加されたディストリビューション(に近いもの)があります。

素のnginxの機能はそのままに、luaで高速に動的な結果を返すことができ、簡単なアプリケーションであれば、これだけで作ることができます。あまり大きな規模のアプリケーションを作るのに向いているとは思わないですが、DBにアクセスするだけとか、内部APIに制限を掛けて外部に出すとか、リクエスト形式を変えるとか、そういうときに大変便利なサーバです。(それ以上のこともできます)

一方でAWSにはAWS Lambdaといういい感じのサーバレスな実行環境があり、Amazon API Gatewayという仕組みと組み合わせることで、アプリケーションサーバを構築することができます。あまり大きな規模のアプリケーションを作るのに向いているとは思わないですが、AWSサービスにアクセスするだけとか、内部APIに制限を掛けて外部に出すとか、リクエスト形式を変えるとか、そういうときに大変便利なサーバレス環境です。(それ以上のこともできます)

言い換えると、OpenRestyはサーバフル環境でのアプリケーションサーバの代替品であり、LambdaはAWSサーバレス環境でのアプリケーションサーバの代替品です。
これはもう組み合わせてプログラムがどっちでも使えるようにするしかないですよね。

どうするか

共通化ということだと、どちらの環境でも動くプログラムを用意するのが理想的ですが、たとえばOpenRestyのLuaコードをJavaやPython上で動かせるように頑張ったとすると、間違いなく開発負荷は増大しますし、実際には互換コード上の問題で運用(保守)負荷も増えるでしょう。

となると、nginxをexecするのがなんだかんだで最善のように考えられます。AWS Lambda環境は「サーバレス」ですが実質Amazon Linuxのコンテナなので、普通のコンテナでできることはたいていできます。もちろんプロセスのexecもできます。Lambdaコンテナは再利用されるので、運が良ければ、execしたプロセスごとコンテナを再利用できて、2回目からはexecしないで済むかもしれません。(できます)

また、できれば、nginxの設定ファイルにもバイナリにも手を加えたくはないです。共通化と言っても、設定全てやり直しというのでは、ありがたみが半減します。サーバ環境で使っていたnginxをそのまま持ってきたら動く、というのが理想です。(だいたいできます)

なお言語

パフォーマンス測定されているのを見ると、Pythonが一番良さそうなのでPythonにします。Linuxというプラットフォーム上も、Pythonが一番いいような気がします。

実装

Pythonでリクエストを再送させてみる

API Gateway経由のLambda環境ではリクエストは分解されて到達します。どういうことかというと、Lambdaは汎用的なもので、API Gatewayから起動したものでも、他のイベントソースから起動したものでも、lambda_handlerに対して第一引数でjsonで言うところのobjectの形で呼び出し元の情報が渡されます。Pythonならdictになります。

httpリクエストも分解されてこのobject内に別々に入っているので、拾い集めて、再び一つにまとめて元のリクエストを作成する必要があります。幸いなことにAPI GatewayにはLambdaプロキシ統合というウェブフレームワーク用の機能が今年の9月頃にリリースされたので、同じようなことを力技で達成していた先人に比べるとだいぶ楽に実現できるようです。マッピングテンプレートに手を触れる必要はなくなりました。とはいえ、httpヘッダを拾い集めてプロトコルを再構築するのは大変なので、Pythonのhttplibに任せます。

実際にどういうデータが入ってくるかは現時点ではあまりドキュメント化されていません。とりあえず下記のプログラムをLambdaコンソールからテスト実行してハンドラどのようなデータが渡されているかを眺めてみます。

lambda側
import json
import httplib

def lambda_handler(event, context):
    conn = httplib.HTTPConnection('xxx.xxx.xxx.xxx', port=80)
    body = json.dumps(event)
    conn.request("GET", "/", body, {})
    return {
        "statusCode": 200,
        "body": "{}"
    }

xxx.xxx.xxx.xxxのサーバではnc -l 80してたと思ってください。こんなデータが取れます。

送信されたjson
{  
  "body":"{\"test\":\"body\"}",
  "resource":"/{proxy+}",
  "requestContext":{  
    "resourceId":"123456",
    "apiId":"1234567890",
    "resourcePath":"/{proxy+}",
    "httpMethod":"POST",
    "requestId":"c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
    "stage":"prod",
    "identity":{  
      "apiKey":null,
      "userArn":null,
      "sourceIp":"127.0.0.1",
      "caller":null,
      "cognitoIdentityId":null,
      "user":null,
      "cognitoIdentityPoolId":null,
      "userAgent":"Custom User Agent String",
      "accountId":null,
      "cognitoAuthenticationType":null,
      "cognitoAuthenticationProvider":null
    },
    "accountId":"123456789012"
  },
  "queryStringParameters":{  
    "foo":"bar"
  },
  "httpMethod":"POST",
  "pathParameters":{  
    "proxy":"path/to/resource"
  },
  "headers":{  
    "Via":"1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
    "Accept-Language":"en-US,en;q=0.8",
    "CloudFront-Is-Desktop-Viewer":"true",
    "CloudFront-Is-SmartTV-Viewer":"false",
    "CloudFront-Forwarded-Proto":"https",
    "X-Forwarded-Port":"443",
    "X-Forwarded-For":"127.0.0.1, 127.0.0.2",
    "CloudFront-Viewer-Country":"US",
    "Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
    "Upgrade-Insecure-Requests":"1",
    "Host":"1234567890.execute-api.us-east-1.amazonaws.com",
    "X-Forwarded-Proto":"https",
    "X-Amz-Cf-Id":"cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==",
    "CloudFront-Is-Tablet-Viewer":"false",
    "Cache-Control":"max-age=0",
    "User-Agent":"Custom User Agent String",
    "CloudFront-Is-Mobile-Viewer":"false",
    "Accept-Encoding":"gzip, deflate, sdch"
  },
  "stageVariables":{  
    "baz":"qux"
  },
  "path":"/path/to/resource"
}

あー、なんかnode.jsでの公式ドキュメントっぽくはあるんだけどちょっとpath違うよね…
Lambdaコンソールで見えるjsonがそのまま第一引数に入るようですね。
ということがわかったので、これを元にhttpリクエストを再構築します。

lambda側
import json
import logging
import subprocess
import httplib
import urllib

def lambda_handler(event, context):
    conn = httplib.HTTPConnection('xxx.xxx.xxx.xxx', port=80)
    method = event["httpMethod"]
    params = event["queryStringParameters"]
    uri = event["path"] + ("?" + urllib.urlencode(params)) if params else ""
    headers = event["headers"]
    body = None
    conn.request(method, uri, body, headers)
    return {
        "statusCode": 200,
        "body": "{}"
    }
送信されたリクエスト
POST /path/to/resource?foo=bar HTTP/1.1
Content-Length: 0
Via: 1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)
Accept-Language: en-US,en;q=0.8
CloudFront-Is-Desktop-Viewer: true
CloudFront-Is-SmartTV-Viewer: false
CloudFront-Forwarded-Proto: https
X-Forwarded-Port: 443
X-Forwarded-For: 127.0.0.1, 127.0.0.2
CloudFront-Viewer-Country: US
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Upgrade-Insecure-Requests: 1
Host: 1234567890.execute-api.us-east-1.amazonaws.com
X-Forwarded-Proto: https
X-Amz-Cf-Id: cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==
CloudFront-Is-Tablet-Viewer: false
Cache-Control: max-age=0
User-Agent: Custom User Agent String
CloudFront-Is-Mobile-Viewer: false
Accept-Encoding: gzip, deflate, sdch

それっぽいリクエストになりました。

nginxをLambda環境で動かす

さて、次はnginxをLambda環境で起動させなければなりません。Lambdaは専用のコンテナの中で一般ユーザ権限で動きます。書き込みができるディスクは/tmpしかありません。
また、外部からの接続は不可能とのことですが、ポートをlistenすることはできるようです。しかし、rootでないので、80番はlistenできない気がします。
それぞれ順番に対応します。

nginxのディレクトリ構成

まずは、ディレクトリ構成を変えない方法を検討します。/tmpしか書き込めないので、ログやらソケットファイルやらは/tmp以下である必要があります。普段からログを/tmpに出している奇特な人はいないと思いますし、それで運用したいとも思わないでしょう。fluentdのようなものでただちに転送削除する場合はわからないですが。

/tmp以下にログを出すようにリビルドしたり、設定ファイルで調整することはできますが、それは結構なコストです。できれば自動でどうにかしたいですし、luaプログラムを設置しているパス情報もLambda環境に合わせて変えるようなことは避けたいです。

となると思い浮かぶのはchrootですが、chrootするにはroot権限が必要です。じゃあfakechrootですね。fakechrootは一部のシステムコールを乗っ取って、一般ユーザでもchrootできたように見せかけるプログラムです。どこまでできるのか実はあんまり知らないんですが、少なくともfakechrootされたプロセスから見たときのファイルシステムの/chrootしたディレクトリに見せかけることが可能です。

fakechrootはAmazon Linuxではyumで入りますが、Lambda環境でyumというわけにはいかないので、yumで得たバイナリを直接持ち込むことにします。と、試してみたらLambda環境にはchrootも用意されていませんでした。fakechrootは名前に反してchrootまではしてくれず、chrootと組み合わせて使う必要があります。しょうがないのでPythonでos.chrootしました。

なお、いまさらですが、Lambdaは持ち込むもの一式を1つのzipファイルに固めてアップロードすると、/var/task以下に展開して実行してくれます。OpenResty(nginx)、fakechrootは今回作成したLambdaハンドラと合わせて全部zipに固めて持ち込みます。

ディレクトリ構成の再構築

fakechrootの場合、シンボリックリンクでchrootの外を参照できるので、/tmp/rootあたりに、nginx専用のchroot環境をシンボリックリンクを使いながら作成します。ログディレクトリ、pidファイル、socketファイルの作成場所が確実に/tmp以下になるように調整します。擬似root一式をzipに埋めておいて、cpするか、逆に書き込みが必要なディレクトリに/tmpへのシンボリックリンクをあらかじめ用意しておいた方が楽でしたね。

listenポートの調整

これはどうにもなりません。fakechrootもそこまではどうにもならないようなので、設定ファイルをプログラムで実行前に書き換えるようにします。

それ以外の設定ファイル調整

実行ユーザ/実行グループの無効化が必要かと思いきや、調整不要でした。nginxかしこい。

rpath問題

OpenRestyを普通にビルドすると、LuaJITを共有ライブラリとしてリンクします。OpenRestyを公式リポジトリ(CentOS6)から連れてくると、他にも依存している共有ライブラリがあることがわかります。これらの共有ライブラリは標準的なライブラリのパスではなく、rpathと呼ばれる設定としてnginxのバイナリにフルパスで記録されています。
困ったことに、fakechrootしてもrpath(や、ld-linux.soが見るパス)までは書き換えることができません。もちろん、Lambda環境にはLuaJITの共有ライブラリなんか用意されていませんし、これらの共有ライブラリがないと、そもそもnginxが起動すらしません。

そこで、LD_LIBRARY_PATHを使います。LD_LIBRARY_PATHは大雑把にいうと、rpathに先だって参照されるディレクトリを指定する環境変数です。ここにあらかじめ共有ライブラリの本当のパスを設定してやることで無事にライブラリをロードすることができます。

rpathはビルド時に-rpathで指定されていたりしますし、正常環境でバイナリに対してlddするとだいたい想像が付きますが、人間が手打ちするのも面倒なので、readelf -dで読みだして、chroot前にLD_LIBRARY_PATHを設定してやることにします。

ただし、これはあくまでも擬似的な手法なので、rpathからロードした共有ライブラリが別なrpathを持っていて、それぞれ同名のライブラリをロードするが、実体は別、というような嫌がらせめいた構成だと破綻します。見たことはないですが。

Lambdaからのレスポンス

調子に乗ってLambdaからnginxのレスポンスヘッダをそのまま返していると、transfer-encoding:chunkedが入っていたりするとAPI Gatewayのおそらくエッジサーバあたりが永久にレスポンス待ちになり、ログは200で記録されているのに、クライアントは長時間待たされ、504 Gateway Timeoutという状態になって発狂しそうになります(なりました)。レスポンスヘッダはちゃんと選ばないとダメなようです。

できあがり

そんなわけで出来上がったものをこちらに用意しておきました。
https://github.com/nishemon/lambda-exec-wrapper

モノとしては何かを起動して実行してhttpリクエストをぶち込むだけのプログラムになるので、fakechrootする/しない、実行するバイナリ、通信方法、設定ファイルの書き換えなどの動作の詳細は設定ファイルに切り出してみました。だいたいどんな動作をしているかわかるかと思います。これで、nginx以外を動かしたくなっても安心ですね。テストしてないけど。

設定ファイル
{
  "fakechroot": {
    "rewrite": {
      # 設定ファイルのlistenをUNIXドメインソケットに変更
      "/usr/local/openresty/nginx/conf/.+\.conf$": {
        "^\s+listen\s.*$": "listen unix:/usr/local/run/nginx.sock;"
      }
    },
    # /から/tmp/rootへリンクが必要なもの
    "symlink": [ "/dev" ]
  },
  # 以下、chroot環境下でのパス
  "mkdir": ["/usr/local/openresty/nginx/logs", "/usr/local/run",
  "exec": "/usr/local/openresty/nginx/sbin/nginx",
  "connection": "unix:/usr/local/run/nginx.sock",
  # nginxに渡すヘッダーを追加または書き換える
  "header": {
    "Host": "www.example.org"
  }
}

OpenRestyの準備

Lambdaハンドラがあっても、Lambda用にOpenResty用のバイナリやリソースファイルを手動で切り出すのは大変です。stowでも使ってない限り、野良ビルド派でも、パッケージ派でも、まずここでLambdaに載せる気がなくなりますよね。

しかし今どきはoverlayfsがあるので、これを利用するとパッケージでもビルドでもこんな感じでOpenRestyをLambda用に構成できます。/をoverlayしてるだけなので、なるべく素のAmazon Linuxでやってください。

mkdir rootimg fakeroot work
sudo mount -t overlay overlay -o lowerdir=/,upperdir=rootimg,workdir=work fakeroot

sudo chroot fakeroot

yum install fakechroot-libs
## ここで 公式のリポジトリ(CentOS6)を設定: http://openresty.org/en/linux-packages.html
yum --disablerepo="*" --enablerepo="openresty" install --releasever=6 openresty
## または、yumの代わりに野良ビルドしたものをmake install

exit

sudo umount fakeroot
## overlayfs用のディレクトリとyumのゴミを捨てる
sudo rm -fr work fakeroot rootimg/{root,var,etc,tmp}

パフォーマンス

というところで、ほとんど実環境のままで動作することはお分かりいただけたかと思います。気になるのは実環境とのパフォーマンス差ですよね。nginxの共有メモリにnginxの起動時刻を置いて、それを返すだけのコードで測定してみます。

似た動作をする完全Python実装のLambdaとt2.micro(http)、t2.micro(http)+ELB(http/https)での比較です。測定はAPI Gatewayの関係で手元のabが使えなかったので、こいつをもう首にしちゃいまして、time curlを10回ぐらい叩くという原始的な手法で行いました。Lambdaはコンテナ単位では並列実行しないので、並列アクセス時のパフォーマンスは考えなくてもいいはずです。

初回を除く平均 初回を除く最小値 初回を除く最大値 初回 API Gateway初回
API Gateway + Lambda(nginx) 142 124 150 389~1444 411
API Gateway + Lambda(Python) 152 139 174 536 406
Lambdaログ 2.16 0.73 12.83 1.23~12 0.95
t2.micro 8.3 8 10 - -
with ELB(http) 13.1 13 15 265 -
with ELB(https) 89.9 87 93 357 -

単位はすべて[ms]です。

Lambda内の通信によるオーバーヘッドはほぼ0と見て良さそうです。初回起動がたまに大きくなりますが、これがexecのせいなのか、名前解決なのか、zipファイルが重いせいなのか、重いエッジサーバがあるのかは調査しきれませんでした。ELBでもなぜか初回は遅かったので、名前解決の都合っぽくはあります。Lambda自体で実行時間として記録されているのは初回の最大でも10msぐらいです。ただ、これに本当にnginxの起動が入っているかというと、だいぶ怪しいようです。

一番右端の「API Gateway初回」はLambdaをLambdaコンソールからテストによって起動したと考えられるコンテナにAPI Gatewayでアクセスした場合の初回実行時間です。Lambdaの起動以外に、Lambdaへの経路の確認のようなものが入るんでしょうか?それともやっぱり名前解決?これ単体でも結構大きいですね。

ちなみに、この測定時はLambdaをメモリ128M設定で動かしています。Lambdaに割り当てられるCPUはメモリ量に比例するんですが、このときメモリを1Gにしてもレコードはほぼ同じでしたので省略しています。
参考としてフィボナッチ数列も計算させてLambdaで使えるCPU性能自体も計測しておきます。

/f/40 API Gateway + Lambda(128M) API Gateway + Lambda(192M) API Gateway + Lambda(1G) t2.small
ms 12571 8098 1616 978

SSL/API Gatewayのオーバーヘッドを勘案すると、Lambda最高性能の1.5G設定時にCPU100%になると推測できます。
あとは単純な四則演算でLambdaで動かした場合の速度はほぼ求められると思います。

以下は実験に使用したOpenResty設定です。コンパクトに抑えるために設定ファイル内にluaコードを埋めています。

/usr/local/openresty/nginx/conf/nginx.conf
events {
    worker_connections  1024;
}
http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    keepalive_timeout  65;

    lua_shared_dict info 1m;
    init_by_lua_block {
      ngx.shared.info:set('time', ngx.time())
      function fib(num)
        if num <= 2 then
          return 1
        end
        return fib(num - 2) + fib(num - 1)
      end
    }

    server {
        listen       80;
        server_name  localhost;

        location / {
             content_by_lua "ngx.say(ngx.shared.info:get('time'))";
        }
        location ~ /f/(\d+) {
             set $value $1;
             content_by_lua "ngx.say(fib(tonumber(ngx.var.value)))";
        }
    }
}

まとめ

というわけでサーバがあっても無くてもOpenRestyを使えるようにする話でした。これでいつでも安心してOpenRestyを使えますね。

本文でリンクされていない参考文献

32
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
13