通过GitLab CI自动化Spring Boot应用的Consul Connect服务网格注入与金丝雀发布


在任何一个成熟的微服务体系中,单纯实现业务逻辑并打包部署只是万里长征的第一步。真正的挑战来自于服务间通信的安全性、部署过程的可靠性以及流量管理的灵活性。我们团队最近就面临这样一个典型的场景:数十个Spring Boot微服务运行在虚拟机上,服务间通过原始的HTTP调用,安全全靠网络ACL,每次上线都伴随着祈祷,回滚则是一场手忙脚乱的脚本执行大会。

我们的目标很明确:实现服务间通信的零信任安全(mTLS),并引入自动化的金丝雀发布流程来降低上线风险。技术选型上,我们已经在使用GitLab作为代码仓库和CI/CD工具,服务发现则依赖于Consul。因此,将Consul强大的服务网格能力——Consul Connect——集成到现有的GitLab CI/CD流程中,成为了最自然的选择。这篇日志记录了我们从零开始,将一个标准的Spring Boot应用接入Consul Connect,并通过GitLab CI流水线实现自动化sidecar注入和金丝雀发布的完整过程,其中不乏我们踩过的坑和做出的权衡。

起点:两个标准的Spring Boot微服务

故事的起点是两个微服务:api-gatewayproduct-serviceapi-gateway 负责对外暴露接口,内部调用product-service获取数据。在改造前,它们的关系简单直白。

product-service 提供一个简单的REST接口:

// product-service/src/main/java/com/example/product/ProductController.java

package com.example.product;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;
import java.util.HashMap;

@RestController
public class ProductController {

    private static final Logger logger = LoggerFactory.getLogger(ProductController.class);

    @Value("${server.port}")
    private int serverPort;
    
    // 通过环境变量获取应用版本,用于金丝雀发布验证
    @Value("${APP_VERSION:v1.0.0}")
    private String appVersion;

    @GetMapping("/products/{id}")
    public Map<String, Object> getProduct(@PathVariable String id) {
        logger.info("Fetching product {} on port {} - version {}", id, serverPort, appVersion);
        
        Map<String, Object> product = new HashMap<>();
        product.put("id", id);
        product.put("name", "Awesome Gadget");
        product.put("version", appVersion);
        product.put("port", serverPort);
        
        return product;
    }
}

api-gateway 使用RestTemplate调用product-service

// api-gateway/src/main/java/com/example/gateway/GatewayController.java

package com.example.gateway;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

@RestController
public class GatewayController {

    private static final Logger logger = LoggerFactory.getLogger(GatewayController.class);

    @Autowired
    private RestTemplate restTemplate;

    // "http://product-service/..." 是通过Spring Cloud Consul + Ribbon/LoadBalancer实现的客户端负载均衡
    private final String productServiceUrl = "http://product-service/products/";

    @GetMapping("/gateway/products/{id}")
    public Object getProductFromService(@PathVariable String id) {
        logger.info("Forwarding request for product id {}", id);
        try {
            return restTemplate.getForObject(productServiceUrl + id, Object.class);
        } catch (Exception e) {
            logger.error("Error calling product-service", e);
            return Map.of("error", "product-service is unavailable", "message", e.getMessage());
        }
    }
}

两个服务都集成了Spring Cloud Consul,通过application.yml配置自动向Consul注册。

product-serviceapplication.yml:

spring:
  application:
    name: product-service
  cloud:
    consul:
      host: consul-server # 假设Consul Server地址可通过DNS解析
      port: 8500
      discovery:
        instance-id: ${spring.application.name}:${vcap.application.instance_id:${spring.cloud.client.ip-address}}:${server.port}
        health-check-path: /actuator/health
        health-check-interval: 15s
        tags: version=${APP_VERSION:v1.0.0} # 将版本信息注册为tag
server:
  port: 8081

这是我们的基线。服务可以注册、发现并互相调用,但所有流量都是明文HTTP,没有任何访问控制。

第一步:手动集成Consul Connect Sidecar

在自动化之前,必须先手动走通流程。Consul Connect通过在每个服务实例旁部署一个轻量级Sidecar代理来实现服务网格。服务间的所有流量都通过各自的Sidecar代理进行,代理之间建立mTLS连接,并根据Consul中的“Intention”(意图)来决定是否放行流量。

首先,我们需要为product-service定义一个服务注册文件 product-service.json,声明它使用Connect:

{
  "service": {
    "name": "product-service",
    "id": "product-service-1",
    "port": 8081,
    "tags": ["v1.0.0"],
    "connect": {
      "sidecar_service": {}
    },
    "check": {
      "http": "http://localhost:8081/actuator/health",
      "interval": "10s"
    }
  }
}

然后,为api-gateway定义类似的服务文件,并声明它需要调用product-service。这被称为“Upstream”:

{
  "service": {
    "name": "api-gateway",
    "id": "api-gateway-1",
    "port": 8080,
    "connect": {
      "sidecar_service": {
        "proxy": {
          "upstreams": [
            {
              "destination_name": "product-service",
              "local_bind_port": 9091 // sidecar会在本地9091端口监听,并将流量转发到product-service
            }
          ]
        }
      }
    },
    "check": {
      "http": "http://localhost:8080/actuator/health",
      "interval": "10s"
    }
  }
}

有了这些定义,我们就可以启动服务和它们的Sidecar了。

  1. 向Consul注册服务:

    consul services register product-service.json
    consul services register api-gateway.json
  2. 启动product-service的应用和它的Sidecar:

    # 启动Spring Boot应用
    java -jar product-service.jar --server.port=8081
    
    # 在另一个终端启动Sidecar
    consul connect proxy -sidecar-for product-service-1
  3. 启动api-gateway的应用和Sidecar:

    # 修改api-gateway的RestTemplate配置,让它调用本地的Sidecar代理
    # 原来是 "http://product-service/...", 现在改为 "http://localhost:9091/..."
    # 这是一个关键变更,服务不再直接发现和调用下游,而是固定调用本地端口
    java -jar api-gateway.jar --product.service.url=http://localhost:9091
    
    # 启动Sidecar
    consul connect proxy -sidecar-for api-gateway-1

此时,如果你尝试从api-gateway调用product-service,请求会失败。这是因为Consul Connect的默认策略是“deny all”。我们需要创建一个Intention来允许api-gateway调用product-service

consul intention create -allow api-gateway product-service

创建Intention后,调用立刻就能成功。至此,我们手动验证了mTLS和基于Intention的访问控制是有效的。但这个过程太繁琐了,而且要求修改应用代码来改变调用地址,这在真实项目中是不可接受的。Spring Cloud Consul已经帮我们做了服务发现,我们不想丢掉这个能力。

这里的坑在于,我们必须让Spring Boot应用透明地使用Sidecar。解决方案是,在api-gatewayapplication.yml中,通过consul.discovery.tags将upstream信息注入,Spring Cloud Consul会自动解析并配置Ribbon/LoadBalancer将product-service的地址解析为localhost:9091。这需要对Spring Cloud Consul有较深的理解。但为了简化CI流程,我们决定采用一种更通用的方法:容器化。

第二步:容器化与Sidecar注入的挑战

将应用和Sidecar打包到同一个容器镜像是实现自动化的关键。一个标准的Docker容器默认只运行一个主进程(CMD/ENTRYPOINT)。我们需要同时运行Java应用和consul connect proxy进程。

一个常见的错误是使用&在启动脚本中后台运行一个进程,但这会导致容器主进程退出后,其他进程成为孤儿,容器随之停止。在生产环境中,我们需要一个进程管理器。supervisord是一个轻量级的选择。

我们的Dockerfile结构如下:

FROM openjdk:11-jre-slim

# 安装Consul和supervisor
ARG CONSUL_VERSION=1.13.1
ADD https://releases.hashicorp.com/consul/${CONSUL_VERSION}/consul_${CONSUL_VERSION}_linux_amd64.zip /tmp/
RUN apt-get update && apt-get install -y unzip supervisor && \
    unzip /tmp/consul.zip -d /usr/local/bin/ && \
    rm /tmp/consul.zip && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# 拷贝应用和配置文件
WORKDIR /app
COPY target/product-service-*.jar app.jar
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh

EXPOSE 8081

# entrypoint.sh会生成Consul服务定义文件,然后启动supervisord
ENTRYPOINT ["/app/entrypoint.sh"]

supervisord.conf负责管理两个进程:

[supervisord]
nodaemon=true

[program:app]
command=java -jar /app/app.jar
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:sidecar]
# 注意这里的-sidecar-for参数,它的值需要动态生成
command=consul connect proxy -sidecar-for %(ENV_CONSUL_SERVICE_ID)s -log-level=info
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

entrypoint.sh脚本是粘合剂。它在容器启动时,根据环境变量动态生成Consul的服务注册JSON文件,然后注册服务,最后启动supervisord

#!/bin/sh
set -e

# 从环境变量中读取配置
# CI流水线会传入这些变量
CONSUL_SERVICE_ID="${SERVICE_NAME}-${CI_PIPELINE_ID}-${CI_JOB_ID}"
export CONSUL_SERVICE_ID

# 动态生成服务定义文件
# 注意:在真实项目中,这个模板会更复杂,包含对upstreams的定义
cat > /app/service-definition.json <<EOF
{
  "service": {
    "name": "${SERVICE_NAME}",
    "id": "${CONSUL_SERVICE_ID}",
    "port": ${SERVICE_PORT},
    "tags": ["${APP_VERSION}"],
    "connect": {
      "sidecar_service": {}
    },
    "check": {
      "http": "http://localhost:${SERVICE_PORT}/actuator/health",
      "interval": "10s",
      "deregister_critical_service_after": "1m"
    }
  }
}
EOF

echo "Generated service definition:"
cat /app/service-definition.json

# 向Consul Agent注册服务
# 容器需要能访问到节点上的Consul Agent
consul services register /app/service-definition.json

# 启动supervisord来管理应用和sidecar
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

这个Dockerfile和辅助脚本构成了一个自包含的、可接入Consul Connect的微服务单元。CI/CD流水线只需要构建这个镜像,并在运行时提供正确的环境变量即可。

第三步:构建GitLab CI/CD流水线

现在,我们将整个流程串接到.gitlab-ci.yml中。流水线分为几个阶段:build, package, deploy_canary, promote_production, cleanup_canary

variables:
  SERVICE_NAME: "product-service"
  SERVICE_PORT: 8081
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE/$SERVICE_NAME
  # Consul地址应该配置在GitLab CI/CD的变量中
  # CONSUL_HTTP_ADDR: http://consul-agent:8500

stages:
  - build
  - package
  - deploy_canary
  - promote_production
  - cleanup_canary

maven_build:
  stage: build
  image: maven:3.8-openjdk-11
  script:
    - mvn package -DskipTests
  artifacts:
    paths:
      - target/*.jar

docker_package:
  stage: package
  image: docker:20.10
  services:
    - docker:20.10-dind
  script:
    - export APP_VERSION="v1.1.0-${CI_COMMIT_SHORT_SHA}" # 金丝雀版本
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build --build-arg APP_VERSION=${APP_VERSION} -t $DOCKER_IMAGE:$APP_VERSION .
    - docker push $DOCKER_IMAGE:$APP_VERSION
  dependencies:
    - maven_build

deploy_canary_job:
  stage: deploy_canary
  image: curlimages/curl:7.78.0 # 使用一个轻量级镜像执行API调用
  script:
    - |
      # 1. 部署金丝雀实例(这里用docker run模拟,实际环境中可能是调用Ansible/Terraform/Kubernetes API)
      # ssh user@deploy-host "docker run -d --name ${SERVICE_NAME}-canary -e SERVICE_NAME=${SERVICE_NAME} ..."
      echo "Simulating deployment of canary version ${CI_COMMIT_SHORT_SHA}"
      
      # 2. 配置Consul Service Splitter,将10%流量切到金丝雀版本
      # 我们通过tag来区分版本。v1.0.0是稳定版,新版本是v1.1.0-*
      cat <<EOF > splitter.json
      {
        "Kind": "service-splitter",
        "Name": "product-service",
        "Splits": [
          {
            "Weight": 90,
            "ServiceSubset": "v1.0.0" 
          },
          {
            "Weight": 10,
            "ServiceSubset": "${CI_COMMIT_TAG:-$CI_COMMIT_SHORT_SHA}"
          }
        ]
      }
      EOF
      
      echo "Applying service splitter configuration to Consul..."
      curl -X PUT --data @splitter.json "${CONSUL_HTTP_ADDR}/v1/config"
      
      echo "10% of traffic for 'product-service' is now directed to the canary version."
  dependencies:
    - docker_package

promote_to_production:
  stage: promote_production
  image: curlimages/curl:7.78.0
  script:
    - |
      echo "Promoting canary to 100% production traffic..."
      cat <<EOF > splitter.json
      {
        "Kind": "service-splitter",
        "Name": "product-service",
        "Splits": [
          {
            "Weight": 0,
            "ServiceSubset": "v1.0.0"
          },
          {
            "Weight": 100,
            "ServiceSubset": "${CI_COMMIT_TAG:-$CI_COMMIT_SHORT_SHA}"
          }
        ]
      }
      EOF
      curl -X PUT --data @splitter.json "${CONSUL_HTTP_ADDR}/v1/config"
      echo "All traffic is now on the new version."
  when: manual # 这是一个手动触发的步骤,等待QA或监控确认金丝雀版本稳定

cleanup_old_version:
  stage: cleanup_canary
  script:
    - echo "Cleaning up old version instances..."
    # 模拟下线旧版本实例
    # ssh user@deploy-host "docker stop/rm old_containers"
  when: manual

这里有几个关键点:

  1. 动态版本: 我们使用CI_COMMIT_SHORT_SHA为每次构建生成一个唯一的版本号,并将其作为Docker镜像的tag和Consul服务的tag。
  2. Service Splitter: 金丝雀发布的核心是Consul的service-splitter配置。它是一个L7流量切分器。我们在deploy_canary阶段通过Consul的HTTP API写入一个配置,将10%的流量引导到带有新版本tag的服务实例上。
  3. Service Subset: Splitter通过ServiceSubset来识别不同版本的服务实例,而ServiceSubset的名字默认就是服务的tag。这就是为什么在application.ymlDockerfile中注入版本tag至关重要的原因。
  4. 手动晋升: promote_production阶段被设置为when: manual。这意味着流水线在部署完金丝雀后会暂停,等待工程师在GitLab界面上点击“play”按钮。这给了我们观察金丝雀实例(通过监控、日志等)的时间。确认无误后,再手动触发全量。

为了让流量切分生效,我们还需要定义service-resolver,告诉Consul如何根据ServiceSubset查找服务实例:

// 需要预先配置到Consul中
{
  "Kind": "service-resolver",
  "Name": "product-service",
  "Subsets": {
    "v1.0.0": {
      "Filter": "Service.Tags contains \"v1.0.0\""
    },
    // 使用通配符来匹配所有金丝雀版本
    "*": {
      "Filter": "Service.Tags not contains \"v1.0.0\""
    }
  }
}

部署流程的Mermaid图如下:

sequenceDiagram
    participant GitLab CI
    participant DeployHost
    participant Consul
    participant APIGateway
    participant ProductSvc_v1
    participant ProductSvc_v2_Canary

    GitLab CI->>DeployHost: Deploy product-service:v2 (Canary)
    DeployHost->>Consul: Register product-service-v2
    GitLab CI->>Consul: Apply ServiceSplitter (90% -> v1, 10% -> v2)

    loop Traffic
        APIGateway->>Consul: Resolve 'product-service'
        alt 90% of requests
            Consul-->>APIGateway: Return address of ProductSvc_v1
            APIGateway->>ProductSvc_v1: Request
        else 10% of requests
            Consul-->>APIGateway: Return address of ProductSvc_v2_Canary
            APIGateway->>ProductSvc_v2_Canary: Request
        end
    end

    Note over GitLab CI: Manual Promotion Triggered

    GitLab CI->>Consul: Apply ServiceSplitter (0% -> v1, 100% -> v2)
    loop All Traffic
        APIGateway->>Consul: Resolve 'product-service'
        Consul-->>APIGateway: Return address of ProductSvc_v2_Canary
        APIGateway->>ProductSvc_v2_Canary: Request
    end
    
    GitLab CI->>DeployHost: Decommission product-service:v1

局限性与未来迭代路径

这套流程打通了从代码提交到安全、可控的线上部署的自动化闭环,解决了我们最初的痛点。然而,它并非银弹,依然存在一些局限和可以改进的地方。

首先,在非容器编排环境(如纯虚拟机)中使用supervisord管理Sidecar和应用进程,虽然可行,但增加了容器镜像的复杂性和潜在的故障点。一个更稳健的方案是迁移到Kubernetes或Nomad,利用其原生的Sidecar注入和生命周期管理能力,这会使我们的Dockerfile和部署脚本大大简化。

其次,直接在CI脚本中通过curl调用Consul API来管理配置,功能上可以满足,但缺乏版本控制和审计。配置(如service-splitter, service-resolver, intention)本身也应该被视为代码。下一步的演进方向是采用Configuration-as-Code的模式,将所有Consul配置存储在专门的Git仓库中,通过Terraform或专门的GitOps工具(如ArgoCD配合一个Consul同步器)来管理这些配置的生命周期。

最后,我们的金丝雀晋升决策目前是手动的。一个更高级的自动化流水线应该能集成可观测性数据。例如,在金丝雀发布阶段,流水线可以自动查询Prometheus,分析金丝雀实例的错误率(SLI)。如果SLI在预设的错误预算(Error Budget)内,则自动执行晋升;反之,则自动回滚并告警。这需要一个更强大的部署工具(如Spinnaker或Argo Rollouts)和成熟的监控体系来支持。


  目录