Gopher道場 Advent Calendar 11日目のエントリになります。
昨日は、@matsu0228 さんによる「Go言語による並行処理の理解を深めるために、ライブラリcheggaaa/pbを読んでみた」でした。
本日のエントリでは、cloud-functions-goでNode.jsのCloud FunctionsコンテナでGo言語を動作させる仕組みの裏側と、比較的容易にこのような動作を実現させることを可能にする、Go言語の仕様について触れていきたいと思います。
背景
先日、Cloud Functions(GCF)でGoを動作させたいことがあったのですが、2018/12/11現在GCFはGoはearly accessのステータスで、普通に利用することはできません。
そこで、cloud-functions-goというNode.js環境のGCFでGoを動作させる、非公式ランタイムを利用することにしたのですが、Node.js用に設定されたコンテナでなんでGoが動くの?というのが気になったので調べてみることにしました。
※cloud-functions-goについて知りたい方は、こちらのエントリをご覧ください。
cloud-functions-goとは
READMEにある通り、GCF用の非公式なGoのランタイムです。
構成はテンプレートプロジェクトのようになっており、特定の場所(main.go)にハンドラを記述しておくと、makeでGCFで実行可能なzipアーカイブを作成してくれます。
How it Works?
READMEを読むと、以下のように書かれています。
A native module calls execve(2) without the normal preceding fork(2) call when the user module is imported. Open socket FDs are preserved and handled by the new Go process to ensure a smooth transition. The new Go process then pretends to be Node.
ざっくり言うと、native(javascript)のモジュールがexecveを呼び、自身のプロセスをGoの実行プロセスで置き換える。その際、ソケットのファイルディスクリプタをGoに渡して、スムーズな切り替えを可能にしている、と書かれています。(実際、行っていることはこれだけです。)
上記がどのように実装されているのか、以下で詳細に見ていきます。
function.zipの構成
cloud-functions-goプロジェクトの構成に従ってmain.goを実装しmakeを行うと、GCFにアップローとするための規定の構造を持ったfunction.zipファイルが生成されます。この構成が、Node.js環境でのGoの動作を実現する一つのキモになっています。
function
├── index.js // エントリポイントのjavascript
├── main // Goの実行ファイル
├── node_modules
│ └── execer // javascriptから呼ばれるC++ Addon
│ ├── binding.gyp
│ ├── execer.cc
│ ├── index.js
│ └── package.json
└── package.json
zipファイル内には、3つの言語の世界が存在しています。
- javascript: リクエストを受け付けるスクリプト
- C++: goの実行ファイルを起動し、javascriptプロセスを置き換えるNode.jsのアドオン
- Go: 実際にリクエストを処理するGoの実行ファイル
この3つの世界をうまく繋げていくことで、Node.jsランタイムでGoのサーバプロセスを動かすことを可能にしています。
javascript
GCFから呼び出されるのは、当然javascriptです。しかし、下記のコードに_it didn't work_とある通り、javascriptのハンドラ関数が実行されることはありません。これは、requireで指定される、execerが、初期化処理でjavascriptのプロセスをGoのプロセスに置き換えてしまうからです。
index.js
_ = require("execer");
exports.helloWorld = function helloWorld (req, res) {
res.set('Content-Type', 'text/plain');
res.send("it didn't work :(");
}
C++
index.jsが呼び出されると、C++ Addonのexecerの初期化処理が実行されます。実はここが動作の中核ではあるのですが、今回はC++のエントリではないので、流れだけざっくりと説明させていただきます。
execer.cc
void init(Handle<Object> target) {
// 起動するGoの実行ファイルを指定する
const char bin[] = "./main";
// プロセスが利用しているファイルディスクリプタを取得
DIR *dir = opendir(
...
"/proc/self/fd"
);
std::vector<std::string> fds;
for (struct dirent *ent = readdir(dir); ent != NULL; ent = readdir(dir)) {
int fd = atoi(ent->d_name);
...
// ファイルディスクリプタがsocketかどうかを判定
if (getsockname(fd, reinterpret_cast<sockaddr*>(&addr), &addrlen) == -1) {
continue;
}
...
// ソケットが読み込みか、書き込みかを判定
if (write_response(fd)) {
// Socket was writable, so it isn't listening.
continue;
}
...
}
...
// ファイルディスクリプタを引数として渡して./main(Goの実行ファイル)を起動
// プロセスを置き換える
execv(bin, const_cast<char* const*>(&args[0]));
...
exit(1);
}
// execerがrequireされた時は、initを読み出す
NODE_MODULE(execer, init)
index.jsからexecerモジュールがrequireされるタイミングで、NODE_MODULE(execer, init)が呼び出され、init関数が起動します。
init関数では、動作中のjavascriptプロセスが利用しているファイルディスクリプタのうち、読み取り用のソケットを特定します。そして、execveコマンドで既存のプロセスを置換する形でGoの実行ファイルを起動し、その際に引数として読み込みのネットワークソケットを渡します。これにより、Goのプロセスが起動すると共に、起動したプロセスがリクエストを受けていたソケットを引き継ぐことができるようになっています。
他の言語でももちろんこうした起動は可能なのですが、Goの特徴であるシングルバイナリの実行ファイルが、より環境に依存しない実行を可能にしていると言えると思います。
Go
お待たせしました。ここからがやっとGoの世界になります。javascriptが受けたリクエストを、どう引き継いでいくのか見ていきましょう。
main.go
func main() {
flag.Parse()
http.HandleFunc(nodego.HTTPTrigger, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, I'm native Go!")
})
nodego.TakeOver()
}
実行ファイルのエントリでは、Goの通常のhttpサーブのように、HandleFuncを記述します。
一見変哲の無い内容ですが、最後に_nodego.TakeOver()_ を呼び出している点にご注目ください。
nodego.go
var fds = flag.String("fds", "", "fd1,fd2,...")
func TakeOver() {
...
var wg sync.WaitGroup
for _, arg := range strings.Split(*fds, ",") {
// ファイルディスクリプタからリスナを作成
f := os.NewFile(uintptr(fd), "")
l, err := net.FileListener(f)
...
wg.Add(1)
go func() {
// リスナを使って、http.Serveする
log.Println(http.Serve(l, nil))
...
wg.Done()
}()
}
wg.Wait()
}
TakeOverと言う名前が表す通り、javascriptがリッスンしていた読み込みsocketをGoのプロセスに引き継ぐのがこの関数の役目です。C++から引数で渡されたファイルディスクリプタをもとにして、読み込みソケットをリッスンする形でハンドラを処理するサーバをhttp.Serveで立ち上げています。
このように、javascriptのプロセスを上書きする形で立ち上がったGoの実行プロセスですが、リクエストのファイルディスクリプタを引き継ぐことで、GCFがindex.jsに渡したリクエストをそのままGoで受け継ぐこと実現しています。
こちらも、必ずしも他言語ではできない、という性質のものではないのですが、ファイルディスクリプタを用いてIOを抽象化するというGoの設計思想により、他プロセスが利用していたソケットを容易に引き継いでクエストを処理することができています。
※ファイルディスクリプタってなんの話?という方はGoならわかるシステムプログラミングが参考になると思います。
まとめ
Goは実行環境への依存度が低い、とよく言われる話ですが、cloud-functions-goのように、工夫をすればGo以外のランタイムでも問題なく動作させることが出来るのが実例としてよく分かりました。特にインフラエンジニアの方には、こうした特徴はありがたい側面も大きいのではないでしょうか。
もっと色々なハックが考えられそうなので、どんどん試してみたいところです。
明日は @go_sagawa さんのエントリになります。お楽しみに。