<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://yunqilee69.github.io/blog</id>
    <title>云星 Blog</title>
    <updated>2026-07-06T19:30:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://yunqilee69.github.io/blog"/>
    <subtitle>云星 Blog</subtitle>
    <icon>https://yunqilee69.github.io/img/favicon.ico</icon>
    <entry>
        <title type="html"><![CDATA[自建轻量化网盘与 WebDAV：用 dufs 替代重量级方案]]></title>
        <id>https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav</id>
        <link href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav"/>
        <updated>2026-07-06T19:30:00.000Z</updated>
        <summary type="html"><![CDATA[你是否想过在家庭服务器或 VPS 上搭一个网盘，却发现 Nextcloud 太重、ownCloud 太臃肿，而 Samba 又只能在内网用？本文介绍一个用 Rust 编写的极简文件服务器 dufs，它同时提供 Web 文件管理和 WebDAV 协议支持，一条命令即可启动，Docker 部署也只需几行配置。]]></summary>
        <content type="html"><![CDATA[<p>你是否想过在家庭服务器或 VPS 上搭一个网盘，却发现 Nextcloud 太重、ownCloud 太臃肿，而 Samba 又只能在内网用？本文介绍一个用 Rust 编写的极简文件服务器 <strong>dufs</strong>，它同时提供 Web 文件管理和 WebDAV 协议支持，一条命令即可启动，Docker 部署也只需几行配置。</p>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="为什么需要一个轻量化网盘">为什么需要一个轻量化网盘<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E4%B8%80%E4%B8%AA%E8%BD%BB%E9%87%8F%E5%8C%96%E7%BD%91%E7%9B%98" class="hash-link" aria-label="为什么需要一个轻量化网盘的直接链接" title="为什么需要一个轻量化网盘的直接链接" translate="no">​</a></h2>
<p>常见的文件共享需求其实很朴素：</p>
<ul>
<li class="">手机想访问电脑上的某个文件</li>
<li class="">局域网内给同事传个大文件</li>
<li class="">在外面想远程访问家里的资料</li>
<li class="">给备份工具（Rclone、Restic）提供一个 WebDAV 目标</li>
</ul>
<p>如果你用过 Nextcloud 或 ownCloud，大概会感受到：为了实现这些简单的需求，你得维护一套 LAMP/LEMP 环境、一个数据库、定期的安全更新——这还没算上 PHP 带来的性能开销。对于个人或小团队来说，这套方案过于沉重。</p>
<p>另一方面，<code>python3 -m http.server</code> 倒是轻量，但只能下载、不能上传，更别提搜索和权限控制了。</p>
<p><strong>dufs</strong> 刚好填补了这个空白：一个 Rust 编写的单二进制文件服务器，同时提供 Web UI 和 WebDAV 协议，支持上传、搜索、权限控制，甚至可以用 curl 完成所有操作。</p>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="dufs-是什么">dufs 是什么<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#dufs-%E6%98%AF%E4%BB%80%E4%B9%88" class="hash-link" aria-label="dufs 是什么的直接链接" title="dufs 是什么的直接链接" translate="no">​</a></h2>
<p><a href="https://github.com/sigoden/dufs" target="_blank" rel="noopener noreferrer" class="">dufs</a> 是一个用 Rust 编写的轻量级文件服务器，核心特性：</p>
<table><thead><tr><th>能力</th><th>说明</th></tr></thead><tbody><tr><td>静态文件服务</td><td>浏览器直接查看和下载文件</td></tr><tr><td>文件上传</td><td>拖拽上传文件和文件夹</td></tr><tr><td>文件管理</td><td>在线创建、编辑、搜索、删除</td></tr><tr><td>目录打包下载</td><td>整个文件夹下载为 ZIP</td></tr><tr><td>断点续传</td><td>支持大文件的分块上传/下载</td></tr><tr><td>权限控制</td><td>基于账号的路径级读写权限</td></tr><tr><td>WebDAV 协议</td><td>可作为网络磁盘挂载</td></tr><tr><td>HTTPS</td><td>内置 TLS 证书支持</td></tr><tr><td>curl 友好</td><td>所有操作均可通过 curl 完成</td></tr><tr><td>健康检查</td><td>内置 <code>/__dufs__/health</code> 端点</td></tr></tbody></table>
<p>一个二进制、零依赖、默认端口 5000，<code>dufs</code> 一条命令即可启动。</p>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="webdav被低估的协议">WebDAV：被低估的协议<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#webdav%E8%A2%AB%E4%BD%8E%E4%BC%B0%E7%9A%84%E5%8D%8F%E8%AE%AE" class="hash-link" aria-label="WebDAV：被低估的协议的直接链接" title="WebDAV：被低估的协议的直接链接" translate="no">​</a></h2>
<p>WebDAV（Web Distributed Authoring and Versioning）是 HTTP 的扩展协议，在标准 HTTP 方法（GET、PUT、DELETE）基础上增加了 PROPFIND、MKCOL、COPY、MOVE 等方法，使得客户端可以像操作本地文件系统一样操作远程文件。</p>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="为什么-webdav-重要">为什么 WebDAV 重要<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E4%B8%BA%E4%BB%80%E4%B9%88-webdav-%E9%87%8D%E8%A6%81" class="hash-link" aria-label="为什么 WebDAV 重要的直接链接" title="为什么 WebDAV 重要的直接链接" translate="no">​</a></h3>
<ol>
<li class=""><strong>系统级挂载</strong>：Windows、macOS、Linux 原生支持将 WebDAV 映射为网络驱动器，文件管理器直接访问</li>
<li class=""><strong>应用生态丰富</strong>：大量笔记软件（Joplin）、备份工具（Rclone、Restic、Duplicati）、同步工具原生支持 WebDAV</li>
<li class=""><strong>跨平台</strong>：不依赖特定客户端，任何支持 WebDAV 的软件都能对接</li>
<li class=""><strong>HTTP 基础</strong>：走标准 HTTP/HTTPS 端口，穿透防火墙和代理天然友好</li>
</ol>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="各平台-webdav-客户端">各平台 WebDAV 客户端<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E5%90%84%E5%B9%B3%E5%8F%B0-webdav-%E5%AE%A2%E6%88%B7%E7%AB%AF" class="hash-link" aria-label="各平台 WebDAV 客户端的直接链接" title="各平台 WebDAV 客户端的直接链接" translate="no">​</a></h3>
<table><thead><tr><th>平台</th><th>客户端</th><th>说明</th></tr></thead><tbody><tr><td><strong>Windows</strong></td><td>资源管理器（原生）</td><td>映射网络驱动器，但有 50MB 默认文件大小限制</td></tr><tr><td><strong>Windows</strong></td><td><a href="https://www.raidrive.com/" target="_blank" rel="noopener noreferrer" class="">RaiDrive</a>（免费）</td><td>体验最好，支持 WebDAV/SFTP/FTP，像本地磁盘一样使用</td></tr><tr><td><strong>Windows</strong></td><td>ExpanDrive / NetDrive</td><td>商业软件，稳定性更好</td></tr><tr><td><strong>macOS</strong></td><td>Finder（原生）</td><td>「连接服务器」输入地址即可，但大文件和重连体验一般</td></tr><tr><td><strong>macOS</strong></td><td>ExpanDrive / NetDrive</td><td>使用 File Provider API，更稳定</td></tr><tr><td><strong>Linux</strong></td><td>davfs2</td><td><code>mount -t davfs</code> 挂载，但项目已不太活跃</td></tr><tr><td><strong>Linux</strong></td><td>RaiDrive CLI</td><td>支持 Debian/RHEL/Alpine 等发行版</td></tr><tr><td><strong>移动端</strong></td><td>各平台 WebDAV App</td><td>iOS/Android 均有大量 WebDAV 客户端</td></tr></tbody></table>
<blockquote>
<p>💡 <strong>Windows 原生 WebDAV 的坑</strong>：默认 50MB 文件大小限制需要改注册表（<code>FileSizeLimitInBytes</code>），且使用 HTTPS 时证书验证较严格。推荐用 RaiDrive 替代。</p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="权限控制与鉴权">权限控制与鉴权<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E6%9D%83%E9%99%90%E6%8E%A7%E5%88%B6%E4%B8%8E%E9%89%B4%E6%9D%83" class="hash-link" aria-label="权限控制与鉴权的直接链接" title="权限控制与鉴权的直接链接" translate="no">​</a></h2>
<p>dufs 的权限模型设计得简洁但够用，核心是 <strong>账号 + 路径 + 权限</strong> 三元组：</p>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="鉴权方式">鉴权方式<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E9%89%B4%E6%9D%83%E6%96%B9%E5%BC%8F" class="hash-link" aria-label="鉴权方式的直接链接" title="鉴权方式的直接链接" translate="no">​</a></h3>
<p>dufs 同时支持 <strong>Basic Auth</strong> 和 <strong>Digest Auth</strong>，默认使用 Digest Auth（更安全，密码不在网络中明文传输）。</p>
<div class="language-bash codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-bash codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain"># Digest Auth（默认）</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">curl --user admin:pass123 --digest http://your-server:5000/</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># Basic Auth</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">curl --user admin:pass123 http://your-server:5000/</span><br></span></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="权限规则格式">权限规则格式<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E6%9D%83%E9%99%90%E8%A7%84%E5%88%99%E6%A0%BC%E5%BC%8F" class="hash-link" aria-label="权限规则格式的直接链接" title="权限规则格式的直接链接" translate="no">​</a></h3>
<div class="language-bash codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-bash codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">-a "用户名:密码@/路径:权限"</span><br></span></code></pre></div></div>
<ul>
<li class=""><code>:rw</code> 表示读写权限，不加则只读</li>
<li class=""><code>@/</code> 前面不写用户名密码表示匿名用户</li>
<li class="">多条规则用 <code>|</code> 分隔</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="实际场景示例">实际场景示例<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E5%AE%9E%E9%99%85%E5%9C%BA%E6%99%AF%E7%A4%BA%E4%BE%8B" class="hash-link" aria-label="实际场景示例的直接链接" title="实际场景示例的直接链接" translate="no">​</a></h3>
<div class="language-bash codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-bash codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain"># 管理员：完全读写权限</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">-a "admin:secretpass@/:rw"</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># 普通用户：只能读写 /share 目录</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">-a "user:userpass@/share:rw"</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># 匿名用户：只读访问 /public 目录</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">-a "@/public"</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># 组合：管理员全权 + 匿名只读</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">-a "admin:secretpass@/:rw|@/"</span><br></span></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="重要细节">重要细节<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E9%87%8D%E8%A6%81%E7%BB%86%E8%8A%82" class="hash-link" aria-label="重要细节的直接链接" title="重要细节的直接链接" translate="no">​</a></h3>
<p><strong>账号权限受全局权限约束</strong>。如果 dufs 启动时没有开启 <code>--allow-upload</code>，即使账号被授予 <code>:rw</code> 权限，也无法上传。这意味着你可以用全局开关做硬性限制，用账号权限做细粒度控制。</p>
<p>dufs 还支持 <strong>哈希密码</strong>，避免在命令行或配置文件中暴露明文密码，对生产环境更友好。</p>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="和其他方案对比">和其他方案对比<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E5%92%8C%E5%85%B6%E4%BB%96%E6%96%B9%E6%A1%88%E5%AF%B9%E6%AF%94" class="hash-link" aria-label="和其他方案对比的直接链接" title="和其他方案对比的直接链接" translate="no">​</a></h2>
<table><thead><tr><th>特性</th><th>dufs</th><th>Filebrowser</th><th>Nextcloud</th><th>Samba</th></tr></thead><tbody><tr><td>部署复杂度</td><td>一条命令</td><td>Docker / 二进制</td><td>LAMP + DB</td><td>系统级安装</td></tr><tr><td>内存占用</td><td>~10MB</td><td>~30MB</td><td>512MB+</td><td>~50MB</td></tr><tr><td>Web UI</td><td>简洁</td><td>完整</td><td>功能丰富</td><td>无</td></tr><tr><td>WebDAV</td><td>原生支持</td><td>不支持</td><td>支持</td><td>不支持</td></tr><tr><td>权限控制</td><td>路径级</td><td>用户级</td><td>完整 ACL</td><td>系统用户级</td></tr><tr><td>文件编辑</td><td>在线编辑</td><td>在线编辑</td><td>在线编辑 + 协作</td><td>无</td></tr><tr><td>搜索</td><td>内置</td><td>内置</td><td>全文搜索</td><td>无</td></tr><tr><td>外网访问</td><td>HTTP 直接</td><td>反代</td><td>反代</td><td>需 VPN</td></tr><tr><td>适用场景</td><td>轻量共享 + WebDAV</td><td>纯 Web 文件管理</td><td>团队协作</td><td>内网共享</td></tr></tbody></table>
<p><strong>选择建议</strong>：</p>
<ul>
<li class="">需要团队协作、日历、联系人 → <strong>Nextcloud</strong></li>
<li class="">只需要 Web 文件管理界面 → <strong>Filebrowser</strong>（本站也有<a class="" href="https://yunqilee69.github.io/docs/tutorials/docker/deployments/Filebrowser%E9%83%A8%E7%BD%B2">部署教程</a>）</li>
<li class="">需要轻量 + WebDAV + 文件管理 → <strong>dufs</strong></li>
<li class="">纯内网共享、不需要外网访问 → <strong>Samba</strong></li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="我的用法">我的用法<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E6%88%91%E7%9A%84%E7%94%A8%E6%B3%95" class="hash-link" aria-label="我的用法的直接链接" title="我的用法的直接链接" translate="no">​</a></h2>
<p>我的典型使用场景：</p>
<ol>
<li class=""><strong>网盘替代</strong>：浏览器访问 dufs 的 Web UI 上传下载文件，手机和电脑通用</li>
<li class=""><strong>WebDAV 挂载</strong>：Windows 上用 RaiDrive 把 dufs 挂载为本地磁盘，像操作本地文件一样操作远程文件</li>
<li class=""><strong>备份目标</strong>：Rclone 定期备份重要数据到 dufs 的 WebDAV 端口</li>
<li class=""><strong>临时共享</strong>：需要给同事传文件时，丢到 dufs 的共享目录，给个链接即可</li>
</ol>
<p>具体部署方式见教程：<a class="" href="https://yunqilee69.github.io/docs/tutorials/docker/deployments/Dufs%E9%83%A8%E7%BD%B2">Dufs 部署</a></p>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="总结">总结<a href="https://yunqilee69.github.io/blog/self-hosted-lightweight-cloud-webdav#%E6%80%BB%E7%BB%93" class="hash-link" aria-label="总结的直接链接" title="总结的直接链接" translate="no">​</a></h2>
<p>dufs 用最简单的方式覆盖了个人网盘的核心需求：文件管理 + WebDAV + 权限控制。对于不需要 Nextcloud 那种全家桶功能的用户来说，dufs 是一个非常优雅的选择——Rust 带来的低资源占用和单二进制部署，让维护成本几乎为零。</p>
<p>如果你的需求更复杂（多人协作、在线编辑、插件生态），Nextcloud 依然是更好的选择。但对于「我就是想简单存取文件、挂个网盘、做个备份目标」这个场景，dufs 值得一试。</p>]]></content>
        <category label="dufs" term="dufs"/>
        <category label="webdav" term="webdav"/>
        <category label="docker" term="docker"/>
        <category label="storage" term="storage"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Jenkins 全链路部署实战：从 Git Push 到 Nginx 重载]]></title>
        <id>https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide</id>
        <link href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide"/>
        <updated>2026-06-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[本文以本站（Docusaurus 文档站）为例，完整记录如何搭建一条从代码提交到站点更新的 Jenkins 自动化部署链路。内容涵盖本地 Jenkins 构建产物、FRP 内网穿透打通 Webhook、SSH 上传到云服务器并重载 Nginx。]]></summary>
        <content type="html"><![CDATA[<p>本文以本站（Docusaurus 文档站）为例，完整记录如何搭建一条从代码提交到站点更新的 Jenkins 自动化部署链路。内容涵盖本地 Jenkins 构建产物、FRP 内网穿透打通 Webhook、SSH 上传到云服务器并重载 Nginx。</p>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="架构背景">架构背景<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E6%9E%B6%E6%9E%84%E8%83%8C%E6%99%AF" class="hash-link" aria-label="架构背景的直接链接" title="架构背景的直接链接" translate="no">​</a></h2>
<p>我目前的开发环境是这样的：Jenkins 跑在本地内网机器上，而 Nginx 站点部署在云服务器上。这会带来一个核心矛盾——GitHub Webhook 无法直接回调内网的 Jenkins。</p>
<p>解决方案是 <strong>FRP 内网穿透</strong>：在云服务器部署 FRP 服务端，本地部署 FRP 客户端，将 Jenkins 端口映射到云服务器公网端口。而 Jenkins 构建完成后，主动 SSH 连接云服务器上传文件——这是出站连接，不受 NAT 限制，不需要 FRP 参与。</p>
<div class="language-text codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-text codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">开发者 push → GitHub Webhook → 云服务器:31000 (FRP穿透) → 本地 Jenkins 触发</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">→ 拉代码 → pnpm install → pnpm run build → SSH直连云服务器上传 build/ → nginx reload → 站点更新</span><br></span></code></pre></div></div>
<table><thead><tr><th>角色</th><th>职责</th></tr></thead><tbody><tr><td>本地服务器</td><td>Docker 运行 Jenkins + FRP 客户端，负责拉取代码和构建</td></tr><tr><td>云服务器</td><td>运行 Nginx + FRP 服务端，负责托管站点和接收部署</td></tr></tbody></table>
<blockquote>
<p>FRP 内网穿透的完整配置不是本文重点，请先参考 <a class="" href="https://yunqilee69.github.io/docs/tutorials/network/frp-nat">FRP 内网穿透教程</a> 完成搭建。</p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="前提条件">前提条件<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E5%89%8D%E6%8F%90%E6%9D%A1%E4%BB%B6" class="hash-link" aria-label="前提条件的直接链接" title="前提条件的直接链接" translate="no">​</a></h2>
<table><thead><tr><th>组件</th><th>位置</th><th>要求</th></tr></thead><tbody><tr><td>Jenkins</td><td>本地服务器</td><td>Docker 部署，端口映射 31000:8080</td></tr><tr><td>Node.js</td><td>Jenkins 全局工具</td><td>通过 NodeJS Plugin 自动管理（下文配置）</td></tr><tr><td>FRP</td><td>本地 + 云服务器</td><td>已配置并连通</td></tr><tr><td>Nginx</td><td>云服务器</td><td>已安装，SSH 服务开启</td></tr><tr><td>SSH 免密</td><td>本地 → 云服务器</td><td>已配置</td></tr></tbody></table>
<p>Jenkins 的 Docker 部署请参考 <a class="" href="https://yunqilee69.github.io/docs/tutorials/ci/jenkins/install-jenkins">Docker 安装 Jenkins</a></p>
<p>SSH 免密配置请参考 <a class="" href="https://yunqilee69.github.io/docs/tutorials/ci/jenkins/jenkins-ssh-config">Jenkins SSH 密钥配置</a>。</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="第一步jenkins-插件准备">第一步：Jenkins 插件准备<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E7%AC%AC%E4%B8%80%E6%AD%A5jenkins-%E6%8F%92%E4%BB%B6%E5%87%86%E5%A4%87" class="hash-link" aria-label="第一步：Jenkins 插件准备的直接链接" title="第一步：Jenkins 插件准备的直接链接" translate="no">​</a></h2>
<p>Jenkins 初始化时，直接选择 <strong>安装社区推荐插件（Install suggested plugins）</strong> 即可。这会自动装好 Pipeline、Git、Credentials Binding 等核心插件。</p>
<p>之后额外手动安装两个插件：</p>
<table><thead><tr><th>额外插件</th><th>用途</th></tr></thead><tbody><tr><td><strong>NodeJS</strong></td><td>自动下载和管理 Node.js 版本，无需手动在容器中安装</td></tr><tr><td><strong>Publish Over SSH</strong></td><td>SSH 传输文件 + 远程执行命令</td></tr></tbody></table>
<p>进入 <strong>Manage Jenkins → Plugins → Available plugins</strong>，依次搜索安装即可。</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="第二步配置-nodejs-全局工具">第二步：配置 Node.js 全局工具<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E7%AC%AC%E4%BA%8C%E6%AD%A5%E9%85%8D%E7%BD%AE-nodejs-%E5%85%A8%E5%B1%80%E5%B7%A5%E5%85%B7" class="hash-link" aria-label="第二步：配置 Node.js 全局工具的直接链接" title="第二步：配置 Node.js 全局工具的直接链接" translate="no">​</a></h2>
<p>安装 NodeJS 插件后，需要在全局工具配置中添加 Node.js：</p>
<ol>
<li class="">进入 <strong>Manage Jenkins → Global Tool Configuration</strong>（旧版为 <strong>Manage Jenkins → Global Tool Configuration</strong>）</li>
<li class="">滚动到 <strong>NodeJS installations</strong> 区域，点击 <strong>Add NodeJS</strong></li>
<li class="">填写配置：</li>
</ol>
<table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody><tr><td>Name</td><td><code>NodeJS-20</code></td></tr><tr><td>Install automatically</td><td>✅ 勾选</td></tr><tr><td>Version</td><td>选择项目所需的版本（本项目要求 ≥ 20.0，如 <code>20.18.0</code>）</td></tr><tr><td>Global npm packages</td><td><code>pnpm@9</code></td></tr></tbody></table>
<ol start="4">
<li class="">点击 <strong>Save</strong></li>
</ol>
<blockquote>
<p><strong>原理</strong>：首次构建时，插件会自动从 nodejs.org 下载对应版本的 Node.js 到 <code>$JENKINS_HOME/tools/</code> 下，后续构建直接复用，无需在容器里手动安装。容器重建后只要挂载了 <code>/var/jenkins_home</code>，已下载的 Node.js 也不会丢失。</p>
<p><strong>注意</strong>：NodeJS 插件下载的是 glibc 编译的二进制文件。本文使用的 <code>jenkins/jenkins:lts-jdk21</code> 基于 Debian（glibc），可以正常运行。<strong>不要使用 Alpine 镜像</strong>，否则会因为 musl libc 不兼容导致 <code>node: not found</code>。</p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="为什么用-pnpm-而不是-npm-ci">为什么用 pnpm 而不是 npm ci<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E4%B8%BA%E4%BB%80%E4%B9%88%E7%94%A8-pnpm-%E8%80%8C%E4%B8%8D%E6%98%AF-npm-ci" class="hash-link" aria-label="为什么用 pnpm 而不是 npm ci的直接链接" title="为什么用 pnpm 而不是 npm ci的直接链接" translate="no">​</a></h3>
<p>本项目最初使用 <code>npm ci</code> 安装依赖，在 Jenkins 中遇到了 <code>Exit handler never called!</code> 报错。这是 npm 内部 <code>agent-base</code> 的一个已知 bug（<a href="https://github.com/npm/cli/issues/9386" target="_blank" rel="noopener noreferrer" class="">npm/cli#9386</a>）：当依赖数超过并发连接上限（默认 15）且网络出现超时时，请求会永久卡住。</p>
<p>改用 pnpm 后，得益于其全局 store + 硬链接机制，<strong>首次构建后几乎不需要网络请求</strong>，从根本上绕开了这个问题。同时构建速度也有显著提升：</p>
<div class="language-text codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-text codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">npm ci（首次）：    从 registry 下载所有包 → 全量写入 node_modules</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">pnpm install（首次）：从 registry 下载到全局 store → 硬链接到 node_modules</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">pnpm install（后续）：直接从 store 硬链接到 node_modules（几乎零网络请求）</span><br></span></code></pre></div></div>
<p>pnpm 全局 store 位于 <code>~/.local/share/pnpm/store</code>（容器内即 <code>/var/jenkins_home/.local/share/pnpm/store</code>），由于已挂载 <code>-v /opt/jenkins/data:/var/jenkins_home</code>，store 会随容器持久化，重建容器也不会丢失。</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="第三步配置-ssh-服务器">第三步：配置 SSH 服务器<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E7%AC%AC%E4%B8%89%E6%AD%A5%E9%85%8D%E7%BD%AE-ssh-%E6%9C%8D%E5%8A%A1%E5%99%A8" class="hash-link" aria-label="第三步：配置 SSH 服务器的直接链接" title="第三步：配置 SSH 服务器的直接链接" translate="no">​</a></h2>
<p>进入 <strong>Manage Jenkins → System</strong>，找到 <strong>SSH Servers</strong> 区域：</p>
<div class="language-text codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-text codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">Name:             cloud-server</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">Hostname:         你的云服务器公网 IP</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">Username:         deploy</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">Remote Directory: /</span><br></span></code></pre></div></div>
<blockquote>
<p><strong>⚠️ Remote Directory 是根目录，不是家目录！</strong></p>
<p>Publish Over SSH 插件中，<code>remoteDirectory</code>（Jenkinsfile 里的）<strong>始终拼接在</strong> SSH Server 配置的 Remote Directory <strong>之后</strong>，即使以 <code>/</code> 开头也是如此。也就是说，最终远程路径 = <code>SSH Server Remote Directory</code> + <code>remoteDirectory</code>。</p>
<p>举例：如果 SSH Server 的 Remote Directory 设为 <code>/home/deploy</code>，Jenkinsfile 里 <code>remoteDirectory: '/var/www/cludix-doc'</code>，实际文件会传到 <code>/home/deploy/var/www/cludix-doc</code>——这不是我们想要的。</p>
<p><strong>正确做法</strong>：将 SSH Server 的 Remote Directory 设为 <code>/</code>，让 Jenkinsfile 中的 <code>remoteDirectory</code> 使用绝对路径 <code>/var/www/cludix-doc</code>，这样拼接后才是正确的部署路径。</p>
</blockquote>
<p>点击 <strong>Advanced</strong> 配置认证（密钥认证推荐），点击 <strong>Test Configuration</strong> 确认连通。</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="第四步配置-git-凭据">第四步：配置 Git 凭据<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E7%AC%AC%E5%9B%9B%E6%AD%A5%E9%85%8D%E7%BD%AE-git-%E5%87%AD%E6%8D%AE" class="hash-link" aria-label="第四步：配置 Git 凭据的直接链接" title="第四步：配置 Git 凭据的直接链接" translate="no">​</a></h2>
<p>进入 <strong>Manage Jenkins → Credentials → System → Global credentials</strong>：</p>
<table><thead><tr><th>字段</th><th>值</th></tr></thead><tbody><tr><td>Kind</td><td>Username with password</td></tr><tr><td>ID</td><td><code>git-credentials</code></td></tr><tr><td>Username</td><td>你的 GitHub 用户名</td></tr><tr><td>Password</td><td>GitHub Personal Access Token</td></tr></tbody></table>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="第五步配置-github-webhook">第五步：配置 GitHub Webhook<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E7%AC%AC%E4%BA%94%E6%AD%A5%E9%85%8D%E7%BD%AE-github-webhook" class="hash-link" aria-label="第五步：配置 GitHub Webhook的直接链接" title="第五步：配置 GitHub Webhook的直接链接" translate="no">​</a></h2>
<p>因为 FRP 已将 Jenkins 映射到云服务器端口，GitHub 可以回调到：</p>
<div class="language-text codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-text codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">http://你的云服务器IP:31000/github-webhook/</span><br></span></code></pre></div></div>
<p><strong>GitHub 仓库 → Settings → Webhooks → Add webhook</strong>：</p>
<div class="language-text codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-text codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">Payload URL:  http://你的云服务器IP:31000/github-webhook/</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">Content type: application/json</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">Events:       Just the push event</span><br></span></code></pre></div></div>
<p>Jenkins Pipeline 任务中勾选 <strong>Build Triggers → GitHub hook trigger for GITScm polling</strong>。</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="第六步创建-pipeline-任务">第六步：创建 Pipeline 任务<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E7%AC%AC%E5%85%AD%E6%AD%A5%E5%88%9B%E5%BB%BA-pipeline-%E4%BB%BB%E5%8A%A1" class="hash-link" aria-label="第六步：创建 Pipeline 任务的直接链接" title="第六步：创建 Pipeline 任务的直接链接" translate="no">​</a></h2>
<ol>
<li class=""><strong>New Item</strong> → 输入名称 → 选择 <strong>Pipeline</strong> → OK</li>
<li class="">勾选 <strong>This project is parameterized</strong>（可选，方便切换分支）：</li>
</ol>
<div class="language-text codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-text codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">参数名: BRANCH</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">类型: String Parameter</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">默认值: main</span><br></span></code></pre></div></div>
<ol start="3">
<li class="">Pipeline 区域，Definition 选择 <strong>Pipeline script</strong>，粘贴以下内容。</li>
</ol>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="jenkinsfile-完整内容">Jenkinsfile 完整内容<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#jenkinsfile-%E5%AE%8C%E6%95%B4%E5%86%85%E5%AE%B9" class="hash-link" aria-label="Jenkinsfile 完整内容的直接链接" title="Jenkinsfile 完整内容的直接链接" translate="no">​</a></h2>
<div class="language-groovy codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-groovy codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">pipeline {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    agent any</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    // 使用 NodeJS Plugin 管理的 Node.js 环境</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    // 名称必须与 Global Tool Configuration 中配置的一致</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    tools {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        nodejs 'NodeJS-20'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    environment {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        // ===== Git 配置 =====</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        GIT_REPO    = 'https://github.com/yunqilee69/cludix-doc.git'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        GIT_CRED    = 'git-credentials'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        // ===== SSH 部署配置（云服务器） =====</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        SSH_SERVER  = 'cloud-server'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        REMOTE_DIR  = '/var/www/cludix-doc'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        // ===== 构建产物目录（Docusaurus 输出到 build/） =====</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        BUILD_DIR   = 'build'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    stages {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        // ==================== 阶段1：拉取代码 ====================</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        stage('Checkout') {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            steps {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                echo '📌 拉取代码...'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                git branch: "${params.BRANCH ?: 'main'}",</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                    credentialsId: "${GIT_CRED}",</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                    url: "${GIT_REPO}"</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        // ==================== 阶段2：安装依赖 ====================</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        stage('Install Dependencies') {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            steps {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                echo '📦 安装依赖（pnpm + 淘宝镜像）...'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                sh '''</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                    pnpm config set registry https://registry.npmmirror.com</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                    pnpm install --frozen-lockfile</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                '''</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        // ==================== 阶段3：构建 ====================</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        stage('Build') {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            steps {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                echo '🔨 构建 Docusaurus 项目...'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                sh 'pnpm run build'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            post {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                success {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                    echo '✅ 构建成功，build 目录已生成'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                    sh 'ls -lh build/ | head -10'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        // ==================== 阶段4：部署到云服务器 ====================</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        stage('Deploy to Cloud') {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            steps {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                echo '🚀 通过 SSH 部署到云服务器...'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                sshPublisher(</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                    continueOnError: false,</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                    failOnError: true,</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                    publishers: [</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                        sshPublisherDesc(</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                            configName: "${SSH_SERVER}",</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                            verbose: true,</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                            transfers: [</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                sshTransfer(</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                    // Docusaurus 构建产物在 build/ 目录</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                    sourceFiles: 'build/**/*',</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                    // 去掉 build 前缀，避免远程出现 build/build/ 嵌套</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                    removePrefix: 'build',</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                    remoteDirectory: "${REMOTE_DIR}",</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                    execCommand: """</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        echo '📁 文件传输完成'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        # 备份当前版本</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        BACKUP_DIR="/var/www/backups/\$(date +%Y%m%d_%H%M%S)"</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        sudo mkdir -p \${BACKUP_DIR}</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        sudo cp -r ${REMOTE_DIR}/* \${BACKUP_DIR}/ 2&gt;/dev/null || true</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        echo "📦 已备份至 \${BACKUP_DIR}"</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        # 修正文件权限</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        sudo chown -R www-data:www-data ${REMOTE_DIR}</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        sudo chmod -R 755 ${REMOTE_DIR}</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        # 检测 Nginx 配置并重载</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        echo '🔄 重载 Nginx...'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        sudo nginx -t &amp;&amp; sudo systemctl reload nginx</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                        echo '✅ 部署完成！'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                    """</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                                )</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                            ]</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                        )</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                    ]</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">                )</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    post {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        success {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            echo '🎉 流水线执行成功！站点已更新。'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        failure {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            echo '❌ 流水线执行失败，请检查日志'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        always {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">            echo '🧹 流水线结束'</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">}</span><br></span></code></pre></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="云服务器-nginx-配置">云服务器 Nginx 配置<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E4%BA%91%E6%9C%8D%E5%8A%A1%E5%99%A8-nginx-%E9%85%8D%E7%BD%AE" class="hash-link" aria-label="云服务器 Nginx 配置的直接链接" title="云服务器 Nginx 配置的直接链接" translate="no">​</a></h2>
<p>云服务器 <code>/etc/nginx/conf.d/cludix-doc.conf</code>：</p>
<div class="language-nginx codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-nginx codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">server {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    listen       80;</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    server_name  your-domain.com;</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    root /var/www/cludix-doc;</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    index index.html;</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    # SPA 路由支持（Docusaurus 需要）</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    location / {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        try_files $uri $uri/ /index.html;</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    # 静态资源缓存</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        expires 30d;</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        add_header Cache-Control "public, immutable";</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    # 禁止访问隐藏文件</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    location ~ /\. {</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">        deny all;</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    }</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">}</span><br></span></code></pre></div></div>
<p>云服务器 sudo 免密配置：</p>
<div class="language-bash codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-bash codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">sudo visudo</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># 添加</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">deploy ALL=(ALL) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx, /bin/mkdir, /bin/cp, /usr/bin/chown, /bin/chmod</span><br></span></code></pre></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="端到端验证">端到端验证<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E7%AB%AF%E5%88%B0%E7%AB%AF%E9%AA%8C%E8%AF%81" class="hash-link" aria-label="端到端验证的直接链接" title="端到端验证的直接链接" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-bash codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain"># 1. FRP 连通性</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">curl http://你的云服务器IP:31000    # 返回 Jenkins 页面</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># 2. SSH 连通性</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">ssh deploy@你的云服务器IP "echo ok"</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># 3. Webhook 连通性（GitHub 仓库 Settings → Webhooks）</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># 最近一次 Delivery 状态码 200</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># 4. 手动触发构建（Jenkins → Build Now）</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># 全阶段绿灯 ✅</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"># 5. 验证站点</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">curl -I http://your-domain.com   # 200 OK</span><br></span></code></pre></div></div>
<p>完整链路：</p>
<div class="language-text codeBlockContainer_FIHn theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_tAhz"><pre tabindex="0" class="prism-code language-text codeBlock_dG78 thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_IUK3"><span class="token-line" style="color:#393A34"><span class="token plain">git push → GitHub Webhook → 云服务器:31000 (FRP穿透) → 本地 Jenkins</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">→ Checkout → pnpm install → pnpm run build</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">→ SSH 上传 build/ 到云服务器 → nginx -t → systemctl reload nginx</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">→ 站点更新 ✅</span><br></span></code></pre></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="常见问题">常见问题<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98" class="hash-link" aria-label="常见问题的直接链接" title="常见问题的直接链接" translate="no">​</a></h2>
<table><thead><tr><th>问题</th><th>原因</th><th>解决</th></tr></thead><tbody><tr><td><code>Exit handler never called!</code></td><td>npm 的 <code>agent-base</code> bug，依赖超过 15 个并发连接且网络超时时触发</td><td>使用 pnpm 替代 npm ci（本文方案），或 <code>npm ci --maxsockets 1000</code></td></tr><tr><td><code>npm ci</code> 失败</td><td>无 <code>package-lock.json</code> 或上述 bug</td><td>改用 pnpm（本文方案），或 <code>npm install</code></td></tr><tr><td>SSH 传输超时</td><td>文件过大</td><td>先 <code>tar</code> 打包再传输</td></tr><tr><td><code>nginx -t</code> 失败</td><td>Nginx 配置语法错误</td><td>服务器手动 <code>nginx -t</code> 排查</td></tr><tr><td><code>sudo: no tty</code></td><td>sudo 需要 TTY</td><td>配置 <code>NOPASSWD</code>，参考 <a class="" href="https://yunqilee69.github.io/docs/tutorials/ci/jenkins/jenkins-ssh-config">SSH 配置文档</a></td></tr><tr><td>文件传输到错误路径</td><td>SSH Server 的 Remote Directory 是根前缀，<code>remoteDirectory</code> 是相对拼接</td><td>SSH Server 的 Remote Directory 设为 <code>/</code>，让 Jenkinsfile 用绝对路径</td></tr><tr><td>文件嵌套 build/build/</td><td><code>removePrefix</code> 未配置</td><td>确认 <code>removePrefix: 'build'</code></td></tr><tr><td>Webhook 无法触发</td><td>FRP 不通或端口未放行</td><td>检查 FRP 状态和云服务器安全组</td></tr><tr><td>FRP 相关问题</td><td>—</td><td>参考 <a class="" href="https://yunqilee69.github.io/docs/tutorials/network/frp-nat">FRP 内网穿透教程</a></td></tr></tbody></table>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="小结">小结<a href="https://yunqilee69.github.io/blog/jenkins-fullstack-deploy-guide#%E5%B0%8F%E7%BB%93" class="hash-link" aria-label="小结的直接链接" title="小结的直接链接" translate="no">​</a></h2>
<p>整条链路涉及四个核心环节：FRP 穿透解决入站、Jenkins 流水线解决构建、SSH 传输解决部署、Nginx reload 解决生效。每一环都有独立的原子文档可以深入，本文将它们串联成一个完整的专题。</p>]]></content>
        <category label="Jenkins" term="Jenkins"/>
        <category label="CI/CD" term="CI/CD"/>
        <category label="FRP" term="FRP"/>
        <category label="Nginx" term="Nginx"/>
        <category label="Docusaurus" term="Docusaurus"/>
        <category label="自动化部署" term="自动化部署"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[阅读指南：Blog 和文档应该怎么读]]></title>
        <id>https://yunqilee69.github.io/blog/automated-deployment-pipeline</id>
        <link href="https://yunqilee69.github.io/blog/automated-deployment-pipeline"/>
        <updated>2026-06-17T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[这个站点同时保留 Blog 和文档区：Blog 用来承载一个专题、一段排查过程或一个范围性问题；文档区则拆成原子化页面，说明某一个具体、清晰、可复用的事情。]]></summary>
        <content type="html"><![CDATA[<p>这个站点同时保留 Blog 和文档区：Blog 用来承载一个专题、一段排查过程或一个范围性问题；文档区则拆成原子化页面，说明某一个具体、清晰、可复用的事情。</p>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="blog-和文档的区别">Blog 和文档的区别<a href="https://yunqilee69.github.io/blog/automated-deployment-pipeline#blog-%E5%92%8C%E6%96%87%E6%A1%A3%E7%9A%84%E5%8C%BA%E5%88%AB" class="hash-link" aria-label="Blog 和文档的区别的直接链接" title="Blog 和文档的区别的直接链接" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="blog围绕专题解决一类问题">Blog：围绕专题解决一类问题<a href="https://yunqilee69.github.io/blog/automated-deployment-pipeline#blog%E5%9B%B4%E7%BB%95%E4%B8%93%E9%A2%98%E8%A7%A3%E5%86%B3%E4%B8%80%E7%B1%BB%E9%97%AE%E9%A2%98" class="hash-link" aria-label="Blog：围绕专题解决一类问题的直接链接" title="Blog：围绕专题解决一类问题的直接链接" translate="no">​</a></h3>
<p>Blog 更适合记录“范围性问题”。它不一定只回答一个命令怎么写，而是把一个主题里的背景、约束、尝试、取舍和结论串起来。</p>
<p>比如当前这类“自动化部署链路搭建”内容，就不仅是 Jenkins 的某个配置项，而是会同时涉及：</p>
<ol>
<li class="">代码仓库触发 Jenkins 任务。</li>
<li class="">Jenkins 拉取代码并执行构建脚本。</li>
<li class="">构建环境通过代理访问外部依赖源。</li>
<li class="">构建产物打包成 Docker 镜像。</li>
<li class="">镜像推送到 Docker 仓库。</li>
<li class="">目标服务器拉取镜像并重启服务。</li>
</ol>
<p>这些内容放在 Blog 里更自然，因为读者通常需要先理解“整条链路怎么协作”，再定位自己要落地或排查的环节。</p>
<p>Blog 还适合记录：</p>
<ul>
<li class="">一个专题的完整配置过程，例如 Jenkins 的一系列配置和使用。</li>
<li class="">一个 bug 的发现、定位、尝试和最终解决过程。</li>
<li class="">某个方案为什么这样设计，以及中间放弃过哪些做法。</li>
<li class="">多个工具组合使用时的上下文，例如 Jenkins、Docker、代理、仓库和服务器部署之间的关系。</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="文档回答一个原子问题">文档：回答一个原子问题<a href="https://yunqilee69.github.io/blog/automated-deployment-pipeline#%E6%96%87%E6%A1%A3%E5%9B%9E%E7%AD%94%E4%B8%80%E4%B8%AA%E5%8E%9F%E5%AD%90%E9%97%AE%E9%A2%98" class="hash-link" aria-label="文档：回答一个原子问题的直接链接" title="文档：回答一个原子问题的直接链接" translate="no">​</a></h3>
<p>文档区更适合放“原子化答案”。每一篇文档尽量只说明一个具体、清晰、边界明确的事情，读者可以直接搜索、打开、照做或引用。</p>
<p>例如：</p>
<ul>
<li class="">Docker 如何安装。</li>
<li class="">如何用 Docker 部署 MySQL。</li>
<li class="">某个 Java 报错的原因和处理方式。</li>
<li class="">某个 Nebula 模块的配置项、接口、DDL 或使用说明。</li>
</ul>
<p>如果一篇内容可以被命名成“如何做某一件具体的事”，并且不依赖很长的前因后果，它通常更适合放在文档区。</p>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="当前文档站是怎么设计的">当前文档站是怎么设计的<a href="https://yunqilee69.github.io/blog/automated-deployment-pipeline#%E5%BD%93%E5%89%8D%E6%96%87%E6%A1%A3%E7%AB%99%E6%98%AF%E6%80%8E%E4%B9%88%E8%AE%BE%E8%AE%A1%E7%9A%84" class="hash-link" aria-label="当前文档站是怎么设计的的直接链接" title="当前文档站是怎么设计的的直接链接" translate="no">​</a></h2>
<p>这个站点的阅读入口按内容类型拆开：</p>
<ul>
<li class=""><strong>专题</strong>：对应 Blog，用来阅读跨多个工具、配置和系统的大型问题处理过程。</li>
<li class=""><strong>教程</strong>：按 Linux、Windows、macOS、Docker、Kubernetes、CI/CD、Java、数据库、网络等场景组织，适合找“怎么做”。</li>
<li class=""><strong>踩坑</strong>：同样按技术场景分类，适合找“为什么出错、怎么修”。</li>
<li class=""><strong>Nebula</strong>：按模块组织框架能力，每个模块下再拆成概览、业务能力、配置、DDL、接口、设计实现和使用指南。</li>
<li class=""><strong>搜索</strong>：站点会索引文档、Blog 和页面，适合在不知道分类时直接检索关键字。</li>
</ul>
<p>文档区的侧边栏采用自动生成方式：新增内容时，优先把文档放进对应目录；目录页使用 <code>index.md</code>；分类元数据通过 <code>_category_.json</code> 维护名称、顺序和可点击入口。这样可以让目录结构、页面 URL 和侧边栏尽量保持稳定。</p>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="推荐阅读方式">推荐阅读方式<a href="https://yunqilee69.github.io/blog/automated-deployment-pipeline#%E6%8E%A8%E8%8D%90%E9%98%85%E8%AF%BB%E6%96%B9%E5%BC%8F" class="hash-link" aria-label="推荐阅读方式的直接链接" title="推荐阅读方式的直接链接" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="先判断自己在找什么">先判断自己在找什么<a href="https://yunqilee69.github.io/blog/automated-deployment-pipeline#%E5%85%88%E5%88%A4%E6%96%AD%E8%87%AA%E5%B7%B1%E5%9C%A8%E6%89%BE%E4%BB%80%E4%B9%88" class="hash-link" aria-label="先判断自己在找什么的直接链接" title="先判断自己在找什么的直接链接" translate="no">​</a></h3>
<p>如果你想了解一个专题的完整背景，先看 Blog。例如自动化部署相关内容，就应该先从专题文章理解 Jenkins、脚本、代理、Docker 仓库和服务器部署之间的关系。</p>
<p>如果你只想完成一个明确动作，直接去文档区。例如“Docker 安装”“Docker 部署 MySQL”“某个报错怎么修”这类问题，不需要先读一篇长文。</p>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="再选择入口">再选择入口<a href="https://yunqilee69.github.io/blog/automated-deployment-pipeline#%E5%86%8D%E9%80%89%E6%8B%A9%E5%85%A5%E5%8F%A3" class="hash-link" aria-label="再选择入口的直接链接" title="再选择入口的直接链接" translate="no">​</a></h3>
<ul>
<li class="">找操作步骤：优先看 <strong>教程</strong>。</li>
<li class="">找故障处理：优先看 <strong>踩坑</strong>。</li>
<li class="">找框架模块说明：优先看 <strong>Nebula</strong>。</li>
<li class="">找完整方案、排查过程、设计思路：优先看 <strong>专题</strong>。</li>
<li class="">不确定放在哪里：直接用搜索。</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_rLmt" id="最后回到原子文档落地">最后回到原子文档落地<a href="https://yunqilee69.github.io/blog/automated-deployment-pipeline#%E6%9C%80%E5%90%8E%E5%9B%9E%E5%88%B0%E5%8E%9F%E5%AD%90%E6%96%87%E6%A1%A3%E8%90%BD%E5%9C%B0" class="hash-link" aria-label="最后回到原子文档落地的直接链接" title="最后回到原子文档落地的直接链接" translate="no">​</a></h3>
<p>Blog 通常负责把问题讲完整，但真正落地时，仍然应该回到原子文档确认具体命令、配置项和注意事项。也就是说：</p>
<ol>
<li class="">用 Blog 建立上下文。</li>
<li class="">用文档执行具体步骤。</li>
<li class="">遇到报错时到踩坑区检索。</li>
<li class="">如果问题跨多个环节，再回到 Blog 看完整链路。</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_rLmt" id="后续内容应该怎么写">后续内容应该怎么写<a href="https://yunqilee69.github.io/blog/automated-deployment-pipeline#%E5%90%8E%E7%BB%AD%E5%86%85%E5%AE%B9%E5%BA%94%E8%AF%A5%E6%80%8E%E4%B9%88%E5%86%99" class="hash-link" aria-label="后续内容应该怎么写的直接链接" title="后续内容应该怎么写的直接链接" translate="no">​</a></h2>
<p>后续新增内容时，可以按下面的规则判断放在哪里：</p>
<ul>
<li class=""><strong>放 Blog</strong>：一个专题、一条链路、一次完整排查、一个 bug 的解决过程、一个方案的思考过程。</li>
<li class=""><strong>放文档</strong>：一个明确动作、一个清晰配置、一个具体命令、一个可独立复用的问题答案。</li>
</ul>
<p>例如 Jenkins 的一系列配置和使用，可以作为 Blog 记录完整部署链路；而“Docker 安装”“Docker 部署 MySQL”这类内容，则应该拆成独立文档，方便搜索和复用。</p>
<p>这样的分工可以让 Blog 保留真实的问题现场，也让文档区保持短、准、可检索。</p>]]></content>
        <category label="阅读指南" term="阅读指南"/>
        <category label="Blog" term="Blog"/>
        <category label="文档站" term="文档站"/>
        <category label="Jenkins" term="Jenkins"/>
    </entry>
</feed>