跳到主要內容

Web Day 42 專案:監控、日誌與備份

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 而非寫檔。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...