はじめに
こんばんは、mirukyです。
インフラエンジニアの方でGoが好きな方は結構多い気がします。Shell Scriptはもちろんですが、Goもインフラ関連のツール開発でよく使われる言語ですよね。
今回は、指定したURLへのHTTPリクエストを通じて接続先の状態を確認し、異常があれば終了コードで知らせるツールをGoで作りながら、実践的に解説していきます。
目次
- 作るツールと最初のプログラム
- 値と条件で判定する
- スライスとmapで複数件を扱う
- 関数、構造体、ポインタ
- エラーとdeferで失敗と後処理を書く
- HTTP応答を確認する
- infra-checkを完成させる
1. 作るツールと最初のプログラム
1-1. infra-checkの入力と終了コード
今回作成するツール、命名:infra-checkはURLへ順にHTTP GETを送るツールです。200〜299の応答は正常、それ以外と通信エラーは異常とし、残りのURLも確認するものです。
# 7章でビルドした後に実行する、完成形の使い方です
./bin/infra-check -timeout=1s http://127.0.0.1:18080/fail http://127.0.0.1:18080/ok
# 直前のコマンドの終了コードを表示します
echo $?
補足ですが終了コードとは、プログラムが終了時にシェルへ返す整数です。異常を一度記録すると後の正常では取り消さず、全件を確認してから0か1を返します。
引数なしで./bin/infra-checkを実行すると、要求を送る前に2で終わります。HTTPの異常を示す1とは区別できる仕様です。
言うまでもないかもですがシェルの&&は、左のコマンドが終わるまで待ち、終了コードが0のときだけ、右に書いた既存の./deploy.shを実行します。
1-2. 最初のmainとgo run
Goは、参考リンクにある公式の導入手順でインストールします。
作業ディレクトリにモジュールを作ります。モジュールはコードを管理する単位で、go mod initが定義ファイルのgo.modを生成します。
# 作業ディレクトリを作り、モジュールを初期化します
mkdir infracheck && cd infracheck
go mod init example.com/infracheck
# 最初のプログラムを置くディレクトリです
mkdir -p steps/01-hello
最初のプログラムをsteps/01-hello/main.goに保存します。
package main
import "fmt"
func main() {
// mainから実行が始まり、1行を出力します。
fmt.Println("GoでHTTPを確認します")
}
package mainのfunc main()から実行が始まります。//から行末まではコメントです。
go versionで、インストールしたGoのバージョンを確認します。続いてgo run ./steps/01-helloを実行し、保存したmain.goからプログラムをコンパイルして動かします。
# Goのバージョンを表示し、ソースをコンパイルしてその場で実行します
go version
go run ./steps/01-hello
go version go1.27.3 darwin/arm64
GoでHTTPを確認します
go build -o bin/helloなら実行ファイルが残り、./bin/helloで起動できます。プログラムが返した0・1・2をシェルで受け取るため、完成したコマンドラインツールもビルドしたファイルを直接実行します。
# 実行ファイルをbin/helloへ作り、直接実行します
go build -o bin/hello ./steps/01-hello
./bin/hello
2. 値と条件で判定する
2-1. 変数の宣言と代入
以降の例も、go runに渡すパスのディレクトリへmain.goを置きます。最初はsteps/02-variables/main.goです。
package main
import "fmt"
func main() {
// 宣言で名前を用意し、その後の代入で値を変更します。
var port int = 8080
host := "web-01"
host = "web-02"
port = 8081
const readyStatus = 200
fmt.Println(host, port, readyStatus)
}
# 宣言と代入を終えた後の値を表示します
go run ./steps/02-variables
web-02 8081 200
var port int = 8080は型を書いた宣言で、host := "web-01"は右辺の値から型を決める短い変数宣言です。host = "web-02"は同じhostへの代入です。
同じmainへhost := "web-03"を足すとビルドエラーです。:=の左辺には新しい変数が1つ以上必要ですが、hostは宣言済みです。
const readyStatus = 200で宣言した定数に、後から別の値は代入できません。readyStatus = 503を足すとビルドエラーになり、port = 8081のような変数への代入は通ります。
2-2. 型で決まる値と演算
importを括弧で囲むと、複数のパッケージを取り込めます。
package main
import (
"fmt"
"time"
)
func main() {
// 型に応じて扱える値と演算が決まります。
name := "web-01"
port := 8080
enabled := true
timeout := 1500 * time.Millisecond
fmt.Printf("name=%s port=%d enabled=%t timeout=%s\n", name, port, enabled, timeout)
fmt.Println(1+2, "1"+"2")
}
# 型ごとの表示と、演算の結果を確認します
go run ./steps/03-types
name=web-01 port=8080 enabled=true timeout=1.5s
3 12
nameは文字列のstring、portは整数のint、enabledは真偽値のboolです。fmt.Printfは、書式指定子と引数を順番に対応させて表示します。
1+2は整数の足し算で、結果は3です。"1"+"2"は文字列の連結で、結果の"12"は1と2を並べた2文字です。port + "1"は、整数と文字列の演算なのでビルドエラーになります。
time.Durationはナノ秒の整数で、1500 * time.Millisecondは1,500,000,000です。%sでは1.5sと表示されます。
2-3. ifで2xxを判定する
statusが503のときの判定を出力します。
package main
import "fmt"
func main() {
status := 503
// 両方の比較がtrueのときだけ正常と判定します。
if status >= 200 && status < 300 {
fmt.Println("正常", status)
} else {
fmt.Println("異常", status)
}
}
# 503に対する判定を表示します
go run ./steps/04-if
異常 503
比較式はtrueかfalseのboolを返します。&&は両辺がtrueのときだけtrueなので、この条件は2xxだけを表します。境界の200と299は正常、199と300は異常です。
Goの&&は、左の比較がtrueのときだけ右の比較を評価します。503では503 >= 200がtrueなので、右の503 < 300まで評価してfalseになります。
条件式全体がfalseなので、if側の表示は実行しません。elseにあるfmt.Printlnが異常 503を表示し、mainの末尾へ進みます。
3. スライスとmapで複数件を扱う
3-1. 一覧を順に処理し、キーで集計する
確認先のパスを一覧にして、順に表示します。
package main
import "fmt"
func main() {
urls := []string{"/ok", "/fail"}
urls = append(urls, "/slow")
// rangeは位置と値を順に返し、位置は0から始まります。
for index, address := range urls {
fmt.Println(index, address)
}
fmt.Println("件数", len(urls))
}
# 一覧の位置と値、件数を表示します
go run ./steps/05-range
0 /ok
1 /fail
2 /slow
件数 3
[]string{"/ok", "/fail"}は、順序付きの一覧である文字列のスライスです。appendは要素を末尾へ足したスライスを返すので、urlsへ代入し直します。
rangeは0から始まる位置と値を先頭から返します。コマンドライン引数もos.Argsというスライスで、os.Args[0]はプログラム名です。完成版のmainは残りのos.Args[1:]をrunへ渡します。
ステータスごとの件数を数える
位置が不要なrangeでは、for _, statusのように位置を_で捨てます。
package main
import "fmt"
func main() {
counts := map[int]int{}
// 同じステータスをキーにして件数を増やします。
for _, status := range []int{200, 503, 200} {
counts[status]++
}
// map自体の反復順に頼らず、表示するキーを指定します。
fmt.Println("200の件数", counts[200])
fmt.Println("503の件数", counts[503])
count, exists := counts[404]
fmt.Println("404の値と存在", count, exists)
}
# キーごとの件数と、存在しないキーの結果を表示します
go run ./steps/06-map
200の件数 2
503の件数 1
404の値と存在 0 false
map[int]int{}は、整数をキーと値に使う空のmapです。counts[status]++は、未登録のキーなら0から数え始めます。
count, exists := counts[404]のように二つの値で受け取れば、未登録をexists == falseで判定できます。値の取得ではキーは追加されません。
mapをrangeで反復する順序は仕様で決まっておらず、実行ごとに変わることがあります。この例はキーを指定して表示するので、出力順は毎回同じです。
4. 関数、構造体、ポインタ
4-1. 関数と構造体で結果を返す
2章の条件式を関数にします。mainの外にfuncで定義し、mainから名前で呼び出します。
package main
import "fmt"
func isHealthy(status int) bool {
// 引数を受け取り、判定した値を呼び出し元へ返します。
return status >= 200 && status < 300
}
func main() {
fmt.Println(isHealthy(200))
fmt.Println(isHealthy(503))
}
# 200と503を関数へ渡した結果を表示します
go run ./steps/07-functions
true
false
isHealthyはintを受け取りboolを返します。isHealthy(503)では503がstatusに入り、returnの結果falseが呼び出し元へ戻ります。
1件の結果を構造体にする
URLとステータスを、1つの値にまとめます。
package main
import "fmt"
type Result struct {
URL string
Status int
}
func main() {
// 1件の確認結果を、名前付きのフィールドで保持します。
result := Result{URL: "/ok", Status: 200}
fmt.Println(result.URL, result.Status)
}
# 構造体のフィールドを表示します
go run ./steps/08-struct
/ok 200
type Result structは、名前付きフィールドを持つ型を定義します。Result{URL: "/ok", Status: 200}で値を作り、.でフィールドを取り出します。
Result{}はフィールドがゼロ値で、URLは空文字列、Statusは0です。6章のcheckOneは、通信に失敗するとこれを返します。
4-2. &と*で元の値を変更する
コピーとポインタで、元の変数への影響を比べます。
package main
import "fmt"
func main() {
port := 8080
copyPort := port
copyPort = 8443
fmt.Println("コピーを変更", port, copyPort)
// &で変数の場所を取得し、*でその場所の値を変更します。
pointer := &port
*pointer = 8443
fmt.Println("元の値を変更", port)
}
# コピーを変えた場合と、ポインタ経由で変えた場合を表示します
go run ./steps/09-pointer
コピーを変更 8080 8443
元の値を変更 8443
copyPort := portは8080のコピーなので、copyPortを変えてもportは8080のままです。関数の引数にもコピーが渡ります。
&portでportの場所を取得し、pointer := &portでpointerに保持します。pointerの型は*intで、その場所にある元のportを参照します。
*pointer = 8443は、pointerが指している変数へ代入します。参照先がportなので、同じportの値が8080から8443へ変わります。
7章では、options.Durationが返す*time.Durationから*timeoutで期限を取り出します。*http.Clientと*http.Responseもポインタ型です。
5. エラーとdeferで失敗と後処理を書く
5-1. 失敗を返し、後処理を予約する
文字列をポート番号へ変換し、範囲も確かめます。
package main
import (
"fmt"
"strconv"
)
func parsePort(text string) (int, error) {
port, err := strconv.Atoi(text)
if err != nil {
return 0, err
}
if port < 1 || port > 65535 {
return 0, fmt.Errorf("portは1〜65535です")
}
return port, nil
}
func main() {
// 値を使う前に、もう一つの戻り値で成否を確認します。
for _, text := range []string{"8080", "zero", "0"} {
port, err := parsePort(text)
if err != nil {
fmt.Println("失敗", text, err)
} else {
fmt.Println("成功", port)
}
}
}
# 3つの文字列を変換した結果を表示します
go run ./steps/10-errors
成功 8080
失敗 zero strconv.Atoi: parsing "zero": invalid syntax
失敗 0 portは1〜65535です
parsePortはintと、失敗の理由を持つerrorを返します。成功時のerrorは、値がないことを示すnilです。||はどちらかがtrueならtrueです。
呼び出し側は値を使う前にif err != nilで成否を確かめます。失敗時の0をポート番号に使わないためです。
関数から戻るときに後処理する
deferの実行順を出力します。
package main
import "fmt"
func work() {
// この関数から戻るときに実行する処理を予約します。
defer fmt.Println("後処理")
fmt.Println("本処理")
}
func main() {
work()
fmt.Println("呼び出し元へ戻る")
}
# deferを含む関数の出力順を表示します
go run ./steps/11-defer
本処理
後処理
呼び出し元へ戻る
deferで指定した呼び出しは、その関数から戻る直前に実行されます。workではdeferの行が先でも、後処理の出力は戻る直前になり、その後にmainが続きます。
returnでも関数の末尾に達しても、deferの処理は実行されます。os.Exitはプロセスをその場で終え、deferの処理を飛ばします。そのため完成版は、os.Exitをmainの1か所だけで呼びます。
6. HTTP応答を確認する
6-1. 学習用サーバーを起動する
HTTPの結果を自分で変えられるように、学習用サーバーを用意します。次のコードをfixture/main.goとして保存してください。
fixture/main.goの全文
package main
import (
"flag"
"fmt"
"net/http"
"os"
"time"
)
func ok(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
}
func fail(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintln(w, "maintenance")
}
func slow(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
fmt.Fprintln(w, "slow")
}
func main() {
addr := flag.String("addr", "127.0.0.1:18080", "学習用サーバーの接続先")
flag.Parse()
mux := http.NewServeMux()
mux.HandleFunc("/ok", ok)
mux.HandleFunc("/fail", fail)
mux.HandleFunc("/slow", slow)
// 学習用のHTTP応答をローカルの接続先だけに提供します。
server := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: time.Second}
fmt.Println("学習用サーバー", *addr)
if err := server.ListenAndServe(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
サーバーがHTTP要求を待ち受けている間は、端末Bのプロンプトへ戻りません。同じパソコンで端末Aを開き、サーバーを動かしたまま要求を送ります。
# 端末Bでinfracheckディレクトリへ移動してから、学習用サーバーを起動します。Ctrl+Cで停止します
go run ./fixture
サーバーを起動したまま、端末Aのcurlでステータスコードだけを表示します。
# 端末Aで、ローカル通信をプロキシへ送らずにステータスコードを表示します
curl --noproxy '*' --silent --output /dev/null --write-out '%{http_code}\n' http://127.0.0.1:18080/ok
curl --noproxy '*' --silent --output /dev/null --write-out '%{http_code}\n' http://127.0.0.1:18080/fail
次はGoから同じURLへ要求を送り、応答の異常と通信エラーを判定します。
6-2. 通信エラーとステータスコードを判定する
infra-check/main.goから、1件を確認するcheckOneを抜粋します。全文は7章にあります。
func checkOne(client *http.Client, address string) (Result, error) {
response, err := client.Get(address)
if err != nil {
return Result{}, err
}
// 応答を受け取った場合は、呼び出し元へ戻る前にBodyを閉じます。
defer response.Body.Close()
return Result{URL: address, Status: response.StatusCode}, nil
}
client.Getは(*http.Response, error)を返し、既定ではリダイレクトに従います。503が届いても通信は成功なので、errはnil、response.StatusCodeは503です。runがisHealthyで2xxか判定します。
期限内に応答がない場合や、待ち受けのないポートへ接続した場合は、errがnil以外になります。このときはゼロ値のResult{}とエラーを返します。
response.Bodyは呼び出し側が閉じます。defer response.Body.Close()をcheckOneへ置くと、1件を処理して戻る際にCloseを呼べます。Getとdeferをrunのループ内へ置く実装では、Closeの呼び出しはrunから戻る直前まで遅れます。
7. infra-checkを完成させる
7-1. flagで期限と指定方法の誤りを扱う
完成したinfra-check/main.goの全文です。
package main
import (
"flag"
"fmt"
"net/http"
"os"
"time"
)
type Result struct {
URL string
Status int
}
func isHealthy(status int) bool {
return status >= 200 && status < 300
}
func checkOne(client *http.Client, address string) (Result, error) {
response, err := client.Get(address)
if err != nil {
return Result{}, err
}
// 応答を受け取った場合は、呼び出し元へ戻る前にBodyを閉じます。
defer response.Body.Close()
return Result{URL: address, Status: response.StatusCode}, nil
}
func run(args []string) int {
options := flag.NewFlagSet("infra-check", flag.ContinueOnError)
timeout := options.Duration("timeout", time.Second, "1接続先の期限")
if err := options.Parse(args); err != nil {
if err == flag.ErrHelp {
return 0
}
return 2
}
if options.NArg() == 0 || *timeout <= 0 {
fmt.Fprintln(os.Stderr, "使用方法: infra-check [-timeout=1s] URL...")
return 2
}
client := &http.Client{Timeout: *timeout}
failed := false
for _, address := range options.Args() {
result, err := checkOne(client, address)
if err != nil {
fmt.Fprintln(os.Stderr, "異常", address, err)
failed = true
} else if !isHealthy(result.Status) {
fmt.Fprintln(os.Stderr, "異常", result.URL, result.Status)
failed = true
} else {
fmt.Println("正常", result.URL, result.Status)
}
}
if failed {
return 1
}
return 0
}
func main() {
// runとcheckOneの後処理が済んでから終了コードを返します。
os.Exit(run(os.Args[1:]))
}
flag.NewFlagSetで、このツールのフラグを定義するoptionsを作ります。options.Durationは-timeoutを登録し、既定値1秒の*time.Durationを返します。
options.Parse(args)はフラグでない最初の引数で解析を止め、残りはoptions.Args()、その個数はoptions.NArg()が返します。そのため-timeoutはURLより前に書きます。URL -timeout=3sの順では期限が1秒のままで、-timeout=3sは2件目の確認先として異常になります。
ifは、条件の前に;で区切った文を置けます。ContinueOnErrorなので、未知のフラグや-timeout=oopsではParseがエラーを返し、runは2で戻ります。URLの未指定と0以下の期限も、送信前に2を返します。-hは使い方を表示し、flag.ErrHelpを受けて0で終わります。
# 実行ファイルを作り、指定方法の誤りと-hの終了コードを1つずつ表示します
go build -o bin/infra-check ./infra-check
./bin/infra-check; echo $?
./bin/infra-check -timeout=0 http://127.0.0.1:18080/ok; echo $?
./bin/infra-check -timeout=oops http://127.0.0.1:18080/ok; echo $?
./bin/infra-check -h; echo $?
7-2. runの戻り値をos.Exitへ渡す
else ifは、前のifが成り立たないときだけ評価されます。!はboolを反転し、異常が1件でもあればfailedがtrueになります。
正常の行は標準出力、異常の行は標準エラーへ出力します。
os.Exit(run(os.Args[1:]))では、引数のrunが先に最後まで実行されます。その時点で各Body.Closeは済み、os.Exitが0、1、2のいずれかで終了します。
fixtureを端末Bで起動したまま、端末Aで全件正常の場合と503を含む場合を確認します。
# 全件正常の場合と、503を含む場合の終了コードを表示します
./bin/infra-check http://127.0.0.1:18080/ok; echo $?
./bin/infra-check http://127.0.0.1:18080/ok http://127.0.0.1:18080/fail; echo $?
1回目は正常の1行を出して0、2回目は/failの異常の行が加わって1です。
/slowはサーバー側で2秒待ってから応答します。-timeout=1sでは応答前に期限が切れて通信エラーとなり終了コード1、-timeout=3sではHTTP200を受け取って終了コード0です。
# 同じ/slowを、期限1秒と3秒で確認します
./bin/infra-check -timeout=1s http://127.0.0.1:18080/slow; echo $?
./bin/infra-check -timeout=3s http://127.0.0.1:18080/slow; echo $?
端末BのfixtureをCtrl+Cで止めると待ち受けがなくなり、/okも通信エラーで1になります。再起動すれば0に戻ります。
# 端末Bでfixtureを止めた後と、再起動した後に同じコマンドを実行します
./bin/infra-check http://127.0.0.1:18080/ok; echo $?
7-3. isHealthyの境界値をテストする
テストはinfra-check/main_test.goに置きます。go testは、_test.goで終わるファイルのfunc TestXxx(t *testing.T)形式の関数を実行します。TestIsHealthyを抜粋します。
func TestIsHealthy(t *testing.T) {
// 成功範囲の両端と、その直前・直後を確認します。
cases := []struct {
status int
want bool
}{{199, false}, {200, true}, {299, true}, {300, false}, {503, false}}
for _, test := range cases {
if got := isHealthy(test.status); got != test.want {
t.Errorf("status=%d got=%t want=%t", test.status, got, test.want)
}
}
}
casesは入力のstatusと期待値のwantを組にした、型名のない構造体のスライスです。gotがwantと違うとt.Errorfが失敗を記録し、次の組へ進みます。
上限をstatus <= 300に変えると、300の組でstatus=300 got=true want=falseが記録されます。status < 300に戻せば全組が一致します。
# 詳細表示でテストを実行します。条件を書き換えた後と戻した後で比べます
go test -v ./infra-check
# モジュール全体の静的検査とテストです
go vet ./...
go test ./...
TestRunはhttptest.NewServerでローカルサーバーを起動し、9通りの引数でrunの戻り値を比べます。最後はサーバーを閉じ、通信エラーでも1になるか確認します。
infra-check/main_test.goの全文
package main
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestIsHealthy(t *testing.T) {
// 成功範囲の両端と、その直前・直後を確認します。
cases := []struct {
status int
want bool
}{{199, false}, {200, true}, {299, true}, {300, false}, {503, false}}
for _, test := range cases {
if got := isHealthy(test.status); got != test.want {
t.Errorf("status=%d got=%t want=%t", test.status, got, test.want)
}
}
}
func testEndpoint(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/fail" {
w.WriteHeader(http.StatusServiceUnavailable)
} else if r.URL.Path == "/slow" {
time.Sleep(80 * time.Millisecond)
}
}
func TestRun(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(testEndpoint))
defer server.Close()
cases := []struct {
name string
args []string
want int
}{
{"healthy", []string{server.URL + "/ok"}, 0},
{"status failure", []string{server.URL + "/fail"}, 1},
{"mixed results", []string{server.URL + "/fail", server.URL + "/ok"}, 1},
{"timeout", []string{"-timeout=10ms", server.URL + "/slow"}, 1},
{"no URL", nil, 2},
{"bad flag", []string{"-unknown"}, 2},
{"bad duration", []string{"-timeout=oops", server.URL}, 2},
{"zero timeout", []string{"-timeout=0", server.URL}, 2},
{"help", []string{"-h"}, 0},
}
for _, test := range cases {
if got := run(test.args); got != test.want {
t.Errorf("%s: got=%d want=%d", test.name, got, test.want)
}
}
// 終了した接続先への通信エラーは、HTTP503と同じ終了1へ集約します。
server.Close()
if got := run([]string{server.URL}); got != 1 {
t.Errorf("closed endpoint: got=%d want=1", got)
}
}
おわりに
ここまでお付き合いいただきありがとうございます。
infra-checkは、応答の状態と通信エラーを終了コードへまとめるツールです。接続先のURLや期限を変えて、判定がどう変わるか試してみてください。
ではまた、お会いしましょう。
参考リンク
作るツールと最初のプログラム
Goの導入と言語仕様
標準ライブラリ
- fmt - Go公式
- strconv - Go公式
- net/http - Go公式
- flag - Go公式
- os - Go公式
- time - Go公式
- testing - Go公式
- net/http/httptest - Go公式










































