使用 CNB 构建并部署maven项目

前言

​CNB(Cloud Native Build) 是腾讯云 CODING 团队推出的全新产品 "云原生构建",对标 GitHub;内置加速服务,可快速访问 GitHub、DockerHub 等资源;还能在 Pipeline 中将代码同步至其他平台,非常适合国内开发者。CNB 基于 Docker 生态,与 Github 等平台类似,开发者通过编写 yml 文件声明自己的流水线。

​本文将以 maven 项目为例介绍在 cnb 上自定义一个简单的流水线。

准备工作

流程分析

​一个简化的流程如下:

flowchart TD st([开发者提交代码]) --> op1[代码推送到 Git 仓库] op1 --> op2[触发 CI 工具] op2 --> op3[代码编译] op3 --> op4[构建产物] op4 --> op5[部署产物] op5 --> cond1{部署成功?} cond1 -- 是 --> op7[发布完成] cond1 -- 否 --> op6[失败通知] op7 --> e([流程结束]) op6 --> e

示例项目结构

假如你的项目结构是这样子的:

示例流水线文件

此处需注意yml语法,详见 YAML 语言教程- 阮一峰的网络日志

.cnb.yml 文件

在根目录创建 .cnb.yml 文件如下(可暂时跳转到分步说明阅读详细步骤):

其中 & 代表锚点, * 代表别名,可以用来引用; & 用来建立锚点,<< 表示合并到当前数据,* 用来引用锚点。

::: collapse

  • .cnb.yml 文件 (点击展开)

    yml 复制代码
    # .cnb.yml
    # ----------------------
    # 各阶段 Job 定义模板
    # ----------------------
    # 准备阶段
    prepare: &prepare
      script: |
        POMS=$(find . -name "pom.xml" | sort | paste -sd "," -)
        if [ "${CNB_IS_TAG}" = "true" ]; then
          TAG1=${CNB_BRANCH} 
          TAG2=latest
        else
          TAG1=${CNB_BRANCH}-${CNB_COMMIT_SHORT} 
          TAG2=${CNB_BRANCH}
        fi
        printf "##[set-output poms=%s]\n" "$POMS"
        printf "##[set-output tag1=%s]\n" "$TAG1"
        printf "##[set-output tag2=%s]\n" "$TAG2"
      exports:
        poms: POMS
        tag1: TAG1
        tag2: TAG2  
    #构建缓存镜像
    docker_cache: &docker_cache
      image: maven:3.9.9-eclipse-temurin-17
      type: docker:cache
      options:
        dockerfile: ./docker/cache.Dockerfile
        by: $POMS 
      exports:
        name: DOCKER_CACHE_IMAGE
    # Maven 打包
    maven_package: &maven_package
      image: $DOCKER_CACHE_IMAGE_NAME
      volumes:
        - /root/.m2:copy-on-write
      script: |
        mvn versions:set -DnewVersion=${TAG1} -DgenerateBackupPoms=false
        mvn clean -B package -DskipTests
    # Docker build && Docker push
    build_push: &build_push
      script: |
        IMAGE_BASE=${CNB_DOCKER_REGISTRY}/${CNB_REPO_SLUG_LOWERCASE}/$   {ARTIFACT_ID_LOWERCASE} 
        docker build -f docker/Dockerfile -t ${IMAGE_BASE}:${TAG1} -t $  {IMAGE_BASE}:${TAG2} --build-arg ARTIFACT_ID=${ARTIFACT_ID}--build-arg   VERSION=${TAG1} .
        docker push ${IMAGE_BASE}:${TAG1}
        docker push ${IMAGE_BASE}:${TAG2}
    # SSH 部署
    ssh_deploy: &ssh_deploy
      image: docker.cnb.cool/falling42/ssh-deploy:v0.1.0
      imports: https://cnb.cool/<your_org>/<your_repo>/-/blob/main/yourenv.yml
      settings:
        use_screen: 'no'
        use_jump_host: 'no'
        ssh_host: ${your_ssh_host}
        ssh_user: ${your_ssh_user}
        ssh_private_key: ${your_ssh_private_key}
        execute_remote_script: 'yes'
        transfer_files: 'no'
        copy_script: 'yes'
        source_script: './docker/deploy.sh'
        deploy_script: '/opt/ops/deploy-demo.sh'
        service_name: ${ARTIFACT_ID}
        service_version: "${TAG1}"
    #失败通知:wechat-bot
    notify_wechat_bot: &notify_wechat_bot
      image: tencentcom/wecom-message
      settings:
        imports: https://cnb.cool/<your_org>/<your_repo>/-/blob/main/yourenvyml
        robot: ${your_webhook_url}
        msgType: text
        content: |
          🚨构  建  失  败  通  知🚨
          📦仓    库: ${CNB_REPO_SLUG_LOWERCASE}  
          👤发 起 人: ${CNB_BUILD_USER}   
          🛠️失败任务: ${CNB_BUILD_FAILED_STAGE_NAME}  
          👉查看详情: ${CNB_BUILD_WEB_URL} 
    #失败通知:wechat
    notify_wechat: &notify_wechat
      imports: https://cnb.cool/<your_org>/<your_repo>/-/blob/main/yourenv.yml
      image: clem109/drone-wechat
      settings:
        corpid: ${your_corpid}
        corp_secret: ${your_corp_secret}
        agent_id: ${your_agent_id}
        to_user: ${your_user_id}
        msg_url: ${CNB_BUILD_WEB_URL}
        safe: 0
        btn_txt: 查看详情
        title: ${CNB_REPO_SLUG_LOWERCASE} 构建失败通知
        description: "发起人: ${CNB_BUILD_USER}\n失败任务: $   {CNB_BUILD_FAILED_STAGE_NAME}\n点击查看详情: ${CNB_BUILD_WEB_URL}\n"
    # 失败通知:serverchan
    notify_serverchan: &notify_serverchan
      image: yakumioto/drone-serverchan
      imports: https://cnb.cool/<your_org>/<your_repo>/-/blob/main/yourenv.yml
      settings:
        key: ${your_sct_key}
        text: 🚨 构建失败通知
        desp: |
          > **📦 仓库:** ${CNB_REPO_SLUG_LOWERCASE}  
          > **👤 发起人:** ${CNB_BUILD_USER}   
          > **🛠️ 失败任务:** ${CNB_BUILD_FAILED_STAGE_NAME}  
          > **[👉 点击查看完整构建日志](${CNB_BUILD_WEB_URL})**  
    # 失败通知:email
    notify_email: &notify_email
      image: drillster/drone-email
      imports: https://cnb.cool/<your_org>/<your_repo>/-/blob/main/yourenv.yml
      settings:
        host: ${your_smtp_host}$
        port: 465
        recipients: ${CNB_COMMITTER_EMAIL}
        username: ${your_smtp_user}
        password: ${your_smtp_password}
        from.address: ${your_smtp_user}
        from.name: CNB构建通知
        subject: ${CNB_REPO_SLUG_LOWERCASE} 构建失败通知
        body: |
          <div style="font-family: Arial, sans-serif; padding: 12px; border:1px    solid #eee;">
            <h2 style="color: #D32F2F;">🚨 构建失败通知</h2>
            <p><strong>📦 仓库:</strong> ${CNB_REPO_SLUG_LOWERCASE}</p>
            <p><strong>👤 发起人:</strong> ${CNB_BUILD_USER}(ID: $  {CNB_BUILD_USER_ID})</p>
            <p><strong>🛠️ 失败任务:</strong> ${CNB_BUILD_FAILED_STAGE_NAME}<p>
            <p style="margin-top: 20px;">
              👉 <a href="${CNB_BUILD_WEB_URL}" style="color: #1976D2;   text-decoration: none;">
              点击查看完整构建日志</a>
            </p>
            <hr style="margin-top: 24px;"/>
            <p style="font-size: 12px; color: #888;">
              来自 CNB构建通知 
            </p>
          </div>
    # 定义要并行构建的模块
    Frontend: &Frontend
      ARTIFACT_ID: Demo-Frontend
      ARTIFACT_ID_LOWERCASE: demo-frontend
    Backend: &Backend
      ARTIFACT_ID: Demo-Backend
      ARTIFACT_ID_LOWERCASE: demo-backend
    # ----------------------
    # 并行 Jobs 定义模板
    # ----------------------
    build_push_jobs: &build_push_jobs
      Frontend: { <<: *build_push, env: { <<: *Frontend } }
      Backend: { <<: *build_push, env: { <<: *Backend } }
    deploy_jobs: &deploy_jobs
      Frontend: { <<: *ssh_deploy, env: { <<: *Frontend } }
      Backend: { <<: *ssh_deploy, env: { <<: *Backend } }
    # ----------------------
    # 主 Pipeline 定义模板
    # ----------------------
    # ----------------------
    pipeline: &pipeline
      name: Demo
      runner:
        tags: cnb:arch:amd64
        cpus: 4
      services:
        - docker
      stages:
        - name: prepare
          <<: *prepare
        - name: build cache image
          <<: *docker_cache
        - name: maven package
          <<: *maven_package
        - name: build push
          jobs:
            <<: *build_push_jobs
        - name: ssh deploy
          jobs:
            <<: *deploy_jobs
      failStages:
        - name: notify
          jobs:
            notify-email:
              <<: *notify_email
            notify-wechat:
              <<: *notify_wechat
            notify-wechat-bot:
              <<: *notify_wechat_bot
            notify-serverchan:
              <<: *notify_serverchan
    # ----------------------
    # 分支触发定义
    # ----------------------
    main:
      push:
        - <<: *pipeline
    "**":
      web_trigger_one:
        - <<: *pipeline
    $:
      tag_push:
        - <<: *pipeline

:::

.cnb/web_trigger.yml 文件

在 项目根目录创建 .cnb/web_trigger.yml 文件,用于给云原生构建的项目首页配置构建按钮

注意event的名字要与 .cnb.yml 文件中的触发条件相对应

更多个性化配置详见 手动触发流水线

yml 复制代码
# .cnb/web_trigger.yml
branch:
  - buttons: # 自定义按钮
      - name: 手动构建
        description: 手动构建
        event: web_trigger_one

分步说明

一个流水线的执行过程是:

仓库发生事件 -> 确定所属分支 -> 确定事件名 -> 执行流水线 -> 执行任务 -> 失败时的任务

触发事件

这部分算是整个流水线的入口,决定何时触发流水线任务

更多触发事件请查看 触发事件

yml 复制代码
# Branch 事件:远端代码分支变动触发的事件。
main: # 分支名称
  push: # git 仓库事件,分支 push 时触发。
    - <<: *pipeline # 流水线配置
## 注意这个'-' 因为将pipeline合并到当前数据时候pipeline的配置无'-',这里不加'-'则无法解析流水线任务
# web_trigger 自定义事件
"**": # ** 代表匹配所有分支名
  web_trigger_one: # 名字随意,不重复即可
    - <<: *pipeline

# Tag 事件:由远端代码和页面 Tag 相关操作触发的事件。
$: # 对所有 tag 生效
  tag_push: # 页面或者 git 创建并推送新 tag 时触发
    - <<: *pipeline

流水线配置

这部分包括配置需要的构建环境以及配置流水线步骤

更多配置请查看 Pipeline

yml 复制代码
pipeline: &pipeline
  name: Demo #流水线名字
  # --- 构建环境配置 ---
  runner:
    tags: cnb:arch:amd64 # 指定使用具备哪些标签的构建节点。例如cnb:arch:arm64:v8
    cpus: 4 # 指定构建需使用的最大 cpu 核数(memory = cpu 核数 * 2 G)
  services: # 用于声明构建时需要的服务,格式:name:[version], version 是可选的。
    - docker # 用于开启 dind 服务,当构建过程中需要使用 docker build,docker login 等操作时声明, 会自动在环境注入 docker daemon 和 docker cli。
  # --- 构建环境配置 结束---
  # --- 执行任务(s) ---
  stages: # 定义一组阶段任务,每个阶段串行运行。Stage 表示一个构建阶段,可以由一个或者多个 Job 组成。每一个"-"开头就代表一个stage(在yml中一组连词线开头的行,构成一个数组。)
    - name: prepare # 只有一个 Job,省略 "jobs:"(省掉 Stage 直接书写这个 Job)
      <<: *prepare
    - name: build cache image
      <<: *docker_cache
    - name: maven package
      <<: *maven_package
    - name: build push
      jobs: # 定义一组任务,每个任务串行/并行运行。本文这里使用并行运行。请见后面的说明。
        <<: *build_push_jobs
    - name: ssh deploy
      jobs: # 定义一组任务,每个任务串行/并行运行。本文这里使用并行运行。请见后面的说明。
        <<: *deploy_jobs
  # --- 执行任务(s) 结束 ---
  # --- 在执行任务失败时执行的任务 --- 
  failStages: # 定义一组失败阶段任务。当正常流程失败,会依次执行此阶段任务。
    - name: notify
      jobs: # 定义一组任务,每个任务串行/并行运行。本文这里使用并行运行。当值为对象(无序)时,那么这组 Job 会并行执行。(对象的一组键值对,使用冒号结构表示。)
        notify-email:
          <<: *notify_email
        notify-wechat:
          <<: *notify_wechat
        notify-wechat-bot:
          <<: *notify_wechat_bot
        notify-serverchan:
          <<: *notify_serverchan

env

由于本文的示例项目使用了两个子项目,为了避免重复配置,本文使用了 yml 的引用结合 CNB 的 env 来声明要构建的多个项目。如果只是一个单体项目,按照此逻辑直接声明一个锚点即可。

yml 复制代码
# 定义要构建的项目, 下方的yml对象声明了每个项目的env,便于在后续流程中使用
Frontend: &Frontend
  ARTIFACT_ID: Demo-Frontend
  ARTIFACT_ID_LOWERCASE: demo-frontend
Backend: &Backend
  ARTIFACT_ID: Demo-Backend
  ARTIFACT_ID_LOWERCASE: demo-backend

准备阶段

这里使用一系列脚本任务,目的是为了统一构建产物的版本,其中:

POMS=$(find . -name "pom.xml" | sort | paste -sd "," -)

是将项目当中所有依赖文件找到存到一个变量里以便后续使用,单体项目可删去

bash 复制代码
if [ "${CNB_IS_TAG}" = "true" ]; then
  #判断是否为 tag_push 是则版本设置为tag和latest
  TAG1=${CNB_BRANCH} 
  TAG2=latest
else
  # 否则版本设置为 分支名-提交短哈希 (例如 dev-6bf073ba) 和 分支名 (例如 dev)
  TAG1=${CNB_BRANCH}-${CNB_COMMIT_SHORT} 
  TAG2=${CNB_BRANCH}
fi

是设置 maven 构建出的产物的版本 $TAG1 和 docker 镜像的 tag 标签 $TAG1$TAG2

后续的 printf 是根据 CNB 导出环境变量的标准把变量通过 exports 导出

可使用 printf "%s" "hello\nworld" 来输出变量,以消除标准输出流最后的换行符,同时保留 \n 等转义字符。

详细信息请看 导出环境变量

完整步骤如下:

yml 复制代码
prepare: &prepare
  script: |
    POMS=$(find . -name "pom.xml" | sort | paste -sd "," -)
    if [ "${CNB_IS_TAG}" = "true" ]; then
      TAG1=${CNB_BRANCH} 
      TAG2=latest
    else
      TAG1=${CNB_BRANCH}-${CNB_COMMIT_SHORT} 
      TAG2=${CNB_BRANCH}
    fi
    printf "##[set-output poms=%s]\n" "$POMS"
    printf "##[set-output tag1=%s]\n" "$TAG1"
    printf "##[set-output tag2=%s]\n" "$TAG2"
  exports:
    poms: POMS
    tag1: TAG1
    tag2: TAG2  

构建缓存镜像

更多信息请看 流水线缓存docker:cache

yml 复制代码
docker_cache: &docker_cache
  image: maven:3.9.9-eclipse-temurin-17 # 基础镜像
  type: docker:cache # 设置内置任务为 docker:cache
  options:
    dockerfile: ./docker/cache.Dockerfile # 用于构建缓存镜像的 Dockerfile 路径。
    by: $POMS # 用来声明缓存镜像构建过程中依赖的文件列表。注意:未出现在 by 列表中的文件,除了 Dockerfile,其他在构建镜像过程中,都当不存在处理。这里本文使用准备阶段找到的pom.xml文件(s),单体项目直接写根目录pom.xml即可
  exports: # 把镜像名字导出供后续使用
    name: DOCKER_CACHE_IMAGE

./docker/cache.Dockerfile 文件:

注意:

  • 基础镜像根据自己项目修改
  • mvn 命令 -P pro 需要根据自己项目删改
Dockerfile 复制代码
# 使用带 Maven 和 JDK 17 的基础镜像
FROM maven:3.9.9-eclipse-temurin-17

# 复制项目代码到容器中
COPY . .

# 根据 COPY 过来的文件进行依赖的安装
RUN mvn -B \
    --file pom.xml \
    -P pro \
    -DskipTests \
    dependency:resolve-plugins dependency:go-offline \
    assembly:help compiler:help enforcer:help exec:help failsafe:help \
    install:help jar:help resources:help surefire:help \
    clean:help dependency:help site:help

# 设置好需要的环境变量(本文实际尚未使用)
ENV M2_PATH=/root/.m2

构建 maven 项目

yml 复制代码
maven_package: &maven_package
  image: $DOCKER_CACHE_IMAGE_NAME # 使用缓存镜像加快构建速度
  volumes: # 声明数据卷
    - /root/.m2:copy-on-write # 用于缓存场景,支持并发构建
  script: |
    mvn versions:set -DnewVersion=${TAG1} -DgenerateBackupPoms=false # 设置版本
    mvn clean -B package -DskipTests # 打包(跳过测试)

构建并推送 docker 镜像

一次 build,两个标签,两次 push,其中 ${TAG1} 是每次触发的不一样的版本,${TAG2} 用于固定(分支的)最新版

--build-arg ARTIFACT_ID=${ARTIFACT_ID} --build-arg VERSION=${TAG1}

根据自己情况修改构建参数

yml 复制代码
build_push: &build_push
  script: |
    IMAGE_BASE=${CNB_DOCKER_REGISTRY}/${CNB_REPO_SLUG_LOWERCASE}/${ARTIFACT_ID_LOWERCASE} 
    docker build -f docker/Dockerfile -t ${IMAGE_BASE}:${TAG1} -t ${IMAGE_BASE}:${TAG2} --build-arg ARTIFACT_ID=${ARTIFACT_ID} --build-arg VERSION=${TAG1} .
    docker push ${IMAGE_BASE}:${TAG1}
    docker push ${IMAGE_BASE}:${TAG2}

因为本文是一次性构建了所有子项目,所以后续的构建镜像、运行镜像部分所有的子项目job要并行执行节省时间,单体项目填一个即可

yml 复制代码
build_push_jobs: &build_push_jobs # 一行一个对象,并行执行,注意合并env
  Frontend: { <<: *build_push, env: { <<: *Frontend } }
  Backend: { <<: *build_push, env: { <<: *Backend } }

docker/Dockerfile 示例,根据项目修改,注意版本的统一,本文统一版本的操作在准备阶段

Dockerfile 复制代码
FROM eclipse-temurin:17

# 注意构建时传递参数
ARG ARTIFACT_ID
ARG VERSION

ENV VERSION=${VERSION} \
    ARTIFACT_ID=${ARTIFACT_ID} \
    TZ=Asia/Shanghai \
    JAR_NAME=${ARTIFACT_ID}-${VERSION}.jar

# 设置时区、复制 jar 和 entrypoint 脚本并授权,全合并到一个 RUN 层中
WORKDIR /app

# 注意单体项目没有${ARTIFACT_ID}/
COPY ./${ARTIFACT_ID}/target/${JAR_NAME} /app/app.jar
COPY ./docker/entrypoint.sh /entrypoint.sh

RUN set -eux; \
    apt-get update && apt-get install -y --no-install-recommends tzdata && \
    ln -fs /usr/share/zoneinfo/$TZ /etc/localtime && \
    echo $TZ > /etc/timezone && \
    dpkg-reconfigure -f noninteractive tzdata && \
    chmod +x /entrypoint.sh && \
    apt-get clean && rm -rf /var/lib/apt/lists/*

ENTRYPOINT ["/entrypoint.sh"]

docker/entrypoint.sh 示例,根据项目修改

bash 复制代码
#!/bin/bash
set -eux
exec java -Xmx2048m -Xms1024m -jar /app/app.jar "$@"

在目标环境中运行 docker 镜像

(不推荐)部署这里本文使用了自己制作的部署工具,详细信息在这里 ssh-deploy

(推荐)也可以使用官方的ssh插件 ssh

其中小写字母的主机凭据变量要通过密钥仓库引入并配置好权限,详见 imports 权限检查

yml 复制代码
ssh_deploy: &ssh_deploy
  image: docker.cnb.cool/falling42/ssh-deploy:v0.1.0
  imports: https://cnb.cool/<your_org>/<your_repo>/-/blob/main/yourenv.yml
  # 导入密钥仓库变量
  settings:
    use_screen: 'no'
    use_jump_host: 'no'
    ssh_host: ${your_ssh_host}
    ssh_user: ${your_ssh_user}
    ssh_private_key: ${your_ssh_private_key}
    execute_remote_script: 'yes'
    transfer_files: 'no'
    copy_script: 'yes'
    source_script: './docker/deploy.sh'
    deploy_script: '/opt/ops/deploy-demo.sh'
    service_name: ${ARTIFACT_ID}
    service_version: "${TAG1}"

因为本文是一次性构建了所有子项目,所以后续的构建镜像、运行镜像部分所有的子项目job要并行执行节省时间,单体项目填一个即可

yml 复制代码
deploy_jobs: &deploy_jobs # 一行一个对象,并行执行,注意合并env
  Frontend: { <<: *ssh_deploy, env: { <<: *Frontend } }
  Backend: { <<: *ssh_deploy, env: { <<: *Backend } }

失败通知

其中小写字母的变量要通过密钥仓库引入并配置好权限,详见 imports 权限检查

yml 复制代码
#失败通知:wechat-bot
notify_wechat_bot: &notify_wechat_bot
  image: tencentcom/wecom-message
  settings:
    imports: https://cnb.cool/<your_org>/<your_repo>/-/blob/main/yourenv.yml
    robot: ${your_webhook_url}
    msgType: text
    content: |
      🚨构  建  失  败  通  知🚨
      📦仓    库: ${CNB_REPO_SLUG_LOWERCASE}  
      👤发 起 人: ${CNB_BUILD_USER}   
      🛠️失败任务: ${CNB_BUILD_FAILED_STAGE_NAME}  
      👉查看详情: ${CNB_BUILD_WEB_URL} 

#失败通知:wechat
notify_wechat: &notify_wechat
  imports: https://cnb.cool/<your_org>/<your_repo>/-/blob/main/yourenv.yml
  image: clem109/drone-wechat
  settings:
    corpid: ${your_corpid}
    corp_secret: ${your_corp_secret}
    agent_id: ${your_agent_id}
    to_user: ${your_user_id}
    msg_url: ${CNB_BUILD_WEB_URL}
    safe: 0
    btn_txt: 查看详情
    title: ${CNB_REPO_SLUG_LOWERCASE} 构建失败通知
    description: "发起人: ${CNB_BUILD_USER}\n失败任务: ${CNB_BUILD_FAILED_STAGE_NAME}\n点击查看详情: ${CNB_BUILD_WEB_URL}\n"

# 失败通知:serverchan
notify_serverchan: &notify_serverchan
  image: yakumioto/drone-serverchan
  imports: https://cnb.cool/<your_org>/<your_repo>/-/blob/main/yourenv.yml
  settings:
    key: ${your_sct_key}
    text: 🚨 构建失败通知
    desp: |
      > **📦 仓库:** ${CNB_REPO_SLUG_LOWERCASE}  
      > **👤 发起人:** ${CNB_BUILD_USER}   
      > **🛠️ 失败任务:** ${CNB_BUILD_FAILED_STAGE_NAME}  
      > **[👉 点击查看完整构建日志](${CNB_BUILD_WEB_URL})**  

# 失败通知:email
notify_email: &notify_email
  image: drillster/drone-email
  imports: https://cnb.cool/<your_org>/<your_repo>/-/blob/main/yourenv.yml
  settings:
    host: ${your_smtp_host}$
    port: 465
    recipients: ${CNB_COMMITTER_EMAIL}
    username: ${your_smtp_user}
    password: ${your_smtp_password}
    from.address: ${your_smtp_user}
    from.name: CNB构建通知
    subject: ${CNB_REPO_SLUG_LOWERCASE} 构建失败通知
    body: |
      <div style="font-family: Arial, sans-serif; padding: 12px; border: 1px solid #eee;">
        <h2 style="color: #D32F2F;">🚨 构建失败通知</h2>
        <p><strong>📦 仓库:</strong> ${CNB_REPO_SLUG_LOWERCASE}</p>
        <p><strong>👤 发起人:</strong> ${CNB_BUILD_USER}(ID: ${CNB_BUILD_USER_ID})</p>
        <p><strong>🛠️ 失败任务:</strong> ${CNB_BUILD_FAILED_STAGE_NAME}</p>
        <p style="margin-top: 20px;">
          👉 <a href="${CNB_BUILD_WEB_URL}" style="color: #1976D2; text-decoration: none;">
          点击查看完整构建日志</a>
        </p>
        <hr style="margin-top: 24px;"/>
        <p style="font-size: 12px; color: #888;">
          来自 CNB构建通知
        </p>
      </div>
相关推荐
重庆小透明20 分钟前
力扣刷题记录【1】146.LRU缓存
java·后端·学习·算法·leetcode·缓存
博观而约取1 小时前
Django 数据迁移全解析:makemigrations & migrate 常见错误与解决方案
后端·python·django
寻月隐君2 小时前
Rust 异步编程实践:从 Tokio 基础到阻塞任务处理模式
后端·rust·github
GO兔2 小时前
开篇:GORM入门——Go语言的ORM王者
开发语言·后端·golang·go
Sincerelyplz2 小时前
【Temproal】快速了解Temproal的核心概念以及使用
笔记·后端·开源
爱上语文2 小时前
Redis基础(6):SpringDataRedis
数据库·redis·后端
Lemon程序馆2 小时前
速通 GO 垃圾回收机制
后端·go
Aurora_NeAr2 小时前
Spark SQL架构及高级用法
大数据·后端·spark
杰尼橙子2 小时前
DPDK BPF:将eBPF虚拟机的灵活性带入到了DPDK的高性能用户态
后端·性能优化
代码老y2 小时前
Spring Boot + 本地部署大模型实现:优化与性能提升
java·spring boot·后端