本文目的:在一套离线内网环境中,从零搭建一条"代码提交即自动上线"的完整 CI/CD 流水线——GitLab 管代码、Harbor 存镜像、Jenkins 调 K8s 动态 Agent 跑流水线、Trivy 扫镜像漏洞,最后一条 Webhook 触发全自动发布。

结论先行:只要按本文顺序把 Harbor、GitLab、Jenkins 三套基础设施部署好、把凭据和 K8s 动态 Agent 打通,剩下的核心就是一份约 200 行的 Jenkinsfile——它串联起「拉代码 → 构建镜像 → 漏洞扫描 → 部署上线」四个阶段。整条链路跑通后,开发者只需 git push 一次代码,其余全部自动化完成。


目录


一、环境与整体架构

1.1 服务器规划

本方案共 5 台机器:2 台 Docker 主机负责跑 GitLab / Harbor / Jenkins 等基础设施,3 台 K8s 节点承载应用运行与动态构建 Agent。

主机名内网 IP(已脱敏,按需替换)角色
docker-1192.168.188.30Harbor + GitLab
docker-2192.168.188.70Jenkins
K8s-master192.168.188.170控制面
K8s-node1192.168.188.171工作节点
K8s-node2192.168.188.172工作节点

1.2 完整流程链路

开发者 push 代码
      │
      ▼
   GitLab ──(Webhook)──▶ Jenkins
                            │
                            ├─▶ 拉取代码(GitLab)
                            ├─▶ 构建镜像(K8s 动态 Agent + dind)
                            ├─▶ Trivy 漏洞扫描
                            ├─▶ 推送镜像到 Harbor
                            └─▶ kubectl 部署到 K8s (production 命名空间)

1.3 技术栈清单

组件版本用途
Harborv2.10.3私有镜像仓库(含 Trivy 扫描)
GitLab CE16.11.5代码仓库 + Webhook 触发
Jenkins2.541.3-jdk17CI/CD 编排引擎
Kubernetes1.36应用运行 + 动态构建 Agent
Trivy0.76.1镜像漏洞扫描
Go1.27-alpine3.23示例应用语言

文中所有密码与内网 IP 已做脱敏处理,实际部署时请替换为自己的环境值。


二、部署 Harbor 私有镜像仓库

在 docker-1 上操作。Harbor 是整套 CI/CD 的镜像中枢,先部署它是后续一切的前提。

2.1 配置 Docker 并下载 Harbor

cat > /etc/docker/daemon.json << 'EOF'
{
  "insecure-registries": ["192.168.188.30:8080"],
  "registry-mirrors": [
    "https://docker.1ms.run",
    "https://docker.xuanyuan.me",
    "https://docker.m.daocloud.io"
  ],
  "exec-opts": ["native.cgroupdriver=systemd"],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "3"
  }
}
EOF

systemctl daemon-reload && sudo systemctl restart docker
docker info | grep -A2 -i insecure

# 下载离线安装包
wget https://github.com/goharbor/harbor/releases/download/v2.10.3/harbor-offline-installer-v2.10.3.tgz -O harbor-offline-installer.tgz
tar -xzf harbor-offline-installer.tgz
cd harbor

由于是内网环境,这里直接把 Harbor 地址加入 insecure-registries,跳过 HTTPS 证书配置;生产环境强烈建议启用 HTTPS。

2.2 修改配置并安装

cp harbor.yml.tmpl harbor.yml
sed -i 's/^hostname: reg.mydomain.com/hostname: <HARBOR_IP>/' harbor.yml
sed -i 's/^  port: 80/  port: 8080/' harbor.yml
sed -i 's/^  port: 443/#  port: 443/' harbor.yml
sed -i 's/^  certificate: /#  certificate: /' harbor.yml
sed -i 's/^  private_key: /#  private_key: /' harbor.yml
sed -i 's/^harbor_admin_password: Harbor12345/harbor_admin_password: Root1234/' harbor.yml

./install.sh --with-trivy
docker ps | grep harbor
curl -s http://192.168.188.30:8080/api/v2.0/health

访问:http://192.168.188.30:8080(账号 admin)

2.3 新建项目

登录 Harbor 后,创建一个名为 cicd 的非公开项目,用于存放后续的镜像。
在这里插入图片描述

2.4 Harbor 上传测试

# 测试上传
docker pull hello-world
docker tag hello-world 192.168.188.30:8080/cicd/hello-world:latest
docker login 192.168.188.30:8080 -u admin -p Root1234
docker push 192.168.188.30:8080/cicd/hello-world:latest

看到 Pushed 即说明 Harbor 工作正常。


三、部署 GitLab 代码仓库

在 docker-1 上操作。GitLab 负责代码托管,并作为 Webhook 的触发源头。

3.1 编写 docker-compose 启动 GitLab

docker pull gitlab/gitlab-ce:16.11.5-ce.0
mkdir -p /root/gitlab
cd /root/gitlab

cat > /root/gitlab/docker-compose.yml << 'EOF'
services:
  gitlab:
    image: gitlab/gitlab-ce:16.11.5-ce.0
    container_name: gitlab
    restart: always
    hostname: gitlab.local
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'http://192.168.188.30'
        gitlab_rails['gitlab_shell_ssh_port'] = 2222
        gitlab_rails['time_zone'] = 'Asia/Shanghai'
        prometheus_monitoring['enable'] = false
        registry['enable'] = false
        nginx['listen_port'] = 80
    ports:
      - "80:80"
      - "2222:22"
    volumes:
      - /root/gitlab/config:/etc/gitlab
      - /root/gitlab/logs:/var/log/gitlab
      - /root/gitlab/data:/var/opt/gitlab
    shm_size: '256m'
EOF

docker compose up -d
docker compose logs -f --tail=20
# 获取初始 root 密码
docker exec gitlab grep 'Password:' /etc/gitlab/initial_root_password 2>/dev/null

这里把 SSH 端口映射到宿主机的 2222,避免和宿主机自带的 22 端口冲突。

3.2 初始化配置

  1. 访问 http://192.168.188.30,用 root 账号登录。
  2. 切换中文:左上角头像 → Preferences → Preferences → 语言选中文。
  3. 修改密码:左上角头像 → 偏好设置 → 密码。
  4. 关闭公开注册:左下角管理中心 → 设置 → 通用 → 注册限制 → 取消勾选「已启用注册功能」。
  5. 创建项目:左上角 + → 新建项目/仓库 → 新建空白项目(后续演示用 demo-app)。

在这里插入图片描述


四、K8s 节点信任 Harbor

在 master、node1、node2 三台 K8s 节点上执行。K8s 用 containerd 拉镜像,需单独配置 Harbor 信任,与 Docker 的 insecure-registries 相互独立。

# master、node1、node2 执行
mkdir -p /etc/containerd/certs.d/192.168.188.30:8080

tee /etc/containerd/certs.d/192.168.188.30:8080/hosts.toml >/dev/null <<'EOF'
server = "http://192.168.188.30:8080"
[host."http://192.168.188.30:8080"]
  capabilities = ["pull", "resolve"]
  skip_verify = true
  [host."http://192.168.188.30:8080".header]
    authorization = "Basic YWRtaW46Um9vdDEyMzQ="
EOF
# authorization 对应的值是密码,被 base 64 转成了 YWRtaW46Um9vdDEyMzQ=

systemctl restart containerd && systemctl status containerd --no-pager
crictl pull 192.168.188.30:8080/cicd/hello-world:latest
crictl images | grep 192.168.188.30:8080/cicd/hello-world

authorization 通过 echo -n 'admin:yijiuruchu' | base64 生成,用于 containerd 免密拉取私有镜像。


五、预下载并导入 Agent / 基础镜像

在 docker-1 上操作。由于内网访问公网镜像源不稳定,这里先在一台能联网的 Docker 机上统一拉取镜像,再分发到各节点,避免构建时拉镜像失败。

5.1 拉取 Agent 镜像与基础镜像

IMAGES=(
  "jenkins/inbound-agent:latest-jdk21"
  "docker:27.3.1-dind"
  "heyvaldemar/kubectl:1.36"
  "golang:1.27-alpine3.23"
  "alpine:3.23"
  "aquasec/trivy:0.76.1"
  "ghcr.io/aquasecurity/trivy-db:2"
)

for img in "${IMAGES[@]}"; do docker pull --platform linux/amd64 "$img"; done
for img in "${IMAGES[@]}"; do docker images | grep "$img"; done

5.2 导出并导入 K8s 节点

# 打包 Agent 相关镜像
docker save \
  jenkins/inbound-agent:latest-jdk21 \
  docker:27.3.1-dind \
  heyvaldemar/kubectl:1.36 \
  -o /root/jenkins-agent-images.tar

# 分发到三台 K8s 节点并导入
for ip in 192.168.188.170 192.168.188.171 192.168.188.172; do
  scp /root/jenkins-agent-images.tar root@$ip:/root/
  ssh root@$ip "ctr -n k8s.io images import /root/jenkins-agent-images.tar"
done

5.3 基础镜像推入 Harbor

# 登录
docker login 192.168.188.30:8080 -u admin -p Root1234

# tag
docker tag golang:1.27-alpine3.23  192.168.188.30:8080/cicd/golang:1.27-alpine3.23
docker tag alpine:3.23            192.168.188.30:8080/cicd/alpine:3.23
docker tag aquasec/trivy:0.76.1   192.168.188.30:8080/cicd/trivy:0.76.1
docker tag ghcr.io/aquasecurity/trivy-db:2 192.168.188.30:8080/cicd/trivy-db:2

# 上传
docker push 192.168.188.30:8080/cicd/golang:1.27-alpine3.23
docker push 192.168.188.30:8080/cicd/alpine:3.23
docker push 192.168.188.30:8080/cicd/trivy:0.76.1
docker push 192.168.188.30:8080/cicd/trivy-db:2

六、部署 Jenkins

在 docker-2 上操作。Jenkins 是整个流水线的调度中枢。

6.1 配置 Docker 并拉取镜像

cat > /etc/docker/daemon.json << 'EOF'
{
  "insecure-registries": ["192.168.188.30:8080"],
  "registry-mirrors": [
    "https://docker.1ms.run",
    "https://docker.xuanyuan.me",
    "https://docker.m.daocloud.io"
  ],
  "exec-opts": ["native.cgroupdriver=systemd"],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "3"
  }
}
EOF

systemctl daemon-reload && systemctl restart docker
docker pull --platform linux/amd64 jenkins/jenkins:2.541.3-jdk17
mkdir -p /root/jenkins

6.2 编写 docker-compose 启动 Jenkins

cat > /root/jenkins/docker-compose.yml <<'EOF'
services:
  jenkins:
    image: jenkins/jenkins:2.541.3-jdk17
    container_name: jenkins
    restart: always
    user: root
    environment:
      JAVA_OPTS: "-Xms512m -Xmx2g"
    ports:
      - "8080:8080"
      - "50000:50000"
    volumes:
      - /root/jenkins/data:/var/jenkins_home
      - /var/run/docker.sock:/var/run/docker.sock
    shm_size: "256m"
    deploy:
      resources:
        limits:
          memory: 4G
EOF

# 放行 Agent 回连端口
ufw allow 8080/tcp
ufw allow 50000/tcp
ufw reload

# 启动 Jenkins
cd /root/jenkins
docker compose up -d
# 获取初始管理员密码
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword

6.3 初始化与插件安装

  1. 访问 http://192.168.188.70:8080,输入初始密码,选择「安装推荐的插件」。
  2. 创建管理员账号(用户名:root,密码:yijiuruchu、邮箱按需填写)。
  3. 切换暗色主题:右上角头像 → Preferences → Appearance → 选暗色主题。
  4. 安装必要插件:右上角设置 → 插件管理 → Available plugins,安装以下插件:
    • Kubernetes
    • Gitlab
    • Docker Pipeline

7. 配置 Jenkins 对接 K8s Cloud

Jenkins 通过 Kubernetes 插件在 K8s 中动态拉起构建 Agent Pod,构建完自动销毁,这是整套方案的核心亮点——不再需要固定 Agent 节点。

7.1 K8s 侧准备(master 执行)

# 获取 CA 证书(配置 Jenkins 时要用)
kubectl config view --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d

kubectl create namespace jenkins # 创建命名空间
kubectl -n jenkins create serviceaccount jenkins-agent # 创建账号

# 赋予 Agent 操作 Pod 的权限
kubectl -n jenkins create role jenkins-agent-role \
  --verb=get,list,watch,create,delete,patch,update \
  --resource=pods,pods/exec,pods/log,secrets,configmaps

kubectl -n jenkins create rolebinding jenkins-agent \
  --role=jenkins-agent-role --serviceaccount=jenkins:jenkins-agent

# 生成 10 年有效期的 Token
kubectl -n jenkins create token jenkins-agent --duration=87600h

生成 10 年 Token 是为了省去反复续期;生产环境建议缩短有效期并配合轮换机制。

7.2 Jenkins 侧配置 Cloud

右上角设置 → Clouds → new clouds → Cloud name:k8s(勾选「固定节点」),填写:

  • 名称:k8s
  • Kubernetes 地址:https://192.168.188.170:6443
  • Kubernetes 服务证书 key:上面获取的 CA 证书
  • Kubernetes 命名空间:jenkins
  • 凭证:添加 → Secret text(全局,secret 填 10 年 Token,ID 建议 k8s-agent-token)
  • Jenkins 地址:http://192.168.188.70:8080
  • 容器数量:5

在这里插入图片描述


八、配置凭据与部署密钥

凭据是 Jenkins 连接 GitLab / Harbor / K8s 的「钥匙」,务必在流水线跑之前配齐。

8.1 配置 GitLab / Harbor 凭据

右上角设置 → 凭证管理 → 添加凭证,共两个:

凭证类型范围UsernamePasswordID
Harbor 凭证Username with password全局adminRoot1234harbor-credentials
GitLab 凭证Username with password全局rootyijiuruchugitlab-credentials

8.2 创建 K8s 部署凭据(production 命名空间)

kubectl create namespace production
kubectl -n production create serviceaccount jenkins-deployer

# verb 必须包含 create——首次部署 kubectl apply 不存在的资源需要 create 权限,
# 否则报 Forbidden (cannot create resource "deployments")
kubectl -n production create role jenkins-deployer \
  --verb=get,list,create,patch,update,watch \
  --resource=deployments,deployments/rollback,replicasets,services,configmaps

kubectl -n production create rolebinding jenkins-deployer \
  --role=jenkins-deployer --serviceaccount=production:jenkins-deployer

# 用 kubectl create token 生成长期 token
# (K8s 1.24+ 手工创建 Secret 会被回收,导致部署时 401 Unauthorized)
# --duration 设 10 年近似永久
TOKEN=$(kubectl -n production create token jenkins-deployer --duration=87600h)
APISERVER="https://192.168.188.170:6443"
CA_DATA=$(kubectl config view --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')

cat > kubeconfig-deployer <<EOF
apiVersion: v1
kind: Config
clusters:
- cluster:
    certificate-authority-data: ${CA_DATA}
    server: ${APISERVER}
  name: production
contexts:
- context:
    cluster: production
    namespace: production
    user: jenkins-deployer
  name: deployer
current-context: deployer
users:
- name: jenkins-deployer
  user:
    token: ${TOKEN}
EOF

# 将 kubeconfig-deployer 文件下载,再上传为 Jenkins 凭据

将生成的 kubeconfig-deployer 上传为 Jenkins 凭据:

  • 右上角设置 → 凭证管理 → 添加凭证 → 类型:Secret file
  • 范围:全局
  • File:上传 kubeconfig-deployer 文件
  • ID:kubeconfig
  • 描述:K8s 部署凭据

8.3 创建 Harbor 拉取密钥

# jenkins 命名空间(Agent Pod 用)
kubectl -n jenkins create secret docker-registry harbor-regcred \
  --docker-server=192.168.188.30:8080 \
  --docker-username=admin \
  --docker-password='Root1234'

# production 命名空间(应用镜像用)
kubectl -n production create secret docker-registry harbor-regcred \
  --docker-server=192.168.188.30:8080 \
  --docker-username=admin \
  --docker-password='Root1234'

# 验证
kubectl -n jenkins get secret harbor-regcred
kubectl -n production get secret harbor-regcred

九、编写应用代码与 Jenkinsfile

准备一个 Go 演示应用,连同 Jenkinsfile 一起推到 GitLab,作为流水线的最小可运行示例。

本节的几个文件共同构成一套完整的"代码 → 镜像 → 部署"闭环,它们的关联如下:

  • go.mod / main.go:应用的源代码,是流水线的输入。
  • Dockerfile:把源码编译成可运行镜像的配方,供 Jenkins 构建阶段使用。
  • deploy/production.yaml:应用在 K8s 里的部署清单,定义几个副本、怎么暴露端口。
  • Jenkinsfile:流水线的总指挥,把前三个文件串起来按阶段自动执行。
go.mod + main.go ──(被 Dockerfile 引用编译)──▶ Dockerfile ──(构建)──▶ demo-app 镜像
                                                        │
deploy/production.yaml ──(引用 demo-app 镜像)──▶ K8s Deployment + Service
                                                        │
Jenkinsfile ──(按顺序驱动上述所有步骤)──▶ 拉代码 → 构建 → 扫描 → 部署

9.1 授权 namespace 读取

kubectl create clusterrole jenkins-deployer-ns-reader \
  --verb=get --resource=namespaces

kubectl create clusterrolebinding jenkins-deployer-ns-reader \
  --clusterrole=jenkins-deployer-ns-reader \
  --serviceaccount=production:jenkins-deployer

作用:给部署账号 jenkins-deployer 追加一个只读 namespaces 的集群权限。因为 Jenkinsfile 部署阶段会先执行 kubectl get ns production 探测 API Server 是否可达,若账号连 namespace 的 get 权限都没有,这一步会直接失败。

9.2 初始化仓库

mkdir -p /root/demo-app/deploy
cd /root/demo-app
git init
git config --global user.email "root@example.com"
git config --global user.name "root"
git remote add origin http://192.168.188.30/root/demo-app.git
git branch -M main

作用:初始化本地 Git 仓库并关联到 GitLab 上的 demo-app 空项目,作为后续所有文件的载体。这里提前建好 deploy/ 目录,用于存放 K8s 部署清单。

9.3 应用源代码(go.mod 与 main.go)

# go.mod —— 声明模块名与 Go 版本
cat > go.mod << 'EOF'
module demo-app
go 1.22
EOF

# main.go —— 一个极简 HTTP 服务
cat > main.go << 'EOF'
package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, this is my Demo App running in K8s!")
    })
    fmt.Println("Server starting on port 8080...")
    http.ListenAndServe(":8080", nil)
}
EOF

作用:这是被部署的"业务本体"。go.mod 声明模块名 demo-app 和 Go 版本 1.22;main.go 用标准库 net/http 起了一个监听 8080 端口的 HTTP 服务,访问根路径返回一段问候文本。

关键逻辑:整个程序不依赖任何第三方库(只用标准库),因此后续 Dockerfile 里只需 COPY go.mod main.go 两个文件就能编译,无需 go mod download,构建快且离线环境无网络依赖。

关联:这两个文件是 Dockerfile 的编译输入。

9.4 镜像构建配方(Dockerfile)

# Dockerfile —— 多阶段构建,减小镜像体积
cat > Dockerfile << 'EOF'
FROM 192.168.188.30:8080/cicd/golang:1.27-alpine3.23 AS builder
WORKDIR /app
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o demo-app .

FROM 192.168.188.30:8080/cicd/alpine:3.23
WORKDIR /app
COPY --from=builder /app/demo-app .
EXPOSE 8080
CMD ["./demo-app"]
EOF

作用:把 Go 源码编译成可运行的容器镜像,并采用多阶段构建把最终镜像压到最小。

关键逻辑(两个阶段):

  1. builder 阶段:基于 Harbor 里的 golang:1.27-alpine3.23 基础镜像编译程序。CGO_ENABLED=0 关闭 CGO 生成纯静态二进制,-trimpath 去掉编译路径、-ldflags="-s -w" 剥离符号表和调试信息,三重手段让产物更小更干净。
  2. 运行阶段:切换到只有几 MB 的 alpine:3.23,用 COPY --from=builder 只把编译好的二进制拷进来,丢弃整个 Go 工具链。这样最终镜像里没有源码、没有编译器,体积小、攻击面也小。

关联:基础镜像 golang 和 alpine 都来自 Harbor(第五章已预推送);产物 demo-app 镜像会被 Jenkins 打上 commit 短哈希 tag 后推回 Harbor。

9.5 部署清单(deploy/production.yaml)

cat > deploy/production.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-app
  namespace: production
spec:
  replicas: 2
  selector:
    matchLabels:
      app: demo-app
  template:
    metadata:
      labels:
        app: demo-app
    spec:
      imagePullSecrets:
      - name: harbor-regcred
      containers:
      - name: demo-app
        image: 192.168.188.30:8080/cicd/demo-app:latest
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: demo-app
  namespace: production
spec:
  type: NodePort
  selector:
    app: demo-app
  ports:
  - port: 8080
    targetPort: 8080
    nodePort: 30080
EOF

作用:定义应用在 K8s 里的运行形态——跑 2 个副本、从私有 Harbor 拉镜像、通过 NodePort 对外暴露。

关键逻辑:

  • replicas: 2:两个副本保证高可用,任一节点挂了流量不中断。
  • imagePullSecrets: harbor-regcred:引用第八章在 production 命名空间创建的拉取密钥,让 K8s 能免密从 Harbor 拉私有镜像。
  • image: .../demo-app:latest:这里的 latest 只是占位,真正部署时 Jenkins 会把它替换成 commit 短哈希 版本。
  • Service 用 NodePort: 30080,集群任意节点访问 30080 端口即可直达服务,方便内网测试。

关联:imagePullSecrets 依赖第八章的 harbor-regcred;image 字段会被 Jenkinsfile 的部署阶段动态改写。

9.6 Jenkinsfile(流水线核心)

Jenkinsfile 是整套方案的大脑,全文约 200 行,一句话概括:在 K8s 里拉起一个临时 Pod 当构建环境,然后在里面依次执行"拉代码 → 构建推送镜像 → Trivy 扫描 → 部署上线"四个阶段。 下面按模块拆开讲。

整体骨架
cat > /root/demo-app/Jenkinsfile << 'EOF'
pipeline {
  agent {
    kubernetes {
      yaml '''
apiVersion: v1
kind: Pod
spec:
  imagePullSecrets:
  - name: harbor-regcred
  initContainers:
  - name: kubectl-bin
    image: heyvaldemar/kubectl:1.36
    imagePullPolicy: IfNotPresent
    securityContext:
      runAsUser: 0
    command: ["/bin/sh", "-c"]
    args:
      - |
        set -e
        KUBECTL_PATH=$(command -v kubectl)
        echo "found kubectl at: $KUBECTL_PATH"
        cp "$KUBECTL_PATH" /home/jenkins/agent/kubectl
        chmod 755 /home/jenkins/agent/kubectl
        /home/jenkins/agent/kubectl version --client
    volumeMounts:
    - mountPath: /home/jenkins/agent
      name: workspace-volume
  containers:
  - name: jnlp
    image: jenkins/inbound-agent:latest-jdk21
    imagePullPolicy: IfNotPresent
    tty: true
    workingDir: /home/jenkins/agent
  - name: docker
    image: docker:27.3.1-dind
    imagePullPolicy: IfNotPresent
    securityContext:
      privileged: true
    tty: true
    workingDir: /home/jenkins/agent
    resources:
      requests:
        cpu: "500m"
        memory: "1Gi"
      limits:
        cpu: "2"
        memory: "4Gi"
    command: ["/bin/sh", "-c"]
    args:
      - |
        mkdir -p /etc/docker
        cat > /etc/docker/daemon.json <<'DOCKERD_EOF'
        {
          "insecure-registries": ["192.168.188.30:8080"],
          "mtu": 1360
        }
        DOCKERD_EOF
        exec dockerd-entrypoint.sh --host=tcp://0.0.0.0:2375
    env:
    - name: DOCKER_TLS_CERTDIR
      value: ""
  volumes:
  - emptyDir: {}
    name: workspace-volume
'''
    }
  }

  environment {
    HARBOR_ADDR = '192.168.188.30:8080'
    REGISTRY    = '192.168.188.30:8080/cicd'
    APP_NAME    = 'demo-app'
    DOCKER_HOST = 'tcp://localhost:2375'
  }

  stages {
    stage('Checkout') {
      steps {
        git branch: 'main',
            url: 'http://192.168.188.30/root/demo-app.git',
            credentialsId: 'gitlab-credentials'
      }
    }

    stage('Build & Push Image') {
      steps {
        container('docker') {
          withCredentials([usernamePassword(credentialsId: 'harbor-credentials',
                                            usernameVariable: 'HARBOR_USER',
                                            passwordVariable: 'HARBOR_PASS')]) {
            sh """
              AUTH=\$(printf '%s:%s' "\${HARBOR_USER}" "\${HARBOR_PASS}" | base64 -w 0)
              mkdir -p ~/.docker
              printf '{"auths":{"${HARBOR_ADDR}":{"auth":"%s"}}}' "\$AUTH" > ~/.docker/config.json

              docker pull ${REGISTRY}/golang:1.27-alpine3.23
              docker pull ${REGISTRY}/alpine:3.23
              docker build -t ${REGISTRY}/${APP_NAME}:${env.GIT_COMMIT.take(7)} .
              docker push ${REGISTRY}/${APP_NAME}:${env.GIT_COMMIT.take(7)}
            """
          }
        }
      }
    }

    stage('Scan Image') {
      steps {
        container('docker') {
          withCredentials([usernamePassword(credentialsId: 'harbor-credentials',
                                            usernameVariable: 'HARBOR_USER',
                                            passwordVariable: 'HARBOR_PASS')]) {
            sh """
              docker run --rm \\
                -e TRIVY_DB_REPOSITORY=192.168.188.30:8080/cicd/trivy-db:2 \\
                192.168.188.30:8080/cicd/trivy:0.76.1 image --insecure \\
                --username \${HARBOR_USER} --password \${HARBOR_PASS} \\
                --severity HIGH,CRITICAL --exit-code 1 \\
                ${REGISTRY}/${APP_NAME}:${env.GIT_COMMIT.take(7)}
            """
          }
        }
      }
    }

    stage('Deploy to Production') {
      steps {
        container('docker') {
          withCredentials([file(credentialsId: 'kubeconfig', variable: 'KUBECONFIG_FILE')]) {
            sh """
              set -eux
              KUBECTL=/home/jenkins/agent/kubectl
              ls -l \$KUBECTL

              echo "STEP 1: kubectl client version"
              \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} version --client --request-timeout=10s

              echo "STEP 2: apiserver reachable"
              \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} get ns production --request-timeout=15s

              echo "STEP 3: deploy"
              if \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} -n production \\
                   get deployment ${APP_NAME} --request-timeout=15s >/dev/null 2>&1; then
                echo "deployment exists, set image only"
                \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} -n production \\
                  set image deployment/${APP_NAME} \\
                  ${APP_NAME}=${REGISTRY}/${APP_NAME}:${env.GIT_COMMIT.take(7)} \\
                  --request-timeout=30s
              else
                echo "first deploy: apply + set image"
                \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} -n production \\
                  apply -f deploy/production.yaml --request-timeout=30s
                \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} -n production \\
                  set image deployment/${APP_NAME} \\
                  ${APP_NAME}=${REGISTRY}/${APP_NAME}:${env.GIT_COMMIT.take(7)} \\
                  --request-timeout=30s
              fi

              echo "STEP 4: rollout status"
              \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} -n production \\
                rollout status deployment/${APP_NAME} --timeout=120s
            """
          }
        }
      }
    }
  }

  post {
    success { echo 'Pipeline succeeded.' }
    failure { echo 'Pipeline failed.' }
  }
}
EOF
拆解骨架各层含义
cat > /root/demo-app/Jenkinsfile << 'EOF'
pipeline {
  agent {
    kubernetes {
      yaml '''... Pod 定义 ...'''
    }
  }
  environment { ... 全局变量 ... }
  stages { ... 四个 stage ... }
  post { ... 结束钩子 ... }
}
EOF

作用:这是一份声明式(Declarative)流水线。最外层 pipeline 由五个块组成:

  • agent:指定用什么环境跑流水线,这里用 kubernetes 插件在 K8s 里动态建一个 Pod。
  • environment:定义全局变量,供各阶段引用。
  • stages:真正的执行步骤(四个 stage)。
  • post:流水线结束后(无论成败)的回调。
agent 动态 Pod 定义(核心)
  agent {
    kubernetes {
      yaml '''
apiVersion: v1
kind: Pod
spec:
  imagePullSecrets:
  - name: harbor-regcred
  initContainers:
  - name: kubectl-bin
    image: heyvaldemar/kubectl:1.36
    command: ["/bin/sh", "-c"]
    args:
      - |
        set -e
        KUBECTL_PATH=$(command -v kubectl)
        cp "$KUBECTL_PATH" /home/jenkins/agent/kubectl
        chmod 755 /home/jenkins/agent/kubectl
    volumeMounts:
    - mountPath: /home/jenkins/agent
      name: workspace-volume
  containers:
  - name: jnlp
    image: jenkins/inbound-agent:latest-jdk21
    workingDir: /home/jenkins/agent
  - name: docker
    image: docker:27.3.1-dind
    securityContext:
      privileged: true
    command: ["/bin/sh", "-c"]
    args:
      - |
        cat > /etc/docker/daemon.json <<'DOCKERD_EOF'
        { "insecure-registries": ["192.168.188.30:8080"], "mtu": 1360 }
        DOCKERD_EOF
        exec dockerd-entrypoint.sh --host=tcp://0.0.0.0:2375
    env:
    - name: DOCKER_TLS_CERTDIR
      value: ""
  volumes:
  - emptyDir: {}
    name: workspace-volume
'''
    }
  }

作用:这是整套方案最关键的差异化设计——不再用固定 Agent 机器,而是每次构建都在 K8s 里临时起一个 Pod,跑完自动销毁。

关键逻辑(这个 Pod 里有三个"角色"):

  1. initContainers: kubectl-bin:构建前先把 kubectl 二进制从专用镜像拷到共享卷 workspace-volume,供后面部署阶段使用。用 initContainer 而非业务容器,是为了"先准备好工具、再干活"。
  2. containers: jnlp:Jenkins 的官方 Agent 容器(inbound-agent),是 Jenkins 主节点与 Pod 通信的桥梁,构建指令都发到它这里执行。
  3. containers: docker:跑了一个 Docker-in-Docker(dind),即"在 Pod 里再起一个 Docker 守护进程",通过 --host=tcp://0.0.0.0:2375 暴露给其他容器调用,用来做 docker build/docker push。配置 insecure-registries 让它能访问内网 HTTP 的 Harbor。

三个容器通过 emptyDir 共享卷 workspace-volume 共享工作目录和 kubectl 工具,imagePullSecrets: harbor-regcred 让 Pod 能拉 Harbor 里的私有镜像。

关联:Pod 里的 docker(dind)、kubectl、harbor-regcred 分别对应第五章预导入的镜像和第八章的拉取密钥,缺一构建就会卡在拉镜像环节。

全局变量
  environment {
    HARBOR_ADDR = '192.168.188.30:8080'
    REGISTRY    = '192.168.188.30:8080/cicd'
    APP_NAME    = 'demo-app'
    DOCKER_HOST = 'tcp://localhost:2375'
  }

作用:把 Harbor 地址、镜像仓库路径、应用名、Docker 守护进程地址集中定义成变量,后续各阶段直接引用,改环境时只改这一处。

关键逻辑:DOCKER_HOST = 'tcp://localhost:2375' 告诉 Agent 里的 docker 客户端去连同一 Pod 里 dind 容器暴露的 2375 端口,实现"无本地 Docker 也能构建"。

四个 stage 详解

Stage 1 — Checkout(拉代码)

    stage('Checkout') {
      steps {
        git branch: 'main',
            url: 'http://192.168.188.30/root/demo-app.git',
            credentialsId: 'gitlab-credentials'
      }
    }

作用:从 GitLab 拉取 main 分支代码到工作目录,是整个流水线的起点。
关联:credentialsId: 'gitlab-credentials' 引用第八章配置的 GitLab 凭据,用于私有仓库鉴权。

Stage 2 — Build & Push Image(构建并推送镜像)

    stage('Build & Push Image') {
      steps {
        container('docker') {
          withCredentials([usernamePassword(credentialsId: 'harbor-credentials', ...)]) {
            sh """
              AUTH=\$(printf '%s:%s' "\${HARBOR_USER}" "\${HARBOR_PASS}" | base64 -w 0)
              printf '{"auths":{"${HARBOR_ADDR}":{"auth":"%s"}}}' "\$AUTH" > ~/.docker/config.json
              docker pull ${REGISTRY}/golang:1.27-alpine3.23
              docker pull ${REGISTRY}/alpine:3.23
              docker build -t ${REGISTRY}/${APP_NAME}:${env.GIT_COMMIT.take(7)} .
              docker push ${REGISTRY}/${APP_NAME}:${env.GIT_COMMIT.take(7)}
            """
          }
        }
      }
    }

作用:在 dind 容器里构建镜像并推送到 Harbor。
关键逻辑:

  • container('docker'):指定在 Pod 里的 docker 容器中执行本阶段。
  • withCredentials:从 Jenkins 凭据库取出 Harbor 账号密码,注入 HARBOR_USER/HARBOR_PASS 环境变量。
  • 先把账号密码拼成 auth 字段写进 ~/.docker/config.json,实现免密 push。
  • ${env.GIT_COMMIT.take(7)}:取当前提交哈希前 7 位作为镜像 tag,做到每次构建镜像版本可追溯、不互相覆盖。
  • 先 docker pull 两个基础镜像,是因为它们已提前存进 Harbor,构建时直接用缓存,离线也能秒级完成。
    关联:docker build 会读取 9.4 节的 Dockerfile,产物镜像推回 Harbor 供下一步扫描和部署。

Stage 3 — Scan Image(漏洞扫描)

    stage('Scan Image') {
      steps {
        container('docker') {
          withCredentials([usernamePassword(credentialsId: 'harbor-credentials', ...)]) {
            sh """
              docker run --rm \\
                -e TRIVY_DB_REPOSITORY=192.168.188.30:8080/cicd/trivy-db:2 \\
                192.168.188.30:8080/cicd/trivy:0.76.1 image --insecure \\
                --username \${HARBOR_USER} --password \${HARBOR_PASS} \\
                --severity HIGH,CRITICAL --exit-code 1 \\
                ${REGISTRY}/${APP_NAME}:${env.GIT_COMMIT.take(7)}
            """
          }
        }
      }
    }

作用:用 Trivy 扫描刚构建的镜像,检查 HIGH 和 CRITICAL 级别的漏洞,实现"安全左移"——在部署前就把高危问题拦下。
关键逻辑:

  • --severity HIGH,CRITICAL:只关注高危和严重漏洞,避免低危告警刷屏。
  • --exit-code 1:一旦命中指定级别漏洞,Trivy 返回非零退出码,整个流水线立刻失败,镜像不会被部署。
  • TRIVY_DB_REPOSITORY 指向 Harbor 里的 trivy-db,离线环境也能更新漏洞库。
    关联:扫描的镜像正是 Stage 2 刚推送的那个(同一个 commit tag),形成"构建 → 扫描"的流水接力。

Stage 4 — Deploy to Production(部署上线)

    stage('Deploy to Production') {
      steps {
        container('docker') {
          withCredentials([file(credentialsId: 'kubeconfig', variable: 'KUBECONFIG_FILE')]) {
            sh """
              set -eux
              KUBECTL=/home/jenkins/agent/kubectl

              echo "=== STEP 1: kubectl client version ==="
              \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} version --client --request-timeout=10s

              echo "=== STEP 2: apiserver reachable ==="
              \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} get ns production --request-timeout=15s

              echo "=== STEP 3: deploy ==="
              if \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} -n production \\
                   get deployment ${APP_NAME} --request-timeout=15s >/dev/null 2>&1; then
                # 已存在:只更新镜像
                \$KUBECTL ... set image deployment/${APP_NAME} \\
                  ${APP_NAME}=${REGISTRY}/${APP_NAME}:${env.GIT_COMMIT.take(7)} ...
              else
                # 首次部署:先 apply 清单再更新镜像
                \$KUBECTL ... apply -f deploy/production.yaml ...
                \$KUBECTL ... set image deployment/${APP_NAME} ...
              fi

              echo "=== STEP 4: rollout status ==="
              \$KUBECTL --kubeconfig=\${KUBECONFIG_FILE} -n production \\
                rollout status deployment/${APP_NAME} --timeout=120s
            """
          }
        }
      }
    }

作用:用 kubectl 把新镜像部署到 K8s 的 production 命名空间。
关键逻辑(四个 STEP):

  • STEP 1/2:先探测 kubectl 可用性和 API Server 连通性,把连接问题提前暴露,避免部署时才报错。
  • STEP 3(核心分支):判断 demo-app 这个 Deployment 是否已存在——已存在就只 set image 更新镜像(快、无中断);不存在则先 apply -f 创建资源再更新镜像。这个 if/else 是"首次部署 vs 增量更新"的关键区分。
  • STEP 4:rollout status 阻塞等待滚动更新完成(最多 120 秒),确保部署真正成功才结束。
  • withCredentials([file(...)]):把第八章上传的 kubeconfig 凭据文件注入环境变量,实现免密访问 K8s。
    关联:deploy/production.yaml(9.5 节)在首次部署时被 apply;kubectl 二进制来自 9.6.2 节 initContainer 预拷贝到共享卷的产物。
post 结束钩子
  post {
    success { echo 'Pipeline succeeded.' }
    failure { echo 'Pipeline failed.' }
  }

作用:流水线结束时按结果回显一句日志,便于在构建控制台快速判断成败。实际项目可在这里扩展为发送企业微信/邮件通知、失败回滚等逻辑。

9.7 推送代码到 GitLab

git add .
git commit -m "完成"
git pull origin main --allow-unrelated-histories --no-rebase # 按 Ctrl + X
git push -u origin main

作用:把上述所有文件(源码、Dockerfile、部署清单、Jenkinsfile)一次性推送到 GitLab。--allow-unrelated-histories 用于合并远程仓库可能已有的初始提交(如初始化时自动生成的 README),避免历史无关导致的拒绝合并。推送成功后,即可进入下一章创建 Pipeline 并让 Webhook 自动触发构建。


十、创建 Pipeline 并配置 Webhook 自动触发

10.1 创建 Pipeline 任务

Jenkins 首页 → 新建任务:

  • 名称:demo-app-pipeline
  • 类型:流水线

进入配置 → 流水线 → 选择「Pipeline script from SCM」:

  • SCM:Git
  • Repository URL:http://192.168.188.30/root/demo-app.git
  • Credentials:gitlab-credentials
  • Branches to build:*/main
  • 脚本路径:Jenkinsfile

在这里插入图片描述

10.2 配置 Webhook 自动触发

Jenkins:

在这里插入图片描述

在这里插入图片描述

GitLab:

  1. 配置出站请求白名单:主页 → 管理中心 → 设置 → 网络 → 出站请求,勾选允许到本机/私网地址。

在这里插入图片描述

  1. 添加 Webhook:项目 → 设置 → webhooks → 添加 webhooks,URL 填 Jenkins 生成的地址,勾选 Push events。

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

推送一次代码,Jenkins 会自动收到事件并触发构建,全流程跑通即代表 CI/CD 链路闭环。


十一、总结

本文从零搭建了一套完整的离线环境 CI/CD 流水线,核心链路为:

GitLab(代码)→ Webhook → Jenkins → K8s 动态 Agent → 构建镜像 → Trivy 扫描 → Harbor 存储 → kubectl 部署上线。

几个关键设计点:

  1. K8s 动态 Agent:Jenkins 用 Kubernetes 插件在集群内按需拉起构建 Pod,构建完自动回收,省去固定 Agent 的运维成本。
  2. 离线镜像预导入:内网环境下先统一拉镜像再分发到各节点,规避构建时拉取公网镜像失败。
  3. Trivy 左移扫描:在推送镜像前先扫 HIGH/CRITICAL 漏洞,命中即中断流水线,把安全问题挡在上线前。
  4. 最小权限分离:Jenkins 的 Agent 账号与生产部署账号分离,各自只授必要 RBAC 权限。

可进一步优化方向:Harbor 启用 HTTPS、Secret 改用外置密钥管理(如 Vault)、Webhook Secret 校验、多环境(test/staging/production)流水线拆分、镜像 tag 用语义化版本替代 commit 短哈希。

本文所有密码与内网 IP 均为示例,实际部署请替换为你的环境值,并避免将真实密钥提交到代码仓库。

Logo

更多推荐