在 Python 容器中處理大數據存儲與 ACID 特性。
在工業大數據場景下,每天可能會產出數千萬筆的晶圓測試資料(像 Thickness、Resistance 這些參數)。傳統關聯式資料庫(如 PostgreSQL)面對這種高維度、又要頻繁聚合運算的場景,真的會跑到懷疑人生。
所以 Wafer BI 的後端計算引擎選了 Python + FastAPI,搭配 Delta Lake 做大數據存儲。Delta Lake 不只有 Parquet 格式帶來的高效能壓縮,還提供 ACID 事務特性,資料寫入時的不一致問題直接根治。
直接看專案中 services/wafer-bi/main.py 的核心配置。為了處理龐大的資料量並確保讀取效能,我們直接用 deltalake 庫讀取掛載的 Delta Table:
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from deltalake import DeltaTable
import pandas as pd
app = FastAPI(title="Wafer BI API")
# 允許跨域請求
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
DELTA_PATH = "/app/wafer_delta_table"
def get_df():
try:
# 直接讀取 Delta Table 並轉為 Pandas DataFrame
dt = DeltaTable(DELTA_PATH)
return dt.to_pandas()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read Delta table: {str(e)}")
FastAPI 自帶的 Swagger UI (/docs) 是開發時最好的朋友,所有統計 API 一目了然,前端同事再也不會來問你 API 長怎樣(是說前端也是我 哈哈):

▲ FastAPI Swagger UI:wafer-map、cdf、stats、yield 等統計 API
上面那張圖只列出了 API 有哪些,但「這個參數要傳什麼」「回傳格式長怎樣」這些細節,光看清單看不出來。FastAPI 的作法是把文件寫在程式碼本身——用型別標註跟 docstring,而不是額外維護一份 API 文件。拿 /stats/{lot_id} 這支算盒鬚圖統計的端點來看:
@app.get(
"/stats/{lot_id}",
tags=["Statistics"],
summary="取得批次的統計盒鬚圖數據",
response_description="每片晶圓的 min/q1/median/q3/max 五數概括,以及各晶圓平均值的趨勢序列",
)
async def get_lot_stats(
lot_id: str = Path(..., description="批次編號", examples=["Lot1"]),
parameter: str = Query(
"Thickness",
description="要統計的測試參數名稱,對應資料表裡的 parameter 欄位",
examples=["Thickness", "Resistance"],
),
):
"""
依批次 (Lot) 與參數 (Parameter) 計算每片晶圓的五數概括統計 (Five-Number Summary),
是箱型圖 (Box Plot) 與趨勢圖的資料來源。
計算方式:先篩選出屬於這個批次、這個參數的所有量測值,
再依 wafer_id 分組,對每片晶圓的數值算出 min / Q1(25百分位) / median / Q3(75百分位) / max,
並取平均值當作跨晶圓比較的趨勢指標。
找不到符合條件的資料時回傳 404。
"""
summary、tags、Path/Query 的 description 跟 examples,加上函式本身的 docstring,FastAPI 會自動組成這樣的畫面:

▲ 展開後的 /stats/{lot_id}:完整說明文字、每個參數的用途與範例值、回應格式與錯誤碼一次到位
這樣做的好處很直接:新加入的人不用讀原始碼,也不用來問你,只要打開 /docs 點開這支 API,就知道 lot_id 是路徑參數、parameter 預設是 Thickness、404 代表查無資料——文件跟程式碼綁在一起,程式改了文件就跟著改,不會有「文件寫的是三個月前的版本」這種常見的老問題。而且右上角那顆 Try it out 按鈕還能直接在瀏覽器裡打真的請求測試,不用另外開 Postman。
容器化 Python 應用時,最在乎的是 Image 大小跟依賴安裝速度。在 services/wafer-bi/Dockerfile 中,我們用輕量的 python:3.10-slim 當基底:
FROM python:3.10-slim
WORKDIR /app
# 先複製 requirements 並安裝,善用 Docker Layer 緩存機制
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 再複製原始碼與資料產生腳本
COPY . .
# 確保容器啟動前,若無資料則自動生成測試集
RUN python data_generator.py
EXPOSE 8000
# 使用 uvicorn 啟動 ASGI 伺服器
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
跟 Day 3 的 Java 一樣,COPY requirements.txt 跟 COPY . . 要分開寫——pip install 是最貴的一層(pandas + pyarrow + deltalake 全家桶),只要 requirements 沒變就走快取。
實際建置的完整過程如下,pip 安裝約 34 秒、資料生成約 1.2 秒:

▲ docker build Python Image:pip install 與 data_generator 資料生成完整過程
哭啊,說好的坑馬上就來。某次重新建置 Image 時,RUN python data_generator.py 突然炸出這個:
_internal.SchemaMismatchError: Cannot cast schema, number of fields does not match: 8 vs 6
當下真的一頭霧水,程式碼明明沒動啊?
原因:先回頭看 Dockerfile 那行 COPY . .——意思是「把目前目錄下的所有東西都複製進 Image」,而不是逐一列出要複製哪些檔案。這樣寫是為了省事:以後新增原始碼檔案,不用回頭改 Dockerfile,COPY . . 自動就會帶到。但代價是「所有東西」不分青紅皂白全部照單全收,包括你根本不希望進 Image 的東西。這個服務目前沒有 .dockerignore,於是本機資料夾裡舊版跑 data_generator.py 殘留下來的 wafer_delta_table/(6 個欄位的舊 Schema)也被一起複製進了 Image。build 到 RUN python data_generator.py 這一步時,新版腳本要寫入 8 個欄位(新增了 product_id 跟 yield),但 Image 裡的同一個路徑已經卡著一份 6 欄位的舊資料,Delta Lake 的 mode="overwrite" 預設只覆寫資料、不覆寫 Schema,於是直接拒絕寫入。
解法:明確告訴 Delta Lake「我要連 Schema 一起演進」:
write_deltalake(delta_path, full_df, mode="overwrite", schema_mode="overwrite")
這個修法解決了「這次」的症狀,但沒解決「COPY . . 沒有把關」這個根本原因——本機資料夾以後長出任何其他不該進 Image 的東西(__pycache__、.venv、測試用的暫存檔),一樣會被原封不動複製進去。更徹底的做法是補一份 .dockerignore,把 wafer_delta_table/、__pycache__/ 這類本機產物明確排除在 build context 之外,讓 COPY . . 的「省事」不用拿「乾淨」去換。
事後想想,Schema 校驗擋下這次寫入,其實就是 Delta Lake ACID 特性的體現——它寧可讓你的寫入失敗,也不會默默把資料表弄壞。開發時被它擋下來會覺得煩,但在生產環境這種「嚴格」就是保命符。
把 Delta Lake 的存儲路徑 /app/wafer_delta_table 獨立出來之後,後續在 K8S 部署時(Day 12 會講),就能很輕鬆地用 PV/PVC 把這個目錄掛成持久化存儲,容器重啟資料也不會消失。
後端引擎就緒,明天來看前端——React + ECharts 怎麼流暢渲染上萬個資料點的晶圓熱圖。