Skip to content

Commit 4c28525

Browse files
加入CI/CD工作流
1 parent 02f69dc commit 4c28525

5 files changed

Lines changed: 160 additions & 7 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: Deploy to VPS
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
7+
env:
8+
REGISTRY: ghcr.io
9+
IMAGE_NAME: ${{ github.repository }}
10+
11+
jobs:
12+
build_and_deploy:
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: read
16+
packages: write
17+
18+
steps:
19+
- name: Checkout repository
20+
uses: actions/checkout@v4
21+
22+
- name: Set up Python
23+
uses: actions/setup-python@v5
24+
with:
25+
python-version: '3.11'
26+
27+
- name: Basic Syntax Check
28+
run: |
29+
python -m py_compile server.py
30+
python -m py_compile grade_fetcher.py
31+
32+
- name: Log in to the Container registry
33+
uses: docker/login-action@v3
34+
with:
35+
registry: ${{ env.REGISTRY }}
36+
username: ${{ github.actor }}
37+
password: ${{ secrets.GITHUB_TOKEN }}
38+
39+
- name: Extract metadata (tags, labels) for Docker
40+
id: meta
41+
uses: docker/metadata-action@v5
42+
with:
43+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
44+
tags: |
45+
type=raw,value=latest
46+
type=sha,format=long
47+
48+
- name: Build and push Docker image
49+
uses: docker/build-push-action@v5
50+
with:
51+
context: .
52+
push: true
53+
tags: ${{ steps.meta.outputs.tags }}
54+
labels: ${{ steps.meta.outputs.labels }}
55+
56+
- name: Deploy to VPS via SSH
57+
uses: appleboy/ssh-action@master
58+
with:
59+
host: ${{ secrets.VPS_HOST }}
60+
username: ${{ secrets.VPS_USERNAME }}
61+
key: ${{ secrets.VPS_SSH_KEY }}
62+
port: ${{ secrets.VPS_PORT }}
63+
script: |
64+
cd ${{ secrets.VPS_APP_PATH }}
65+
66+
# 拉取最新的 docker-compose.yml
67+
git pull origin main
68+
69+
# 登入 GHCR (避免 Pull 失敗)
70+
echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
71+
72+
# 設定自動更新的 Image 名稱變數並以 Swarm 發布
73+
export GHCR_IMAGE=${{ github.repository }}
74+
docker stack deploy --with-registry-auth -c docker-compose.yml school_grades

DEPLOYMENT_GUIDE.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# 專案無停機部署 (Zero-Downtime Deployment) 與 CI/CD 指南
2+
3+
本專案採用 **GitHub Actions** 進行 CI/CD 流水線配置,並在 Ubuntu VPS 上使用 **Docker Swarm (單節點模式)** 達成無停機滾動更新(Rolling Update)。
4+
5+
---
6+
7+
## 🚀 階段一:VPS 環境初始化設定
8+
9+
為了讓 `start-first` 滾動更新生效,我們必須將 Docker 轉換為 Swarm 模式。您只需要在 VPS 上執行一次:
10+
11+
### 1. 啟用 Docker Swarm
12+
登入您的 Ubuntu VPS 並執行:
13+
```bash
14+
docker swarm init
15+
```
16+
*(如果 VPS 有多張網卡,系統可能會提示您指定 `--advertise-addr`)*
17+
18+
### 2. 環境變數 (.env) 準備
19+
在您的專案目錄(例如 `~/school_grades`)下建立 `.env` 檔案以存放敏感資訊,確保它們**沒有被提交到 Git 儲存庫**
20+
```env
21+
GHCR_IMAGE=您的GitHub帳號/school_grades
22+
SECRET_KEY=您的Flask_Secret_Key
23+
TURNSTILE_SECRET_KEY=您的Cloudflare_Turnstile_Secret
24+
TUNNEL_TOKEN=您的Cloudflare_Tunnel_Token
25+
```
26+
27+
---
28+
29+
## 🔐 階段二:GitHub Secrets 設定
30+
31+
要讓 GitHub Actions 能夠順利拉取並部署您的程式碼,請前往 GitHub 專案的 **Settings -> Secrets and variables -> Actions** 中新增以下 Secrets:
32+
33+
| Secret 變數名稱 | 說明 / 注意事項 |
34+
| --- | --- |
35+
| `VPS_HOST` | VPS 的 IP 位址。 |
36+
| `VPS_PORT` | SSH 的 Port,預設通常是 `22`|
37+
| `VPS_USERNAME` | 登入 VPS 的帳號名稱(如 `root``ubuntu`)。 |
38+
| `VPS_SSH_KEY` | 用來登入 VPS 的 SSH 私鑰(Private Key,通常是 `~/.ssh/id_rsa` 的內容)。 |
39+
| `VPS_APP_PATH` | 專案在 VPS 上的絕對路徑(例如 `/home/ubuntu/school_grades`),以便 Action 進入該目錄執行指令。 |
40+
| `GHCR_PAT` | 個人存取權杖 (Personal Access Token, Classic 即可),須勾選 `read:packages` 權限供 VPS 從 GHCR 拉取映像檔。 |
41+
42+
*(請確保 `VPS_APP_PATH` 目錄內已經有 `git clone` 過您的 repo 並且切換到 `main` 分支)*
43+
44+
---
45+
46+
## 🔄 日常部署與回滾 (Rollback) 流程
47+
48+
每次 Push 至 `main` 分支時,GitHub Actions 會自動執行建置、推送 Image 至 GHCR 並觸發 VPS 更新服務。
49+
50+
### 服務監控與管理
51+
您可以透過以下指令查看服務運行狀態:
52+
- 查看運作中的 Services:`docker service ls`
53+
- 查看 Web App 的詳細狀態與 Healthcheck:`docker service ps school_grades_app`
54+
- 查看服務 Logs:`docker service logs school_grades_app`
55+
56+
### 如何進行回滾 (Rollback)
57+
58+
我們在推播 Image 到 GHCR 時,不僅加上了 `latest` 標籤,還會加上 GitHub 的 **Commit SHA** 標籤。
59+
60+
**1. 自動回滾(健康檢查失敗)**
61+
如果在 CI/CD 更新後,新版的 API 或系統導致 `/health` 端點檢查無法通過 (回傳非 200),Docker Swarm 最多重試 3 次,隨後會觸發 `failure_action: rollback`**自動將系統退回前一個穩定的版本**,全程不會有任何停機斷線。
62+
63+
**2. 手動回滾(業務邏輯瑕疵)**
64+
如果系統部署成功且 Healthcheck 也通過了,但您發現了業務邏輯上的 Bugs 而想要緊急降版,您只需使用特定的 Commit SHA (例如 `5a2b3c4`) 對服務進行強制更新。這同樣會**以無停機的方式滾動更新到舊版本**
65+
```bash
66+
docker service update --image ghcr.io/<您的GitHub帳號>/school_grades:sha-<退回的Commit_SHA> school_grades_app
67+
```
68+
*(請將 `<您的GitHub帳號>``<退回的Commit_SHA>` 替換為實際數值)*

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ FROM python:3.11-slim
22

33
WORKDIR /app
44

5+
# Install system dependencies (curl for healthcheck)
6+
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
7+
58
# Install Python dependencies
69
COPY requirements.txt .
710
RUN pip install --no-cache-dir -r requirements.txt

docker-compose.yml

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
services:
22
app:
3-
build: .
4-
ports:
5-
- "5000:5000"
3+
image: ghcr.io/${GHCR_IMAGE:-alvin000009238/school_grades}:latest
64
volumes:
75
- ./shared_grades:/app/shared_grades
86
environment:
@@ -11,11 +9,17 @@ services:
119
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}
1210
- TZ=Asia/Taipei
1311

14-
restart: unless-stopped
12+
deploy:
13+
restart_policy:
14+
condition: on-failure
15+
update_config:
16+
order: start-first
17+
failure_action: rollback
18+
delay: 10s
1519
healthcheck:
16-
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
17-
interval: 30s
18-
timeout: 10s
20+
test: [ "CMD", "curl", "-f", "http://localhost:5000/health" ]
21+
interval: 10s
22+
timeout: 5s
1923
retries: 3
2024
start_period: 10s
2125

server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,10 @@ def view_shared_page(share_id):
292292
# Just serve the index page, JS will handle the rest based on URL
293293
return send_from_directory('.', 'index.html')
294294

295+
@app.route('/health')
296+
def health_check():
297+
return jsonify({'status': 'ok'}), 200
298+
295299
if __name__ == '__main__':
296300
logger.info("Starting School Grades Server...")
297301
app.run(host='0.0.0.0', port=5000, debug=True)

0 commit comments

Comments
 (0)