背景:为什么非要把脚本搬走
上一篇文章里那台 CM311-1A 还在跑,2G 内存 7 个容器,青龙面板上挂着一堆定时任务。
但它有三个绕不过去的短板:
| 问题 | 具体表现 |
|---|---|
| eMMC 寿命 | 16G 存储写满过一次,日志和数据库反复写同一个块 |
| 内存天花板 | 2G,多开一个容器就可能触发 OOM Killer |
| 单点 | 家里断电、宽带故障、路由器重启,任务全停,而且没人告诉你 |
前两个靠调优能压住,第三个不行。家宽没有固定公网 IP,机器一挂就是静默失败——你得自己去翻日志才知道昨天没跑。
所以真正想解决的不是"性能",是**"别把定时任务绑在一台会坏的机器上"**。
GitHub Actions 正好是干这个的:不用服务器,不用备案,私有仓库每月免费 2000 分钟。这篇讲清楚它到底能干什么、怎么用、以及那几个文档里不写的坑。
先说结论:免费额度到底够不够
GitHub Free 账号的额度:
| 项目 | 额度 |
|---|---|
| 公开仓库 | 标准 runner 完全免费,不计分钟 |
| 私有仓库 | 2000 分钟/月 |
| 计费倍率 | Linux 1× / Windows 2× / macOS 10× |
| 存储(Artifact 等) | 500 MB |
| 单 job 最长 | 6 小时 |
| 免费账号并发 | 20 个 job |
| 日志保留 | 默认 90 天,可调 |
算一笔账。一个签到脚本单次跑 1 分钟左右,一天 3 次,一个月 90 次 ≈ 90 分钟。再加一个监控脚本一天 2 次,60 分钟。一个月总共 150 分钟,只用掉额度的 7.5%。
只要你的脚本不是拿 Actions 当视频转码机,额度根本用不完。
三个必须记住的硬上限:
- 单 job 6 小时,超时直接杀,没有商量
- 定时任务是"尽量准时",不是"准时",高峰期延迟 5–30 分钟是常态
- Windows 和 macOS runner 按 2× / 10× 计费,能上 Linux 就别用别的
它到底是怎么跑起来的
四层结构,一层套一层:
workflow ← 一个 .yml 文件 = 一个工作流
└─ job ← 可以有多个 job,默认并行
└─ step ← 顺序执行的步骤
└─ action ← 可复用的封装,如 actions/checkout@v4
一次运行的完整链路:
GitHub 收到事件(定时到点 / 你手动点 / 有人 push)
→ 去仓库里找匹配的 .yml
→ 开一台全新的云端虚拟机(Runner)
→ clone 你的仓库
→ 按 steps 逐条执行
→ 跑完销毁,机器上的东西全没了
最后一句是重点:机器是一次性的。这一点决定了后面所有的设计取舍,第五节的坑 5 会展开。
Runner 有两种。GitHub-hosted 就是上面这种,用完销毁,算在额度里;Self-hosted 是你自己的机器,挂上去接任务,不占 GitHub 分钟数,但要自己维护、自己保证在线——那又回到"绑在一台机器上"的老问题了,除非你本来就有一台常年开着的 VPS。
五分钟跑通第一个工作流
建文件
路径必须是这个,一个字都不能错:
你的仓库/
└── .github/
└── workflows/
└── hello.yml
workflows 是复数,.github 前面那个点不能少。
写最小版本
name: Hello Actions
on:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: 打招呼
run: echo "你好,现在是 $(date)"
- name: 看看环境
run: |
python3 --version
node --version
workflow_dispatch: 这一行表示"允许手动触发",第一版先用它,方便调试。
提交到默认分支
这一步很多人栽。schedule 和 workflow_dispatch 只认默认分支上的 workflow 文件。你在别的分支建的 yml,Actions 页面上 Run workflow 按钮压根不出现,你会以为是权限问题,其实只是分支不对。
手动跑一次
仓库首页 → 顶部 Actions 标签 → 左侧选你的工作流 → 右上角 Run workflow → 选分支 → 再点一次绿色按钮。
第一次进 Actions 页可能会看到一条横幅 I understand my workflows, go ahead and enable them,点掉就行。
看日志
点进某次运行 → 左侧点 job 名 → 展开每个 step。echo 和 print 的输出全在这。
右上角 ... 菜单里有 Download log archive,可以把整次运行的日志打包下载。
cron 是 UTC,而且会延迟
这是新手最容易搞错的地方。
cron 表达式用的是 UTC 时间,北京时间 = UTC + 8。所以:
schedule:
- cron: "0 23 * * *" # UTC 23:00 = 北京 07:00
- cron: "0 4 * * *" # UTC 04:00 = 北京 12:00
- cron: "30 15 * * *" # UTC 15:30 = 北京 23:30
写 0 7 * * * 以为是早上 7 点,实际是北京时间下午 3 点。
还有一个更隐蔽的误解:工作流里写 TZ: Asia/Shanghai 这个环境变量,只影响脚本内部打印出来的日志时间,完全不影响 cron 的触发时刻。cron 那一层是 GitHub 的调度器在算,跟你 job 里的环境变量没有半点关系。
很多人看到日志里时间是"对的",就以为 cron 也对,结果触发时刻一直歪着。这两件事必须分开看。
延迟也要有心理预期。定时任务在整点前后是全局高峰,延迟 5–30 分钟是正常现象。签到、日报、监控这类场景完全无所谓;抢购、秒杀这类场景想都不要想。
新版 UI 把入口藏起来了
如果你按老教程走「Settings → Secrets and variables → Actions」,会在正确的页面上找不到那一项。
原因很简单:GitHub 把仓库设置的左侧栏改成了分组结构,Secrets and variables 被挪到另一个分组下面了。真实结构是这样:
| 分组 | 菜单项 |
|---|---|
| (最上方) | General、Collaborators |
| Access | Collaborators |
| Code, planning, and automation | Rulesets、Branches、Tags、Actions(可展开)、Webhooks、Copilot、Pages |
| Security and quality | Advanced Security、Deploy keys、Secrets and variables(可展开) |
| Integrations | GitHub Apps |
Secrets and variables 在 Security and quality 分组里。 这是最容易卡住的一步。
比记菜单更省事的办法是直接用直达链接,UI 会改,URL 基本不会:
| 要干什么 | 路径 |
|---|---|
| 加/改密钥 | /settings/secrets/actions |
| 加普通变量 | /settings/variables/actions |
| 改 Workflow permissions | /settings/actions |
| 工作流运行列表 | /actions |
| 某个工作流的记录 | /actions/workflows/你的.yml |
| 用量统计 | /settings/billing/usage |
| 管理 PAT | /settings/tokens |
两个高频操作的精确路径
加密钥:
仓库首页 → Settings
→ 左侧 Security and quality 分组
→ Secrets and variables
→ Actions
→ 右上 "New repository secret"
新版这里多了一个子项叫 Agents(给 Copilot Agent 用的),别点错,要的是 Actions。加完之后列表里只显示名字和更新时间,看不到值,忘了只能重新加。
把 GITHUB_TOKEN 改成可写:
Settings → Code, planning, and automation
→ Actions → General
→ 往下滚,找到 "Workflow permissions"
→ 选 "Read and write permissions"
→ 点这一块的 Save
注意这个页面上有 4 个独立的 Save 按钮,分别属于 Actions permissions / Retention / Fork pull request / Workflow permissions。改哪块就点哪块的 Save,点错了看着像保存了其实没生效。新版最常漏的就是这一步。
五个坑,每个都让人重装一次
坑 1:60 天不活动,定时任务自动停用
仓库 60 天内没有任何提交活动,GitHub 会自动禁用所有 schedule,同时发一封邮件——但邮件很容易被当成通知忽略掉。
症状特别迷惑:手动点 Run workflow 一切正常,定时就是不响。
排查的时候如果只测手动触发,会得出"工作流没问题"的结论,然后永远找不到原因。
补救很简单:随便提交一次代码,或者进 Actions 页面点 Enable workflow。
顺带一提,如果你的工作流本身会往仓库回写文件(比如保存登录态),那每天都有提交,不会触发这个限制。一旦你哪天把回写关掉了,这个问题就会在两个月后突然出现。
坑 2:GITHUB_TOKEN 默认只有读权限
新建或导入的仓库,Workflow permissions 默认是 read-only。工作流里只要出现 git push,必然失败:
remote: Permission to xxx.git denied
改法见上面那一节。这个坑的特点是报错信息很像凭据错误,容易往 PAT 方向查很久。
坑 3:on: 在 YAML 里会变成 True
YAML 1.1 规范把裸词 on、off、yes、no 都当布尔值。所以你在本地用 PyYAML 读工作流文件,会发现:
import yaml
d = yaml.safe_load(open('.github/workflows/x.yml'))
print(d['on']) # KeyError
print(d[True]) # 这才是你要的
GitHub 服务端不受影响,它自己解析。但如果你写本地校验脚本,必须写成 d.get('on') or d.get(True),否则会误判成"这个 workflow 没有触发条件"。
坑 4:多个工作流同时推 main 会撞车
每个 workflow 的 checkout 都是各自的独立快照。A 先 push 成功,B 再 push 就会被拒:
! [rejected] main -> main (fetch first)
如果两个工作流都往同一个仓库回写文件,而且是同一时刻触发,这个冲突几乎必然发生。
解决办法有两种:给每个工作流加不同的 concurrency group,或者在工作流里写"被拒 → fetch → reset --hard → 重试"的逻辑。后一种更稳但要多写几行:
for i in 1 2 3 4 5; do
git add -A && git commit -m "update state" || exit 0
git push && break
git fetch origin main
git reset --hard origin/main
sleep $((i * 3))
done
纪律上还有一条:手动触发时一次只点一个,别同时点三个。
坑 5:Runner 上的改动默认全丢
这个最根本。每次运行都是全新虚拟机,跑完之后:
- 生成的文件没了
- 数据库没了
- 去重标记没了
- 下载的缓存没了
唯一能留存的方式是把文件 git push 回仓库。
所以你的工作流最后往往需要一个"Persist state"步骤——把登录态、进度文件、去重列表提交回去。这也是为什么 Actions 不适合跑那种状态一大堆的脚本。
有人会想到 Actions Cache。它确实能存文件,但有清理策略,不是可靠的持久存储,别把它当唯一存储用。小 json 就 push 回仓库,敏感凭据放 Secrets,大数据老老实实用外部服务。
什么脚本能搬,什么不能
判断标准只有一个:这个脚本有没有"必须留住"的东西?
| 依赖特征 | 支持度 | 说明 |
|---|---|---|
| 纯 HTTP + 无状态 | ✅ 完美 | 最理想的用例 |
| 需要保存登录态 / cookie | ⚠️ 需改造 | 必须 push 回仓库或外部存储 |
依赖青龙专有 API(sendNotify 等) | ⚠️ 要替换 | 直接 POST 推送服务即可 |
| 依赖 MySQL / Redis | ⚠️ 要外接云库 | Runner 上没有本地持久库 |
| 需要浏览器(Playwright) | ⚠️ 能跑但慢 | 每次装 Chromium,几百 MB |
| 单次跑超 6 小时 | ❌ 不行 | 硬上限 |
| 需要固定出口 IP | ❌ 很难 | Runner 在 Azure,IP 每次不同 |
| 需要 GUI / 扫码 | ❌ 不行 | 没有交互界面 |
迁移的时候有四件事一定要做:
- 先列出所有"需要留存"的东西。登录态、去重标记、累计进度 → 必须留存;临时缓存 → 直接不管
- 选留存方式。小 json → push 回仓库;敏感凭据 → Secrets;大数据 → 外部服务
- 砍掉平台专有依赖。
sendNotify→ 直接 POST 推送接口;面板定时 → workflow cron;读取面板配置 → 环境变量 - 配置搬进 Secrets。名字保持不变,脚本里
os.getenv("XXX")一行都不用改
有一个判断特别反直觉:需要固定 IP 或者设备指纹的脚本,不适合搬到 Actions。 因为 Runner 位于 Azure,出口 IP 每次都不一样,绑定 IP 的登录态第二天就掉。这类脚本恰恰需要一台固定 IP 的常驻机器。
混合结构才是正解
所以最后的结论不是"全部搬过去",而是分工:
| 任务类型 | 放哪 | 理由 |
|---|---|---|
| 无状态签到、日报、监控 | GitHub Actions | 免费、不占机器、挂了有邮件 |
| 需要固定 IP / 登录态的 | 自建机器(青龙) | Runner 出口 IP 一直在变 |
| 需要长时运行(>6h) | 自建机器 | Actions 硬上限 |
| 需要扫码 / GUI 的 | 自建机器 | Runner 没有界面 |
两边都往同一个推送通道发通知,手机上看到的结果是一样的。
这样做的收益很实际:盒子挂了,签到和监控还在跑;盒子恢复之后,需要固定身份的那部分任务照常继续。单点故障从"全部停摆"降级成"部分降级"。
如果非要给一个优先级:先把每天都要跑、且必须跑成功的那个任务搬出去,剩下的慢慢来。
机器还在跑,欢迎交流。
本文同步发布于 xiaoyiweb.xyz,一台 2G 电视盒子上跑起来的那点东西,都整理在站内手册里。