在生產環境中,日誌管理是系統穩定性與可維護性的關鍵基石。一個配置不當的日誌系統可能導致磁碟空間爆滿、系統崩潰,甚至影響業務運作。本文將深入探討 Docker、PM2 以及 Go、Node.js、Python 等常見運行環境的日誌管理最佳實踐,幫助你避免「日誌爆炸」的災難。
為什麼日誌管理如此重要?
在生產環境中,日誌管理不當可能導致以下問題:
- 磁碟空間耗盡: 未限制的日誌檔案會持續增長,最終佔滿磁碟空間
- 系統效能下降: 過度的日誌寫入會影響 I/O 效能
- 除錯困難: 日誌過多或過少都會影響問題排查效率
- 合規風險: 敏感資訊洩漏或日誌保存期限不符合規範
[!IMPORTANT]
生產環境的日誌策略應該在可觀測性與資源消耗之間取得平衡。過多的日誌會浪費資源,過少的日誌則無法有效除錯。
Docker 日誌管理
Docker 日誌驅動概述
Docker 支援多種日誌驅動 (Logging Driver),每種驅動適用於不同的場景:
| 驅動名稱 |
適用場景 |
優點 |
缺點 |
json-file |
本地開發、小型部署 |
預設驅動、簡單易用 |
需手動管理日誌輪替 |
syslog |
集中式日誌管理 |
與系統日誌整合 |
需額外配置 syslog 服務 |
journald |
systemd 系統 |
與 systemd 深度整合 |
僅限 Linux |
fluentd |
大規模日誌聚合 |
強大的日誌處理能力 |
需部署 Fluentd |
awslogs |
AWS 環境 |
無縫整合 CloudWatch |
僅限 AWS |
gcplogs |
GCP 環境 |
無縫整合 Cloud Logging |
僅限 GCP |
配置 Docker Daemon 全域日誌設定
編輯 /etc/docker/daemon.json 設定全域日誌策略:
1 2 3 4 5 6 7 8 9 10
| { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3", "compress": "true", "labels": "production,backend", "env": "APP_ENV,APP_VERSION" } }
|
參數說明:
max-size: 單一日誌檔案最大大小 (10m = 10MB)
max-file: 保留的日誌檔案數量
compress: 是否壓縮舊日誌檔案
labels: 在日誌中包含的容器標籤
env: 在日誌中包含的環境變數
修改後重啟 Docker 服務:
1 2 3 4 5
|
sudo systemctl restart docker
|
[!WARNING]
修改 daemon.json 會影響所有新建立的容器。現有容器需要重新建立才會套用新設定。
Docker Compose 中的日誌配置
在 docker-compose.yml 中為個別服務配置日誌:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| version: '3.8'
services: web: image: nginx:alpine logging: driver: "json-file" options: max-size: "10m" max-file: "3" compress: "true"
api: image: myapp:latest logging: driver: "syslog" options: syslog-address: "tcp://192.168.1.100:514" tag: "api-service" syslog-format: "rfc5424"
backend: image: backend:latest logging: driver: "fluentd" options: fluentd-address: "localhost:24224" tag: "docker.backend" fluentd-async-connect: "true" fluentd-retry-wait: "1s" fluentd-max-retries: "30"
worker: image: worker:latest logging: driver: "awslogs" options: awslogs-region: "ap-northeast-1" awslogs-group: "my-app-logs" awslogs-stream: "worker-service" awslogs-create-group: "true"
|
Docker 日誌查看與管理指令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| docker logs <container_id>
docker logs -f <container_id>
docker logs --tail 100 <container_id>
docker logs --since 2026-01-25T10:00:00 <container_id> docker logs --since 1h <container_id>
docker inspect --format='{{.LogPath}}' <container_id>
truncate -s 0 $(docker inspect --format='{{.LogPath}}' <container_id>)
|
Docker 日誌輪替最佳實踐
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| version: '3.8'
services: app: image: myapp:${VERSION} logging: driver: "json-file" options: max-size: "50m" max-file: "5" compress: "true" labels: "env,service" env: "APP_VERSION" labels: env: "production" service: "api" environment: - APP_VERSION=1.2.3
|
PM2 日誌管理
PM2 日誌配置
PM2 是 Node.js 應用的流行進程管理器,提供強大的日誌管理功能。
基本配置檔案 (ecosystem.config.js)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| module.exports = { apps: [{ name: 'api-server', script: './dist/server.js', instances: 4, exec_mode: 'cluster', error_file: '/var/log/pm2/api-error.log', out_file: '/var/log/pm2/api-out.log', log_file: '/var/log/pm2/api-combined.log', log_date_format: 'YYYY-MM-DD HH:mm:ss Z', merge_logs: true, time: true, env: { NODE_ENV: 'production', LOG_LEVEL: 'info' } }] };
|
PM2 日誌輪替配置
安裝 PM2 日誌輪替模組:
1
| pm2 install pm2-logrotate
|
配置日誌輪替參數:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true
pm2 set pm2-logrotate:rotateInterval '0 0 * * *'
pm2 set pm2-logrotate:dateFormat 'YYYY-MM-DD_HH-mm-ss'
pm2 conf pm2-logrotate
|
PM2 日誌管理指令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| pm2 logs
pm2 logs api-server
pm2 logs --err
pm2 logs --out
pm2 logs --lines 100
pm2 flush
pm2 reloadLogs
|
PM2 進階日誌配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| module.exports = { apps: [{ name: 'api-server', script: './dist/server.js', instances: 'max', exec_mode: 'cluster', error_file: '/var/log/pm2/api-error.log', out_file: '/var/log/pm2/api-out.log', merge_logs: false, log_type: 'json', env_production: { NODE_ENV: 'production', LOG_LEVEL: 'warn', LOG_FORMAT: 'json' }, env_development: { NODE_ENV: 'development', LOG_LEVEL: 'debug', LOG_FORMAT: 'pretty' } }, { name: 'worker', script: './dist/worker.js', instances: 2, out_file: '/dev/null', error_file: '/var/log/pm2/worker-error.log', log_date_format: 'YYYY-MM-DD HH:mm:ss.SSS' }] };
|
Go 應用日誌管理
使用標準函式庫 log/slog
Go 1.21+ 引入的 log/slog 提供結構化日誌功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| package main
import ( "log/slog" "os" )
func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, AddSource: true, })) slog.SetDefault(logger) slog.Info("server started", "port", 8080, "env", "production", ) slog.Error("database connection failed", "error", err, "host", "localhost", "port", 5432, ) }
|
生產環境日誌配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| package logger
import ( "io" "log/slog" "os" "path/filepath" "time" "gopkg.in/natefinch/lumberjack.v2" )
func InitLogger(logDir string, level slog.Level) *slog.Logger { if err := os.MkdirAll(logDir, 0755); err != nil { panic(err) } logFile := &lumberjack.Logger{ Filename: filepath.Join(logDir, "app.log"), MaxSize: 100, MaxBackups: 5, MaxAge: 30, Compress: true, LocalTime: true, } multiWriter := io.MultiWriter(os.Stdout, logFile) handler := slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{ Level: level, AddSource: true, ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { if a.Key == slog.TimeKey { return slog.String("timestamp", a.Value.Time().Format(time.RFC3339)) } return a }, }) return slog.New(handler) }
func main() { logger := InitLogger("/var/log/myapp", slog.LevelInfo) slog.SetDefault(logger) slog.Info("application started", "version", "1.0.0", "env", "production", ) }
|
使用 Zap 高效能日誌庫
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| package main
import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" "gopkg.in/natefinch/lumberjack.v2" )
func InitZapLogger() *zap.Logger { logWriter := &lumberjack.Logger{ Filename: "/var/log/myapp/app.log", MaxSize: 100, MaxBackups: 5, MaxAge: 30, Compress: true, } encoderConfig := zapcore.EncoderConfig{ TimeKey: "timestamp", LevelKey: "level", NameKey: "logger", CallerKey: "caller", MessageKey: "msg", StacktraceKey: "stacktrace", LineEnding: zapcore.DefaultLineEnding, EncodeLevel: zapcore.LowercaseLevelEncoder, EncodeTime: zapcore.ISO8601TimeEncoder, EncodeDuration: zapcore.SecondsDurationEncoder, EncodeCaller: zapcore.ShortCallerEncoder, } core := zapcore.NewCore( zapcore.NewJSONEncoder(encoderConfig), zapcore.AddSync(logWriter), zapcore.InfoLevel, ) logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) return logger }
func main() { logger := InitZapLogger() defer logger.Sync() logger.Info("server started", zap.Int("port", 8080), zap.String("env", "production"), ) logger.Error("failed to connect database", zap.Error(err), zap.String("host", "localhost"), ) }
|
Node.js 應用日誌管理
使用 Winston 日誌庫
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
| const winston = require('winston'); require('winston-daily-rotate-file');
const dailyRotateFileTransport = new winston.transports.DailyRotateFile({ filename: '/var/log/myapp/app-%DATE%.log', datePattern: 'YYYY-MM-DD', maxSize: '100m', maxFiles: '30d', compress: true, format: winston.format.combine( winston.format.timestamp(), winston.format.json() ) });
const errorRotateFileTransport = new winston.transports.DailyRotateFile({ filename: '/var/log/myapp/error-%DATE%.log', datePattern: 'YYYY-MM-DD', maxSize: '100m', maxFiles: '30d', compress: true, level: 'error', format: winston.format.combine( winston.format.timestamp(), winston.format.json() ) });
const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true }), winston.format.splat(), winston.format.json() ), defaultMeta: { service: 'api-server', env: process.env.NODE_ENV }, transports: [ dailyRotateFileTransport, errorRotateFileTransport ] });
if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ) })); }
module.exports = logger;
|
使用範例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| const logger = require('./logger');
logger.info('Server started', { port: 3000 });
logger.error('Database connection failed', { error: err.message, stack: err.stack, host: 'localhost', port: 5432 });
logger.warn('High memory usage detected', { usage: '85%', threshold: '80%' });
logger.debug('Request received', { method: 'POST', path: '/api/users', body: req.body });
|
Python 應用日誌管理
使用標準函式庫 logging
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| import logging import logging.handlers import os from pathlib import Path
def setup_logger( name: str, log_dir: str = '/var/log/myapp', level: int = logging.INFO ) -> logging.Logger: """設定生產環境日誌""" Path(log_dir).mkdir(parents=True, exist_ok=True) logger = logging.getLogger(name) logger.setLevel(level) if logger.handlers: return logger formatter = logging.Formatter( '%(asctime)s - %(name)s - %(levelname)s - ' '%(filename)s:%(lineno)d - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) file_handler = logging.handlers.RotatingFileHandler( filename=os.path.join(log_dir, 'app.log'), maxBytes=100 * 1024 * 1024, backupCount=5, encoding='utf-8' ) file_handler.setLevel(level) file_handler.setFormatter(formatter) error_handler = logging.handlers.RotatingFileHandler( filename=os.path.join(log_dir, 'error.log'), maxBytes=100 * 1024 * 1024, backupCount=5, encoding='utf-8' ) error_handler.setLevel(logging.ERROR) error_handler.setFormatter(formatter) if os.getenv('ENV') != 'production': console_handler = logging.StreamHandler() console_handler.setLevel(logging.DEBUG) console_handler.setFormatter(formatter) logger.addHandler(console_handler) logger.addHandler(file_handler) logger.addHandler(error_handler) return logger
|
使用時間輪替
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import logging import logging.handlers
def setup_daily_logger(name: str, log_dir: str = '/var/log/myapp'): """設定按日期輪替的日誌""" logger = logging.getLogger(name) logger.setLevel(logging.INFO) time_handler = logging.handlers.TimedRotatingFileHandler( filename=os.path.join(log_dir, 'app.log'), when='midnight', interval=1, backupCount=30, encoding='utf-8', utc=False ) time_handler.suffix = '%Y-%m-%d' formatter = logging.Formatter( '%(asctime)s - %(levelname)s - %(message)s' ) time_handler.setFormatter(formatter) logger.addHandler(time_handler) return logger
|
使用 structlog 結構化日誌
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| import structlog import logging import logging.handlers from pathlib import Path
def setup_structlog(log_dir: str = '/var/log/myapp'): """設定結構化日誌""" Path(log_dir).mkdir(parents=True, exist_ok=True) logging.basicConfig( format="%(message)s", handlers=[ logging.handlers.RotatingFileHandler( filename=f"{log_dir}/app.log", maxBytes=100 * 1024 * 1024, backupCount=5 ) ], level=logging.INFO, ) structlog.configure( processors=[ structlog.stdlib.filter_by_level, structlog.stdlib.add_logger_name, structlog.stdlib.add_log_level, structlog.stdlib.PositionalArgumentsFormatter(), structlog.processors.TimeStamper(fmt="iso"), structlog.processors.StackInfoRenderer(), structlog.processors.format_exc_info, structlog.processors.UnicodeDecoder(), structlog.processors.JSONRenderer() ], context_class=dict, logger_factory=structlog.stdlib.LoggerFactory(), cache_logger_on_first_use=True, ) return structlog.get_logger()
logger = setup_structlog()
logger.info("server_started", port=8000, env="production") logger.error("database_error", error=str(e), host="localhost")
|
集中式日誌管理方案
ELK Stack (Elasticsearch + Logstash + Kibana)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| version: '3.8'
services: elasticsearch: image: elasticsearch:8.11.0 environment: - discovery.type=single-node - "ES_JAVA_OPTS=-Xms2g -Xmx2g" - xpack.security.enabled=false volumes: - elasticsearch_data:/usr/share/elasticsearch/data ports: - "9200:9200"
logstash: image: logstash:8.11.0 volumes: - ./logstash/pipeline:/usr/share/logstash/pipeline - ./logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml ports: - "5000:5000" - "5044:5044" depends_on: - elasticsearch
kibana: image: kibana:8.11.0 ports: - "5601:5601" environment: - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 depends_on: - elasticsearch
volumes: elasticsearch_data:
|
Logstash 配置範例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| input { tcp { port => 5000 codec => json } beats { port => 5044 } }
filter { if [message] =~ /^\{.*\}$/ { json { source => "message" } } date { match => [ "timestamp", "ISO8601" ] target => "@timestamp" } mutate { remove_field => [ "host", "agent" ] } }
output { elasticsearch { hosts => ["elasticsearch:9200"] index => "app-logs-%{+YYYY.MM.dd}" } stdout { codec => rubydebug } }
|
Loki + Promtail + Grafana
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| version: '3.8'
services: loki: image: grafana/loki:latest ports: - "3100:3100" volumes: - ./loki/config.yml:/etc/loki/config.yml - loki_data:/loki command: -config.file=/etc/loki/config.yml
promtail: image: grafana/promtail:latest volumes: - /var/log:/var/log - /var/lib/docker/containers:/var/lib/docker/containers:ro - ./promtail/config.yml:/etc/promtail/config.yml command: -config.file=/etc/promtail/config.yml depends_on: - loki
grafana: image: grafana/grafana:latest ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin volumes: - grafana_data:/var/lib/grafana depends_on: - loki
volumes: loki_data: grafana_data:
|
日誌管理最佳實踐總結
1. 日誌等級策略
| 環境 |
建議等級 |
說明 |
| 開發 |
DEBUG |
詳細除錯資訊 |
| 測試 |
INFO |
一般資訊與警告 |
| 預發布 |
WARN |
警告與錯誤 |
| 生產 |
ERROR |
僅記錄錯誤 |
2. 日誌輪替建議
1 2 3 4 5 6 7 8 9
| - 單檔大小: 50-100MB - 保留檔案數: 5-10 個 - 壓縮舊日誌: 是
- 輪替週期: 每天 - 保留天數: 30-90 天 - 壓縮舊日誌: 是
|
3. 敏感資訊處理
[!CAUTION]
絕對不要在日誌中記錄以下資訊:
- 密碼、API 金鑰、Token
- 信用卡號、身分證字號
- 個人隱私資料 (Email、電話等)
1 2 3 4 5
| logger.info('User login', { password: user.password });
logger.info('User login', { userId: user.id, username: user.name });
|
4. 效能考量
- 使用非同步日誌寫入
- 避免在高頻路徑記錄過多日誌
- 使用結構化日誌格式 (JSON)
- 考慮使用日誌取樣 (高流量場景)
5. 監控與告警
1 2 3 4 5 6 7 8 9 10
| - 日誌檔案大小 - 磁碟使用率 - 日誌寫入速率 - 錯誤日誌數量
- 磁碟使用率 > 80% - 錯誤日誌激增 (5 分鐘內 > 100 筆) - 日誌寫入失敗
|
快速檢查清單
在部署生產環境前,請確認以下項目:
結語
日誌管理是生產環境運維的基礎設施,一個良好的日誌策略能夠:
- 預防災難: 避免磁碟空間耗盡導致的系統崩潰
- 快速除錯: 在問題發生時快速定位根因
- 效能優化: 透過日誌分析發現效能瓶頸
- 合規審計: 滿足法規要求的日誌保存與追蹤
記住:日誌不是越多越好,而是要恰到好處。在可觀測性與資源消耗之間找到平衡,才是生產環境日誌管理的精髓。
[!TIP]
定期檢視日誌配置,隨著業務成長調整策略。建議每季度審查一次日誌管理策略,確保其符合當前需求。