DE Day 36 部署:排程機器與容器化
執行需求:需 Docker。今天是端到端管線系列的部署篇(容器化路線)。前 35 篇我們把管線在筆電上跑得很順,但「每天要有人記得手動跑」不是長久之計。今天要把整條管線打包成 Docker 映像、用 docker-compose 啟動一個「排程機器」容器、用 APScheduler 3.11 在容器內定時執行管線。這條部署路徑適合「有專屬機器或 VM」的情境;如果你的機器是閒置的舊筆電、公司內部的主機、或一台便宜的 VPS,今天的方案最划算。讀完這篇,你會有一個可以丟到任何 Linux 機器上跑的容器化資料管線。
引言
前 35 篇我們把所有東西都寫成本機腳本:pipelines/ingest.py、pipelines/transform_*.py、pipelines/build_marts.py。這些腳本要靠「使用者每天早上 9 點手動跑」才能運作——這對一個每天 9 點必須跑一次的管線來說非常脆弱(人會請假、會忘記、會出國)。今天要解決這個問題:把管線「容器化」並加上「排程器」,讓它可以無人值守地每天自動跑。
容器化有兩個核心好處。第一,環境一致性:容器映像包含 Python、DuckDB、所有依賴套件、設定檔,部署到任何機器都能跑得一樣。這避免「在我機器上可以跑」這種經典問題。第二,隔離性:容器跑在自己獨立的檔案系統與網路空間,不會影響主機的其他服務。這對「要把管線部署到已經跑了很多服務的主機」特別有用。
今天的部署設計是「一個容器跑一個排程器」。容器啟動後,APScheduler 3.11 會每分鐘檢查是否有任務該跑;當時間到了,APScheduler 呼叫 run_pipeline.py,依序執行採集、轉換、建模、品質、通知五個階段。如果任何階段失敗,APScheduler 會記錄到 log 檔,並在下次排程時間自動重試(可設定)。整個流程不需要人工介入。
容器化的核心觀念
容器化的第一步是寫 Dockerfile。Dockerfile 是「映像的配方」:每一行指令都在新的一層建立檔案系統快照。Docker 在建立映像時會快取每一層,所以「變更頻率低的層放前面、變更頻率高的層放後面」是基本原則。例如:
- 基底映像(
python:3.13-slim)放在最前面,因為它幾乎不變。 - 系統套件(
tzdata、ca-certificates)放第二層,這些也少變。 - Python 套件(
duckdb、pydantic)放第三層,這層每天可能更新。 - 應用程式碼(
pipelines/、dashboards/)放最後面,這層每次部署都會變。
第二個觀念是「映像小才好」。Python 的 slim 變體比 full 變體小 800 MB 左右;用 uv pip install 比 pip install 快 10–100 倍(因為 uv 是用 Rust 寫的、快取更聰明)。我們會在 Dockerfile 裡用 python:3.13-slim 當基底,搭配 uv 安裝套件。
第三個觀念是「設定與程式碼分離」。映像裡只放「不會因環境而變」的東西(程式碼、Dockerfile);URL、密碼、收件人信箱這些「會因環境而變」的東西用環境變數注入。我們會在 docker-compose.yml 裡定義環境變數,正式部署時用 secret manager 注入。
在開始寫 Dockerfile 之前,先建立 .dockerignore 排除不必要的檔案。這能讓映像更小、build 更快:
# de-journey/.dockerignore:排除不該進映像的檔案
.venv/
__pycache__/
*.pyc
*.pyo
data/
warehouse/
logs/
.git/
.gitignore
.env
.env.local
*.md
tests/
docs/
.DS_Store
.dockerignore 的語法跟 .gitignore 一樣,每一行是一個排除模式。.venv/ 不需要進映像(容器會重新安裝套件);data/ 與 warehouse/ 透過 volume 掛載,不該複製進映像;.env 包含敏感資料,更不能進映像。一份好的 .dockerignore 通常可以讓映像小 200–500 MB、build 時間縮短 30–60 秒。
另外,我們需要一份 pyproject.toml 讓 uv 可以管理套件版本:
[project]
name = "de-journey"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"duckdb>=1.4.1",
"polars>=1.33.0",
"pandas>=2.3.0",
"pyarrow>=18.0.0",
"httpx>=0.28.1",
"tenacity>=9.0.0",
"streamlit>=1.41.0",
"apscheduler>=3.11.0",
]
[tool.uv]
managed = true
這份 pyproject.toml 讓 uv 可以解析套件相依、產生 uv.lock 鎖定版本。注意 requires-python = ">=3.13" 是必要的——這樣當團隊成員用 Python 3.12 試圖 build 映像時,uv 會直接報錯並提示需要升級,避免部署到正式環境才發現版本不符。
完整實作:Dockerfile
把 de-journey/ 打包成 Docker 映像。這個 Dockerfile 放在專案根目錄:
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
# 1. 系統套件(少變)
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Taipei
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
tzdata ca-certificates curl \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& rm -rf /var/lib/apt/lists/*
# 2. 安裝 uv(少變)
COPY --from=ghcr.io/astral-sh/uv:0.5.0 /uv /uvx /usr/local/bin/
# 3. 設定工作目錄
WORKDIR /app
# 4. Python 套件(中頻更新)
COPY pyproject.toml uv.lock* ./
RUN uv pip install --system \
"duckdb==1.4.1" \
"polars==1.33.0" \
"pandas==2.3.0" \
"pyarrow==18.0.0" \
"httpx==0.28.1" \
"tenacity==9.0.0" \
"streamlit==1.41.0" \
"apscheduler==3.11.0"
# 5. 應用程式碼(高頻更新)
COPY pipelines/ ./pipelines/
COPY dashboards/ ./dashboards/
COPY run_pipeline.py ./
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
# 6. 預先建立工作目錄
RUN mkdir -p data warehouse logs
# 7. 健康檢查(讓 docker-compose 能偵測容器是否健康)
HEALTHCHECK --interval=5m --timeout=30s --retries=3 \
CMD python -c "import duckdb; duckdb.connect('warehouse/de-journey.duckdb').execute('SELECT 1').fetchone()" \
|| exit 1
ENTRYPOINT ["./entrypoint.sh"]
這個 Dockerfile 用 multi-stage 的精神(雖然只有一個 stage)把環境分成幾層。幾個重點說明:第一,python:3.13-slim 比 full 變體小很多;slim 只包含 Python 解譯器與必要的函式庫,映像大小約 150 MB 而不是 1 GB。第二,uv 用 COPY --from=ghcr.io/astral-sh/uv:0.5.0 從官方映像複製,這樣不用在 Dockerfile 裡 pip install uv,節省時間。第三,HEALTHCHECK 用 DuckDB 的 SELECT 1 當探針——如果 DuckDB 檔案可以開啟並回應查詢,就代表管線環境是健康的。第四,entrypoint.sh 是容器啟動時執行的腳本,下面會介紹它的內容。
另一個重點是「映像不要包含敏感資料」。URL 與密碼用環境變數注入,不寫進 Dockerfile 也不寫進 docker-compose.yml。正式部署時用 Docker secrets 或雲端的 secret manager(如 AWS Secrets Manager、GCP Secret Manager)注入。
完整實作:entrypoint.sh 與排程器
容器啟動時需要決定「跑什麼」。最簡單的設計是「啟動排程器、由排程器決定何時跑管線」:
#!/usr/bin/env bash
# de-journey/entrypoint.sh:容器啟動時執行
set -euo pipefail
# 第一次啟動時跑一次完整管線(warm-up)
echo "[entrypoint] 暖機:跑一次完整管線"
python run_pipeline.py
# 啟動 APScheduler 排程器
echo "[entrypoint] 啟動排程器,每天 09:00 跑一次"
exec python -m apscheduler.scheduler
這個腳本做了兩件事:第一,跑一次完整管線(暖機)讓 DuckDB 檔案被建立、確保第一次排程不會失敗;第二,啟動 APScheduler 排程器。exec 是關鍵——它讓排程器接管容器的主行程,這樣當排程器收到 docker stop 時能優雅關閉。
APScheduler 排程器的程式碼放在 apscheduler_scheduler.py:
"""de-journey/apscheduler_scheduler.py:每天 09:00 跑一次管線。"""
import logging
import os
from datetime import datetime
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
from run_pipeline import main as run_pipeline_main
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("scheduler")
scheduler = BlockingScheduler(timezone="Asia/Taipei")
# 每天 09:00 跑一次(時區 Asia/Taipei)
@scheduler.scheduled_job(
CronTrigger.from_crontab("0 9 * * *"),
id="daily_pipeline",
name="每日管線",
max_instances=1,
coalesce=True,
misfire_grace_time=3600,
)
def daily_job() -> None:
log.info("=== 排程觸發:執行管線 ===")
rc = run_pipeline_main()
log.info("=== 管線結束,return code: %d ===", rc)
# 啟動時立即跑一次(方便測試與首次部署)
if os.environ.get("RUN_ON_START", "true").lower() == "true":
log.info("[啟動時立即跑一次]")
daily_job()
log.info("排程器啟動,等待觸發...")
try:
scheduler.start()
except (KeyboardInterrupt, SystemExit):
log.info("排程器收到停止訊號")
這支排程器用 BlockingScheduler(阻塞式),適合容器場景。APScheduler 3.11 的 CronTrigger.from_crontab("0 9 * * *") 是標準 cron 表達式:每天 09:00 觸發。max_instances=1 防止上一輪還沒跑完就啟動下一輪;coalesce=True 把「錯過的多次觸發」合併成一次;misfire_grace_time=3600 允許 1 小時的錯過寬限(例如機器關機 30 分鐘後重開,不會立刻補跑而是跳過)。
完整實作:docker-compose.yml
用 docker-compose 啟動排程機器。這個檔案放在專案根目錄:
# de-journey/docker-compose.yml:本地啟動排程機器
services:
pipeline:
build:
context: .
dockerfile: Dockerfile
image: de-journey:latest
container_name: de-journey-pipeline
restart: unless-stopped
environment:
# 12-factor:URL 與 secrets 用環境變數注入
DATASET_URL_COMPANY_BASIC: "${DATASET_URL_COMPANY_BASIC}"
DATASET_URL_COMPANY_CHANGE: "${DATASET_URL_COMPANY_CHANGE}"
TZ: Asia/Taipei
RUN_ON_START: "true"
volumes:
# 把 DuckDB 與 data 掛到主機,避免容器重啟後資料不見
- ./warehouse:/app/warehouse
- ./data:/app/data
- ./logs:/app/logs
healthcheck:
test: ["CMD", "python", "-c", "import duckdb; duckdb.connect('warehouse/de-journey.duckdb').execute('SELECT 1').fetchone()"]
interval: 5m
timeout: 30s
retries: 3
volumes: 是關鍵設計:把 warehouse/、data/、logs/ 三個目錄從主機掛載到容器。這樣當容器重啟或重建時,歷史資料不會丟失——容器本身是「無狀態的」,狀態都保存在主機的目錄裡。environment: 區塊用 ${DATASET_URL_COMPANY_BASIC} 從主機的 .env 檔讀取 URL;正式部署時可以用 Docker secrets 替代。
啟動方式:
cp .env.example .env
# 編輯 .env 填入真實 URL
docker-compose up -d --build
# -d: 背景執行
# --build: 強制重建映像
docker-compose logs -f pipeline
# 查看排程器與管線的 log
第一行複製 .env.example(樣板檔)到 .env(實際檔)。第二行啟動容器。第三行查看 log。如果要停止,用 docker-compose down;要重啟用 docker-compose restart。
除了排程器與 docker-compose,還需要一個 run_pipeline.py 把 Day 30–Day 35 的所有腳本串起來:
"""de-journey/run_pipeline.py:把 Day 30-35 的腳本串成一支主程式。"""
import importlib
import logging
import sys
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("pipeline")
# 階段順序:後一步依賴前一步的產出
STAGES = [
("ingest", "pipelines.ingest"),
("transform_basic", "pipelines.transform_basic"),
("transform_change", "pipelines.transform_change"),
("build_marts", "pipelines.build_marts"),
("check_quality", "pipelines.check_quality"),
]
def run_stage(name: str, module_name: str) -> int:
log.info("[%s] 開始", name)
try:
mod = importlib.import_module(module_name)
rc = mod.main()
except Exception:
log.exception("[%s] 失敗", name)
return 1
if rc != 0:
log.error("[%s] 回傳非 0:%d", name, rc)
return rc
def main() -> int:
overall_rc = 0
for name, module_name in STAGES:
rc = run_stage(name, module_name)
overall_rc = overall_rc or rc
if rc != 0:
log.error("在 [%s] 階段停止,後續階段不會執行", name)
break
return overall_rc
if __name__ == "__main__":
sys.exit(main())
run_pipeline.py 是排程器呼叫的「主程式」:依序執行五個階段,任何階段失敗就停止(後續階段不執行)。這個設計與 Day 33 的 incident_response.py 類似,但範圍不同:incident_response.py 處理「已經失敗後的救援」,run_pipeline.py 處理「日常的完整流程」。實務上你會希望兩者共用同一套階段定義(可以再抽出 STAGES 字典到 common.py)。
接著是 .env.example 樣板檔,正式部署時複製成 .env 並填入真實值:
# de-journey/.env.example(複製成 .env 並填入真實值)
# data.gov.tw 上的「公司登記基本資料」下載 URL
DATASET_URL_COMPANY_BASIC=https://data.gov.tw/api/v1/example-company-basic.csv
# data.gov.tw 上的「公司變更登記資料」下載 URL
DATASET_URL_COMPANY_CHANGE=https://data.gov.tw/api/v1/example-company-change.csv
# APScheduler 是否在啟動時立即跑一次(測試時用 true)
RUN_ON_START=true
這份樣板檔有三個要點:第一,所有 URL 都是假的 example,避免誤導;第二,.env.example 與 .env 兩個檔案都要列在 .gitignore,.env.example 用來讓新成員知道需要哪些設定、.env 是實際執行用的;第三,把 secrets 集中放在 .env 是 12-factor 的標準做法,方便部署到不同環境時只換 .env 不改程式碼。
容器化管線還需要考慮「log 與監控」。在容器環境裡,log 預設輸出到 stdout/stderr,需要用 docker logs 查看。實務上我們會建議把 log 同時寫到檔案(透過 volume 掛到主機):
"""de-journey/pipelines/log_helper.py:把 logging 同時導到 stdout 與檔案。"""
import logging
import sys
from pathlib import Path
def setup_logging(log_dir: Path, log_name: str, level: int = logging.INFO) -> None:
"""把 logging 同時導到 stdout(容器可見)與檔案(主機持久化)。"""
log_dir.mkdir(parents=True, exist_ok=True)
handlers = [
logging.StreamHandler(sys.stdout),
logging.FileHandler(log_dir / f"{log_name}.log"),
]
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
handlers=handlers,
)
這支小工具讓所有管線腳本可以用一致的 logging 設定。容器環境的 docker logs 會看到 stdout 輸出,主機的 logs/ 目錄則保留完整記錄。實務上你會希望 log 同時輸出,因為「容器被刪除時 stdout log 也會跟著消失」,但 volume 掛載的檔案不會。
另一個重要的考量是「容器健康檢查」。除了 Dockerfile 裡的 HEALTHCHECK,也可以寫一支獨立的 health-check 腳本:
"""de-journey/health_check.py:回傳 0 表示健康,非 0 表示異常。"""
import sys
from datetime import date, timedelta
import duckdb
from pipelines.common import DUCKDB_PATH
try:
con = duckdb.connect(str(DUCKDB_PATH), read_only=True)
# 1. 倉儲檔案可開啟
con.execute("SELECT 1").fetchone()
# 2. 三層表都有資料
for table in ("raw.company_basic", "staging.company_basic_clean",
"mart.dim_company", "mart.fact_company_change"):
n = con.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
if n == 0:
print(f"FAIL: {table} 是空的")
sys.exit(1)
# 3. 今天的資料有更新
last_date = con.execute("""
SELECT MAX(ingested_date) FROM mart.fact_company_change
""").fetchone()[0]
if last_date is None or last_date < date.today() - timedelta(days=1):
print(f"FAIL: 最後更新日期 {last_date} 距今超過 1 天")
sys.exit(1)
con.close()
print("OK")
except Exception as e:
print(f"FAIL: {e}")
sys.exit(1)
這支 health-check 腳本可以做三件事:驗證 DuckDB 檔案可開啟、驗證三層表都有資料、驗證最近有更新過。當 docker-compose ps 顯示容器 unhealthy 時,可以執行 docker exec pipeline python health_check.py 找出問題根源。實務上我們會建議把這支腳本接到監控系統(如 Prometheus + Grafana),但對小型管線來說,docker-compose 的 healthcheck 已經夠用。
最後,把整個容器化方案的指令彙整成一張表:
# 第一次部署
cp .env.example .env
vi .env # 填入真實 URL
docker-compose up -d --build
# 查看狀態
docker-compose ps
docker-compose logs --tail=100 pipeline
# 進入容器除錯
docker exec -it de-journey-pipeline bash
# 停止服務
docker-compose down
# 強制重建映像並啟動
docker-compose up -d --build --force-recreate
# 清理舊映像
docker image prune -f
這份指令彙整涵蓋了日常維運需要的所有動作。實務上建議寫進專案的 RUNBOOK.md,讓團隊成員都能查到。
替代方案:本機跑 APScheduler(沒有 Docker)
替代方案:本機跑 APScheduler(沒有 Docker)
如果讀者沒有 Docker、但想把管線跑成「每日自動執行」,可以用本機的 APScheduler 與 cron 替代:
# 替代方案 1:用 cron 每天 09:00 跑一次
# 編輯 crontab:
crontab -e
# 加入這一行:
0 9 * * * cd /path/to/de-journey && /usr/bin/env bash -c 'source .venv/bin/activate && python run_pipeline.py >> logs/cron.log 2>&1'
# 替代方案 2:用 systemd 服務(Linux)
# /etc/systemd/system/de-journey.service
[Unit]
Description=DE Journey Daily Pipeline
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/path/to/de-journey
ExecStart=/path/to/de-journey/.venv/bin/python apscheduler_scheduler.py
Restart=on-failure
User=hao
[Install]
WantedBy=multi-user.target
# 啟用:
# sudo systemctl enable --now de-journey.service
兩個替代方案的核心都是「讓作業系統的排程器每天觸發管線」。cron 適合個人筆電、systemd 適合伺服器;Docker 適合雲端或公司內部 VM。讀者可以根據自己的環境選擇最合適的方案。
另一個重要的延伸是「容器化測試」。我們可以在 CI 裡跑 docker build 與 docker run 來驗證映像可以正確啟動:
"""de-journey/tests/test_docker_smoke.py:用 docker 跑一個冒煙測試。"""
import subprocess
def test_docker_image_builds() -> None:
"""確認 docker build 不會失敗。"""
result = subprocess.run(
["docker", "build", "-t", "de-journey:test", "."],
capture_output=True, text=True, timeout=600,
)
assert result.returncode == 0, f"docker build 失敗:{result.stderr}"
def test_docker_image_starts() -> None:
"""確認 docker run 可以啟動並執行入口腳本。"""
subprocess.run(["docker", "rm", "-f", "de-journey-test"], check=False)
result = subprocess.run(
[
"docker", "run", "--rm", "--name", "de-journey-test",
"-e", "DATASET_URL_COMPANY_BASIC=http://invalid",
"-e", "DATASET_URL_COMPANY_CHANGE=http://invalid",
"-e", "RUN_ON_START=true",
"de-journey:test",
],
capture_output=True, text=True, timeout=300,
)
# 因為 URL 是假的,第一階段 ingest 會失敗,但容器應該有啟動
assert "暖機" in result.stdout or "entrypoint" in result.stdout
這兩個測試可以在 GitHub Actions 裡跑(明天 Day 37 會介紹),確保每次改 Dockerfile 或 docker-compose.yml 時,映像仍能正確建立並啟動。注意第二個測試故意讓 ingest 失敗——只要容器有啟動到「跑管線」的階段,就算測試通過。這種「故意失敗」的測試可以驗證容器的錯誤處理邏輯。
最後一個延伸是「多環境部署」。實務上你會希望同一個映像能在 dev、staging、prod 三個環境運作,這可以透過 docker-compose 的 profiles 與 .env.{profile} 達成:
# docker-compose.yml(簡化版,展示 profiles 設計)
services:
pipeline:
image: de-journey:latest
profiles: ["dev", "staging", "prod"]
environment:
ENV: "${ENV:-dev}"
DATASET_URL_COMPANY_BASIC: "${DATASET_URL_COMPANY_BASIC}"
DATASET_URL_COMPANY_CHANGE: "${DATASET_URL_COMPANY_CHANGE}"
volumes:
- ./warehouse:/app/warehouse
- ./data:/app/data
- ./logs:/app/logs
# 啟動 dev 環境
ENV=dev docker-compose --profile dev up -d
# 啟動 prod 環境
ENV=prod docker-compose --profile prod up -d
這個設計讓「同一個映像」+「不同環境變數」對應到不同部署。實務上 dev 環境的 DATASET_URL_COMPANY_BASIC 可能指向 mock server,prod 環境則指向真實的 data.gov.tw URL;兩個環境用同一個映像,差別只在 .env.dev 與 .env.prod 兩個檔案。
常見錯誤與踩雷
常見錯誤與踩雷
錯誤一:映像大小失控,每個層都加新東西。常見症狀:映像 1.5 GB,部署到雲端要 5 分鐘。對應排查方向:用 docker history de-journey:latest 看每一層的大小;通常最大的層是「裝了不必要的套件」或「複製了大量資料」。建議用 python:3.13-slim 而非 full、用 .dockerignore 排除不必要的檔案。
錯誤二:時區設定錯誤,導致排程在錯的時間觸發。常見症狀:明明設定 09:00,排程在 21:00 觸發。對應排查方向:Dockerfile 裡要設定 TZ=Asia/Taipei 並安裝 tzdata;APScheduler 的 timezone="Asia/Taipei" 也要明確指定。容器預設時區是 UTC,不改會差 8 小時。
錯誤三:忘了掛載 volume,容器重啟後資料全部不見。對應排查方向:docker-compose.yml 的 volumes: 是必填;少掛任何一個目錄都會在容器重啟時丟失。
錯誤四:把 .env 推上 Git,洩漏真實 URL 或密碼。對應排查方向:把 .env 加入 .gitignore,只推 .env.example(沒有真實密碼的樣板)。CI/CD 環境用 secret manager 注入正式密碼。
錯誤五:APScheduler 容器被 OOM killer 殺掉。常見症狀:log 顯示 Out of memory: Killed process。對應排查方向:管線如果一次處理 70 萬筆資料、可能會用到 1–2 GB 記憶體;用 docker-compose.yml 的 mem_limit: 2g 限制容器最多用 2 GB。如果還是被殺,就分批處理(每次 10 萬筆)。
效能與實務提醒
容器化管線的效能瓶頸通常在「映像 pull 時間」與「容器啟動時間」。第一次部署到新機器時,pull 映像可能需要 1–3 分鐘;之後因為有 cache 只要 5 秒。容器啟動本身只要 1–2 秒,但管線的 Python 套件 import(特別是 pandas、polars)會額外花 3–5 秒。所以「暖機那次管線跑」會比「已經跑過一次」慢約 5–10 秒。
實務上有兩個重要的提醒。第一,不要在容器裡跑互動式命令(docker exec -it ... bash 進入容器改東西)。容器應該被視為「不可變的」——改設定就重建映像,不要在容器內修改。這保證了「部署到不同機器行為一致」。第二,用 docker-compose.yml 的 profiles 區分環境:開發用 dev profile(容器重啟就清掉資料)、正式用 prod profile(保留資料)。這樣不會不小心把正式資料刪掉。
另一個常見的設計是「管線與儀表板分開容器」。本篇的 docker-compose.yml 只跑了排程器;儀表板可以另外用 streamlit run dashboards/app.py 在另一個容器啟動。兩個容器共享同一個 warehouse/ volume,但職責分離:排程器負責「每天跑管線」、儀表板負責「隨時可被存取」。這個分工讓「儀表板壞了不影響管線」、「管線在跑不影響儀表板」成為可能。
如果想要在容器啟動時印出診斷資訊,可以用 Python 寫一支 startup-info 腳本:
"""de-journey/startup_info.py:印出容器環境的診斷資訊。"""
import os
import sys
import duckdb
import httpx
import polars as pl
print("=== Python 環境 ===")
print(f"Python:{sys.version.split()[0]}")
print(f"工作目錄:{os.getcwd()}")
print(f"環境變數 ENV:{os.environ.get('ENV', '(未設定)')}")
print(f"環境變數 TZ:{os.environ.get('TZ', '(未設定)')}")
print()
print("=== 套件版本 ===")
print(f"duckdb:{duckdb.__version__}")
print(f"polars:{pl.__version__}")
print(f"httpx:{httpx.__version__}")
print()
print("=== 網路連線 ===")
try:
r = httpx.get("https://data.gov.tw/", timeout=10)
print(f"data.gov.tw 連線:HTTP {r.status_code}")
except Exception as e:
print(f"data.gov.tw 連線失敗:{e}")
這支腳本可以在 docker exec pipeline python startup_info.py 呼叫,快速確認容器的 Python 版本、套件版本、網路連線是否正常。當問題發生時,這個輸出就是除錯的第一手資料。把它的輸出貼到 issue 或 Slack 裡,工程師就能立刻看出問題是「套件版本錯」、「網路不通」還是「環境變數沒設」。
小結
今天把管線部署到容器化的排程機器。我們寫了 Dockerfile(python:3.13-slim + uv 安裝套件)、apscheduler_scheduler.py(每天 09:00 跑一次管線)、docker-compose.yml(用 volume 持久化資料、用環境變數注入 secrets)、以及沒有 Docker 時的 cron / systemd 替代方案。重點回顧:第一,容器化解決「環境一致性」與「隔離性」兩個問題;第二,python:3.13-slim + uv 是 2025 年最小的 Python 容器組合;第三,APScheduler 3.11 的 CronTrigger 是標準的 cron 表達式;第四,volumes: 是持久化的關鍵,少掛一個目錄就會丟資料;第五,時區設定要在 Dockerfile 與 APScheduler 兩處都明確指定。
明天 Day 37 會介紹另一條部署路徑:GitHub Actions。這條路徑「不需要自己的機器」、但有限制(每月 2,000 分鐘免費、單次任務最長 6 小時)。對於「每天跑一次、每次 2 分鐘」的管線非常划算。兩條路徑各有利弊,實務上可以同時採用。
結語
今天的重點是「把管線變成無人值守」。容器化讓部署變成「拉映像、設定環境變數、啟動容器」三步,無論是公司內部的主機還是雲端的 VM 都能跑得起來。排程器用 APScheduler 3.11 是輕量級的選擇;如果未來需要更複雜的工作流(DAG、跨任務依賴、視覺化監控),可以升級到 Airflow 3.x(Day 28 已介紹)。
明天,我們會用 GitHub Actions 做另一條部署路徑:把 .github/workflows/pipeline.yml 寫好、把 secrets 設到 GitHub repo、讓 GitHub 每天自動跑我們的管線。GitHub Actions 的限制是「單次 6 小時、每月 2,000 分鐘」,對我們的 2 分鐘管綽綽有餘。
延伸資源
- Docker 官方文件(2025):
https://docs.docker.com/。Dockerfile最佳實踐、multi-stage build、.dockerignore等主題在此涵蓋。 - docker-compose 官方文件(2025):
https://docs.docker.com/compose/。本篇的volumes、environment、healthcheck設定以此文件為準。 - APScheduler 3.11 官方文件(2025):
https://apscheduler.readthedocs.io/。CronTrigger.from_crontab()與BlockingScheduler是本篇的核心 API。 - uv 官方文件(2025):
https://docs.astral.sh/uv/。uv pip install --system是把套件裝到系統 Python 的指令,比pip install快 10–100 倍。 - Python Docker 映像官方頁面(2025):
https://hub.docker.com/_/python。python:3.13-slim是 Python 官方維護的 slim 變體,映像大小約 150 MB。 - 12-Factor App「設定分離」原則:
https://12factor.net/config。本篇用環境變數注入 URL,正是 12-factor 的標準做法。
留言
張貼留言