[Go]ginでzapロガーを使って構造化ログを吐き出す
目次
はじめに
ginでzapロガーを使って構造化ログを吐き出す方法についてまとめます
GitHubのリポジトリはこちら
ブランチ構成
- main: zapロガーをmiddlewareとして自分で実装
- contrib: gin-contribを使って実装
共通部分の実装
Dockerfileはマルチステージビルドを使います
devステージでビルド開発を行い、productionステージでビルドしたバイナリを実行します
devステージでは開発ツールやswaggerのインストールを行い、快適に開発を行いつつ、productionステージで極小のバイナリで実行します
実行後のバイナリサイズは数十MB程度になります
※マルチステージビルドを使わない場合は10倍以上のバイナリサイズです
また環境変数で環境変数違いによる挙動の変更を行います
私たちのような製造業の現場では、同じアプリケーションなんだけど違うオンプレミスサーバーにデプロイしたい!という場合にこのように環境変数で挙動を変えることが多いです
例えばクラウド上のDBのキーに環境変数としてSERVER_ID=xxxxxxxなどのようにエッジデバイスにIDを割り振り、そのIDによって対象設備やアプリケーションのログ出力を管理できます。
いつかk3sで実装しているクラスター構成なども記事にしたいと思います
FROM golang:1.22-alpine as dev
WORKDIR /app
COPY ./ ./
RUN go mod download
RUN go install github.com/go-delve/delve/cmd/dlv@latest
RUN go install golang.org/x/tools/gopls@latest
RUN go install github.com/swaggo/swag/cmd/swag@latest
ARG ENV_KEY
ENV ENV_KEY=${ENV_KEY}
RUN go build -o /httpserver
FROM alpine:latest as production
ARG ENV_KEY
ENV ENV_KEY=${ENV_KEY}
COPY /httpserver ./httpserver
WORKDIR /app
ENV PORT=8080
ENTRYPOINT [ "/httpserver" ]
docker-compose.ymlは主に開発時のビルドを行うための設定を記述します
そのためtarget: devとして、devステージまでビルドを行うようにしています
本番環境はクラウドへデプロイするなりしてください
# docker-compose.yml
services:
httpserver:
image: httpserver:latest
container_name: "httpserver"
ports:
- 8080:8080
build:
context: ./
dockerfile: Dockerfile
# docker-compose file for dev
target: dev
volumes:
- ./:/app
tty: true
restart: always
environment:
- ENV_KEY=ENV_VALUE
ディレクトリ構成の違い
.
├── Dockerfile
├── docker-compose.yml
├── go.mod
├── go.sum
├── handlers
│ ├── sample-handlers.go
│ └── sample-handlers_test.go
├── main.go
└── middleware
└── logger.go
.
├── Dockerfile
├── docker-compose.yml
├── go.mod
├── go.sum
├── handlers
│ ├── sample-handlers.go
│ └── sample-handlers_test.go
└── main.go
ginで素のzapロガーを使う
zapロガー(https://github.com/uber-go/zap)をそのまま使います
requestのbodyやheader、responseのsizeなどをログに出力します
// middleware/logger.go
package middleware
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
"github.com/gin-gonic/gin"
"github.com/rs/xid"
zap "go.uber.org/zap"
)
func Logger(l *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
bodyBytes, _ := io.ReadAll(c.Request.Body)
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
heaserBytes, err := json.Marshal(c.Request.Header)
if err != nil {
fmt.Println("failed to marshal header")
}
headerStr := string(heaserBytes)
// log here
l.Info("Request",
zap.String("uuid", xid.New().String()),
zap.Int("status", c.Writer.Status()),
zap.Int64("content_length", c.Request.ContentLength),
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.String("query", c.Request.URL.RawQuery),
zap.String("request_body", string(bodyBytes)),
zap.String("ip", c.ClientIP()),
zap.String("user_agent", c.Request.UserAgent()),
zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),
zap.Duration("elapsed", time.Since(start)),
zap.String("header", headerStr),
zap.Int("response_size(bytes)", c.Writer.Size()),
)
}
}
main.goではloggerをmiddlewareとして登録します
おまけにswaggerも追加しています
// main.go
package main
import (
"httpserver/docs"
handlers "httpserver/handlers"
"httpserver/middleware"
"os"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"go.uber.org/zap"
)
// @title Swagger Example API
// @version 1.0
// @description This is a httpserver
// @termsOfService http://swagger.io/terms/
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @host your-url.com
// @BasePath /
// @securityDefinitions.basic BasicAuth
// @externalDocs.description OpenAPI
// @externalDocs.url https://swagger.io/resources/open-api/
func main() {
// get env value
ENV_VALUE := os.Getenv("ENV_KEY")
// set zap logger as default logger
logger, err := zap.NewProduction()
if err != nil {
os.Exit(1)
}
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowAllOrigins: true,
AllowWebSockets: true,
MaxAge: 12 * time.Hour,
}))
r.Use(middleware.Logger(logger))
docs.SwaggerInfo.Title = "API Docs"
docs.SwaggerInfo.Description = "This is a http server."
docs.SwaggerInfo.Version = "1.0"
docs.SwaggerInfo.Schemes = []string{"http", "https"}
// swagger
// use ginSwagger middleware to serve the API docs
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// GET /sample
r.GET("/sample", func(c *gin.Context) {
handlers.SampleHandler(c, ENV_VALUE)
})
r.Run(":8080")
}
出力ログ↓
{"level":"info","ts":1715009587.3875072,"caller":"middleware/logger.go:46","msg":"Request","uuid":"cosfgcqef13l8m6uemh0","status":200,"content_length":0,"method":"GET","path":"/sample","query":"key=ss","request_body":"","ip":"172.19.0.1","user_agent":"PostmanRuntime/7.37.3","errors":"","elapsed":0.002162771,"header":"{\"Accept\":[\"*/*\"],\"Accept-Encoding\":[\"gzip, deflate, br\"],\"Connection\":[\"keep-alive\"],\"Content-Type\":[\"application/json\"],\"Postman-Token\":[\"9b32708d-a87b-485f-a12a-7998cf0eca27\"],\"User-Agent\":[\"PostmanRuntime/7.37.3\"]}","response_size(bytes)":64}
gin-contrib/zapを使う
gin-contribのzapを使います
先ほど裸のzapを使った場合と比べて、コードが簡潔になります
// main.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"httpserver/docs"
handlers "httpserver/handlers"
"io"
"os"
"time"
"github.com/gin-contrib/cors"
ginzap "github.com/gin-contrib/zap"
"github.com/gin-gonic/gin"
"github.com/rs/xid"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"go.uber.org/zap"
)
// @title Swagger Example API
// @version 1.0
// @description This is a httpserver
// @termsOfService http://swagger.io/terms/
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @host your-url.com
// @BasePath /
// @securityDefinitions.basic BasicAuth
// @externalDocs.description OpenAPI
// @externalDocs.url https://swagger.io/resources/open-api/
func main() {
// get env value
ENV_VALUE := os.Getenv("ENV_KEY")
// set zap logger as default logger
logger, err := zap.NewProduction()
if err != nil {
os.Exit(1)
}
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowAllOrigins: true,
AllowWebSockets: true,
MaxAge: 12 * time.Hour,
}))
r.Use(ginzap.GinzapWithConfig(logger, &ginzap.Config{
UTC: true,
TimeFormat: time.RFC3339,
Context: ginzap.Fn(func(c *gin.Context) []zap.Field {
start := time.Now()
bodyBytes, _ := io.ReadAll(c.Request.Body)
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// CloudLoggingに送信用のメッセージを定義
heaserBytes, err := json.Marshal(c.Request.Header)
if err != nil {
fmt.Println("failed to marshal header")
}
headerStr := string(heaserBytes)
return []zap.Field{
zap.String("uuid", xid.New().String()),
zap.Int("status", c.Writer.Status()),
zap.Int64("content_length", c.Request.ContentLength),
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.String("query", c.Request.URL.RawQuery),
zap.String("request_body", string(bodyBytes)),
zap.String("ip", c.ClientIP()),
zap.String("user_agent", c.Request.UserAgent()),
zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),
zap.Duration("elapsed", time.Since(start)),
zap.String("header", headerStr),
zap.Int("response_size(bytes)", c.Writer.Size()),
}
}),
}))
docs.SwaggerInfo.Title = "API Docs"
docs.SwaggerInfo.Description = "This is a http server."
docs.SwaggerInfo.Version = "1.0"
docs.SwaggerInfo.Schemes = []string{"http", "https"}
// swagger
// use ginSwagger middleware to serve the API docs
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// GET /sample
r.GET("/sample", func(c *gin.Context) {
handlers.SampleHandler(c, ENV_VALUE)
})
r.Run(":8080")
}
出力ログ↓
{"level":"info","ts":1715009826.8321772,"caller":"zap@v1.1.3/zap.go:117","msg":"","status":200,"method":"GET","path":"/sample","query":"key=ss","ip":"172.19.0.1","user-agent":"PostmanRuntime/7.37.3","latency":0.000193689,"time":"2024-05-06T15:37:06Z","uuid":"cosfi8h6e1vethr1n2rg","status":200,"content_length":0,"method":"GET","path":"/sample","query":"key=ss","request_body":"","ip":"172.19.0.1","user_agent":"PostmanRuntime/7.37.3","errors":"","elapsed":0.000021581,"header":"{\"Accept\":[\"*/*\"],\"Accept-Encoding\":[\"gzip, deflate, br\"],\"Connection\":[\"keep-alive\"],\"Content-Type\":[\"application/json\"],\"Postman-Token\":[\"a34f514a-031e-4384-b353-ae7ba24e2b8a\"],\"User-Agent\":[\"PostmanRuntime/7.37.3\"]}","response_size(bytes)":64}
まとめ
ginでzapロガーを使って構造化ログを吐き出す方法についてまとめました
素のzapを使う場合はresponseWriterの構造体にWriteのヘルパーメソッドを追加して、responseWriterのWriteメソッドをオーバーライドする必要があり、コードが冗長になります
そこでgin-contrib/zapを使い、簡潔にわかりやすく実装することが出来ました
実は当初middlewareとして実装していたのですが、gin-contrib/zapがあることに気づき、
車輪の再発明をしていたことに気づきました。
車輪の再発明、ダメ絶対!
Discussion