DE Day 40 文件化與交接:讓管線活得比作者久
執行需求:CPU 可跑。資料工程最常見的交接慘劇是這樣的:作者離職或轉調,三個月後同事打開 repo,看到一堆 Python 腳本與 SQL 檔案,卻找不到「為什麼這樣寫」「怎麼跑」「出問題找誰」。今天的主題是文件化與交接:我們把整套管線的「為什麼、怎麼跑、出問題怎麼辦」寫成 README、runbook、決策紀錄,並用 MkDocs 把這些文件變成可搜尋的網站,讓接手的人第一天就能開始改。整個流程 CPU 可跑,沒有額外雲端相依。
引言
寫程式容易,寫文件難。把文件當成「給未來的自己與同事的信」這個比喻很貼切:未來的自己已經忘記為什麼當初這樣設計;同事更不知道你的腦袋裡裝了什麼。文件的目的不是炫技術,而是把作者的「隱性知識」變成讀者能吸收的「顯性知識」。一份好的文件讓接手的人能在第一天就把管線跑起來,第一週就能開始改;沒有文件,再簡單的管線也要花一週才搞得懂。
資料工程的特殊性在於「管線 = 程式 + 資料 + 排程 + 監控」。文件必須同時涵蓋這四個面向,缺一個都不完整。程式碼本身的 docstring 只處理「程式怎麼跑」,資料字典處理「欄位是什麼意思」,排程文件處理「什麼時候跑、誰負責」,監控文件處理「出問題怎麼發現」。今天我們把這四份文件整合成一個可搜尋的靜態網站,部署到 GitHub Pages 之後就成了 24 小時可用的交接手冊。
這一篇的設計原則是「文件要可以被搜尋、被版本化、被驗證」。可以被搜尋意味著不要把文件散落在 wiki、Slack、Email 各處,要集中在一個站點;可以被版本化意味著文件跟程式碼一起進 git,每次改文件都有 PR 紀錄;可以被驗證意味著文件中的指令必須可以從乾淨的機器跑一遍就通,把過期的指令藏在文件裡比沒有文件更糟。
文件化的四個層次
資料工程的文件大致分成四個層次,每層解決不同的問題。第一層是 README:給「剛打開 repo 的人」看的入口,目標是讓他們 5 分鐘內知道這是做什麼的、用什麼技術、怎麼跑起來。第二層是 ARCHITECTURE:給「想理解整體設計的人」看的技術總覽,涵蓋資料流、模型、排程與監控的互動關係。第三層是 RUNBOOK:給「值班的人」看的操作手冊,列舉常見失敗情境與對應處理步驟。第四層是 DECISIONS(Architecture Decision Records, ADR):給「未來想改設計的人」看的歷史紀錄,每篇記錄一次重大決策的脈絡與替代方案。
這四層文件不是替代關係,而是互補。README 告訴你「做什麼」,ARCHITECTURE 告訴你「怎麼做」,RUNBOOK 告訴你「壞了怎麼辦」,DECISIONS 告訴你「為什麼這樣做」。一份完整的專案文件應該四層都有,並且互相引用。我們這四天(Day 38-40)寫的合約、監控腳本、LLM 清洗程式就是「程式」的部分,今天把它們對應的文件補齊,整個管線才算「可交接」。
README:5 分鐘入口
README 是 repo 的門面。理想的 README 應該在一頁 A4 內回答六個問題:這個專案做什麼、用什麼技術架構、怎麼跑起來、怎麼驗證跑成功、上線後怎麼監控、出問題找誰。我們用一份 Markdown 範例展示這個結構,所有內容都可以從 README 直接連到 ARCHITECTURE、RUNBOOK、ADR。
# de-journey:政府開放資料每日管線
## 這個專案做什麼
每日從行政院環境部(airtw.moenv.gov.tw)拉取逐時空氣品質量測,
建立「測站 → 觀測時間 → AQI / 污染物」事實表,並用 Streamlit
儀表板呈現最近 30 天的趨勢與各測站排名。資料授權為政府資料開放
授權條款第 1 版。
## 技術架構
- Python 3.13 + uv 管理套件
- DuckDB 1.4(倉儲)
- dbt-core 1.10 + dbt-duckdb 1.10(轉換建模)
- APScheduler 3.11(本機排程)
- Streamlit 1.41(儀表板)
- GitHub Actions(雲端排程與通知)
## 怎麼跑起來
```bash
bash scripts/bootstrap.sh # 安裝環境(見 ARCHITECTURE.md)
uv run python -m pipelines.daily_run --date 2025-12-09
```
## 怎麼驗證跑成功
```bash
uv run python scripts/verify_env.py
uv run python scripts/check_contract.py
```
最後一行必須出現「All checks passed!」(」」
## 上線後怎麼監控
見 `RUNBOOK.md` 與 `monitoring/` 目錄。每日 03:00 (UTC+8)
跑 daily_run,並把 SLI 寫入 `logs/sli_report.json`。
## 出問題找誰
Owner:data-platform@example.com
On-call:本團隊輪值表見 `RUNBOOK.md` 的「值班」段。
這個 README 有幾個關鍵設計。第一,「做什麼」這段把資料來源、授權、用途三個關鍵資訊都寫進去,避免讀者還要去翻其他文件。第二,「技術架構」把版本號寫死,讀者可以一眼看出這個專案基於哪一年的生態系,避免用過期的工具升級。第三,「怎麼跑起來」與「怎麼驗證跑成功」分離:先跑得起來、再驗證結果正確,兩段都不能省略。第四,「出問題找誰」直接把負責人 email 寫進去,避免讀者還要去翻組織圖。
ARCHITECTURE:技術總覽
ARCHITECTURE 文件回答「這套管線的元件怎麼互動」。一份好的 ARCHITECTURE 至少要有:資料流圖、模型清單(含顆粒度與更新頻率)、排程相依關係、失敗處理策略。我們用一個 Markdown 段落加幾張 ASCII 圖把這些資訊壓在一頁 A4。
# ARCHITECTURE
## 資料流
```
+----------------+ +----------------+ +----------------+
| 環境部 AQI |-->| DuckDB 倉儲 |-->| dbt 模型 |
| (原始 API) | | (warehouse/) | | (models/) |
+----------------+ +----------------+ +----------------+
|
v
+----------------+
| Streamlit |
| (儀表板) |
+----------------+
```
## 模型清單(與 dbt 對應)
- `stg_aqi_raw`:原始逐時量測,顆粒度「測站 × 小時」。
- `stg_stations`:測站基本資料,顆粒度「測站」。
- `int_aqi_with_station`:事實表 join 維度表。
- `fct_aqi_hourly`:給儀表板的最終事實表。
- `mart_daily_summary`:每日聚合(給月報用)。
## 排程
每日 03:00 (UTC+8) 跑 daily_run:
1. ingest_aqi.py(從 API 抓取昨天資料)
2. dbt run(重建模型)
3. dbt test(合約檢查)
4. check_contract.py(SLI 報表)
5. notify.py(違規告警)
## 失敗處理
- ingest 失敗:APScheduler 重試 3 次,每次間隔 5 分鐘。
- dbt 失敗:保留前一次成功的 DuckDB,發 Slack 告警。
- test 失敗:同上,並把違規列寫進 contract_breaches.jsonl。
ASCII 圖雖然醜,但可以放進 Markdown、用 vim 改、進 git 版本化。專業的繪圖工具(draw.io、Excalidraw)適合放在對外簡報,但放在 repo 裡反而會變成「PNG 不能 diff、不能用 grep 找」的維護負擔。當專案長大、需要更精緻的圖時,再改用 Mermaid(Markdown 直接支援)也是好選擇。
完整實作:用 MkDocs 把文件變成靜態網站
把 README、ARCHITECTURE、RUNBOOK、DECISIONS 集中在一個靜態網站裡,是「文件可被搜尋」的最低成本解法。我們用 MkDocs(Material 主題)來做這件事。MkDocs 把 Markdown 轉成 HTML,支援全文搜尋與多頁導覽,整套工具鏈 5 分鐘裝好,輸出是純靜態檔,可以丟到任何 CDN。
"""de-journey/scripts/doc_init.py:把現有的 Markdown 文件搬進 docs/。"""
from __future__ import annotations
import shutil
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / "docs"
# 1. 把根目錄的 README.md / ARCHITECTURE.md 搬進 docs/
mapping = {
"README.md": "index.md",
"ARCHITECTURE.md": "architecture.md",
}
for src_name, dst_name in mapping.items():
src = ROOT / src_name
dst = DOCS / dst_name
if src.exists():
shutil.copy(src, dst)
print(f"已複製 {src_name} -> docs/{dst_name}")
# 2. 建立空的 RUNBOOK 與 DECISIONS
for name in ["runbook.md", "decisions.md"]:
target = DOCS / name
if not target.exists():
target.write_text(
f"# {name.removesuffix('.md').title()}\n\n待開寫。\n",
encoding="utf-8",
)
print(f"已建立 docs/{name}")
print("文件初始化完成")
這段用 Python 把「搬檔案、建空檔」這件事包起來,好處是可以放進 PR 的 pre-commit 檢查:每次改 README.md,CI 就跑這個腳本確保 docs/index.md 也跟著更新。shutil.copy(而不是 shutil.move)保留根目錄的 README.md,這樣 GitHub repo 首頁仍能看到入口。
cd de-journey
uv run python scripts/doc_init.py
uv pip install mkdocs==1.6.1 mkdocs-material==9.6.0
uv run mkdocs serve # 本機預覽:http://127.0.0.1:8000
輸出(啟動後的 banner):
INFO - Building documentation...
INFO - Documentation built in 0.42 seconds
INFO - Serving on http://127.0.0.1:8000
這段把整份 docs/ 目錄建立起來。MkDocs Material 主題自帶全文搜尋(用 lunr.js)、深色模式、可折疊導覽列,是 2025 年最受歡迎的技術文件主題之一。mkdocs serve 是本機預覽模式,改 Markdown 後瀏覽器自動重整。正式上線用 mkdocs build 產出 site/ 目錄,再丟到 GitHub Pages。
設定檔 mkdocs.yml 是 MkDocs 的核心,我們把導覽列、搜尋、主題顏色都寫進去:
# de-journey/mkdocs.yml
site_name: de-journey 文件站
site_description: 政府開放資料每日管線的技術文件
repo_url: https://example.com/your-org/de-journey
repo_directory: .
theme:
name: material
palette:
- scheme: default
primary: indigo
accent: indigo
features:
- navigation.instant
- navigation.tracking
- search.highlight
- search.suggest
nav:
- 首頁: index.md
- 架構總覽: architecture.md
- 維運手冊: runbook.md
- 決策紀錄: decisions.md
markdown_extensions:
- admonition
- codehilite
- toc:
permalink: true
這個設定檔把網站的導覽結構、搜尋行為、主題顏色都寫死。nav 段落決定左邊欄的順序,新增文件時記得在這裡加一行。markdown_extensions 啟用 admonition(提示框)、codehilite(程式碼區塊著色)、toc(標題錨點),這三個是技術文件最常用的擴充。repo_url 設成你的實際 git repo 位址,MkDocs 會自動在右上角顯示「編輯此頁」按鈕。
# 把文件發布到 GitHub Pages(CI 也會跑這個指令)
uv run mkdocs gh-deploy --force
# 輸出:
# INFO - Building documentation...
# INFO - Documentation built in 1.18 seconds
# INFO - Copying '/path/to/site' to 'gh-pages' branch...
# INFO - Branch 'gh-pages' pushed to remote.
mkdocs gh-deploy 把 site/ 目錄推到 gh-pages 分支,GitHub Pages 會自動部署。每次 main 分支更新時,Day 44 會在 GitHub Actions 加上 mkdocs gh-deploy 步驟,文件站就會自動保持最新。
文件站建好後,我們寫一支「文件醫生」腳本,定期掃描文件裡的指令是否還能跑。這個腳本會把 Markdown 裡的 bash 程式碼區塊抓出來,對幾個關鍵指令做實際執行驗證:
"""de-journey/scripts/doc_doctor.py:掃描文件裡的關鍵指令,確保仍可執行。"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
DOCS = Path(__file__).resolve().parents[1] / "docs"
CHECKS = [
("uv --version", "確認 uv 仍在 PATH"),
("python --version", "確認 python 連結正確"),
("duckdb --version", "確認 duckdb CLI 已安裝"),
]
for md_file in sorted(DOCS.glob("*.md")):
text = md_file.read_text(encoding="utf-8")
blocks = re.findall(r"```bash\s*\n(.*?)```", text, flags=re.DOTALL)
if not blocks:
continue
print(f"== {md_file.name}:{len(blocks)} 個 bash 區塊 ==")
for cmd, note in CHECKS:
try:
out = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=10,
check=False,
)
status = "OK" if out.returncode == 0 else "FAIL"
except subprocess.TimeoutExpired:
status = "TIMEOUT"
print(f" [{status}] {cmd}({note})")
輸出範例(實際輸出會略有不同):
== architecture.md:5 個 bash 區塊 ==
[OK] uv --version(確認 uv 仍在 PATH)
[OK] python --version(確認 python 連結正確)
[FAIL] duckdb --version(確認 duckdb CLI 已安裝)
== runbook.md:3 個 bash 區塊 ==
[OK] uv --version(確認 uv 仍在 PATH)
[OK] python --version(確認 python 連結正確)
[OK] duckdb --version(確認 duckdb CLI 已安裝)
這個腳本是「文件可被驗證」的具體實作。當 uv 或 duckdb 升級、原本文件裡的指令不再能跑時,這支腳本會在 CI 跳出紅燈,提醒我們更新文件。實務上這支腳本會在 Day 44 部署到 GitHub Actions 的 docs_check 工作裡,每天跑一次。
RUNBOOK:值班手冊
RUNBOOK 是給「半夜被打來處理問題的人」看的手冊。一份好的 RUNBOOK 應該把每一種已知失敗情境的「症狀 → 排查 → 處理」流程都列出來。當問題不在 RUNBOOK 的清單裡時,至少要寫「該找誰問」。我們用 Markdown 範例展示這個結構:
# RUNBOOK
## 值班輪值
本週輪值:見團隊 Wiki 的 On-call 頁面。
非值班時段:寫信到 data-platform@example.com,
SLA 為 4 小時內回應。
## 場景 1:dbt run 失敗
症狀:daily_run 在 dbt run 階段跳出非零 exit code。
排查:
1. 看 `logs/daily_run_*.log` 找失敗的模型名稱。
2. 進入 models/ 對應目錄,用 `uv run dbt run --select <model>` 重跑一次。
3. 若仍失敗,把錯誤訊息貼到 #data-platform Slack。
處理:
- 若單一模型失敗:先看是上游缺資料還是該模型 SQL 寫錯。
- 若整批失敗:通常是 DuckDB 連線問題,重啟 DuckDB 檔案。
## 場景 2:合約違規
症狀:logs/contract_breaches.jsonl 出現新違規。
排查:
1. 跑 `uv run python scripts/check_contract.py --table <table>`。
2. 看違規類型(completeness / uniqueness / range / enum)。
3. 對違規列抽樣看原始資料。
處理:
- completeness:補資料或修合約門檻。
- uniqueness:清掉重複(通常是上游重發)。
- range / enum:修資料或修合約欄位。
## 場景 3:GitHub Actions 沒跑
症狀:每日 03:00 沒看到新的 daily_run 工作。
排查:
1. 看 GitHub repo 的 Actions 頁面,是否有失敗的工作。
2. 看 cron 排程是否被 disable(Settings → Actions)。
處理:
- 若 workflow 失敗:點進去看錯誤訊息。
- 若 cron 被 disable:請 Owner 重新啟用。
這個 RUNBOOK 有三個關鍵設計。第一,每個場景都分成「症狀、排查、處理」三段,讓值班的人不用思考下一步該做什麼,照著走就好。第二,「症狀」這段用讀者會看到的訊息(log 訊息、檔案名稱、Slack 通知)來描述,避免值班的人要在腦中翻譯「我看到的訊息是哪個場景」。第三,「非值班時段」這段明確寫 SLA(4 小時內回應),讓接手的人有預期。
我們寫一支「場景覆蓋率」腳本,定期掃描 logs/daily_run_*.log 的錯誤訊息,與 RUNBOOK 的場景比對,找出「文件裡沒寫、卻常常發生」的失敗模式:
"""de-journey/scripts/runbook_coverage.py:比對 log 錯誤與 RUNBOOK 場景。"""
from __future__ import annotations
import re
from collections import Counter
from pathlib import Path
LOG_DIR = Path(__file__).resolve().parents[1] / "logs"
RUNBOOK = Path(__file__).resolve().parents[1] / "docs" / "runbook.md"
# 1. 從 RUNBOOK 抓出所有「場景 N:」開頭的場景名稱
scenes = set(re.findall(r"場景\s*\d+[::]\s*([^\n]+)", RUNBOOK.read_text(encoding="utf-8")))
print(f"RUNBOOK 已有 {len(scenes)} 個場景:")
for s in sorted(scenes):
print(f" - {s}")
# 2. 從最近 7 天的 log 抓出所有錯誤訊息
errors = Counter()
for log in LOG_DIR.glob("daily_run_*.log"):
for line in log.read_text(encoding="utf-8").splitlines():
if "ERROR" in line or "Traceback" in line:
key = line.split(":")[0][:40]
errors[key] += 1
# 3. 找出 log 有但 RUNBOOK 沒有的錯誤
unmapped = [k for k in errors if not any(s[:6] in k for s in scenes)]
print(f"\nlog 出現 {len(errors)} 種錯誤,其中 {len(unmapped)} 種尚未寫進 RUNBOOK")
for u in unmapped[:5]:
print(f" - {u}(出現 {errors[u]} 次,建議補進 RUNBOOK)")
這支腳本做的事很單純:把「文件裡列出的場景」與「log 裡出現的錯誤」做交集,找出差異。差異越大,代表文件過期得越嚴重。實務上這支腳本會在 Day 43 的評估儀表板裡露出一個小圖,提示「RUNBOOK 還有 N 個場景需要補」。
DECISIONS:決策紀錄
DECISIONS(Architecture Decision Records, ADR)是給「想改設計的人」看的歷史紀錄。每篇 ADR 記錄一次重大決策:當時面臨什麼選項、為什麼選這個、付出的代價是什麼。我們用 Markdown 範例展示這個結構:
# ADR-0001:選擇 dbt-core + dbt-duckdb 做轉換建模
## 狀態
已接受(2025-11-25)
## 脈絡
Day 25 之前,管線的轉換是用 Python + DuckDB 直接寫 SQL 檔案。
缺點是模型相依關係靠人工維護、測試要自己寫、文件要自己生。
當模型數量超過 10 個時,管理成本急劇上升。
## 選項
- A. 繼續用 Python + DuckDB,自己寫工具。
- B. 引入 dbt-core + dbt-duckdb。
- C. 引入 SQLMesh 或 dbt 2.0(當時仍在 beta)。
## 決定
採用 B(dbt-core 1.10 + dbt-duckdb 1.10)。
## 後果
正面:
- 模型相依關係由 dbt 自動管理。
- 內建測試框架(unique、not_null、relationships、accepted_values)。
- dbt docs 自動產生資料字典。
- 社群支援活躍,文件完整。
負面:
- 學習曲線:團隊需要學 Jinja 與 dbt 的 SELECT 風格。
- 增加一個相依(dbt-core + dbt-duckdb)。
- 對小型模型來說「殺雞用牛刀」。
## 替代方案
若未來模型數量減少到 5 個以下,可以重新評估是否改回 A。
這個 ADR 有幾個關鍵設計。第一,「狀態」段標出決策生效日期,方便讀者判斷這個決策是「最近」還是「很久以前」。第二,「選項」段把當時考慮的所有方案列出,讀者才知道「為什麼不是用 X」。第三,「後果」段同時寫正面與負面,避免「事後諸葛」式的美化。
把 ADR 集中放在 docs/decisions.md 或 docs/decisions/0001-xxx.md 目錄裡,每個決策一個檔案,永遠不刪除(即使決策後來被推翻,也保留原檔並標記 status: superseded)。這是給未來的自己最大的禮物:當有人問「為什麼當初用 dbt 而不是 SQLMesh」,你能直接指向 ADR-0001。
為了讓 ADR 不會被「忘記補狀態」,我們寫一支「ADR 格式檢查」腳本掃描 decisions 目錄,確認每篇都符合基本 schema:
"""de-journey/scripts/check_adrs.py:確認 decisions 目錄的 ADR 都有必要段落。"""
from __future__ import annotations
import re
from pathlib import Path
DECISIONS_DIR = Path(__file__).resolve().parents[1] / "docs" / "decisions"
REQUIRED = ["## 狀態", "## 脈絡", "## 選項", "## 決定", "## 後果"]
if not DECISIONS_DIR.exists():
print(f"{DECISIONS_DIR} 尚未建立,先用 mkdir -p 建立")
else:
for adr in sorted(DECISIONS_DIR.glob("*.md")):
text = adr.read_text(encoding="utf-8")
missing = [s for s in REQUIRED if s not in text]
if missing:
print(f"[FAIL] {adr.name} 缺少:{', '.join(missing)}")
else:
print(f"[OK] {adr.name}")
這支腳本確保每篇 ADR 都有「狀態、脈絡、選項、決定、後果」五個必要段落,缺一段就會在 CI 跳出紅燈。實務上可以更嚴格:要求每篇都要引用至少一個 PR 或 issue,或要求「後果」段落不能少於 50 字。這些規則用 GitHub Actions 的 workflow 跑,效果就是把 ADR 的格式檢查自動化。
PR 模板:把文件檢查嵌進流程
前面提到的「PR template」是把文件檢查嵌進開發流程的低成本做法。我們在 .github/PULL_REQUEST_TEMPLATE.md 寫一份檢查清單,要求每個 PR 都要勾選對應的文件段落是否需要更新:
"""de-journey/scripts/render_pr_template.py:把文件檢查清單渲染成 Markdown。"""
from __future__ import annotations
SECTIONS = [
("README.md", "改了一般資訊、套件版本、執行指令?"),
("docs/architecture.md", "改了資料流、模型、排程?"),
("docs/runbook.md", "新增或修改失敗場景?"),
("docs/decisions/0001-xxx.md", "改了過去的 ADR 決策?"),
("docs/decisions/ 新檔", "新增了重大決策需要記錄?"),
]
print("## 文件檢查清單\n")
print("請勾選這次 PR 影響的文件段落(若都不需要也要勾「無」):\n")
for path, q in SECTIONS:
print(f"- [ ] **{path}** — {q}")
print("- [ ] 無(這次 PR 不影響任何文件)")
輸出:
## 文件檢查清單
請勾選這次 PR 影響的文件段落(若都不需要也要勾「無」):
- [ ] **README.md** — 改了一般資訊、套件版本、執行指令?
- [ ] **docs/architecture.md** — 改了資料流、模型、排程?
- [ ] **docs/runbook.md** — 新增或修改失敗場景?
- [ ] **docs/decisions/0001-xxx.md** — 改了過去的 ADR 決策?
- [ ] **docs/decisions/ 新檔** — 新增了重大決策需要記錄?
- [ ] 無(這次 PR 不影響任何文件)
這段把「文件檢查清單」用 Python 模板渲染出來,再用 GitHub 的 PR template 機制自動套到每個新 PR。實務上你會把這段輸出直接寫進 .github/PULL_REQUEST_TEMPLATE.md 檔,PR 開起來就會自動帶出這個清單,迫使 PR 作者思考「我這次改的東西,文件要不要同步更新」。
除了 PR template,再寫一支「連結檢查」腳本掃描文件站內部連結是否 404。這支腳本在 Day 44 部署到 GitHub Actions 後會每天跑,避免文件裡的內部連結在重新命名後變成死鏈:
"""de-journey/scripts/check_links.py:掃描 docs/ 內部 Markdown 連結是否都有對應檔案。"""
from __future__ import annotations
import re
from pathlib import Path
DOCS = Path(__file__).resolve().parents[1] / "docs"
LINK_RE = re.compile(r"\[[^\]]+\]\((?!https?://)([^)]+)\)")
broken = []
for md_file in sorted(DOCS.rglob("*.md")):
text = md_file.read_text(encoding="utf-8")
for link in LINK_RE.findall(text):
target = (md_file.parent / link.split("#")[0]).resolve()
if not target.exists():
broken.append((md_file.relative_to(DOCS), link))
if broken:
print(f"發現 {len(broken)} 個失效連結:")
for src, link in broken[:10]:
print(f" {src} -> {link}")
else:
print("所有內部連結都正常")
這支腳本只檢查「內部」連結(也就是以相對路徑而非 http(s) 開頭的),對外部連結不做檢查——外部連結可能暫時 404,但不一定是錯的。輸出範例:當有人把 architecture.md 重新命名成 arch.md 但忘了更新其他文件的連結時,這支腳本會印出失效的連結清單。
常見錯誤與踩雷
第一個雷:文件散落各地(README 在 GitHub、ARCHITECTURE 在 Wiki、RUNBOOK 在 Slack)。讀者要花時間找,且沒有搜尋。請一律把文件集中在一個靜態網站(MkDocs、Docusaurus、VitePress 都可),用 git 版本化。
第二個雷:文件寫完後沒人更新。文件過期比沒有文件更糟,會誤導接手的人。請把「更新文件」放進每個 PR 的檢查清單(PR template),並在 CI 加一個簡單的檢查:grep README 看有沒有引用過期的指令或套件版本。
第三個雷:把「為什麼」寫得太空泛。範例:「我們選擇 DuckDB 因為它很快」。這句話對讀者毫無幫助。好的寫法是「我們選擇 DuckDB 因為 (1) 1.4 世代支援直接查 Parquet 檔,省下 ETL 中介層;(2) CPU 單機效能比 SQLite 快 5–10 倍;(3) 與 dbt-duckdb 1.10 整合最佳」。三個具體原因比一句模糊形容詞有資訊密度得多。
第四個雷:RUNBOOK 只列「快樂路徑」。RUNBOOK 的目的是處理失敗,但很多人寫 RUNBOOK 只寫「正常流程怎麼跑」。請把每一個你曾經 debug 過的失敗都寫進 RUNBOOK:症狀、排查、處理步驟。這些都是未來你或同事最需要的內容。
第五個雷:文件用「我們」當主詞,卻沒有指明「我們是誰」。當文件讀者不知道作者是誰、組織在哪裡、文件何時寫的,這份文件就難以信任。請在每份文件的開頭明確標示作者、日期、組織,這是「可驗證」的第一步。
效能與實務提醒
MkDocs Material 主題的靜態網站通常小於 5 MB,部署到 GitHub Pages 完全免費(公開 repo)或便宜(私有 repo)。整個 site/ 目錄可以被 CDN 快取,載入時間在數百毫秒內。
實務上有三個取捨值得記得。第一,文件深度:一份好文件的長度通常在 10–50 頁 Markdown 之間;太短不夠具體、太長讀者會跳著讀找重點。第二,圖 vs 文字:技術細節盡量用文字(可 grep、可搜尋),高層次的資料流用 ASCII 或 Mermaid。第三,搜尋友善:每個段落都有一個明確的標題、不要用「其他」「雜項」這種模糊詞當標題,這樣全文搜尋才能命中。
另一個工程上的提醒:把文件的「驗證指令」放進 CI。例如 README 寫「跑 bash scripts/bootstrap.sh」,CI 就跑這個指令並把結果截圖存到 docs/screenshots/。這樣讀者一眼就知道「這個指令可以跑」,不會被過期的命令誤導。
小結
今天把文件化與交接這條線建立起來。我們把 README、ARCHITECTURE、RUNBOOK、DECISIONS 四份文件用 MkDocs Material 集中成一個可搜尋的靜態網站,並展示了如何用 mkdocs gh-deploy 部署到 GitHub Pages。重點觀念有三個。第一,文件要可以被搜尋、被版本化、被驗證,三者缺一不可。第二,文件要回答「做什麼、怎麼做、壞了怎麼辦、為什麼這樣做」四個問題,分別對應 README、ARCHITECTURE、RUNBOOK、DECISIONS。第三,文件更新要進入 PR 與 CI 流程,避免過期。
這套文件結構會跟著 Day 41 之後的專案一起演進。當我們加入新的模型、新的排程、新的監控指標時,相關段落要同步更新。明天,我們會正式進入專案篇,從「專案定義與資料模型」開始,把 Day 1–40 的所有工具與觀念組合成一個可以每天自動運行的真實管線。
結語
今天的重點是「讓管線活得比作者久」。我們用四份文件(README、ARCHITECTURE、RUNBOOK、DECISIONS)把整套管線的設計與操作流程寫下來,並用 MkDocs 把它們集中成一個可搜尋的靜態網站。讀完這篇你應該能回答:為什麼需要四份文件而不是一份?怎麼用 MkDocs 把 Markdown 變成可搜尋的網站?怎麼寫一份有用的 RUNBOOK?明天,我們會正式進入專案篇,從「專案定義與資料模型」開始,把過去 40 天的所有工具與觀念組合成一個真實可運行的管線。
延伸資源
- MkDocs Material 文件(9.6 版,2025):
https://squidfunk.github.io/mkdocs-material/。MkDocs Material 是 2025 年最受歡迎的技術文件主題,自帶全文搜尋、深色模式與響應式設計。 - Architecture Decision Records 概念介紹(Michael Nygard,2011):
https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions。ADR 的原始文章,至今仍是業界標準。 - Google SRE Book「Runbook」章節:
https://sre.google/sre-book/runbook/。RUNBOOK 的設計原則與範例。 - Diátaxis 文件框架(2025):
https://diataxis.fr/。把文件分成「教學、指引、參考、解釋」四類,與本篇的四層文件結構互補。 - GitHub Pages 官方文件:
https://docs.github.com/en/pages。gh-pages分支的部署流程。
留言
張貼留言