Slide 1

Slide 1 text

ホットリロードツールの作り方 Go Conference 2021 Spring 牧内大輔 (MakKi, @makki_d)

Slide 2

Slide 2 text

この発表について 発表の内容 ● ホットリロードツールとは ○ どのような場面で利用するのか ○ Goのホットリロードツールについて ● ホットリロードツールの作り方 ○ ファイル変更の監視方法 ○ プロセスの再起動方法 この発表のゴール ● ホットリロードツールを自作できるようになること

Slide 3

Slide 3 text

自己紹介 ● 牧内大輔 ○ MakKi ○ twitter: @makki_d ○ github: makiuchi-d ● KLab株式会社 ○ スマホゲームつくってます ● 過去の発表 ○ JavaプログラムをGoに移植するためのテクニック ――継承と例外 ■ Go Conference 2019 Spring ■ Go Conference'19 Summer in Fukuoka

Slide 4

Slide 4 text

KLabでのGo言語の利用場面 ● 常時接続型のゲームサーバ(対戦やMO) ○ 並行処理を活かして多人数を収容 ● インフラ管理ツール ○ シングルバイナリなので別環境にもっていきやすい ● 開発補助ツール ○ クロスコンパイルで開発職以外の人にも ● Slack bot ○ 2020年新卒技術研修の紹介 〜Go研修編〜 ■ https://www.klab.com/jp/blog/tech/2020/2020-bootcamp.html ● 他…

Slide 5

Slide 5 text

こんな経験ありませんか?

Slide 6

Slide 6 text

サーバ開発中のこんな経験 ● 書き換えたはずのものが反映されない ○ 必ず通るパスに仕込んだログが出ない ○ ソース変更前の挙動から変わっていない ● 原因は大抵ケアレスミス ○ 編集したソースの保存し忘れ ○ ビルドし忘れ ○ サーバの再起動し忘れ

Slide 7

Slide 7 text

コンソールプログラムの動作確認 1. ビルド 2. 実行 ● go run が便利 ○ ビルド〜実行を1コマンドでできる ○ ビルドし忘れのケアレスミスを防げる

Slide 8

Slide 8 text

サーバプログラムの動作確認 1. すでに動いているサーバを停止 2. ビルド 3. サーバを起動 4. クライアントからアクセス ● ケアレスミスのポイントが多い、かつ、気づきにくい ○ サーバを再起動せずにクライアントを起動できてしまう ○ 古いサーバが動いているとクライアントから接続できてしまう

Slide 9

Slide 9 text

こんなとき ホットリロードツール が便利

Slide 10

Slide 10 text

ホットリロードツールとは 基本的な機能 ● ソースコードの変更を検知 ● プログラムをビルドして再起動 何が解決されるか ● ソースコードの保存忘れ防止 ○ 保存することがビルドと再起動のトリガー ● ビルド忘れ、再起動忘れの防止 ○ 自動的に行われる

Slide 11

Slide 11 text

Goのホットリロードツール ● github.com/codegangsta/gin ● github.com/gravityblast/fresh ● github.com/oxequa/realize ● github.com/go-task/task ● github.com/cosmtrek/air ● github.com/kanataxa/fresher ● 少し前まではrealizeがよく使われていた ○ Go Modulesに対応しないまま開発停止、現在は使いにくい ● 最近はairが使われていそう

Slide 12

Slide 12 text

なぜ自作するのか ● 要求を全て満たすツールがほしい ○ 拡張子のないファイルを監視対象にしたい ■ 監視対象を拡張子でしか指定できない ○ go runコマンドで実行したい ■ 指定できない、もしくは動作に問題がある ○ 子プロセスを利用したい ■ 子プロセスを正しく停止できない ○ Go以外でも使いたい ■ GoやGoのウェブアプリフレームワークを前提としている ● 作れそうだったから

Slide 13

Slide 13 text

ホットリロードツール「arelo」 github.com/makiuchi-d/arelo ● コマンドラインベース ○ 設定ファイル無し ■ 保存したかったらシェルスクリプト ● シンプルな機能と実装 ○ 現在400行強 ● 汎用的 ○ どのようなコマンドでも利用可能 ● 安全なプロセス制御 ○ 子プロセスも正しく停止

Slide 14

Slide 14 text

ホットリロードツール の作り方

Slide 15

Slide 15 text

必要な機能 本当に必要な機能はたったの2つ ● ファイル変更の監視 ○ 特定ディレクトリ以下の全てのファイル ○ 再起動するべきファイルの変更か判定 ● プロセスの再起動 ○ ビルド〜実行まで1コマンドでできるものを指定すればよい ■ go run ■ シェルスクリプト

Slide 16

Slide 16 text

ファイル変更の監視

Slide 17

Slide 17 text

fsnotify ● github.com/fsnotify/fsnotify ○ ファイル変更の監視の定番ライブラリ ■ realizeやairも利用 ○ クロスプラットフォーム ■ Linux / Android ■ BSD / macOS / iOS ■ Windows

Slide 18

Slide 18 text

fsnotifyの基本的な使い方 1. Watcherを初期化 2. 監視対象を登録 3. Eventsチャネルに流れてくる ○ イベント種別 (event.Op) ○ ファイル名 (event.Name) package main import ( "fmt" "github.com/fsnotify/fsnotify" ) func main() { w, _ := fsnotify.NewWatcher() // (1) defer w.Close() w.Add("./") // (2) for { event := <-w.Events // (3) fmt.Println(event) } }

Slide 19

Slide 19 text

監視対象の登録 ● func (w *Watcher) Add(name string) error ○ 監視したいファイルまたはディレクトリのパスを指定して登録 実装のポイント: ● ディレクトリを登録する ○ ディレクトリ直下のファイルのイベントも取得できる ○ ファイルを指定した場合 ■ ファイルを削除すると監視対象から外れる ■ 同名ファイルを作っても監視対象に復帰しない ● 再帰的にサブディレクトリも登録する

Slide 20

Slide 20 text

fsnotifyのイベント ● Create ○ ディレクトリの場合は監視対象として登録する ○ 他の場所から移動してきた場合も Create ● Write ○ ファイルへの書き込み ● Remove ○ 監視対象だった場合、自動的に監視も解除される ● Rename ○ リネーム前のファイル名 ○ リネーム後のパスが監視対象ディレクトリ直下の場合、別途 Createが届く ● Chmod ○ 属性変更だけでなく、タイムスタンプの変更も Chmod

Slide 21

Slide 21 text

ファイル名のパターンマッチング ファイル名から再起動するか無視するか判定 ● 拡張子によるマッチング ○ Go標準ライブラリ ■ path.Ext(), filepath.Ext() ● globパターンによるマッチング ○ 拡張子より柔軟に指定できる ■ *_test.goを無視、など ○ Go標準ライブラリ ■ path.Match(), filepath.Match() ■ 拡張パターンは使えない ● {alt1,alt2...} や **

Slide 22

Slide 22 text

拡張globが使えるライブラリ github.com/bmatcuk/doublestar ● BashやZshの拡張globと同等のパターンが使える ○ {alt1,alt2,...} ■ 「,」で区切られたうちのいずれかとマッチ ○ ** ■ ディレクトリと再帰的にマッチ ● 使い方がGo標準ライブラリと同じ ● 余談:BashとZshの "**" (globstar) の挙動の違い ○ http://makiuchi-d.github.io/2020/04/11/bash-zsh-globstar.ja.html

Slide 23

Slide 23 text

ファイル変更監視の処理まとめ 1. fsnotify.Watcherに監視対象のディレクトリを登録 ○ サブディレクトリも再帰的に登録 2. イベントの処理 ○ ディレクトリがCreateされたら監視対象に追加 3. 変更されたファイル名のパターンマッチング 4. トリガー通知チャネルで通知 ○ プロセス再起動処理のトリガー

Slide 24

Slide 24 text

プロセスの再起動

Slide 25

Slide 25 text

プロセスの再起動の処理 1. プロセスを起動 2. トリガー通知チャネルを待つ 3. 動いているプロセスの停止 4. 1に戻る

Slide 26

Slide 26 text

プロセスの起動 ● 起動するだけなら簡単 ○ Go標準ライブラリ ■ exec.Command() ■ exec.Cmd.Start() ■ exec.Cmd.Wait() 実装のポイント: ● コマンド名・引数リスト ○ realize, air:設定ファイル ○ arelo:コマンドライン引数 ■ arelo -p '**/*.go' -i '**/.*' -- go run -v main.go package main import ( "os" "os/exec" ) func main() { cmd := exec.Command("echo", "-n", "hello") cmd.Stdout = os.Stdout _ = cmd.Start() _ = cmd.Wait() }

Slide 27

Slide 27 text

余談:CLIの“--”について ● “--”があったらそこでオプションの解析を終了する ○ それ以降はオプションではなく引数 ● 元はPOSIXのガイドライン ● 多くのライブラリが対応している ○ 特定言語に限らない ○ Go標準ライブラリflagも対応 ■ 他主要なライブラリも対応 ● areloでは github.com/spf13/pflag を利用

Slide 28

Slide 28 text

プロセスの停止 ● 子プロセスも含めて停止する必要がある ○ リソースを開放しないといけない ■ 新しいプロセスが使えない ● Go標準ライブラリの問題 ○ os.Process.Kill() ■ 該当プロセスのみ停止、子プロセスは止めない ● Unix系OSではSIGKILLを送信 ○ プロセスは子プロセスを処理できない ■ SIGKILL, SIGSTOP にはシグナルハンドラを設定できない ○ exec.CommandContext() ■ コンテキスト完了時に os.Process.Kill()しているだけ

Slide 29

Slide 29 text

Windowsの場合 ● TASKKILLコマンド ○ /pid ■ 停止するプロセスIDを指定 ■ Goのos.Process.Pidをそのまま指定すればよい ○ /t ■ 子プロセスも含めて停止する ■ このスイッチを付けるだけでよい

Slide 30

Slide 30 text

Unix系OSの場合 1. プロセス起動時にPGIDを設定する ○ PGID: プロセスグループID ○ 子プロセスのPGIDの初期値は親プロセスと一緒 2. PGIDを指定してシグナルを送信 ○ killシステムコールに負の値を渡す 3. SIGKILL以外のシグナルを送る ○ 子プロセスのPGIDを変更している場合対策 ■ 親プロセスが子を処理することを期待 ● 詳しくは:Goで子プロセスを確実にKillする方法 ○ http://makiuchi-d.github.io/2020/05/10/go-kill-child-process.ja.html cmd := exec.Command(...) cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, // (1) } _ = cmd.Start() syscall.Kill( -cmd.Process.Pid, // (2) syscall.SIGTERM) // (3)

Slide 31

Slide 31 text

どのシグナルを送るか ● SIGTERM ○ 終了させるためのシグナル、 killコマンドのデフォルト ● SIGINT ○ Ctrl+Cで送信されるシグナル ● その他の選択肢 ○ SIGHUP ■ 端末切断時のシグナル ○ SIGUSR1, SIGUSR2 ■ ユーザ定義として用意されているシグナル ○ SIGQUIT, SIGWINCH ■ ApacheやnginxでGraceful shutdownとして使われている

Slide 32

Slide 32 text

ホットリロードツール自身のシグナル処理 ● 起動したプロセスを停止してから自身も終了 ○ 停止しないとプロセスが動き続けてしまう ● 処理するべきシグナル ○ SIGTERM ■ killコマンド ○ SIGINT ■ ターミナルでのCtrl+C ○ SIGHUP ■ 端末の切断 ● signal.Notify()、signal.NotifyContext() ○ 指定したシグナルをチャネルで受け取れる s := make(chan os.Signal, 1) signal.Notify(s, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP) <-s killCmd(cmd)

Slide 33

Slide 33 text

プロセスの終了の待機 ● 起動したプロセスはwaitする必要がある ○ waitされないとリソースを開放できない ○ ゾンビプロセス ■ 終了したのにwaitされていないプロセス ○ exec.Cmd.Wait() 実装のポイント: ● 起動したらすぐgoroutineでCmd.Wait() ○ 他の処理をブロックしない ● チャネルで終了を通知 ○ context.Contextやtime.Timerとの共存 _ = cmd.Start() var cerr error done := make(chan struct{}) go func() { cerr = cmd.Wait() close(done) }()

Slide 34

Slide 34 text

プロセスが終了してくれない場合 ● シグナルを送っても終了しない場合 ● 一定時間待ってからSIGKILLを送る ● タイマーとチャネルを同時に待つ ○ selectの出番 go func() { cerr = cmd.Wait() close(done) }() <-trigger killCmd(cmd) select { case <-time.NewTimer(waitForTerm).C: killCmdForce(cmd) <-done case <-done: }

Slide 35

Slide 35 text

短期間に届くトリガーを無視 ● 短期間にトリガーが何度も届くケース ○ ディレクトリ移動やgitの操作 ○ 頻繁なファイル保存 ● 何度も再起動したくない ○ 一定時間待ってから再起動 実装のポイント: ● 容量1のチャネルを使ったトリック 1. goroutineでチャネル移し替え ■ selectのdefaultを活用 2. チャネルを待つ前に取り出す ■ ここでもselectのdefault trg := make(chan struct{}, 1) go func() { for { <-trigger select {   // (1) case trg <- struct{}{}: default: } } }() for { cmd := runCmd() select { // (2) case <-trg: default: } <-trg <-time.NewTimer(delay).C killCmd(cmd) }

Slide 36

Slide 36 text

まとめ

Slide 37

Slide 37 text

ホットリロードツールの作り方 ● ファイル変更の監視 ○ fsnotifyの使い方 ○ ファイル名のパターンマッチング ● プロセスの再起動 ○ execパッケージによるプロセスの起動 ○ 子プロセスを含めた停止 ■ Windowの場合とUnix系OSの場合 ○ プロセス終了の待機 ○ 短期間に届くトリガーを無視

Slide 38

Slide 38 text

おまけ

Slide 39

Slide 39 text

コンソールプログラムでもareloを使う ● ソースを保存すると自動的にビルドと実行 ● ターミナルを操作する必要がなくなる ○ エディタとターミナルを行き来しなくてよい ● ターミナルを見える場所に表示しておくだけ ○ プログラムの標準出力は areloの標準出力に表示 ● 意外と便利でした

Slide 40

Slide 40 text

Dockerでareloを使う ● バイナリを置くだけで使える ○ スタティックリンクなシングルバイナリ ■ CGO_ENABLED=0でビルド ● pflagがnetをimport ○ どんなコンテナでもOK ● 監視対象ディレクトリ ○ 起動時にバインドマウントしておく ○ 手元のファイルを書き換えるとリロード FROM hello-world COPY arelo / CMD ["/arelo", "-t", "/trg", \ "-p", "**", "--", "/hello"] $ docker build -t arelo-hello . $ docker run -v `pwd`:/trg arelo-hello

Slide 41

Slide 41 text

areloを複数使ったトリック ● ビルド〜実行までを1コマンドで指定する弊害 ○ ビルド中もサーバが停止してしまう ○ 同一バイナリを複数実行するとき複数ビルドされてしまう ● ビルド担当と実行担当で分ける ○ ビルド担当arelo ■ ソースコードを監視 ■ ビルドするコマンドを実行 ○ 実行担当arelo ■ 実行バイナリを監視 ■ それを実行

Slide 42

Slide 42 text

areloの開発にareloを使う ● ちゃんと動きました ○ 正直びっくり ● シンプルに作ることが大事

Slide 43

Slide 43 text

おわり ご清聴ありがとうございました areloをぜひ使ってみてください https://github.com/makiuchi-d/arelo