macOSでDockerを使ったGoのアプリケーション開発を爆速にするホットリローダを作った

# はじめに

メリークリスマス！！

みなさんは Go のアプリケーション開発をどのような環境で行っていますか？
弊社ではゲームのアプリケーションサーバに Go を採用しており、開発は macOS で Docker for Mac を利用しています。開発当初はこの構成による不満は特に感じていませんでしたが、1年半ほど経ってプロジェクトの規模が大きくなったことで、無視できないレベルで開発スピードを低下させる要因となってしまいました。 
弊社ではアプリケーション開発にソースコードの自動生成を多用しており、その影響もあってかコードベースの Go のコードは 150万行を超える規模になっています。 加えて、ビルドする際は cgo 経由で利用している C++ のコードもそれなりの量絡んでくることもあり、 Docker for Mac を使った Docker コンテナ上でのビルドに要する時間は、 メモリ8GB, 6CPUを割り当てたコンテナにも関わらず 5分を超える時間がかかっていました。 ( それでもまだ良い方で、他の方のマシンスペックでは 10分程かかる場合もあったようです )

実際はビルドキャッシュが効くので毎回 5 ~ 10 分かかるわけではありませんが、パッケージの依存関係によっては数珠つなぎ的に再ビルドが必要になってしまうケースもあるので、一文字編集したら 10 分待つという状況も起こり得ます。

このままではとても開発していられないということで、ビルドを爆速にするツールを開発してみました。
この記事では、開発したツールの紹介と、ちょっとトリッキーな実装をしているので、どうやって実現したかという話にも触れたいと思います。

# ホットリローディング

ウェブアプリケーションなどを開発する際、ファイルの追加・更新・削除といったイベントを契機に自動で再ビルド・実行する仕組みを利用することが多いと思います。これらはホットリローディングやライブリローディングなどと呼ばれたりしますが、もちろん Go にも存在します。
有名どころだと https://github.com/gravityblast/fresh や https://github.com/oxequa/realize が挙げられますが、どちらも現在メンテされてはおりません...。

弊社では、上で挙げた https://github.com/gravityblast/fresh を利用していました。
また、ホットリローディングを Dockerコンテナ 上で動作するアプリケーションで行うため、以下のような設定を行っていました。

### docker-compose.yml
```yaml
version: '2'
services:
  app:
    image: golang:1.13.5
    container_name: app
    volumes:
      - '.:/go/src/app'
    working_dir: /go/src/app
    environment:
      GO111MODULE: "on"
    command: |
      go get -u github.com/pilu/fresh && fresh
```

つまり、 `volumes` でビルドに必要なソースコードが置かれているディレクトリをまるっとコンテナにマウントし、ホットリローダ ( `fresh` ) をコンテナ上にインストールしてファイル監視を始めます。

これによって、ローカル上のファイルを編集した場合でも、その変更がコンテナにも伝わり、
コンテナ上で動作しているホットリローダがそれを検知してアプリケーションを再ビルドし、無事ビルドできたら現在動いているアプリケーションと入れ替えます(リスタート)。

仕組み自体はとてもシンプルなものなので、再実装も難しくはありません。
ただ今回改善したいのは **ビルド時間** なので、コンテナの上でビルドしているうちは改善できません。
そこで次のようなツールを開発しました

# rebirth

<img src="https://user-images.githubusercontent.com/209884/71393497-2ba9aa00-2650-11ea-90ca-5c6b1dd0df7e.png"></img>

[rebirth](https://github.com/goccy/rebirth) という Go のための ホットリローダを開発しました。
既存のホットリローダと大きく異なるのは、 Docker コンテナ上でのビルドを避けるために
**ホスト上でクロスコンパイルしつつDockerコンテナで動くアプリケーションをホットリロードできる** 機能を持っている点です。
これによって、 Docker for Mac に依存せずにホストマシンの力を使い切ってビルドできるようになります。
( ホスト上でビルドするようにした結果 5分かかっていたビルド時間が 30 秒ほどに減り、目に見えて高速化しました )

どのように使うかというと

```
.
├── docker-compose.yml
├── main.go
└── rebirth.yml
```

このような構成のワークスペースがあったとして、
`docker-compose.yml` が以下のような内容だとします。 ( 先に挙げた `docker-compose.yml` 中の `fresh` の部分が `tail -f /dev/null` になっているだけです )

### docker-compose.yml
```yaml
version: '2'
services:
  app:
    image: golang:1.13.5
    container_name: app
    volumes:
      - '.:/go/src/app'
    working_dir: /go/src/app
    environment:
      GO111MODULE: "on"
    command: tail -f /dev/null
```

ここで `docker-compose up -d` とすると、 `app` という名前のコンテナが立ち上がると思います。
ここで、 `rebirth.yml` を記述します。

### rebirth.yml

```yaml
host:
  docker: app
```

`host.docker` にホットリロードしたいアプリケーションのあるコンテナの名前を書きます。

次に、以下を実行して `rebirth` という CLI をインストールします。

```bash
$ GO111MODULE=on go get -u github.com/goccy/rebirth/cmd/rebirth
```

これで準備完了です。 **macOS上で** `rebirth` を実行します

```bash
$ rebirth
# ホットリローダが立ち上がる。
# ファイルを編集すると、 app コンテナ上のアプリケーションがビルド後のものに入れ替わる
```

以上になります。

...ここで

### !?

と思っていただけたら嬉しいのですが

**コンテナ上に何もインストールしていないのに、 macOSにインストールしたバイナリのみでコンテナ上のアプリケーションのホットリロードを実現する** というのがこのツールを作った時のこだわりポイントでした。これによって、Docker を使わない場合と使い方を変えることなく利用することができるようになっています ( `rebirth.yml` の書き方を変えるだけ )

続いて、これをどう実現しているかについて触れていきます

# 実装

流れが少し複雑なので、図を使って説明していきます。
はじめに、下の図を見てください。

<img width="600px" src="https://user-images.githubusercontent.com/209884/71261949-f7996500-2381-11ea-9b18-a8e4dfd49c41.png"></img>

一番外の大きい枠が macOS 上だということを表現しています。
その上にあるグレーの `linux` と書かれている部分は、 
Docker for Mac を使って動作している linux コンテナを表しています。

破線で囲われている中は、 `volumes` でマウントされていることを表しています。
( つまり、 workspace が `~/work/app` という状況で `docker-compose.yml` の `volumes` に `.:/go/src/app` と書かれている状態になります )

#### 1. `rebirth` をインストールする 

まず、 `GO111MODULE=on go get -u github.com/goccy/rebirth/cmd/rebirth` で `rebirth` CLI をインストールします

#### 2. `rebirth` 自身のクロスコンパイル

`rebirth` を実行した際にはじめに行うのは、ターゲットとなる Dockerコンテナのアーキテクチャ向けに自分自身をクロスコンパイルし、 `__rebirth` という名前で `~/work/app` 直下の `.rebirth` ディレクトリ配下に置きます

コンパイル対象のアーキテクチャを知るため、 https://godoc.org/github.com/docker/docker/client を利用して docker remote API 経由で `go env GOOS` と `go env GOARCH` を実行しています。

#### 3. コンテナ上にクロスコンパイルした rebirth バイナリを配置する

`.rebirth` があるディレクトリは `~/work/app` 直下なので、コンテナ上にマウントされています。
このため、自動でコンテナ上の `/go/src/app/.rebirth` 配下にコンテナ上で実行可能な `__rebirth` バイナリが配置されます。

( このあたりは、マウントを利用せずともバイナリを直接コンテナ上にコピーすれば同じことができますが、大抵の場合はマウントを前提としても問題ないと思っているので、処理を簡単にするためにこのようにしています )

#### 4. アプリケーションのファイルを監視し始める

図では `main.go` のファイルイベントを監視し始めることを表しています
( 実装には [fsnotify](https://github.com/fsnotify/fsnotify)  を使っています )

<img width="500px" src="https://user-images.githubusercontent.com/209884/71261979-05e78100-2382-11ea-8955-91e5b01f0234.png"></img>

#### 5. アプリケーションコードをクロスコンパイルする

`rebirth` 自身をクロスコンパイルしたときと同じ要領で、コンテナのアーキテクチャ向けにクロスコンパイルします。
少し違うのは、アプリケーションコードが `cgo` を利用したものであったとしてもコンパイルできるようにしなければらない点です。

このため `GOOS` や `GOARCH` の指定に加え、 `CGO_ENABLED=1` を有効にします。
さらに、 C/C++ コードを macOS 上で linux 向けにコンパイルできるよう、クロスコンパイラを作らなければいけません。 https://github.com/FiloSottile/homebrew-musl-cross にあるように

```bash
$ brew install FiloSottile/musl-cross/musl-cross
```

でインストールをお願いします。 ( ビルドに30分程度かかります )

( 参考 : https://qiita.com/keijidosha/items/5f4a68a3341a44a25ab9 )

また、今までコンテナ上のパスとして設定されていた `GOPATH` が、ホスト上で指定されたものに変わるため、
振る舞いを揃えるために、ワークスペースに作った `.rebirth` ディレクトリを `GOPATH` の起点として扱い、 `go.mod` のモジュール名を見ながら、アプリケーションコードを `.rebirth` 配下に配置し直してビルドを行っています。

例えば、 `go.mod` に `module github.com/company/webapp` と書かれていたとすると、

1. `.rebirth/src/github.com/company` というディレクトリを掘る
2. ワークスペース ( `webapp` ディレクトリ ) への symlink を 1 で作ったディレクトリの配下に作る
3. `.rebirth/src/github.com/company/webapp` へ移動する
4. `GOPATH=/path/to/.rebirth go build ...` のように `GOPATH` を変更してビルドする

というようなことを行います。これによって、依存モジュールなどをアプリケーションワイドにインストールすることが可能なので、
ローカルの GOPATH と混ざったりすることはなくなります。

上記をまとめると、以下のような環境変数やオプションをつけて `go build` を実行しています

```bash
$ GOPATH=.rebirth GOOS=linux GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-linux-musl-cc CXX=x86_64-linux-musl-c++ go build --ldflags '-linkmode external -extldflags "-static"'
```

( ※ 実際には クロスコンパイラへのパスを通すために `PATH` に追加したり、 GOPATH も絶対パスで表現したりしています )

#### 6. コンテナ上にクロスコンパイルしたアプリケーションバイナリを配置する

ビルドした結果は、 `.rebirth` 配下に `program` という名前で配置しています。
`__rebirth` のときと同様に、マウント先のコンテナ上に自動的に配置されます。


<img width="600px" src="https://user-images.githubusercontent.com/209884/71357811-3883ba80-25ca-11ea-9e92-b4cec89e9c95.png"></img>

#### 7. `__rebirth` の実行

コンテナ上で `__rebirth` バイナリを実行します。このとき、動作しているのが Dockerコンテナ上であり、
かつ `rebirth.yml` に `host.docker` の指定がある場合には、ファイル監視を行わない専用のモードで起動します。
起動時に、自身の PID を記録したファイルを `.rebirth` 配下に書き出します。

( 自身がDockerコンテナ上で起動しているかどうかは、 `/.dockerenv` が存在するかで判定することができます )

#### 8. コンテナ上でアプリケーションを起動する

`__rebirth` から `.rebirth/program` を実行します

#### 9. ファイルの変更

`main.go` をホスト上で編集します

#### 10. ファイルの変更を検知

ホスト上の `rebirth` プロセスが `main.go` の更新を検知します

<img width="500px" src="https://user-images.githubusercontent.com/209884/71261992-0b44cb80-2382-11ea-9e1d-a2c44f0262ae.png"></img>

#### 11. アプリケーションの再ビルド

5 で説明したことをもう一度行います

#### 12. アプリケーションの配置

6 で説明したことをもう一度行います

#### 13. アプリケーションの再起動要請

ホスト上の `rebirth` から コンテナ上の `__rebirth` へアプリケーションの再起動要請を行います。
実装には、事前に書き出しておいた `__rebirth` プロセスの PID をもとに、 `SIGHUP` を送ることで実現しています。

本当は `PID` をファイルを経由せずに取得したかったのですが、 docker remote API を経由して知ろうとすると、ホスト上のPID名前空間で表現された値しかとれないため実現できませんでした ( コンテナ上で `ps` したときとは別の PID が返ってくる )

#### 14. アプリケーションの再起動

`SIGHUP` を受け取った `__rebirth` プロセスが、起動中のプロセスを停止して新しく配置された `program` を実行すれば再起動の完了です

# おまけ

## cgo を利用しているコードから、他の C ライブラリを参照している場合

例えば弊社では、 cgo で記述されたコードから、 `zlib` を利用していました。
こういった場合は、別途 `libz.a` をクロスコンパイルする必要があります。

ビルドした `libz.a` や `zlib` のヘッダファイルを参照可能な場所に移して ( たとえば ワークスペース配下 )
`rebirth.yml` に以下のように書けばそれを用いてシンボル解決してくれるようになります

```yaml
host:
  docker: app
build:
  env:
    CGO_LDFLAGS: ./lib/libz.a # lib に置いたクロスコンパイル済みの libz.a を参照する
    CGO_CXXFLAGS: -I./include # include に置いた `zlib.h` などを参照する
```

( 相対パスは、適宜ツール内部で絶対パスに置き換えて参照します )

## ホットリロード以外の機能

ホットリロードをクロスコンパイルで行うようになると、今までコンテナ上で行っていたテストやスクリプトの実行などなどを同じ手段で行いたくなると思います。

そこで `rebirth` では `go build` , `go test`, `go run` をクロスコンパイルしつつ実行してくれるコマンドを用意しています。それぞれ `rebirth build` , `rebirth test` , `rebirth run` をホスト上で実行していただければ、クロスコンパイルしつつ、必要であればその結果をコンテナ上で走らせてくれます。ぜひご活用ください

# おわりに

macOS 上で Docker を利用しているときのビルドを高速化したい！というニーズから実装したツールですが、
普通にホットリローダとして使う場合においても `fresh` より使いやすくなっていると思いますので、ぜひお試しいただければ幸いです。

引き続き改善しながら弊社で使っていこうと思っていますので、
なにか要望やバグを見つけた場合も気軽に報告いただければと思います。

それではよいクリスマスをお過ごしください！！

https://github.com/goccy/rebirth