基于 GitHub Actions 实现 Apache APISIX Go 自定义插件的自动化构建与部署


在生产环境中,标准的 API 网关插件集合往往无法满足所有定制化的业务需求。例如,我们需要一个与内部配置中心联动的动态金丝雀发布策略,或者一个基于特定业务逻辑的复杂认证流程。直接修改网关源码风险高、维护成本大,而使用 Lua 编写复杂插件又面临着生态、性能和团队技术栈的挑战。Apache APISIX 提供的 Go Plugin Runner 机制,允许我们用 Go 语言来编写高性能的外部插件,这是一个非常务实的选择。

但问题随之而来:如何管理这些 Go 插件的生命周期?一个独立的 Go 程序,需要被编译、打包、分发,并与 APISIX 实例一同部署。任何手动的构建和部署流程都是不可靠且低效的。在真实项目中,我们需要一个完全自动化的 CI/CD 流程来确保插件的质量与交付速度。

本文将复盘一个完整的实践过程:从零开始开发一个基于 Redis 的动态金丝雀发布 Go 插件,并利用 GitHub Actions 构建一个从代码提交到插件部署的全自动化流水线。

架构设计:APISIX 与 Go Plugin Runner 的协同

在我们深入代码之前,必须先理解 APISIX 与 Go Plugin Runner 的交互模型。它们并非一个单一进程,而是通过 Unix Domain Socket 进行通信的两个独立进程。

graph TD
    subgraph "Client Side"
        Client[HTTP Client]
    end

    subgraph "Gateway Server"
        Client -- HTTP/S Request --> APISIX[Apache APISIX Core]
        APISIX -- 1. gRPC over UDS --> GoRunner[Go Plugin Runner]
        GoRunner -- 2. Process Request with Custom Plugin --> GoRunner
        GoRunner -- 3. gRPC Response --> APISIX
        APISIX -- HTTP/S Response --> Upstream[Upstream Service]
        Upstream -- Response --> APISIX
        APISIX -- Final Response --> Client
    end

    style GoRunner fill:#f9f,stroke:#333,stroke-width:2px
  1. APISIX 核心 (Nginx + Lua): 接收外部请求,当路由规则匹配到配置了 Go 插件时,它不会在内部执行 Lua 逻辑。
  2. 序列化与通信: APISIX 会将请求的上下文(Headers, Body, Args 等)通过 gRPC 序列化,并通过 Unix Domain Socket 发送给 Go Plugin Runner 进程。
  3. Go Plugin Runner: 这是一个独立的 Go 长进程,它加载了我们编写的所有自定义插件。收到请求后,它会根据 APISIX 传递的插件名称和配置,执行相应的 Go 代码。
  4. 执行与返回: Go 插件代码执行业务逻辑(例如修改请求头、重定向、执行认证等),并将结果(或者错误)返回给 APISIX。
  5. 后续处理: APISIX 接收到 Go 插件的处理结果后,继续执行后续的 Nginx 阶段和 Lua 插件,最终将请求转发给上游服务。

这种架构的优势在于隔离性。Go 插件的崩溃不会直接导致 APISIX 核心进程宕机,并且我们可以独立地更新和重启 Go Plugin Runner,这为我们的自动化部署提供了基础。

插件实现:一个动态金丝雀发布插件

我们的目标是实现一个名为 dynamic-canary 的插件。它的逻辑如下:

  1. 从插件配置中获取稳定版(stable)和金丝雀版(canary)的上游服务名称。
  2. 从 Redis 中读取一个键,例如 canary_release_percentage:service-a,获取金丝雀版本应分配的流量百分比(0-100)。
  3. 如果请求头中包含 x-user-group: internal,则强制将流量路由到金丝雀版本,方便内部测试。
  4. 对于普通流量,根据从 Redis 获取的百分比,通过随机数决定将流量路由到稳定版还是金丝雀版。

1. 项目初始化与依赖

# 创建项目目录
mkdir apisix-go-plugin-dynamic-canary
cd apisix-go-plugin-dynamic-canary

# 初始化 Go Module
go mod init github.com/your-org/apisix-go-plugin-dynamic-canary

# 添加 APISIX Go Plugin SDK 和 Redis 客户端
go get github.com/apache/apisix-go-plugin-runner
go get github.com/go-redis/redis/v8

2. 插件核心代码 (main.go)

这是整个插件的核心,代码必须是生产级的,包含错误处理和必要的日志。

// main.go
package main

import (
	"context"
	"fmt"
	"log"
	"math/rand"
	"strconv"
	"time"

	"github.com/apache/apisix-go-plugin-runner/pkg/plugin"
	"github.com/apache/apisix-go-plugin-runner/pkg/runner"
	"github.com/go-redis/redis/v8"
)

// 定义插件的配置结构体,与 APISIX 路由配置中的 JSON 对应
type DynamicCanaryConf struct {
	StableUpstream  string `json:"stable_upstream"`
	CanaryUpstream  string `json:"canary_upstream"`
	RedisHost       string `json:"redis_host"`
	RedisPort       int    `json:"redis_port"`
	RedisKey        string `json:"redis_key"`
	InternalHeader  string `json:"internal_header_key"` // e.g., "x-user-group"
	InternalValue   string `json:"internal_header_value"` // e.g., "internal"
}

// DynamicCanaryPlugin 实现了 APISIX 的插件接口
type DynamicCanaryPlugin struct {
	// 每个插件实例可以在这里持有自己的状态
	redisClient *redis.Client
}

// 确保我们的插件实现了 plugin.Plugin 接口
var _ plugin.Plugin = &DynamicCanaryPlugin{}

// Name 返回插件的名称,这个名称将在 APISIX 路由配置中使用
func (p *DynamicCanaryPlugin) Name() string {
	return "dynamic-canary"
}

// ParseConf 解析用户在 APISIX 路由中为该插件配置的 JSON
// 这个方法在路由配置更新时被调用
func (p *DynamicCanaryPlugin) ParseConf(in []byte) (interface{}, error) {
	conf := DynamicCanaryConf{}
	if err := plugin.JSON.Unmarshal(in, &conf); err != nil {
		return nil, fmt.Errorf("failed to parse configuration: %w", err)
	}

    // 基础配置校验
	if conf.StableUpstream == "" || conf.CanaryUpstream == "" || conf.RedisKey == "" {
		return nil, fmt.Errorf("stable_upstream, canary_upstream, and redis_key are required")
	}

	if conf.RedisHost == "" {
		conf.RedisHost = "127.0.0.1"
	}
	if conf.RedisPort == 0 {
		conf.RedisPort = 6379
	}
    if conf.InternalHeader == "" {
        conf.InternalHeader = "x-user-group"
    }
    if conf.InternalValue == "" {
        conf.InternalValue = "internal"
    }

	// 初始化 Redis 客户端。在真实项目中,这里应该使用连接池和更健壮的配置。
	// 注意:这里的初始化是每个路由规则更新时都会执行,对于 Redis 连接这种重资源,
	// 更好的做法是在插件级别共享一个客户端。这里为简化示例,每次都创建。
	p.redisClient = redis.NewClient(&redis.Options{
		Addr: fmt.Sprintf("%s:%d", conf.RedisHost, conf.RedisPort),
	})

	return conf, nil
}

// Filter 是插件的核心逻辑,在请求的 `rewrite` 阶段执行
func (p *DynamicCanaryPlugin) Filter(conf interface{}, r plugin.Request) {
	// 将配置转换为我们定义的结构体
	canaryConf := conf.(DynamicCanaryConf)
	
	// 检查内部测试用户头
	userGroup := r.Header().Get(canaryConf.InternalHeader)
	if userGroup == canaryConf.InternalValue {
		// 强制路由到金丝雀版本
		err := r.SetVar("upstream", canaryConf.CanaryUpstream)
		if err != nil {
			log.Printf("failed to set upstream for internal user: %v", err)
			r.Stop() // 出现严重错误,中断请求
		}
		log.Printf("Request routed to canary [internal user]: %s", canaryConf.CanaryUpstream)
		return
	}

	// 从 Redis 获取金丝雀流量百分比
	percentageStr, err := p.redisClient.Get(context.Background(), canaryConf.RedisKey).Result()
	if err == redis.Nil {
		// Key 不存在,所有流量都走稳定版
		log.Printf("Redis key '%s' not found, routing to stable", canaryConf.RedisKey)
		err = r.SetVar("upstream", canaryConf.StableUpstream)
        if err != nil {
            log.Printf("failed to set stable upstream: %v", err)
        }
		return
	} else if err != nil {
		// Redis 查询失败,出于安全考虑,回退到稳定版
		log.Printf("Error querying Redis key '%s': %v. Routing to stable as fallback.", canaryConf.RedisKey, err)
		err = r.SetVar("upstream", canaryConf.StableUpstream)
        if err != nil {
            log.Printf("failed to set stable upstream on redis error: %v", err)
        }
		return
	}

	percentage, err := strconv.Atoi(percentageStr)
	if err != nil {
		// 值格式错误,回退到稳定版
		log.Printf("Invalid percentage value in Redis '%s': %s. Routing to stable.", canaryConf.RedisKey, percentageStr)
		err = r.SetVar("upstream", canaryConf.StableUpstream)
        if err != nil {
            log.Printf("failed to set stable upstream on parse error: %v", err)
        }
		return
	}

	// 根据百分比进行随机路由
	if rand.Intn(100) < percentage {
		// 路由到金丝雀版
		err = r.SetVar("upstream", canaryConf.CanaryUpstream)
        if err != nil {
            log.Printf("failed to set canary upstream: %v", err)
        }
		log.Printf("Request routed to canary [percentage: %d%%]: %s", percentage, canaryConf.CanaryUpstream)
	} else {
		// 路由到稳定版
		err = r.SetVar("upstream", canaryConf.StableUpstream)
        if err != nil {
            log.Printf("failed to set stable upstream by percentage: %v", err)
        }
		log.Printf("Request routed to stable [percentage: %d%%]: %s", percentage, canaryConf.StableUpstream)
	}
}

// 入口函数
func main() {
	// 初始化随机数种子
	rand.Seed(time.Now().UnixNano())
	
	// 配置 Go Plugin Runner
	cfg := runner.RunnerConf{
		// APISIX 和 runner 之间的通信 socket 文件路径
		SocketPath: "/tmp/runner.sock",
		// 日志级别和输出路径
		LogLevel: "info",
		LogOutput: "stderr",
	}

	// 注册我们的插件
	if err := plugin.RegisterPlugin(&DynamicCanaryPlugin{}); err != nil {
		log.Fatalf("failed to register plugin: %v", err)
	}

	// 启动 runner
	runner.Run(cfg)
}

这个插件通过 r.SetVar("upstream", "...") 动态修改了当前请求的目标上游。这是 APISIX 插件系统非常强大的一个特性。

本地环境配置与测试

在提交到 CI/CD 之前,本地验证是必不可少的步骤。我们使用 docker-compose 来编排 APISIX、Go Plugin Runner 和 Redis。

docker-compose.yml:

version: '3'
services:
  apisix:
    image: apache/apisix:3.5.0-debian
    restart: always
    volumes:
      - ./apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro
      - /tmp/runner.sock:/tmp/runner.sock # 共享 Unix Socket
    ports:
      - "9080:9080"
      - "9180:9180"
    depends_on:
      - go-plugin-runner
      - redis
  
  go-plugin-runner:
    build:
      context: .
      dockerfile: Dockerfile.runner
    restart: always
    volumes:
      - /tmp/runner.sock:/tmp/runner.sock # 共享 Unix Socket
    depends_on:
      - redis

  redis:
    image: redis:6.2-alpine
    restart: always
    ports:
      - "6379:6379"

  # 两个模拟的上游服务
  upstream_stable:
    image: kennethreitz/httpbin
    restart: always

  upstream_canary:
    image: kennethreitz/httpbin
    restart: always

Dockerfile.runner:

FROM golang:1.20-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .

# 编译 Go 插件,静态链接,移除调试信息,确保在 Alpine 环境下可运行
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-s -w' -o runner .

FROM alpine:latest

WORKDIR /app

# 从 builder 阶段拷贝编译好的二进制文件
COPY --from=builder /app/runner .

# 暴露 Unix Socket 路径
VOLUME /tmp

# 容器启动命令
CMD ["./runner"]

apisix_conf/config.yaml:

apisix:
  node_listen: 9080
  enable_admin: true
  admin_key:
    - name: "admin"
      key: "edd1c9f034335f136f87ad84b625c8f1"
      role: admin

plugins:
  - ... # 默认插件
  - ext-plugin     # 必须启用 ext-plugin

ext-plugin:
  cmd:
    # 这里的配置会被 Go Plugin Runner 自己的配置覆盖,但必须存在
    - path: /usr/local/bin/go-runner
  # 这里的配置告诉 APISIX 去哪里找 Go Plugin Runner
  runner_socket: /tmp/runner.sock

启动服务:

docker-compose up -d --build

配置 APISIX 路由和上游:

# 1. 添加上游服务
curl -i "http://127.0.0.1:9180/apisix/admin/upstreams/stable-service" -X PUT -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '
{
  "nodes": {
    "upstream_stable:80": 1
  },
  "type": "roundrobin"
}'

curl -i "http://127.0.0.1:9180/apisix/admin/upstreams/canary-service" -X PUT -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '
{
  "nodes": {
    "upstream_canary:80": 1
  },
  "type": "roundrobin"
}'

# 2. 添加使用 dynamic-canary 插件的路由
curl -i "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '
{
  "uri": "/anything/*",
  "plugins": {
    "dynamic-canary": {
      "stable_upstream": "stable-service",
      "canary_upstream": "canary-service",
      "redis_host": "redis",
      "redis_port": 6379,
      "redis_key": "canary_percentage"
    }
  },
  "upstream_id": "stable-service"
}'
# 注意:我们仍然需要配置一个默认的 upstream_id,即使插件会覆盖它。

测试路由:

# 设置 Redis 百分比为 30%
docker-compose exec redis redis-cli SET canary_percentage 30

# 循环发送请求,观察日志输出
# 在 go-plugin-runner 的日志中,你会看到路由决策
for i in {1..10}; do curl http://127.0.0.1:9080/anything/test; done

# 使用内部头测试
curl -H "x-user-group: internal" http://127.0.0.1:9080/anything/test
# 此时 go-plugin-runner 日志应该显示强制路由到 canary

本地验证通过,我们现在可以放心地构建自动化流水线了。

GitHub Actions CI/CD 流水线

我们的目标是:当代码推送到 main 分支时,自动触发一个流程,该流程完成编译、测试、构建 Docker 镜像,并将其推送到 GitHub Container Registry (GHCR),最后在目标服务器上更新服务。

.github/workflows/deploy-plugin.yml:

name: Deploy APISIX Go Plugin Runner

on:
  push:
    branches:
      - main
  workflow_dispatch: # 允许手动触发

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write # 授权向 GHCR 推送镜像

    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: '1.20'
          cache: true # 启用 Go 模块缓存

      - name: Run vet and tests
        run: |
          go vet ./...
          # 在真实项目中,这里应该有完整的单元测试
          # go test -v ./...

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN 是由 GitHub Actions 自动提供的

      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v4
        with:
          images: ghcr.io/${{ github.repository }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          dockerfile: Dockerfile.runner
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          # 使用 buildx 并为 amd64 平台构建
          platforms: linux/amd64

  deploy:
    needs: build-and-push # 依赖于上一个 job
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            # 这里的脚本在你的目标服务器上执行
            cd /path/to/your/apisix/deployment # 进入部署目录
            
            # 登录到 GHCR,需要提前在服务器上设置好 DOCKER_USER 和 DOCKER_PAT
            echo ${{ secrets.GHCR_PAT }} | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
            
            # 拉取最新的镜像
            docker-compose pull go-plugin-runner
            
            # 重启服务以应用更新。这里的 `up -d` 会重新创建已更改的服务。
            docker-compose up -d --no-deps go-plugin-runner
            
            # 清理旧的、未被使用的镜像,防止磁盘空间被占满
            docker image prune -f

关键点解释

  1. Secrets: SERVER_HOST, SERVER_USER, SSH_PRIVATE_KEYGHCR_PAT 必须在 GitHub 仓库的 Settings -> Secrets and variables -> Actions 中配置。GHCR_PAT 是一个具有 write:packages 权限的 Personal Access Token。
  2. appleboy/ssh-action: 这是一个非常实用的 Action,它允许我们在 Runner 中通过 SSH 连接到远程服务器并执行脚本。
  3. 部署脚本: 脚本的核心是 docker-compose pulldocker-compose up -d。这个组合会拉取新的镜像并只重启 go-plugin-runner 服务,APISIX 核心服务不会中断。这是一个简单但有效的更新策略。
  4. 镜像清理: docker image prune -f 是一个很好的实践,可以避免服务器磁盘被不断生成的旧镜像占满。

局限性与未来迭代方向

这个方案虽然实现了自动化,但在一个更严苛的生产环境中,仍然存在一些可以改进的地方。

  1. 部署中断: docker-compose up -d 会销毁旧容器并创建新容器。在这个短暂的切换窗口内(通常是亚秒级),APISIX 对 Go Plugin Runner 的 gRPC 调用可能会失败。APISIX 自身有重试机制,但对于高并发场景,更优的方案是使用滚动更新。如果部署在 Kubernetes 中,可以利用其原生的滚动更新能力实现零停机部署。
  2. 配置管理: 插件中的 Redis 地址等配置是硬编码或通过 APISIX 配置传递的。更好的方式是通过环境变量注入到 go-plugin-runner 容器中,这样配置与代码可以分离,更符合十二要素应用原则。
  3. 可观测性: 当前插件只使用了 log.Printf。一个生产级的插件应该集成结构化日志库(如 zerolog),并暴露 Prometheus 指标(例如,路由到 canary/stable 的请求计数、Redis 访问延迟等),以便进行监控和告警。
  4. GitOps 集成: 对于 Kubernetes 环境,可以更进一步。CI 流程只负责构建和推送镜像。部署则交给 ArgoCD 或 Flux 这类 GitOps 工具。GitHub Actions 在构建成功后,只需更新一个 K8s manifest Git 仓库中的镜像标签,GitOps 工具会监听到变化并自动将更新应用到集群中,这使得整个流程的声明性更强,也更易于审计和回滚。

  目录