最近把项目的 CI/CD 流程整体迁移到了 GitHub Actions。功能倒是挺香,但月底一看账单直接傻眼:编译账单贵得离谱,而且每次构建 Docker 镜像,光是拉取 base image 和下载 npm 依赖就要花掉好几分钟。云端默认的 Runner 只有双核,性能捉襟见肘,遇到大项目编译直接卡成狗。为了给公司省钱,同时也是为了拯救自己的下班时间,我决定把 Runner 部署到我们私有 VPC 里的闲置物理机上。不仅 CPU 核数管够,而且跟内网的镜像仓库(Harbor)在同一个内网,构建速度直接起飞。

踩坑:本地 Runner 的基本环境搭建

首先,我们需要在 VPC 里的 Linux 虚拟机上配置自托管 Runner。为了避免 Runner 污染宿主机环境,同时也为了更好的安全隔离,我们通常会使用专门的用户来运行它,并且配置好 Docker 的执行权限。

下面是我们在物理机上部署自托管 Runner 的一键安装与注册脚本:

#!/usr/bin/env bash
# /home/deploy/actions-runner/setup-runner.sh
set -euo pipefail

RUNNER_DIR="/home/deploy/actions-runner"
RUNNER_VERSION="2.316.1"
# 替换为你的组织或仓库 URL
GITHUB_URL="https://github.com/our-tech-org/main-app"
# 替换为在 GitHub 页面获取的临时注册 Token
RUNNER_TOKEN="A7XYZ8899SAMPLETOKEN"

echo "=== 开始安装 GitHub Self-Hosted Runner ==="
mkdir -p "${RUNNER_DIR}"
cd "${RUNNER_DIR}"

if [ ! -f "config.sh" ]; then
    echo "正在下载 Runner 包..."
    curl -o actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz -L \
        "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
    tar xzf "./actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
    rm -f "./actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
fi

echo "正在配置 Runner..."
./config.sh --url "${GITHUB_URL}" \
            --token "${RUNNER_TOKEN}" \
            --name "vpc-runner-node-01" \
            --work "_work" \
            --labels "vpc-runner,linux,x64" \
            --unattended \
            --replace

echo "正在安装 systemd 服务..."
sudo ./svc.sh install deploy
sudo ./svc.sh start

echo "=== Runner 服务已成功启动 ==="

解决 Docker 权限与 Docker-in-Docker 痛点

自托管 Runner 最常遇到的坑就是权限问题。因为 Runner 运行在非 root 用户(如 deploy)下,而打包镜像需要执行 docker build。如果你直接在工作流里调 docker,会遇到 Permission Denied 报错。

千万别偷懒直接用 sudo chmod 777 /var/run/docker.sock!这会让机器面临巨大的越权风险。最规范的做法是将 deploy 用户加入到 docker 用户组中,并重启 Runner 服务:

sudo usermod -aG docker deploy
sudo systemctl restart github-runner.service

终极加速:利用 Docker Buildx 缓存进行本地内网分发

有了本地 Runner 后,我们就可以直接使用私有 VPC 里的 Harbor 作为缓存后端。在云端,GitHub Actions 默认提供的 cache-to=type=gha 虽然方便,但由于云端带宽限制,上传和下载缓存的速度其实并不算快。如果我们将 Docker 构建缓存直接推送到内网 Harbor,不仅免去了公网流量费,而且内网千兆/万兆带宽下,缓存下载几乎是秒级完成。

下面是我们的生产环境 Workflow 配置文件:

# .github/workflows/vpc-docker-build.yml
name: Private VPC Build & Deploy

on:
  push:
    branches:
      - main

jobs:
  build-and-push:
    runs-on: [self-hosted, vpc-runner]
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          driver-opts: image=moby/buildkit:master

      - name: Log in to Private Harbor
        uses: docker/login-action@v3
        with:
          registry: registry.internal.company.com
          username: ${{ secrets.HARBOR_USERNAME }}
          password: ${{ secrets.HARBOR_PASSWORD }}

      - name: Build and Push with Registry Cache
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: registry.internal.company.com/my-project/app:${{ github.sha }}
          cache-from: type=registry,ref=registry.internal.company.com/my-project/app:buildcache
          cache-to: type=registry,ref=registry.internal.company.com/my-project/app:buildcache,mode=max

物理资源清理的“心智负担”

使用自托管 Runner 还有一个不得不防的大坑:磁盘空间暴涨。GitHub 官方托管的 Runner 在每次运行结束后都会直接销毁,天然干净。但自托管 Runner 就像是一台长期运行的服务器,工作流运行久了,本地的 _work 目录和 Docker 产生的悬空镜像(dangling images)会直接把磁盘挤爆。

针对这个问题,我们在物理机上挂了一个每天凌晨跑的 crontab 任务,用于强制回收无用的 Docker 资源:

0 4 * * * /usr/bin/docker system prune -af --volumes

同时在工作流的最后,也可以使用一些清理插件把 _work 目录里的临时构建产物抹掉,防止空间被大文件占满。

把构建任务搬回私有 VPC 之后,原本需要 10 分钟的 Docker 构建流程,在缓存命中和多核物理 CPU 的加持下直接缩短到了 1 分钟左右,编译费用也从几百美金骤降到零。

大家在用自托管 Runner 的时候,有没有遇到过多个构建任务排队阻塞的问题?你们是怎么做多实例动态扩缩容的?