Help us understand the problem. What is going on with this article?

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/freshhttps://github.com/oxequa/realize が挙げられますが、どちらも現在メンテされてはおりません...。

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

docker-compose.yml

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

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

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

host:
  docker: app

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

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

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

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

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

以上になります。

...ここで

!?

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

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

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

実装

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

例えば、 go.modmodule 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 を実行しています

$ 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 のときと同様に、マウント先のコンテナ上に自動的に配置されます。

7. __rebirth の実行

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

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

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

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

9. ファイルの変更

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

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

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

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.azlib のヘッダファイルを参照可能な場所に移して ( たとえば ワークスペース配下 )
rebirth.yml に以下のように書けばそれを用いてシンボル解決してくれるようになります

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

goccy
[前職] mixi, Inc. [言語] C / C++ / Perl / Ruby / Python / Java / JavaScript / TypeScript / Go / Objective-C(++) / Swift
https://github.com/goccy
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした