Techブログでよく見かけるHTTPサーバとDBコネクションの実装例です。
サーバ接続時にDBのコネクションを張るので、移行はDB接続を意識しなくていいので、一見とてもよさそうに見えます。
というか実際が楽になっていいのです。
しかし、Goを書き始めた頃に似たような実装を見て、そのまま実装したら障害起きて悲しい気持ちになったこともあります。
package main
import (
"context"
"database/sql"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
)
func main() {
db, err := sql.Open("mysql", fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?parseTime=true",
"(user name)", "(password)", "(host)", "(port)", "(database)")+"&loc=Asia%2FTokyo",
)
if err != nil {
log.Fatal(err)
}
defer db.Close()
router := http.NewServeMux()
router.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
if err := db.PingContext(r.Context()); err != nil {
w.WriteHeader(503)
}
})
srv := &http.Server{
Handler: router,
Addr: fmt.Sprintf(":%s", "8081"),
}
go func() {
panic(srv.ListenAndServe())
}()
// Create channel for shutdown signals.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt)
signal.Notify(stop, syscall.SIGTERM)
//Recieve shutdown signals.
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("error shutting down server %s", err)
} else {
log.Println("Server gracefully stopped")
}
}
正常系
まずは普通の状態でリクエストしてみます。
1回目
$ curl -v localhost:8081/hello
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Thu, 30 Jan 2020 02:36:28 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact
2回目
普通に再度アクセスします。
$ curl -v localhost:8081/hello
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Thu, 30 Jan 2020 02:36:28 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact
異常系
では、DBのコネクションが切れてしまったときはどうなるでしょう。
サーバを上げっぱなししている状態で、DBをあげたり落としたりしてみます。
まずは普通にアクセス
$ curl -v localhost:8081/hello
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Thu, 30 Jan 2020 02:36:28 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact
DBを落とした状態でアクセス
DBにアクセスできないので、503が返ってきました。
これは期待値です
$ curl -v localhost:8081/hello
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 503 Service Unavailable
< Date: Thu, 30 Jan 2020 03:06:08 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact
DBを再度立ち上げてからリクエスト
200が返ってきました。
どうやら動きとしては問題なさそうです。
$ curl -v localhost:8081/hello
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Thu, 30 Jan 2020 02:36:28 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact
やらかし系
次に、どこかのAPIハンドラで異常時にDB接続を切ってしまった場合、もしくは依存パッケージで実はコネクションを切ってしまったパターンを考えます。
仕込み
今回は、Handlerの末尾にわかりやすいようにdb.Close()
を仕込みます
router.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
if err := db.PingContext(r.Context()); err != nil {
w.WriteHeader(503)
}
db.Close() // コネクションを意図的にClose
})
1回目
$ curl -v localhost:8081/hello
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Thu, 30 Jan 2020 02:36:28 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact
2回目
再度、普通にアクセスします。
すると503が返ってきます。
$ curl -v localhost:8081/hello
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 503 Service Unavailable
< Date: Thu, 30 Jan 2020 03:06:08 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact
#何が問題か?
問題点
サーバを立ち上げるときにしかDBのコネクションを作る機会がないので、エラーハンドリングの処理などで不意にコネクションを切ってしまった場合に、DBコネクションのリトライをかける手段がないことです。
Unitテストを書いていたとしても、サーバ上げっぱなしで異常系を挟んで複数回アクセスするなんていうテストを真面目に書いている人はそこまでいないと思うので、気づきづらい挙動です。
気をつければいいという声もありそうですが、実装時に精神的負担が増えてしまいます。
Handler増やすたびに上記のようなテストパターンを作らなきゃいけないのもつらそうです。
解決方法
最も簡単なのは、middlewareなどでDB再接続の処理を共通処理として行ってしまうことだと思います。
- Handlerにわたす前にDBコネクションが張られているか確認してからはられていなかったら再接続してからHandlerに渡す。
- DBコネクションが張られていなかったらそのリクエストはエラーにしてしまうけどその後にDBに再接続して、その後のリクエストは正常に動くようにする。
などが考えられます。