跳到主要内容

Jenkins 全链路部署实战:从 Git Push 到 Nginx 重载

· 阅读需 9 分钟

本文以本站(Docusaurus 文档站)为例,完整记录如何搭建一条从代码提交到站点更新的 Jenkins 自动化部署链路。内容涵盖本地 Jenkins 构建产物、FRP 内网穿透打通 Webhook、SSH 上传到云服务器并重载 Nginx。

架构背景

我目前的开发环境是这样的:Jenkins 跑在本地内网机器上,而 Nginx 站点部署在云服务器上。这会带来一个核心矛盾——GitHub Webhook 无法直接回调内网的 Jenkins。

解决方案是 FRP 内网穿透:在云服务器部署 FRP 服务端,本地部署 FRP 客户端,将 Jenkins 端口映射到云服务器公网端口。而 Jenkins 构建完成后,主动 SSH 连接云服务器上传文件——这是出站连接,不受 NAT 限制,不需要 FRP 参与。

开发者 push → GitHub Webhook → 云服务器:31000 (FRP穿透) → 本地 Jenkins 触发
→ 拉代码 → pnpm install → pnpm run build → SSH直连云服务器上传 build/ → nginx reload → 站点更新
角色职责
本地服务器Docker 运行 Jenkins + FRP 客户端,负责拉取代码和构建
云服务器运行 Nginx + FRP 服务端,负责托管站点和接收部署

FRP 内网穿透的完整配置不是本文重点,请先参考 FRP 内网穿透教程 完成搭建。

前提条件

组件位置要求
Jenkins本地服务器Docker 部署,端口映射 31000:8080
Node.jsJenkins 全局工具通过 NodeJS Plugin 自动管理(下文配置)
FRP本地 + 云服务器已配置并连通
Nginx云服务器已安装,SSH 服务开启
SSH 免密本地 → 云服务器已配置

Jenkins 的 Docker 部署请参考 Docker 安装 Jenkins

SSH 免密配置请参考 Jenkins SSH 密钥配置


第一步:Jenkins 插件准备

Jenkins 初始化时,直接选择 安装社区推荐插件(Install suggested plugins) 即可。这会自动装好 Pipeline、Git、Credentials Binding 等核心插件。

之后额外手动安装两个插件:

额外插件用途
NodeJS自动下载和管理 Node.js 版本,无需手动在容器中安装
Publish Over SSHSSH 传输文件 + 远程执行命令

进入 Manage Jenkins → Plugins → Available plugins,依次搜索安装即可。


第二步:配置 Node.js 全局工具

安装 NodeJS 插件后,需要在全局工具配置中添加 Node.js:

  1. 进入 Manage Jenkins → Global Tool Configuration(旧版为 Manage Jenkins → Global Tool Configuration
  2. 滚动到 NodeJS installations 区域,点击 Add NodeJS
  3. 填写配置:
字段
NameNodeJS-20
Install automatically✅ 勾选
Version选择项目所需的版本(本项目要求 ≥ 20.0,如 20.18.0
Global npm packagespnpm@9
  1. 点击 Save

原理:首次构建时,插件会自动从 nodejs.org 下载对应版本的 Node.js 到 $JENKINS_HOME/tools/ 下,后续构建直接复用,无需在容器里手动安装。容器重建后只要挂载了 /var/jenkins_home,已下载的 Node.js 也不会丢失。

注意:NodeJS 插件下载的是 glibc 编译的二进制文件。本文使用的 jenkins/jenkins:lts-jdk21 基于 Debian(glibc),可以正常运行。不要使用 Alpine 镜像,否则会因为 musl libc 不兼容导致 node: not found

为什么用 pnpm 而不是 npm ci

本项目最初使用 npm ci 安装依赖,在 Jenkins 中遇到了 Exit handler never called! 报错。这是 npm 内部 agent-base 的一个已知 bug(npm/cli#9386):当依赖数超过并发连接上限(默认 15)且网络出现超时时,请求会永久卡住。

改用 pnpm 后,得益于其全局 store + 硬链接机制,首次构建后几乎不需要网络请求,从根本上绕开了这个问题。同时构建速度也有显著提升:

npm ci(首次):    从 registry 下载所有包 → 全量写入 node_modules
pnpm install(首次):从 registry 下载到全局 store → 硬链接到 node_modules
pnpm install(后续):直接从 store 硬链接到 node_modules(几乎零网络请求)

pnpm 全局 store 位于 ~/.local/share/pnpm/store(容器内即 /var/jenkins_home/.local/share/pnpm/store),由于已挂载 -v /opt/jenkins/data:/var/jenkins_home,store 会随容器持久化,重建容器也不会丢失。


第三步:配置 SSH 服务器

进入 Manage Jenkins → System,找到 SSH Servers 区域:

Name:             cloud-server
Hostname: 你的云服务器公网 IP
Username: deploy
Remote Directory: /

⚠️ Remote Directory 是根目录,不是家目录!

Publish Over SSH 插件中,remoteDirectory(Jenkinsfile 里的)始终拼接在 SSH Server 配置的 Remote Directory 之后,即使以 / 开头也是如此。也就是说,最终远程路径 = SSH Server Remote Directory + remoteDirectory

举例:如果 SSH Server 的 Remote Directory 设为 /home/deploy,Jenkinsfile 里 remoteDirectory: '/var/www/cludix-doc',实际文件会传到 /home/deploy/var/www/cludix-doc——这不是我们想要的。

正确做法:将 SSH Server 的 Remote Directory 设为 /,让 Jenkinsfile 中的 remoteDirectory 使用绝对路径 /var/www/cludix-doc,这样拼接后才是正确的部署路径。

点击 Advanced 配置认证(密钥认证推荐),点击 Test Configuration 确认连通。


第四步:配置 Git 凭据

进入 Manage Jenkins → Credentials → System → Global credentials

字段
KindUsername with password
IDgit-credentials
Username你的 GitHub 用户名
PasswordGitHub Personal Access Token

第五步:配置 GitHub Webhook

因为 FRP 已将 Jenkins 映射到云服务器端口,GitHub 可以回调到:

http://你的云服务器IP:31000/github-webhook/

GitHub 仓库 → Settings → Webhooks → Add webhook

Payload URL:  http://你的云服务器IP:31000/github-webhook/
Content type: application/json
Events: Just the push event

Jenkins Pipeline 任务中勾选 Build Triggers → GitHub hook trigger for GITScm polling


第六步:创建 Pipeline 任务

  1. New Item → 输入名称 → 选择 Pipeline → OK
  2. 勾选 This project is parameterized(可选,方便切换分支):
参数名: BRANCH
类型: String Parameter
默认值: main
  1. Pipeline 区域,Definition 选择 Pipeline script,粘贴以下内容。

Jenkinsfile 完整内容

pipeline {
agent any

// 使用 NodeJS Plugin 管理的 Node.js 环境
// 名称必须与 Global Tool Configuration 中配置的一致
tools {
nodejs 'NodeJS-20'
}

environment {
// ===== Git 配置 =====
GIT_REPO = 'https://github.com/yunqilee69/cludix-doc.git'
GIT_CRED = 'git-credentials'

// ===== SSH 部署配置(云服务器) =====
SSH_SERVER = 'cloud-server'
REMOTE_DIR = '/var/www/cludix-doc'

// ===== 构建产物目录(Docusaurus 输出到 build/) =====
BUILD_DIR = 'build'
}

stages {

// ==================== 阶段1:拉取代码 ====================
stage('Checkout') {
steps {
echo '📌 拉取代码...'
git branch: "${params.BRANCH ?: 'main'}",
credentialsId: "${GIT_CRED}",
url: "${GIT_REPO}"
}
}

// ==================== 阶段2:安装依赖 ====================
stage('Install Dependencies') {
steps {
echo '📦 安装依赖(pnpm + 淘宝镜像)...'
sh '''
pnpm config set registry https://registry.npmmirror.com
pnpm install --frozen-lockfile
'''
}
}

// ==================== 阶段3:构建 ====================
stage('Build') {
steps {
echo '🔨 构建 Docusaurus 项目...'
sh 'pnpm run build'
}
post {
success {
echo '✅ 构建成功,build 目录已生成'
sh 'ls -lh build/ | head -10'
}
}
}

// ==================== 阶段4:部署到云服务器 ====================
stage('Deploy to Cloud') {
steps {
echo '🚀 通过 SSH 部署到云服务器...'

sshPublisher(
continueOnError: false,
failOnError: true,
publishers: [
sshPublisherDesc(
configName: "${SSH_SERVER}",
verbose: true,
transfers: [
sshTransfer(
// Docusaurus 构建产物在 build/ 目录
sourceFiles: 'build/**/*',
// 去掉 build 前缀,避免远程出现 build/build/ 嵌套
removePrefix: 'build',
remoteDirectory: "${REMOTE_DIR}",
execCommand: """
echo '📁 文件传输完成'

# 备份当前版本
BACKUP_DIR="/var/www/backups/\$(date +%Y%m%d_%H%M%S)"
sudo mkdir -p \${BACKUP_DIR}
sudo cp -r ${REMOTE_DIR}/* \${BACKUP_DIR}/ 2>/dev/null || true
echo "📦 已备份至 \${BACKUP_DIR}"

# 修正文件权限
sudo chown -R www-data:www-data ${REMOTE_DIR}
sudo chmod -R 755 ${REMOTE_DIR}

# 检测 Nginx 配置并重载
echo '🔄 重载 Nginx...'
sudo nginx -t && sudo systemctl reload nginx

echo '✅ 部署完成!'
"""
)
]
)
]
)
}
}
}

post {
success {
echo '🎉 流水线执行成功!站点已更新。'
}
failure {
echo '❌ 流水线执行失败,请检查日志'
}
always {
echo '🧹 流水线结束'
}
}
}

云服务器 Nginx 配置

云服务器 /etc/nginx/conf.d/cludix-doc.conf

server {
listen 80;
server_name your-domain.com;

root /var/www/cludix-doc;
index index.html;

# SPA 路由支持(Docusaurus 需要)
location / {
try_files $uri $uri/ /index.html;
}

# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}

# 禁止访问隐藏文件
location ~ /\. {
deny all;
}
}

云服务器 sudo 免密配置:

sudo visudo

# 添加
deploy ALL=(ALL) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx, /bin/mkdir, /bin/cp, /usr/bin/chown, /bin/chmod

端到端验证

# 1. FRP 连通性
curl http://你的云服务器IP:31000 # 返回 Jenkins 页面

# 2. SSH 连通性
ssh deploy@你的云服务器IP "echo ok"

# 3. Webhook 连通性(GitHub 仓库 Settings → Webhooks)
# 最近一次 Delivery 状态码 200

# 4. 手动触发构建(Jenkins → Build Now)
# 全阶段绿灯 ✅

# 5. 验证站点
curl -I http://your-domain.com # 200 OK

完整链路:

git push → GitHub Webhook → 云服务器:31000 (FRP穿透) → 本地 Jenkins
→ Checkout → pnpm install → pnpm run build
→ SSH 上传 build/ 到云服务器 → nginx -t → systemctl reload nginx
→ 站点更新 ✅

常见问题

问题原因解决
Exit handler never called!npm 的 agent-base bug,依赖超过 15 个并发连接且网络超时时触发使用 pnpm 替代 npm ci(本文方案),或 npm ci --maxsockets 1000
npm ci 失败package-lock.json 或上述 bug改用 pnpm(本文方案),或 npm install
SSH 传输超时文件过大tar 打包再传输
nginx -t 失败Nginx 配置语法错误服务器手动 nginx -t 排查
sudo: no ttysudo 需要 TTY配置 NOPASSWD,参考 SSH 配置文档
文件传输到错误路径SSH Server 的 Remote Directory 是根前缀,remoteDirectory 是相对拼接SSH Server 的 Remote Directory 设为 /,让 Jenkinsfile 用绝对路径
文件嵌套 build/build/removePrefix 未配置确认 removePrefix: 'build'
Webhook 无法触发FRP 不通或端口未放行检查 FRP 状态和云服务器安全组
FRP 相关问题参考 FRP 内网穿透教程

小结

整条链路涉及四个核心环节:FRP 穿透解决入站、Jenkins 流水线解决构建、SSH 传输解决部署、Nginx reload 解决生效。每一环都有独立的原子文档可以深入,本文将它们串联成一个完整的专题。