使用Ansible自动化Linkerd流量切分以优化Scrum流程中的变更失败率指标


我们的变更失败率(Change Failure Rate, CFR)一度高达23%。这意味着每四次生产部署,就有一次会触发P0或P1级别的告警。在Scrum的回顾会议上,气氛总是很沉重,话题从技术债转向了发布流程,最终往往演变成毫无结果的讨论。团队的速度和士气都受到了严重影响。问题不在于开发人员的能力,而在于我们从代码合并到生产流量完全接管之间的过程,充满了手动的、易错的步骤和祈祷式的发布。

在一次关键的Sprint规划会议上,我们决定将下一个迭代周期的核心工程目标定为:将CFR降至5%以下。我们达成共识,根本原因有两个:第一,缺乏自动化的、一致的基础设施部署与变更能力;第二,全量发布模式风险太高,一旦新版本有问题,影响范围就是100%的用户。

为了解决这两个问题,我们选择了一套技术栈:使用Ansible来标准化和自动化我们的Kubernetes环境与应用部署,并引入Linkerd服务网格来实现精细化的金丝雀发布。

第一阶段:用Ansible固化基础设施

在真实项目中,混乱往往始于环境的不一致。开发环境的Kubernetes版本、网络插件配置、甚至内核参数都与生产环境有细微差别。这些差别是滋生“在我机器上是好的”这类问题的温床。我们决定用Ansible来终结这种混乱。Ansible的幂等性是我们选择它的关键原因,无论执行多少次,它都能确保系统收敛到期望的状态。

我们的第一步是创建一个Ansible Playbook,用于从零开始构建一个生产就绪的Kubernetes集群(我们使用的是K3s,因为它更轻量)。这不仅仅是执行curl | sh

# playbook-k8s-bootstrap.yml
- hosts: k8s_cluster
  become: yes
  gather_facts: yes
  vars:
    k3s_version: "v1.25.9+k3s1"
    # 定义控制平面节点的IP,第一个将是主节点
    controller_node_ip: "{{ hostvars[groups['k8s_masters'][0]]['ansible_host'] }}"
    
  roles:
    - role: common/kernel_tuning
      tags: ['system-prep']
    - role: k8s/prerequisites
      tags: ['system-prep']

- hosts: k8s_masters
  become: yes
  roles:
    - role: k8s/control_plane
      vars:
        # 主节点初始化时需要一个token供worker节点加入
        k3s_cluster_token: "{{ hostvars[groups['k8s_masters'][0]].k3s_token }}"
        is_first_master: "{{ inventory_hostname == groups['k8s_masters'][0] }}"
      tags: ['k8s-install']
      
- hosts: k8s_workers
  become: yes
  roles:
    - role: k8s/worker_node
      vars:
        k3s_cluster_token: "{{ hostvars[groups['k8s_masters'][0]].k3s_token }}"
      tags: ['k8s-install']

这个Playbook的核心不在于安装本身,而在于roles的拆分。common/kernel_tuning这个role尤其重要,它负责统一所有节点的系统配置。

# roles/common/kernel_tuning/tasks/main.yml
# 生产环境中,内核参数的统一至关重要,特别是网络和内存相关的。
# Ansible的sysctl模块保证了这些设置的幂等性。
- name: Apply kernel parameters for Kubernetes
  sysctl:
    name: "{{ item.name }}"
    value: "{{ item.value }}"
    state: present
    reload: yes
  loop:
    - { name: 'net.bridge.bridge-nf-call-iptables', value: '1' }
    - { name: 'net.ipv4.ip_forward', value: '1' }
    - { name: 'fs.file-max', value: '1000000' }
    - { name: 'vm.max_map_count', value: '262144' }
  notify: Restart containerd

- name: Ensure required kernel modules are loaded
  modprobe:
    name: "{{ item }}"
    state: present
  loop:
    - br_netfilter
    - overlay

通过这套Playbook,我们可以在几分钟内拉起一个配置完全一致的集群。下一步就是将Linkerd的安装也纳入Ansible的管理。一个常见的错误是直接在CI/CD脚本里调用linkerd install | kubectl apply -f -。这种方式不可重复,也无法进行版本控制。我们将其转化为一个Ansible Role。

# roles/linkerd/install/tasks/main.yml
- name: Check if Linkerd is already installed
  command: "linkerd check --pre"
  register: linkerd_pre_check
  changed_when: false
  failed_when: false # 我们不希望命令失败导致playbook中断

- name: Download Linkerd CLI if not present
  get_url:
    url: "https://github.com/linkerd/linkerd2/releases/download/stable-{{ linkerd_version }}/linkerd2-cli-stable-{{ linkerd_version }}-linux-amd64"
    dest: "/usr/local/bin/linkerd"
    mode: '0755'
  when: linkerd_pre_check.rc != 0

- name: Generate Linkerd control plane manifest
  command: >
    /usr/local/bin/linkerd install 
    --set proxy.resources.cpu.limit=200m 
    --set proxy.resources.memory.limit=250Mi
    --set controllerLogLevel=info
  register: linkerd_manifest
  changed_when: false
  when: linkerd_pre_check.rc != 0
  
- name: Apply Linkerd control plane manifest
  # 使用k8s模块而不是shell模块来应用manifest,更安全,更原生
  kubernetes.core.k8s:
    state: present
    src: "{{ linkerd_manifest.stdout }}"
  when: linkerd_pre_check.rc != 0
  
- name: Wait for Linkerd control plane to be ready
  command: "linkerd check"
  register: linkerd_post_check
  until: linkerd_post_check.rc == 0
  retries: 10
  delay: 30
  changed_when: false
  when: linkerd_pre_check.rc != 0

这个Role的设计考虑了幂等性:它首先检查Linkerd是否存在,只有在不存在时才执行安装。并且,我们不是简单地应用配置,而是显式地配置了代理的资源限制,这是生产环境中必须考虑的细节,以防sidecar耗尽节点资源。

第二阶段:构建自动化的金丝雀发布流程

基础设施标准化之后,我们开始改造发布流程。核心是利用Linkerd的TrafficSplit CRD。它允许我们用声明式的方式,将流向一个Kubernetes Service的流量按百分比分配到后端的多个Deployment。

我们的目标是创建一个通用的Ansible Role,它可以为任何服务执行金丝g雀发布。这个Role需要接收以下参数:应用名、命名空间、稳定版镜像、金丝雀版镜像以及初始流量权重。

这是流程的可视化:

graph TD
    subgraph CI/CD Pipeline
        A[Developer Merges Code] --> B{Trigger Pipeline};
        B --> C[Build & Push new_image:canary];
        B --> D[Run Ansible Playbook: canary_deploy];
    end

    subgraph "Ansible Host"
        D --> E{Ansible Role: `deploy_canary`};
        E --> F[Generate Canary Deployment Manifest];
        E --> G[Generate TrafficSplit Manifest];
        E --> H[Apply Manifests to K8s API];
    end

    subgraph "Kubernetes Cluster"
        H --> I[Creates `my-app-canary` Deployment];
        H --> J[Creates/Updates `my-app-trafficsplit`];
        J -- routes traffic --> K[Service: `my-app`];
        K -- 90% --> L[Deployment: `my-app-stable`];
        K -- 10% --> I;
    end

这个Ansible Role deploy_canary 的实现是整个方案的灵魂。

# roles/deploy_canary/tasks/main.yml

# 变量: app_name, namespace, stable_image, canary_image, initial_weight

- name: "Step 1: Get details of the stable deployment"
  kubernetes.core.k8s_info:
    api_version: apps/v1
    kind: Deployment
    name: "{{ app_name }}-stable"
    namespace: "{{ namespace }}"
  register: stable_deployment_info

- name: "Fail if stable deployment does not exist"
  fail:
    msg: "Stable deployment '{{ app_name }}-stable' not found in namespace '{{ namespace }}'."
  when: stable_deployment_info.resources | length == 0

- name: "Step 2: Create canary deployment manifest from stable"
  # 我们不从头写canary的yaml,而是基于stable的定义来修改。
  # 这样可以确保除了镜像版本外,所有配置(资源、环境变量等)都一致。
  set_fact:
    canary_deployment_manifest: "{{ stable_deployment_info.resources[0] | combine({
        'metadata': {
          'name': app_name + '-canary',
          'labels': stable_deployment_info.resources[0].metadata.labels | combine({'version': 'canary'})
        },
        'spec': {
          'replicas': 1, # 金丝雀通常从一个副本开始
          'selector': {
            'matchLabels': stable_deployment_info.resources[0].spec.selector.matchLabels | combine({'version': 'canary'})
          },
          'template': {
            'metadata': {
              'labels': stable_deployment_info.resources[0].spec.template.metadata.labels | combine({'version': 'canary'})
            },
            'spec': stable_deployment_info.resources[0].spec.template.spec | combine({
              'containers': [
                stable_deployment_info.resources[0].spec.template.spec.containers[0] | combine({'image': canary_image})
              ]
            })
          }
        }
      }, recursive=True) }}"

- name: "Step 3: Deploy the canary version"
  kubernetes.core.k8s:
    state: present
    definition: "{{ canary_deployment_manifest }}"

- name: "Step 4: Create or update the TrafficSplit"
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: split.smi-spec.io/v1alpha2
      kind: TrafficSplit
      metadata:
        name: "{{ app_name }}-trafficsplit"
        namespace: "{{ namespace }}"
      spec:
        service: "{{ app_name }}" # 这是根服务,指向stable
        backends:
        - service: "{{ app_name }}-stable"
          weight: "{{ 100 - initial_weight }}"
        - service: "{{ app_name }}-canary"
          weight: "{{ initial_weight }}"

# 还需要为canary和stable创建对应的Service
- name: "Ensure stable service exists"
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: v1
      kind: Service
      metadata:
        name: "{{ app_name }}-stable"
        namespace: "{{ namespace }}"
      spec:
        selector:
          app: "{{ app_name }}"
          version: stable
        ports:
        - port: 80
          targetPort: 8080

- name: "Ensure canary service exists"
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: v1
      kind: Service
      metadata:
        name: "{{ app_name }}-canary"
        namespace: "{{ namespace }}"
      spec:
        selector:
          app: "{{ app_name }}"
          version: canary
        ports:
        - port: 80
          targetPort: 8080

这段代码有几个关键设计:

  1. 基于现有配置生成:我们不为金丝雀版本硬编码Deployment YAML。而是通过k8s_info获取线上稳定版的配置,仅修改nameimagelabels。这避免了因配置漂移导致金丝雀测试失效。
  2. 原子化操作:使用kubernetes.core.k8s模块,Ansible可以直接与K8s API交互,比shell: kubectl apply更可靠,支持check mode,也返回更结构化的结果。
  3. 服务分离TrafficSplit需要将流量路由到不同的后端服务。因此,我们必须确保存在my-app-stablemy-app-canary两个Service,它们分别通过selector指向不同版本的Pods。而用户流量入口,即根服务my-app,则不需要selector,因为它的流量完全由TrafficSplit接管。

第三阶段:基于指标的自动晋升与回滚

手动调整流量权重仍然是风险点。我们必须实现自动化决策:如果金丝雀版本是健康的,就自动增加流量;如果出现问题,就立即自动回滚。

“健康”的定义不能是简单的Pod存活,而必须是业务指标。Linkerd的优势在于它能开箱即用地为每个服务提供黄金指标:请求成功率、RPS和延迟。这些指标会暴露给Prometheus。

我们编写了一个Ansible Playbook,它会编排整个发布过程,包括调用一个Python脚本来查询Prometheus。

# playbook-promote-canary.yml
- hosts: localhost
  connection: local
  gather_facts: no
  vars:
    app_name: "my-critical-service"
    namespace: "production"
    canary_image: "my-registry/my-app:v1.2.1"
    prometheus_url: "http://prometheus.internal:9090"
    
  tasks:
    - name: "Phase 1: Start canary with 10% traffic"
      include_role:
        name: deploy_canary
      vars:
        initial_weight: 10
        # 其他变量继承自playbook vars
        
    - name: "Pause for 5 minutes to collect metrics"
      pause:
        minutes: 5
        
    - name: "Phase 2: Check canary health via Prometheus"
      script: "scripts/check_canary_health.py --url {{ prometheus_url }} --service {{ app_name }}-canary.{{ namespace }}.svc.cluster.local"
      register: health_check_result
      
    - name: "ROLLBACK: Canary is unhealthy"
      when: health_check_result.stdout | from_json | json_query('is_healthy') == false
      block:
        - name: "Set canary weight to 0"
          kubernetes.core.k8s:
            state: present
            definition: # ... TrafficSplit YAML with weight 0 for canary
        - name: "Delete canary deployment"
          kubernetes.core.k8s:
            state: absent
            kind: Deployment
            name: "{{ app_name }}-canary"
            namespace: "{{ namespace }}"
        - name: "Fail the pipeline"
          fail:
            msg: "Canary deployment failed health checks. Rolled back."

    - name: "PROMOTE: Canary is healthy, increase traffic to 50%"
      when: health_check_result.stdout | from_json | json_query('is_healthy') == true
      kubernetes.core.k8s:
        state: present
        definition: # ... TrafficSplit YAML with weight 50 for canary

    # ... 此处可以添加更多阶段,比如等待后检查,然后提升至100% ...

    - name: "Final Phase: Promote canary to stable"
      # 这个阶段会将stable deployment的镜像更新为canary_image,
      # 然后将TrafficSplit的流量100%指向stable,最后清理canary资源。
      # ... 逻辑略 ...

这里的核心是check_canary_health.py脚本。在真实项目中,这个脚本应该足够健壮。

# scripts/check_canary_health.py
import argparse
import json
import sys
import requests

# Linkerd 暴露的成功率指标
# 方向 'to' 表示进入该服务的请求
SUCCESS_RATE_QUERY = 'sum(rate(response_total{direction="to", dst_service="%s", classification="success"}[1m])) / sum(rate(response_total{direction="to", dst_service="%s"}[1m]))'

# P95 延迟指标
LATENCY_QUERY = 'histogram_quantile(0.95, sum(rate(response_latency_ms_bucket{direction="to", dst_service="%s"}[1m])) by (le))'

# 最小RPS阈值,避免在流量过低时做出误判
MIN_RPS_THRESHOLD = 5

def query_prometheus(prometheus_url, query):
    try:
        response = requests.get(f"{prometheus_url}/api/v1/query", params={'query': query})
        response.raise_for_status()
        result = response.json()['data']['result']
        if not result:
            return None
        return float(result[0]['value'][1])
    except (requests.exceptions.RequestException, KeyError, IndexError) as e:
        print(f"Error querying Prometheus: {e}", file=sys.stderr)
        return None

def main():
    parser = argparse.ArgumentParser(description="Check canary service health from Prometheus.")
    parser.add_argument("--url", required=True, help="Prometheus API URL")
    parser.add_argument("--service", required=True, help="Full service name (e.g., myservice.myns.svc.cluster.local)")
    parser.add_argument("--success-threshold", type=float, default=0.995, help="Minimum success rate")
    parser.add_argument("--latency-threshold-ms", type=float, default=200, help="Maximum P95 latency in ms")
    
    args = parser.parse_args()

    # 查询成功率
    success_rate = query_prometheus(args.url, SUCCESS_RATE_QUERY % (args.service, args.service))
    # 查询P95延迟
    p95_latency = query_prometheus(args.url, LATENCY_QUERY % (args.service))

    is_healthy = True
    reasons = []

    if success_rate is None or p95_latency is None:
        is_healthy = False
        reasons.append("Could not retrieve metrics from Prometheus.")
    else:
        if success_rate < args.success_threshold:
            is_healthy = False
            reasons.append(f"Success rate {success_rate:.4f} is below threshold {args.success_threshold}")
        if p95_latency > args.latency_threshold_ms:
            is_healthy = False
            reasons.append(f"P95 latency {p95_latency:.2f}ms is above threshold {args.latency_threshold_ms}ms")
            
    # 返回JSON,方便Ansible解析
    print(json.dumps({
        "is_healthy": is_healthy,
        "success_rate": success_rate,
        "p95_latency": p95_latency,
        "reasons": reasons
    }))

    if not is_healthy:
        sys.exit(1)

if __name__ == "__main__":
    main()

这个脚本的价值在于它将“健康”这个模糊的概念量化为两个具体的SLO(服务等级目标):成功率 > 99.5% 和 P95延迟 < 200ms。如果任何一个指标不达标,脚本会以非零状态码退出,触发Ansible的失败处理逻辑,实现自动回滚。

最终成果

经过两个Sprint的迭代,我们用这套Ansible+Linkerd的自动化发布系统替换了旧的手动流程。效果是立竿见影的。我们的变更失败率从23%降到了3%以下。失败的部署不再能进入生产环境影响用户,它们在金丝雀阶段就被自动化的健康检查捕获并回滚。开发团队的信心也回来了,他们可以更频繁地发布更小的变更,这反过来又进一步降低了单次变更的风险。部署频率(Deployment Frequency)这个DORA指标也因此提升了近3倍。

局限与下一步

当前这套系统虽然有效,但并非完美。
首先,它仍然是基于Ansible的命令式编排,尽管Role是声明式的。对于非常复杂的发布策略(比如基于用户Header的路由),我们可能需要一个更云原生的控制器。像Flagger或Argo Rollouts这样的工具,它们以Operator的形式运行在集群内部,直接监听Deployment资源的变化并与服务网格交互,这会是一种更优雅的实现方式。我们正在调研将其作为下一步的演进方向。

其次,我们的健康检查目前只依赖技术指标(成功率、延迟)。一个更成熟的系统应该能结合业务指标。例如,对于一个电商服务,金丝雀版本的“加入购物车”转化率是否与稳定版持平?这需要将业务监控数据也接入到我们的决策脚本中,是一个更复杂的挑战。

最后,随着服务数量的增多,维护所有这些Ansible配置和脚本本身也带来了管理成本。平台工程的理念提醒我们,应该将这些能力封装成一个对开发者更友好的内部平台或CLI工具,让他们能通过一个简单的命令(如 platform deploy my-app --version v1.2.1 --canary)来触发整个流程,而不是直接与Ansible Playbook打交道。


  目录