从 代码仓库 拉取 release 分支代码,构建出带有“提交哈希 + 时间戳”的不可变镜像标签,将镜像推送到 digitalocean 私有镜像仓库,然后直接更新线上 k8s deployment 资源,完成滚动升级。
在部署成功或失败后,通过 Discord Webhook 主动把状态、提交信息和操作者推送到团队频道,同时在 post { always } 中清理本地镜像,避免 Jenkins 节点磁盘被长时间累积的镜像拖垮。
在凭证管理上,通过 jenkins界面选择 Username with password,将 DigitalOcean Registry 的用户名和 Access Token 以全局凭证形式保存,并指定一个语义清晰的 id,之后在 pipeline 中只暴露这个 id,而不直接暴露账号和密码,这种“凭证中心化 + id 引用”的方式也适用于 git、harbor 等多种私有仓库,具有通用性。

pipeline {
agent any
environment {
REPOSITORY_ADDR = "registry.digitalocean.com/registry-yc"
PROJECT_NAME = "b2c-backend"
DEPLOYMENT_NAME = "b2c-backend"
CONTAINER_NAME = "backend"
NAMESPACE_NAME = "b2c"
WEBHOOK_URL = "https://discord.com/api/webhooks/14047768xxxxxxxx/xxxxxxxxxxx"
TITLE = "DIGITALOCEAN-ADMIN"
PROXY_URL = "http://192.168.2.30:10808"
API_SERVER="https://77d0ef66-6a5e-4cdf-9da7-31635991c4e4.k8s.ondigitalocean.com"
KUBECTL_CREDENTIAL="DIGITALOCEAN-CLUSTER"
GIT_BRANCHE = "release"
GIT_CREDENTIALID = "gitea_access_token"
GIT_REPOSITORY_URL = "http://gitea.repository.yc/mall-b2c/mall-b2c-backend.git"
}
stages {
stage('Pull') {
steps {
git branch: "${GIT_BRANCHE }", credentialsId: "${GIT_CREDENTIALID }", url: "${GIT_REPOSITORY_URL }"
}
}
stage('Prepare') {
steps {
script {
// 获取当前最新 commit 哈希(短版本,更易读)
env.COMMIT_HASH = sh(
script: 'git rev-parse --short HEAD',
returnStdout: true
).trim()
// 生成时间戳
env.TIMESTAMP = new Date().format('yyyyMMdd-HHmmss')
// 组合成最终的IMAGE_TAG
env.IMAGE_TAG = "${env.COMMIT_HASH}-${env.TIMESTAMP}"
echo "当前最新提交哈希:${env.COMMIT_HASH}"
echo "构建时间戳:${env.TIMESTAMP}"
echo "最终镜像标签:${env.IMAGE_TAG}"
echo "Building docker image: ${PROJECT_NAME}:${env.IMAGE_TAG}"
}
}
}
stage('Build Docker Image') {
steps {
script {
sh """
docker build --no-cache -t ${PROJECT_NAME}:${env.IMAGE_TAG} -f Dockerfile-cicd . --network host
"""
}
}
}
stage('Push to Registry') {
steps {
script {
withCredentials([usernamePassword(credentialsId: 'DIGITALOCEAN-REGISTRY',
passwordVariable: 'REGISTRY_PASSWORD',
usernameVariable: 'REGISTRY_USERNAME')]) {
sh """
echo "Logging in to Docker registry"
echo \$REGISTRY_PASSWORD | docker login ${REPOSITORY_ADDR} -u \$REGISTRY_USERNAME --password-stdin
echo "Pushing docker image to registry"
docker tag ${PROJECT_NAME}:${env.IMAGE_TAG} ${REPOSITORY_ADDR}/${PROJECT_NAME}:${env.IMAGE_TAG}
docker push ${REPOSITORY_ADDR}/${PROJECT_NAME}:${env.IMAGE_TAG}
echo "Logging out from Docker registry"
docker logout ${REPOSITORY_ADDR}
"""
}
}
}
}
stage('Update Kubernetes Deployment') {
steps {
script {
// 使用withKubeConfig注入凭证
withKubeConfig([
credentialsId: "${KUBECTL_CREDENTIAL}",
serverUrl: "${API_SERVER}"
]) {
sh """
echo "Updating deployment image"
kubectl -n ${NAMESPACE_NAME} set image deployment/${DEPLOYMENT_NAME} ${CONTAINER_NAME}=${REPOSITORY_ADDR}/${PROJECT_NAME}:${env.IMAGE_TAG}
"""
}
}
}
}
stage('Add Change Cause') {
steps {
script {
echo "add CHANGE CAUSE"
def commitMessage = sh(
script: 'git show -s --pretty=%s',
returnStdout: true
).trim()
// 使用withKubeConfig注入凭证
withKubeConfig([
credentialsId: "${KUBECTL_CREDENTIAL}",
serverUrl: "${API_SERVER}"
]) {
sh """
echo "Updating deployment image"
kubectl annotate deployment/${DEPLOYMENT_NAME} -n ${NAMESPACE_NAME} kubernetes.io/change-cause="${commitMessage}"
echo "Deployment image updated successfully."
"""
}
}
}
}
}
post {
success {
script {
sendDiscordNotification('success')
}
}
failure {
script {
sendDiscordNotification('failure')
}
}
always {
// 清理本地镜像(可选)
script {
sh """
docker rmi ${PROJECT_NAME}:${env.IMAGE_TAG} || true
docker rmi ${REPOSITORY_ADDR}/${PROJECT_NAME}:${env.IMAGE_TAG} || true
"""
}
}
}
}
def sendDiscordNotification(String status) {
script {
try {
def epoch = sh(script: 'date +%s', returnStdout: true).trim()
def commitMessage = sh(script: 'git show -s --pretty=%s', returnStdout: true).trim()
def commitUser = sh(script: 'git show --format="%an <%ae>" --no-patch', returnStdout: true).trim()
// 转义特殊字符,避免JSON解析错误
commitMessage = commitMessage.replace('"', '\\"').replace('\n', '\\n')
commitUser = commitUser.replace('"', '\\"')
def color = status == 'success' ? '3066993' : '15158332'
def emoji = status == 'success' ? '🎉' : '❌'
def statusText = status == 'success' ? '✅ Success' : '❌ Failed'
def actionText = status == 'success' ? '构建完成' : '构建失败'
// 使用heredoc方式创建JSON,避免引号转义问题
def jsonFile = 'discord_payload.json'
writeFile file: jsonFile, text: """
{
"content": "@everyone",
"username": "CI · Build Bot",
"avatar_url": "https://i.imgur.com/your-bot-avatar.png",
"embeds": [
{
"title": "${emoji} ${TITLE} ${actionText}",
"description": "**状态:** ${statusText}\\n**结束时间:** <t:${epoch}:F>\\n**提交描述:** ${commitMessage}\\n**提交用户:** ${commitUser}",
"color": ${color}
}
]
}
"""
echo "发送Discord通知..."
echo "----------------------------------------------------------------------------------"
// 使用文件方式传递JSON数据,避免命令行引号问题
def curlResult = sh(
script: """
curl --connect-timeout 10 --retry 5 --retry-all-errors --retry-delay 2 \\
--proxy "${PROXY_URL}" \\
-s -w "%{http_code}" -o /tmp/discord_response.txt \\
-X POST \\
-H "Content-Type: application/json" \\
-d @${jsonFile} \\
"${WEBHOOK_URL}"
""",
returnStdout: true
).trim()
// 检查HTTP响应状态
if (curlResult == "200" || curlResult == "204") {
echo "✅ Discord通知发送成功 (HTTP ${curlResult})"
} else {
error "❌ Discord通知发送失败 (HTTP ${curlResult})"
}
// 清理临时文件
sh "rm -f ${jsonFile} /tmp/discord_response.txt"
} catch (Exception e) {
echo "⚠️ Discord通知发送失败: ${e.getMessage()}"
echo "继续执行流水线..."
}
}
}