學習目標
- 看懂正式環境的完整流程:掃描 → 建映像 → 推 Harbor → 映像掃描與 SBOM → cosign 簽章 → 更新 GitOps repo → Argo CD 同步。
- 知道為什麼 CI 不直接
kubectl apply,而是改 Git 讓 Argo CD 部署。 - 會用
include: project共用別的 repo 的流水線,並覆寫其中一個 job。 - 會驗證部署:Pod、HTTPRoute、Argo CD、
curl --resolve。
這一關部署的就是課程網站本身:https://cicd-tutorial.yc-group.org。
架構
push main(受保護分支)
│
├─ k8s-scan(k8s-ci runner,沒有任何密鑰)
│ gitleaks 機密掃描、Trivy 相依套件、Semgrep 程式寫法
├─ site-check:把網站產生一次並檢查
│
├─ k8s-build(k8s-release runner) BuildKit 無 root 建置 → harbor/sites/cicd-tutorial:<commit>
├─ k8s-image-scan Trivy 掃映像(有修補版的 CRITICAL 就擋)+ SBOM
├─ k8s-sign cosign 簽章,傳統 .sig 標籤格式
└─ k8s-deploy 改 homelab-k8s 的 apps/cicd-tutorial/values.yaml → push
│
Argo CD(約 3 分鐘內偵測)同步 site-cicd-tutorial
│
Kyverno 驗章 → Pod 啟動 → HTTPRoute 接上共用 Gateway → Envoy 對外 443| 元件 | 角色 |
|---|---|
k8s-ci runner | 跑掃描。分支、MR 也會跑,所以這裡沒有任何密鑰 |
k8s-release runner | 只接受保護分支的 job,Harbor 帳密、cosign 私鑰、GitOps 推送金鑰只掛在這裡 |
| Harbor | 私有映像倉庫,課程網站在 sites/cicd-tutorial |
| cosign+Kyverno | 簽章與准入驗章,沒簽章的映像進不了 site-* 命名空間 |
| Argo CD | 以 homelab-k8s repo 為準,自動同步、自我修復 |
| Envoy Gateway | 共用 Gateway envoy-gateway-system/homelab,每站一條獨立的 HTTPRoute |
| cert-manager | 用 Let's Encrypt HTTP-01 簽發與續期憑證 |
為什麼走 GitOps
- Git 就是正式環境的樣子:要知道線上跑哪一版,看
values.yaml就好。 - **回復就是
git revert**:Argo CD 會把叢集改回去,不需要登入主機。 - CI 不需要叢集寫入權限:CI 只能讀部署結果(等 rollout),改版一律經過 Git。
- 手動改的會被拉回來:Argo CD 開了 selfHeal,在叢集上手改的設定會被還原。
本關的檔案
| 檔案 | 用途 |
|---|---|
.gitlab-ci.yml | include homelab-k8s 的共用流水線,設定 SITE、IMAGE、DOCKERFILE |
Dockerfile | 兩階段:Python 產生網頁 → nginx-unprivileged 只放靜態檔 |
nginx/default.conf | 容器內的 nginx,只聽 8080、只送靜態檔(反向代理是 Envoy 的工作) |
nginx/security-headers.conf | CSP、nosniff 等安全標頭,每個 location 都 include |
site/build.py | 把每一關的 README 轉成網頁,只用標準函式庫 |
site/check_site.py | 檢查網頁與品牌資產是否齊全、沒有會被 CSP 擋掉的 inline 腳本 |
範例解說:共用流水線
yaml
include:
- project: fjw08199999/homelab-k8s
ref: main
file: ci/site.gitlab-ci.yml
variables:
SITE: cicd-tutorial
IMAGE: cicd-tutorial
DOCKERFILE: lv10-k8s-deploy/Dockerfile
BUILD_CONTEXT: .
k8s-build:
needs: [site-check, k8s-secret-scan, k8s-dependency-scan, k8s-sast]include: project拿別的 repo 的檔案,所有網站共用同一份流水線,改一次全部生效。- 在自己的檔案再寫一次
k8s-build,GitLab 會把兩份合併,只覆寫needs。這裡讓建置只等網站檢查與三項掃描,不用等 Lv.1~Lv.9 的教材子 pipeline。 - 映像標籤是
$CI_COMMIT_SHORT_SHA,不用latest:每一版都能追溯,回復就是換回舊標籤。
homelab-k8s 這邊要做的事(一次性)
apps/cicd-tutorial/values.yaml:映像、資源限制、/tmp暫存、網域、開route.enabled、maxBodySize: 1Mi(純靜態站)、Harbor 拉映像的密鑰(OpenBaosites/cicd-tutorial/pull)。argocd/charts/envoy-gateway/values-homelab.yaml:hosts加cicd-tutorial.yc-group.org(產生 HTTPS listener 與憑證),allowedNamespaces加site-cicd-tutorial。- 在主機跑
ansible-playbook playbooks/openbao-sites.yml -e sites=cicd-tutorial,建立 OpenBao 的站台角色與拉映像密鑰。 - 專案啟用
k8s-ci、k8s-release兩個 runner,main必須是受保護分支。 - DNS:
cicd-tutorial.yc-group.org的 A 紀錄指到主機的公網 IP,cert-manager 才能用 HTTP-01 簽憑證。**第 2 步的hosts一定要等 DNS 生效後才加**:沒有 DNS 時憑證簽不下來,listener 找不到 Secret,整個共用 Gateway 會變成 Degraded,Argo CD 同步一直重試。本課程第一次上線就踩到,先拿掉網域,DNS 生效後(2026-11-09 17:37)再加回,憑證約 20 秒簽好。
ApplicationSet 會為 apps/ 底下每個資料夾自動產生一個 Argo CD 應用(site-cicd-tutorial),namespace 強制 Pod Security restricted,預設拒絕所有網路,只放行 Envoy 進來。
驗證
sh
# Pod 與映像
kubectl -n site-cicd-tutorial get pods -o wide
kubectl -n site-cicd-tutorial get deploy cicd-tutorial-web -o jsonpath='{.spec.template.spec.containers[0].image}'
# 路由:Accepted、ResolvedRefs 都要是 True
kubectl -n site-cicd-tutorial get httproute cicd-tutorial -o jsonpath='{.status.parents[0].conditions}'
# Argo CD
kubectl -n argocd get application site-cicd-tutorial -o jsonpath='{.status.sync.status} {.status.health.status}'
# 不靠 DNS,直接打主機
curl --resolve cicd-tutorial.yc-group.org:443:10.10.10.50 -I https://cicd-tutorial.yc-group.org/回復
sh
# 在 homelab-k8s 找到 CI 的部署 commit(訊息是「cicd-tutorial 部署 <版本>」)
git revert <那個 commit>
git pushArgo CD 約 3 分鐘內把舊版換回來。舊映像在 Harbor 上都有簽章,Kyverno 會放行。
常見錯誤
- **Pod 起不來,事件寫
no signatures found**:映像沒簽章,或用了新的 bundle 格式。共用流水線用--new-bundle-format=false產生傳統.sig。 - **HTTPRoute
Accepted=False、NotAllowedByListeners**:namespace 沒加進allowedNamespaces。 - 憑證一直不 Ready:DNS 還沒指到主機,Let's Encrypt 連不到 HTTP-01 驗證網址。
- **HTTPRoute
Accepted=False、NoMatchingParent**:網域還沒加進 Gateway 的hosts,沒有對應的 listener。DNS 建好前這是預期狀態。 - **Pod 第一次
ErrImagePull**:namespace 剛建立時,拉映像的 Secret 由 External Secrets 同步,比 Pod 晚幾秒出現;kubelet 重試後就會成功。 - 15 秒就 504:路由沒設 timeout。共用 chart 預設已經寫好,不要拿掉。
- 用了 Docker Hub 映像直接部署:Kyverno 會拒絕,映像一律從 Harbor 拉。
結業
恭喜完成十關!回到課程地圖,把卡片拖進「已完成」吧。
範例檔案
資料夾 lv10-k8s-deploy/ 的全部檔案。.gitlab-ci.yml 可以直接複製到自己的專案根目錄執行。
lv10-k8s-deploy/.gitlab-ci.yml
lv10-k8s-deploy/.gitlab-ci.yml
# Lv.10 實機部署:建置映像 → 推上 Harbor → 掃描與 cosign 簽章 → 更新 GitOps repo → Argo CD 上線
# 這個檔案由根目錄的 .gitlab-ci.yml include,部署的是課程網站 https://cicd-tutorial.yc-group.org
# 共用流水線在 homelab-k8s 的 ci/site.gitlab-ci.yml,每個網站都用同一份,說明見 homelab-k8s 的 docs/CICD.md
include:
- project: fjw08199999/homelab-k8s
ref: main
file: ci/site.gitlab-ci.yml
variables:
SITE: cicd-tutorial # homelab-k8s 的 apps/cicd-tutorial/values.yaml、namespace site-cicd-tutorial
IMAGE: cicd-tutorial # Harbor 的 sites/cicd-tutorial
DOCKERFILE: lv10-k8s-deploy/Dockerfile # 映像要包含每一關的教材,所以 context 是根目錄
BUILD_CONTEXT: .
# 先在 CI 裡把網站建一次並檢查,壞掉就不會建映像
site-check:
stage: site-check
image: python:3.13-alpine
tags: [k8s-ci]
needs: []
script:
- python3 lv10-k8s-deploy/site/build.py --out /tmp/site
- python3 lv10-k8s-deploy/site/check_site.py /tmp/site
# 共用流水線的 k8s-build 原本要等前面所有階段;改成只等網站檢查與三項掃描,教材子 pipeline 失敗不會擋住網站上線
k8s-build:
needs: [site-check, k8s-secret-scan, k8s-dependency-scan, k8s-sast]lv10-k8s-deploy/Dockerfile
lv10-k8s-deploy/Dockerfile
# Lv.10 課程網站映像:第一階段用 Python 把各關 README 轉成網頁,第二階段只留 nginx 與靜態檔
FROM python:3.13-alpine AS build
ARG REVISION=local
WORKDIR /src
COPY . .
RUN REVISION="$REVISION" python3 lv10-k8s-deploy/site/build.py --out /out \
&& python3 lv10-k8s-deploy/site/check_site.py /out
# 執行階段:非 root 的 nginx,只提供靜態檔(反向代理由 Envoy Gateway 負責)
FROM nginxinc/nginx-unprivileged:1.31.7-alpine3.24-slim
COPY lv10-k8s-deploy/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY lv10-k8s-deploy/nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf
COPY --from=build /out /usr/share/nginx/html
USER 101lv10-k8s-deploy/nginx/default.conf
lv10-k8s-deploy/nginx/default.conf
# 課程網站:純靜態。TLS、HSTS、壓縮由 Envoy Gateway 處理,這裡只聽 8080
absolute_redirect off;
server {
listen 8080;
server_tokens off;
root /usr/share/nginx/html;
index index.html;
charset utf-8;
error_page 404 /404.html;
location = /404.html { internal; add_header Cache-Control no-cache always; include /etc/nginx/snippets/security-headers.conf; }
location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; }
location = /robots.txt { default_type text/plain; add_header Cache-Control "public, max-age=3600" always; include /etc/nginx/snippets/security-headers.conf; }
location = /sitemap.xml { default_type application/xml; add_header Cache-Control "public, max-age=3600" always; include /etc/nginx/snippets/security-headers.conf; }
# readme.html 是獨立文件,樣式與腳本寫在檔案裡,只有這一頁放寬 inline
location = /readme.html {
add_header Cache-Control no-cache always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'self' https://*.yc-group.org" always;
}
location /assets/ { try_files $uri =404; add_header Cache-Control "public, max-age=604800" always; include /etc/nginx/snippets/security-headers.conf; }
location / { try_files $uri $uri/ =404; add_header Cache-Control no-cache always; include /etc/nginx/snippets/security-headers.conf; }
}lv10-k8s-deploy/nginx/security-headers.conf
lv10-k8s-deploy/nginx/security-headers.conf
# 每個 location 都要重新 include:nginx 的 add_header 只要 location 裡有寫,就不會繼承 server 層的設定
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'self' https://*.yc-group.org" always;lv10-k8s-deploy/site/build.py
lv10-k8s-deploy/site/build.py
"""課程網站產生器:把每一關的 README.md 與範例檔轉成靜態網頁(只用 Python 標準函式庫)。
用法:python3 lv10-k8s-deploy/site/build.py --out <輸出目錄>
映像建置(Dockerfile 第一階段)與 CI 的 site-check 都跑這支程式,確保兩邊產出相同。
"""
import argparse
import html
import json
import os
from pathlib import Path
import re
import shutil
ROOT = Path(__file__).resolve().parents[2]
STATIC = Path(__file__).resolve().parent / 'static'
SITE_URL = 'https://cicd-tutorial.yc-group.org'
SITE_NAME = 'GitLab CI/CD 十關'
SITE_DESC = '從第一條 pipeline 到 k8s 實機部署:stages、artifacts、cache、variables、rules、needs、services、environments,最後經 Harbor、cosign、Argo CD 上線。'
TYPE_CLASS = {'基礎': 'basic', '進階': 'advanced', '實機': 'live'}
# 範例檔:依序顯示,README 已經另外轉成網頁
SHOW_SUFFIX = {'.yml', '.yaml', '.sh', '.py', '.sql', '.txt', '.md', '.conf'}
SHOW_NAMES = {'Dockerfile'}
def esc(text):
return html.escape(text, quote=True)
# ---------- Markdown(只支援本課程用到的語法) ----------
def inline(text):
parts = re.split(r'(`[^`]+`)', text)
out = []
for part in parts:
if part.startswith('`') and part.endswith('`') and len(part) > 1:
out.append(f'<code>{esc(part[1:-1])}</code>')
continue
s = esc(part)
s = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', s)
# 自動連結 <https://...>(跳脫後是 <…>)
s = re.sub(r'<(https://[^\s&]+)>', r'<a href="\1" rel="noopener" target="_blank">\1</a>', s)
def link(m):
label, url = m.group(1), html.unescape(m.group(2))
if not re.match(r'^(https?://|/|#|\.\.?/)', url):
return label
ext = ' rel="noopener" target="_blank"' if url.startswith('http') else ''
return f'<a href="{esc(url)}"{ext}>{label}</a>'
s = re.sub(r'\[([^\]]+)\]\(([^)\s]+)\)', link, s)
out.append(s)
return ''.join(out)
def slug(text, used):
base = re.sub(r'[^\w一-鿿-]+', '-', text.lower()).strip('-') or 'section'
name, n = base, 2
while name in used:
name, n = f'{base}-{n}', n + 1
used.add(name)
return name
def highlight(code, lang):
"""很簡單的上色:只標註註解,不改變內容。"""
lines = []
for line in code.split('\n'):
mark = None
if lang in ('yaml', 'yml', 'sh', 'shell', 'python', 'py', 'conf', 'dockerfile', 'nginx'):
quote = None
for i, ch in enumerate(line):
if ch in '"\'' and quote is None:
quote = ch
elif ch == quote:
quote = None
elif ch == '#' and quote is None and (i == 0 or line[i - 1] in ' \t'):
mark = i
break
elif lang == 'sql' and '--' in line:
mark = line.index('--')
if mark is None:
lines.append(esc(line))
else:
lines.append(esc(line[:mark]) + '<span class="c">' + esc(line[mark:]) + '</span>')
return '\n'.join(lines)
def code_block(code, lang='', title='', copy=True):
head = ''
if title or copy:
btn = '<button type="button" class="copy" data-copy>複製</button>' if copy else ''
head = f'<div class="code-head"><span>{esc(title or lang)}</span>{btn}</div>'
return f'<figure class="code">{head}<pre><code class="lang-{esc(lang)}">{highlight(code, lang)}</code></pre></figure>'
def markdown(text):
text = re.sub(r'<!--.*?-->', '', text, flags=re.S)
lines = text.split('\n')
out, used, toc = [], set(), []
i = 0
block_start = re.compile(r'^(```|#{1,6} |\||[-*] |\d+\. |>|---\s*$)')
while i < len(lines):
line = lines[i]
if line.startswith('```'):
lang = line[3:].strip()
body = []
i += 1
while i < len(lines) and not lines[i].startswith('```'):
body.append(lines[i])
i += 1
i += 1
out.append(code_block('\n'.join(body), lang, copy=lang not in ('text', '')))
continue
m = re.match(r'^(#{1,6}) (.*)', line)
if m:
level, title = len(m.group(1)), m.group(2).strip()
if level == 1:
i += 1
continue # 標題放在頁首
anchor = slug(title, used)
if level == 2:
toc.append((anchor, title))
out.append(f'<h{level} id="{anchor}">{inline(title)}</h{level}>')
i += 1
continue
if line.startswith('|'):
rows = []
while i < len(lines) and lines[i].startswith('|'):
rows.append([c.strip() for c in lines[i].strip().strip('|').split('|')])
i += 1
head, body = rows[0], [r for r in rows[2:]]
th = ''.join(f'<th>{inline(c)}</th>' for c in head)
trs = ''.join('<tr>' + ''.join(f'<td>{inline(c)}</td>' for c in r) + '</tr>' for r in body)
out.append(f'<div class="table"><table><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table></div>')
continue
m = re.match(r'^([-*]|\d+\.) ', line)
if m:
tag = 'ol' if m.group(1)[0].isdigit() else 'ul'
items = []
while i < len(lines) and re.match(r'^([-*]|\d+\.) ', lines[i]):
item = re.sub(r'^([-*]|\d+\.) ', '', lines[i])
i += 1
while i < len(lines) and lines[i].startswith(' ') and lines[i].strip():
item += ' ' + lines[i].strip()
i += 1
items.append(f'<li>{inline(item)}</li>')
out.append(f'<{tag}>{"".join(items)}</{tag}>')
continue
if line.startswith('>'):
quote = []
while i < len(lines) and lines[i].startswith('>'):
quote.append(lines[i].lstrip('> ').rstrip())
i += 1
out.append(f'<blockquote><p>{inline(" ".join(quote))}</p></blockquote>')
continue
if re.match(r'^---\s*$', line):
out.append('<hr>')
i += 1
continue
if not line.strip():
i += 1
continue
para = [line.strip()]
i += 1
while i < len(lines) and lines[i].strip() and not block_start.match(lines[i]):
para.append(lines[i].strip())
i += 1
out.append(f'<p>{inline(" ".join(para))}</p>')
return '\n'.join(out), toc
# ---------- 讀取關卡 ----------
def load_lessons():
lessons = []
for folder in sorted(ROOT.glob('lv[0-9][0-9]-*')):
readme = (folder / 'README.md').read_text(encoding='utf-8')
meta = {}
m = re.search(r'<!--\s*meta:(.*?)-->', readme, re.S)
if m:
for pair in m.group(1).split(';'):
if '=' in pair:
key, value = pair.split('=', 1)
meta[key.strip()] = value.strip()
title = re.search(r'^# (.+)$', readme, re.M).group(1).strip()
number = int(folder.name[2:4])
files = sorted(
[p for p in folder.rglob('*') if p.is_file() and p.name != 'README.md' and ' 2.' not in p.name
and 'static' not in p.parts and (p.suffix in SHOW_SUFFIX or p.name in SHOW_NAMES)],
key=lambda p: (p.name != '.gitlab-ci.yml', str(p)))
lessons.append({
'id': f'lv{number:02d}', 'number': number, 'folder': folder.name, 'readme': readme,
'title': re.sub(r'^Lv\.\d+\s*', '', title), 'full_title': title,
'type': meta.get('type', '基礎'), 'minutes': meta.get('minutes', ''),
'keywords': meta.get('keywords', ''), 'summary': meta.get('summary', ''), 'files': files,
})
return lessons
# ---------- 版面 ----------
def page(title, desc, body, path, rev, active=''):
url = SITE_URL + path
full = title if title == SITE_NAME else f'{title}|{SITE_NAME}'
nav = [('/', '課程', 'home'), ('/#flow', '部署流程', 'flow'), ('/readme.html', '說明文件', 'readme')]
current = ' aria-current="page"'
links = ''.join(f'<a href="{h}"{current if k == active else ""}>{t}</a>' for h, t, k in nav)
v = esc(rev)
return f'''<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{esc(full)}</title>
<meta name="description" content="{esc(desc)}">
<link rel="canonical" href="{url}">
<meta name="theme-color" content="#F2EEE4" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#070918" media="(prefers-color-scheme: dark)">
<link rel="icon" href="/assets/favicon.svg?v={v}" type="image/svg+xml">
<link rel="icon" href="/assets/favicon-32.png?v={v}" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png?v={v}">
<link rel="manifest" href="/manifest.webmanifest">
<meta property="og:type" content="website">
<meta property="og:site_name" content="{SITE_NAME}">
<meta property="og:locale" content="zh_TW">
<meta property="og:title" content="{esc(full)}">
<meta property="og:description" content="{esc(desc)}">
<meta property="og:url" content="{url}">
<meta property="og:image" content="{SITE_URL}/assets/og-image.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="GitLab CI/CD 十關:從第一條 pipeline 到 k8s 實機部署">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{esc(full)}">
<meta name="twitter:description" content="{esc(desc)}">
<meta name="twitter:image" content="{SITE_URL}/assets/og-image.png">
<script src="/assets/theme-init.js?v={v}"></script>
<link rel="stylesheet" href="/assets/site.css?v={v}">
<script src="/assets/site.js?v={v}" defer></script>
</head>
<body>
<a class="skip" href="#main">跳到主要內容</a>
<header class="topbar">
<div class="wrap topbar-in">
<a class="brand" href="/"><img src="/assets/favicon.svg?v={v}" alt="" width="28" height="28"><span>CI/CD 十關</span></a>
<nav class="nav" aria-label="主要選單">{links}</nav>
<button type="button" class="theme-toggle" data-theme-toggle aria-label="切換深淺色">
<svg class="i-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="i-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z"/></svg>
<span class="theme-label">深色</span>
</button>
</div>
</header>
<main id="main">
{body}
</main>
<footer class="footer">
<div class="wrap footer-in">
<p>GitLab CI/CD 十關|部署在 homelab k8s(Envoy Gateway、Harbor、Argo CD)</p>
<p class="muted">版本 <code>{esc(rev[:8])}</code>|字型 Sora、Inter 採 <a href="/assets/licenses/OFL-Sora.txt">SIL OFL</a> 授權</p>
</div>
</footer>
<div class="toast" role="status" aria-live="polite" data-toast hidden></div>
</body>
</html>
'''
def card(lesson):
t = lesson['type']
return f'''<article class="card" tabindex="0" data-card="{lesson['id']}" data-type="{esc(t)}" aria-describedby="drag-help">
<div class="card-top"><span class="lv">Lv.{lesson['number']}</span><span class="tag tag-{TYPE_CLASS.get(t, 'basic')}">{esc(t)}</span><span class="mins">約 {esc(lesson['minutes'])} 分鐘</span></div>
<h3 class="card-title">{esc(lesson['title'])}</h3>
<p class="card-summary">{esc(lesson['summary'])}</p>
<p class="card-keys"><span>關鍵字</span>{esc(lesson['keywords'])}</p>
<div class="card-actions">
<a class="btn btn-primary btn-sm" href="/{lesson['id']}/">開啟</a>
<label class="move"><span>移到群組</span><select data-move aria-label="把 Lv.{lesson['number']} 移到群組"></select></label>
</div>
</article>'''
FLOW = [
('push main', '受保護分支才會建映像與部署;分支與 MR 只跑掃描'),
('掃描', 'gitleaks 機密、Trivy 相依套件、Semgrep 程式寫法'),
('建置映像', 'BuildKit 無 root 建置,推上 Harbor sites/cicd-tutorial'),
('映像檢查', 'Trivy 掃描映像並產生 SBOM'),
('cosign 簽章', '傳統 .sig 標籤格式,Kyverno 只認得這種'),
('更新 GitOps', 'CI 把新標籤寫進 homelab-k8s 的 apps/cicd-tutorial/values.yaml'),
('Argo CD 同步', '約 3 分鐘內偵測到變更,滾動更新 site-cicd-tutorial'),
('Envoy 上線', 'HTTPRoute 接到共用 Gateway,cert-manager 簽發 HTTPS 憑證'),
]
STACK = [
('GitLab CI/CD', 'gitlab.com 共享 runner 跑 Lv.1~Lv.9;homelab 的 k8s-ci、k8s-release runner 跑 Lv.10'),
('Harbor', '私有映像倉庫,映像放 sites/ 專案'),
('cosign+Kyverno', '簽章與准入驗章,未簽章的映像進不了 site-* 命名空間'),
('Argo CD', 'GitOps:以 homelab-k8s repo 為準,自動同步與自我修復'),
('Envoy Gateway', '對外入口與反向代理,每站獨立 HTTPRoute'),
('cert-manager', "Let's Encrypt HTTP-01 簽發與自動續期"),
('nginx-unprivileged', '容器內提供靜態檔,非 root、唯讀根目錄'),
('Python 標準函式庫', '把各關 README 轉成網頁,不需要任何第三方套件'),
]
def build_index(lessons, rev):
cards = '\n'.join(card(x) for x in lessons)
flow = ''.join(f'<li><span class="step-n">{n}</span><div><strong>{esc(t)}</strong><p>{esc(d)}</p></div></li>'
for n, (t, d) in enumerate(FLOW, 1))
stack = ''.join(f'<tr><th scope="row">{esc(a)}</th><td>{esc(b)}</td></tr>' for a, b in STACK)
counts = {t: sum(1 for x in lessons if x['type'] == t) for t in TYPE_CLASS}
chips = ''.join(f'<button type="button" class="chip" data-filter="{t}" aria-pressed="false">{t}<span>{counts[t]}</span></button>'
for t in TYPE_CLASS)
body = f'''<section class="hero">
<div class="wrap">
<p class="eyebrow">GITLAB CI/CD 教學</p>
<h1>從第一條 pipeline,<br>一路做到 k8s 實機部署</h1>
<p class="lede">十關由淺入深。Lv.1~Lv.9 每關一個可以直接執行的 <code>.gitlab-ci.yml</code>,都在 gitlab.com 共享 runner 實跑通過;Lv.10 用和正式網站相同的流水線,把這個網站建成映像、簽章,經 GitOps 部署到 k8s。</p>
<div class="hero-actions"><a class="btn btn-primary" href="/lv01/">從 Lv.1 開始</a><a class="btn" href="#flow">看部署流程</a></div>
</div>
</section>
<section class="wrap section" aria-labelledby="map-title">
<div class="section-head">
<div><p class="eyebrow">課程地圖</p><h2 id="map-title">十關課程</h2></div>
<p class="muted" id="drag-help">按住卡片拖拉可以排序或拖進群組;手機長按約半秒再拖。鍵盤:在卡片上按空白鍵拿起,方向鍵移動,再按空白鍵放下,Esc 取消;Enter 開啟。排法存在這台裝置的瀏覽器。</p>
</div>
<div class="toolbar">
<div class="chips" role="group" aria-label="依類型篩選"><button type="button" class="chip" data-filter="" aria-pressed="true">全部<span>{len(lessons)}</span></button>{chips}</div>
<div class="toolbar-actions"><button type="button" class="btn btn-sm" data-add-group>+ 新增群組</button><button type="button" class="btn btn-sm btn-ghost" data-reset>恢復預設</button></div>
</div>
<div class="board" data-board>
<section class="group" data-group="ungrouped">
<div class="group-head"><h3 class="group-name">未分類</h3><span class="count">{len(lessons)}</span></div>
<div class="card-list" data-list="ungrouped">
{cards}
</div>
</section>
</div>
<p class="sr-only" aria-live="assertive" data-live></p>
</section>
<section class="wrap section" id="flow" aria-labelledby="flow-title">
<div class="section-head"><div><p class="eyebrow">Lv.10 實機部署</p><h2 id="flow-title">push 之後發生什麼事</h2></div>
<p class="muted">這個網站本身就是 Lv.10 的成果。詳細步驟與 YAML 在 <a href="/lv10/">Lv.10</a>。</p></div>
<ol class="flow">{flow}</ol>
</section>
<section class="wrap section" aria-labelledby="stack-title">
<div class="section-head"><div><p class="eyebrow">技術棧</p><h2 id="stack-title">用到的工具</h2></div></div>
<div class="table"><table class="stack"><tbody>{stack}</tbody></table></div>
</section>'''
return page(SITE_NAME, SITE_DESC, body, '/', rev, 'home')
def build_lesson(lesson, prev, nxt, rev):
content, toc = markdown(lesson['readme'])
samples = []
for f in lesson['files']:
rel = f.relative_to(ROOT).as_posix()
lang = {'.yml': 'yaml', '.yaml': 'yaml', '.sh': 'sh', '.py': 'python', '.sql': 'sql', '.conf': 'nginx',
'.md': 'text', '.txt': 'text'}.get(f.suffix, 'dockerfile' if f.name == 'Dockerfile' else 'text')
code = f.read_text(encoding='utf-8').rstrip('\n')
opened = ' open' if f.name == '.gitlab-ci.yml' else ''
samples.append(f'<details class="sample"{opened}><summary><code>{esc(rel)}</code></summary>{code_block(code, lang, rel)}</details>')
if lesson['number'] == 10:
root_ci = (ROOT / '.gitlab-ci.yml').read_text(encoding='utf-8').rstrip('\n')
samples.append(f'<details class="sample"><summary><code>.gitlab-ci.yml</code>(根目錄總控)</summary>{code_block(root_ci, "yaml", ".gitlab-ci.yml")}</details>')
toc_html = ''.join(f'<li><a href="#{a}">{esc(t)}</a></li>' for a, t in toc)
pager = '<nav class="pager" aria-label="上一關與下一關">'
pager += f'<a href="/{prev["id"]}/" rel="prev"><span>上一關</span>{esc(prev["full_title"])}</a>' if prev else '<span></span>'
pager += f'<a href="/{nxt["id"]}/" rel="next" class="next"><span>下一關</span>{esc(nxt["full_title"])}</a>' if nxt else '<a href="/" class="next"><span>完成</span>回到課程地圖</a>'
pager += '</nav>'
t = lesson['type']
body = f'''<div class="wrap lesson">
<nav class="crumbs" aria-label="路徑"><a href="/">課程</a><span aria-hidden="true">/</span><span>Lv.{lesson['number']}</span></nav>
<header class="lesson-head">
<p class="eyebrow">LV.{lesson['number']}/10</p>
<h1>{esc(lesson['title'])}</h1>
<p class="lede">{esc(lesson['summary'])}</p>
<p class="meta"><span class="tag tag-{TYPE_CLASS.get(t, 'basic')}">{esc(t)}</span><span>約 {esc(lesson['minutes'])} 分鐘</span><span>{esc(lesson['keywords'])}</span></p>
</header>
<div class="lesson-grid">
<aside class="toc" aria-label="本頁目錄"><p class="eyebrow">本頁目錄</p><ol>{toc_html}<li><a href="#samples">範例檔案</a></li></ol></aside>
<article class="prose">
{content}
<h2 id="samples">範例檔案</h2>
<p>資料夾 <code>{esc(lesson['folder'])}/</code> 的全部檔案。<code>.gitlab-ci.yml</code> 可以直接複製到自己的專案根目錄執行。</p>
{''.join(samples)}
{pager}
</article>
</div>
</div>'''
return page(lesson['full_title'], lesson['summary'], body, f'/{lesson["id"]}/', rev)
def main():
parser = argparse.ArgumentParser(description='產生課程網站')
parser.add_argument('--out', required=True)
args = parser.parse_args()
out = Path(args.out)
rev = os.environ.get('REVISION') or os.environ.get('CI_COMMIT_SHA') or 'local'
if out.exists():
shutil.rmtree(out)
shutil.copytree(STATIC, out / 'assets', ignore=shutil.ignore_patterns('* 2.*', '.DS_Store'))
lessons = load_lessons()
(out / 'index.html').write_text(build_index(lessons, rev), encoding='utf-8')
for i, lesson in enumerate(lessons):
prev = lessons[i - 1] if i else None
nxt = lessons[i + 1] if i + 1 < len(lessons) else None
d = out / lesson['id']
d.mkdir()
(d / 'index.html').write_text(build_lesson(lesson, prev, nxt, rev), encoding='utf-8')
body404 = '''<div class="wrap notfound"><p class="eyebrow">404</p><h1>這一頁不存在</h1>
<p class="lede">網址可能打錯了,或這一關換了位置。</p><p><a class="btn btn-primary" href="/">回到課程地圖</a></p></div>'''
(out / '404.html').write_text(page('找不到頁面', SITE_DESC, body404, '/404.html', rev), encoding='utf-8')
shutil.copy(ROOT / 'readme.html', out / 'readme.html')
(out / 'robots.txt').write_text(f'User-agent: *\nAllow: /\nSitemap: {SITE_URL}/sitemap.xml\n', encoding='utf-8')
urls = ['/'] + [f'/{x["id"]}/' for x in lessons] + ['/readme.html']
sitemap = ''.join(f'<url><loc>{SITE_URL}{u}</loc></url>' for u in urls)
(out / 'sitemap.xml').write_text(f'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">{sitemap}</urlset>\n', encoding='utf-8')
manifest = {
'name': SITE_NAME, 'short_name': 'CI/CD 十關', 'lang': 'zh-Hant', 'start_url': '/', 'display': 'standalone',
'background_color': '#070918', 'theme_color': '#070918',
'icons': [{'src': '/assets/icon-192.png', 'sizes': '192x192', 'type': 'image/png'},
{'src': '/assets/icon-512.png', 'sizes': '512x512', 'type': 'image/png'}],
}
(out / 'manifest.webmanifest').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'已產生 {len(lessons)} 關,輸出到 {out}(版本 {rev[:8]})')
if __name__ == '__main__':
main()lv10-k8s-deploy/site/check_site.py
lv10-k8s-deploy/site/check_site.py
"""檢查產生的網站:每一關都有頁面、品牌資產齊全、頁面沒有會被 CSP 擋掉的 inline 腳本。
用法:python3 lv10-k8s-deploy/site/check_site.py <網站目錄>
"""
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[2]
site = Path(sys.argv[1])
errors = []
lessons = sorted(ROOT.glob('lv[0-9][0-9]-*'))
if len(lessons) != 10:
errors.append(f'應該有 10 關,實際 {len(lessons)} 關')
for folder in lessons:
for name in ('README.md', '.gitlab-ci.yml'):
if not (folder / name).is_file():
errors.append(f'{folder.name} 缺少 {name}')
required = ['index.html', '404.html', 'readme.html', 'robots.txt', 'sitemap.xml', 'manifest.webmanifest',
'assets/site.css', 'assets/site.js', 'assets/theme-init.js', 'assets/favicon.svg', 'assets/favicon-32.png',
'assets/apple-touch-icon.png', 'assets/og-image.png', 'assets/icon-192.png', 'assets/icon-512.png',
'assets/fonts/sora-latin-wght.woff2', 'assets/fonts/inter-latin-wght.woff2']
required += [f'lv{n:02d}/index.html' for n in range(1, 11)]
for rel in required:
path = site / rel
if not path.is_file() or path.stat().st_size == 0:
errors.append(f'缺少 {rel}')
for path in [site / 'index.html', site / '404.html', *sorted(site.glob('lv*/index.html'))]:
if not path.is_file():
continue
text = path.read_text(encoding='utf-8')
rel = path.relative_to(site)
for needle in ('og:image', 'twitter:card', 'apple-touch-icon', 'rel="icon"', 'name="viewport"', 'lang="zh-Hant"'):
if needle not in text:
errors.append(f'{rel} 缺少 {needle}')
if re.search(r'<script(?![^>]*\bsrc=)[^>]*>', text):
errors.append(f'{rel} 有 inline <script>,會被 CSP 擋掉')
if re.search(r'\sstyle="', text):
errors.append(f'{rel} 有 inline style,會被 CSP 擋掉')
index = (site / 'index.html').read_text(encoding='utf-8') if (site / 'index.html').is_file() else ''
cards = re.findall(r'data-card="(lv\d\d)"', index)
if cards != [f'lv{n:02d}' for n in range(1, 11)]:
errors.append(f'首頁卡片不是 lv01~lv10:{cards}')
if errors:
print('網站檢查失敗:')
for e in errors:
print(' -', e)
sys.exit(1)
print(f'網站檢查通過:{len(required)} 個檔案、{len(cards)} 張課程卡片').gitlab-ci.yml(根目錄總控)
.gitlab-ci.yml
# 課程總控 pipeline
# - Lv.1~Lv.9:每一關的 .gitlab-ci.yml 都是獨立範例,這裡用「子 pipeline」逐關觸發,跑在 gitlab.com 共享 runner
# - Lv.10:include lv10-k8s-deploy/.gitlab-ci.yml,把課程網站建成映像並部署到 k8s(跑在 homelab 的 k8s-ci、k8s-release runner)
# 只有改到的關卡才會重跑;手動執行(Run pipeline)或帶 RUN_LESSONS=all 時十關全跑
include:
- local: lv10-k8s-deploy/.gitlab-ci.yml
stages:
- lessons
- site-check
- k8s-scan
- k8s-build
- k8s-check
- k8s-deploy
.lesson:
stage: lessons
needs: []
trigger:
strategy: depend # 子 pipeline 的結果就是這個 job 的結果
forward:
yaml_variables: false # 不把 Lv.10 的 SITE、IMAGE 等變數帶進教材
pipeline_variables: false
lv01:
extends: .lesson
trigger: { include: lv01-first-pipeline/.gitlab-ci.yml }
rules:
- if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
- changes: [lv01-first-pipeline/**/*]
lv02:
extends: .lesson
trigger: { include: lv02-stages-jobs/.gitlab-ci.yml }
rules:
- if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
- changes: [lv02-stages-jobs/**/*]
lv03:
extends: .lesson
trigger: { include: lv03-artifacts/.gitlab-ci.yml }
rules:
- if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
- changes: [lv03-artifacts/**/*]
lv04:
extends: .lesson
trigger: { include: lv04-variables/.gitlab-ci.yml }
rules:
- if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
- changes: [lv04-variables/**/*]
lv05:
extends: .lesson
trigger: { include: lv05-cache/.gitlab-ci.yml }
rules:
- if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
- changes: [lv05-cache/**/*, lv0*/.gitlab-ci.yml]
lv06:
extends: .lesson
trigger: { include: lv06-rules/.gitlab-ci.yml }
rules:
- if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
- changes: [lv06-rules/**/*]
lv07:
extends: .lesson
trigger: { include: lv07-needs-dag/.gitlab-ci.yml }
rules:
- if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
- changes: [lv07-needs-dag/**/*]
lv08:
extends: .lesson
trigger: { include: lv08-services/.gitlab-ci.yml }
rules:
- if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
- changes: [lv08-services/**/*]
lv09:
extends: .lesson
trigger: { include: lv09-environments/.gitlab-ci.yml }
rules:
- if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
- changes: [lv09-environments/**/*]