Gokins

一个用 Go 编写的轻量级自动化构建系统,零依赖、单文件部署,可作为个人或小团队的 Jenkins 替代方案。


特性一览

特性 说明
零数据库依赖 数据全量存于本地 jobs.json / history.json,无需 MySQL / Redis
单文件部署 通过 go:embed 将前端打包进二进制,丢到服务器直接运行
跨平台执行 Linux 自动使用 bash,Windows 自动生成 .bat 运行
实时日志 强制注入 PYTHONUNBUFFERED=1,结合 I/O 管道实现毫秒级日志上屏
Jenkins 兼容 自动转译 H * * * * 轮询语法,修复 Python dict 格式问题
参数化构建 支持 String(文本)和 Choice(下拉框)动态传参
生命周期控制 手动触发、强制中断(Kill 进程树)、暂停 / 恢复定时器
崩溃自愈 服务重启时自动将滞留的 RUNNING 构建标记为 ABORTED,终止空跑计时
无感部署 Graceful Shutdown + 部署脚本,等待当前构建完成后再切换,零中断
现代化 UI TailwindCSS + Vue3,毛玻璃卡片 + Mac 终端风格控制台

快速开始

环境要求:Go 1.18+

# 克隆项目
git clone https://github.com/yourname/gokins && cd gokins

# 安装依赖
go mod tidy

# 启动(访问 http://localhost:8080)
go run main.go

编译为单一二进制

# Linux
GOOS=linux GOARCH=amd64 go build -o gokins main.go

# Windows
GOOS=windows GOARCH=amd64 go build -o gokins.exe main.go

gokins 文件上传到服务器,执行 ./gokins 即可,无需任何额外依赖。


认证

Gokins 支持账号密码认证,凭据通过环境变量配置。

环境变量 默认值 说明
GOKINS_USER admin 登录用户名
GOKINS_PASS 123456 登录密码

Basic Auth 方式(推荐用于脚本调用)

curl -u "admin:yourpassword" http://localhost:8080/api/jobs

前端 fetch 写法

const token = localStorage.getItem('gokins_auth')
const res = await fetch('/api/jobs', {
  headers: { 'Authorization': `Basic ${token}` }
})

API 文档

所有接口均需携带认证 Header(见上方认证章节)。

触发任务

POST /api/run
{
  "job_name": "充电",
  "params": {
    "charge_times": "40",
    "index": "mi13"
  }
}
curl -X POST http://localhost:8080/api/run \
     -u "admin:yourpassword" \
     -H "Content-Type: application/json" \
     -d '{"job_name": "充电", "params": {"charge_times": "40"}}'

获取任务列表

GET /api/jobs

返回所有任务的名称、运行状态、上次执行时间。

获取任务详情与历史

GET /api/job/detail?name={job_name}

返回任务配置及最近构建记录。

查看构建日志

GET /api/logs?name={job_name}&build_id={build_id}

实时日志流(SSE)

GET /api/logs/stream?name={job_name}&build_id={build_id}

前端通过 EventSource 订阅,300ms 增量推送,任务结束后自动发送 event: done 关闭连接。

强制中断任务

GET /api/stop?name={job_name}

向目标进程树发送 SIGTERM,3 秒后强制 SIGKILL

暂停 / 恢复定时器

GET /api/pause?name={job_name}

导出全量配置

GET /api/export

批量导入配置

POST /api/import

目录结构

gokins/
├── main.go               # 启动入口(含 Graceful Shutdown)
├── models/
│   └── job.go            # 数据模型(Job、Parameter、BuildRecord)
├── store/
│   └── store.go          # 持久化层(读写 jobs.json / history.json)
├── scheduler/
│   └── cron.go           # Cron 解析与调度引擎
├── runner/
│   ├── runner.go         # 进程执行器(环境变量注入 + 实时日志落盘)
│   └── recover.go        # 重启自愈:清理滞留 RUNNING 构建
├── api/
│   ├── router.go         # RESTful HTTP 路由
│   ├── sse.go            # SSE 实时日志推送
│   ├── webhook.go        # Git Webhook 触发
│   └── auth.go           # Basic Auth 中间件
└── ui/
    ├── ui.go             # go:embed 静态资源入口
    ├── index.html        # Vue3 SPA
    └── assets/
        ├── style.css
        └── auth.js

重启自愈机制

服务因任何原因重启(崩溃、更新、手动重启),启动时会自动扫描 history.json,将所有状态为 RUNNING 的构建记录:

  • 状态标记为 ABORTED,写入终止时间
  • 在对应日志文件末尾追加终止说明,供前端展示
[Recover] Job=停止任务 Build=#4  RUNNING → ABORTED
[Recover] 共清理 1 个滞留构建

前端下次轮询后,#4 自动变为 🛑 状态,计时停止,终端显示终止原因。

关键文件:runner/recover.go

func RecoverStaleBuilds() {
    jobs := store.GetJobs()
    now := time.Now().Unix()

    for _, job := range jobs {
        for _, record := range store.GetHistory(job.JobName) {
            if record.Status != "RUNNING" {
                continue
            }
            // 追加日志说明
            if f, err := os.OpenFile(record.LogFile, os.O_APPEND|os.O_WRONLY, 0644); err == nil {
                f.WriteString(fmt.Sprintf("\n\n=== ⚠️ 服务已重启,构建 #%d 被强制终止 (ABORTED) ===\n", record.BuildID))
                f.Close()
            }
            store.UpdateHistoryStatus(job.JobName, record.BuildID, "ABORTED", now)
        }
    }
}

main.go 中的调用顺序(顺序严格不可换):

runner.Init()
store.Init()                  // ← 必须先加载数据
runner.RecoverStaleBuilds()   // ← 再清理滞留构建
scheduler.Init()
scheduler.Reload()

无感部署

目标:上传新二进制 → 等待当前正在跑的构建全部完成 → 新进程接管,全程无中断。

原理

旧进程收到 SIGTERM
    └─ HTTP Server 停止接受新请求
    └─ 等待所有 in-flight HTTP 请求完成(最多 30s)
    └─ 等待所有正在运行的 Build 完成(最多 10min)
    └─ 进程退出

systemd 自动拉起新进程
    └─ RecoverStaleBuilds() 扫描(超时的 Build 会被 ABORTED)
    └─ 新进程开始服务

第一步:为 main.go 增加 Graceful Shutdown

替换原来的 http.ListenAndServe 为以下写法:

import (
    "context"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    // ... 初始化代码不变 ...

    srv := &http.Server{
        Addr:    ":" + port,
        Handler: authedHandler,
    }

    // 后台启动 HTTP Server
    go func() {
        log.Printf("Gokins 正在监听端口 :%s\n", port)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("服务器异常退出: %v", err)
        }
    }()

    // 监听系统信号
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    sig := <-quit
    log.Printf("收到信号 %v,开始优雅退出...", sig)

    // 1. 停止接受新 HTTP 请求(30s 超时)
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Printf("HTTP Server 强制关闭: %v", err)
    }

    // 2. 等待所有 Build 完成(最多 10 分钟)
    runner.WaitAllBuilds(10 * time.Minute)

    log.Println("Gokins 已安全退出")
}

第二步:在 runner/runner.go 中增加 WaitAllBuilds

// WaitAllBuilds 等待所有正在运行的构建完成,最多等待 timeout
func WaitAllBuilds(timeout time.Duration) {
    deadline := time.Now().Add(timeout)
    for time.Now().Before(deadline) {
        mutex.RLock()
        count := len(cancelFunc)
        mutex.RUnlock()
        if count == 0 {
            return
        }
        log.Printf("[Shutdown] 等待 %d 个构建完成...", count)
        time.Sleep(3 * time.Second)
    }
    log.Println("[Shutdown] 超时,强制退出")
}

第三步:配置 systemd

/etc/systemd/system/gokins.service

[Unit]
Description=Gokins CI Server
After=network.target

[Service]
Type=simple
User=yys
WorkingDirectory=/home/yys/gokins-app
ExecStart=/home/yys/gokins-app/gokins
Restart=always
RestartSec=3
# 发送 SIGTERM 后最多等待 15 分钟再强制 SIGKILL
TimeoutStopSec=900
Environment=GOKINS_USER=admin
Environment=GOKINS_PASS=yourpassword

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable gokins
systemctl start gokins

第四步:一键部署脚本

在本地或 CI 机器上执行 deploy.sh

#!/bin/bash
set -e

REMOTE="yys@your-server"
REMOTE_DIR="/home/yys/gokins-app"
BINARY="gokins"

echo "📦 编译新版本..."
GOOS=linux GOARCH=amd64 go build -o $BINARY main.go

echo "🚀 上传到服务器..."
scp $BINARY $REMOTE:$REMOTE_DIR/gokins.new

echo "🔄 原子替换二进制..."
# mv 是原子操作,替换过程中旧进程仍使用旧文件(inode 不变)
ssh $REMOTE "mv $REMOTE_DIR/gokins.new $REMOTE_DIR/gokins"

echo "⏳ 通知旧进程优雅退出(等待构建完成)..."
# systemctl reload 发送 SIGHUP;如需完全重启用 restart
ssh $REMOTE "systemctl restart gokins"

echo "✅ 部署完成!"

为什么 mv 是安全的? Linux 中 mv 替换文件只是改变目录项指向的 inode,正在运行的旧进程持有原 inode 的文件描述符,不受影响。新进程启动时才读取新 inode,两者互不干扰。

部署时序图

本地                          服务器(旧进程)          服务器(新进程)
 │                                  │                        │
 ├─ go build ──────────────────────►│                        │
 ├─ scp gokins.new ────────────────►│                        │
 ├─ mv gokins.new → gokins ────────►│(inode 替换,旧进程不感知)
 ├─ systemctl restart ─────────────►│                        │
 │                            收到 SIGTERM                   │
 │                            停止接受请求                   │
 │                            等待 Build 完成...             │
 │                            安全退出 ──────────────────────►│
 │                                                     systemd 拉起新进程
 │                                                     RecoverStaleBuilds()
 │                                                     开始服务 ✅

常见问题

Q:Windows 上运行 cd /home/... 路径报错?

Gokins 不修改脚本内容。从 Jenkins(Linux)导出的脚本在 Windows 上运行时,Linux 路径会失效,请确保脚本与运行环境匹配。

Q:重启后某个构建一直显示 RUNNING 计时怎么办?

检查 main.go 中的初始化顺序,必须是 store.Init()runner.RecoverStaleBuilds(),顺序不能颠倒。

Q:日志会无限增大吗?

日志存放在 logs/ 目录,构建历史保留最近 1000 条(history.json)。物理日志文件可通过以下命令定期清理:

# 删除 30 天前的日志
find /home/yys/gokins-app/logs -name "*.log" -mtime +30 -delete

Q:从 Jenkins 迁移需要注意什么?

Gokins 自动处理:H * * * * Cron 语法转标准格式、Python dict 中的单引号 / None / True 等非标准值。其余内容导入后通过 UI 验证即可。

Q:部署期间正在运行的 Build 会被中断吗?

不会。systemctl restart 会发送 SIGTERM,进程会等待所有 Build 完成后再退出(最多 10 分钟),超时才强制结束。超时被终止的 Build 下次启动时会被 RecoverStaleBuilds 标记为 ABORTED