跳到主要內容

DE Day 36 部署:排程機器與容器化

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 在建立映像時會快取每一層,所以「變更頻率低的層放前面、變更頻率高的層放後面」是基本原則。例如:

  1. 基底映像(python:3.13-slim)放在最前面,因為它幾乎不變。
  2. 系統套件(tzdata、ca-certificates)放第二層,這些也少變。
  3. Python 套件(duckdb、pydantic)放第三層,這層每天可能更新。
  4. 應用程式碼(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 的標準做法。

留言

這個網誌中的熱門文章

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 建構深度學習模型。 開發者與研究人員 :想更深入了...