Docker 教學 第 5 堂:CI/CD 與 GitHub Actions 實戰

本系列為 18 小時 Docker 基礎教學講義,適合初學者,使用 Windows 電腦。 本篇為第 5 堂課,約 3 小時。

本堂課目標

前四堂課已經學過:

  1. Docker 的基本概念
  2. Image 和 Container 的操作
  3. Docker Compose 管理多個服務
  4. Dockerfile 建立自己的映像檔
  5. 把 image push 到 Docker Hub

第五堂課要把這些能力接到自動化流程:只要把程式碼 push 到 GitHub,就自動測試、自動 build Docker Image、自動檢查容器能不能正常啟動,最後再 push 到 Docker Hub。

這堂課結束後,你應該能做到:

  • 看懂 CI/CD 的基本流程
  • 建立 GitHub Actions workflow
  • 使用 envsecretsifneeds
  • 讓 GitHub Actions 自動跑 pytest
  • 測試通過後自動 build Docker Image
  • 在 CI 裡啟動容器並測 /health
  • 健康檢查通過後 push image 到 Docker Hub
  • 看懂常見的 GitHub Actions 失敗原因

5-1 什麼是 CI/CD?(約 20 分鐘)

先講一個故事

想像你在一間餐廳工作:

  • 傳統做法:廚師做完一道菜,自己端出去,自己收盤子,回來再做下一道。每件事都要手動做。
  • 自動化做法:廚師做完菜放到出餐口,傳送帶自動送到客人桌上,髒盤子自動回收。廚師只需要專心做菜。

CI/CD 就是軟體開發的「傳送帶」:你專心寫程式碼,測試、打包、部署由自動化流程負責。

CI/CD 的定義

CI(Continuous Integration,持續整合):

  • 開發者把程式碼 push 到 GitHub 之後,自動執行測試
  • 確保每次改動都不會弄壞現有功能
  • 典型流程是「push 程式碼 -> 自動跑測試 -> 告訴你有沒有通過」

CD(Continuous Delivery / Deployment,持續交付 / 持續部署):

  • 測試通過後,自動把程式打包成 Docker Image
  • 自動 push 到 Docker Hub 或其他 image registry
  • 後續可以手動或自動部署到伺服器

完整流程可以想成:

開發者 push 程式碼
  |
  v
CI:自動跑測試
  |
  v
測試通過
  |
  v
CD:自動 build Docker Image
  |
  v
啟動容器做健康檢查
  |
  v
push image 到 Docker Hub
  |
  v
部署到伺服器

沒有 CI/CD 的痛苦

手動流程CI/CD 自動化
自己跑測試,常常忘記跑每次 push 自動跑,不會漏
自己打 docker builddocker pushworkflow 自動 build 和 push
本機可以跑,上線卻壞掉每次都在固定環境測試
壞掉的 image 可能被推上去health check 通過才 push
部署一次要很多手動步驟流程可以重複、可觀察、可除錯

常見 CI/CD 工具

工具說明
GitHub ActionsGitHub 內建,本課程使用
GitLab CI/CDGitLab 內建
Jenkins老牌工具,通常需要自己架伺服器
CircleCI第三方 CI/CD 服務

本課程使用 GitHub Actions,因為大部分人都有 GitHub 帳號,而且可以直接和 repository、pull request、secret、Docker Hub 流程整合。


5-2 GitHub Actions 基礎與實用語法(約 40 分鐘)

前置準備

  1. 確認你有 GitHub 帳號
  2. 安裝 Git
  3. 確認 Git 可以使用:
git --version
git config --global user.name "你的名字"
git config --global user.email "你的email"

GitHub Actions 的核心概念

GitHub Actions 的架構可以先記四個詞:

Repository
  └── .github/workflows/
        └── cicd.yml
              ├── Event:什麼時候觸發?
              ├── Job:要做哪幾個大任務?
              │     ├── Step 1
              │     ├── Step 2
              │     └── Step 3
              └── Runner:在哪一台機器上執行?
名詞說明
Workflow一份自動化腳本,寫在 YAML 檔案裡
Event觸發條件,例如 push、pull request、手動觸發
Job一個大任務,例如 test、build、deploy
Stepjob 裡的一個步驟
RunnerGitHub 提供的虛擬機,負責執行 workflow

第一個 Workflow:Hello GitHub Actions

建立一個 GitHub repository,例如 docker-cicd-demo,clone 到本機:

cd C:\docker-lab
git clone https://github.com/你的帳號/docker-cicd-demo.git
cd docker-cicd-demo

建立 workflow 目錄:

mkdir -p .github/workflows

建立 .github/workflows/hello.yml

name: Hello GitHub Actions

on:
  push:
    branches: [ main ]

jobs:
  say-hello:
    runs-on: ubuntu-latest

    steps:
      - name: Say hello
        run: echo "Hello GitHub Actions!"

      - name: Show current directory
        run: pwd

      - name: Show system info
        run: uname -a

Push 到 GitHub:

git add .
git commit -m "add first workflow"
git push

到 repository 的 Actions 分頁,就能看到 GitHub 的 runner 自動執行這份 workflow。

Workflow 語法快速拆解

name: CI Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest test_app.py -v

Step 有兩種常見寫法:

寫法用途範例
uses使用別人寫好的 Actionuses: actions/checkout@v4
run自己寫 shell 指令run: pytest test_app.py -v

actions/checkout@v4 是最常用的 Action,功能是把 repository 裡的程式碼拉到 runner 上。沒有 checkout,runner 上通常不會有你的程式碼。

環境變數 env

Workflow 裡可以用 env 避免重複寫相同設定。

name: CI with Environment Variables

on:
  push:
    branches: [ main ]

env:
  IMAGE_NAME: docker-cicd-demo
  PYTHON_VERSION: '3.11'

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      FLASK_ENV: testing

    steps:
      - name: Show env
        run: |
          echo "Image: ${{ env.IMAGE_NAME }}"
          echo "Python: ${{ env.PYTHON_VERSION }}"
          echo "Flask: $FLASK_ENV"          
層級範圍寫在哪裡
Workflow 層所有 job 都能用最外層的 env:
Job 層只有這個 job 能用job 底下的 env:
Step 層只有這個 step 能用step 底下的 env:

適合放在 env 的東西:

  • image 名稱
  • Python / Node 版本
  • port
  • 測試環境名稱
  • 重複出現、但不是機密的設定

不適合放在 env 的東西:

  • 密碼
  • API key
  • access token
  • database password

這些機密資料要放在 secrets

Secrets

CI/CD 最重要的安全規則:

密碼和 token 不要寫進 Git repository。

如果把 Docker Hub token 寫在 .github/workflows/cicd.yml,等於把推送 image 的權限放進程式碼。正確做法是放到 GitHub Secrets。

設定位置:

  1. 打開 GitHub repository
  2. Settings
  3. Secrets and variables
  4. Actions
  5. New repository secret

本課程會用到兩個 secret:

Secret 名稱用途
DOCKERHUB_USERNAMEDocker Hub 帳號
DOCKERHUB_TOKENDocker Hub Access Token

在 workflow 裡這樣使用:

- name: Login to Docker Hub
  uses: docker/login-action@v3
  with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_TOKEN }}

GitHub 通常會把 secret 在 log 中遮成 ***,但不要故意把 secret 印出來。遮罩不是安全設計的全部,只是最後一道防線。

GitHub Context

GitHub Actions 有很多內建資訊可以使用,稱為 context。最常用的是 github context:

- name: Show GitHub context
  run: |
    echo "event: ${{ github.event_name }}"
    echo "ref: ${{ github.ref }}"
    echo "sha: ${{ github.sha }}"
    echo "actor: ${{ github.actor }}"    

常見欄位:

寫法說明
${{ github.event_name }}觸發事件,例如 pushpull_request
${{ github.ref }}目前 ref,例如 refs/heads/main
${{ github.sha }}目前 commit SHA
${{ github.actor }}觸發 workflow 的使用者
${{ github.repository }}repository 名稱,例如 user/project

條件判斷 if

可以根據條件決定某個 step 或 job 要不要執行。

steps:
  - name: Only on main branch
    if: github.ref == 'refs/heads/main'
    run: echo "This is main branch"

  - name: Only on pull request
    if: github.event_name == 'pull_request'
    run: echo "This is a pull request"

  - name: Print logs on failure
    if: failure()
    run: echo "Something went wrong"

在 CI/CD 裡常見規則:

  • PR:只跑測試
  • push 到 main:跑測試、build image、push image
  • 失敗時:印出 log,方便除錯

多個 Job 的依賴關係 needs

預設情況下,多個 job 會平行執行。

如果 docker job 必須等 test job 通過,就要加 needs

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Running tests..."

  docker:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - run: echo "Building image..."

執行順序:

test -> docker

如果 test 失敗,docker 不會跑。這可以避免測試沒過的程式碼被 build 成 image。

permissions

Workflow 預設會拿到一個 GITHUB_TOKEN。比較好的做法是明確寫出需要的權限:

permissions:
  contents: read

如果只是 checkout 程式碼、跑測試、build image,通常 contents: read 就夠了。

concurrency

如果連續 push 很多次,GitHub Actions 可能同時排很多 workflow。對部署或 push image 來說,通常只需要最新的一次。

concurrency:
  group: cicd-${{ github.ref }}
  cancel-in-progress: true

意思是:同一個分支如果已經有 workflow 在跑,新的 workflow 來了就取消舊的。


5-3 實作:完整 Flask CI/CD Pipeline(約 1 小時 30 分鐘)

目標

這一節要建立一個完整流程:

  1. Push 程式碼到 GitHub
  2. 自動跑 Flask 測試
  3. 測試通過後 build Docker Image
  4. 在 CI 裡把容器跑起來做健康檢查
  5. 健康檢查通過後 push 到 Docker Hub
  6. latest 和 commit SHA 產生兩個 tag
  7. 輸出部署指令

流程長這樣:

git push
  |
  v
test job
  |
  v
docker job
  |
  +-- build image
  +-- run container
  +-- curl /health
  +-- login Docker Hub
  +-- push image
  +-- print deploy command

課堂節奏建議

這個實作不要一次貼完整 workflow。建議拆成四個 checkpoint:

Checkpoint預估時間目標
A20 分鐘建立 Flask 專案並本機測試
B20 分鐘建立 test job,確認 CI 綠燈
C25 分鐘加入 Docker build 和 health check,但先不 push
D25 分鐘加入 Docker Hub login 和 push

每個 checkpoint 都讓學生 push 一次、看一次 Actions log。

步驟 1:建立 Flask 專案

建立新的專案資料夾,或使用剛才的 docker-cicd-demo

建議目錄:

docker-cicd-demo/
  ├── .github/
  │   └── workflows/
  │       └── cicd.yml
  ├── app.py
  ├── test_app.py
  ├── requirements.txt
  ├── Dockerfile
  ├── .dockerignore
  └── README.md

app.py

from flask import Flask, jsonify

app = Flask(__name__)


@app.route("/")
def hello():
    return jsonify({
        "message": "Hello from CI/CD!",
        "version": "1.0.0"
    })


@app.route("/health")
def health():
    return jsonify({"status": "healthy"})


def add(a, b):
    return a + b


def is_positive(n):
    return n > 0


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

test_app.py

import pytest
from app import app, add, is_positive


@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client


def test_hello(client):
    response = client.get("/")
    assert response.status_code == 200
    data = response.get_json()
    assert "message" in data
    assert data["version"] == "1.0.0"


def test_health(client):
    response = client.get("/health")
    assert response.status_code == 200
    data = response.get_json()
    assert data["status"] == "healthy"


def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0


def test_is_positive():
    assert is_positive(1) is True
    assert is_positive(-1) is False
    assert is_positive(0) is False

requirements.txt

flask
pytest

Dockerfile

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]

.dockerignore

.git
.github
__pycache__
*.pyc
venv/
.venv/
.env
*.md

這裡不要忽略 test_*.py,因為我們等一下會在 Docker build 前跑 pytest。真正 production image 是否要包含測試檔,要看專案策略。初學階段先保持簡單。

步驟 2:本機測試

先在本機確認 Flask 專案可以測試:

pip install -r requirements.txt
pytest test_app.py -v

預期結果:

test_app.py::test_hello PASSED
test_app.py::test_health PASSED
test_app.py::test_add PASSED
test_app.py::test_is_positive PASSED

再測 Docker Image:

docker build -t docker-cicd-demo:local .
docker run -d --name cicd-local -p 5000:5000 docker-cicd-demo:local
curl http://localhost:5000/
curl http://localhost:5000/health
docker rm -f cicd-local

如果 Windows PowerShell 的 curl 行為怪怪的,可以改用:

Invoke-WebRequest http://localhost:5000/health

步驟 3:建立第一版 workflow,只跑測試

建立 .github/workflows/cicd.yml

name: Flask CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

permissions:
  contents: read

env:
  PYTHON_VERSION: '3.11'
  IMAGE_NAME: docker-cicd-demo

jobs:
  test:
    name: Run tests
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run pytest
        run: pytest test_app.py -v

Push 到 GitHub:

git add .
git commit -m "add flask test workflow"
git push

到 Actions 頁面確認:

  • workflow 有觸發
  • Run tests job 成功
  • pytest log 看得到 4 個測試

步驟 4:故意讓測試失敗,練習看 log

app.pyversion 改掉:

"version": "2.0.0"

test_app.py 還是期待 1.0.0

Push:

git add .
git commit -m "break version test"
git push

到 Actions 頁面,你應該會看到紅色失敗。點進 log,找到類似訊息:

E       AssertionError: assert '2.0.0' == '1.0.0'

除錯時照這個順序:

  1. 失敗的是哪個 job?
  2. 失敗的是哪個 step?
  3. 第一個真正的錯誤訊息是什麼?
  4. 要修程式,還是要修測試?

修回來:

"version": "1.0.0"

再 push 一次,確認 CI 回到綠色。

步驟 5:加入 Docker build,但先不 push

接著加入第二個 job。先只 build,不登入 Docker Hub、不 push。

  docker:
    name: Build Docker image
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build image locally
        run: docker build -t ${{ env.IMAGE_NAME }}:ci .

完整 workflow 會變成:

name: Flask CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

permissions:
  contents: read

env:
  PYTHON_VERSION: '3.11'
  IMAGE_NAME: docker-cicd-demo

jobs:
  test:
    name: Run tests
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run pytest
        run: pytest test_app.py -v

  docker:
    name: Build Docker image
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build image locally
        run: docker build -t ${{ env.IMAGE_NAME }}:ci .

Push 後觀察:

Run tests
  |
  v
Build Docker image

這個 checkpoint 的教學重點:

  • needs: test 讓 docker job 等測試通過
  • if 讓 PR 不會 build image
  • 先 build local image,確認 Dockerfile 沒問題

步驟 6:在 CI 裡啟動容器做健康檢查

Image build 成功不代表服務真的能跑。

常見問題:

  • Dockerfile build 成功,但啟動時找不到檔案
  • Flask port 設錯
  • app 啟動後立刻 crash
  • /health endpoint 壞掉

所以 build 後要把容器真的跑起來測。

docker job 後面加:

      - name: Run container
        run: docker run -d --name app -p 5000:5000 ${{ env.IMAGE_NAME }}:ci

      - name: Check health endpoint
        run: |
          for i in 1 2 3 4 5; do
            curl --fail http://localhost:5000/health && exit 0
            echo "App is not ready yet..."
            sleep 3
          done
          exit 1          

      - name: Show container logs on failure
        if: failure()
        run: docker logs app

      - name: Stop container
        if: always()
        run: docker rm -f app

幾個重點:

寫法用途
curl --failHTTP 不是 2xx/3xx 時讓 step 失敗
retry loopapp 啟動需要幾秒時,比固定 sleep 更穩
if: failure()只有前面失敗時才印 log
if: always()不管成功失敗都清掉容器

這個設計比「只 build image」更可靠。

步驟 7:設定 Docker Hub Access Token

到 Docker Hub 建立 Access Token:

  1. 登入 Docker Hub
  2. 點右上角帳號
  3. Account settings
  4. Security
  5. New access token
  6. 輸入名稱,例如 github-actions-docker-course
  7. 建立後複製 token

接著到 GitHub repository 設定 secrets:

  1. Repository -> Settings
  2. Secrets and variables
  3. Actions
  4. New repository secret
  5. 新增:
    • DOCKERHUB_USERNAME
    • DOCKERHUB_TOKEN

Docker Hub token 只會顯示一次。如果忘記複製,就刪掉重建。

步驟 8:完整 workflow:build、health check、push

.github/workflows/cicd.yml

name: Flask CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

permissions:
  contents: read

concurrency:
  group: cicd-${{ github.ref }}
  cancel-in-progress: true

env:
  PYTHON_VERSION: '3.11'
  IMAGE_NAME: docker-cicd-demo

jobs:
  test:
    name: Run tests
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Cache pip packages
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-            

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run pytest
        run: pytest test_app.py -v

  docker:
    name: Build and push Docker image
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Create image tags
        id: meta
        run: |
          echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
          echo "image=${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}" >> $GITHUB_OUTPUT          

      - name: Build image locally
        run: |
          docker build \
            -t ${{ steps.meta.outputs.image }}:latest \
            -t ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha_short }} \
            .          

      - name: Run container
        run: docker run -d --name app -p 5000:5000 ${{ steps.meta.outputs.image }}:latest

      - name: Check health endpoint
        run: |
          for i in 1 2 3 4 5; do
            curl --fail http://localhost:5000/health && exit 0
            echo "App is not ready yet..."
            sleep 3
          done
          exit 1          

      - name: Show container logs on failure
        if: failure()
        run: docker logs app

      - name: Stop container
        if: always()
        run: docker rm -f app

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Push image
        run: |
          docker push ${{ steps.meta.outputs.image }}:latest
          docker push ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha_short }}          

      - name: Print deployment commands
        run: |
          echo "Docker image pushed successfully."
          echo "Image: ${{ steps.meta.outputs.image }}"
          echo "Tags: latest, ${{ steps.meta.outputs.sha_short }}"
          echo ""
          echo "Deploy with:"
          echo "docker pull ${{ steps.meta.outputs.image }}:latest"
          echo "docker run -d -p 5000:5000 ${{ steps.meta.outputs.image }}:latest"          

步驟 9:Push 並觀察完整流程

git add .
git commit -m "add complete CI/CD pipeline"
git push

到 GitHub Actions 頁面觀察:

test(跑測試)
  |
  v
docker(build + health check + push)
  |
  v
Docker Hub 上出現新的映像檔

步驟 10:驗證 Docker Hub 上的映像檔

docker pull 你的帳號/docker-cicd-demo:latest

docker run -d --name cicd-test -p 5000:5000 你的帳號/docker-cicd-demo:latest

curl http://localhost:5000
curl http://localhost:5000/health

docker rm -f cicd-test

如果看到:

{"status":"healthy"}

表示這個 image 從 build、push、pull、run 都成功了。

課堂討論:為什麼 health check 要放在 push 前?

如果流程是這樣:

build image -> push image -> run container test

那就有機會把壞掉的 image 推到 Docker Hub。

比較好的流程是:

build image -> run container test -> push image

先確認 image 能跑,再推到遠端 registry。

為什麼要同時使用 latest 和 commit SHA?

latest 很方便,但它不是固定版本。今天的 latest 和明天的 latest 可能不是同一個 image。

Tag優點缺點
latest好記、適合快速測試不知道精確版本
commit SHA每次 push 都有固定版本比較不好手打

實務上常見做法是兩個都推:

yourname/docker-cicd-demo:latest
yourname/docker-cicd-demo:a1b2c3d

要快速測試可以用 latest。要回滾或追查問題時,用 commit SHA tag 會清楚很多。


5-4 實用技巧、常見錯誤與練習(約 30 分鐘)

Badge:在 README 顯示 CI 狀態

README.md 加上一行,就能顯示 CI 是通過還是失敗:

![CI](https://github.com/你的帳號/docker-cicd-demo/actions/workflows/cicd.yml/badge.svg)

效果是別人一進 repository 就能看到目前 CI 狀態。

如果 repo 名稱或 workflow 檔名不同,要記得修改 URL。

快取依賴加速 CI

每次 CI 都重新 pip install 會比較慢,可以加上快取:

- name: Cache pip packages
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-      

第一次跑會正常安裝,之後只要 requirements.txt 沒變,就會用快取。

多版本測試 Matrix

想同時在多個 Python 版本上測試,可以使用 matrix:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.10', '3.11', '3.12']

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install -r requirements.txt
      - run: pytest test_app.py -v

這會同時跑 3 個 job:

  • Python 3.10
  • Python 3.11
  • Python 3.12

如果你的專案是套件或 library,matrix 很有用。如果你的專案是固定部署到某個 Python 版本的 Web app,就不一定需要一開始就做 matrix。

上傳測試報告 Artifact

有時候 CI 產生的檔案想下載,例如:

  • 測試報告
  • coverage 報告
  • build log
  • 打包後的檔案

可以使用 artifact。

先讓 pytest 輸出 junit 報告:

- name: Run pytest
  run: pytest test_app.py -v --junitxml=test-results.xml

再上傳:

- name: Upload test report
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: test-results
    path: test-results.xml

if: always() 表示測試失敗時也要上傳報告。

常見錯誤 1:YAML 縮排錯誤

錯誤範例:

jobs:
test:
  runs-on: ubuntu-latest

正確範例:

jobs:
  test:
    runs-on: ubuntu-latest

YAML 非常依賴縮排。

建議:

  • 一律用空白,不要用 Tab
  • 同一層縮排保持一致
  • VS Code 安裝 YAML extension

常見錯誤 2:Secret 名稱打錯

workflow 寫:

${{ secrets.DOCKERHUB_TOKEN }}

但 GitHub 設的是:

DOCKER_HUB_TOKEN

這樣登入會失敗。

排查方式:

  1. 檢查 GitHub Secrets 名稱
  2. 檢查 workflow 裡的拼字
  3. 確認 token 沒過期
  4. 確認 Docker Hub 帳號名稱正確

常見錯誤 3:Image 名稱不合法

Docker image 名稱通常要小寫。

錯誤:

MyName/Docker-CICD-Demo

建議:

myname/docker-cicd-demo

如果你的 Docker Hub 帳號有大寫,實務上建議在 workflow 裡仍然用小寫 namespace。

常見錯誤 4:容器啟動太慢,健康檢查失敗

不要只固定睡很久,改用 retry:

- name: Check health endpoint
  run: |
    for i in 1 2 3 4 5; do
      curl --fail http://localhost:5000/health && exit 0
      echo "App is not ready yet..."
      sleep 3
    done
    exit 1    

常見錯誤 5:本機可以跑,CI 不能跑

常見原因:

現象可能原因
找不到檔案檔案沒有 commit
ModuleNotFoundErrorrequirements.txt 漏套件
測試路徑錯誤workflow 的工作目錄不對
Docker build 找不到 DockerfileDockerfile 名稱或位置錯
curl 連不上app 沒有 listen 0.0.0.0

Flask 在容器裡一定要:

app.run(host="0.0.0.0", port=5000)

如果寫 127.0.0.1,容器外可能連不到。

除錯 Checklist

看到 GitHub Actions 失敗時,照這個順序查:

  1. 失敗的是哪個 job?
  2. 失敗的是哪個 step?
  3. 第一個 error message 是什麼?
  4. 是測試失敗、安裝失敗、Docker build 失敗,還是 Docker login 失敗?
  5. 本機能不能重現?
  6. 是程式問題、設定問題,還是 secret/token 問題?

課堂練習

練習 1:修改 workflow 觸發條件

修改 hello.yml,讓它在建立 Pull Request 時也會觸發。

練習 2:加上 README Badge

README.md 加上 CI badge,確認 repository 首頁看得到目前 workflow 狀態。

練習 3:讓 CI 抓到壞掉的 Dockerfile

故意把 Dockerfile 的啟動指令改錯:

CMD ["python", "missing.py"]

Push 後觀察:

  • Docker build 會不會成功?
  • 容器健康檢查會不會失敗?
  • docker logs app 印出什麼?

修回正確版本:

CMD ["python", "app.py"]

練習 4:多版本測試

test job 改成 matrix,讓它同時測:

  • Python 3.10
  • Python 3.11
  • Python 3.12

觀察 Actions 頁面會出現幾個 job。

練習 5:驗證 Docker Hub

到 Docker Hub 確認你的映像檔有自動上傳,試著在本機 pull 下來跑:

docker pull 你的帳號/docker-cicd-demo:latest
docker run -d --name final-test -p 5000:5000 你的帳號/docker-cicd-demo:latest
curl http://localhost:5000/health
docker rm -f final-test

下課前檢查

確認每個人至少完成:

  • GitHub Actions 有一個綠色的 CI/CD workflow
  • Docker Hub 上看得到自己 push 的 image
  • 本機可以 docker pull 並跑起來
  • README 有 CI badge
  • 知道 GitHub Secrets 在哪裡設定
  • 看得懂失敗 log 的第一個 error message

如果這些都完成,第五堂課就把 Dockerfile、Docker Hub、GitHub Actions 串成了一個完整的自動化流程。

0%