跳过正文
GitLab CI/CD + Kubernetes:从代码提交到生产部署全流程

GitLab CI/CD + Kubernetes:从代码提交到生产部署全流程

·1044 字·5 分钟·
目录

在我们团队从传统 Jenkins 迁移到 GitLab CI 的过程中,最大的挑战不是写 .gitlab-ci.yml,而是让 Runner 在 Kubernetes 上稳定运行,同时解决镜像构建的特权问题。这篇文章把整个过程从头梳理一遍,包括那些让我们折腾了好几天的坑。

整体架构
#

代码提交触发 Pipeline 之后,流程大致如下:

git push → GitLab → webhook → GitLab Runner (K8s Pod)
  → test stage (单元测试)
  → build stage (kaniko 构建镜像)
  → push stage (推送 ECR)
  → deploy stage (更新 GitOps 仓库)
  → ArgoCD 监听变更 → 滚动更新到 K8s

核心选型原则:

  • Runner 跑在 K8s 上,executor 用 kubernetes,按需创建 Job Pod
  • 镜像构建用 kaniko,不需要 DinD,不需要特权容器
  • 部署走 GitOps,pipeline 只更新 image tag,不直接 kubectl apply

GitLab Runner 部署
#

Helm 安装
#

官方 Helm Chart 是最省心的方式:

helm repo add gitlab https://charts.gitlab.io
helm repo update

helm install gitlab-runner gitlab/gitlab-runner \
  --namespace gitlab-runner \
  --create-namespace \
  -f runner-values.yaml

runner-values.yaml 的关键配置:

gitlabUrl: https://gitlab.example.com

# Runner 注册 token,从 GitLab 项目设置里拿
runnerRegistrationToken: "your-registration-token"

rbac:
  create: true
  # Runner 需要在 cicd namespace 创建 Job Pod
  rules:
    - apiGroups: [""]
      resources: ["pods", "pods/exec", "pods/attach", "secrets", "configmaps"]
      verbs: ["get", "list", "watch", "create", "patch", "delete", "update"]
    - apiGroups: ["batch"]
      resources: ["jobs"]
      verbs: ["get", "list", "watch", "create", "patch", "delete"]

runners:
  config: |
    [[runners]]
      [runners.kubernetes]
        namespace = "cicd"
        image = "alpine:latest"
        # 关键:不开特权
        privileged = false
        # Pod 跑完自动清理
        poll_interval = 3
        poll_timeout = 180
        
        # 资源限制,防止 CI job 把节点打爆
        cpu_request = "100m"
        memory_request = "128Mi"
        cpu_limit = "2"
        memory_limit = "2Gi"
        
        # service account,用于访问 K8s API(deploy 阶段需要)
        service_account = "gitlab-runner"
        
        # 镜像拉取策略
        image_pull_secrets = ["regcred"]

# Runner Pod 自身的资源配置
resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

RBAC 权限配置
#

Runner 需要在 cicd namespace 里创建 Job Pod,同时 deploy 阶段要更新其他 namespace 的 Deployment:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: gitlab-runner
  namespace: gitlab-runner
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: gitlab-runner
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/exec", "pods/attach", "secrets", "configmaps", "namespaces"]
    verbs: ["get", "list", "watch", "create", "patch", "delete", "update"]
  - apiGroups: ["apps"]
    resources: ["deployments", "replicasets"]
    verbs: ["get", "list", "watch", "patch", "update"]
  - apiGroups: ["batch"]
    resources: ["jobs"]
    verbs: ["get", "list", "watch", "create", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: gitlab-runner
subjects:
  - kind: ServiceAccount
    name: gitlab-runner
    namespace: gitlab-runner
roleRef:
  kind: ClusterRole
  name: gitlab-runner
  apiGroup: rbac.authorization.k8s.io

踩坑: 最开始只给了 namespace 级别的 Role,deploy 阶段死活无法更新 production namespace 里的 Deployment,报 403。换成 ClusterRoleBinding 后解决。如果安全要求严格,可以针对每个目标 namespace 单独绑定 Role,不要图省事直接 ClusterRole。

.gitlab-ci.yml 完整示例
#

下面是一个 Go 服务的完整 pipeline 配置:

variables:
  # AWS ECR 配置
  AWS_REGION: us-west-2
  ECR_REGISTRY: 123456789.dkr.ecr.us-west-2.amazonaws.com
  IMAGE_NAME: $ECR_REGISTRY/my-service
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA
  
  # GitOps 仓库
  GITOPS_REPO: gitlab.example.com/devops/k8s-manifests.git
  
  # Go 缓存
  GOPATH: $CI_PROJECT_DIR/.go
  GOCACHE: $CI_PROJECT_DIR/.go/cache

# 缓存 Go modules,加快构建速度
cache:
  key: "$CI_PROJECT_NAME-go-modules"
  paths:
    - .go/pkg/mod/
    - .go/cache/

stages:
  - test
  - build
  - push
  - deploy

# 单元测试
unit-test:
  stage: test
  image: golang:1.22-alpine
  script:
    - go test -v -race -coverprofile=coverage.out ./...
    - go tool cover -func=coverage.out | tail -1
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
    expire_in: 7 days

# lint 检查
lint:
  stage: test
  image: golangci/golangci-lint:v1.57
  script:
    - golangci-lint run --timeout=5m
  allow_failure: false

# kaniko 构建镜像
build-image:
  stage: build
  # kaniko 官方镜像,无需特权
  image:
    name: gcr.io/kaniko-project/executor:v1.21.0-debug
    entrypoint: [""]
  script:
    # 配置 ECR 认证
    # 这里用的是 IRSA(IAM Roles for Service Accounts),不需要明文 AK/SK
    - mkdir -p /kaniko/.docker
    - |
      cat > /kaniko/.docker/config.json << EOF
      {
        "credHelpers": {
          "$ECR_REGISTRY": "ecr-login"
        }
      }
      EOF
    # 构建并推送,同时打两个 tag:commit sha 和 branch 名
    - /kaniko/executor
        --context $CI_PROJECT_DIR
        --dockerfile $CI_PROJECT_DIR/Dockerfile
        --destination $IMAGE_NAME:$IMAGE_TAG
        --destination $IMAGE_NAME:$CI_COMMIT_BRANCH
        --cache=true
        --cache-repo=$ECR_REGISTRY/my-service/cache
        --snapshot-mode=redo
        --use-new-run
  rules:
    - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'

# 更新 GitOps 仓库触发部署
deploy-staging:
  stage: deploy
  image: alpine/git:latest
  script:
    - git config --global user.email "ci@example.com"
    - git config --global user.name "GitLab CI"
    # 使用 deploy token 克隆 GitOps 仓库
    - git clone https://gitlab-ci-token:$GITOPS_DEPLOY_TOKEN@$GITOPS_REPO /tmp/gitops
    - cd /tmp/gitops
    # 更新 staging 环境的 image tag
    - sed -i "s|image: $IMAGE_NAME:.*|image: $IMAGE_NAME:$IMAGE_TAG|g" envs/staging/my-service/deployment.yaml
    - git add .
    - git commit -m "ci: update my-service to $IMAGE_TAG [skip ci]"
    - git push
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'
  environment:
    name: staging
    url: https://staging.example.com

deploy-production:
  stage: deploy
  image: alpine/git:latest
  script:
    - git clone https://gitlab-ci-token:$GITOPS_DEPLOY_TOKEN@$GITOPS_REPO /tmp/gitops
    - cd /tmp/gitops
    - sed -i "s|image: $IMAGE_NAME:.*|image: $IMAGE_NAME:$IMAGE_TAG|g" envs/production/my-service/deployment.yaml
    - git add .
    - git commit -m "ci: update my-service to $IMAGE_TAG [skip ci]"
    - git push
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  # 生产环境需要手动确认
  when: manual
  environment:
    name: production
    url: https://example.com

kaniko 镜像构建详解
#

为什么不用 DinD
#

Docker-in-Docker(DinD)需要 privileged: true,在多租户 K8s 集群里是安全隐患。kaniko 在用户态完成镜像构建,不需要 Docker daemon,不需要特权模式。

kaniko 的工作原理:直接解析 Dockerfile,把每一层的文件系统变更打包成 OCI 格式,最后推送到 registry。

ECR 认证的正确姿势
#

方案一:IRSA(推荐,AWS EKS 环境)

给 Runner 的 ServiceAccount 绑定 IAM Role,Role 有 ECR 推送权限。kaniko 通过 credential helper 自动获取临时凭证:

# IAM Policy 需要包含
{
  "Effect": "Allow",
  "Action": [
    "ecr:GetAuthorizationToken",
    "ecr:BatchCheckLayerAvailability",
    "ecr:GetDownloadUrlForLayer",
    "ecr:BatchGetImage",
    "ecr:PutImage",
    "ecr:InitiateLayerUpload",
    "ecr:UploadLayerPart",
    "ecr:CompleteLayerUpload"
  ],
  "Resource": "*"
}

方案二:CI/CD Variables(非 AWS 托管集群)

在 GitLab 项目设置 → CI/CD → Variables 中添加:

  • AWS_ACCESS_KEY_ID:masked,不保护(让所有 branch 可用)
  • AWS_SECRET_ACCESS_KEY:masked

然后在 job 里:

build-image:
  before_script:
    - apk add --no-cache aws-cli
    - aws ecr get-login-password --region $AWS_REGION | 
        docker login --username AWS --password-stdin $ECR_REGISTRY

踩坑: masked 变量在 log 里不显示,但如果变量值包含特殊字符(比如 + / =),AWS SDK 解析会报错。建议把 AK/SK Base64 编码后存,使用时 decode。

kaniko cache 加速
#

kaniko 支持把中间层缓存推到 registry,第二次构建时直接复用:

/kaniko/executor \
  --cache=true \
  --cache-repo=$ECR_REGISTRY/my-service/cache \
  --cache-ttl=24h \
  --snapshot-mode=redo

--snapshot-mode=redo 比默认的 full 模式快很多,但在极少数情况下可能漏掉文件变更。如果遇到奇怪的构建问题,先换回 full 排查。

变量管理策略
#

CI/CD Variables vs K8s Secrets
#

这两个不是替代关系,各有用途:

场景使用方式
构建阶段需要的密钥(AK/SK、registry token)GitLab CI/CD Variables
运行时应用需要的密钥(DB 密码、JWT secret)K8s Secrets
构建配置(镜像名、环境 URL)GitLab CI/CD Variables
应用配置(DB host、feature flags)ConfigMap 或 Nacos

GitLab Variables 的几个注意点:

  • Protected:只在 protected branch/tag 上可用,main 和 release/* 分支才能用
  • Masked:值不在 log 里显示,但有长度和字符限制
  • File type:变量内容写到临时文件,适合存证书、kubeconfig 等

在 deploy job 里用 K8s Secret
#

如果 deploy 阶段需要直接 kubectl apply 而不是走 GitOps,可以把 kubeconfig 存为 File 类型的 Variable:

deploy:
  script:
    # $KUBECONFIG 是 File 类型变量,GitLab 自动写到临时文件
    - kubectl --kubeconfig=$KUBECONFIG set image deployment/my-service
        my-service=$IMAGE_NAME:$IMAGE_TAG -n production

pipeline 并发控制
#

默认情况下 GitLab 会尽量并发运行 job,但有些场景需要控制:

# 同一个项目的 deploy 不能并发
deploy-production:
  resource_group: production
  # 同一时间只有一个 job 持有这个 resource_group

对于 monorepo,用 rules: changes 只在相关文件变更时触发:

build-service-a:
  rules:
    - changes:
        - services/service-a/**/*
        - shared/**/*

踩坑记录
#

坑1:Runner Pod 拉不到私有镜像

症状:job 里指定的 image 一直 ImagePullBackOff

原因:Runner 创建 Job Pod 时,Pod 的 imagePullSecrets 需要在 runners.config 里配置,而不是在 runner 自身的 Pod 上配置。

[runners.kubernetes]
  image_pull_secrets = ["ecr-regcred"]

这个 Secret 必须在 Runner 创建 Job Pod 的那个 namespace(cicd)里存在。

坑2:kaniko 构建时 /workspace 里缺文件

症状:COPY 指令报文件不存在,但本地构建没问题。

原因:.dockerignore 文件排除了需要的文件,或者 --context 指向了错误的目录。kaniko 的 context 是 $CI_PROJECT_DIR,确认 Dockerfile 里的路径相对于项目根目录。

坑3:pipeline 并发导致 GitOps 仓库 push 冲突

症状:多个 branch 同时触发 deploy,git push 报 non-fast-forward

解法:用 resource_group 或者在脚本里加重试:

for i in $(seq 1 5); do
  git pull --rebase origin main && git push && break
  sleep $((RANDOM % 10 + 1))
done

坑4:group_wait 导致 job 长时间 Pending

症状:Job Pod 创建成功,但 runner 日志显示一直在等 executor 响应。

原因:K8s 节点资源不足,Pod 调度 Pending,runner 的 poll_timeout(默认 180s)超时后标记 job 失败。

解法:要么加节点,要么配置 Karpenter/Cluster Autoscaler 自动扩容,同时把 poll_timeout 适当调大到 300s。


整个方案跑通之后,开发提交代码到 main,大约 4-6 分钟后 ArgoCD 检测到 GitOps 仓库变更,开始滚动更新,整个过程完全自动。kaniko 的构建速度在开启 cache 之后比 DinD 快了约 30%,而且彻底解决了特权容器的安全审计问题。

Wenzhuo Huang
作者
Wenzhuo Huang
搞运维的工程师,写代码的运维人。专注 Kubernetes、AWS、GitOps 与基础设施可靠性。这个博客既是我的技术笔记本,也是我踩过的坑的受害者档案。

相关文章

Kubernetes 从零开始:工程师视角的入门指南

·1353 字·7 分钟
Docker Compose 能运行多个容器,为什么还需要 Kubernetes?本文从这个问题出发,用类比的方式讲清楚 Pod/Deployment/Service/Ingress 等核心概念,给出最常用的 kubectl 命令和完整的入门部署示例。