ตอนนี้โครงสร้างพื้นฐานของเราหรือแกนหลักของ DevOps พร้อม 100% แล้ว ได้แก่
ในบทนี้เราจะนำทุกจิ๊กซอว์มาต่อกันผ่านโปรเจกต์ FastAPI "Hello World" เพื่อทดสอบสายพาน CI/CD ตั้งแต่การเขียนโค้ดบรรทัดแรกไปจนถึงหน้าเว็บ HTTPS
เราจะใช้ IDE เป็นเครื่องมือหลักในการสร้างโปรเจกต์และเตรียมไฟล์สำคัญ และเตรียมโครงสร้างไฟล์ในโปรเจกต์อย่างง่าย โดยเน้นเฉพาะที่จำเป็นต่อการทำงานเพื่อความเข้าใจ
"ใน Dockerfile เราจะสร้าง appuser ขึ้นมาใช้งานแทน Root เพื่อความปลอดภัยตามมาตรฐาน SonarQube และยังช่วยลดความเสี่ยงหากแอปโดนเจาะ (Exploited) แฮกเกอร์ก็จะไม่สามารถสั่งการระดับระบบ (Root) ได้ทันที นอกจากนี้ใน deployment.yaml เราจะตั้งค่า Readiness Probe เพื่อให้ Kubernetes ตรวจสอบความพร้อมของแอปก่อนรับ Traffic จริง"
ปัญหาที่พบบ่อย คือ "Recursive Trigger" เมื่อ Jenkins อัปเดต Image Tag แล้ว Push กลับไปที่ GitLab ทำให้ GitLab ส่ง Webhook กลับมาปลุก Jenkins ให้ทำงานวนไปไม่รู้จบ ดังนั้น เฉพาะในโปรเจคนี้เราจะใช้การป้องกันแบบหลายชั้นเพื่อหยุดปัญหานี้
ในไฟล์ Jenkinsfile เราจะรวมทุกขั้นตอนสำคัญไว้ในที่เดียว
เพื่อให้ GitLab คุยกับ Jenkins ได้อย่างปลอดภัย เราต้องทำการปรับจูนที่หน้า Jenkins เล็กน้อย ดังนี้
"เหตุผล คือ เพื่อเปลี่ยนจากการใช้รหัสผ่านส่วนตัว มาเป็นการใช้ Secret Token ที่ปลอดภัยและเฉพาะเจาะจงกับโปรเจกต์มากกว่า"
เมื่อทุกอย่างพร้อมแล้ว ส่งโค้ดขึ้น GitLab โดยผลลัพธ์ที่ควรเห็น
"นี่คือ DevSecOps ที่ทุกอย่างทำงานเป็นอัตโนมัติ ปลอดภัย และตรวจสอบได้ในทุกขั้นตอน"
การทดสอบเปลี่ยนโปรเจกต์ธรรมดาให้กลายเป็น Pipeline
python -m venv venv
venv\Scripts\activate
fastapi
uvicorn
pip install -r requirements.txt
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {
"message": "Hello World from FastAPI",
"status": "Automation Success",
"infra": "K3s + ArgoCD + Let's Encrypt"
}
@app.get("/healthz")
def health_check():
return {"message": "Test DevSecOps Success!"}
FROM python:3.11-alpine
WORKDIR /app
# ก๊อปปี้ไฟล์ดัชนี library และติดตั้ง
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ก๊อปปี้โค้ดแอป
COPY main.py .
# สร้าง User ใหม่ (ไม่รันด้วย Root ตามมาตรฐาน SonarQube)
RUN adduser -D appuser
USER appuser
EXPOSE 8000
# สั่งรันด้วย uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
apiVersion: apps/v1
kind: Deployment
metadata:
name: fastapi-app
# แนะนำให้ระบุ namespace ให้ตรงกับที่ตั้งไว้ใน ArgoCD
namespace: default
spec:
replicas: 2
selector:
matchLabels:
app: fastapi-app
template:
metadata:
labels:
app: fastapi-app
spec:
automountServiceAccountToken: false
imagePullSecrets:
- name: nexus-auth
containers:
- name: fastapi
# เลข Tag นี้ Jenkins จะเป็นคนสั่ง sed แก้ให้เองเมื่อ Build เสร็จ
image: registry.panmodel.com/my-app:42
# ลบส่วน command และ args ของเดิมออก เพื่อให้มันรันตาม Dockerfile (uvicorn)
ports:
- containerPort: 8000 # FastAPI ปกติรันที่พอร์ต 8000
resources:
requests:
memory: "64Mi"
cpu: "50m"
ephemeral-storage: "50Mi"
limits:
memory: "128Mi"
cpu: "100m"
ephemeral-storage: "100Mi"
# เพิ่ม Liveness & Readiness Probe เพื่อให้ Kubernetes เช็คว่าแอปค้างหรือไม่
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: fastapi-service
namespace: default # ระบุให้ตรงกับ Deployment
spec:
selector:
app: fastapi-app
ports:
- port: 80 # พอร์ตที่ Service รับ traffic
targetPort: 8000 # ส่งต่อไปที่พอร์ต 8000 ของ Container
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fastapi-ingress
namespace: default # ระบุให้ตรงกันเพื่อป้องกันอาการ Missing
annotations:
# 🛡ใช้ Nginx และ Cert-Manager ตามที่ติดตั้งไว้
kubernetes.io/ingress.class: "nginx"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- test.panmodel.com
secretName: fastapi-tls-cert # Let's Encrypt จะมาสร้างกุญแจ SSL ไว้ที่ชื่อนี้
rules:
- host: test.panmodel.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: fastapi-service
port:
number: 80
git init
git remote add origin https://git.panmodel.com/jenkins_bot/pipeline-test.git
# ดึงไฟล์จาก main branch ลงมา
git pull origin main
pipeline {
agent any
options {
timestamps()
disableConcurrentBuilds()
}
environment {
GITLAB_URL = 'git.panmodel.com'
// ====== Credentials ======
SONAR_TOKEN = credentials('jenkins-token')
GITLAB_CREDS = credentials('gitlab-jenkins-creds')
NEXUS_CREDS = credentials('nexus-docker-auth')
// ====== OpenTelemetry → SigNoz ======
OTEL_EXPORTER_OTLP_ENDPOINT = 'https://collector.panmodel.com'
OTEL_EXPORTER_OTLP_PROTOCOL = 'http/protobuf'
OTEL_SERVICE_NAME = 'Panmodel-Jenkins'
OTEL_RESOURCE_ATTRIBUTES = 'service.namespace=Production,deployment.environment=production,ci.tool=jenkins,ci.pipeline=my-factory-app'
OTEL_TRACES_SAMPLER = 'parentbased_always_on'
// ย้ำตัวส่งออก (กัน fallback)
OTEL_TRACES_EXPORTER = 'otlp'
OTEL_METRICS_EXPORTER = 'otlp'
// (ถ้าเคยเจอเงียบ ๆ บางเอเยนต์ ให้ uncomment 2 บรรทัดนี้)
// OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'https://collector.panmodel.com/v1/traces'
// OTEL_EXPORTER_OTLP_TRACES_PROTOCOL = 'http/protobuf'
// ====== Flush เร็ว ลดโอกาส no-data ======
OTEL_BSP_SCHEDULE_DELAY = '200ms'
OTEL_BSP_MAX_EXPORT_BATCH_SIZE = '128'
OTEL_BSP_MAX_QUEUE_SIZE = '2048'
OTEL_BSP_EXPORT_TIMEOUT = '10s'
// ====== กัน Proxy ขวาง collector ======
NO_PROXY = 'collector.panmodel.com'
no_proxy = 'collector.panmodel.com'
}
stages {
// ---- Root trace & warm-up exporter ----
stage('0 - Initialize tracing (root span)') {
steps {
echo 'Initialize root trace & warm-up exporter'
sh 'sleep 1'
}
}
// ---- ตรวจ TLS/Reachability จาก agent จริง (ดีบักครั้งแรก ๆ) ----
stage('0.1 - OTEL Sanity (TLS from agent)') {
steps {
sh '''
set -euxo pipefail
echo "[1/3] TLS handshake"
(command -v openssl >/dev/null 2>&1 && \
echo | openssl s_client -connect collector.panmodel.com:443 -servername collector.panmodel.com -brief | sed -n '1,15p') || true
echo "[2/3] Reachability /v1/traces (404/405/415 is OK)"
curl -sS -o /dev/null -w "HTTP %{http_code}\\n" https://collector.panmodel.com/v1/traces || true
echo "[3/3] Effective OTEL env"
env | grep -E '^OTEL_|^NO_PROXY|^no_proxy' || true
'''
}
}
// ---- ยิง span ทดสอบผ่าน 443 จาก agent ----
stage('0.2 - Generate spans (telemetrygen)') {
steps {
sh '''
set -euxo pipefail
echo "[telemetrygen] send test spans over HTTPS:443 via OTLP/HTTP"
docker run --rm \
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
traces \
--service "jenkins-agent-smoke" \
--duration 3s \
--rate 5 \
--otlp-endpoint collector.panmodel.com:443 \
--otlp-http
'''
}
}
stage('Check Commit Author') {
steps {
script {
// ดึงชื่อ Author ล่าสุดออกมา
def author = sh(script: "git log -1 --pretty=%an", returnStdout: true).trim()
echo "Commit ล่าสุดทำโดย: ${author}"
// เช็คว่าใช่ชื่อที่เราตั้งให้บอทใน Stage 5 ไหม
if (author == "Jenkins Bot") {
echo "ตรวจพบ Jenkins Bot! สั่งหยุด Pipeline เพื่อป้องกัน Loop ไม่รู้จบ"
currentBuild.result = 'NOT_BUILT' // เปลี่ยนสถานะเป็นไม่รัน (สีเทา/ขาว)
error("Loop Protection: Stopping because this is a Bot commit.")
} else {
echo "Commit โดย ${author} (มนุษย์) -> อนุญาตให้ไปต่อได้!"
}
}
}
}
stage('1. Checkout') {
steps { checkout scm }
}
stage('2. SonarQube Analysis') {
steps {
script {
def scannerHome = tool 'SonarScanner'
withSonarQubeEnv('SonarQube-Server-Name') {
sh """
${scannerHome}/bin/sonar-scanner \
-Dsonar.projectKey=my-factory-app \
-Dsonar.sources=. \
-Dsonar.host.url=https://sonar.panmodel.com \
-Dsonar.login=${SONAR_TOKEN}
"""
}
}
}
}
stage('3. Quality Gate') {
steps {
timeout(time: 5, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true
}
}
}
stage('4. Build & Push to Nexus') {
steps {
script {
docker.withRegistry('https://registry.panmodel.com', 'nexus-docker-auth') {
def image = docker.build("registry.panmodel.com/my-app:${env.BUILD_ID}")
image.push()
}
}
}
}
stage('5. Update Manifest in Git') {
steps {
script {
def newTag = "${env.BUILD_ID}"
echo "กำลังอัปเดตไฟล์ deployment.yaml เป็น Tag: ${newTag}"
// 1. ตั้งค่า Git User ก่อนเริ่มงาน
sh 'git config user.email "jenkins-bot@panmodel.com"'
sh 'git config user.name "Jenkins Bot"'
withCredentials([usernamePassword(credentialsId: 'gitlab-jenkins-creds', passwordVariable: 'GIT_PASS', usernameVariable: 'GIT_USER')]) {
// 2. ดึงข้อมูลล่าสุดลงมาก่อน (เพื่อป้องกันกรณีมีคนอื่น Push แทรกเข้ามา)
sh "git pull https://${GIT_USER}:${GIT_PASS}@${GITLAB_URL}/jenkins_bot/pipeline-test.git HEAD:main"
// 3. แก้ไขเลข Tag
sh "sed -i 's|image: registry.panmodel.com/my-app:.*|image: registry.panmodel.com/my-app:${newTag}|g' deployment.yaml"
// 4. ตรวจสอบว่ามีการเปลี่ยนแปลงจริงหรือไม่ก่อน Commit (ป้องกัน Commit ว่าง)
sh """
if git diff --quiet deployment.yaml; then
echo "ไม่มีการเปลี่ยนแปลงใน deployment.yaml"
else
git add deployment.yaml
# 5. ใส่ [skip ci] เพื่อบอก GitLab/Jenkins ว่าไม่ต้องรันซ้ำ
git commit -m "chore: update image tag to ${newTag} [skip ci]"
git push https://${GIT_USER}:${GIT_PASS}@${GITLAB_URL}/jenkins_bot/pipeline-test.git HEAD:main
fi
"""
}
}
}
}
stage('6. Ensure flush') {
steps { sh 'sleep 2' }
}
}
post {
always {
echo "SigNoz → https://monitor.panmodel.com (Last 15 min)"
echo "- Services ควรเห็น: Panmodel-Jenkins (trace เปิดได้), jenkins-agent-smoke (spans test)"
cleanWs()
}
}
}
ตามมาตรฐานความปลอดภัยแบบ Modern CI/CD เราไม่นิยมส่ง User/Password ของคนจริงๆ ผ่าน Webhook แต่จะใช้วิธี Token-based Authentication แทน ซึ่งการ Uncheck ช่องนั้นคือการบอกให้ Jenkins ยอมรับการยืนยันตัวตนผ่าน Token ที่เราสร้างขึ้นนั่นเอง
git branch -m main
git add .
git commit -m "feat: complete FastAPI migration with proper Jenkinsfile"
git push origin main
ผลลัพธ์ที่คาดหวัง คือ เมื่อ Push ไปที่ GitLab แล้ว ระบบโรงงานจะเริ่มทำงาน และ FastAPI จะถูก Deploy ไปทำงานบน K3S แบบ HTTPS ได้สำเร็จ