「一般消費者が事業者の表示であることを判別することが困難である表示」の運用基準に基づく開示: この記事は記載の日付時点で株式会社ソラコムのエンジニアリングチームに所属する社員が執筆しました。ただし、個人としての投稿であり、株式会社ソラコムとしての正式な発言や見解ではありません。

はじめに

株式会社ソラコム Advent Calendar 2021 16 日目の記事です。今日は 2021 年 6 月に発表された SORACOM Arc (以下 Arc) とそのクライアントエージェント soratun、そして AWS Lambda extension のお話です。

SORACOM Arc とは

SORACOM Arc はソラコムが 2021 年 6 月にリリースした、SIM カードがなくてもソラコムのプラットフォームサービスをセキュアに利用できるサービスです。伝送路の安全性を WireGuard を使って確保しており、WireGuard を使える機器であれば iPhone や Android のようなスマートフォンから、Raspberry Pi を含む Linux、そして M5Stack で利用されている ESP32 でも動作します(参照: WireGuard for ESP32 の実装的なところ—ciniml さんによる SORACOM Advent Calendar 2021 7 日目の記事)。

物理的な SIM カードに代わってバーチャル SIM を発行し、バーチャル SIM に紐付いた WireGuard 認証情報を使って SORACOM プラットフォームと接続します。

soratun とは

soratun (sora-com tun-nel, ソラタン) とは SORACOM Arc を簡単・安全に使っていただくために開発した WireGuard クライアントで、MIT ライセンスで公開しています。

バーチャル SIM は SORACOM ユーザーコンソールSORACOM CLI から作成できますが、soratun は作成から仮想ネットワークデバイス(tun)を使った WireGuard 接続までを一つのコンパクトなバイナリファイルでまとめていい感じにやってくれます。詳細は 公式ドキュメントSORACOM Meetup〜Wi-Fi からもソラコムを!はじめよう新サービス SORACOM Arc で発表した資料 クライアントエージェント soratun とは / SORACOM Meetup Arc soratun overview - Speaker Deck をご覧ください。

AWS Lambda Extension とは

AWS Lambda extension は 2021 年 5 月に GA となった比較的新しい機能で、AWS Lambda (以下 Lambda) 関数の実行環境を独自に拡張できます。こちらも詳細は 公式ドキュメント をご覧いただきたいところですが、誤解を恐れずに今回のユースケースに絞って簡単に説明すると、Lambda の実行環境に Lambda 関数とネットワーク経由でコミュニケーションできる独自のプロセスを立てられる機能です。

GA が発表された 記事 ではトレーシング、ロギング、モニタリング、プロファイリングなどの extension が紹介されています。

AWS Lambda Extension と SORACOM Arc

ようやく本題です。Lambda extension と改造版 soratun を組み合わせて以下のようなことをやりました。

  1. soratun を SORACOM プラットフォームへのプロキシサーバーとして動作するよう改造しました (雑に soraproxy と命名)。
  2. soraproxy を Lambda extension として動作するよう AWS のサンプルを元にラッパースクリプトを作りました。
  3. Lambda extension を経由して Lambda 関数からプログラミング言語を問わず簡単に SORACOM プラットフォームへデータを送信できるようになりました。

アウトプットの質と量で尊敬する 1stship さんによる SORACOM Advent Calendar 2021 13 日目の記事 SORACOM Arc を特権なしで使えるツールを開発してみた を直前に拝見してネタが重複してしまったかと焦りましたが、アプローチが異なっていたのでよかったです。そして 1stship さんの記事は AWS やソラコムのスクリーンショット満載で大変親切であります。

以下にそれぞれのステップを説明します。

soratun の改造

soratun を SORACOM プラットフォームへのプロキシサーバーとして動作するよう改造します。既存の soratun コマンドとは別に soraproxy というコマンドを作っています。

soratun の WireGuard 実装そのものはユーザー空間で動くとはいえ、稼動には tun デバイスの作成が必須です。実際に試していないのですが、Lambda extension では root 権限(正確には NET_ADMIN capability)が必要になるネットワークインターフェースの作成ができないような気がしました。

そこで、soratun が使用している wireguard-go に含まれる tun/netstack パッケージを利用します。このパッケージはドキュメントがほとんど無いのですが、gVisor の TCP/IP スタックを使った WireGuard トンネルを提供します。すなわちネットワークインターフェースを含め WireGuard 接続を全部ユーザー空間で実装できる(= 特別な権限が不要)ことになります。

soratunsoraproxy
WireGuardユーザー空間 (wireguard-go)ユーザー空間 (wireguard-go)
ネットワークカーネル (tun)ユーザー空間 (gVisor)

プロキシサーバーとしての実装はだいぶ適当で、SORACOM プラットフォームの Unified Endpoint に対する HTTP クライアントを作成し、受け取った POST リクエストを転送します。

今回のポイントである tun/netstack パッケージを使用しているのはこのあたりです。gVisor を使ったトンネルを作成し Go の net/http.ClientTransport として渡しています。簡単ですね。

AWS Lambda Extension としてパッケージ

AWS の公式サンプル aws-lambda-extensions/extension1.sh を参考に soraproxy が Lambda extension として動作するようラッパースクリプトを作成します。

LAMBDA_TASK_ROOT 環境変数を使用し、SORACOM Arc の認証情報 (arc.json) は Lambda 関数にパッケージされたファイルを参照しています。Lambda 関数単位でバーチャル SIM を切り替えるのも簡単ですね。最後に & を付けずにブロックしてしまってはまりましたが、厳密には soraproxy の起動まで待つようにしないと問題が起きそうな概念検証感あふれる実装であります。

/opt/bin/soraproxy up --config ${LAMBDA_TASK_ROOT}/arc.json --address ${SORAPROXY_ADDRESS} --port ${SORAPROXY_PORT} &

soraproxy のバイナリと上記のスクリプトを aws-lambda-extension/scripts/build.sh を使って Zip ファイルにまとめ、Lambda レイヤーとして登録します(参考: Lambda レイヤーの作成と共有 - AWS Lambda)。

soraproxy を使用する AWS Lambda 関数の実装

新しい Lambda 関数を作って上記の Lambda extension をレイヤーとして指定します(Lambda 関数の言語は何でも使えますが、レイヤーは ARN で指定する必要がありました)。

今回は JavaScript でテストしました。関数(index.js)と SORACOM Arc の認証情報を保存した arc.json (soratun で使っているものそのまま)の 2 つのみです。Lambda 関数としてはシンプルで、soraproxy が待ち受けている localhost:8080POST しています。これだけで Unified Endpoint へデータが送信できます。

const http = require("http");

exports.handler = async (event) =>
  new Promise(async (resolve, reject) => {
    const req = http.request(
      { host: "localhost", port: 8080, path: "/", method: "POST" },
      (res) => {
        let buffer = "";
        res.on("data", (chunk) => (buffer += chunk));
        res.on("end", () => resolve({ statusCode: 200, body: buffer }));
      }
    );
    req.on("error", (e) =>
      reject({ statusCode: 500, body: JSON.stringify(e.message, null, 2) })
    );
    req.write(JSON.stringify(event));
    req.end();
  });

AWS Lambda Management Console

テスト実行すると、以下のように Lambda extension (soraproxy) が WireGuard 接続を初期化し、Unified Endpoint へ POST している様子が見えます。

Function Logs
15:23:32 setup proxy server for Unified Endpoint
DEBUG: (soraproxy/proxy) 2021/12/12 15:23:32 proxy server for Unified Endpoint started at 0.0.0.0:8080
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 Routine: handshake worker 1 - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 Routine: encryption worker 1 - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 Routine: decryption worker 1 - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 Routine: encryption worker 2 - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 Routine: receive incoming v4 - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 peer(cx/Q…7vVA) - Routine: sequential sender - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 peer(cx/Q…7vVA) - Routine: sequential receiver - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 Routine: decryption worker 2 - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 Routine: handshake worker 2 - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 Routine: TUN reader - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 Routine: event worker - started
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 Interface up requested
EXTENSION	Name: extension.sh	State: Ready	Events: [SHUTDOWN,INVOKE]
+ trap - SIGTERM
+ EVENT_DATA='{"eventType":"INVOKE","deadlineMs":1639322915570,"requestId":"xxxxxxxx-68e3-4be4-a682-e362c35837ff","invokedFunctionArn":"arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:soraproxy","tracing":{"type":"X-Amzn-Trace-Id","value":"Root=1-61b613f3-37678b955845097c7d499e07;Parent=033592cd59b3e60b;Sampled=0"}}'
+ [[ {"eventType":"INVOKE","deadlineMs":1639322915570,"requestId":"xxxxxxxx-68e3-4be4-a682-e362c35837ff","invokedFunctionArn":"arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:soraproxy","tracing":{"type":"X-Amzn-Trace-Id","value":"Root=1-61b613f3-37678b955845097c7d499e07;Parent=033592cd59b3e60b;Sampled=0"}} == *\S\H\U\T\D\O\W\N* ]]
+ echo '[extension.sh] Received event: {"eventType":"INVOKE","deadlineMs":1639322915570,"requestId":"xxxxxxxx-68e3-4be4-a682-e362c35837ff","invokedFunctionArn":"arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:soraproxy","tracing":{"type":"X-Amzn-Trace-Id","value":"Root=1-61b613f3-37678b955845097c7d499e07;Parent=033592cd59b3e60b;Sampled=0"}}'
[extension.sh] Received event: {"eventType":"INVOKE","deadlineMs":1639322915570,"requestId":"xxxxxxxx-68e3-4be4-a682-e362c35837ff","invokedFunctionArn":"arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:soraproxy","tracing":{"type":"X-Amzn-Trace-Id","value":"Root=1-61b613f3-37678b955845097c7d499e07;Parent=033592cd59b3e60b;Sampled=0"}}
+ sleep 1
DEBUG: (soraproxy/proxy) 2021/12/12 15:23:32 route POST / to Unified Endpoint
--- Request dump ---------------------------------
POST / HTTP/1.1
Host: 100.127.69.42:80

{"key1":10.2,"key2":0,"key3":5}
--- End of request dump --------------------------
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 peer(cx/Q…7vVA) - Sending handshake initiation
DEBUG: (soraproxy/proxy/tunnel) 2021/12/12 15:23:32 peer(cx/Q…7vVA) - Received handshake response
--- Response dump --------------------------------
HTTP/1.1 201 Created
Connection: close
Content-Length: 0
Date: Sun, 12 Dec 2021 15:23:32 GMT


--- End of response dump -------------------------
+ true
+ echo '[extension.sh] Waiting for event. Get /next event from http://127.0.0.1:9001/2020-01-01/extension/event/next'
[extension.sh] Waiting for event. Get /next event from http://127.0.0.1:9001/2020-01-01/extension/event/next
+ PID=50
+ forward_sigterm_and_wait
+ trap _term SIGTERM
+ /opt/bin/curl -sS -L -XGET http://127.0.0.1:9001/2020-01-01/extension/event/next --header 'Lambda-Extension-Identifier: xxxxxxxx-6dff-4060-83b5-c3fac699488f'
+ wait 50
END RequestId: xxxxxxxx-68e3-4be4-a682-e362c35837ff
REPORT RequestId: xxxxxxxx-68e3-4be4-a682-e362c35837ff	Duration: 1005.43 ms	Billed Duration: 1006 ms	Memory Size: 512 MB	Max Memory Used: 81 MB	Init Duration: 275.38 ms

試してみたい方は

実験的な機能であることから soracom/soratun にはマージしていません。soratun のメインのターゲットである Raspberry Pi (の 32-bit 版)を gVisor が 正式にサポートしていない ことが大きな理由です。とはいえ soratun はオープンソースとして公開しており、要件に応じたカスタマイズの土台として利用できます。今回のようにすべてユーザー空間で実装する方式にはいろいろな可能性がありそうに思いますのでぜひお試しください。

Lambda extension レイヤーの Zip ファイルアーカイブを作る手順は以下のとおりです。soratun/CONTRIBUTING.md の Prerequisites が必要です。

$ git clone https://github.com/0x6b/soratun
$ cd soratun
$ git switch soraproxy
$ make lambda-extension
...
Created /path/to/aws-lambda-extension/soraproxy.zip

差分全体は GitHub でご覧ください。

ちなみに、最近の gVisor ではコンパイルに失敗してしまうため少し古めのバージョンを使用しています。コミットログから分かるように soraproxy は結構前から試作していたのですが、アドベントカレンダーしめきり前に最新パッケージにしようとして失敗して焦りました。詳細は追いかけられていません。

まとめ

wireguard-go と gVisor という超巨人の肩を借りつつ AWS Lambda extension として実装することで、Lambda 関数から簡単にソラコムのプラットフォームサービスを利用できるようになりました。

2021 年 12 月現在、SORACOM Arc は 1 アカウントあたりバーチャル SIM1 契約分の基本使用料(月額)、1GB 分のデータ通信が無料で利用できますのでぜひお試しください。