nodemon を使って Go 製 Gin サーバをホットリロードしようとしたらハマった話

Bash,Go,JavaScript

はじめに

事前にお伝えすると、Go 言語アプリケーションの開発を効率化するためにホットリロードを導入しようとした場合、最適なのは Air と呼ばれるライブリロードツールを利用するのが良いでしょう
Air も Go 製ですので、Go の実行環境が整っていれば利用できます

という前提をおいた上で今回のエントリへ。

ホットリロード(ライブリロード)を導入し、コードが変更されたことを自動検知して実行プロセスを再起動させると開発がスムーズに進められます。

今までいくつかのツールを遷移してきましたが、ここ数年は nodemon がお気に入りです。

アプリケーション開発時だけでなく、シェルスクリプト作成時にも利用でき、かつコマンド+オプション指定で利用できるところが気に入っています。過去にも以下のようなエントリを投稿しています。

前置きが長くなりましたが、Go 言語+Gin Web Framework を使った開発中をしていてホットリロードを導入するために nodemon 経由で go run したところ、うまくリロードされないという事象にあいました。

どのような問題が発生したのかと、どのように解決したかを紹介します。

検証環境

$ uname -moi
arm64 unknown Darwin

$ bash -version | head -n 1
GNU bash, バージョン 5.1.16(1)-release (aarch64-apple-darwin21.1.0)

$ nodemon --version
2.0.19

$ go version
go version go1.18 darwin/arm64

まずは Gin のチュートリアルどおりにかんたんな Web API を作成

このあたりは足早に流していきます。

はじめに Gin のチュートリアルページをもとに、Web API を作成しました。

# ディレクトリ作成 >> ディレクトリ移動
$ mkdir -p genzouw/begin-gin && cd "$_"

# プロジェクト初期化
$ go mod init genzouw/begin-gin
go: creating new go.mod: module genzouw/begin-gin

$ go get -u github.com/gin-gonic/gin
go: downloading golang.org/x/sys v0.0.0-20220730100132-1609e554cd39
go: added github.com/gin-contrib/sse v0.1.0
go: added github.com/gin-gonic/gin v1.8.1
go: added github.com/go-playground/locales v0.14.0
go: added github.com/go-playground/universal-translator v0.18.0
go: added github.com/go-playground/validator/v10 v10.11.0
go: added github.com/goccy/go-json v0.9.10
go: added github.com/json-iterator/go v1.1.12
go: added github.com/leodido/go-urn v1.2.1
go: added github.com/mattn/go-isatty v0.0.14
go: added github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd
go: added github.com/modern-go/reflect2 v1.0.2
go: added github.com/pelletier/go-toml/v2 v2.0.2
go: added github.com/ugorji/go/codec v1.2.7
go: added golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
go: added golang.org/x/net v0.0.0-20220728211354-c7608f3a8462
go: added golang.org/x/sys v0.0.0-20220730100132-1609e554cd39
go: added golang.org/x/text v0.3.7
go: added google.golang.org/protobuf v1.28.1
go: added gopkg.in/yaml.v2 v2.4.0

# エントリポイントとなるGoのコードを作成
$ cat <<'EOF' > main.go
package main

import (
        "net/http"

        "github.com/gin-gonic/gin"
)

func main() {
        r := gin.Default()
        r.GET("/ping", func(c *gin.Context) {
                c.JSON(http.StatusOK, gin.H{
                        "message": "pong",
                })
        })
        r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}
EOF

準備ができたら main.go を実行します。

$ go run main.go
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /ping                     --> main.main.func1 (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080

起動できたようなので、出力されたメッセージに従って、 8080 ポートにリクエストを投げてみます。
別ターミナルを開き、 curl でつついてみます。

$ curl http://localhost:8080/ping
{"message":"pong"}

無事、Gin の Quick Start が終わりました。

一度プロセスを停止するために、 go run main.go を停止します。
実行中のターミナルで CTRL + C を押して停止します。

[GIN-debug] GET    /ping                     --> main.main.func1 (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080

signal: interrupt

停止できました。

開発効率を上げるために nodemon でホットリロードをさせようとするが...

先程実行した Web サーバ起動用のコマンド go run main.gonodemon コマンドで起動します。

  • --watch . : カレントディレクトリ配下の変更をウォッチします
  • --ext go : .go という拡張子を持つファイルの変更をウォッチします
  • --exec "go run main.go" : 変更を検知した際に go run main.go を実行します
$ nodemon --watch . --ext go --exec "go run main.go"

実行ししばらくすると、以下のようなメッセージが出力されます。

[nodemon] 2.0.19
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: go
[nodemon] starting `go run main.go`
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /ping                     --> main.main.func1 (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080

http://localhost:8080/ping にアクセスすると、先程同様の API 実行結果が取得できます。

$ curl http://localhost:8080/ping
{"message":"pong"}

それでは、 main.go を変更してみます。
レスポンス JSON の message フィールドの値を pong から hello に変更してみました。

main.go

package main

import (
        "net/http"

        "github.com/gin-gonic/gin"
)

func main() {
        r := gin.Default()
        r.GET("/ping", func(c *gin.Context) {
                c.JSON(http.StatusOK, gin.H{
                        "message": "hello",
                })
        })
        r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

nodemon 実行中のターミナルを確認してみると、変更を検知してプロセスを再起動しようとしているようですが、「サブプロセスの終了を待っている」というメッセージが表示され続けます。

[nodemon] restarting due to changes...
[nodemon] still waiting for 1 sub-process to finish...
[nodemon] still waiting for 1 sub-process to finish...
[nodemon] still waiting for 1 sub-process to finish...

http://localhost:8080/ping にアクセスし、レスポンスが変わったか確認してみても、やはり変更前の情報が返却されます。

$ curl http://localhost:8080/ping
{"message":"pong"}

対処法 : nodemon で変更を検知した際に SIGINT/SIGTERM シグナルが送られるようにする

最初に go run main.go を実行後に停止させたときに CTRL + C を押したことと
ターミナルに以下のようなメッセージが表示されていたことを思い出しました。

signal: interrupt

CTRL + C でなら問題なくプロセスが停止できるのであれば、 nodemon--exec に指定したコマンドに SIGINT シグナルを送信するようにすれば、解決するのではないでしょうか。

( 過去にシグナルに関係したエントリを書いたことがありました。シグナルってなんだろうという方は こちらのエントリ もどうぞ。 )

nodemon には --signal という隠しオプション(なぜかヘルプを引いても確認できない)があって、これを使って SIGINT シグナルを送信してみます。

$ nodemon --watch . --ext go --exec "go run main.go" --signal SIGINT
[nodemon] 2.0.19
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: go
[nodemon] starting `go run main.go`
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /ping                     --> main.main.func1 (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080
[GIN] 2022/07/31 - 08:36:21 | 200 |      52.666µs |       127.0.0.1 | GET      "/ping"
[nodemon] restarting due to changes...

起動後に現在の JSON レスポンスを確認しておきます。

$ curl http://localhost:8080/ping
{"message":"pong"}

main.go を書き換え、JSON レスポンスの値を変更します。
同時に nodemon を実行しているターミナルを確認してみました。

[GIN] 2022/07/31 - 08:36:21 | 200 |      52.666µs |       127.0.0.1 | GET      "/ping"
[nodemon] restarting due to changes...
signal: interrupt
[nodemon] starting `go run main.go`
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

今度は無事、変更が検知された後 go run main.go が再起動されたようです。

再び JSON レスポンスを確認してみます。

$ curl http://localhost:8080/ping
{"message":"hello"}

変更が反映されています

この後、他のシグナルでも試してみたところ、 SIGTERM シグナルでも問題なく再起動されることがわかりました。

$ nodemon --watch . --ext go --exec "go run main.go" --signal SIGTERM

ひとこと

一応解決はしたのですが、 この解決方法で良いのか疑問が残ります。

nodemon はファイルの変更を検知した際に、実行中プロセスにどんなシグナルを送信しているんでしょうね。

Gthub 上の nodemon のソースコードをチラ見すると、 SIGUSR2 というシグナルを送っているように見えます。

Bash,Go,JavaScript