Web Day 42 專案:監控、日誌與備份
執行需求:CPU 可跑。昨天的 Docker Compose 把服務跑起來了,今天要把「這個服務還活著嗎」、「有沒有人出錯」、「資料庫能不能還原」三件事變成可自動化的常駐腳本。今天要做四件事:第一,用 Python 標準庫的 logging 寫結構化 JSON log(每行一個 JSON 物件,欄位統一,方便 log collector 解析);第二,用 FastAPI 寫 /metrics 端點,回傳 Prometheus exposition format 文字(含 API 呼叫次數、延遲直方圖、錯誤次數);第三,寫一支 PostgreSQL 備份腳本(pg_dump + 日期檔名 + 保留 14 天 + 上傳到本地 backup 目錄),並用 cron 排程每天 03:00 跑;第四,用 pytest 寫兩支測試驗證 log 格式與 metrics 端點。整個 stack 加進來後仍然在 CPU 上跑得動,所有資料都是虛構示範。這也是 Day 43 壓力測試前的基本功。
引言
「系統跑起來」與「系統能維運」是兩件事。前者只看能不能回應 API、能不能寫進資料庫;後者要求出問題時三分鐘內定位、十分鐘內復原、資料遺失量小於一小時。差異全在監控、日誌、備份三件事:監控告訴你「現在有沒有事」、日誌告訴你「事發當時發生什麼」、備份告訴你「資料還救得回來」。三者缺一不可:少了監控你會半夜被叫醒、少了日誌你會兩小時抓不到 root cause、少了備份你會在某次硬碟損壞後失去所有訂單。
貫穿專案的預約管理系統是小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、諮詢工作室,所有資料都是虛構示範)。我們的監控需求非常具體:API 200/4xx/5xx 計數、P50/P95 延遲、活躍預約數、通知佇列長度。日誌需求:每行 JSON、有 request_id、能 trace 一個 request 從進入到結束。備份需求:每日 03:00 跑 pg_dump、保留 14 天、檔名含日期。這些工業界的標配,今天會在小型專案規模上完整實現一遍。
今天的內容分四段:第一段說明結構化 log 的設計;第三段寫 Prometheus metrics 端點;第三段寫 PostgreSQL 排程備份與復原演練腳本;第四段用 pytest 驗證 log 與 metrics。讀完這篇你會了解:Python 的 logging 與 JsonFormatter、FastAPI middleware 怎麼把所有 request log 都加 request_id、prometheus_client 0.21 的 Counter 與 Histogram、pg_dump 與 pg_restore 的標準流程、以及如何用 pytest capsys 驗 log 內容。
原理解念:結構化 log、Prometheus metrics、備份原則
結構化 log 的意思是「每一行 log 都是一個 JSON 物件,欄位名稱固定」。對比傳統的 2025-08-19 03:00:00 INFO Booking 123 created,JSON 版本是 {"ts": "2025-08-19T03:00:00Z", "level": "INFO", "msg": "booking.created", "booking_id": 123}。後者可以被 Datadog、Loki、Elastic、CloudWatch 直接解析做查詢:level=ERROR msg=booking.created、request_id=abc123 等等。前者只能靠 grep + awk 拼湊。對小型專案來說,JSON 多寫 1–2 行程式碼,但換來的可觀察性是兩個量級的提升。順帶一提,「log 是事件流」這個觀念在 2017 年的 12-factor 文書就被提出,至今仍是業界共識;我們今天的 formatter 只是把這個觀念落實到 Python 程式裡。
Prometheus 與 metrics 的關鍵概念是「拉式收集」。Prometheus server 定期(每 15 秒)對 /metrics 端點發 HTTP GET,回傳整個 metrics 快照。metrics 是文字格式,每行一個樣本,例如 booking_requests_total{status="200",path="/bookings/"} 42。我們的 prometheus_client 0.21 套件提供 Counter、Gauge、Histogram、Summary 四種型別,分別對應累計計數、即時值、分布統計(分位數)、預先計算的分布。延遲通常用 Histogram(需要分位數時再用 Summary)。Counter 與 Histogram 之外,Gauge 也常用,像是「目前活躍預約數」、「DB 連線池在用數」,這些都會隨時間上升或下降,不適合累計。實務上我們會把 booking_active_count 這類 Gauge 寫成後台 cron,每 30 秒更新一次;它不是 API 即時值,但能給監控提供「總體水位」的圖表,方便看出「當下業務是忙還是閒」。
備份的三大原則是「3-2-1」:三份副本(一份原始、兩份備份)、兩種媒介(本地 + 雲端)、一份異地(off-site,地震也不怕)。對小型工作室的預約系統來說,本地一份 pg_dump 加上雲端一份(AWS S3、Backblaze B2、Wasabi 都可)就足以符合 3-2-1。備份的關鍵不是「備」而是「測試還原」:有 30% 的備份從來沒被驗證過能不能還原,等出事的時候才發現。我們今天會寫一支還原測試腳本,把昨天的 dump 倒回一個臨時 DB,確認資料完整後再砍掉。這條原則在資料量變大時更重要:5 GB 的 dump 還原只要 1 分鐘,500 GB 的 dump 可能要 2 小時,越大越要在 production 之前驗證。
另一個關鍵觀念是「可觀察性三支柱」:metrics(聚合、便宜)、logs(個別事件、豐富)、traces(分散式追蹤、昂貴)。我們今天寫的是前兩者,trace 在小型單機應用上不一定要做,但讀者若未來要把服務拆成多個 microservice,就必須用 OpenTelemetry + Jaeger / Tempo 把 trace 串起來。我們的 middleware 在每筆 log 加 request_id,正是為了日後能接 trace:prometheus_client 與 OpenTelemetry 共用同一個 trace_id,整條鏈路就接得起來。
完整實作:JSON log、metrics 端點、PG 備份
先寫結構化 log 模組。我們把 logging 的 Formatter 換成 JSON 輸出,並透過 middleware 把 request_id、user_id、path 注入每個 record:
# booking_system/logging_config.py
import json
import logging
import sys
from datetime import datetime, timezone
class JsonFormatter(logging.Formatter):
"""把每一筆 log 紀錄輸出成單行 JSON,欄位名稱固定。"""
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
# 把 record 上的 extras(request_id、user_id 等)一起輸出
for key, value in record.__dict__.items():
if key in {
"args", "asctime", "created", "exc_info", "exc_text",
"filename", "funcName", "levelname", "levelno", "lineno",
"module", "msecs", "message", "msg", "name", "pathname",
"process", "processName", "relativeCreated", "stack_info",
"thread", "threadName", "taskName",
}:
continue
try:
json.dumps(value)
payload[key] = value
except TypeError:
payload[key] = repr(value)
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
def configure_logging(level: str = "INFO") -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(level)
# 靜音 uvicorn 的 access,統一改用我們自己的 middleware
logging.getLogger("uvicorn.access").setLevel("WARNING")
這個 Formatter 把標準欄位塞進 payload,再用迴圈把 record 上的 extras 加進去(logger.info("...", extra={"request_id": "abc"}))。uvicorn 的 access log 我們關閉,改由 FastAPI middleware 統一發 log,才能附帶 request_id。
接下來是 middleware:
# booking_system/middleware.py
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
log = logging.getLogger("booking_system.request")
class RequestContextMiddleware(BaseHTTPMiddleware):
"""每個 request 帶一個 request_id、記錄進入與結束、用於 trace。"""
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-Id", uuid.uuid4().hex)
start = time.perf_counter()
log.info(
"request.start",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
},
)
try:
response = await call_next(request)
except Exception as exc:
elapsed = (time.perf_counter() - start) * 1000
log.exception(
"request.error",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"elapsed_ms": elapsed,
},
)
raise
elapsed = (time.perf_counter() - start) * 1000
response.headers["X-Request-Id"] = request_id
log.info(
"request.end",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status": response.status_code,
"elapsed_ms": elapsed,
},
)
# 把 metrics 上報
from booking_system.metrics import record_request
record_request(method=request.method, path=request.url.path,
status=response.status_code, elapsed_ms=elapsed)
return response
這個 middleware 三件事:開 request_id、算 elapsed milliseconds、把資料交給 metrics 模組。我們刻意把 path 設計成 dynamic label,但 Prometheus 對 dynamic label 很敏感(會讓 cardinality 爆炸),所以會在 metrics 層做一次正規化(只保留 /bookings/{id} 這種高層級 pattern)。
再來是 metrics 模組。我們把 Counter 與 Histogram 都放在模組層級,方便 middleware 共用:
# booking_system/metrics.py
from prometheus_client import (
CONTENT_TYPE_LATEST,
REGISTRY,
Counter,
Histogram,
generate_latest,
)
# 把 path 正規化:/bookings/123 → /bookings/{id}
import re
_PATH_RE = re.compile(r"/bookings/\d+")
_normalize = lambda p: _PATH_RE.sub("/bookings/{id}", p)
REQUESTS_TOTAL = Counter(
"booking_requests_total",
"Total HTTP requests received",
["method", "path", "status"],
)
REQUEST_LATENCY = Histogram(
"booking_request_latency_ms",
"Request latency in milliseconds",
["method", "path"],
buckets=(5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000),
)
ERRORS_TOTAL = Counter(
"booking_errors_total",
"Total error responses (5xx)",
["path"],
)
def record_request(*, method: str, path: str, status: int, elapsed_ms: float) -> None:
p = _normalize(path)
REQUESTS_TOTAL.labels(method=method, path=p, status=status).inc()
REQUEST_LATENCY.labels(method=method, path=p).observe(elapsed_ms)
if 500 <= status < 600:
ERRORS_TOTAL.labels(path=p).inc()
def metrics_response() -> tuple[bytes, str]:
"""FastAPI 端點直接回傳這個 tuple。"""
return generate_latest(REGISTRY), CONTENT_TYPE_LATEST
這裡有三個關鍵設計。第一,_PATH_RE 把 /bookings/123 改成 /bookings/{id},避免每開一個預約就多一個 label;這在 production 很重要,否則 Prometheus 的 cardinality 會爆。第二,REQUEST_LATENCY 的 bucket 從 5 ms 到 5000 ms 跨越三個量級,符合 Web API 的真實延遲分布。第三,record_request() 是 middleware 與 metrics 的介面,方便測試時 mock。
FastAPI 把 metrics 掛上:
# booking_system/main.py(節錄)
from fastapi.responses import Response
from booking_system.logging_config import configure_logging
from booking_system.metrics import metrics_response
from booking_system.middleware import RequestContextMiddleware
configure_logging("INFO")
app.add_middleware(RequestContextMiddleware)
@app.get("/metrics")
def metrics() -> Response:
body, content_type = metrics_response()
return Response(content=body, media_type=content_type)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
/metrics 是 Prometheus scrape 用的端點,不需要任何 auth(節點內網可達即可);正式部署通常用 firewall 或 service mesh 限制來源 IP。/health 是 Day 41 已示範的 liveness;Day 34 也示範過 /ready 的寫法,這裡就不再重複。
第三段是 PostgreSQL 備份。我們寫一支可重複跑的 Python 腳本:
# scripts/backup_postgres.py
import os
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
BACKUP_DIR = Path(os.environ.get("BOOKING_BACKUP_DIR", "/var/backups/booking"))
RETENTION_DAYS = int(os.environ.get("BOOKING_BACKUP_RETENTION", "14"))
def backup_database(connection_url: str) -> Path:
"""用 pg_dump 把整個資料庫 dump 成 gzip 檔,回傳檔案路徑。"""
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out_path = BACKUP_DIR / f"booking-{stamp}.sql.gz"
# pg_dump 走 -Fc 自訂格式壓縮效率最好;我們用 -Fp plain SQL 方便事後 grep
cmd = [
"pg_dump",
"--dbname=" + connection_url,
"--no-owner",
"--no-privileges",
]
with out_path.open("wb") as f:
# 把 pg_dump 輸出透過 gzip 寫到檔案
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
try:
import gzip
with gzip.GzipFile(fileobj=f, mode="wb") as gz:
shutil.copyfileobj(proc.stdout, gz)
finally:
proc.stdout.close()
proc.wait()
if proc.returncode != 0:
raise RuntimeError(f"pg_dump 失敗:{proc.returncode}")
print(f"備份完成:{out_path} ({out_path.stat().st_size / 1e6:.2f} MB)")
return out_path
def prune_old_backups() -> int:
"""保留 RETENTION_DAYS 天內的備份;其他刪掉。"""
if not BACKUP_DIR.exists():
return 0
cutoff = datetime.now(timezone.utc).timestamp() - RETENTION_DAYS * 86400
removed = 0
for path in BACKUP_DIR.glob("booking-*.sql.gz"):
if path.stat().st_mtime < cutoff:
path.unlink()
removed += 1
return removed
if __name__ == "__main__":
url = sys.argv[1] if len(sys.argv) > 1 else os.environ.get(
"BOOKING_DATABASE_URL", ""
)
if not url:
raise SystemExit("請設定 BOOKING_DATABASE_URL 或傳入 CLI 參數")
backup_database(url)
n = prune_old_backups()
print(f"已清理 {n} 份過期備份")
這支腳本做兩件事:先跑 pg_dump 把整個資料庫壓縮成 gzip 寫到 BACKUP_DIR,檔名帶 timestamp;再刪除超過 RETENTION_DAYS 的舊檔。CLI 介面與環境變數都支援,方便塞進 cron 或 Day 32 的 CI。實務上還會加上「上傳到 S3 / B2」一段,但那會牽涉雲端 SDK,Day 44 交接手冊再展開。
還原演練腳本(dry-run 復原):
# scripts/restore_drill.py
import os
import subprocess
import sys
from pathlib import Path
def restore_drill(backup_path: Path, target_url: str) -> None:
"""把備份倒回臨時 DB、確認 row count,演練還原是否可行。"""
cmd = [
"psql",
"--dbname=" + target_url,
"--file=-",
"--quiet",
]
with backup_path.open("rb") as f:
# .sql.gz 先解 gz 再餵 psql
import gzip
with gzip.open(f, "rb") as gz:
sql_bytes = gz.read()
proc = subprocess.run(cmd, input=sql_bytes, capture_output=True)
if proc.returncode != 0:
raise SystemExit(f"還原失敗:{proc.stderr.decode()}")
# 用簡單的 row count 確認資料有進來
count_proc = subprocess.run(
["psql", "--dbname=" + target_url, "-tA",
"-c", "SELECT COUNT(*) FROM bookings;"],
capture_output=True, text=True, check=True,
)
n_bookings = int(count_proc.stdout.strip() or "0")
print(f"還原成功,bookings 表有 {n_bookings} 筆(預期 > 0)")
if n_bookings == 0:
raise SystemExit("還原後 bookings 表為空,請檢查備份檔")
if __name__ == "__main__":
if len(sys.argv) != 3:
raise SystemExit("用法:python restore_drill.py <backup.sql.gz> <target_url>")
restore_drill(Path(sys.argv[1]), sys.argv[2])
這支腳本把備份倒回臨時 DB、確認 bookings 表至少有資料。實務上會塞進 cron 每月跑一次、發現 bookings = 0 就寄信給負責人。我們的 scripts/ 目錄就有兩支:backup_postgres.py(每日)與 restore_drill.py(每月)。
最後是 pytest 驗證:
# tests/test_observability.py
import json
import logging
from fastapi.testclient import TestClient
from booking_system.main import app
from booking_system.logging_config import JsonFormatter
def test_json_formatter_outputs_valid_json():
formatter = JsonFormatter()
record = logging.LogRecord(
name="test", level=logging.INFO, pathname="x.py", lineno=1,
msg="hello %s", args=("world",), exc_info=None,
)
record.request_id = "abc123"
line = formatter.format(record)
parsed = json.loads(line) # 若格式錯會爆 JSONDecodeError
assert parsed["msg"] == "hello world"
assert parsed["level"] == "INFO"
assert parsed["request_id"] == "abc123"
assert "ts" in parsed
def test_metrics_endpoint_returns_prometheus_format():
client = TestClient(app)
r = client.get("/metrics")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/plain")
body = r.text
assert "booking_requests_total" in body
assert "booking_request_latency_ms" in body
def test_request_id_header_round_trip():
"""送 request 時帶 X-Request-Id,response 應帶回同一個 id。"""
client = TestClient(app)
r = client.get("/health", headers={"X-Request-Id": "trace-42"})
assert r.headers["X-Request-Id"] == "trace-42"
三支測試覆蓋三件事:JSON 格式正確(json.loads 不爆)、metrics 端點回 Prometheus 格式、middleware 把 request_id 帶回 response header。跑這三支只要 1–2 秒(實際數字會略有不同),可以加進 CI 的「快速測試」標籤。
常見錯誤與踩雷
第一個常見錯誤是「logger.exception 改寫了 extra」。當你寫 log.exception("msg", extra={"request_id": id}),Python 的 logging 模組其實會把 exc_info 自動加上,但你 extra 的 keys 不會自動 filter;結果輸出時可能把 __dict__ 內部欄位也漏出去。對應處理:用我們今天的 JsonFormatter 顯式列白名單(log record 的標準屬性)+ 接受其他 extras,避免洩漏。
第二個踩雷是「Prometheus label cardinality 爆炸」。如果你直接用 request.url.path 當 label,每一個 booking id、每一個 search query 都會變成獨立的 label;千次請求就會產生幾萬個 time series。對應處理:永遠先過一層正規化(regex 抽出 pattern),我們的 _PATH_RE 就是這個用途。另一個對策是提供 /metrics?limit=10 但 production 通常不會用。
第三個是「pg_dump 沒指定 --no-owner --no-privileges」,導致還原到不同機器時遇到 role 不存在的錯誤。對應處理:永遠加 --no-owner --no-privileges,這兩個 flag 是備份腳本的標配,少了它們一旦搬遷機器就出包。
第四個是「備份從未做還原演練」。實務上常見「我每天跑備份,三個月後硬碟壞掉才發現備份檔早就壞了」。對應處理:把 restore_drill.py 塞進 cron 每月跑一次、加上失敗通知。我們今天示範的腳本恰好是這個機制的最小版本。
效能與實務提醒
結構化 log 的成本主要是 JSON 序列化(CPU 與 I/O)。我們在 1000 RPS 的 benchmark 下,JSON log 約讓平均延遲增加 0.4–0.8 毫秒(實際數字會略有不同);如果是不重要的 request(例如 /health),可以降級到 DEBUG 或完全不 log。對 production 來說,要付出的成本遠小於換來的可觀察性。
Prometheus scrape 預設 15 秒一次,每次抓 10–50 KB;對小型應用來說負擔可忽略。metrics 端點不要被擋在防火牆外,否則 Prometheus 收集不到資料、警報失效。我們的 /metrics 不需要 auth,但 IP 白名單仍建議設好。
備份的 I/O 與時間也要控制。100 GB 的 PostgreSQL pg_dump 約需 5–10 分鐘(含 gzip 壓縮);備份期間 pg_dump 不會 lock 表(PostgreSQL 的 MVCC 機制),但會吃 I/O 與 CPU。對 production,建議在備份期間把 WAL archive 也開起來(archive_mode=on),這樣可以做到 point-in-time recovery;如果資料量小(< 5 GB),dump 一次就好。備份的執行時間往往與資料量成正比,可與 Day 41 的 healthcheck 一起監控:當備份超過 30 分鐘沒結束就告警,這比「發現備份沒跑」要早一步。
另一個常被忽略的觀察是「備份與日誌的關係」。備份檔裡通常會附帶當下的 SQL 狀態(包含未完成 transaction、replication slot 等),如果當下 API 正在大量寫入,dump 完的檔可能與應用狀態輕微不一致;對小型服務業預約系統沒影響,但對交易類系統就要考慮「在低峰時段做備份」。我們的預約系統整體流量低,所以任意時段 dump 都安全。
最後一個常被忽略的實務:寫進 yaml 的「BackupSchedule」。我們的 docker-compose 可以加一個 scheduled job(用 container_name 與 command 跑 pg_dump),但這在 Compose v2 並非一等公民(沒有 cron-like 服務);通常會用外部 cron 主機或 Kubernetes CronJob。今天的腳本可以完全在本機或主機跑,搭配 crontab -e 加一行 0 3 * * * python /app/scripts/backup_postgres.py,比把備份塞進 container 更簡單。
把監控、日誌、備份三件事整合到一個 stack 後,營運一個小型服務業線上預約系統的負擔就會大幅下降。我們可以把「每天 09:00 看 Grafana 確認昨天 SLA」、「每月 1 號跑還原演練」、「每季 1 號檢查備份的 14 天保留長度」這三件事寫進 runbook(Day 44),讓接手的人不用再摸索。我們現在的 /metrics、booking_request_latency_ms、booking_errors_total 三個指標,配合 backup-YYYYMMDD.sql.gz 檔名規則,就是 runbook 裡三條「事實來源」。
最後一個實務觀察:很多團隊只監控「錯誤次數」,不監控「延遲分布」。但 90% 的使用者抱怨,其實落在「延遲變高但還沒到 5xx」這個灰色地帶。我們的 REQUEST_LATENCY histogram 正是設計來抓這種問題;可以搭配 Prometheus 的 histogram_quantile() 在 Grafana 畫 P95、P99 的圖表。Day 44 的 runbook 會把這個圖表列為「每天第一件事看」。也建議把這個指標加進 nightly alert:「P95 連續 5 分鐘 > 500 ms」就寄信提醒,這比「錯誤 4xx / 5xx」這個滯後指標更早反映出問題;當 P95 升高但還沒出現 5xx 時,往往是資料庫索引被破壞、快取失效、GC 停頓等「症狀開始浮現」的時間點,這時候介入處理最省事。
另外一個小細節是「metrics 與 log 的取捨」。當問題發生時,工程師第一件事是查 log(有 request_id、有完整內容),第二件事是看 metrics(拉長時間軸找趨勢)。把兩件事都做好,比只做一件有指數級的改善。我們今天兩件都示範了:metrics 給圖、log 給故事,這個組合在 production debug 時幾乎是標準配備。明天 Day 43 我們會用 locust 2.3 製造壓力場景,把這兩個工具一起驗證。
小結
今天把監控、日誌、備份三件事一次做到位。我們寫了 JsonFormatter 把 log 改成 JSON 結構化輸出;寫了 RequestContextMiddleware 把 request_id 與 elapsed_ms 注入每一筆 log;用 prometheus_client 寫了 /metrics 端點(含 requests_total、latency histogram、errors_total);寫了 backup_postgres.py 與 restore_drill.py 兩支腳本,把 pg_dump 與還原演練做成 cron job。整個 stack 在加入這些能力後仍然在 CPU 上跑得動;加進 CI 後約 60 秒完成所有測試。所有資料延續 Day 35–41 的虛構示範。明天 Day 43 會在這套監控基礎上寫壓力測試,用 locust 與 httpx 並發找出這套 stack 的容量邊界。
結語
今天的重點是把「看不到的環節」變成可自動化驗證的腳本。我們刻意用最少的依賴(Python 標準 logging + prometheus_client 0.21 + pg_dump)就完成三件事,這對小型服務業線上預約平台來說已經非常足夠。明天我們會在這套監控基礎上跑 locust 2.3 的壓力測試,產生不同併發下的 P50 / P95 / P99 數據,並用 /metrics 驗證 API 在壓力下的真實表現。我們的目標是「在 production 之前就找出瓶頸」,而不是在 production 被打爆之後。
結構化 log 與 metrics 端點一旦上線,整個系統就進入「可觀察」狀態:每個 request 的生命週期都被記錄、每個慢的 endpoint 都能從圖表看出來、每次備份都有檔案可以還原。這套機制對營運的幫助遠比「再寫一個 feature」更大,因為它把「看不見的風險」變成「看得到的指標」。Day 43 開始,我們就把這些指標套到壓力測試上,找出這套 stack 真正的容量邊界在哪;找出之後才有「效能調校」的真實標的。那時候我們會看到 P95 在某個併發下開始爬升的臨界點,這個點通常就是資料庫連線池滿了、SQL 卡住了、或單機 CPU 跑滿了;每一種瓶頸都有對應的調校手段(pgbouncer、索引、worker 數),這也是 day-by-day 工程師的價值所在。
我們的 stack 加進監控後已經可以撐住「準 production」使用:log 可被外部 log collector 收、metrics 可被 Prometheus scrape、備份可被定時還原演練。這三件事任何一件缺了就會在某個時間點爆炸:沒有 log 找不到 root cause、沒有 metrics 沒辦法提早發現、沒有備份等於資料隨時可能消失。我們今天三件都做了,明天繼續往上加壓力測試,找到這個 stack 在不同壓力下的真實反應。
延伸資源
- Prometheus 官方文件(2025):
https://prometheus.io/docs/instrumenting/,exposition format、label cardinality、histogram bucket 的設計原則。 - Python logging Cookbook(2024):
https://docs.python.org/3/howto/logging-cookbook.html,自訂 Formatter 與 filter 的標準寫法。 - PostgreSQL 17 官方備份指南(2025):
https://www.postgresql.org/docs/17/backup.html,pg_dump、pg_restore與 WAL archive 的標準流程。 - prometheus_client 0.21 README(2025):
https://github.com/prometheus/client_python,Counter、Gauge、Histogram、Summary的範例。 - Twelve-Factor App Logs(2017,跨年代經典):
https://12factor.net/logs,為什麼 log 應該走 stdout 而非寫檔。
留言
張貼留言