GitHub Actions + 校园网服务器自动部署

GitHub Actions + 校园网服务器自动部署

概述

已生成图片

在我们的开发流程中,代码主要在本地主机上编写和测试,每次更新后,我们会将代码推送(Push)到 GitHub 的远程仓库。而部署环境则是在校园网内部的一台服务器上。过去每次部署,都需要登录服务器手动拉取代码、重启服务,操作繁琐、效率低下。是否能够实现一种自动化机制:每当我们将代码 Push 到 GitHub 时,服务器能够自动拉取最新代码并完成部署。这正是现代软件开发中广泛采用的 CI/CD(持续集成 / 持续部署) 的典型应用场景。简要来说:

  • CI(Continuous Integration):自动执行代码构建、测试、静态检查等步骤,确保主分支代码质量;
  • CD(Continuous Deployment):将构建/测试通过的代码自动部署到目标环境,减少手动干预。

那么如何实现这样的自动化流程呢?GitHub 为我们提供了一套内置工具 —— GitHub Actions。它是一套基于事件驱动的自动化工作流系统,能够在以下事件发生时自动执行用户定义的操作:

  • 推送代码(push)
  • 创建 Pull Request
  • 发布版本(release)
  • 甚至定时任务(schedule)

通过编写简单的工作流配置文件(YAML 格式),我们可以轻松实现自动构建、测试、部署等操作。在我们的场景中,需求其实很简单:Push 之后自动远程登录到服务器,拉取代码并重启服务。然而,这里有一个额外的现实问题:

由于服务器位于校园网内网,无法直接通过公网 SSH 登录

清华的校园网屏蔽了包括 22(SSH 默认端口)、3389(远程桌面)、8000~8100 等常用端口,导致外部网络无法直接访问校园网主机。因此,除了配置 GitHub Actions,我们还需要借助一个公网中转节点(如腾讯云 VPS)通过 SSH 反向隧道 建立内外连接通道。这个博客梳理一下这个自动化部署流程的配置步骤,涉及的主要技术包括:

  • systemd:服务器端服务管理与自启动
  • SSH 反向隧道:穿透校园网内网,实现远程连接
  • GitHub Actions:自动触发与部署控制

目标是搭建一个 轻量、自动、稳定的 CI/CD 方案,适用于校园网络环境下的个人服务器部署。

systemd

什么是服务?

我们经常提到“服务(service)”这个词,我们在服务器上启动的也是一个自己开发的”服务“,但它具体指的是什么?简单来说,服务就是在后台持续运行、等待处理请求的程序进程。比如:

  • sshd:提供 SSH 登录功能;
  • httpd:提供 HTTP 网站服务;
  • mysqld:提供数据库服务。

由于系统中可能同时运行着几十上百个服务,因此我们很自然会想:有没有一种统一的方法,来管理这些服务?例如统一地启动、停止、重启、监控状态,甚至设置开机自启等。这正是系统初始化工具(init system)所负责的任务。

SysVinit 与 systemd 的演进

在较早的 Linux 系统中,使用的是 System V init(SysVinit)。它的基本原理是:将每个服务对应的启动脚本统一放在 /etc/init.d/ 目录下,通过调用这些脚本来控制服务的启动与关闭:

1
sudo /etc/init.d/cron start

以上命令的含义是启动名为 cron 的服务。我们可以查看这些脚本的内容来了解其具体实现:

1
cat /etc/init.d/cron

脚本中定义了服务的启动命令、PID 文件、日志路径、以及对 startstoprestartstatus 等命令的响应逻辑。

但 SysVinit 存在以下几个明显的缺点:

  • 串行启动:导致系统启动缓慢;
  • 依赖管理薄弱:无法很好地处理服务之间的依赖关系;
  • 脚本风格不统一:各服务的管理方式五花八门,缺乏一致性;
  • 状态管理不清晰:无法准确判断服务是否已经启动、是否运行正常。

为了解决这些问题,现代 Linux 系统普遍采用了 systemd。

什么是 systemd?

systemd 是一个全新的系统初始化与服务管理框架,自 2010 年推出以来,已成为多数主流 Linux 发行版的默认 init 系统(包括 Fedora、CentOS、Ubuntu 等)。

它的核心特性包括:

  • 并行启动服务,显著加快开机速度;
  • 原生支持服务依赖关系;
  • 使用统一命令 systemctl 管理服务;
  • 集成日志系统(journald);
  • 支持挂载点、网络、定时任务等多种资源的一体化管理。

systemd 框架里还包含了一个叫作 systemd 的程序(/bin/systemd),它是用户空间第一个启动的守护进程(PID 1),它负责系统初始化,并接管所有后续服务的管理工作。

systemd 框架包括多个组件,比如:

  • systemctl:服务管理命令行工具;
  • journald:日志收集系统;
  • logind:用户会话管理;
  • networkd:网络配置工具;
  • resolved:DNS 解析守护进程;
  • ……等等。

Unit(单元)

systemd 是怎么进行的一体化整合的呢?计算机领域有一句名言:“计算机科学中的所有问题都可以通过增加一个中间层来解决。” 实际上一切整合类的工作,都可以采用同样的思路:构建统一的更高级别的抽象。

systemd 的设计核心是 unit(单元)这一抽象概念。每一个 unit 描述一个可管理资源,比如服务、挂载点、定时任务等。

unit 文件通常位于以下路径:

1
2
/usr/lib/systemd/system/    # 系统安装的 unit 文件
/etc/systemd/system/ # 用户自定义的 unit 文件(优先级更高)

常见 unit 类型如下表:

Unit 类型 描述 文件后缀
service 后台服务进程(daemon) .service
socket 套接字激活的服务 .socket
target 一组 unit 的逻辑集合(类似运行级别) .target
timer 定时任务(类似 cron .timer
mount 文件系统挂载点 .mount
device 内核识别的设备 .device
path 路径监控(如文件创建/修改触发) .path

这种统一的 unit 抽象,使得 systemd 能够对各种系统资源进行一致性管理。

systemctl:服务管理命令

systemctlsystemd 提供的主要命令行工具,用于操作和查询服务状态。常用命令示例如下:

操作 命令
启动服务 systemctl start nginx
停止服务 systemctl stop nginx
重启服务 systemctl restart nginx
查看服务状态 systemctl status nginx
设置开机自启 systemctl enable nginx
取消开机自启 systemctl disable nginx
列出所有服务 systemctl list-units --type=service
重载 unit 文件 systemctl daemon-reload
重新执行 systemd systemctl daemon-reexec

journalctl:日志管理工具

journald 是 systemd 体系中的 后台日志收集守护进程(daemon),它的任务是收集内核日志、标准输出/错误、Syslog 消息、服务日志等内容,并统一保存在一种二进制日志格式中(通常在 /var/log/journal/)。 journalctl 是 systemd 提供的 用户命令行工具,用于查询、过滤、查看由 journald 收集的日志数据。journalctl 的常用参数包括:

功能 命令示例
查看全部日志 journalctl
查看某服务日志 journalctl -u nginx
查看最近日志 journalctl -r
实时滚动日志 journalctl -f
指定时间查看 journalctl --since "2024-01-01"
查看最近启动日志 journalctl -b

自定义 .service 文件示例

假设我们要部署的是一个基于 Flask 编写的 Web 应用,我们将其作为服务托管给 systemd 管理,可以新建如下的最简 unit 文件(例如 /etc/systemd/system/myapp.service):

1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=My Flask Web App
After=network.target

[Service]
WorkingDirectory=/home/ubuntu/myapp
ExecStart=/usr/bin/python3 app.py
Restart=always

[Install]
WantedBy=multi-user.target

然后执行以下命令启用和启动服务:

1
2
3
4
sudo systemctl daemon-reload # 重新加载 unit 文件
sudo systemctl enable myapp # 设置为开机自启
sudo systemctl start myapp # 启动服务
sudo systemctl status myapp # 查看服务状态

这个 .service 文件分为三个主要部分:

[Unit]:服务的元信息与依赖设置

  • Description=:对服务的简要描述,会显示在 systemctl status 等命令中。
  • After=:表示该服务启动的顺序依赖,network.target 表示“在网络就绪后再启动本服务”。

[Service]:服务的核心配置

  • WorkingDirectory=:指定服务运行时的工作目录,通常是你的项目目录。
  • ExecStart=:服务的启动命令(必须使用绝对路径)。
  • Restart=:当服务异常退出时是否自动重启。always 表示无论什么原因退出都重启。

[Install]:服务如何被“安装”或激活

  • WantedBy=:指定此服务在哪个“target”中被启用。multi-user.target 是一个常用的运行级别,表示该服务在系统正常启动后自动启动。

sudo 权限

当我们配置好了 systemd 服务后,如果运行:

1
sudo systemctl start myapp

默认会要求输入密码。而自动部署时,由于脚本执行是非交互式的,无法输入密码,因此就会失败。为了解决这个问题,我们需要配置服务器的 sudoer 文件,让某个用户在执行特定命令时不需要输入密码。

在 Linux 中,sudo 的行为是由以下这些文件控制的:

配置位置 作用说明
/etc/sudoers 🔧 主配置文件,定义所有用户/组的 sudo 权限,是 sudo 的核心配置
/etc/sudoers.d/ 📁 补充配置目录,存放额外的“分模块配置文件”,可按需单独管理特定用户/服务

当我们想新增 sudo 配置的时候,不要直接修改 /etc/sudoers 本体,否则就可能出错。主配置文件 /etc/sudoers 中显式包含了对 /etc/sudoers.d/ 的引用,只需要在 /etc/sudoers.d/ 中新建一个文件,写入我们要配置的内容即可,sudo 启动时会自动读取并解析这些补充文件。

对于我们的需求,我们可以新建一个配置文件,例如 depoly

1
sudo visudo -f /etc/sudoers.d/deploy

注意,一定要用 visudo 而不是直接编辑visudo 会帮你做语法检查,防止配置错误导致无法 sudo

添加如下内容:

1
ubuntu ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp.service

意思是用户 ubuntu 可以以 root 身份(ALL)运行 /bin/systemctl restart myapp.service,并且不需要输入密码(NOPASSWD)。更安全的是,这条权限只允许执行该一条命令,而不是 ALLsudoer 文件中规则的核心格式是这样的:

1
<用户或组>  <主机列表>= (<可切换身份>) <可执行命令>

因此对于我们的命令,各部分的含义如下:

部分 含义
ubuntu 被授权的用户(也可以是 %groupname 表示组)
ALL(主机列表) 表示在任意主机上都生效(用于集群配置,单机上通常写 ALL)
(ALL)(切换身份) 表示 ubuntu 用户可以以 任何身份(包括 root) 来执行指定命令
NOPASSWD: 执行时不要求输入密码
/bin/systemctl... 被授权执行的命令(精确路径)

SSH 反向隧道

SSH 是什么?

我们经常使用 SSH 客户端(如 ssh 命令或 Xshell 等)远程登录服务器。那么 SSH 到底是什么?

许多时候,我们会直接把它等同于“远程登录服务器的工具”。实际上,SSH(Secure Shell)是一种协议规范,本质上用于在不安全的网络中构建加密的通信通道。所谓的 ”SSH 软件“,实际上只是实现了 SSH 协议的产品。

SSH 协议定义了在网络上传输数据时的三大核心机制:

  • 认证(Authentication):验证通信双方身份;
  • 加密(Encryption):对通信内容进行加密,防止被窃听;
  • 完整性校验(Integrity):确保数据传输过程中未被篡改。

总而言之,SSH 在计算机之间建立网络连接,并能充分保障连接的双方是真实可信的,SSH 还能确保使用该连接传输的所有数据不会被窃听者读取或者修改。“远程登录”、“安全文件传输(如 scp)”、“端口转发(Port Forwarding)”这些只是利用 SSH 安全通信能力在应用层之上的特定功能实现,本质上都只是在一条加密通道上传送不同类型的数据。因此我们需要格外注意:

SSH 是网络通信安全的基础设施,而不是某一种具体的工具用途。

SSH 隧道

基于 SSH,可以实现一种被称为端口转发(port forwarding)隧道(tunneling)的技术,可以实现对 TCP/IP 进行重新路由,使其通过 SSH 连接传输,并且透明底进行端到端地加密。

假设我们在本地(HostA),要连接公司的内网数据库服务 HostC(db.internal.company.com:3306),但这台数据库服务器不能直接从外网访问,只允许公司内网访问。不过公司有一台“跳板机”或“堡垒机”(HostB),它既能从外部访问,也能访问内网数据库。这个时候,我们就可以使用 SSH 本地转发(-L)。

ssh -L 本地端口转发命令,其通用语法如下:

1
ssh -L [本地端口]:[目标主机]:[目标端口] user@跳板机

含义如下:

  • 本地端口:在本地机器监听的端口;
  • 目标主机:是从跳板机所能访问的内部机器,可以是 localhost,也可以是另外一台服务器;
  • 目标端口:目标主机上的服务端口;
  • 跳板机(也称“SSH 网关”):你要通过 SSH 连接的机器,它作为中转。

这条命令可以翻译成:

“在本地监听端口 本地端口,把通过这个端口的所有数据,通过 SSH 连接发给 user@跳板机,由 user@跳板机 再把数据转发到 目标主机:目标端口 上去。”

整个过程如下图所示。这里需要注意的是,HostB 需要写成 user@HostB 的形式,这是因为本地客户端需要主动发起一条 SSH 连接到 HostB,因此这里会涉及到 SSH 登录以及认证。而 HostC 并不是通过 SSH 被连接的对象,它只是 HostB 能访问的目标机器或者服务地址。HostC 和 HostB 之间的通信就是普通的 TCP 请求,而不是 SSH 登录,因为在内网之间我们也无须如此防范。换言之,整个数据流可以总结为:

1
本地程序 → 本地端口 → SSH 加密通道 → HostB(中转机) → 普通 TCP → HostC:目标端口
sequenceDiagram
    participant A as 本地客户端 (HostA)
    participant B as 跳板机/SSH Server (HostB)
    participant C as 目标服务主机 (HostC)

    Note over A,C: SSH 建立连接:ssh -L 端口1:HostC:端口2 user@HostB
    A->>B: SSH 连接(加密通道)

    Note over A: 本地监听 localhost:端口1
    A->>A: 启动本地监听端口(端口1)

    Note over A,B: 通过 SSH 通道发送应用层数据
    A-->>B: 请求转发到 HostC:端口2(加密传输)

    Note over B,C: HostB 普通 TCP 请求转发
    B-->>C: 普通 TCP 请求 → HostC:端口2(未加密)

    C-->>B: 普通 TCP 响应数据(未加密)
    B-->>A: 返回响应(通过 SSH 加密通道)

    Note over A: 最终应用通过 localhost:端口1 完成加密通信

回到我们数据库的例子,我们就可以执行:

1
ssh -L 3307:db.internal.company.com:3306 [email protected]

这条命令会在本地的 3307 端口和内网的 3306 端口之间建立一条 ”隧道“,使得访问本地的 3307 端口,就好像访问内网的 3306 端口一样。接下来在本地执行:

1
mysql -h 127.0.0.1 -P 3307 -u dbuser -p

实际上就是通过本地的 127.0.0.1:3307 连接到了远端的内网数据库 db.internal.company.com:3306

参考文献:Barrett D J, Silverman R E. SSH 权威指南. 中国电力出版社, 2003.


SSH 反向隧道(Reverse Tunnel)

在前面的本地端口转发(ssh -L)中,我们假设有一个可以被公网访问的跳板机,用它作为中转来访问目标服务器。

然而,在 清华校园网的实际情况中,并不存在这样一台“公网可访问”的跳板机,反而是:

  • 外部机器无法连接校园网内主机的 22 端口(因为端口被屏蔽);
  • 校园网内部主机可以主动连接公网机器(比如 VPS 服务器);

在这种场景下,如果我们想要从外部访问校园网内部的服务器,就需要反过来建立连接——由内网主机主动发起 SSH 连接,预先建立一条“隧道”,供外部访问使用。这种技术叫作 SSH 反向隧道,也常被称为“内网穿透”。命令如下:

1
ssh -R <远程端口>:<目标主机>:<目标端口> <用户>@<host2>

在这里,目标主机是运行实际服务的主机,比如数据库服务,服务运行在 <目标端口> 上。host1 就是执行反向隧道命令的主机,它在内网中。在通常的情况下,host1target 是同一个主机,这样 <目标主机> 部分就是 localhosthost2 是公网服务器,作为端口暴露和隧道中转。整个流程如下所示:

sequenceDiagram
    participant Target as 内网目标主机 (target)
    participant Host1 as 内网客户端 (host1,执行 ssh -R)
    participant Host2 as 公网服务器 (host2,VPS)
    participant Client as 外部访问者

    Note over Host1,Host2: host1 执行:ssh -R <远程端口>:<目标主机>:<目标端口> user@host2
    Host1->>Host2: 建立 SSH 连接(加密通道)
    Host2->>Host2: 在本地监听 <远程端口>

    Note over Client,Host2: 外部访问者连接 host2:<远程端口>
    Client->>Host2: 发起 TCP 连接请求(未加密)
    Host2->>Host1: 通过 SSH 隧道转发请求(加密)

    Note over Host1,Target: host1 普通 TCP 转发到 target:<目标端口>
    Host1->>Target: 发起普通 TCP 请求(未加密)
    Target-->>Host1: 响应数据返回(未加密)
    Host1-->>Host2: 响应数据通过 SSH 隧道返回(加密)
    Host2-->>Client: 返回 TCP 响应(未加密)
  1. 首先 host1(在内网)主动通过 SSH 连接 host2(公网 VPS),通过命令建立了一条 SSH 加密隧道,同时要求 host2 开发 <远程端口> 并做好转发准备;

  2. 连接建立后,host2 会在本地监听 <远程端口>,等待外部访问;

  3. 某个外部主机(client)访问 host2:<远程端口>,发起普通 TCP 请求,就像访问一台正常服务;

  4. host2 把这个请求通过SSH 加密隧道转发给 host1

  5. host1 收到后,发起普通 TCP 请求访问 <目标主机>:<目标端口>,也就是最终的服务运行位置。注意:

    • <目标主机> 可以是 localhost(host1 本机);
    • 也可以是 host1 能访问的内网其他机器(比如某台数据库服务器)。
  6. 服务响应返回后,数据沿着原路返回:

    • target 返回给 host1(普通 TCP);

    • host1 再通过 SSH 加密隧道回传给 host2

    • host2 最后将数据返回给最初发起连接的外部 client

在整个过程中,只有 host1host2 之间是通过 SSH 隧道加密通信的,因此需要 host2 可以认证 host1,比如存有 host1 的 SSH 公钥,或者输入用户密码。

数据路径 是否加密 描述
host1 ↔︎ host2 ✅ 是 SSH 隧道,加密通信
host1 ↔︎ target ❌ 否 局域网普通 TCP 请求
client ↔︎ host2 ❌ 否 公开网络 TCP 请求(如 6000)

回到我们的场景,我们希望实现的是:GitHub Actions 能自动登录校园网内的服务器,拉取最新代码并重启服务,也就是一次完整的远程自动部署。也就是说,GitHub Actions 所在的服务器(GitHub 的 Runner)必须能访问这台服务器的 SSH(22 端口)。因此,我们采用SSH 反向隧道,在校园网服务器(内网)上主动向公网 VPS 建立连接,并把自己的 22 端口映射到 VPS 的某个公网端口上(例如 2222):

1
ssh -N -f -R 2222:localhost:22 [email protected]

在校园网内服务器(host1)上执行,建立 SSH 连接到公网 VPS(host2),要求在 VPS 上监听端口 2222。此后所有访问 VPS 2222 的连接,都会被反向转发回校园服务器的 localhost:22,这样就实现了:从公网访问 VPS:2222 = 实际访问 校园网服务器:22。常用的 SSH 选项含义如下所示:

选项 语义/全拼(概念) 作用说明
-L Local Port Forwarding 本地端口转发,将本地端口映射到远程服务
-R Remote Port Forwarding 反向端口转发,将远程端口映射回本地服务
-N No Command 不执行远程命令,仅建立连接(常用于端口转发)
-f Fork to background 建立连接后立即转入后台运行
-i Identity File 指定私钥文件(用于密钥登录)
-p Port 指定远程主机的 SSH 端口
-T Disable pseudo-terminal 不分配伪终端,适用于脚本调用
-v Verbose 显示详细调试信息(排查连接问题时非常有用)
-C Compression 启用压缩传输,适合低带宽网络

autossh

autossh 是一个非常实用的小工具,它可以自动监控并重连断掉的 SSH 会话,常用于保持端口转发或反向隧道长期稳定运行。我们在创建了一个 SSH 隧道之后,网络偶尔中断、SSH 会话被踢掉、VPS 重启等问题都可能导致隧道失效。这时候,就需要 autossh ——一个能在隧道断开时自动重新连接的工具。autossh 可以像正常的 ssh 一样启动一个 SSH 会话,但它会周期性地探测隧道是否活着。如果隧道断了,它会自动重新连接,直到恢复为止。

假设我们想让内网主机一直维持一个反向 SSH 隧道到 VPS:

1
autossh -M 0 -f -N -R 2222:localhost:22 [email protected]

默认情况下,autossh 会额外建立一组“探测连接”,用来监控实际 SSH 隧道是否可用。它通过两个额外端口进行双向通信测试,如果这些端口的通信失败,就判断 SSH 隧道可能挂了,然后自动重启。比如当我们运行:

1
autossh -M 20000 ...

autossh 会在本地监听 20000 和远程监听 20001 两个端口,用它们之间的数据往返作为“心跳探测”。如果这两个端口间的 echo 测试失败,说明 SSH 连接断了,autossh 会自动重连。使用 -M 0 表示禁用 autossh 的内建监控端口机制,只依赖 SSH 自身的重连检测,避免额外占用端口或被防火墙干扰。

Github Actions

概述

GitHub Actions 是一套自动化平台,包含:事件触发机制 + 执行环境(Runner)+ 步骤控制系统。在项目中启用 Github Actions 非常简单,只需要使用一个 YAML 格式的配置文件即可,放在代码仓库路径:

1
.github/workflows/your-workflow-name.yml

只要这个文件存在,GitHub 就会自动启用该工作流(Workflow),并在后台自动分配一台虚拟机(Runner)来执行我们定义的所有步骤。你在 .yml 里写的每一个 run: 命令,都是在这台虚拟机上执行的。这台虚拟机是 GitHub 提供的临时环境,和我们的本地电脑无关。所以 GitHub Actions 本质上就是:自动调度云端虚拟机来执行我们写的步骤,用 Github Actions 的服务器跑我们设定好的流程。所以我们可以把整个流程想象成:

  1. GitHub 检测到我们 push 了代码;
  2. GitHub 立刻找来一台干净的云服务器(虚拟机);
  3. 下载我们的项目代码;
  4. 按照我们的 .yml 文件中定义的步骤逐一执行;
  5. 所有的脚本、命令、SSH 操作、部署行为,都在那台临时云服务器上完成。

下面是一个最小可用的 GitHub Actions 示例,用于在 push 到 main 分支时,执行一条 shell 脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name: Deploy to Server

on:
push:
branches:
- main

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3

- name: Run deploy script
run: bash scripts/deploy.sh

主要字段说明:

字段 说明
name 工作流名称,会显示在 GitHub Actions 页面
on 触发条件,可以是 push, pull_request, schedule
jobs 工作流中要执行的一个或多个任务(job)
runs-on 指定 runner 的操作系统,如 ubuntu-latest, windows-latest
steps 任务的执行步骤,每一步是 shell 命令或使用 uses 引用其他 action
uses 调用别人已经封装好的 Action,比如 actions/checkout@v3
run 执行一条 shell 命令

在这里,actions/checkout@v3 是一个 官方 GitHub Action,用于将我们项目的代码仓库克隆(checkout)到运行环境(Runner)里。当 GitHub Actions 启动时,它只是一台“空”的云主机。默认情况下,它并不会自动包含我们的项目代码。所以必须使用 actions/checkout@v3 把代码“拉取下来”,才能在后续步骤中使用这些文件、脚本、配置等。这个命令表示,使用 actions 组织下的 checkout 模块,并指定版本 v3(目前稳定主流版本)。同时,把当前仓库的代码拉取到当前工作目录中(默认是 /home/runner/work/your-repo)。

run: bash scripts/deploy.sh 的意思是,运行项目代码中 scripts/deploy.sh 脚本。在 .yml 中可以直接使用相对路径,因为默认当前工作目录就是项目根目录。

当然,我们还可能依赖于特定版本的工具,Github Actions 也提供了官方工具 Action 来设置运行时的环境,比如:

1
2
3
4
5
6
7
8
9
10
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'

- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'

如果要设置特定版本的 GCC/CMake/Java 等,可以使用 apt-get install 来安装依赖,也可以使用官方提供的 setup-* 系列 action 来自动安装指定版本的工具,如 setup-java, setup-go, setup-ruby 等。整个流程其实和 Docker 的配置是比较相近的。

执行多行命令

使用 run: | 可以运行多行 shell 脚本,格式如下:

1
2
3
4
5
6
7
- name: 安装依赖并构建
run: |
echo "当前目录:$(pwd)"
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python3 scripts/test.py

run: | 表示多行命令块开始(YAML 中叫“多行块字面量”,就像 Python 的 """ 一样),每行都是标准 Bash 命令,所有命令会在同一个 shell 会话中执行。使用 | 后,在 YAML 中,每行的缩进和行尾空白都会被去掉,而额外的缩进会被保留:

1
2
3
4
5
6
7
8
9
10
# YAML
lines: |
我是第一行
我是第二行
我是吴彦祖
我是第四行
我是第五行

// JSON
"lines": "我是第一行\n我是第二行\n 我是吴彦祖\n 我是第四行\n我是第五行"

如果要在 SSH 登录后再执行多条命令,可以使用 Here Document:

1
2
3
4
5
6
7
8
- name: SSH 远程部署
run: |
ssh -p 2222 [email protected] <<EOF
cd ~/myapp
git pull origin main
source venv/bin/activate
systemctl restart app
EOF

这种写法,属于 Bash 的 Here Document 语法,是一种向命令传递多行字符串的方式。Here Document 的语法是:

1
2
3
command <<[标识符]
...多行内容...
[标识符]

这个语法的意思是把“多行内容”作为标准输入(stdin),送入前面的命令执行。对于我们的例子,整段代码的完整含义是:

  • 用 SSH 连接到远程主机 vps.example.com,端口 2222

  • 然后在连接后执行 <<EOF 之间的所有命令(这些命令会在远程主机上执行);

  • 类似于登录进 SSH 后你手动一条一条输命令,但现在你把它打包好一次性送进去。

片段 含义
ssh -p 2222 user@host 使用 SSH 连接远程主机,端口 2222,用户名 user
<<'EOF' 开启 Here Document,并用 'EOF' 作为“终止标志”
cd ~/myapp ... 是一组命令,会通过 SSH 被传到远程服务器,并在服务器上执行
EOF(最后一行) 表示 Here Document 的结尾,告诉 Bash:“命令结束了”

Secrets 和 Variables

在 Github Actions 中,我们可以使用变量,比如定义服务器 IP、端口、或者私钥等等。在 Github Actions 中,有两种变量,Secrets 和 Variables。Secrets 是我们不希望公开的敏感信息,比如 SSH 私钥、API 密钥、数据库密码。我们可以在 Github 仓库中添加:

Settings → Secrets and variables → Actions → New repository secret

添加后,可以在工作流中这样使用:

1
2
3
4
- name: Connect to server
run: ssh -i key.pem user@host
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}

Variables 是非敏感的环境变量,比如环境名称(dev、prod)、自定义部署路径、标记版本号等。它们的配置入口和 Secrets 是同一地方,也可以在 .yml 中直接定义:

1
2
env:
DEPLOY_ENV: production

然后在步骤中使用 ${{ env.DEPLOY_ENV }}

在自动部署中,我们需要 GitHub Actions 在代码 push 后远程登录服务器,执行命令(如 git pullsystemctl restart),我们有两种认证选择。

方式一:使用账号 + 密码登录

把密码以明文形式保存在 GitHub 的 Secrets 里,然后配合 sshpass 工具进行非交互登录:

1
sshpass -p ${{ secrets.SSH_PASSWORD }} ssh user@host "some-command"

方式二:使用 SSH 密钥对

📍 步骤 1:生成密钥对(在本地)

1
ssh-keygen -t rsa -b 4096 -m PEM -f cicd_rsa
  • -t rsa:生成 RSA 密钥;
  • -b 4096:4096 位强度;
  • -m PEM:强制使用传统 PEM 格式(这才是 -----BEGIN RSA PRIVATE KEY-----);
  • -f cicd_rsa:文件名为 cicd_rsacicd_rsa.pub

会生成:

  • 私钥:~/.ssh/cid_rsa
  • 公钥:~/.ssh/cid_rsa.pub

📍 步骤 2:将公钥加入服务器的信任列表

登录到服务器,执行:

1
2
mkdir -p ~/.ssh
nano ~/.ssh/authorized_keys

然后将 公钥内容(cid_rsa.pub) 粘贴进去保存。

📌 路径是 ~/.ssh/authorized_keys,表示“允许哪些公钥可以无密码 SSH 登录我”。

📍 步骤 3:把私钥配置到 GitHub Secrets

  1. 打开项目仓库 → SettingsSecrets and variablesActions
  2. 点击 New repository secret
  3. 添加一项名称为 SSH_PRIVATE_KEY
  4. 把私钥内容(cid_rsa 文件)复制粘贴进去

📍 步骤 4:GitHub Actions 脚本中使用它

1
2
3
4
5
6
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan server.example.com >> ~/.ssh/known_hosts

然后就可以无密码远程执行命令了:

1
2
3
- name: Deploy
run: |
ssh [email protected] 'cd ~/myapp && git pull && systemctl restart app'

个人项目配置

最后,对于我们所说的项目配置情景,可以使用如下所示的 yml 配置,具体字段含义均显示在 yml 文件的注释中:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# deploy.yml

# 工作流名称
name: Deploy to Server

# 触发工作流的事件
on:
# 当有代码推送到 main 分支时触发
push:
branches:
- main
# 允许在 GitHub Actions 页面手动触发
workflow_dispatch:

jobs:
# 定义一个名为 'deploy' 的任务
deploy:
# 使用最新的 Ubuntu 虚拟机运行
runs-on: ubuntu-latest

steps:
# 第一步:检出代码
# 这是必需的,以便工作流可以访问你的仓库代码
- name: Checkout Code
uses: actions/checkout@v4 # 建议使用最新主版本v4

# 第二步:设置 SSH 连接
- name: Setup SSH
run: |
# 创建 SSH 目录并设置权限
mkdir -p ~/.ssh

# 从 GitHub Secrets 中读取私钥并写入文件
# secrets.SSH_PRIVATE_KEY 应该包含你的 SSH 私钥
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa

# 为私钥文件设置严格的权限
chmod 600 ~/.ssh/id_rsa

# 将服务器的公钥添加到 known_hosts 文件,以避免交互式提示
# secrets.JUMP_HOST_IP 是你的服务器IP地址
ssh-keyscan -p 2222 -H ${{ secrets.JUMP_HOST_IP }} >> ~/.ssh/known_hosts

# 第三步:验证 SSH 连接是否成功
- name: Verify SSH connection
run: |
# 使用配置好的 SSH 密钥尝试连接服务器并执行一个简单命令
# secrets.SERVER_USER 是你登录服务器的用户名
ssh -p 2222 -i ~/.ssh/id_rsa ${{ secrets.SERVER_USER }}@${{ secrets.JUMP_HOST_IP }} echo "SSH connection successful."

# 第四步:部署到服务器
# -t 用于强制分配伪终端(remote shell 更像“真正终端”),-tt:更强地强制(有些环境需要两次 -t 才能让 sudo 正常工作),适用于远程执行带 sudo 或 systemctl 这类需要终端环境的命令
- name: Deploy to Server
run: |
ssh -tt -p 2222 -i ~/.ssh/id_rsa ${{ secrets.SERVER_USER }}@${{ secrets.JUMP_HOST_IP }} << EOF
echo "--- Navigating to project directory: ~/app ---"
cd ~/app

echo "--- Pulling latest changes from main branch ---"
git pull origin main

echo "--- Restarting app service (requires passwordless sudo) ---"
sudo systemctl restart app

echo "--- Deployment finished successfully ---"
exit
EOF

整个部署流程如下图所示:

sequenceDiagram
    participant Dev as 本地开发机
    participant GitHub as GitHub Actions
    participant VPS as 腾讯云中转机 (sheny@vps)
    participant Campus as 校园服务器 (sheny@kepler)

    %% 建立反向隧道(常驻过程)
    Campus->>VPS: ssh -R 2222:localhost:22 sheny@vps
    note over VPS,Campus: VPS 上开放 2222 端口,反向连接 Campus:22

    %% 部署过程开始
    Dev->>GitHub: git push 到 main 分支
    GitHub->>GitHub: 触发 Actions Workflow
    GitHub->>VPS: SSH 连接 VPS:2222(跳板端口)
    VPS->>Campus: 通过反向隧道转发 SSH 到校园服务器
    GitHub->>Campus: (透过隧道)执行部署脚本
    Campus->>Campus: git pull origin main
    Campus->>Campus: systemctl restart app
    Campus-->>GitHub: 返回执行结果
    GitHub-->>Dev: 显示部署成功 / 失败日志

小结

本文结合实际校园网络环境,构建了一套基于 GitHub Actions + SSH 反向隧道 + systemd 的轻量化自动部署方案,核心流程为:

  1. Push 代码到 GitHub → 触发 Actions;
  2. GitHub Actions 通过 VPS 转发 SSH 连接 → 登录校园服务器;
  3. 执行部署脚本 → 拉取最新代码并重启服务。

在配置过程中,我们还介绍了:

  • systemd 的服务管理机制与 .service 文件写法;
  • SSH 反向隧道的原理与实用命令;
  • GitHub Actions 的基础结构、运行环境、Secrets 管理方法;
  • sudoers.d 的免密码配置,保障部署流程稳定自动执行。

借助这些工具,即使在内网受限的场景下,我们也可以优雅实现自动化部署,从此告别手动登录拉代码的繁琐流程。