<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>博客随笔</title>
  
  <subtitle>昨日是历史，明日是未知</subtitle>
  <link href="/atom.xml" rel="self"/>
  
  <link href="https://blog.hpan.xin/"/>
  <updated>2026-10-10T13:00:00.000Z</updated>
  <id>https://blog.hpan.xin/</id>
  
  <author>
    <name>yvshengyouni</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>在 Windows 上通过 MSYS2 使用 rsync 同步</title>
    <link href="https://blog.hpan.xin/2026/10/10/%E9%80%9A%E8%BF%87MSYS2%E4%BD%BF%E7%94%A8rsync%E5%90%8C%E6%AD%A5-butterfly/"/>
    <id>https://blog.hpan.xin/2026/10/10/%E9%80%9A%E8%BF%87MSYS2%E4%BD%BF%E7%94%A8rsync%E5%90%8C%E6%AD%A5-butterfly/</id>
    <published>2026-10-10T13:00:00.000Z</published>
    <updated>2026-10-10T13:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<p>Windows 上没有官方 rsync。本文用 <strong>MSYS2</strong> 装一个原生 <code>rsync.exe</code>，全程放在非系统盘、不碰 C 盘，再通过 SSH 密钥同步到远端服务器。</p><p>文中用 <code>D:\tools\msys64</code>（MSYS2 里写作 <code>/d/tools/msys64</code>）代表 MSYS2 安装目录，用 <code>&lt;本地目录&gt;</code> / <code>&lt;远端目录&gt;</code> 代表同步的源和目标，用 <code>203.0.113.10</code>（RFC 5737 文档专用地址）代表服务器地址。照抄时替换成自己的。</p><span id="more"></span><h2 id="方案选择"><a href="#方案选择" class="headerlink" title="方案选择"></a>方案选择</h2><table><thead><tr><th>方案</th><th>许可证</th><th>真 rsync</th><th>形态</th><th>可装到别盘</th><th>结论</th></tr></thead><tbody><tr><td><strong>MSYS2 + rsync</strong></td><td>GPLv3</td><td>✅</td><td>原生 exe</td><td>✅</td><td><strong>本文方案</strong></td></tr><tr><td>WSL2 + rsync</td><td>GPLv3</td><td>✅ 本体</td><td>Linux</td><td>需迁 VHDX</td><td>语义最纯，但 <code>/mnt/c</code> 慢</td></tr><tr><td>rclone</td><td>MIT</td><td>❌</td><td>单文件</td><td>✅</td><td>零依赖，适合多云</td></tr><tr><td>cwRsync</td><td>商业打包</td><td>✅</td><td>exe</td><td>✅</td><td>发行条款非纯开源</td></tr><tr><td>Acrosync / Syncovery</td><td>闭源</td><td>✅</td><td>GUI</td><td>—</td><td>需要 GUI 时才考虑</td></tr></tbody></table><p>选 MSYS2 的理由：<strong>开源、原生 exe、无虚拟机、I/O 接近原生</strong>（不像 WSL 的 <code>/mnt/c</code> 走 9p 协议），而且能装到任意盘。</p><div class="note default flat"><p><strong>MSYS2 是什么</strong></p><p>一个跑在 Windows 用户态的 POSIX 翻译层（<code>msys-2.0.dll</code>），把 <code>open()</code> / <code>fork()</code> / <code>chmod()</code> 翻译成 Win32 调用。它不是虚拟机、不是内核。</p><p>你装的 <code>rsync.exe</code> <strong>就是 GNU rsync 源码编译出来的</strong>，只是链接了这层运行时。所有”怪”（<code>/c/...</code> 路径、假的权限位）都来自这层翻译。</p></div><h2 id="1-把-MSYS2-装到非系统盘"><a href="#1-把-MSYS2-装到非系统盘" class="headerlink" title="1. 把 MSYS2 装到非系统盘"></a>1. 把 MSYS2 装到非系统盘</h2><h3 id="1-1-选路径"><a href="#1-1-选路径" class="headerlink" title="1.1 选路径"></a>1.1 选路径</h3><p>官方要求：<strong>短、纯 ASCII、无空格、无中文、NTFS 本地盘，不能是 subst 或网络驱动器</strong>。</p><pre class="line-numbers language-text" data-language="text"><code class="language-text">D:\tools\msys64          ✅D:\项目\msys64            ❌ 中文D:\Program Files\msys64  ❌ 空格<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><div class="note warning flat"><p><strong>中文路径是硬伤</strong></p><p>MSYS2 里大量工具（autotools、cmake、部分 pkg-config 脚本）处理不了非 ASCII 路径，会报出截断的路径错误。这条规则对后面的同步源目录同样适用。</p></div><h3 id="1-2-解压（不要用-GUI-安装器）"><a href="#1-2-解压（不要用-GUI-安装器）" class="headerlink" title="1.2 解压（不要用 GUI 安装器）"></a>1.2 解压（不要用 GUI 安装器）</h3><p>从 <a href="https://github.com/msys2/msys2-installer/releases">msys2-installer releases</a> 下载 <code>msys2-base-x86_64-latest.sfx.exe</code>：</p><pre class="line-numbers language-powershell" data-language="powershell"><code class="language-powershell">cd D:\<span class="token punctuation">.</span>\msys2-base-x86_64-latest<span class="token punctuation">.</span>sfx<span class="token punctuation">.</span>exe <span class="token operator">-</span>y <span class="token operator">-</span>oD:\tools\<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p><strong>为什么不用 GUI 安装器 / winget / scoop / choco</strong>：</p><table><thead><tr><th>分发形式</th><th>是否写 C 盘</th></tr></thead><tbody><tr><td><code>msys2-x86_64-*.exe</code>（GUI 安装器）</td><td>注册表卸载项、开始菜单快捷方式</td></tr><tr><td><strong><code>*.sfx.exe</code> 自解压归档</strong></td><td><strong>无</strong>（绿色版，可随时整个删掉）</td></tr><tr><td><code>winget install MSYS2.MSYS2</code></td><td>强制装 <code>C:\msys64</code>，改不了</td></tr></tbody></table><div class="note danger flat"><p><strong><code>-o</code> 是父目录，不是最终目录</strong></p><p>归档里自带一个 <code>msys64</code> 顶层文件夹，所以：</p><ul><li><code>-oD:\tools\</code> → ✅ 得到 <code>D:\tools\msys64</code></li><li><code>-oD:\tools\msys64</code> → ❌ 得到 <code>D:\tools\msys64\msys64</code></li></ul><p>解压完先验证：<code>Test-Path D:\tools\msys64\usr\bin\bash.exe</code></p></div><h3 id="1-3-首次初始化"><a href="#1-3-首次初始化" class="headerlink" title="1.3 首次初始化"></a>1.3 首次初始化</h3><pre class="line-numbers language-powershell" data-language="powershell"><code class="language-powershell">D:\tools\msys64\msys2_shell<span class="token punctuation">.</span>cmd <span class="token operator">-</span>defterm <span class="token operator">-</span>here <span class="token operator">-</span>no-<span class="token function">start</span> <span class="token operator">-</span>msys<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>首次登录会看到类似输出，这是<strong>一次性</strong>的：</p><pre class="line-numbers language-text" data-language="text"><code class="language-text">'./.bashrc' -&gt; '/home/yourname/.bashrc''./.bash_profile' -&gt; '/home/yourname/.bash_profile''./.profile' -&gt; '/home/yourname/.profile''C:\WINDOWS\system32\drivers\etc\hosts' -&gt; '/etc/hosts''C:\WINDOWS\system32\drivers\etc\protocol' -&gt; '/etc/protocols''C:\WINDOWS\system32\drivers\etc\services' -&gt; '/etc/services''C:\WINDOWS\system32\drivers\etc\networks' -&gt; '/etc/networks'They will never be overwritten nor automatically updated.<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>它来自 <code>base-files</code> 包的 postinstall 脚本，干两件事：</p><ol><li>把 <code>/etc/skel</code> 的骨架配置复制进你的 <code>$HOME</code></li><li>把 Windows 的 <code>hosts</code> / <code>protocol</code> / <code>services</code> / <code>networks</code> 拷进 MSYS2 的 <code>/etc/</code></li></ol><p>那句 <code>never overwritten nor automatically updated</code> 是重点：<strong>脚本只在目标文件不存在时才拷贝</strong>，所以 <code>/etc/hosts</code> 是<strong>一次性快照</strong>——以后你在 Windows 改了 hosts，MSYS2 里不会同步。需要时手动覆盖：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">cp</span> /c/WINDOWS/system32/drivers/etc/hosts /etc/hosts<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><h3 id="1-4-把-HOME-钉在非系统盘"><a href="#1-4-把-HOME-钉在非系统盘" class="headerlink" title="1.4 把 HOME 钉在非系统盘"></a>1.4 把 HOME 钉在非系统盘</h3><p>安装目录在 D 盘 ≠ 运行时数据在 D 盘。<code>$HOME</code> 决定 <code>~/.ssh</code>、<code>~/.config</code>、<code>~/.cache</code> 落哪儿。</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token builtin class-name">echo</span> <span class="token string">"<span class="token environment constant">$HOME</span>"</span>cygpath <span class="token parameter variable">-w</span> <span class="token string">"<span class="token environment constant">$HOME</span>"</span>       <span class="token comment"># 看物理路径落在哪个盘</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><table><thead><tr><th>结果</th><th>处置</th></tr></thead><tbody><tr><td><code>/home/yourname</code></td><td>✅ 已在 MSYS2 安装目录下，无需处理</td></tr><tr><td><code>/c/Users/...</code> 或 <code>C:\Users\...</code></td><td>❌ 要改</td></tr></tbody></table><p>改法（只影响 MSYS2，不污染全局环境）：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token builtin class-name">echo</span> <span class="token string">'db_home: /home/%U'</span> <span class="token operator">&gt;&gt;</span> /etc/nsswitch.conf<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><div class="note warning flat"><p><strong>不要设 Windows 全局 <code>HOME</code> 环境变量</strong></p><p>MSYS2 会优先认它。一旦设成 <code>C:\Users\...</code>，整个 <code>~</code> 体系（含 <code>.ssh</code>）就被拽回 C 盘了。</p></div><h3 id="1-5-换国内镜像提速"><a href="#1-5-换国内镜像提速" class="headerlink" title="1.5 换国内镜像提速"></a>1.5 换国内镜像提速</h3><p>MSYS2 现在只有两个镜像列表文件，<strong>注意两者用的变量不同</strong>：</p><table><thead><tr><th>文件</th><th>用途</th><th>占位符</th></tr></thead><tbody><tr><td><code>/etc/pacman.d/mirrorlist.msys</code></td><td>MSYS 环境的包</td><td><code>$arch</code></td></tr><tr><td><code>/etc/pacman.d/mirrorlist.mingw</code></td><td>mingw 工具链的包</td><td><code>$repo</code></td></tr></tbody></table><p>官方列表里其实<strong>已经有清华、中科大、南大、北外</strong>，但排在第 8～11 位，轮不到。<strong>不用删任何东西，插到第一行即可</strong>（pacman 从上往下试，第一个成功的就用，缺包会自动 fallback）：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">cp</span> /etc/pacman.d/mirrorlist.msys<span class="token punctuation">{</span>,.bak<span class="token punctuation">}</span><span class="token function">cp</span> /etc/pacman.d/mirrorlist.mingw<span class="token punctuation">{</span>,.bak<span class="token punctuation">}</span><span class="token function">sed</span> <span class="token parameter variable">-i</span> <span class="token string">'1i Server = https://mirrors.tuna.tsinghua.edu.cn/msys2/msys/$arch/'</span> /etc/pacman.d/mirrorlist.msys<span class="token function">sed</span> <span class="token parameter variable">-i</span> <span class="token string">'1i Server = https://mirrors.tuna.tsinghua.edu.cn/msys2/mingw/$repo/'</span> /etc/pacman.d/mirrorlist.mingwpacman <span class="token parameter variable">-Syu</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p><strong>其他国内镜像</strong>（完整列表见 <a href="https://www.msys2.org/dev/mirrors/">MSYS2 Mirrors</a>）：</p><table><thead><tr><th>镜像</th><th>备注</th></tr></thead><tbody><tr><td><code>https://mirrors.tuna.tsinghua.edu.cn/msys2/</code></td><td>Tier 1，每天至少同步一次，最稳</td></tr><tr><td><code>https://mirrors.ustc.edu.cn/msys2/</code></td><td>Tier 1</td></tr><tr><td><code>https://mirror.nju.edu.cn/msys2/</code></td><td>Tier 1</td></tr><tr><td><code>https://mirrors.bfsu.edu.cn/msys2/</code></td><td>Tier 1</td></tr><tr><td><code>https://mirrors.aliyun.com/msys2/</code></td><td>Tier 2，同步频率低，<strong>更容易 404</strong></td></tr></tbody></table><div class="note info flat"><p><strong>下载时报 404 是怎么回事</strong></p><pre class="line-numbers language-text" data-language="text"><code class="language-text">error: failed retrieving file 'xxx.pkg.tar.zst' from mirror.msys2.org : 404<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p><code>mirror.msys2.org</code> 是<strong>地理重定向服务</strong>，把你转到就近的 tier-1 镜像。如果那个镜像上某个包还没同步完，就会 404。</p><p><strong>pacman 会自动降级去试列表里的下一个镜像</strong>，只要后面出现 <code>installing xxx [###] 100%</code> 就是成功了。这不是故障，是镜像同步延迟。</p></div><h3 id="1-6-装-rsync-和-openssh"><a href="#1-6-装-rsync-和-openssh" class="headerlink" title="1.6 装 rsync 和 openssh"></a>1.6 装 rsync 和 openssh</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash">pacman <span class="token parameter variable">-S</span> <span class="token function">rsync</span> openssh<span class="token function">rsync</span> <span class="token parameter variable">--version</span><span class="token function">ssh</span> <span class="token parameter variable">-V</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span></span></code></pre><h3 id="1-7-「不占-C-盘」核对表"><a href="#1-7-「不占-C-盘」核对表" class="headerlink" title="1.7 「不占 C 盘」核对表"></a>1.7 「不占 C 盘」核对表</h3><table><thead><tr><th>位置</th><th>默认落点</th><th>状态</th></tr></thead><tbody><tr><td>安装目录</td><td><code>D:\tools\msys64</code></td><td>✅</td></tr><tr><td><code>$HOME</code>（<code>.ssh</code> / <code>.config</code> / <code>.cache</code>）</td><td>见 1.4</td><td>需确认</td></tr><tr><td>pacman 缓存 <code>/var/cache/pacman/pkg</code></td><td>安装目录内</td><td>✅ 定期 <code>pacman -Scc</code> 清</td></tr><tr><td>pacman 日志/数据库 <code>/var/log</code>、<code>/var/lib</code></td><td>安装目录内</td><td>✅</td></tr><tr><td>MSYS 工具的临时文件 <code>/tmp</code></td><td>MSYS2 安装目录下</td><td>✅</td></tr><tr><td><strong>原生 Windows 子进程</strong>的 <code>%TEMP%</code></td><td><code>C:\Users\...\AppData\Local\Temp</code></td><td>⚠️ 只在调用原生 exe 时触发</td></tr><tr><td>快捷方式 / 卸载项</td><td>仅 GUI 安装器会产生</td><td>✅ 用归档方式则无</td></tr></tbody></table><p>核对命令：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token keyword">case</span> <span class="token string">"<span class="token environment constant">$HOME</span>"</span> <span class="token keyword">in</span> /c/*<span class="token operator">|</span><span class="token punctuation">[</span>Cc<span class="token punctuation">]</span>:*<span class="token punctuation">)</span> <span class="token builtin class-name">echo</span> <span class="token string">"❌ HOME 在 C 盘"</span><span class="token punctuation">;</span><span class="token punctuation">;</span> *<span class="token punctuation">)</span> <span class="token builtin class-name">echo</span> <span class="token string">"✅ HOME: <span class="token environment constant">$HOME</span>"</span><span class="token punctuation">;</span><span class="token punctuation">;</span> <span class="token keyword">esac</span><span class="token function">mount</span> <span class="token operator">|</span> <span class="token function">grep</span> <span class="token parameter variable">-E</span> <span class="token string">' / | /home| /tmp| /var'</span><span class="token function">du</span> <span class="token parameter variable">-sh</span> /var/cache/pacman/pkg<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><pre class="line-numbers language-powershell" data-language="powershell"><code class="language-powershell"><span class="token function">Get-ChildItem</span> C:\ <span class="token operator">-</span>Directory <span class="token operator">-</span><span class="token keyword">Filter</span> <span class="token operator">*</span>msys* <span class="token operator">-</span>ErrorAction SilentlyContinue<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><h3 id="1-8-三个「不要做」"><a href="#1-8-三个「不要做」" class="headerlink" title="1.8 三个「不要做」"></a>1.8 三个「不要做」</h3><ol><li><strong>不要把 <code>D:\tools\msys64\usr\bin</code> 加进系统 <code>PATH</code>。</strong> 里面有 <code>bash.exe</code> / <code>find.exe</code> / <code>sort.exe</code> / <code>link.exe</code>，会和 Windows 自带命令、Git、Cygwin 抢 <code>msys-2.0.dll</code>。用 <code>msys2_shell.cmd</code> 进环境，或需要时写绝对路径。</li><li><strong>不要用 winget / scoop / choco 装 MSYS2 的 rsync。</strong> 它们会落到 <code>C:\Users\...\scoop</code> 或 <code>C:\ProgramData\chocolatey</code>，正好和目的相反。</li><li><strong>不要把 MSYS2 目录放进 OneDrive / 坚果云同步，或放在 exFAT 盘上。</strong> MSYS2 大量使用符号链接和 POSIX 权限，同步盘会打架、exFAT 不支持。</li></ol><h2 id="2-配置密钥登录"><a href="#2-配置密钥登录" class="headerlink" title="2. 配置密钥登录"></a>2. 配置密钥登录</h2><h3 id="2-1-私钥就位"><a href="#2-1-私钥就位" class="headerlink" title="2.1 私钥就位"></a>2.1 私钥就位</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">mkdir</span> <span class="token parameter variable">-p</span> ~/.ssh <span class="token operator">&amp;&amp;</span> <span class="token function">chmod</span> <span class="token number">700</span> ~/.ssh<span class="token comment"># 从 Windows 侧拷进来（按实际路径改）</span><span class="token function">cp</span> /d/keys/key.pem ~/.ssh/key.pem<span class="token comment"># 必须 chmod：cp 过来默认 644，会被 ssh 直接拒绝</span><span class="token function">chmod</span> <span class="token number">600</span> ~/.ssh/key.pem<span class="token function">ls</span> <span class="token parameter variable">-l</span> ~/.ssh/key.pem        <span class="token comment"># 应为 -rw-------</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><div class="note success flat"><p><strong>MSYS2 的 ssh 认 POSIX 权限位，不是 Windows ACL</strong></p><p>所以 <code>chmod 600</code> 就够了，<strong>不需要</strong> <code>icacls</code>。这也是建议用 MSYS2 自带 <code>ssh</code> 而不是 <code>C:\Windows\System32\OpenSSH\ssh.exe</code> 的原因——两套权限模型混用最容易在私钥上翻车。</p></div><h3 id="2-2-ssh-config"><a href="#2-2-ssh-config" class="headerlink" title="2.2 ~/.ssh/config"></a>2.2 <code>~/.ssh/config</code></h3><pre class="line-numbers language-ini" data-language="ini"><code class="language-ini">Host aliyun    HostName 203.0.113.10    User root    Port 2222    IdentityFile ~/.ssh/key.pem    IdentitiesOnly yes    ServerAliveInterval 30    ServerAliveCountMax 6<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>写好之后，<code>ssh</code> / <code>scp</code> / <code>rsync</code> / <code>git</code> 全部复用，命令里再也不用写 <code>-i</code> 和 <code>-p</code>。</p><h3 id="2-3-三条验证"><a href="#2-3-三条验证" class="headerlink" title="2.3 三条验证"></a>2.3 三条验证</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token comment"># 密钥能登 + 主机名对 + 对端装了 rsync</span><span class="token function">ssh</span> aliyun <span class="token string">'echo SSH-OK; hostname; rsync --version | head -1'</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>第三条很关键：对端没装 rsync 会报 <code>rsync: command not found</code>。</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">sudo</span> <span class="token function">apt</span> <span class="token function">install</span> <span class="token parameter variable">-y</span> <span class="token function">rsync</span>     <span class="token comment"># Debian / Ubuntu</span><span class="token function">sudo</span> yum <span class="token function">install</span> <span class="token parameter variable">-y</span> <span class="token function">rsync</span>     <span class="token comment"># RHEL / CentOS / Alibaba Cloud Linux</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><h2 id="3-rsync-用法要点"><a href="#3-rsync-用法要点" class="headerlink" title="3. rsync 用法要点"></a>3. rsync 用法要点</h2><h3 id="3-1-冒号决定方向"><a href="#3-1-冒号决定方向" class="headerlink" title="3.1 冒号决定方向"></a>3.1 冒号决定方向</h3><table><thead><tr><th>方向</th><th>写法</th></tr></thead><tbody><tr><td><strong>上传</strong></td><td><code>rsync [选项] "&lt;本地目录&gt;/"  aliyun:"&lt;远端目录&gt;/"</code></td></tr><tr><td><strong>下载</strong></td><td><code>rsync [选项] aliyun:"&lt;远端目录&gt;/"  "&lt;本地目录&gt;/"</code></td></tr></tbody></table><p>忘了冒号，就变成两个本地目录互相同步了。</p><h3 id="3-2-结尾斜杠（第一大坑）"><a href="#3-2-结尾斜杠（第一大坑）" class="headerlink" title="3.2 结尾斜杠（第一大坑）"></a>3.2 结尾斜杠（第一大坑）</h3><p>源是目录时：</p><table><thead><tr><th>写法</th><th>结果</th></tr></thead><tbody><tr><td><code>"&lt;本地目录&gt;/"</code></td><td>把目录<strong>里面的内容</strong>放到目标 → <code>&lt;远端目录&gt;/index.html</code> ✅</td></tr><tr><td><code>"&lt;本地目录&gt;"</code></td><td>把<strong>整个目录</strong>放进目标 → <code>&lt;远端目录&gt;/&lt;目录名&gt;/index.html</code> ❌</td></tr></tbody></table><p><strong>记法：源路径结尾加 <code>/</code> = 「要里面的东西」。</strong></p><h3 id="3-3-为什么是-avz-no-owner-no-group-而不是纯-a"><a href="#3-3-为什么是-avz-no-owner-no-group-而不是纯-a" class="headerlink" title="3.3 为什么是 -avz --no-owner --no-group 而不是纯 -a"></a>3.3 为什么是 <code>-avz --no-owner --no-group</code> 而不是纯 <code>-a</code></h3><p><code>-a</code> 展开是 <code>-rlptgoD</code>。其中 <code>-o</code>（保留属主）和 <code>-g</code>（保留属组）<strong>在 MSYS2 → Linux 场景下必须去掉</strong>：</p><ul><li>MSYS2 报告的文件属主是<strong>数字 UID</strong>（不是用户名）、属组是 <code>None</code></li><li>rsync 会老老实实在服务器上执行 <code>chown 1001:None</code>，然后刷一屏 <code>chown ... failed: Invalid argument</code></li><li>非致命，但很吵，还可能把文件属主改坏</li></ul><p>去掉后 <code>-a --no-owner --no-group</code> ≡ <code>-rlptD</code>，<strong>权限位和时间戳照样保留</strong>。</p><div class="note success flat"><p><strong>权限位为什么天然是对的</strong></p><p>MSYS2 的 <code>/</code> 挂载带 <code>noacl</code> 选项，会把文件统一报告成 <strong>644</strong>、目录 <strong>755</strong>、<code>.exe/.dll</code> 类报告成 755。</p><p>传过去之后服务器上就是标准的 Web 目录权限，不需要额外 <code>--chmod</code>。</p></div><p><strong>反方向（服务器 → Windows）</strong> 则相反，要加 <code>--no-perms</code>：NTFS 存不了 POSIX 权限位，不加的话 MSYS2 会去动只读属性并刷警告。</p><h3 id="3-4-常用参数"><a href="#3-4-常用参数" class="headerlink" title="3.4 常用参数"></a>3.4 常用参数</h3><table><thead><tr><th>参数</th><th>作用</th></tr></thead><tbody><tr><td><code>-a</code></td><td>归档模式 = <code>-rlptgoD</code>，基本必带</td></tr><tr><td><code>-v</code> / <code>-z</code></td><td>详细输出 / 传输压缩（文本划算，已压缩文件无益）</td></tr><tr><td><code>-P</code></td><td>= <code>--partial --progress</code>，断点续传 + 逐文件进度</td></tr><tr><td><code>--info=progress2</code></td><td>只显示一条总进度条（文件多时比 <code>-P</code> 清爽）</td></tr><tr><td><code>-n</code> / <code>--dry-run</code></td><td>干跑，只打印不执行</td></tr><tr><td><code>--delete</code></td><td>目标端删除源端已不存在的文件（镜像同步，<strong>危险</strong>）</td></tr><tr><td><code>-i</code> / <code>--itemize-changes</code></td><td>逐文件打印变更类型</td></tr><tr><td><code>--stats</code></td><td>传输结束打印统计汇总</td></tr><tr><td><code>--exclude=</code> / <code>--exclude-from=</code></td><td>排除</td></tr><tr><td><code>--bwlimit=2M</code></td><td>限速</td></tr><tr><td><code>--rsync-path="sudo rsync"</code></td><td>对端用 sudo 跑 rsync</td></tr><tr><td><code>--backup --backup-dir=DIR</code></td><td>被删/被覆盖的文件挪进 DIR 而不是消失</td></tr></tbody></table><h3 id="3-5-排除规则"><a href="#3-5-排除规则" class="headerlink" title="3.5 排除规则"></a>3.5 排除规则</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token parameter variable">--exclude</span><span class="token operator">=</span><span class="token string">'.git'</span> <span class="token punctuation">\</span><span class="token parameter variable">--exclude</span><span class="token operator">=</span><span class="token string">'node_modules'</span> <span class="token punctuation">\</span><span class="token parameter variable">--exclude</span><span class="token operator">=</span><span class="token string">'*.log'</span> <span class="token punctuation">\</span><span class="token parameter variable">--exclude</span><span class="token operator">=</span><span class="token string">'.env'</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span></span></code></pre><p>多了就写文件，一行一条：</p><pre class="line-numbers language-text" data-language="text"><code class="language-text"># &lt;本地目录&gt;/.rsyncignore.git/node_modules/*.log.env.cache/<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash">--exclude-from<span class="token operator">=</span><span class="token string">"&lt;本地目录&gt;/.rsyncignore"</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><div class="note warning flat"><p><strong>顺序有意义</strong></p><p><code>--exclude</code> 从上往下匹配，<strong>先命中先生效</strong>。想只排除根目录的 <code>.git</code> 就写 <code>/.git</code>，写 <code>.git</code> 会连子目录里的一起排掉。</p></div><h3 id="3-6-四种典型配方"><a href="#3-6-四种典型配方" class="headerlink" title="3.6 四种典型配方"></a>3.6 四种典型配方</h3><table><thead><tr><th>场景</th><th>命令</th></tr></thead><tbody><tr><td><strong>镜像同步</strong>（远端与本地完全一致）</td><td><code>rsync -avz --no-owner --no-group --delete "&lt;本地目录&gt;/" aliyun:"&lt;远端目录&gt;/"</code></td></tr><tr><td><strong>首次全量</strong>（大文件可断点续传）</td><td><code>rsync -avzP --no-owner --no-group "&lt;本地目录&gt;/" aliyun:"&lt;远端目录&gt;/"</code></td></tr><tr><td><strong>拉回本地备份</strong></td><td><code>rsync -avz --no-perms --no-owner --no-group aliyun:"&lt;远端目录&gt;/" "&lt;本地目录&gt;/"</code></td></tr><tr><td><strong>只增量、不删除</strong>（去掉 <code>--delete</code>）</td><td><code>rsync -avz --no-owner --no-group "&lt;本地目录&gt;/" aliyun:"&lt;远端目录&gt;/"</code></td></tr></tbody></table><h3 id="3-7-三处可选加固"><a href="#3-7-三处可选加固" class="headerlink" title="3.7 三处可选加固"></a>3.7 三处可选加固</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token parameter variable">--info</span><span class="token operator">=</span>progress2              <span class="token comment"># 替代 -P 的逐文件进度条</span><span class="token parameter variable">--chmod</span><span class="token operator">=</span>D755,F644             <span class="token comment"># 把权限钉死，免疫本地挂载选项变化</span>--compress-choice<span class="token operator">=</span>zstd        <span class="token comment"># rsync 3.2+，比默认 zlib 快很多</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><div class="note warning flat"><p><strong><code>--compress-choice=zstd</code> 需要两端都支持</strong></p><p>先查：<code>ssh aliyun 'rsync --version | head -1'</code>。对端低于 3.2 会直接报 <code>unknown compressor</code>，那就保持默认。</p></div><h2 id="4-执行同步"><a href="#4-执行同步" class="headerlink" title="4. 执行同步"></a>4. 执行同步</h2><h3 id="4-1-整体流程"><a href="#4-1-整体流程" class="headerlink" title="4.1 整体流程"></a>4.1 整体流程</h3><div class="mermaid-wrap"><pre class="mermaid-src" data-config="{}" hidden="">    flowchart LR    A["本地构建产物"] --&gt; B["MSYS2 rsync.exe"]    B --&gt;|"SSH 密钥"| C["远端服务器"]    C --&gt; D["目标目录"]  </pre></div><h3 id="4-2-最小流程"><a href="#4-2-最小流程" class="headerlink" title="4.2 最小流程"></a>4.2 最小流程</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token comment"># 第一步：在本地生成要同步的产物（按你的项目而定）</span><span class="token operator">&lt;</span>构建命令<span class="token operator">&gt;</span><span class="token comment"># 第二步：镜像同步</span><span class="token function">rsync</span> <span class="token parameter variable">-avz</span> <span class="token parameter variable">--partial</span> <span class="token parameter variable">--info</span><span class="token operator">=</span>progress2 --no-owner --no-group <span class="token parameter variable">--delete</span> <span class="token punctuation">\</span>  <span class="token string">"&lt;本地目录&gt;/"</span> aliyun:<span class="token string">"&lt;远端目录&gt;/"</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>发布类场景建议<strong>先干跑</strong>：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token comment"># 插入 --dry-run，确认增删清单无误后再去掉</span><span class="token function">rsync</span> <span class="token parameter variable">-avz</span> --no-owner --no-group <span class="token parameter variable">--delete</span> --dry-run <span class="token punctuation">\</span>  <span class="token string">"&lt;本地目录&gt;/"</span> aliyun:<span class="token string">"&lt;远端目录&gt;/"</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><h3 id="4-3-一键脚本"><a href="#4-3-一键脚本" class="headerlink" title="4.3 一键脚本"></a>4.3 一键脚本</h3><p>把脚本<strong>放进项目目录</strong>（例如 <code>&lt;本地目录&gt;\deploy.cmd</code>），用 <code>%~dp0</code> 取自身路径，<strong>文件里一个中文都不需要</strong>：</p><pre class="line-numbers language-bat" data-language="bat"><code class="language-bat">@echo offsetlocalcd /d "%~dp0"set "NOPAUSE="set "RC=0"if /i "%~1"=="/nopause" set "NOPAUSE=1"echo [1/2] Building...call &lt;构建命令&gt;if errorlevel 1 (    set "RC=1"    echo.    echo *** Build failed. Aborted. ***    goto :done)echo [2/2] Syncing...set "CHERE_INVOKING=1""D:\tools\msys64\usr\bin\bash.exe" -lc "rsync -avz --partial --info=progress2 --no-owner --no-group --delete ./&lt;产物目录&gt;/ aliyun:&lt;远端目录&gt;/"if errorlevel 1 (    set "RC=1"    echo.    echo *** Sync failed. ***    goto :done)echo.echo Done.:doneif not defined NOPAUSE (    echo.    pause)endlocal &amp; exit /b %RC%<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p><strong>为什么要这么写</strong>：</p><table><thead><tr><th>要点</th><th>原因</th></tr></thead><tbody><tr><td>用 <code>goto :done</code> 而不是 <code>exit /b 1</code></td><td><code>exit /b</code> 会立刻结束批处理，<strong>错误路径上的 <code>pause</code> 永远执行不到</strong></td></tr><tr><td><code>set "RC=1"</code> 记住返回值</td><td><code>goto</code> 之后 <code>errorlevel</code> 会变，最后用 <code>exit /b %RC%</code> 把失败状态还给调用方（任务计划能感知失败）</td></tr><tr><td><code>if not defined NOPAUSE</code></td><td>交互式双击时暂停；挂任务计划时加 <code>/nopause</code>，否则会<strong>永久挂住等按键</strong></td></tr><tr><td><code>endlocal &amp; exit /b %RC%</code></td><td>写一行是必须的：<code>endlocal</code> 会丢弃 <code>setlocal</code> 内的变量，同一行展开 <code>%RC%</code> 才能取到值</td></tr><tr><td><code>set "CHERE_INVOKING=1"</code></td><td><strong>不加这行，<code>cd /d "%~dp0"</code> 等于白做</strong> —— <code>bash -l</code> 会读 <code>/etc/profile</code>，那里会 <code>cd "$HOME"</code>，把工作目录冲掉（详见 4.4）</td></tr></tbody></table><div class="note danger flat"><p><strong>批处理文件千万别存成 UTF-8</strong></p><p><code>cmd.exe</code> 按<strong>系统 ANSI 代码页</strong>（中文 Windows = GBK/936）解析 <code>.cmd</code> 文件，<strong>不是 UTF-8</strong>。存成 UTF-8 会引发一条连锁故障：</p><pre class="line-numbers language-text" data-language="text"><code class="language-text">[1/2] 鐢熸垚闈欐€佹枃浠?..      ← 中文 echo 变乱码系统找不到指定的路径。          ← cd /d "含中文的路径" 解成乱码，目录进不去Need to install xxx@1.2.3      ← 停在错误目录，找不到本地依赖，包管理器要去联网下载<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>所以脚本必须满足两条：<strong>① 文件内容纯 ASCII；② 用 <code>%~dp0</code> 而不是硬编码路径</strong>。<code>%~dp0</code> 由 cmd 在运行时从文件真实路径展开（操作系统给的是 UTF-16 字符串），完全绕过文件编码问题。</p></div><p>注意 rsync 的源路径写的是<strong>相对路径 <code>./&lt;产物目录&gt;/</code></strong>，原因见下一节。</p><div class="note warning flat"><p><strong>依赖命令别用裸 <code>npx</code></strong></p><p><code>npx xxx</code> 在本地找不到 <code>xxx</code> 时会<strong>联网下载</strong>一个新版本，并且阻塞在交互提示上：</p><pre class="line-numbers language-text" data-language="text"><code class="language-text">Need to install the following packages: xxx@9.9.9Ok to proceed? (y)<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>自动化脚本里这是灾难（版本不可控、断网就跑不了）。三种写法按优先级：</p><table><thead><tr><th>情况</th><th>写法</th></tr></thead><tbody><tr><td>本地有依赖</td><td><code>call "%~dp0node_modules\.bin\&lt;命令&gt;.cmd"</code></td></tr><tr><td>全局安装过</td><td><code>call &lt;命令&gt;</code></td></tr><tr><td>一定要用 npx</td><td><code>call npx --no-install &lt;命令&gt;</code>（找不到就报错，不下载）</td></tr></tbody></table></div><h3 id="4-4-工作目录与中文路径的三层坑"><a href="#4-4-工作目录与中文路径的三层坑" class="headerlink" title="4.4 工作目录与中文路径的三层坑"></a>4.4 工作目录与中文路径的三层坑</h3><p>中文路径会在<strong>两个不同的地方</strong>出问题，症状相似但根因不同。</p><p><strong>第一层：cmd 解析 <code>.cmd</code> 文件本身</strong></p><p><code>cmd.exe</code> 按 GBK(936) 解析批处理文件。文件存成 UTF-8 时，<code>cd /d "D:\项目\..."</code> 里的中文直接变乱码 → <code>系统找不到指定的路径。</code>。<strong>解法就是 4.3 的两条：纯 ASCII + <code>%~dp0</code>。</strong></p><p><strong>第二层：cmd 把参数传给 bash</strong></p><p>即使目录进对了，bash 的<strong>参数</strong>仍要经过 cmd → bash 这一层字节传递，而 bash 期望 UTF-8：</p><pre class="line-numbers language-bat" data-language="bat"><code class="language-bat">:: 路径含中文时，这样传过去大概率报“找不到文件”bash.exe -lc "rsync -avz /d/项目/输出/ aliyun:/srv/app/"<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>诡异之处在于：<strong>你在 MSYS2 终端里手敲同样的命令却是好的</strong>（那里全程 UTF-8），所以极难定位。</p><p>规避方式：</p><table><thead><tr><th>方式</th><th>做法</th></tr></thead><tbody><tr><td><strong>只传相对路径</strong>（推荐，脚本里已用）</td><td><code>cd /d "%~dp0"</code> 之后 rsync 的源写成 <code>./&lt;产物目录&gt;/</code>。工作目录是 bash 从操作系统继承的（不是字符串），<strong>参数里根本不出现中文</strong>。⚠️ 必须同时设 <code>CHERE_INVOKING=1</code>，否则工作目录会被冲掉，见下</td></tr><tr><td><strong>把逻辑写进 <code>.sh</code></strong></td><td>建一个 <strong>UTF-8 无 BOM</strong> 的 <code>~/sync.sh</code>，中文路径写在 <code>.sh</code> 里由 bash 自己读；<code>.cmd</code> 只负责 <code>bash -l /home/yourname/sync.sh</code></td></tr><tr><td><strong>改成 ASCII 路径</strong></td><td>本地目录改成纯英文，两层坑一起消失，一劳永逸</td></tr></tbody></table><p><strong>第三层：<code>bash -l</code> 会偷偷改掉工作目录</strong></p><p>即使前面两关都过了，还有第三个陷阱：<strong>登录 shell 会重置当前目录</strong>。Cygwin / MSYS2 的 <code>/etc/profile</code> 里有这么一段：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token keyword">if</span> <span class="token punctuation">[</span> <span class="token parameter variable">-z</span> <span class="token string">"<span class="token variable">${CHERE_INVOKING}</span>"</span> <span class="token punctuation">]</span><span class="token punctuation">;</span> <span class="token keyword">then</span>  <span class="token builtin class-name">cd</span> <span class="token string">"<span class="token variable">${<span class="token environment constant">HOME</span>}</span>"</span> <span class="token operator">||</span> <span class="token builtin class-name">echo</span> <span class="token string">"WARNING: Failed to cd to <span class="token variable">${<span class="token environment constant">HOME</span>}</span>"</span><span class="token keyword">fi</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>所以 <code>bash -lc "..."</code> 一启动就跳到了 <code>$HOME</code>，<code>.cmd</code> 里 <code>cd /d "%~dp0"</code> 设好的目录<strong>白设了</strong>，<code>./&lt;产物目录&gt;/</code> 被解析到 <code>$HOME</code> 下面：</p><pre class="line-numbers language-text" data-language="text"><code class="language-text">rsync: [sender] change_dir "/home/yourname/./public" failed: No such file or directory (2)<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>解法是在调用 bash 之前设置一个环境变量：</p><pre class="line-numbers language-bat" data-language="bat"><code class="language-bat">set "CHERE_INVOKING=1""D:\tools\msys64\usr\bin\bash.exe" -lc "rsync ..."<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p><code>CHERE_INVOKING</code> 是 Cygwin 系专门为「保留当前目录」提供的开关。它走<strong>环境变量</strong>而不是命令行参数，所以顺带也不受编码问题影响。</p><div class="note warning flat"><p><strong>名字必须全大写</strong></p><p>shell 环境变量大小写敏感，<code>Chere_Invoking</code> 不生效。</p></div><p><strong>不想被这层 shell 折腾，也可以完全绕开 bash</strong>：<code>rsync.exe</code> 本身就是可执行程序，直接调用即可，工作目录原样保留：</p><pre class="line-numbers language-bat" data-language="bat"><code class="language-bat">set "PATH=D:\tools\msys64\usr\bin;%PATH%""D:\tools\msys64\usr\bin\rsync.exe" -avz --partial --info=progress2 --no-owner --no-group --delete ./&lt;产物目录&gt;/ aliyun:&lt;远端目录&gt;/<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>临时设 <code>PATH</code> 是为了让 <code>rsync.exe</code> 能找到同目录下的 <code>ssh.exe</code>（它靠 <code>PATH</code> 查找远程 shell）。这个 <code>set</code> 只影响当前批处理进程，<strong>不会污染系统环境变量</strong>。</p><p>方式二的脚本：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token shebang important">#!/bin/bash</span><span class="token builtin class-name">set</span> <span class="token parameter variable">-euo</span> pipefail<span class="token assign-left variable">SRC</span><span class="token operator">=</span><span class="token string">"&lt;本地目录&gt;/"</span><span class="token assign-left variable">DST</span><span class="token operator">=</span><span class="token string">"aliyun:&lt;远端目录&gt;/"</span><span class="token builtin class-name">echo</span> <span class="token string">"=== <span class="token variable"><span class="token variable">$(</span><span class="token function">date</span> <span class="token string">'+%F %T'</span><span class="token variable">)</span></span> 同步开始 ==="</span><span class="token function">rsync</span> <span class="token parameter variable">-avz</span> <span class="token parameter variable">--partial</span> <span class="token parameter variable">--info</span><span class="token operator">=</span>progress2 <span class="token punctuation">\</span>      --no-owner --no-group <span class="token parameter variable">--delete</span> <span class="token punctuation">\</span>      <span class="token string">"<span class="token variable">$SRC</span>"</span> <span class="token string">"<span class="token variable">$DST</span>"</span><span class="token builtin class-name">echo</span> <span class="token string">"=== <span class="token variable"><span class="token variable">$(</span><span class="token function">date</span> <span class="token string">'+%F %T'</span><span class="token variable">)</span></span> 同步完成 ==="</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><pre class="line-numbers language-bat" data-language="bat"><code class="language-bat">"D:\tools\msys64\usr\bin\bash.exe" -l /home/yourname/sync.sh<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><div class="note danger flat"><p><strong>症状识别</strong></p><ul><li>批处理输出里中文变乱码、<code>cd</code> 报「系统找不到指定的路径」 → <strong>第一层</strong>，文件编码问题</li><li>报 <code>No such file or directory</code> 但你确认文件明明在 → <strong>第二层</strong>，参数传递问题</li><li>报 <code>change_dir "/home/xxx/./..." failed</code>，路径里出现了家目录 → <strong>第三层</strong>，<code>bash -l</code> 改了工作目录</li></ul><p>前两层的根治办法是同一个：<strong>让中文既不出现在 <code>.cmd</code> 文件里，也不出现在跨 cmd→bash 的参数里。</strong> 第三层则是永远要记得 <code>CHERE_INVOKING=1</code>。</p></div><h3 id="4-5-验证同步结果"><a href="#4-5-验证同步结果" class="headerlink" title="4.5 验证同步结果"></a>4.5 验证同步结果</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token comment"># 远端文件数与权限抽样</span><span class="token function">ssh</span> aliyun <span class="token string">'du -sh "&lt;远端目录&gt;"; find "&lt;远端目录&gt;" -type f | wc -l'</span><span class="token comment"># 找权限异常的文件（应该只剩 644 和 755）</span><span class="token function">ssh</span> aliyun <span class="token string">'find "&lt;远端目录&gt;" -type f ! -perm 644 | head'</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>本地对照：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">find</span> <span class="token string">"&lt;本地目录&gt;"</span> <span class="token parameter variable">-type</span> f <span class="token operator">|</span> <span class="token function">wc</span> <span class="token parameter variable">-l</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>两个数字一致 = 完全镜像。</p><h3 id="4-6-回滚保险"><a href="#4-6-回滚保险" class="headerlink" title="4.6 回滚保险"></a>4.6 回滚保险</h3><p>把 <code>--delete</code> 换成带备份的版本，被删的文件不会直接消失：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token parameter variable">--delete</span> <span class="token parameter variable">--backup</span> --backup-dir<span class="token operator">=</span>/srv/.trash-<span class="token variable"><span class="token variable">$(</span><span class="token function">date</span> +%F<span class="token variable">)</span></span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>远端被”删”的文件会挪进 <code>/srv/.trash-2026-10-10/</code>，定期清旧日期目录即可。</p><h2 id="5-自动化（可选）"><a href="#5-自动化（可选）" class="headerlink" title="5. 自动化（可选）"></a>5. 自动化（可选）</h2><h3 id="5-1-挂-Windows-任务计划"><a href="#5-1-挂-Windows-任务计划" class="headerlink" title="5.1 挂 Windows 任务计划"></a>5.1 挂 Windows 任务计划</h3><pre class="line-numbers language-powershell" data-language="powershell"><code class="language-powershell"><span class="token comment"># 路径含空格的话，/tr 里的引号会被再解析一次，很麻烦——这也是尽量别用空格路径的理由</span>schtasks <span class="token operator">/</span>create <span class="token operator">/</span>tn <span class="token string">"sync-task"</span> <span class="token operator">/</span><span class="token function">sc</span> daily <span class="token operator">/</span>st 09:00 <span class="token operator">/</span>tr <span class="token string">"&lt;本地目录&gt;\deploy.cmd /nopause"</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><div class="note warning flat"><p><strong>任务计划的三个前提</strong></p><ol><li><strong>必须加 <code>/nopause</code></strong> —— 否则脚本末尾的 <code>pause</code> 会一直等按键，任务<strong>永久挂住不退出</strong>，下次触发还会叠加一个</li><li><strong>全部用绝对路径</strong>，工作目录也要显式设置</li><li><strong>私钥必须已解锁</strong>（无 passphrase）或已加进 <code>ssh-agent</code>，否则会静默失败</li></ol></div><h3 id="5-2-如果项目自带部署插件"><a href="#5-2-如果项目自带部署插件" class="headerlink" title="5.2 如果项目自带部署插件"></a>5.2 如果项目自带部署插件</h3><p>不少项目框架自带部署命令（本质是调 rsync），它们<strong>要求 <code>rsync</code> 在 Windows 的 <code>PATH</code> 里</strong>——这和 1.8 的「不要污染 PATH」冲突。</p><p>只在脚本内临时设 PATH（只影响该进程，不动系统环境变量）：</p><pre class="line-numbers language-bat" data-language="bat"><code class="language-bat">set "PATH=D:\tools\msys64\usr\bin;%PATH%"call &lt;部署命令&gt;<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>但既然已经有一条验证通过的 rsync 命令，<strong>用脚本包起来更可控</strong>：参数、日志、失败中止都在你手里。</p><h2 id="6-排错表"><a href="#6-排错表" class="headerlink" title="6. 排错表"></a>6. 排错表</h2><table><thead><tr><th>报错</th><th>原因 / 处置</th></tr></thead><tbody><tr><td><code>Permissions 0644 for 'xxx.pem' are too open</code></td><td>忘了 <code>chmod 600</code></td></tr><tr><td><code>Permission denied (publickey)</code></td><td>用户名错 / 私钥不对 / 公钥没进 <code>authorized_keys</code></td></tr><tr><td><code>rsync: command not found</code></td><td>对端没装 rsync</td></tr><tr><td><code>chown ... failed: Invalid Argument</code> 刷屏</td><td>忘了 <code>--no-owner --no-group</code></td></tr><tr><td><code>mkdir "&lt;远端目录&gt;" failed: Permission denied</code></td><td>加 <code>--rsync-path="sudo rsync"</code>，或用有权限的用户</td></tr><tr><td>批处理输出中文乱码 + <code>系统找不到指定的路径</code></td><td><code>.cmd</code> 存成了 UTF-8，被 cmd 按 GBK 解析（见 4.3）</td></tr><tr><td><code>No such file or directory</code> 但文件确实在</td><td>中文路径跨 cmd→bash 参数编码问题（见 4.4）</td></tr><tr><td><code>change_dir "/home/xxx/./..." failed: No such file or directory</code></td><td><code>bash -l</code> 读 <code>/etc/profile</code> 时 <code>cd "$HOME"</code> 了，加 <code>set "CHERE_INVOKING=1"</code>（见 4.4）</td></tr><tr><td><code>Need to install xxx@y.z</code> 卡在 <code>Ok to proceed?</code></td><td>裸 <code>npx</code> 在联网下载，改用本地依赖或 <code>--no-install</code></td></tr><tr><td><code>unknown compressor</code></td><td>对端 rsync &lt; 3.2，去掉 <code>--compress-choice=zstd</code></td></tr><tr><td><code>Connection refused</code> / 超时</td><td>端口错 / 安全组没放行 / sshd 没在听</td></tr><tr><td>下载时报 <code>404</code> from mirror</td><td>镜像同步延迟，pacman 会自动 fallback，无害</td></tr><tr><td><code>--delete</code> 删多了</td><td>没干跑。<strong>任何带 <code>--delete</code> 的新命令都必须先 <code>--dry-run</code></strong></td></tr><tr><td>WSL 里私钥 chmod 无效</td><td>那是 WSL 的 DrvFs 特性，与本方案无关</td></tr></tbody></table><h2 id="7-参考"><a href="#7-参考" class="headerlink" title="7. 参考"></a>7. 参考</h2><ul><li><a href="https://www.msys2.org/docs/installer/">MSYS2 Installer 官方文档</a> —— SFX 归档与 <code>-o</code> 参数</li><li><a href="https://www.msys2.org/docs/mirrors/">MSYS2 Package Mirrors</a> / <a href="https://www.msys2.org/dev/mirrors/">完整镜像列表</a></li><li><a href="https://www.msys2.org/docs/configuration/">MSYS2 Configuration Locations</a> —— <code>$HOME</code> 与配置落点</li><li><code>man rsync</code>、<code>man ssh_config</code></li></ul>]]></content>
    
    <summary type="html">
    
      在 Windows 上用 MSYS2 装一个原生 rsync，全程放非系统盘、不碰 C 盘，再配合 SSH 密钥同步到远端。包含镜像提速、权限语义、中文路径编码坑与完整排错表。
    
    </summary>
    
    
      <category term="运维" scheme="https://blog.hpan.xin/categories/%E8%BF%90%E7%BB%B4/"/>
    
      <category term="Windows" scheme="https://blog.hpan.xin/categories/%E8%BF%90%E7%BB%B4/Windows/"/>
    
    
      <category term="SSH" scheme="https://blog.hpan.xin/tags/SSH/"/>
    
      <category term="rsync" scheme="https://blog.hpan.xin/tags/rsync/"/>
    
      <category term="MSYS2" scheme="https://blog.hpan.xin/tags/MSYS2/"/>
    
      <category term="Windows" scheme="https://blog.hpan.xin/tags/Windows/"/>
    
      <category term="文件同步" scheme="https://blog.hpan.xin/tags/%E6%96%87%E4%BB%B6%E5%90%8C%E6%AD%A5/"/>
    
      <category term="命令行" scheme="https://blog.hpan.xin/tags/%E5%91%BD%E4%BB%A4%E8%A1%8C/"/>
    
  </entry>
  
  <entry>
    <title>云服务器SSH 端口变更与 rsync 密钥传输</title>
    <link href="https://blog.hpan.xin/2026/10/10/SSH%E7%AB%AF%E5%8F%A3%E4%B8%8Ersync%E5%AF%86%E9%92%A5%E7%99%BB%E5%BD%95/"/>
    <id>https://blog.hpan.xin/2026/10/10/SSH%E7%AB%AF%E5%8F%A3%E4%B8%8Ersync%E5%AF%86%E9%92%A5%E7%99%BB%E5%BD%95/</id>
    <published>2026-10-10T12:05:00.000Z</published>
    <updated>2026-10-10T12:05:00.000Z</updated>
    
    <content type="html"><![CDATA[<p>rsync 自己不做身份认证，认证交给 SSH，所以「密钥对登录」就是把 <code>ssh -i 私钥</code> 塞进 rsync 的 <code>-e</code>。而 SSH 改端口则要看系统用的是传统 <code>sshd.service</code> 还是 <strong>systemd socket 激活</strong>——如果是后者，<code>sshd_config</code> 的 <code>Port</code> 是唯一真源，<code>ssh.socket</code> 由 <code>sshd-socket-generator</code> 自动派生。</p><p>本文记录一套完整可复现的流程，包括实测输出与排错表。</p><span id="more"></span><h2 id="速查"><a href="#速查" class="headerlink" title="速查"></a>速查</h2><table><thead><tr><th>场景</th><th>命令</th></tr></thead><tbody><tr><td>rsync 走密钥对</td><td><code>rsync -avz -e "ssh -i ~/.ssh/key.pem" ./src/ root@IP:/dst/</code></td></tr><tr><td>写进 config 后</td><td><code>rsync -avz ./src/ aliyun:/dst/</code></td></tr><tr><td>改 SSH 端口</td><td>改 <code>sshd_config</code> 的 <code>Port</code> → <code>sshd -t</code> → <code>daemon-reload</code> → <code>restart ssh.socket ssh.service</code></td></tr><tr><td>验证监听</td><td><code>systemctl show ssh.socket -p Listen</code> + <code>ss -tlnp | grep 端口</code></td></tr><tr><td>外部验证</td><td>本地 <code>Test-NetConnection -ComputerName IP -Port 2233</code></td></tr><tr><td>失联兜底</td><td>阿里云控制台 <strong>VNC 远程连接</strong>（不走 SSH）</td></tr></tbody></table><div class="note danger flat"><p><strong>三条铁律</strong></p><ol><li><strong>先开安全组，再改服务端</strong>，顺序反了就是自锁。</li><li><strong>改完 <code>sshd_config</code> 必须先 <code>sshd -t</code></strong>，配置写坏会导致 generator 不生成、静默回落 22。</li><li><strong>永远保持至少一个非默认端口</strong>，否则可能生成空的 <code>ListenStream=</code> 导致无人监听。</li></ol></div><h2 id="1-rsync-使用密钥对"><a href="#1-rsync-使用密钥对" class="headerlink" title="1. rsync 使用密钥对"></a>1. rsync 使用密钥对</h2><h3 id="1-1-原理"><a href="#1-1-原理" class="headerlink" title="1.1 原理"></a>1.1 原理</h3><p>rsync 是纯传输工具，不做认证。它把连接交给「远程 shell」（默认 SSH）。所以任何 SSH 的参数都能通过 <code>-e</code> 传进去：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">rsync</span> <span class="token parameter variable">-avz</span> <span class="token parameter variable">--progress</span> <span class="token punctuation">\</span>  <span class="token parameter variable">-e</span> <span class="token string">"ssh -i ~/.ssh/aliyun.pem -o IdentitiesOnly=yes"</span> <span class="token punctuation">\</span>  ./dist/ root@47.98.x.x:/var/www/html/<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><ul><li><code>-o IdentitiesOnly=yes</code>：防止 SSH 先去试 agent 里的其它 key，避免「公钥太多被拒」。</li><li>改端口用 <strong>SSH 自己的 <code>-p</code></strong>（小写），别和 rsync 的 <code>-p</code>/<code>--perms</code> 混淆：<code>-e "ssh -p 2233 -i ~/.ssh/aliyun.pem"</code>。</li></ul><div class="note warning flat"><p><strong>路径结尾的斜杠是 rsync 第一大坑</strong></p></div><table><thead><tr><th>写法</th><th>含义</th></tr></thead><tbody><tr><td><code>./dist/</code></td><td>把 dist <strong>里面的内容</strong>同步到目标目录</td></tr><tr><td><code>./dist</code></td><td>把 dist <strong>这个目录本身</strong>放进目标目录，结果是 <code>.../html/dist/...</code></td></tr></tbody></table><h3 id="1-2-推荐：写进-ssh-config"><a href="#1-2-推荐：写进-ssh-config" class="headerlink" title="1.2 推荐：写进 ~/.ssh/config"></a>1.2 推荐：写进 <code>~/.ssh/config</code></h3><pre class="line-numbers language-ini" data-language="ini"><code class="language-ini">Host aliyun    HostName 47.98.x.x    User root    Port 2233    IdentityFile ~/.ssh/aliyun.pem    IdentitiesOnly yes    ServerAliveInterval 30    ServerAliveCountMax 6<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>之后命令干净到只剩业务参数，<code>ssh</code> / <code>scp</code> / <code>git</code> 全部复用同一份配置：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">rsync</span> <span class="token parameter variable">-avz</span> <span class="token parameter variable">--delete</span> ./dist/ aliyun:/var/www/html/<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><h3 id="1-3-私钥权限"><a href="#1-3-私钥权限" class="headerlink" title="1.3 私钥权限"></a>1.3 私钥权限</h3><p>控制台下载的 <code>.pem</code> 在 Windows 上通常是「所有人可读」，OpenSSH 直接拒绝：</p><pre class="line-numbers language-text" data-language="text"><code class="language-text">Permissions for 'aliyun.pem' are too open.<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>WSL / Linux / macOS：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">chmod</span> <span class="token number">600</span> ~/.ssh/aliyun.pem    <span class="token comment"># 400 也可以</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><div class="note warning flat"><p><strong>WSL 里的隐藏坑</strong></p><p>私钥如果留在 <code>/mnt/c/...</code>，<code>chmod</code> <strong>无效</strong>（DrvFs 固定 777）。必须拷进 Linux 文件系统：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">mkdir</span> <span class="token parameter variable">-p</span> ~/.ssh <span class="token operator">&amp;&amp;</span> <span class="token function">cp</span> /mnt/c/Users/you/Downloads/aliyun.pem ~/.ssh/ <span class="token operator">&amp;&amp;</span> <span class="token function">chmod</span> <span class="token number">600</span> ~/.ssh/aliyun.pem<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre></div><p>原生 Windows OpenSSH（PowerShell）：</p><pre class="line-numbers language-powershell" data-language="powershell"><code class="language-powershell">icacls aliyun<span class="token punctuation">.</span>pem <span class="token operator">/</span>inheritance:r <span class="token operator">/</span>grant:r <span class="token string">"<span class="token function">$<span class="token punctuation">(</span><span class="token variable">$env</span>:USERNAME<span class="token punctuation">)</span></span>:(R)"</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><h3 id="1-4-阿里云侧的-5-个检查点"><a href="#1-4-阿里云侧的-5-个检查点" class="headerlink" title="1.4 阿里云侧的 5 个检查点"></a>1.4 阿里云侧的 5 个检查点</h3><ul><li><input disabled="" type="checkbox"> <strong>登录名</strong>：Linux 默认 <code>root</code>；创建实例时选了非 root 登录名则是 <code>ecs-user</code>。用 <code>ecs-user</code> 写 <code>/var/www</code> 这类目录要提权：<br><code>rsync -avz --rsync-path="sudo rsync" ./dist/ aliyun:/var/www/html/</code></li><li><input disabled="" type="checkbox"> <strong>绑定密钥对需重启实例生效</strong>（控制台换绑/解绑同理）。</li><li><input disabled="" type="checkbox"> <strong>安全组</strong>入方向放行 SSH 端口，授权对象最好只填自己的 IP。</li><li><input disabled="" type="checkbox"> <strong>对端要装 rsync</strong>：<code>rsync --version</code>，没有就 <code>sudo yum install -y rsync</code> / <code>sudo apt install -y rsync</code>。</li><li><input disabled="" type="checkbox"> <strong>密钥类型</strong>：控制台导入支持 <code>rsa</code> / <code>dsa</code> / <code>ssh-rsa</code> / <code>ssh-dss</code> / <code>ecdsa</code>（<strong>不含 ed25519</strong>）。用 <code>ssh-keygen -y -f 私钥</code> 反查公钥内容。</li></ul><h3 id="1-5-常用参数"><a href="#1-5-常用参数" class="headerlink" title="1.5 常用参数"></a>1.5 常用参数</h3><table><thead><tr><th>参数</th><th>作用</th></tr></thead><tbody><tr><td><code>-a</code></td><td>归档模式（递归 + 保留权限/时间/软链），基本必带</td></tr><tr><td><code>-v</code> / <code>-z</code></td><td>详细输出 / 传输压缩（文本划算，已压缩文件无益）</td></tr><tr><td><code>-P</code></td><td>= <code>--partial --progress</code>，断点续传 + 进度，传大文件必带</td></tr><tr><td><code>-n</code></td><td>dry-run，改 <code>--delete</code> 前一定先跑一次</td></tr><tr><td><code>--delete</code></td><td>目标端删除源端已不存在的文件（镜像同步，<strong>危险</strong>）</td></tr><tr><td><code>--exclude='.git'</code> / <code>--exclude-from=file</code></td><td>排除</td></tr><tr><td><code>--bwlimit=2M</code></td><td>限速</td></tr><tr><td><code>--rsync-path="sudo rsync"</code></td><td>对端用 sudo 跑 rsync</td></tr></tbody></table><h2 id="2-SSH-改端口"><a href="#2-SSH-改端口" class="headerlink" title="2. SSH 改端口"></a>2. SSH 改端口</h2><h3 id="2-1-先判断服务模式"><a href="#2-1-先判断服务模式" class="headerlink" title="2.1 先判断服务模式"></a>2.1 先判断服务模式</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash">systemctl status ssh.socket ssh.service <span class="token operator"><span class="token file-descriptor important">2</span>&gt;</span>/dev/nullsystemctl <span class="token function">cat</span> ssh.socketss <span class="token parameter variable">-tlnp</span> <span class="token operator">|</span> <span class="token function">grep</span> <span class="token parameter variable">-E</span> <span class="token string">'sshd|:22\b'</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><table><thead><tr><th>模式</th><th>特征</th><th>谁监听</th><th>改哪里</th></tr></thead><tbody><tr><td>传统</td><td>只有 <code>sshd.service</code> / <code>ssh.service</code> 常驻</td><td>sshd 自己</td><td><code>sshd_config</code> 的 <code>Port</code></td></tr><tr><td>socket（<code>Accept=yes</code>）</td><td>有 <code>ssh@.service</code>，每个连接一个进程</td><td>systemd</td><td><code>ssh.socket</code> 的 <code>ListenStream</code></td></tr><tr><td>socket（<code>Accept=no</code>）</td><td><code>ssh.service</code> 常驻，<code>TriggeredBy: ssh.socket</code></td><td>systemd 建 socket，<strong>fd 传给 sshd</strong></td><td>看有没有 generator ↓</td></tr></tbody></table><p><strong><code>Accept=no</code> 的指纹</strong>：<code>ss -tlnp</code> 里同一个 socket 同时挂在 <code>systemd</code>(pid 1) 和 <code>sshd</code> 上：</p><pre class="line-numbers language-text" data-language="text"><code class="language-text">LISTEN 0 4096 0.0.0.0:2233 0.0.0.0:* users:(("sshd",pid=1269022,fd=3),("systemd",pid=1,fd=76))                                                    ↑ 共享 fd，不是各自监听<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><h3 id="2-2-决定性诊断：有没有-sshd-socket-generator"><a href="#2-2-决定性诊断：有没有-sshd-socket-generator" class="headerlink" title="2.2 决定性诊断：有没有 sshd-socket-generator"></a>2.2 决定性诊断：有没有 <code>sshd-socket-generator</code></h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">ls</span> <span class="token parameter variable">-l</span> /usr/lib/systemd/system-generators/ /lib/systemd/system-generators/ <span class="token operator"><span class="token file-descriptor important">2</span>&gt;</span>/dev/null <span class="token operator">|</span> <span class="token function">grep</span> <span class="token parameter variable">-i</span> <span class="token function">ssh</span><span class="token function">ls</span> <span class="token parameter variable">-l</span> /run/systemd/generator/ssh.socket.d/ <span class="token operator"><span class="token file-descriptor important">2</span>&gt;</span>/dev/null<span class="token function">sudo</span> sshd <span class="token parameter variable">-T</span> <span class="token operator">|</span> <span class="token function">grep</span> <span class="token parameter variable">-E</span> <span class="token string">'^(port|listenaddress)'</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><ul><li><strong>有 <code>sshd-socket-generator</code></strong> → 改 <code>sshd_config</code> 就够了（generator 自动翻译成 <code>ListenStream</code>）</li><li><strong>没有</strong> → 必须手改 <code>ssh.socket</code></li></ul><h3 id="2-3-自动同步链路（有-generator）"><a href="#2-3-自动同步链路（有-generator）" class="headerlink" title="2.3 自动同步链路（有 generator）"></a>2.3 自动同步链路（有 generator）</h3><div class="mermaid-wrap"><pre class="mermaid-src" data-config="{}" hidden="">    flowchart LR    A["/etc/ssh/sshd_config&lt;br/&gt;Port 2233"] --&gt;|daemon-reload| B["sshd-socket-generator&lt;br/&gt;（内部调用 sshd -T 解析）"]    B --&gt;|写入| C["/run/systemd/generator/&lt;br/&gt;ssh.socket.d/addresses.conf"]    C --&gt; D["ssh.socket&lt;br/&gt;持有监听 fd"]    D --&gt;|SD_LISTEN_FDS 传递 fd| E["ssh.service&lt;br/&gt;sshd -D 常驻"]    E --&gt; F["客户端连接"]  </pre></div><p><strong>操作（三步）</strong></p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token comment"># 1. 改配置（drop-in 优先于主文件，因为 Include 在顶部，sshd 是"先出现者生效"）</span><span class="token function">sudo</span> <span class="token function">mkdir</span> <span class="token parameter variable">-p</span> /etc/ssh/sshd_config.d<span class="token builtin class-name">printf</span> <span class="token string">'Port 2233\n'</span> <span class="token operator">|</span> <span class="token function">sudo</span> <span class="token function">tee</span> /etc/ssh/sshd_config.d/99-ssh-port.conf<span class="token comment"># 2. 校验 + 触发 generator</span><span class="token function">sudo</span> sshd <span class="token parameter variable">-t</span><span class="token function">sudo</span> systemctl daemon-reload<span class="token comment"># 3. 看生成结果（关键验证）</span><span class="token function">cat</span> /run/systemd/generator/ssh.socket.d/addresses.conf<span class="token comment"># 4. 重启</span><span class="token function">sudo</span> systemctl restart ssh.socket ssh.service<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>生成结果应形如：</p><pre class="line-numbers language-ini" data-language="ini"><code class="language-ini"><span class="token comment"># Automatically generated by sshd-socket-generator</span><span class="token section"><span class="token punctuation">[</span><span class="token section-name selector">Socket</span><span class="token punctuation">]</span></span><span class="token key attr-name">ListenStream</span><span class="token punctuation">=</span><span class="token key attr-name">ListenStream</span><span class="token punctuation">=</span><span class="token value attr-value">0.0.0.0:2233</span><span class="token key attr-name">ListenStream</span><span class="token punctuation">=</span><span class="token value attr-value">[::]:2233</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><h3 id="2-4-手改-ssh-socket（无-generator）"><a href="#2-4-手改-ssh-socket（无-generator）" class="headerlink" title="2.4 手改 ssh.socket（无 generator）"></a>2.4 手改 <code>ssh.socket</code>（无 generator）</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">sudo</span> systemctl edit ssh.socket<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><pre class="line-numbers language-ini" data-language="ini"><code class="language-ini"><span class="token section"><span class="token punctuation">[</span><span class="token section-name selector">Socket</span><span class="token punctuation">]</span></span><span class="token key attr-name">ListenStream</span><span class="token punctuation">=</span><span class="token key attr-name">ListenStream</span><span class="token punctuation">=</span><span class="token value attr-value">0.0.0.0:2233</span><span class="token key attr-name">ListenStream</span><span class="token punctuation">=</span><span class="token value attr-value">[::]:2233</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span></span></code></pre><div class="note danger flat"><p><strong>必须写两条 <code>ListenStream</code></strong></p><p>若基础单元含 <code>BindIPv6Only=ipv6-only</code>，<code>[::]</code> 那条 socket <strong>不接收 IPv4 连接</strong>，基础单元靠显式多写一条 <code>0.0.0.0:22</code> 补齐 IPv4。</p><p>只写一条 <code>ListenStream=2233</code> → 变成「仅 <code>[::]:2233</code> 且 ipv6-only」→ <strong>IPv4 全部失联</strong>，且表象是「改了端口但连不上」，极难排查。</p></div><ul><li>第一行空值 <code>ListenStream=</code> 是<strong>清空继承的监听列表</strong>，不写则新旧端口同时监听。</li><li><code>BindIPv6Only</code> / <code>FreeBind</code> 不用重写，drop-in 只覆盖显式设置的指令。</li><li>过渡期想双端口并存：别写清空行，直接写四条 <code>ListenStream</code>。</li></ul><h3 id="2-5-生效与验证"><a href="#2-5-生效与验证" class="headerlink" title="2.5 生效与验证"></a>2.5 生效与验证</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">sudo</span> systemctl restart ssh.socket ssh.service   <span class="token comment"># reload 无效！见下</span>systemctl show ssh.socket <span class="token parameter variable">-p</span> Listenss <span class="token parameter variable">-tlnp</span> <span class="token operator">|</span> <span class="token function">grep</span> <span class="token number">2233</span><span class="token function">sudo</span> sshd <span class="token parameter variable">-T</span> <span class="token operator">|</span> <span class="token function">grep</span> <span class="token parameter variable">-E</span> <span class="token string">'^(port|listenaddress)'</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span></span></code></pre><div class="note warning flat"><p><strong><code>systemctl reload ssh</code> 永远不会改端口</strong></p><p>reload 只是发 SIGHUP 让 sshd re-exec，而复用的是 systemd 给的那个 fd。<strong>必须 <code>restart</code> socket。</strong></p></div><p>外部验证（<code>ss</code> 只证明服务端在听，证明不了安全组放行）：</p><pre class="line-numbers language-powershell" data-language="powershell"><code class="language-powershell"><span class="token function">Test-NetConnection</span> <span class="token operator">-</span>ComputerName 47<span class="token punctuation">.</span>98<span class="token punctuation">.</span>x<span class="token punctuation">.</span>x <span class="token operator">-</span>Port 2233<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">ssh</span> <span class="token parameter variable">-p</span> <span class="token number">2233</span> <span class="token parameter variable">-i</span> ~/.ssh/aliyun.pem root@47.98.x.x<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><div class="note success flat"><p>在服务器上 <code>ssh -p 2233 root@127.0.0.1</code> 成功<strong>没有意义</strong>，那走 lo 接口，完全绕过安全组。</p></div><h3 id="2-6-收尾：关掉旧端口"><a href="#2-6-收尾：关掉旧端口" class="headerlink" title="2.6 收尾：关掉旧端口"></a>2.6 收尾：关掉旧端口</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">sudo</span> <span class="token function">grep</span> <span class="token parameter variable">-rn</span> <span class="token parameter variable">-E</span> <span class="token string">'^\s*Port\s'</span> /etc/ssh/sshd_config /etc/ssh/sshd_config.d/   <span class="token comment"># 先定位</span><span class="token function">sudo</span> <span class="token function">cp</span> <span class="token parameter variable">-a</span> /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.<span class="token variable"><span class="token variable">$(</span><span class="token function">date</span> +%F<span class="token variable">)</span></span>         <span class="token comment"># 备份</span><span class="token comment"># 注释掉 Port 22，只留 Port 2233</span><span class="token function">sudo</span> sshd <span class="token parameter variable">-t</span> <span class="token operator">&amp;&amp;</span> <span class="token function">sudo</span> systemctl daemon-reload<span class="token function">cat</span> /run/systemd/generator/ssh.socket.d/addresses.conf                       <span class="token comment"># 确认只剩 2233</span><span class="token function">sudo</span> systemctl restart ssh.socket ssh.service<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>验证新连接稳定后，再回阿里云控制台<strong>删掉 22 的安全组规则</strong>。</p><h3 id="2-7-安全性检查"><a href="#2-7-安全性检查" class="headerlink" title="2.7 安全性检查"></a>2.7 安全性检查</h3><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash">systemctl <span class="token function">cat</span> ssh.service <span class="token operator">|</span> <span class="token function">grep</span> <span class="token parameter variable">-iE</span> <span class="token string">'killmode|execstart'</span>   <span class="token comment"># KillMode=process → 重启不踢现有会话</span><span class="token function">sudo</span> ufw status<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><div class="note primary flat"><p><strong><code>ufw status: inactive</code> 的含义</strong></p><p>主机侧没有任何防火墙，访问控制 <strong>100% 依赖阿里云安全组</strong>。云上常见做法，但要知悉。</p><p>将来若启用 ufw，<strong>必须先放行新端口再 enable</strong>：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">sudo</span> ufw allow <span class="token number">2233</span>/tcp<span class="token function">sudo</span> ufw <span class="token builtin class-name">enable</span>    <span class="token comment"># 最后一步</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre></div><p><strong>其他连带影响</strong></p><ul><li>阿里云控制台 <strong>Workbench 远程连接</strong>走 SSH 22，改端口后失效（部分版本可指定端口）。</li><li><strong>VNC 管理终端不走 SSH，永远可用</strong>——唯一的兜底通道。</li><li>别忘了：fail2ban 的 <code>port</code>、CI/CD 部署脚本、监控探针、Ansible 的 <code>ansible_port</code>、宝塔面板、iptables 旧规则。</li></ul><h3 id="2-8-排错表"><a href="#2-8-排错表" class="headerlink" title="2.8 排错表"></a>2.8 排错表</h3><table><thead><tr><th>现象</th><th>原因</th></tr></thead><tbody><tr><td>改了 <code>sshd_config</code> 的 <code>Port</code> 没反应</td><td>socket 激活下 <code>Port</code> 由 generator 翻译，需 <code>daemon-reload</code>；无 generator 则要手改 <code>ssh.socket</code></td></tr><tr><td>新旧端口同时监听</td><td>drop-in 里漏写了清空行 <code>ListenStream=</code></td></tr><tr><td>改了端口但 IPv4 连不上</td><td><code>BindIPv6Only=ipv6-only</code> 下只写了 <code>[::]</code>，漏写 <code>0.0.0.0:</code></td></tr><tr><td>改了配置后完全没监听</td><td><code>sshd -t</code> 失败 → generator 不生成 → 空 <code>ListenStream=</code> 清空了列表</td></tr><tr><td><code>reload</code> 后端口没变</td><td>正常，必须 <code>restart ssh.socket</code></td></tr><tr><td><code>ssh.socket</code> 起不来 <code>Address already in use</code></td><td>旧进程占用，或 drop-in 端口重复</td></tr><tr><td>安全组加了还是连不上</td><td>ufw 没放行 / 授权对象 IP 不对（家宽 IP 会变）</td></tr><tr><td>Workbench 连不上</td><td>走 SSH 22，改用 VNC</td></tr><tr><td><code>no mutual signature algorithm</code></td><td>老服务端 + RSA/SHA-1：<code>-o PubkeyAcceptedAlgorithms=+ssh-rsa -o HostKeyAlgorithms=+ssh-rsa</code></td></tr><tr><td><code>Permission denied (publickey)</code></td><td>用户名错 / 公钥没进 <code>authorized_keys</code> / 用错私钥</td></tr></tbody></table><h3 id="2-9-兜底与回滚"><a href="#2-9-兜底与回滚" class="headerlink" title="2.9 兜底与回滚"></a>2.9 兜底与回滚</h3><p>阿里云控制台 → <strong>VNC 远程连接</strong>登录：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">sudo</span> <span class="token function">cp</span> <span class="token parameter variable">-a</span> /etc/ssh/sshd_config.bak.<span class="token variable"><span class="token variable">$(</span><span class="token function">date</span> +%F<span class="token variable">)</span></span> /etc/ssh/sshd_config<span class="token function">sudo</span> sshd <span class="token parameter variable">-t</span><span class="token function">sudo</span> systemctl daemon-reload<span class="token function">sudo</span> systemctl restart ssh.socket ssh.service<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span></span></code></pre><p>或删掉手写的 drop-in：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">sudo</span> <span class="token function">rm</span> <span class="token parameter variable">-f</span> /etc/systemd/system/ssh.socket.d/*.conf /etc/ssh/sshd_config.d/99-ssh-port.conf<span class="token function">sudo</span> systemctl daemon-reload <span class="token operator">&amp;&amp;</span> <span class="token function">sudo</span> systemctl restart ssh.socket ssh.service<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p><code>ConditionPathExists=!/etc/ssh/sshd_not_to_be_run</code>：<code>sudo touch /etc/ssh/sshd_not_to_be_run</code> 可让 sshd 彻底不监听。</p><h2 id="3-本机实测记录"><a href="#3-本机实测记录" class="headerlink" title="3. 本机实测记录"></a>3. 本机实测记录</h2><h3 id="3-1-环境事实"><a href="#3-1-环境事实" class="headerlink" title="3.1 环境事实"></a>3.1 环境事实</h3><p><code>/usr/lib/systemd/system/ssh.socket</code>：</p><pre class="line-numbers language-ini" data-language="ini"><code class="language-ini"><span class="token section"><span class="token punctuation">[</span><span class="token section-name selector">Unit</span><span class="token punctuation">]</span></span><span class="token key attr-name">Description</span><span class="token punctuation">=</span><span class="token value attr-value">OpenBSD Secure Shell server socket</span><span class="token key attr-name">Before</span><span class="token punctuation">=</span><span class="token value attr-value">sockets.target ssh.service</span><span class="token key attr-name">ConditionPathExists</span><span class="token punctuation">=</span><span class="token value attr-value">!/etc/ssh/sshd_not_to_be_run</span><span class="token section"><span class="token punctuation">[</span><span class="token section-name selector">Socket</span><span class="token punctuation">]</span></span><span class="token key attr-name">ListenStream</span><span class="token punctuation">=</span><span class="token value attr-value">0.0.0.0:22</span><span class="token key attr-name">ListenStream</span><span class="token punctuation">=</span><span class="token value attr-value">[::]:22</span><span class="token key attr-name">BindIPv6Only</span><span class="token punctuation">=</span><span class="token value attr-value">ipv6-only</span><span class="token key attr-name">Accept</span><span class="token punctuation">=</span><span class="token value attr-value">no</span><span class="token key attr-name">FreeBind</span><span class="token punctuation">=</span><span class="token value attr-value">yes</span><span class="token section"><span class="token punctuation">[</span><span class="token section-name selector">Install</span><span class="token punctuation">]</span></span><span class="token key attr-name">WantedBy</span><span class="token punctuation">=</span><span class="token value attr-value">sockets.target</span><span class="token key attr-name">RequiredBy</span><span class="token punctuation">=</span><span class="token value attr-value">ssh.service</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p><code>ssh.service</code> 显示 <code>disabled</code> 但 <code>active (running)</code>、<code>TriggeredBy: ● ssh.socket</code>——<strong>这是正常的</strong>，不是配置坏了。</p><p><code>RequiredBy=ssh.service</code> 是 <code>[Install]</code> 指令，<code>enable</code> 时会在 <code>/etc/systemd/system/ssh.service.requires/</code> 建符号链接，于是 <strong><code>ssh.service</code> 反向依赖 <code>ssh.socket</code></strong>。</p><div class="note warning flat"><p><strong>因此不推荐简单的 <code>disable ssh.socket</code></strong></p><p>顺序必须是：<code>disable ssh.socket</code> → <strong>确认 <code>.requires</code> 链接消失</strong> → <code>enable/restart ssh.service</code>。只 <code>mask ssh.socket</code> 而不清依赖链接，<code>ssh.service</code> 会因缺 Requires 直接启动失败。</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash">systemctl disable ssh.socket<span class="token function">ls</span> <span class="token parameter variable">-l</span> /etc/systemd/system/ssh.service.requires/   <span class="token comment"># 应无 ssh.socket</span><span class="token function">sudo</span> systemctl <span class="token builtin class-name">enable</span> <span class="token parameter variable">--now</span> ssh.servicesystemctl show ssh.service <span class="token parameter variable">-p</span> Requires<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span></span></code></pre></div><h3 id="3-2-诊断输出"><a href="#3-2-诊断输出" class="headerlink" title="3.2 诊断输出"></a>3.2 诊断输出</h3><pre class="line-numbers language-text" data-language="text"><code class="language-text">$ ls -l /usr/lib/systemd/system-generators/ | grep -i ssh-rwxr-xr-x 1 root root 482376 Sep  1 02:38 sshd-socket-generator$ ls -l /run/systemd/generator/ssh.socket.d/-rw-r--r-- 1 root root 169 Oct 10 19:55 addresses.conf$ sudo sshd -T | grep -E '^(port|listenaddress)'port 22port 2233listenaddress [::]:22listenaddress 0.0.0.0:22listenaddress [::]:2233listenaddress 0.0.0.0:2233$ cat /run/systemd/generator/ssh.socket.d/addresses.conf# Automatically generated by sshd-socket-generator[Socket]ListenStream=ListenStream=0.0.0.0:2233ListenStream=[::]:2233ListenStream=0.0.0.0:22ListenStream=[::]:22$ systemctl show ssh.socket -p ListenListen=0.0.0.0:2233 (Stream)Listen=[::]:2233 (Stream)Listen=0.0.0.0:22 (Stream)Listen=[::]:22 (Stream)$ ss -tlnp | grep 2233LISTEN 0 4096 0.0.0.0:2233 0.0.0.0:* users:(("sshd",pid=1269022,fd=3),("systemd",pid=1,fd=76))LISTEN 0 4096   [::]:2233   [::]:* users:(("sshd",pid=1269022,fd=4),("systemd",pid=1,fd=77))<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p><strong>结论</strong>：<code>sshd_config</code> → generator → <code>ssh.socket</code> 自动同步链路验证通过。此后改端口只需改 <code>sshd_config</code> 一处。</p><h3 id="3-3-待办清单"><a href="#3-3-待办清单" class="headerlink" title="3.3 待办清单"></a>3.3 待办清单</h3><ul><li><input disabled="" type="checkbox"> 本地 <code>Test-NetConnection -ComputerName &lt;IP&gt; -Port 2233</code> 通</li><li><input disabled="" type="checkbox"> 阿里云安全组确认有 <code>TCP 2233</code>（授权对象限自己 IP）</li><li><input disabled="" type="checkbox"> <code>sshd_config</code> 注释掉 <code>Port 22</code></li><li><input disabled="" type="checkbox"> <code>sshd -t</code> → <code>daemon-reload</code> → 确认 <code>addresses.conf</code> 只剩 2233 → <code>restart</code></li><li><input disabled="" type="checkbox"> 本地 <code>ssh -p 2233</code> 成功</li><li><input disabled="" type="checkbox"> 删除安全组 22 规则</li><li><input disabled="" type="checkbox"> 更新本地 <code>~/.ssh/config</code> 的 <code>Port 2233</code></li><li><input disabled="" type="checkbox"> 检查 fail2ban（若有）的 <code>port</code> 设置</li><li><input disabled="" type="checkbox"> 确认阿里云 Workbench 是否需要改用 VNC</li></ul><h2 id="4-参考"><a href="#4-参考" class="headerlink" title="4. 参考"></a>4. 参考</h2><ul><li><a href="https://www.alibabacloud.com/help/zh/ecs/user-guide/instance-logon-credential-management">实例登录凭证管理（登录名/密码/密钥对） - 阿里云 ECS</a></li><li><a href="https://bugs.launchpad.net/ubuntu/+source/openssh/+bug/1991592">openssh-server should ship a systemd generator… (Launchpad #1991592)</a> —— <code>sshd-socket-generator</code> 的来源与设计讨论</li><li><a href="https://konbluesky.github.io/zh-CN/ssh_port_change_failed/">理解 SSH 改端口不生效的问题及解决方案</a></li><li><code>man 5 sshd_config</code>、<code>man 5 systemd.socket</code>、<code>man 8 sshd</code>（socket activation 章节）</li></ul>]]></content>
    
    <summary type="html">
    
      阿里云 ECS 使用密钥对时如何用 rsync 同步文件，以及 systemd socket 激活模式下（Accept=no + sshd-socket-generator）SSH 改端口的完整流程、验证方法与排错表。
    
    </summary>
    
    
      <category term="运维" scheme="https://blog.hpan.xin/categories/%E8%BF%90%E7%BB%B4/"/>
    
      <category term="Linux" scheme="https://blog.hpan.xin/categories/%E8%BF%90%E7%BB%B4/Linux/"/>
    
    
      <category term="SSH" scheme="https://blog.hpan.xin/tags/SSH/"/>
    
      <category term="rsync" scheme="https://blog.hpan.xin/tags/rsync/"/>
    
      <category term="systemd" scheme="https://blog.hpan.xin/tags/systemd/"/>
    
      <category term="阿里云" scheme="https://blog.hpan.xin/tags/%E9%98%BF%E9%87%8C%E4%BA%91/"/>
    
      <category term="ECS" scheme="https://blog.hpan.xin/tags/ECS/"/>
    
  </entry>
  
</feed>
