From aa581edf841aa1efb4b7396f52ddfbbd6d5d5907 Mon Sep 17 00:00:00 2001 From: gitea Date: Fri, 23 Oct 2026 10:31:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=9A=E5=AE=A2=E5=BA=94=E7=94=A8=EF=BC=9AFl?= =?UTF-8?q?ask=20+=20Dockerfile=20+=20K8s=20=E6=B8=85=E5=8D=95=20+=20Jenki?= =?UTF-8?q?nsfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + Dockerfile | 21 +++++ Jenkinsfile | 116 ++++++++++++++++++++++++ README.md | 45 ++++++++++ app.py | 213 ++++++++++++++++++++++++++++++++++++++++++++ k8s/deployment.yaml | 113 +++++++++++++++++++++++ requirements.txt | 2 + 7 files changed, 513 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Jenkinsfile create mode 100644 README.md create mode 100644 app.py create mode 100644 k8s/deployment.yaml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac481ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +data/ +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cf9711e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..9434331 --- /dev/null +++ b/Jenkinsfile @@ -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 "❌ 流水线失败,查看上面的日志" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..c603f82 --- /dev/null +++ b/README.md @@ -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 diff --git a/app.py b/app.py new file mode 100644 index 0000000..d794ea4 --- /dev/null +++ b/app.py @@ -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 = """ + +""" + +LIST_TPL = """ + + +{{ site }}""" + BASE_CSS + """
+

{{ site }}

写文章 →
+{% for p in posts %} +
+

{{ p.title }}

+
{{ p.created }} · {{ p.views }} 次阅读
+

{{ p.excerpt }}

+
+{% else %} +

还没有文章。点右上角"写文章"开始。

+{% endfor %} +
由 Flask + Kubernetes + Jenkins + Harbor 驱动
+
+""" + +POST_TPL = """ + + +{{ p.title }} · {{ site }}""" + BASE_CSS + """
+

{{ site }}

+← 返回列表
+
+

{{ p.title }}

+
{{ p.created }}
+ {{ body|safe }} +
+
阅读 {{ p.views }} 次
+
+""" + +NEW_TPL = """ + + +写文章 · {{ site }}""" + BASE_CSS + """
+

{{ site }}

+← 返回列表
+{% if error %}
+

{{ error }}

{% endif %} +
+ + + + +
+
+""" + + +@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/") +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//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"))) diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..fc5faca --- /dev/null +++ b/k8s/deployment.yaml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4291eb9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask==3.1.0 +markdown==3.7