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 Conference 2021 Spring 牧内大輔 (MakKi, @makki_d)

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

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

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

    開発補助ツール ◦ クロスコンパイルで開発職以外の人にも • Slack bot ◦ 2020年新卒技術研修の紹介 〜Go研修編〜 ▪ https://www.klab.com/jp/blog/tech/2020/2020-bootcamp.html • 他…
  5. こんな経験ありませんか?

  6. サーバ開発中のこんな経験 • 書き換えたはずのものが反映されない ◦ 必ず通るパスに仕込んだログが出ない ◦ ソース変更前の挙動から変わっていない • 原因は大抵ケアレスミス ◦

    編集したソースの保存し忘れ ◦ ビルドし忘れ ◦ サーバの再起動し忘れ
  7. コンソールプログラムの動作確認 1. ビルド 2. 実行 • go run が便利 ◦

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

    ケアレスミスのポイントが多い、かつ、気づきにくい ◦ サーバを再起動せずにクライアントを起動できてしまう ◦ 古いサーバが動いているとクライアントから接続できてしまう
  9. こんなとき ホットリロードツール が便利

  10. ホットリロードツールとは 基本的な機能 • ソースコードの変更を検知 • プログラムをビルドして再起動 何が解決されるか • ソースコードの保存忘れ防止 ◦

    保存することがビルドと再起動のトリガー • ビルド忘れ、再起動忘れの防止 ◦ 自動的に行われる
  11. 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が使われていそう
  12. なぜ自作するのか • 要求を全て満たすツールがほしい ◦ 拡張子のないファイルを監視対象にしたい ▪ 監視対象を拡張子でしか指定できない ◦ go runコマンドで実行したい

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

    ◦ 現在400行強 • 汎用的 ◦ どのようなコマンドでも利用可能 • 安全なプロセス制御 ◦ 子プロセスも正しく停止
  14. ホットリロードツール の作り方

  15. 必要な機能 本当に必要な機能はたったの2つ • ファイル変更の監視 ◦ 特定ディレクトリ以下の全てのファイル ◦ 再起動するべきファイルの変更か判定 • プロセスの再起動

    ◦ ビルド〜実行まで1コマンドでできるものを指定すればよい ▪ go run ▪ シェルスクリプト
  16. ファイル変更の監視

  17. fsnotify • github.com/fsnotify/fsnotify ◦ ファイル変更の監視の定番ライブラリ ▪ realizeやairも利用 ◦ クロスプラットフォーム ▪

    Linux / Android ▪ BSD / macOS / iOS ▪ Windows
  18. 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) } }
  19. 監視対象の登録 • func (w *Watcher) Add(name string) error ◦ 監視したいファイルまたはディレクトリのパスを指定して登録

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

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

    globパターンによるマッチング ◦ 拡張子より柔軟に指定できる ▪ *_test.goを無視、など ◦ Go標準ライブラリ ▪ path.Match(), filepath.Match() ▪ 拡張パターンは使えない • {alt1,alt2...} や **
  22. 拡張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
  23. ファイル変更監視の処理まとめ 1. fsnotify.Watcherに監視対象のディレクトリを登録 ◦ サブディレクトリも再帰的に登録 2. イベントの処理 ◦ ディレクトリがCreateされたら監視対象に追加 3.

    変更されたファイル名のパターンマッチング 4. トリガー通知チャネルで通知 ◦ プロセス再起動処理のトリガー
  24. プロセスの再起動

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

  26. プロセスの起動 • 起動するだけなら簡単 ◦ 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() }
  27. 余談:CLIの“--”について • “--”があったらそこでオプションの解析を終了する ◦ それ以降はオプションではなく引数 • 元はPOSIXのガイドライン • 多くのライブラリが対応している ◦

    特定言語に限らない ◦ Go標準ライブラリflagも対応 ▪ 他主要なライブラリも対応 • areloでは github.com/spf13/pflag を利用
  28. プロセスの停止 • 子プロセスも含めて停止する必要がある ◦ リソースを開放しないといけない ▪ 新しいプロセスが使えない • Go標準ライブラリの問題 ◦

    os.Process.Kill() ▪ 該当プロセスのみ停止、子プロセスは止めない • Unix系OSではSIGKILLを送信 ◦ プロセスは子プロセスを処理できない ▪ SIGKILL, SIGSTOP にはシグナルハンドラを設定できない ◦ exec.CommandContext() ▪ コンテキスト完了時に os.Process.Kill()しているだけ
  29. Windowsの場合 • TASKKILLコマンド ◦ /pid <processID> ▪ 停止するプロセスIDを指定 ▪ Goのos.Process.Pidをそのまま指定すればよい

    ◦ /t ▪ 子プロセスも含めて停止する ▪ このスイッチを付けるだけでよい
  30. 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)
  31. どのシグナルを送るか • SIGTERM ◦ 終了させるためのシグナル、 killコマンドのデフォルト • SIGINT ◦ Ctrl+Cで送信されるシグナル

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

    func() { cerr = cmd.Wait() close(done) }() <-trigger killCmd(cmd) select { case <-time.NewTimer(waitForTerm).C: killCmdForce(cmd) <-done case <-done: }
  35. 短期間に届くトリガーを無視 • 短期間にトリガーが何度も届くケース ◦ ディレクトリ移動や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) }
  36. まとめ

  37. ホットリロードツールの作り方 • ファイル変更の監視 ◦ fsnotifyの使い方 ◦ ファイル名のパターンマッチング • プロセスの再起動 ◦

    execパッケージによるプロセスの起動 ◦ 子プロセスを含めた停止 ▪ Windowの場合とUnix系OSの場合 ◦ プロセス終了の待機 ◦ 短期間に届くトリガーを無視
  38. おまけ

  39. コンソールプログラムでもareloを使う • ソースを保存すると自動的にビルドと実行 • ターミナルを操作する必要がなくなる ◦ エディタとターミナルを行き来しなくてよい • ターミナルを見える場所に表示しておくだけ ◦

    プログラムの標準出力は areloの標準出力に表示 • 意外と便利でした
  40. 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
  41. areloを複数使ったトリック • ビルド〜実行までを1コマンドで指定する弊害 ◦ ビルド中もサーバが停止してしまう ◦ 同一バイナリを複数実行するとき複数ビルドされてしまう • ビルド担当と実行担当で分ける ◦

    ビルド担当arelo ▪ ソースコードを監視 ▪ ビルドするコマンドを実行 ◦ 実行担当arelo ▪ 実行バイナリを監視 ▪ それを実行
  42. areloの開発にareloを使う • ちゃんと動きました ◦ 正直びっくり • シンプルに作ることが大事

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