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

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

99b72ba4c7dd4da957edb3e619a6d71f?s=47 MakKi
June 13, 2021

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

Go Conference Online 2021 Spring

99b72ba4c7dd4da957edb3e619a6d71f?s=128

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