博客应用:Flask + Dockerfile + K8s 清单 + Jenkinsfile

This commit is contained in:
2026-10-23 10:31:31 +08:00
commit aa581edf84
7 changed files with 513 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
data/
__pycache__/
*.pyc
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATA_DIR=/data \
PORT=5000
WORKDIR /app
# 用清华源加速,构建时不需要翻墙
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
-i https://pypi.tuna.tsinghua.edu.cn/simple
COPY app.py .
RUN useradd -r -u 10001 blog && mkdir -p /data && chown -R blog /data /app
USER blog
EXPOSE 5000
CMD ["python", "app.py"]
Vendored
+116
View File
@@ -0,0 +1,116 @@
pipeline {
agent {
kubernetes {
yaml '''
apiVersion: v1
kind: Pod
spec:
serviceAccountName: jenkins
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:v1.23.2-debug
command: ["sleep"]
args: ["999999"]
volumeMounts:
- name: docker-config
mountPath: /kaniko/.docker
- name: kubectl
image: bitnami/kubectl:1.34
command: ["sleep"]
args: ["999999"]
volumes:
- name: docker-config
secret:
secretName: harbor-dockerconfig
items:
- key: .dockerconfigjson
path: config.json
'''
}
}
options {
timestamps()
buildDiscarder(logRotator(numToKeepStr: '10'))
timeout(time: 20, unit: 'MINUTES')
}
environment {
REGISTRY = 'harbor.nineleaves.cn'
IMAGE = 'harbor.nineleaves.cn/apps/blog'
NS = 'apps'
}
stages {
stage('准备') {
steps {
echo "构建号 #${env.BUILD_NUMBER},分支 ${env.BRANCH_NAME ?: 'main'}"
sh 'ls -la'
}
}
stage('构建镜像并推送 Harbor') {
steps {
container('kaniko') {
sh '''
set -e
TAG="build-${BUILD_NUMBER}"
echo "开始构建 ${IMAGE}:${TAG}"
/kaniko/executor \
--context=dir://${WORKSPACE} \
--dockerfile=${WORKSPACE}/Dockerfile \
--destination=${IMAGE}:${TAG} \
--destination=${IMAGE}:latest \
--skip-tls-verify \
--skip-tls-verify-pull \
--cache=false \
--verbosity=info
echo "镜像已推送: ${IMAGE}:${TAG}"
'''
}
}
}
stage('部署到 Kubernetes') {
steps {
container('kubectl') {
sh '''
set -e
TAG="build-${BUILD_NUMBER}"
kubectl apply -f ${WORKSPACE}/k8s/deployment.yaml
kubectl -n ${NS} set image deployment/blog blog=${IMAGE}:${TAG}
kubectl -n ${NS} rollout status deployment/blog --timeout=180s
kubectl -n ${NS} get pods -l app=blog
'''
}
}
}
stage('冒烟测试') {
steps {
container('kubectl') {
sh '''
set -e
for i in $(seq 1 20); do
CODE=$(kubectl -n apps run smoke-$$ --rm -i --restart=Never \
--image=curlimages/curl:8.10.1 --quiet -- \
-s -o /dev/null -w "%{http_code}" http://blog/healthz 2>/dev/null || echo 000)
if [ "$CODE" = "200" ]; then echo "健康检查通过"; exit 0; fi
echo "第 $i 次: $CODE, 等待中..."; sleep 3
done
echo "健康检查失败"; exit 1
'''
}
}
}
}
post {
success {
echo "✅ 部署成功:https://blog.nineleaves.cn"
}
failure {
echo "❌ 流水线失败,查看上面的日志"
}
}
}
+45
View File
@@ -0,0 +1,45 @@
# 我的博客
一个用 Flask 写的极简博客,用来演示完整的 CI/CD 链路:
```
Gitea(代码) → Jenkins(流水线) → Kaniko(构建镜像) → Harbor(镜像仓库) → Kubernetes(部署)
```
## 功能
- 首页文章列表
- Markdown 文章详情(代码块、表格、引用)
- 写文章(`/new`,需要管理口令)
- 阅读计数
- 文章持久化在 Longhorn 卷上
## 本地跑
```bash
pip install -r requirements.txt
DATA_DIR=./data ADMIN_TOKEN=test123 python app.py
# 打开 http://localhost:5000
```
## 构建镜像
```bash
docker build -t harbor.nineleaves.cn/apps/blog:dev .
docker push harbor.nineleaves.cn/apps/blog:dev
```
## 部署
Jenkins 流水线会自动 apply `k8s/deployment.yaml` 并把镜像替换成本次的构建号。
手动执行:
```bash
kubectl apply -f k8s/deployment.yaml
kubectl -n apps set image deployment/blog blog=harbor.nineleaves.cn/apps/blog:dev
```
## 访问
https://blog.nineleaves.cn
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""极简博客 —— 单文件 Flask 应用,文章以 JSON 存在 PVC 上。"""
import os
import json
import re
import html
import datetime
import markdown
from flask import Flask, request, redirect, abort, render_template_string
DATA_DIR = os.environ.get("DATA_DIR", "/data")
DB_FILE = os.path.join(DATA_DIR, "posts.json")
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "change-me")
SITE_TITLE = os.environ.get("SITE_TITLE", "我的博客")
app = Flask(__name__)
def load_posts():
if not os.path.exists(DB_FILE):
return []
try:
with open(DB_FILE, encoding="utf-8") as f:
posts = json.load(f)
return sorted(posts, key=lambda p: p.get("created", ""), reverse=True)
except (json.JSONDecodeError, OSError):
return []
def save_posts(posts):
os.makedirs(DATA_DIR, exist_ok=True)
tmp = DB_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(posts, f, ensure_ascii=False, indent=2)
os.replace(tmp, DB_FILE)
def slugify(text):
slug = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", text.strip().lower()).strip("-")
return slug[:60] or "post"
BASE_CSS = """
<style>
:root{--fg:#1a1a1a;--muted:#666;--line:#e5e5e5;--accent:#0b6bcb}
*{box-sizing:border-box}
body{margin:0;font:16px/1.75 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
color:var(--fg);background:#fafafa}
.wrap{max-width:760px;margin:0 auto;padding:48px 24px 96px}
header.site{margin-bottom:48px;display:flex;justify-content:space-between;align-items:baseline;
border-bottom:1px solid var(--line);padding-bottom:16px}
header.site h1{font-size:1.6rem;margin:0;letter-spacing:-.02em}
header.site a{color:var(--muted);text-decoration:none;font-size:.9rem}
header.site a:hover{color:var(--accent)}
article.post{background:#fff;border:1px solid var(--line);border-radius:12px;
padding:24px 28px;margin-bottom:20px}
article.post h2{margin:0 0 8px;font-size:1.25rem;line-height:1.4}
article.post h2 a{color:var(--fg);text-decoration:none}
article.post h2 a:hover{color:var(--accent)}
.meta{color:var(--muted);font-size:.85rem}
.excerpt{color:#444;margin:10px 0 0}
.content{background:#fff;border:1px solid var(--line);border-radius:12px;padding:32px 36px}
.content h1{margin-top:0}
.content pre{background:#f5f5f5;padding:16px;border-radius:8px;overflow:auto}
.content code{background:#f5f5f5;padding:2px 6px;border-radius:4px;font-size:.9em}
.content pre code{background:none;padding:0}
.content img{max-width:100%;height:auto;border-radius:8px}
.content blockquote{border-left:3px solid var(--line);margin:0;padding-left:16px;color:var(--muted)}
form.new{background:#fff;border:1px solid var(--line);border-radius:12px;padding:24px}
form.new label{display:block;font-size:.85rem;color:var(--muted);margin:14px 0 6px}
form.new input,form.new textarea{width:100%;padding:10px 12px;border:1px solid var(--line);
border-radius:8px;font:inherit}
form.new textarea{min-height:320px;font-family:ui-monospace,Menlo,monospace;font-size:.92rem}
button{background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 20px;
font:inherit;cursor:pointer;margin-top:18px}
button:hover{opacity:.9}
footer{margin-top:64px;color:var(--muted);font-size:.85rem;text-align:center}
</style>
"""
LIST_TPL = """
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ site }}</title>""" + BASE_CSS + """</head><body><div class="wrap">
<header class="site"><h1>{{ site }}</h1><a href="/new">写文章 →</a></header>
{% for p in posts %}
<article class="post">
<h2><a href="/post/{{ p.slug }}">{{ p.title }}</a></h2>
<div class="meta">{{ p.created }} · {{ p.views }} 次阅读</div>
<p class="excerpt">{{ p.excerpt }}</p>
</article>
{% else %}
<article class="post"><p class="meta">还没有文章。点右上角"写文章"开始。</p></article>
{% endfor %}
<footer>由 Flask + Kubernetes + Jenkins + Harbor 驱动</footer>
</div></body></html>
"""
POST_TPL = """
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ p.title }} · {{ site }}</title>""" + BASE_CSS + """</head><body><div class="wrap">
<header class="site"><h1><a href="/" style="color:inherit">{{ site }}</a></h1>
<a href="/">← 返回列表</a></header>
<div class="content">
<h1>{{ p.title }}</h1>
<div class="meta" style="margin-bottom:24px">{{ p.created }}</div>
{{ body|safe }}
</div>
<footer>阅读 {{ p.views }} 次</footer>
</div></body></html>
"""
NEW_TPL = """
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>写文章 · {{ site }}</title>""" + BASE_CSS + """</head><body><div class="wrap">
<header class="site"><h1><a href="/" style="color:inherit">{{ site }}</a></h1>
<a href="/">← 返回列表</a></header>
{% if error %}<div class="post" style="border-color:#e0b4b4;background:#fff5f5">
<p class="meta" style="color:#b00">{{ error }}</p></div>{% endif %}
<form class="new" method="post" action="/new">
<label>标题</label><input name="title" required maxlength="120" value="{{ title or '' }}">
<label>正文(支持 Markdown)</label><textarea name="body" required>{{ body or '' }}</textarea>
<label>管理口令</label><input name="token" type="password" required>
<button type="submit">发布</button>
</form>
</div></body></html>
"""
@app.get("/healthz")
def healthz():
return "ok", 200
@app.get("/")
def index():
posts = []
for p in load_posts():
posts.append({
"slug": p["slug"],
"title": p["title"],
"created": p["created"],
"views": p.get("views", 0),
"excerpt": p.get("excerpt", ""),
})
return render_template_string(LIST_TPL, site=SITE_TITLE, posts=posts)
@app.get("/post/<slug>")
def post_detail(slug):
posts = load_posts()
for p in posts:
if p["slug"] == slug:
p["views"] = p.get("views", 0) + 1
save_posts(posts)
body = markdown.markdown(p["body"], extensions=["fenced_code", "tables"])
return render_template_string(POST_TPL, site=SITE_TITLE, p=p, body=body)
abort(404)
@app.route("/new", methods=["GET", "POST"])
def new_post():
if request.method == "GET":
return render_template_string(NEW_TPL, site=SITE_TITLE, error=None, title="", body="")
title = (request.form.get("title") or "").strip()
body = (request.form.get("body") or "").strip()
token = request.form.get("token") or ""
if token != ADMIN_TOKEN:
return render_template_string(NEW_TPL, site=SITE_TITLE,
error="管理口令不正确", title=title, body=body), 403
if not title or not body:
return render_template_string(NEW_TPL, site=SITE_TITLE,
error="标题和正文都不能为空", title=title, body=body), 400
posts = load_posts()
base = slugify(title)
slug = base
n = 1
existing = {p["slug"] for p in posts}
while slug in existing:
n += 1
slug = f"{base}-{n}"
plain = re.sub(r"[#>*_`\[\]()!-]", "", body)
posts.append({
"slug": slug,
"title": title,
"body": body,
"excerpt": html.escape(plain[:120].replace("\n", " ")),
"created": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
"views": 0,
})
save_posts(posts)
return redirect(f"/post/{slug}")
@app.post("/post/<slug>/delete")
def delete_post(slug):
token = request.form.get("token") or ""
if token != ADMIN_TOKEN:
abort(403)
posts = [p for p in load_posts() if p["slug"] != slug]
save_posts(posts)
return redirect("/")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "5000")))
+113
View File
@@ -0,0 +1,113 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: blog
namespace: apps
labels:
app: blog
spec:
replicas: 1
revisionHistoryLimit: 3
selector:
matchLabels:
app: blog
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: blog
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
containers:
- name: blog
image: harbor.nineleaves.cn/apps/blog:BUILD_TAG
imagePullPolicy: Always
ports:
- containerPort: 5000
env:
- name: SITE_TITLE
value: "我的博客"
- name: DATA_DIR
value: /data
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: blog-secret
key: admin-token
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
memory: 256Mi
readinessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 3
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 15
periodSeconds: 20
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: blog-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: blog-data
namespace: apps
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: longhorn
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: blog
namespace: apps
spec:
selector:
app: blog
ports:
- port: 80
targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: blog
namespace: apps
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
ingressClassName: nginx
rules:
- host: blog.nineleaves.cn
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: blog
port:
number: 80
+2
View File
@@ -0,0 +1,2 @@
flask==3.1.0
markdown==3.7