Go 서버 모니터링
Go로 서버 프로그램 작성하기
Go에서는 서버 프로그램을 작성하기 위한 유틸리티가 풍부하게 준비되어 있으며, 고루틴이나 채널을 활용하면 고성능이 요구되는 환경에서도 충분한 성능을 발휘할 수 있습니다.
언제였는지 기억나지 않지만 '그것은 HTTP 서버를 작성하기 위한 언어입니다'라는 이야기를 어떤 엔지니어로부터 들었던 적이 있습니다.
예를 들어 'Hello, World!'만 반환하는 HTTP 서버라면 표준 라이브러리인 net/http를 사용하여 아래와 같이 작성할 수 있습니다.
hello_server.go
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "Hello, World!\n")
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)}
ab로 간단한 벤치마크를 취해봅시다.
$ ab -k -n 10000 -c 100 "http://127.0.0.1:8080/" 2>&1 | grep "Requests per second:"
Requests per second: 120169.20 [#/sec] (mean)
$
상당한 성능입니다.
runtime.GOMAXPROCS(runtime.NumCPU())를 사용하면 더욱 향상됩니다.
Go 서버 모니터링
자, Go로 성능이 좋은 HTTP 서버를 작성할 수 있을 것 같다는 것을 알았습니다.
하지만, 프로덕션에서 사용하려면 당연히 그에 상응하는 모니터링 시스템을 마련하고 싶을 것입니다.
갑자기 생각나는 항목만 해도 아래와 같은 것들이 있습니다.
- 메모리 사용 상황
- GC 활동 상황
- 실행 중인 고루틴의 수
- 내부 채널의 사용 상황
- 서버로의 연결 수, requests/sec 등...
이 글에서는 1~4까지의 Go 특유의 항목에 대해 설명하겠습니다.
참고로 5를 모니터링하기 위한 제가 가장 좋아하는 방법은 앞단에 nginx를 두고 거기서 모니터링하는 것입니다.
(단, HTTP 서버에 한정됩니다. 물론, 모니터링을 위해서만 두는 것은 아닙니다)
runtime 패키지를 사용하기
runtime 패키지는 Go의 내부나 실행 환경의 정보를 참조할 수 있는 패키지입니다.
저는 주로 아래의 항목들을 참조합니다.
함수 또는 변수 | 설명 | 비고 |
---|---|---|
runtime.Version() | Go의 버전 | |
runtime.Compiler | Go 컴파일러의 이름 | |
runtime.GOOS | 타겟 OS linux, darwin 등 | |
runtime.GOARCH | 타겟 아키텍처 | 386, amd64 |
runtime.NumCPU() | CPU 코어 수 | |
runtime.NumGoroutine() | 실행 중인 고루틴의 수 | |
runtime.GOMAXPROCS(0) | Go 프로그램에 할당된 CPU 코어 수 | |
runtime.NumCgoCall() | cgo를 통한 함수 호출 횟수 | |
runtime.MemStats | 메모리 및 힙, GC 활동 상황 |
이 표에서 알 수 있듯이 앞서 언급한 1~3의 항목에 대해서는 runtime 패키지를 사용하여 상당히 자세하게 모니터링할 수 있습니다.
앞서 언급한 hello_server.go에 runtime 정보를 가져올 수 있는 핸들러를 추가해봅시다.
hello_server_with_stats.go
package main
import (
"encoding/json"
"fmt"
"net/http"
"runtime"
)
type Stats struct {
GoVersion string `json:"go_version"`
GoOs string `json:"go_os"`
GoArch string `json:"go_arch"`
CPUNum int `json:"cpu_num"`
GoroutineNum int `json:"goroutine_num"`
Gomaxprocs int `json:"gomaxprocs"`
CgoCallNum int64 `json:"cgo_call_num"`
// 메모리와 GC 항목은 많으므로 생략
}
func statsHandler(w http.ResponseWriter, r *http.Request) {
stats := &Stats{
GoVersion: runtime.Version(),
GoOs: runtime.GOOS,
GoArch: runtime.GOARCH,
CPUNum: runtime.NumCPU(),
GoroutineNum: runtime.NumGoroutine(),
Gomaxprocs: runtime.GOMAXPROCS(0),
CgoCallNum: runtime.NumCgoCall(),
}
statsJson, err := json.Marshal(stats)
if err != nil {
msg := "Response-body could not be created"
http.Error(w, msg, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, string(statsJson))
}
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "Hello, World!\n")
}
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/stats", statsHandler)
http.ListenAndServe(":8080", nil)
}
'/stats'에 접속합니다.
$ curl -s "http://127.0.0.1:8080/stats" | jq '.'
{
"go_version": "go1.21.6",
"go_os": "darwin",
"go_arch": "arm64",
"cpu_num": 8,
"goroutine_num": 3,
"gomaxprocs": 8,
"cgo_call_num": 1
}
$
runtime 정보를 얻을 수 있었습니다. 간단하네요.
golang-stats-api-handler를 사용하기
그럼, 위에서 설명한 것처럼 자신이 runtime 정보를 가져오는 핸들러를 작성할 수도 있지만, 매번 서버를 작성할 때마다 핸들러 코드를 복사 붙여넣기하는 것은 번거롭습니다.
사실 이미 이런 용도로 사용할 수 있는 범용 패키지로 golang-stats-api-handler가 있어서 저는 보통 이것을 사용하고 있습니다.
go get -u github.com/fukata/golang-stats-api-handler
아래는 golang-stats-api-handler를 사용한 코드입니다.
hello_server_with_golang_stats_api_handler.go
package main
import (
"fmt"
"net/http"
stats_api "github.com/fukata/golang-stats-api-handler"
)
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "Hello, World!\n")
}
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/stats", stats_api.Handler)
http.ListenAndServe(":8080", nil)
}
자신이 작성하는 것과 비교하면 훨씬 깔끔한 코드가 되었습니다.
그럼 다시 /stats에 접속해 봅시다.
$ curl -s "http://127.0.0.1:8080/stats" | jq '.'
{
"time": 1707900470957515000,
"go_version": "go1.21.6",
"go_os": "darwin",
"go_arch": "arm64",
"cpu_num": 8,
"goroutine_num": 3,
"gomaxprocs": 8,
"cgo_call_num": 1,
"memory_alloc": 164944,
"memory_total_alloc": 164944,
"memory_sys": 7769104,
"memory_lookups": 0,
"memory_mallocs": 414,
"memory_frees": 12,
"memory_stack": 327680,
"heap_alloc": 164944,
"heap_sys": 3866624,
"heap_idle": 3112960,
"heap_inuse": 753664,
"heap_released": 3080192,
"heap_objects": 402,
"gc_next": 4194304,
"gc_last": 0,
"gc_num": 0,
"gc_per_second": 0,
"gc_pause_per_second": 0,
"gc_pause": []
}
$
메모리, 힙, GC의 정보도 볼 수 있게 되었습니다.
정보만 얻을 수 있다면 이후에는 Munin이나 Zabbix 등의 에이전트를 통해 모니터링하는 것은 쉽습니다.
애플리케이션 고유의 정보를 얻기
runtime 패키지를 사용하여 얻을 수 있는 것은 결국 Go의 내부나 실행 환경의 정보뿐입니다.
애플리케이션 고유의 정보에 대해서는 자신이 어떤 것이 필요한지 판단하고 얻을 수 있도록 해야 합니다.
(예: 핸들러별 실행 횟수 등)
그러나, 내부에서 사용하는 채널의 사용 상황은 얻고 싶을 것입니다.
왜냐하면 Go의 채널에는 편리한 인메모리 큐의 측면이 있기 때문이고, Go로 서버 프로그램을 작성할 때는 이것과 고루틴을 결합하여 매우 효율적인 프로그램을 작성할 수 있습니다.
한편으로 채널의 용량은 유한하며, 서버의 처리가 무거워져 큐가 막히게 되면 거기에서 고루틴의 실행이 차단될 수 있습니다.
큐의 처리 자체를 고루틴화하면 일단 차단되는 것은 방지할 수 있지만, 그렇게 하면 이번에는 고루틴의 수가 계속 증가하게 됩니다.
이것은 Go 프로그램의 메모리 팽창이나 성능 저하를 초래합니다.
일단, 큐(채널)의 용량을 미리 크게 설정해 두는 것도 좋지만, 결국 얼마나 사용하고 있는지 알 수 있는 것이 바람직할 것입니다.
// 채널의 용량을 크게 설정해 둡니다.
Queue := make(chan int, 10240)
채널의 용량은 cap, 사용량은 len으로 얻을 수 있습니다.
QueueMax := cap(Queue)
QueueUsage := len(Queue)
그리고 '/stats/app'과 같은 핸들러를 정의하여 이 값을 얻을 수 있도록 하면 특정 '채널의 사용률이 90%를 초과했습니다'와 같은 알림을 보내는 것은 어렵지 않을 것입니다.
고루틴의 누수에 주의
채널의 사용량도 그렇지만, Go로 서버 프로그램을 작성할 때 특히 주의해야 할 것이 고루틴의 누수입니다.
예를 들어 고루틴화된 함수가 무한 루프에 빠지거나, 어떤 잠금 해제 대기 상태에 빠지게 되면 그 고루틴은 계속 남아 있게 됩니다.
이것이 요청을 처리할 때마다 매번 호출되는 곳에서 발생하게 되면 runtime.NumGoroutine()의 수가 계속 증가하게 되어, 메모리 팽창이나 성능 저하를 초래하게 됩니다.
결론
Go로 작성한 서버의 모니터링을 할 때는, Go의 내부나 실행 환경의 정보를 얻기 위해서는
- runtime 패키지 또는 golang-stats-api-handler를 사용하자!
- 애플리케이션 고유의 정보는 자신이 어떤 것이 필요한지 판단하여 얻을 수 있도록 하자!
- 채널의 사용량이나 고루틴의 누수에 주의하자!
라는 이야기였습니다.
'Go' 카테고리의 다른 글
Go 언어 개발 환경 설정 - go mod init과 그 필요성 (0) | 2024.03.03 |
---|---|
Go 언어의 net/http 패키지의 http.HandleFunc이 실행되는 방식 이해하기 (0) | 2024.03.03 |
Go 언어로 웹 애플리케이션 만든 경험담 (2) | 2024.03.01 |
goroutine과 channel로 알아보는 비동기 처리 (0) | 2024.02.20 |
Go 언어의 포인터와 atomic.Value의 이해 (1) | 2024.02.14 |