オンプレ環境のアプリケーションとログ管理の難
昨今のクラウド環境ではあまり聞かなくなったが、オンプレサーバー環境で運用する際、ログの取り扱いはよく問題になる。
例えばコンテナ前提でアプリケーションを作る場合、そもそも標準出力にしか投げないんじゃないだろうか。実質これで問題にならない。Google CloudのCloudRunやAWSのECS, k8sなどの場合、それで全く問題なく運用できてしまう。細かいことは気にしなくていい。
しかし、オンプレやらVM環境などでは全く事情が変わってくる。どうやってログを管理するか?は 非機能要件としてかなり重要な要素 となってくる。
アプリケーションを長時間稼働させようと思えば、ログ・ファイルなんて1ヶ月もかからず数GB… なんてことも別に珍しい話じゃない。それをそのままにしておくわけにはいかない。そもそもエラーログを捕まえてアラーティングなどをやりたいのであれば、そもそもそれをローカルに置きっぱなしにもできない。
いろんな課題や考えられることはあるのだが、ことこの場においてはログローテーションに着目する。サーバーのストレージは有限だ。ログファイルをそのまま頬っておくわけにはいかない。
多くの場合は、古典的なlogrotateを使うとか、journaldにまかせてしまうとか、そもそも最初からsyslogにぶん投げるとかよくある方法っていうのがいくつかあると思う。実際、私もそういうことを散々やってきた。
これは一定程度、正しいやり方だと思う。そもそもログをどう取り扱うか?なんてアプリケーション側からしたら知ったこっちゃない。責務の分離として、それを外部に頼んだ!まかせた!とするのは妥当な話だと思う。
課題と背景
が、こんな状況が訪れた。
- 小型Linuxマシンの中でコンテナ運用
- アプリケーションは24365で数年動き続ける
- コンテナから出力されるログファイルは一日で数十MBになる可能性があり、ストレージ容量からいって致命的
- どうにかして、そして簡単に、運用保守も楽ちんな感じでログローテーションさせたい
- 物理機器を制御する必要があり、処理のタイミングによってはそもそも簡単にコンテナを止めたりはできない
systemdやjournaldは入ってない。改めて構築して使えるようにすることもできなくはないが、その分あらゆるリソースを食うことになり、それがもったいない。そこまでして入れるメリットが薄い。
古典的な方法として、logrotateをベースに考えつつ、シグナルぶんなげてログファイルのハンドルを掴み直すのを最初にやろうとしたが、上述の通り、そんな気軽に止められない。まぁタイミングを図れば全然やれるのだが、ログファイルがデカくなってきていて切羽詰まってるのにそんな都合よく良いタイミングは訪れない。
さて、どうしたもんか。あまり面倒なこと/複雑なことはやりたくない。そういうことをやればやるほど運用保守コストが上がってしまう。運用保守するためのログなのに、そのために運用保守コストが上がってしまっては本末転倒だ。
対策: アプリケーションネイティブなログローテーション
というわけで、アプリケーション側の仕組みとして、ログローテーションを実装してあげることにした。
logrotateとかそういう別の仕組みに頼らずとも、このアプリケーションの特性からいってログローテーションって絶対必要だよね、標準でやるよ!自分でやるよ!というのは十分に責務の範疇内だと思う。だってそういうアプリケーションなんだもん、外部でやるべきじゃないよねっていう。
元々コンテナによって可搬性を向上させている。”他”に頼らず自分でやるべきことをやるっていう状態になっているのも十分に妥当な話なはず。
最近はよくGolangで書いてる。やっぱりどんなアーキテクチャのマシンに対しても一撃でネイティブアプリを作れるっていうのはすごく魅力的。書きやすいし。ちっこくて速いし。
Go v1.21から標準パッケージとなった構造化ロギングライブラリ slogと 、3rd-partyなロギングライブラリであるlumberjackを組み合わせることで、上述のログローテーションの仕組みを実現させる。
slogとlumberjackの役割
1. slog
Go 1.21で標準パッケージとして追加された log/slog は、構造化されたログ(JSON形式など)を出力するための新しい標準ロギングである。どんな良さがあるか?とかは散々ネット上に転がった話だと思うので割愛する。とりあえず長いこと愛用している。
2. lumberjack
github.com/natefinch/lumberjack.v2 は、Goのio.Writerインターフェースを実装したロギングライブラリになる。これをログの出力先として組み込むだけで超簡単/超強力にログローテーションなどが可能になる。
繰り返しになるのだが、これらによってはlogrotateなどの外部ツールに頼る必要がなくなり、構成としては非常にシンプルになる。アプリケーションが直接ファイルを管理するため、ローテーション時のリネーム処理でファイルディスクリプタの問題(外部ツールがファイルを移動した際にアプリケーションが古いファイルを参照し続ける問題)が発生せず、どんな環境でも同じ機能を発揮し続けられる。ロバスト性が高い。
実装と使い方:設定可能なロガーの作成
lumberjackの設定を構造体で受け取り、ファイルへの書き出しと標準出力への書き出しを切り替えられる、汎用的なロガー作成関数を定義する。
1. ログローテーション設定構造体の定義
まず、lumberjackの設定をまとめる構造体を定義します。
// LogRotateConfig はlumberjackで必要なログローテーション設定を保持する
type LogRotateConfig struct {
Filename string // ログファイル名 (例: app.log)
MaxSize int // ログファイルの最大サイズ(MB)。このサイズを超えるとローテーション
MaxBackups int // 保持するローテーションファイルの最大数
MaxAge int // 保持するローテーションファイルの最大日数
Compress bool // ローテーション後のファイルをgzip圧縮するかどうか
}
2. ロガー作成関数
この設定構造体を受け取り、ファイル出力(ローテーションあり)と標準出力のみを切り替えられる関数を定義する。このように標準出力にも出すしファイルにも書き出すといったことが簡単にできる。もちろん問答無用でファイル出力だけっていう形にしたっていい。
// NewLogger はslog.Loggerを初期化し、必要に応じてlumberjackによるログローテーションを設定
func NewLogger(config *LogRotateConfig) *slog.Logger {
options := &slog.HandlerOptions{
Level: slog.LevelInfo, // ログレベルを環境変数などで設定できるように拡張可能
// AddSource: true, // 開発時など、ログ出力元情報が必要な場合に有効化
}
var output io.Writer
// Configが設定されており、Filenameが指定されている場合のみログローテーションを有効化
if config != nil && config.Filename != "" {
// lumberjackのインスタンスを作成(io.Writerインターフェースを満たす)
localOutput := &lumberjack.Logger{
Filename: config.Filename,
MaxSize: config.MaxSize,
MaxBackups: config.MaxBackups,
MaxAge: config.MaxAge,
Compress: config.Compress,
}
// 標準出力とファイル出力の両方へ書き出す
output = io.MultiWriter(os.Stdout, localOutput)
slog.Info("File logging with rotation enabled", slog.String("file", config.Filename))
} else {
// 設定がない場合は標準出力のみ
output = os.Stdout
slog.Info("Only standard output logging enabled")
}
// JSON形式のハンドラを作成し、ロガーにセット
return slog.New(slog.NewJSONHandler(output, options))
}`
3. main関数での使用例
main関数では、環境変数などから設定を取得し、上記関数を呼び出すだけ。
// main関数内での使用例(抜粋)
func main() {
// ... 環境変数などのロード処理 ...
// 環境変数からローテーション設定を読み込む
logPath := os.Getenv("LOG_PATH")
// 設定値を環境変数から取得するなどして初期化する
rotateConfig := &LogRotateConfig{
Filename: logPath,
MaxSize: 5, // 5MB
MaxBackups: 10, // 10世代
MaxAge: 30, // 30日間
Compress: true, // 圧縮有効
}
// ロガーを作成し、デフォルトロガーとして設定
logger := NewLogger(rotateConfig)
slog.SetDefault(logger)
// ... アプリケーションの処理 ...
logger.Info("Application started successfully!")
// ...
}
slog + lumberjack のメリットまとめ
| メリット | 詳細 |
|---|---|
| 外部ツールの排除 |
logrotateなどのOS依存の外部ツールが不要になり、デプロイや環境構築が簡素化される。すべてGoバイナリ内で完結してしまう。 |
| ログ管理のコード化 | ローテーションポリシー(サイズ、世代、圧縮)がアプリケーションコード内にあり、Git管理やCI/CDの恩恵を受けられやすい。設定変更も容易でありわかりやすい。 |
| ファイルディスクリプタ問題の回避 |
lumberjackが直接ローテーション処理を行うため、外部やアプリケーションの内部状態に影響を受けず、安定的にログローテーションを完了できる。仮にログ出力中にファイルがリネームされても、アプリケーションは問題なく新しいログファイルに書き込みを継続できる。 |
| 構造化ログの実現 | そもそもslogによりJSON形式でログが出力されるため、時刻、レベル、メッセージ、カスタムデータが明確に分離され、ログの検索・分析性が飛躍的に向上できてる。 |
| リソースの効率化 | 圧縮機能により、ローテーション後のログが自動でgzip圧縮され、ストレージ容量を大幅に節約できる。 |
この組み合わせによって、オンプレ環境/VM環境などで懸念されるログ管理の複雑さを大幅に解消できる可能性がある。少なくとも自分が直面していた場面にはバッチリはまった。私はこういうケースでの開発がかなり多いので、実際に個の組み合わせでの開発をよくやってる。