跳过正文
  1. 全部文章/

GitHub Actions 自动编译 HAProxy Windows 版本

目录

1. 背景
#

HAProxy 官方不提供 Windows 二进制文件。如果你在 Windows 环境下需要用 HAProxy 做反向代理或负载均衡,哪怕只是开发测试用途,要么自己编译,要么找别人编译好的。

我从 2021 年开始在 GitHub 上维护 HAProxyForWindows 项目,提供预编译的 Windows 版 HAProxy。最早的做法很原始:在本地 Windows 机器上装 Cygwin,手动下载源码、编译、收集 DLL、打包上传 Release。每次 HAProxy 发新版本都要重复一遍这个流程。

这个方式有几个明显的问题:版本更新靠人记得去做,经常滞后几周甚至几个月;编译环境的依赖全靠个人记忆维护,换台机器就可能编不过;DLL 收集容易遗漏,用户下载后跑不起来。

这篇文章记录了我如何把这个流程迁移到 GitHub Actions 上,实现全自动的多版本构建和发布。

2. 核心思路
#

最终方案的设计目标是三件事:自动检测上游新版本并触发构建;支持所有在维护的 HAProxy 分支(LTS + Stable + Dev)同时构建;构建完成后自动创建 GitHub Release。

整个 workflow 拆成两个阶段。第一阶段在 Ubuntu runner 上检测版本、构建 matrix;第二阶段在自托管的 Windows runner 上执行实际编译。之所以分成两阶段,是因为版本检测只需要 curl 和 jq,跑在 GitHub 提供的免费 Ubuntu runner 上就够了,没必要占用自己的 Windows 机器。

为什么用自托管 Windows Runner 而非 GitHub 托管的 windows-latest
#

方案优点缺点
GitHub 托管 windows-latest无需维护,开箱即用每次都要重新安装 Cygwin(~5 分钟),构建环境不稳定
自托管 Windows RunnerCygwin 预装,编译快,环境一致需要维护一台 Windows 机器

最初我尝试过在 windows-latest 上动态安装 Cygwin,但每次 CI 跑起来光装环境就要好几分钟,而且 Cygwin 的包版本可能因为镜像更新而变化,导致构建结果不一致。最终选择自托管 runner,把 Cygwin 环境作为前置条件固定下来。

Runner 前置条件:Cygwin 环境准备
#

自托管 runner 需要预装 Cygwin 和编译所需的全部开发包。以下是完整的包列表及其对应的编译参数:

包名版本(参考)用途
gcc-core13.4.0C 编译器
make4.4.1构建工具
openssl3.0.19TLS/SSL 支持(USE_OPENSSL=1
libpcre2-devel10.47正则表达式 JIT(USE_PCRE2=1
zlib-devel1.3.1压缩支持(USE_ZLIB=1
liblua-devel5.4.4Lua 脚本支持(USE_LUA=1
lua5.4.4Lua 运行时
libcrypt-devel4.5.2加密支持(USE_CRYPT_H=1
wget1.25.0下载源码
tar1.35解压源码
sed4.9Makefile patch

一键安装命令:

1
2
3
4
5
6
7
8
9
# 下载 Cygwin 安装器(如尚未下载)
Invoke-WebRequest -Uri "https://www.cygwin.com/setup-x86_64.exe" -OutFile "C:\cygwin-setup.exe"

# 安装所有编译所需的包
C:\cygwin-setup.exe `
    --quiet-mode `
    --root "C:\cygwin" `
    --site "https://mirrors.kernel.org/sourceware/cygwin/" `
    --packages "gcc-core,make,openssl,libssl-devel,libpcre2-devel,zlib-devel,lua,liblua-devel,libcrypt-devel,wget,tar,sed"

安装后验证环境:

1
2
3
4
5
# 检查关键工具是否就位
& "C:\cygwin\bin\bash.exe" -lc "gcc --version | head -1 && make --version | head -1 && lua -v"

# 检查编译所需的 DLL 是否存在(这些后续要打包进 Release)
& "C:\cygwin\bin\bash.exe" -lc "ls /bin/cygwin1.dll /bin/cygcrypto-*.dll /bin/cygssl-*.dll /bin/cygpcre2-8-0.dll /bin/cygz.dll /bin/cyglua-*.dll /bin/cyggcc_s-seh-1.dll"

3. 技术选型与原理
#

3.1 为什么是 Cygwin 而不是其他方案
#

在 Windows 上编译 HAProxy 有几种可能的路径:

方案可行性说明
Cygwin✅ 官方支持HAProxy Makefile 内置 TARGET=cygwin,是唯一官方认可的 Windows 编译目标
MSYS2/MinGW❌ 不可行HAProxy 不支持 MinGW 编译目标,无法生成可用的二进制文件
WSL2❌ 产物不对编译出的是 Linux ELF 二进制,不是 Windows PE,只能在 WSL 内运行
交叉编译❌ 不可行HAProxy 没有 Windows 交叉编译的支持

没什么可选的,Cygwin 是唯一的路。Cygwin 提供了一个 POSIX 兼容层,让 HAProxy 的 Unix 代码能在 Windows 上编译运行,代价是运行时需要依赖 cygwin1.dll 等动态库。

3.2 编译参数选择
#

最终使用的编译参数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
make -j$(nproc) \
  TARGET=cygwin \
  USE_OPENSSL=1 \
  USE_PCRE2=1 \
  USE_PCRE2_JIT=1 \
  USE_ZLIB=1 \
  USE_THREAD=1 \
  USE_CRYPT_H=1 \
  USE_PROMEX=1 \
  USE_LUA=1 \
  USE_GETADDRINFO=1 \
  USE_MATH=1 \
  LUA_LIB_NAME=lua5.4

几个关键决策:

USE_PROMEX=1 启用内置的 Prometheus exporter,这样用户不需要额外部署 exporter 就能接入监控。USE_LUA=1 启用 Lua 脚本支持,可以在配置中嵌入自定义逻辑。USE_GETADDRINFO=1 启用 DNS 解析,这个在早期版本里是不支持的,加上之后 HAProxy 可以解析后端的域名。USE_MATH=1 是 Lua 数学库依赖,不加的话某些 Lua 脚本会报错。

有些参数在 Cygwin 下是不可用的。USE_EPOLLUSE_LINUX_SPLICEUSE_LINUX_TPROXY 这些 Linux 专有特性在 Windows 上没有对应实现,不能开启。USE_QUIC 也不行,QUIC 支持依赖 Linux 的 UDP 相关系统调用。

3.3 Lua 5.3 到 5.4 的升级
#

项目最初使用 Lua 5.3,后来升级到了 5.4。升级过程并不复杂,主要就是改编译参数里的 LUA_LIB_NAME=lua5.4,但需要确认几件事:

Cygwin 仓库里有 lua5.4-devel 包可用;编译后 haproxy -vv 输出中显示 Built with Lua version : Lua 5.4.x;运行时加载 Lua 脚本正常工作。我在本地 Windows 上用 PowerShell 调用 Cygwin bash 做了完整的验证,包括写一个简单的 Lua HTTP service 测试运行时兼容性,确认没问题后才改 workflow。

3.4 –export-dynamic 链接器警告的修复
#

Cygwin 下编译 HAProxy 会产生一个烦人的警告:

1
2
ld: warning: --export-dynamic is not supported for PE+ targets,
did you mean --export-all-symbols?

这是因为 HAProxy 的 Makefile 里写死了 -Wl,--export-dynamic,这是一个 ELF(Linux)的链接器选项,Cygwin 的 PE 格式不支持。虽然不影响编译结果和运行,但在 CI 中 PowerShell 会把 stderr 输出当作错误,导致 pipeline 失败。

解决方案是在编译前 patch Makefile:

1
sed -i 's/--export-dynamic/--export-all-symbols/g' Makefile

一行搞定,干净利落。

4. 具体实现
#

4.1 整体架构
#

flowchart TD
    A["定时触发 / 手动触发"] --> B["detect-versions (Ubuntu)"]
    B --> C{"扫描 haproxy.org\n获取所有分支最新版本"}
    C --> D{"对比 GitHub Release\n过滤已发布版本"}
    D --> E["输出 Matrix JSON"]
    E --> F["build (Self-hosted Windows)\nMatrix 并行"]
    F --> G["下载源码"]
    G --> H["Patch Makefile"]
    H --> I["Cygwin 编译"]
    I --> J["收集 DLL 依赖"]
    J --> K["打包 ZIP"]
    K --> L["commit-to-repo\n更新 README"]
    L --> M["创建 GitHub Release"]

4.2 版本检测与 Matrix 构建
#

第一个 job detect-versions 负责扫描 HAProxy 官网,获取所有在维护分支的最新版本号,然后和已有的 GitHub Release 对比,只构建新版本。

核心逻辑是两层循环:先扫稳定版(2.8, 3.0, 3.1, 3.2, 3.3),再扫开发版(3.4)。稳定版和开发版的源码路径不同,稳定版在 /download/3.3/src/,开发版在 /download/3.4/src/devel/。这是一个实际踩过的坑——最初没区分这两个路径,导致开发版下载 404。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
detect-versions:
  runs-on: ubuntu-latest
  outputs:
    matrix: ${{ steps.build_matrix.outputs.matrix }}
    has_builds: ${{ steps.build_matrix.outputs.has_builds }}

  steps:
  - name: Build version matrix
    id: build_matrix
    env:
      STABLE_SERIES: "2.8 3.0 3.1 3.2 3.3"
      DEV_SERIES: "3.4"
    run: |
      TARGET_REPO="xjoker/HAProxyForWindows"
      MATRIX_ITEMS="[]"

      # 扫描稳定版
      for series in $STABLE_SERIES; do
        LATEST=$(curl -sf "https://www.haproxy.org/download/${series}/src/" | \
          grep -oP 'haproxy-\K[0-9.]+(?=\.tar\.gz)' | sort -V | tail -1)
        if [ -n "$LATEST" ]; then
          MATRIX_ITEMS=$(echo "$MATRIX_ITEMS" | jq --arg v "$LATEST" --arg s "$series" \
            '. + [{"version": $v, "series": $s, "is_dev": "false"}]')
        fi
      done

      # 扫描开发版(注意路径多了 devel/)
      for series in $DEV_SERIES; do
        LATEST_DEV=$(curl -sf "https://www.haproxy.org/download/${series}/src/devel/" | \
          grep -oP 'haproxy-\K[0-9.]+-[a-z]+[0-9]+(?=\.tar\.gz)' | sort -V | tail -1)
        if [ -n "$LATEST_DEV" ]; then
          MATRIX_ITEMS=$(echo "$MATRIX_ITEMS" | jq --arg v "$LATEST_DEV" --arg s "$series" \
            '. + [{"version": $v, "series": $s, "is_dev": "true"}]')
        fi
      done

      # 过滤掉已有 Release 的版本
      FILTERED="[]"
      for row in $(echo "$MATRIX_ITEMS" | jq -r '.[] | @base64'); do
        VERSION=$(echo "$row" | base64 -d | jq -r '.version')
        TAG="v$VERSION"
        if ! gh release view "$TAG" --repo "$TARGET_REPO" > /dev/null 2>&1; then
          FILTERED=$(echo "$FILTERED" | jq --argjson item "$(echo "$row" | base64 -d)" '. + [$item]')
        fi
      done

      COUNT=$(echo "$FILTERED" | jq 'length')
      if [ "$COUNT" -eq 0 ]; then
        echo "has_builds=false" >> $GITHUB_OUTPUT
      else
        echo "has_builds=true" >> $GITHUB_OUTPUT
        echo "matrix={\"include\":$(echo "$FILTERED" | jq -c '.')}" >> $GITHUB_OUTPUT
      fi

workflow 同时支持两种触发方式:定时触发(每天 UTC 02:00 自动扫描)和手动触发(指定版本号或填 all 全量扫描)。

4.3 编译核心:PowerShell 调 Cygwin Bash
#

在 Windows runner 上,GitHub Actions 的 shell 是 PowerShell,但编译需要在 Cygwin 环境下执行。这中间有一个关键的桥接函数 Invoke-Bash

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function Invoke-Bash {
  param([string]$Cmd)

  # 清理 Windows 的 CRLF 换行,Cygwin bash 不认
  $Cmd = $Cmd -replace "`r`n", "`n" -replace "`r", "`n"

  # 临时关闭 PowerShell 的错误处理
  # 否则编译器警告会被当作错误终止 pipeline
  $oldErrorActionPreference = $ErrorActionPreference
  $ErrorActionPreference = 'Continue'

  $output = & "$env:CYGROOT\bin\bash.exe" -lc "set -o igncr; cd `"$wsUnix`" && $Cmd" 2>&1
  $exitCode = $LASTEXITCODE

  $ErrorActionPreference = $oldErrorActionPreference

  # make 命令:检查产物而非退出码(因为警告会导致非零退出码)
  if ($Cmd -like "*make*") {
    Write-Host "===== Make Output (last 50 lines) ====="
    $output | Select-Object -Last 50 | ForEach-Object { Write-Host $_ }
    Write-Host "===== End | Exit code: $exitCode ====="
  }

  return $output -join "`n"
}

这个函数解决了三个实际问题:

set -o igncr 让 bash 忽略 Windows 风格的 \r 回车符,否则脚本会报语法错误。$ErrorActionPreference = 'Continue' 临时关闭 PowerShell 的严格错误处理——Cygwin 编译器产生的 stderr 警告(比如前面提到的 --export-dynamic 警告)会被 PowerShell 视为 NativeCommandError 并终止整个 step。路径转换用 cygpath.exe 把 Windows 路径 D:\work\... 转成 Cygwin 路径 /cygdrive/d/work/...

4.4 DLL 依赖收集
#

编译出的 haproxy.exe 不能独立运行,需要带上 Cygwin 的 DLL。用通配符匹配依赖列表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
$dependencies = @(
  @{Pattern = "cygwin1.dll"; Required = $true},
  @{Pattern = "cygcrypto-*.dll"; Required = $true},
  @{Pattern = "cygssl-*.dll"; Required = $true},
  @{Pattern = "cygpcre2-8-0.dll"; Required = $true},
  @{Pattern = "cygz.dll"; Required = $true},
  @{Pattern = "cyglua-*.dll"; Required = $true},
  @{Pattern = "cyggcc_s-seh-1.dll"; Required = $true}
)

foreach ($dep in $dependencies) {
  $files = Get-ChildItem "$env:CYGROOT\bin" -Filter $dep.Pattern -ErrorAction SilentlyContinue
  if ($files) {
    foreach ($file in $files) {
      Copy-Item $file.FullName $versionDir -Force
      Write-Host "  - $($file.Name)"
    }
  } elseif ($dep.Required) {
    Write-Warning "Required dependency not found: $($dep.Pattern)"
  }
}

使用通配符而非硬编码 DLL 名称很重要——OpenSSL 从 1.1 升级到 3.0 后,DLL 名从 cygcrypto-1.1.dll 变成了 cygcrypto-3.dll,通配符 cygcrypto-*.dll 可以自动适应这种变化。

4.5 打包与发布
#

每个版本产出两个 ZIP 包:

  • haproxy-x.y.z-windows-full.zip:包含 haproxy.exe + 所有 DLL,解压即用
  • haproxy-x.y.z-windows-nolib.zip:只有 haproxy.exe,适合已经安装了 Cygwin 的用户

Release 通过 softprops/action-gh-release 创建,开发版(版本号含 dev/alpha/beta)自动标记为 Pre-release。

5. 端到端流程
#

完整的一次构建周期:

sequenceDiagram
    participant Cron as 定时触发
    participant Ubuntu as Ubuntu Runner
    participant HAProxy as haproxy.org
    participant GH as GitHub API
    participant Win as Windows Runner
    participant Release as GitHub Release

    Cron->>Ubuntu: 触发 workflow
    Ubuntu->>HAProxy: 抓取各分支最新版本
    HAProxy-->>Ubuntu: 3.2.5, 3.3.2, 3.4-dev5...
    Ubuntu->>GH: 检查已有 Release
    GH-->>Ubuntu: 3.2.4 已发布,3.2.5 未发布
    Ubuntu->>Win: Matrix: [version: 3.2.5, ...]
    Win->>HAProxy: 下载 3.2.5 源码
    Win->>Win: sed patch + make + 收集 DLL
    Win->>Win: 打包 full.zip + nolib.zip
    Win->>Release: 创建 v3.2.5 Release

异常处理:下载失败会直接终止该版本的构建但不影响其他版本(fail-fast: false);编译失败通过检查 haproxy.exe 是否存在来判断,而非依赖 make 的退出码;Matrix 中 max-parallel: 1 避免自托管 runner 上的资源竞争。

6. 踩坑记录
#

坑 1:PowerShell 把编译器警告当错误
#

编译过程中 Cygwin ld 产生的 --export-dynamic 警告被写到 stderr,PowerShell 的 $ErrorActionPreference = "Stop" 会把任何 stderr 输出视为致命错误并终止 step。

解决方案是在 Invoke-Bash 函数中临时将 $ErrorActionPreference 设为 Continue,执行完再恢复。同时用 sed patch 掉 Makefile 中的 --export-dynamic,从源头消除警告。

坑 2:开发版的下载 URL 路径不同
#

稳定版源码在 https://www.haproxy.org/download/3.3/src/haproxy-3.3.2.tar.gz,开发版在 https://www.haproxy.org/download/3.4/src/devel/haproxy-3.4-dev4.tar.gz,多了一层 devel/ 目录。

最初的 URL 构造没区分这两种情况,导致开发版下载 404。修复方法是在 PowerShell 中根据版本号判断:

1
2
3
4
5
if ($ver -match "dev|alpha|beta") {
    $downloadUrl = "https://www.haproxy.org/download/$series/src/devel/haproxy-$ver.tar.gz"
} else {
    $downloadUrl = "https://www.haproxy.org/download/$series/src/haproxy-$ver.tar.gz"
}

坑 3:版本号解析——3.4-dev4 提取 series 失败
#

cut -d. -f1,2 从版本号提取系列号时,3.4-dev4 会被提取为 3.4-dev4 而非 3.4

改用正则:

1
SERIES=$(echo "$VERSION" | grep -oP '^\d+\.\d+')

坑 4:CRLF 换行符导致 Cygwin bash 脚本执行失败
#

GitHub Actions 在 Windows 上写入的临时脚本文件使用 Windows 的 CRLF 换行。Cygwin bash 不认 \r,会报各种诡异的语法错误。

解决方案已经在 Invoke-Bash 函数中处理:-replace "`r`n", "`n" 清理输入命令的换行符,set -o igncr 让 bash 忽略残留的 \r

坑 5:haproxy -vv 输出无法正常捕获
#

构建后需要运行 haproxy -vv 获取编译信息写入 Release notes。但这个命令的输出有时为空,原因是 PowerShell 和 Cygwin 进程之间的 stdout/stderr 管道在某些情况下会丢失数据。

最终采用多重回退策略:先在 PowerShell 中直接运行,失败则通过 Cygwin bash 运行,再失败则使用编译参数拼接的默认值。同时用 Base64 编码传递多行输出,避免 GitHub Actions output 中换行符被吞掉。

7. Windows 下 HAProxy 的性能局限性
#

这是一个必须诚实说明的问题。HAProxy 在 Cygwin 下运行有本质的架构限制:

Cygwin 只提供 pollselect 两种 I/O 多路复用机制,Linux 下的 epoll 不可用。poll 的时间复杂度是 O(n),连接数上千后性能急剧下降。Cygwin 的 socket 实现是在 Winsock 上的模拟层,每个连接都有额外的用户态转换开销。pthread 也是用 Windows 线程模拟的,锁竞争开销比原生 Linux 大。

-vv 输出可以看到差异:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Linux 版
Available polling systems :
      epoll : pref=300, test result OK    # 高性能
       poll : pref=200, test result OK
     select : pref=150, test result OK

# Cygwin 版
Available polling systems :
       poll : pref=200, test result OK    # 只有这两个
     select : pref=150, test result OK

实际测试中,Cygwin 下的 HAProxy 能稳定处理几百到低千级并发连接。超过这个范围就会明显感受到延迟上升。

如果在 Windows 上需要更高并发,替代方案是 WSL2(原生 Linux 版 HAProxy,有 epoll)或 Docker Desktop。但对于开发测试、内部工具、低流量的反向代理场景,Cygwin 版完全够用。

8. 局限性与边界
#

适用场景:开发测试环境、Windows 服务器上的轻量反向代理(并发 < 1000)、需要 HAProxy 特定功能但无法部署 Linux 的场景。

不适用场景:高并发生产负载均衡(应使用 Linux 版)、需要 QUIC/HTTP3 支持的场景(Cygwin 不支持)、对延迟敏感的金融交易系统。

CI 层面的局限:版本检测依赖爬取 haproxy.org 的 HTML 目录页,页面结构变化会导致解析失败;自托管 runner 是单点故障,runner 挂了就没有构建能力;max-parallel: 1 限制了并行度,全量构建 6+ 个版本需要排队等待。

9. 结语
#

从手工编译到全自动 CI,核心收益是消除了人为遗忘和环境不一致的问题。现在每天凌晨 workflow 自动扫描上游,有新版本就编译发布,整个过程不需要人工介入。

项目地址:github.com/xjoker/HAProxyForWindows

如果你也需要在 Windows 上使用 HAProxy,可以直接从 Releases 页面下载预编译的二进制文件。

相关文章