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


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

&lt;!--more--&gt;

## 本堂課目標

前四堂課已經學過：

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
- 使用 `env`、`secrets`、`if`、`needs`
- 讓 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 程式碼 -&gt; 自動跑測試 -&gt; 告訴你有沒有通過」

**CD（Continuous Delivery / Deployment，持續交付 / 持續部署）：**

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

完整流程可以想成：

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

### 沒有 CI/CD 的痛苦

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

### 常見 CI/CD 工具

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

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

---

## 5-2 GitHub Actions 基礎與實用語法（約 40 分鐘）

### 前置準備

1. 確認你有 GitHub 帳號
2. 安裝 Git
3. 確認 Git 可以使用：

```powershell
git --version
git config --global user.name &#34;你的名字&#34;
git config --global user.email &#34;你的email&#34;
```

### GitHub Actions 的核心概念

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

```text
Repository
  └── .github/workflows/
        └── cicd.yml
              ├── Event：什麼時候觸發？
              ├── Job：要做哪幾個大任務？
              │     ├── Step 1
              │     ├── Step 2
              │     └── Step 3
              └── Runner：在哪一台機器上執行？
```

| 名詞 | 說明 |
|------|------|
| Workflow | 一份自動化腳本，寫在 YAML 檔案裡 |
| Event | 觸發條件，例如 push、pull request、手動觸發 |
| Job | 一個大任務，例如 test、build、deploy |
| Step | job 裡的一個步驟 |
| Runner | GitHub 提供的虛擬機，負責執行 workflow |

### 第一個 Workflow：Hello GitHub Actions

建立一個 GitHub repository，例如 `docker-cicd-demo`，clone 到本機：

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

建立 workflow 目錄：

```powershell
mkdir -p .github/workflows
```

建立 `.github/workflows/hello.yml`：

```yaml
name: Hello GitHub Actions

on:
  push:
    branches: [ main ]

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

    steps:
      - name: Say hello
        run: echo &#34;Hello GitHub Actions!&#34;

      - name: Show current directory
        run: pwd

      - name: Show system info
        run: uname -a
```

Push 到 GitHub：

```powershell
git add .
git commit -m &#34;add first workflow&#34;
git push
```

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

### Workflow 語法快速拆解

```yaml
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` | 使用別人寫好的 Action | `uses: actions/checkout@v4` |
| `run` | 自己寫 shell 指令 | `run: pytest test_app.py -v` |

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

### 環境變數 env

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

```yaml
name: CI with Environment Variables

on:
  push:
    branches: [ main ]

env:
  IMAGE_NAME: docker-cicd-demo
  PYTHON_VERSION: &#39;3.11&#39;

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

    steps:
      - name: Show env
        run: |
          echo &#34;Image: ${{ env.IMAGE_NAME }}&#34;
          echo &#34;Python: ${{ env.PYTHON_VERSION }}&#34;
          echo &#34;Flask: $FLASK_ENV&#34;
```

| 層級 | 範圍 | 寫在哪裡 |
|------|------|----------|
| 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 最重要的安全規則：

&gt; 密碼和 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_USERNAME` | Docker Hub 帳號 |
| `DOCKERHUB_TOKEN` | Docker Hub Access Token |

在 workflow 裡這樣使用：

```yaml
- 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：

```yaml
- name: Show GitHub context
  run: |
    echo &#34;event: ${{ github.event_name }}&#34;
    echo &#34;ref: ${{ github.ref }}&#34;
    echo &#34;sha: ${{ github.sha }}&#34;
    echo &#34;actor: ${{ github.actor }}&#34;
```

常見欄位：

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

### 條件判斷 if

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

```yaml
steps:
  - name: Only on main branch
    if: github.ref == &#39;refs/heads/main&#39;
    run: echo &#34;This is main branch&#34;

  - name: Only on pull request
    if: github.event_name == &#39;pull_request&#39;
    run: echo &#34;This is a pull request&#34;

  - name: Print logs on failure
    if: failure()
    run: echo &#34;Something went wrong&#34;
```

在 CI/CD 裡常見規則：

- PR：只跑測試
- push 到 main：跑測試、build image、push image
- 失敗時：印出 log，方便除錯

### 多個 Job 的依賴關係 needs

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

如果 `docker` job 必須等 `test` job 通過，就要加 `needs`：

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo &#34;Running tests...&#34;

  docker:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - run: echo &#34;Building image...&#34;
```

執行順序：

```text
test -&gt; docker
```

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

### permissions

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

```yaml
permissions:
  contents: read
```

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

### concurrency

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

```yaml
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. 輸出部署指令

流程長這樣：

```text
git push
  |
  v
test job
  |
  v
docker job
  |
  &#43;-- build image
  &#43;-- run container
  &#43;-- curl /health
  &#43;-- login Docker Hub
  &#43;-- push image
  &#43;-- print deploy command
```

### 課堂節奏建議

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

| Checkpoint | 預估時間 | 目標 |
|------------|----------|------|
| A | 20 分鐘 | 建立 Flask 專案並本機測試 |
| B | 20 分鐘 | 建立 test job，確認 CI 綠燈 |
| C | 25 分鐘 | 加入 Docker build 和 health check，但先不 push |
| D | 25 分鐘 | 加入 Docker Hub login 和 push |

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

### 步驟 1：建立 Flask 專案

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

建議目錄：

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

`app.py`：

```python
from flask import Flask, jsonify

app = Flask(__name__)


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


@app.route(&#34;/health&#34;)
def health():
    return jsonify({&#34;status&#34;: &#34;healthy&#34;})


def add(a, b):
    return a &#43; b


def is_positive(n):
    return n &gt; 0


if __name__ == &#34;__main__&#34;:
    app.run(host=&#34;0.0.0.0&#34;, port=5000)
```

`test_app.py`：

```python
import pytest
from app import app, add, is_positive


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


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


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


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`：

```text
flask
pytest
```

`Dockerfile`：

```dockerfile
FROM python:3.11-slim

WORKDIR /app

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

COPY . .

EXPOSE 5000

CMD [&#34;python&#34;, &#34;app.py&#34;]
```

`.dockerignore`：

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

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

### 步驟 2：本機測試

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

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

預期結果：

```text
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：

```powershell
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` 行為怪怪的，可以改用：

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

### 步驟 3：建立第一版 workflow，只跑測試

建立 `.github/workflows/cicd.yml`：

```yaml
name: Flask CI/CD Pipeline

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

permissions:
  contents: read

env:
  PYTHON_VERSION: &#39;3.11&#39;
  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：

```powershell
git add .
git commit -m &#34;add flask test workflow&#34;
git push
```

到 Actions 頁面確認：

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

### 步驟 4：故意讓測試失敗，練習看 log

把 `app.py` 的 `version` 改掉：

```python
&#34;version&#34;: &#34;2.0.0&#34;
```

但 `test_app.py` 還是期待 `1.0.0`。

Push：

```powershell
git add .
git commit -m &#34;break version test&#34;
git push
```

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

```text
E       AssertionError: assert &#39;2.0.0&#39; == &#39;1.0.0&#39;
```

除錯時照這個順序：

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

修回來：

```python
&#34;version&#34;: &#34;1.0.0&#34;
```

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

### 步驟 5：加入 Docker build，但先不 push

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

```yaml
  docker:
    name: Build Docker image
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name == &#39;push&#39; &amp;&amp; github.ref == &#39;refs/heads/main&#39;

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

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

完整 workflow 會變成：

```yaml
name: Flask CI/CD Pipeline

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

permissions:
  contents: read

env:
  PYTHON_VERSION: &#39;3.11&#39;
  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 == &#39;push&#39; &amp;&amp; github.ref == &#39;refs/heads/main&#39;

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

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

Push 後觀察：

```text
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 後面加：

```yaml
      - 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 &amp;&amp; exit 0
            echo &#34;App is not ready yet...&#34;
            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 --fail` | HTTP 不是 2xx/3xx 時讓 step 失敗 |
| retry loop | app 啟動需要幾秒時，比固定 `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 -&gt; **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`：

```yaml
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: &#39;3.11&#39;
  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(&#39;requirements.txt&#39;) }}
          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 == &#39;push&#39; &amp;&amp; github.ref == &#39;refs/heads/main&#39;

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

      - name: Create image tags
        id: meta
        run: |
          echo &#34;sha_short=$(git rev-parse --short HEAD)&#34; &gt;&gt; $GITHUB_OUTPUT
          echo &#34;image=${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}&#34; &gt;&gt; $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 &amp;&amp; exit 0
            echo &#34;App is not ready yet...&#34;
            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 &#34;Docker image pushed successfully.&#34;
          echo &#34;Image: ${{ steps.meta.outputs.image }}&#34;
          echo &#34;Tags: latest, ${{ steps.meta.outputs.sha_short }}&#34;
          echo &#34;&#34;
          echo &#34;Deploy with:&#34;
          echo &#34;docker pull ${{ steps.meta.outputs.image }}:latest&#34;
          echo &#34;docker run -d -p 5000:5000 ${{ steps.meta.outputs.image }}:latest&#34;
```

### 步驟 9：Push 並觀察完整流程

```powershell
git add .
git commit -m &#34;add complete CI/CD pipeline&#34;
git push
```

到 GitHub Actions 頁面觀察：

```text
test（跑測試）
  |
  v
docker（build &#43; health check &#43; push）
  |
  v
Docker Hub 上出現新的映像檔
```

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

```powershell
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
```

如果看到：

```json
{&#34;status&#34;:&#34;healthy&#34;}
```

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

### 課堂討論：為什麼 health check 要放在 push 前？

如果流程是這樣：

```text
build image -&gt; push image -&gt; run container test
```

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

比較好的流程是：

```text
build image -&gt; run container test -&gt; push image
```

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

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

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

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

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

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

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

---

## 5-4 實用技巧、常見錯誤與練習（約 30 分鐘）

### Badge：在 README 顯示 CI 狀態

在 `README.md` 加上一行，就能顯示 CI 是通過還是失敗：

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

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

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

### 快取依賴加速 CI

每次 CI 都重新 `pip install` 會比較慢，可以加上快取：

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

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

### 多版本測試 Matrix

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

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [&#39;3.10&#39;, &#39;3.11&#39;, &#39;3.12&#39;]

    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 報告：

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

再上傳：

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

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

### 常見錯誤 1：YAML 縮排錯誤

錯誤範例：

```yaml
jobs:
test:
  runs-on: ubuntu-latest
```

正確範例：

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
```

YAML 非常依賴縮排。

建議：

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

### 常見錯誤 2：Secret 名稱打錯

workflow 寫：

```yaml
${{ secrets.DOCKERHUB_TOKEN }}
```

但 GitHub 設的是：

```text
DOCKER_HUB_TOKEN
```

這樣登入會失敗。

排查方式：

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

### 常見錯誤 3：Image 名稱不合法

Docker image 名稱通常要小寫。

錯誤：

```text
MyName/Docker-CICD-Demo
```

建議：

```text
myname/docker-cicd-demo
```

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

### 常見錯誤 4：容器啟動太慢，健康檢查失敗

不要只固定睡很久，改用 retry：

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

### 常見錯誤 5：本機可以跑，CI 不能跑

常見原因：

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

Flask 在容器裡一定要：

```python
app.run(host=&#34;0.0.0.0&#34;, 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 的啟動指令改錯：

```dockerfile
CMD [&#34;python&#34;, &#34;missing.py&#34;]
```

Push 後觀察：

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

修回正確版本：

```dockerfile
CMD [&#34;python&#34;, &#34;app.py&#34;]
```

#### 練習 4：多版本測試

把 `test` job 改成 matrix，讓它同時測：

- Python 3.10
- Python 3.11
- Python 3.12

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

#### 練習 5：驗證 Docker Hub

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

```powershell
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 串成了一個完整的自動化流程。


---

> 作者: luk  
> URL: https://yoru-karu-blog-lalaluk-52581ac5e0cef170a3c8922c19182ecb6f7bd604.gitlab.io/posts/tutorial/docker/docker-session5-cicd-github-actions-practice/  

