Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

MakKi
June 13, 2021

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

Go Conference Online 2021 Spring

MakKi

June 13, 2021
Tweet

More Decks by MakKi

Other Decks in Programming

Transcript

  1. この発表について 発表の内容 • ホットリロードツールとは ◦ どのような場面で利用するのか ◦ Goのホットリロードツールについて • ホットリロードツールの作り方

    ◦ ファイル変更の監視方法 ◦ プロセスの再起動方法 この発表のゴール • ホットリロードツールを自作できるようになること
  2. 自己紹介 • 牧内大輔 ◦ MakKi ◦ twitter: @makki_d ◦ github:

    makiuchi-d • KLab株式会社 ◦ スマホゲームつくってます • 過去の発表 ◦ JavaプログラムをGoに移植するためのテクニック ――継承と例外 ▪ Go Conference 2019 Spring ▪ Go Conference'19 Summer in Fukuoka
  3. KLabでのGo言語の利用場面 • 常時接続型のゲームサーバ(対戦やMO) ◦ 並行処理を活かして多人数を収容 • インフラ管理ツール ◦ シングルバイナリなので別環境にもっていきやすい •

    開発補助ツール ◦ クロスコンパイルで開発職以外の人にも • Slack bot ◦ 2020年新卒技術研修の紹介 〜Go研修編〜 ▪ https://www.klab.com/jp/blog/tech/2020/2020-bootcamp.html • 他…
  4. コンソールプログラムの動作確認 1. ビルド 2. 実行 • go run が便利 ◦

    ビルド〜実行を1コマンドでできる ◦ ビルドし忘れのケアレスミスを防げる
  5. サーバプログラムの動作確認 1. すでに動いているサーバを停止 2. ビルド 3. サーバを起動 4. クライアントからアクセス •

    ケアレスミスのポイントが多い、かつ、気づきにくい ◦ サーバを再起動せずにクライアントを起動できてしまう ◦ 古いサーバが動いているとクライアントから接続できてしまう
  6. 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が使われていそう
  7. なぜ自作するのか • 要求を全て満たすツールがほしい ◦ 拡張子のないファイルを監視対象にしたい ▪ 監視対象を拡張子でしか指定できない ◦ go runコマンドで実行したい

    ▪ 指定できない、もしくは動作に問題がある ◦ 子プロセスを利用したい ▪ 子プロセスを正しく停止できない ◦ Go以外でも使いたい ▪ GoやGoのウェブアプリフレームワークを前提としている • 作れそうだったから
  8. ホットリロードツール「arelo」 github.com/makiuchi-d/arelo • コマンドラインベース ◦ 設定ファイル無し ▪ 保存したかったらシェルスクリプト • シンプルな機能と実装

    ◦ 現在400行強 • 汎用的 ◦ どのようなコマンドでも利用可能 • 安全なプロセス制御 ◦ 子プロセスも正しく停止
  9. 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) } }
  10. 監視対象の登録 • func (w *Watcher) Add(name string) error ◦ 監視したいファイルまたはディレクトリのパスを指定して登録

    実装のポイント: • ディレクトリを登録する ◦ ディレクトリ直下のファイルのイベントも取得できる ◦ ファイルを指定した場合 ▪ ファイルを削除すると監視対象から外れる ▪ 同名ファイルを作っても監視対象に復帰しない • 再帰的にサブディレクトリも登録する
  11. fsnotifyのイベント • Create ◦ ディレクトリの場合は監視対象として登録する ◦ 他の場所から移動してきた場合も Create • Write

    ◦ ファイルへの書き込み • Remove ◦ 監視対象だった場合、自動的に監視も解除される • Rename ◦ リネーム前のファイル名 ◦ リネーム後のパスが監視対象ディレクトリ直下の場合、別途 Createが届く • Chmod ◦ 属性変更だけでなく、タイムスタンプの変更も Chmod
  12. ファイル名のパターンマッチング ファイル名から再起動するか無視するか判定 • 拡張子によるマッチング ◦ Go標準ライブラリ ▪ path.Ext(), filepath.Ext() •

    globパターンによるマッチング ◦ 拡張子より柔軟に指定できる ▪ *_test.goを無視、など ◦ Go標準ライブラリ ▪ path.Match(), filepath.Match() ▪ 拡張パターンは使えない • {alt1,alt2...} や **
  13. 拡張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
  14. プロセスの起動 • 起動するだけなら簡単 ◦ 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() }
  15. プロセスの停止 • 子プロセスも含めて停止する必要がある ◦ リソースを開放しないといけない ▪ 新しいプロセスが使えない • Go標準ライブラリの問題 ◦

    os.Process.Kill() ▪ 該当プロセスのみ停止、子プロセスは止めない • Unix系OSではSIGKILLを送信 ◦ プロセスは子プロセスを処理できない ▪ SIGKILL, SIGSTOP にはシグナルハンドラを設定できない ◦ exec.CommandContext() ▪ コンテキスト完了時に os.Process.Kill()しているだけ
  16. 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)
  17. どのシグナルを送るか • SIGTERM ◦ 終了させるためのシグナル、 killコマンドのデフォルト • SIGINT ◦ Ctrl+Cで送信されるシグナル

    • その他の選択肢 ◦ SIGHUP ▪ 端末切断時のシグナル ◦ SIGUSR1, SIGUSR2 ▪ ユーザ定義として用意されているシグナル ◦ SIGQUIT, SIGWINCH ▪ ApacheやnginxでGraceful shutdownとして使われている
  18. ホットリロードツール自身のシグナル処理 • 起動したプロセスを停止してから自身も終了 ◦ 停止しないとプロセスが動き続けてしまう • 処理するべきシグナル ◦ 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)
  19. プロセスの終了の待機 • 起動したプロセスは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) }()
  20. プロセスが終了してくれない場合 • シグナルを送っても終了しない場合 • 一定時間待ってからSIGKILLを送る • タイマーとチャネルを同時に待つ ◦ selectの出番 go

    func() { cerr = cmd.Wait() close(done) }() <-trigger killCmd(cmd) select { case <-time.NewTimer(waitForTerm).C: killCmdForce(cmd) <-done case <-done: }
  21. 短期間に届くトリガーを無視 • 短期間にトリガーが何度も届くケース ◦ ディレクトリ移動や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) }
  22. ホットリロードツールの作り方 • ファイル変更の監視 ◦ fsnotifyの使い方 ◦ ファイル名のパターンマッチング • プロセスの再起動 ◦

    execパッケージによるプロセスの起動 ◦ 子プロセスを含めた停止 ▪ Windowの場合とUnix系OSの場合 ◦ プロセス終了の待機 ◦ 短期間に届くトリガーを無視
  23. 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