<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Sync on Tarragon</title><link>https://tarrragon.github.io/blog/tags/sync/</link><description>Recent content in Sync on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 02 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/sync/index.xml" rel="self" type="application/rss+xml"/><item><title>遠端連線與同步工具選型：連得穩、斷得起、檔案一致</title><link>https://tarrragon.github.io/blog/linux/tools/remote/connection-and-sync-tools/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/tools/remote/connection-and-sync-tools/</guid><description>&lt;p>遠端工作有三塊彼此獨立的能力：&lt;strong>保住 session&lt;/strong>（多工器讓遠端的工作不隨連線消失）、&lt;strong>連線層&lt;/strong>（決定你怎麼接上遠端、斷了怎麼辦）、&lt;strong>同步層&lt;/strong>（決定本地與遠端的檔案怎麼保持一致）。多工器是地基、已在&lt;a href="../">遠端工具總覽&lt;/a>談過；這篇補另外兩塊——連線用什麼、檔案怎麼同步。這三塊要分開看，因為它們解的是不同問題，混在一起挑會挑錯：session 掉了是多工器的事，打字延遲高是連線層的事，本地改了遠端沒更新是同步層的事。&lt;/p>
&lt;h2 id="連線層從-ssh-出發按弱點往上補">連線層：從 SSH 出發，按弱點往上補&lt;/h2>
&lt;p>連線層的基準是 SSH——它是遠端登入的通用標準，加密、認證、port forwarding 都靠它，多數情況直接用 SSH 就夠。往上補工具的時機，是 SSH 在特定弱點上讓你難受的時候，而不是「有更潮的工具就換」。SSH 的兩個典型弱點是「網路一換就斷」（筆電休眠、Wi-Fi 換點、行動網路切換）和「連線中斷後要手動重連」，mosh 與 autossh 各補一個。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>解的問題&lt;/th>
 &lt;th>代價&lt;/th>
 &lt;th>何時值得換&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>SSH&lt;/td>
 &lt;td>通用遠端登入基準&lt;/td>
 &lt;td>網路一換 IP 就斷、休眠喚醒常要重連&lt;/td>
 &lt;td>預設就用它&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>mosh&lt;/td>
 &lt;td>漫遊不斷線、高延遲下打字順&lt;/td>
 &lt;td>走 UDP 要開額外 port、不支援 port forwarding&lt;/td>
 &lt;td>行動網路 / Wi-Fi 換點 / 高延遲&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>autossh&lt;/td>
 &lt;td>SSH 斷線自動重連&lt;/td>
 &lt;td>只是重連、session 內容還是靠多工器保住&lt;/td>
 &lt;td>需要一條長期自動維持的隧道&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="mosh換網路不掉線高延遲下還能打字">mosh：換網路不掉線、高延遲下還能打字&lt;/h3>
&lt;p>mosh（mobile shell）解的是「連線的存活與手感」：它在 SSH 之上用 UDP 維持一個跟客戶端 IP 無關的 session，所以你的筆電從家裡 Wi-Fi 換到行動網路、或休眠喚醒換了 IP，連線不會斷。它還做本地回顯預測，高延遲鏈路上打字不會有一個字一個字等回應的黏滯感。從咖啡廳、通勤、跨國高延遲連遠端時，mosh 的體驗明顯優於裸 SSH。&lt;/p>
&lt;p>它的代價是走 UDP，要在遠端開一段 UDP port 範圍（防火牆/雲端 security group 要放行），且不做 SSH 的 port forwarding——需要轉發本地端口時還是得另開一條 SSH。所以 mosh 通常跟多工器搭配用：mosh 保住連線手感、多工器保住 session 內容，兩者互補。&lt;/p>
&lt;h3 id="autossh維持一條會自己重連的隧道">autossh：維持一條會自己重連的隧道&lt;/h3>
&lt;p>autossh 解的是「隧道的自動存活」：它監控一條 SSH 連線，斷了就自動重建，適合需要長期維持的場景——例如把遠端某個服務 port forward 回本地、或維持一條反向隧道讓 NAT 後的機器可被連入。它本身只負責「重連」這個動作，重連後你原本的工作是否還在，取決於你有沒有用多工器把 session 保住。&lt;/p>
&lt;p>判讀：autossh 是「基礎設施型」工具，用在你要一條無人值守、掉了要自己回來的隧道；日常互動式登入用 mosh 的漫遊能力更順。兩者不衝突。&lt;/p>
&lt;h2 id="網路層機器根本連不到時先解可達性">網路層：機器根本連不到時，先解可達性&lt;/h2>
&lt;p>前面的連線工具都假設「遠端機器的 IP 你連得到」。當遠端機器在 NAT 或防火牆後面、沒有公開 IP 時，連不到是可達性問題，要在網路層解，而不是換 SSH 客戶端。WireGuard 是現代的輕量 VPN 協定，讓兩台機器像在同一個私網裡直接互連；Tailscale 建在 WireGuard 之上，把「交換金鑰、打洞穿透 NAT、管理裝置清單」這些麻煩事自動化，通常裝好登入就能讓你的所有裝置互相 SSH，不必自己配 VPN。&lt;/p>
&lt;p>判讀：家裡的機器、公司內網的開發機、雲端私網裡的主機，想從外面連進去又不想開公網 port 暴露 SSH，用 Tailscale（要省事）或自建 WireGuard（要完全自主、不依賴第三方協調伺服器）在網路層打通，之後 SSH/mosh 照常用。這一層跟連線層是疊加關係：先有可達性，上面才談連線手感。（機器連不到的診斷——是網路層、服務層還是機器沒起——見&lt;a href="../../../debug/machine-unreachable/">機器連不到或起不來&lt;/a>。）&lt;/p>
&lt;h2 id="同步層三種語義依-workflow-選">同步層：三種語義，依 workflow 選&lt;/h2>
&lt;p>檔案同步不是一個問題，是三種不同語義的問題，挑錯工具會很痛。核心差異在「同步是單向還是雙向、是一次性快照還是持續即時、檔案存在本地還是遠端」。rsync、sshfs、mutagen 各自代表一種語義：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>語義&lt;/th>
 &lt;th>檔案實際在哪&lt;/th>
 &lt;th>適合的 workflow&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>rsync&lt;/td>
 &lt;td>單向、一次性快照、增量傳輸&lt;/td>
 &lt;td>兩邊各一份&lt;/td>
 &lt;td>部署、備份、把成果拉回來&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>sshfs&lt;/td>
 &lt;td>把遠端目錄掛載成本地路徑&lt;/td>
 &lt;td>只在遠端&lt;/td>
 &lt;td>偶爾存取遠端檔案、當本地資料夾用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>mutagen&lt;/td>
 &lt;td>雙向、持續、即時同步&lt;/td>
 &lt;td>兩邊各一份即時&lt;/td>
 &lt;td>本地編輯、遠端執行的開發迴圈&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="rsync單向增量部署與拉回成果的正典">rsync：單向增量，部署與拉回成果的正典&lt;/h3>
&lt;p>rsync 解的是「把一批檔案有效率地從 A 複製到 B」：它只傳有變動的部分（增量），可保留權限與時間戳，是單向的一次性動作——你下指令、它同步一次、結束。它適合明確的「推上去」或「拉回來」：把本地建好的東西部署到遠端、把遠端跑完的產出拉回本地、定期備份。它不做「持續盯著兩邊、誰改了就同步」，所以拿它當即時開發同步用會很累（每次改都要手動跑）。&lt;/p>
&lt;p>因為單向且明確，rsync 也是三者裡最可預測、最不會意外覆蓋的——你清楚知道哪邊是來源、哪邊被更新。無人值守的成果回收（遠端跑完長任務、把結果 rsync 回本地）用它最穩。&lt;/p>
&lt;h3 id="sshfs把遠端目錄當本地資料夾掛載">sshfs：把遠端目錄當本地資料夾掛載&lt;/h3>
&lt;p>sshfs 解的是「我想用本地的工具存取遠端的檔案、但不想先複製下來」：它透過 SSH 把遠端目錄掛載成本地的一個路徑，你用本地的編輯器、檔案管理員直接開，實際檔案仍只在遠端。適合偶爾存取、檔案不宜落地到本地的場景。&lt;/p>
&lt;p>它的代價是脆與慢：每次存取都走網路，延遲高時開大目錄、跑 &lt;code>git status&lt;/code> 這種大量小檔操作會很卡；連線一斷，掛載點就進入壞狀態要重掛。所以 sshfs 適合「輕度、偶爾」的遠端存取，不適合當重度開發的主力——重度開發本地要有一份真的檔案，那是 mutagen 的場景。&lt;/p>
&lt;h3 id="mutagen雙向即時本地編輯遠端執行的開發迴圈">mutagen：雙向即時，本地編輯遠端執行的開發迴圈&lt;/h3>
&lt;p>mutagen 解的是現代遠端開發最常見的迴圈：「在本地用順手的編輯器改、在遠端（有算力、有環境、有相依）執行」，它在兩邊各保留一份實體檔案並持續雙向即時同步——你本地存檔，遠端幾乎同時更新；遠端產生的檔案也同步回本地。因為兩邊都是本地檔案，&lt;code>git status&lt;/code>、搜尋、建置都快，沒有 sshfs 那種每次存取走網路的黏滯。&lt;/p>
&lt;p>它的代價是多一個常駐同步 daemon 與初次設定，且雙向同步要處理衝突（兩邊同時改同一檔）。適合「本地機器弱/環境不對、但要在強遠端上跑」的長期開發關係。如果你的需求只是「偶爾拉個檔」，mutagen 是殺雞用牛刀，rsync 或 sshfs 更省。&lt;/p>
&lt;h2 id="ide-remote把編輯器的執行環境整個搬到遠端">IDE remote：把編輯器的執行環境整個搬到遠端&lt;/h2>
&lt;p>VS Code Remote（SSH/Containers/WSL）與 JetBrains Gateway 是另一條路線：它們不同步檔案，而是把編輯器的後端（語言伺服器、終端機、除錯器）整個跑在遠端，本地只留 UI。你在本地視窗編輯，但索引、建置、執行全發生在遠端那台機器上，檔案也只在遠端。這解掉了同步的衝突問題（沒有兩份檔案要對齊），代價是綁定該編輯器、且需要一條夠穩的連線維持 UI 與後端的通訊。&lt;/p>
&lt;p>判讀：如果你本來就用 VS Code / JetBrains，remote 模式通常比自己接 mutagen + SSH 更省事、體驗更整合；如果你用終端機編輯器（Vim/Neovim/Emacs）或要編輯器無關的方案，走 mutagen（雙向同步）或直接在遠端多工器裡編輯（檔案只在遠端、靠多工器保住 session）。&lt;/p></description><content:encoded><![CDATA[<p>遠端工作有三塊彼此獨立的能力：<strong>保住 session</strong>（多工器讓遠端的工作不隨連線消失）、<strong>連線層</strong>（決定你怎麼接上遠端、斷了怎麼辦）、<strong>同步層</strong>（決定本地與遠端的檔案怎麼保持一致）。多工器是地基、已在<a href="../">遠端工具總覽</a>談過；這篇補另外兩塊——連線用什麼、檔案怎麼同步。這三塊要分開看，因為它們解的是不同問題，混在一起挑會挑錯：session 掉了是多工器的事，打字延遲高是連線層的事，本地改了遠端沒更新是同步層的事。</p>
<h2 id="連線層從-ssh-出發按弱點往上補">連線層：從 SSH 出發，按弱點往上補</h2>
<p>連線層的基準是 SSH——它是遠端登入的通用標準，加密、認證、port forwarding 都靠它，多數情況直接用 SSH 就夠。往上補工具的時機，是 SSH 在特定弱點上讓你難受的時候，而不是「有更潮的工具就換」。SSH 的兩個典型弱點是「網路一換就斷」（筆電休眠、Wi-Fi 換點、行動網路切換）和「連線中斷後要手動重連」，mosh 與 autossh 各補一個。</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>解的問題</th>
          <th>代價</th>
          <th>何時值得換</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SSH</td>
          <td>通用遠端登入基準</td>
          <td>網路一換 IP 就斷、休眠喚醒常要重連</td>
          <td>預設就用它</td>
      </tr>
      <tr>
          <td>mosh</td>
          <td>漫遊不斷線、高延遲下打字順</td>
          <td>走 UDP 要開額外 port、不支援 port forwarding</td>
          <td>行動網路 / Wi-Fi 換點 / 高延遲</td>
      </tr>
      <tr>
          <td>autossh</td>
          <td>SSH 斷線自動重連</td>
          <td>只是重連、session 內容還是靠多工器保住</td>
          <td>需要一條長期自動維持的隧道</td>
      </tr>
  </tbody>
</table>
<h3 id="mosh換網路不掉線高延遲下還能打字">mosh：換網路不掉線、高延遲下還能打字</h3>
<p>mosh（mobile shell）解的是「連線的存活與手感」：它在 SSH 之上用 UDP 維持一個跟客戶端 IP 無關的 session，所以你的筆電從家裡 Wi-Fi 換到行動網路、或休眠喚醒換了 IP，連線不會斷。它還做本地回顯預測，高延遲鏈路上打字不會有一個字一個字等回應的黏滯感。從咖啡廳、通勤、跨國高延遲連遠端時，mosh 的體驗明顯優於裸 SSH。</p>
<p>它的代價是走 UDP，要在遠端開一段 UDP port 範圍（防火牆/雲端 security group 要放行），且不做 SSH 的 port forwarding——需要轉發本地端口時還是得另開一條 SSH。所以 mosh 通常跟多工器搭配用：mosh 保住連線手感、多工器保住 session 內容，兩者互補。</p>
<h3 id="autossh維持一條會自己重連的隧道">autossh：維持一條會自己重連的隧道</h3>
<p>autossh 解的是「隧道的自動存活」：它監控一條 SSH 連線，斷了就自動重建，適合需要長期維持的場景——例如把遠端某個服務 port forward 回本地、或維持一條反向隧道讓 NAT 後的機器可被連入。它本身只負責「重連」這個動作，重連後你原本的工作是否還在，取決於你有沒有用多工器把 session 保住。</p>
<p>判讀：autossh 是「基礎設施型」工具，用在你要一條無人值守、掉了要自己回來的隧道；日常互動式登入用 mosh 的漫遊能力更順。兩者不衝突。</p>
<h2 id="網路層機器根本連不到時先解可達性">網路層：機器根本連不到時，先解可達性</h2>
<p>前面的連線工具都假設「遠端機器的 IP 你連得到」。當遠端機器在 NAT 或防火牆後面、沒有公開 IP 時，連不到是可達性問題，要在網路層解，而不是換 SSH 客戶端。WireGuard 是現代的輕量 VPN 協定，讓兩台機器像在同一個私網裡直接互連；Tailscale 建在 WireGuard 之上，把「交換金鑰、打洞穿透 NAT、管理裝置清單」這些麻煩事自動化，通常裝好登入就能讓你的所有裝置互相 SSH，不必自己配 VPN。</p>
<p>判讀：家裡的機器、公司內網的開發機、雲端私網裡的主機，想從外面連進去又不想開公網 port 暴露 SSH，用 Tailscale（要省事）或自建 WireGuard（要完全自主、不依賴第三方協調伺服器）在網路層打通，之後 SSH/mosh 照常用。這一層跟連線層是疊加關係：先有可達性，上面才談連線手感。（機器連不到的診斷——是網路層、服務層還是機器沒起——見<a href="../../../debug/machine-unreachable/">機器連不到或起不來</a>。）</p>
<h2 id="同步層三種語義依-workflow-選">同步層：三種語義，依 workflow 選</h2>
<p>檔案同步不是一個問題，是三種不同語義的問題，挑錯工具會很痛。核心差異在「同步是單向還是雙向、是一次性快照還是持續即時、檔案存在本地還是遠端」。rsync、sshfs、mutagen 各自代表一種語義：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>語義</th>
          <th>檔案實際在哪</th>
          <th>適合的 workflow</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>rsync</td>
          <td>單向、一次性快照、增量傳輸</td>
          <td>兩邊各一份</td>
          <td>部署、備份、把成果拉回來</td>
      </tr>
      <tr>
          <td>sshfs</td>
          <td>把遠端目錄掛載成本地路徑</td>
          <td>只在遠端</td>
          <td>偶爾存取遠端檔案、當本地資料夾用</td>
      </tr>
      <tr>
          <td>mutagen</td>
          <td>雙向、持續、即時同步</td>
          <td>兩邊各一份即時</td>
          <td>本地編輯、遠端執行的開發迴圈</td>
      </tr>
  </tbody>
</table>
<h3 id="rsync單向增量部署與拉回成果的正典">rsync：單向增量，部署與拉回成果的正典</h3>
<p>rsync 解的是「把一批檔案有效率地從 A 複製到 B」：它只傳有變動的部分（增量），可保留權限與時間戳，是單向的一次性動作——你下指令、它同步一次、結束。它適合明確的「推上去」或「拉回來」：把本地建好的東西部署到遠端、把遠端跑完的產出拉回本地、定期備份。它不做「持續盯著兩邊、誰改了就同步」，所以拿它當即時開發同步用會很累（每次改都要手動跑）。</p>
<p>因為單向且明確，rsync 也是三者裡最可預測、最不會意外覆蓋的——你清楚知道哪邊是來源、哪邊被更新。無人值守的成果回收（遠端跑完長任務、把結果 rsync 回本地）用它最穩。</p>
<h3 id="sshfs把遠端目錄當本地資料夾掛載">sshfs：把遠端目錄當本地資料夾掛載</h3>
<p>sshfs 解的是「我想用本地的工具存取遠端的檔案、但不想先複製下來」：它透過 SSH 把遠端目錄掛載成本地的一個路徑，你用本地的編輯器、檔案管理員直接開，實際檔案仍只在遠端。適合偶爾存取、檔案不宜落地到本地的場景。</p>
<p>它的代價是脆與慢：每次存取都走網路，延遲高時開大目錄、跑 <code>git status</code> 這種大量小檔操作會很卡；連線一斷，掛載點就進入壞狀態要重掛。所以 sshfs 適合「輕度、偶爾」的遠端存取，不適合當重度開發的主力——重度開發本地要有一份真的檔案，那是 mutagen 的場景。</p>
<h3 id="mutagen雙向即時本地編輯遠端執行的開發迴圈">mutagen：雙向即時，本地編輯遠端執行的開發迴圈</h3>
<p>mutagen 解的是現代遠端開發最常見的迴圈：「在本地用順手的編輯器改、在遠端（有算力、有環境、有相依）執行」，它在兩邊各保留一份實體檔案並持續雙向即時同步——你本地存檔，遠端幾乎同時更新；遠端產生的檔案也同步回本地。因為兩邊都是本地檔案，<code>git status</code>、搜尋、建置都快，沒有 sshfs 那種每次存取走網路的黏滯。</p>
<p>它的代價是多一個常駐同步 daemon 與初次設定，且雙向同步要處理衝突（兩邊同時改同一檔）。適合「本地機器弱/環境不對、但要在強遠端上跑」的長期開發關係。如果你的需求只是「偶爾拉個檔」，mutagen 是殺雞用牛刀，rsync 或 sshfs 更省。</p>
<h2 id="ide-remote把編輯器的執行環境整個搬到遠端">IDE remote：把編輯器的執行環境整個搬到遠端</h2>
<p>VS Code Remote（SSH/Containers/WSL）與 JetBrains Gateway 是另一條路線：它們不同步檔案，而是把編輯器的後端（語言伺服器、終端機、除錯器）整個跑在遠端，本地只留 UI。你在本地視窗編輯，但索引、建置、執行全發生在遠端那台機器上，檔案也只在遠端。這解掉了同步的衝突問題（沒有兩份檔案要對齊），代價是綁定該編輯器、且需要一條夠穩的連線維持 UI 與後端的通訊。</p>
<p>判讀：如果你本來就用 VS Code / JetBrains，remote 模式通常比自己接 mutagen + SSH 更省事、體驗更整合；如果你用終端機編輯器（Vim/Neovim/Emacs）或要編輯器無關的方案，走 mutagen（雙向同步）或直接在遠端多工器裡編輯（檔案只在遠端、靠多工器保住 session）。</p>
<h2 id="在-arch-上的安裝與依賴實測-aarch64">在 Arch 上的安裝與依賴（實測 aarch64）</h2>
<p>這些工具多數在官方 repo，但有幾個安裝陷阱與部署前提是實機才看得出來的，選好工具後要一起確認：</p>
<ul>
<li><strong>mosh</strong>：官方 repo（<code>pacman -S mosh</code>）。同一套件同時含 client（<code>mosh</code>）與 server（<code>mosh-server</code>），所以<strong>遠端機器也要裝 mosh</strong>——遠端是別人的伺服器時這是前提；另外 UDP 埠範圍（預設 60000–61000）要在防火牆 / security group 放行。</li>
<li><strong>autossh</strong> / <strong>rsync</strong> / <strong>sshfs</strong>：都在官方 repo（<code>pacman -S autossh rsync sshfs</code>）。<code>sshfs</code> 會自動拉 <code>fuse3</code> 相依，不用手動裝 FUSE；掛載需要 <code>/dev/fuse</code> 可存取（一般環境已就緒）。</li>
<li><strong>tailscale</strong>：官方 repo（<code>pacman -S tailscale</code>），但<strong>裝完 daemon 預設沒起</strong>——要 <code>sudo systemctl enable --now tailscaled</code> 之後才能 <code>tailscale up</code>。少了這步，<code>tailscale</code> 指令會因 daemon 未運行而失敗。</li>
<li><strong>wireguard</strong>：裝 <code>wireguard-tools</code>（<code>wg</code> / <code>wg-quick</code>）。核心模組在多數發行版是 loadable module，<code>wg-quick</code> 會在需要時自動 <code>modprobe wireguard</code>，日常不用手動處理。</li>
<li><strong>mutagen</strong>：<strong>不在官方 repo</strong>，且 <code>pacman -S mutagen</code> 會裝到完全無關的 <code>python-mutagen</code>（音訊 metadata 函式庫）。正確安裝是 AUR 的 <code>mutagen.io-bin</code>（<code>paru -S mutagen.io-bin</code>），提供 <code>mutagen</code> 執行檔，aarch64 有官方 binary。這是「以為一句 pacman 就有、實際會裝錯東西」的典型坑。</li>
<li><strong>VS Code Remote / JetBrains Gateway</strong>：綁各自的 IDE，不透過套件管理器單獨裝，隨 IDE 的 remote 擴充啟用。</li>
</ul>
<h2 id="依情境選">依情境選</h2>
<p>把上面的工具對回你的實際處境：</p>
<ul>
<li><strong>日常從筆電連遠端、常換網路</strong>：mosh（漫遊）+ 多工器（保 session）。</li>
<li><strong>要一條長期自動維持的隧道 / 反向連接</strong>：autossh + 多工器。</li>
<li><strong>遠端機器在 NAT 後、根本連不到</strong>：先用 Tailscale 或 WireGuard 在網路層打通，再照常 SSH/mosh。</li>
<li><strong>部署上去、或把遠端跑完的成果拉回來</strong>：rsync（單向、可預測）。</li>
<li><strong>偶爾存取遠端幾個檔、不想複製下來</strong>：sshfs（掛載）。</li>
<li><strong>本地編輯、遠端執行的長期開發迴圈</strong>：mutagen（雙向即時）或你的 IDE 的 remote 模式。</li>
<li><strong>無人值守跑長任務、跑完自動回收成果</strong>：多工器保住任務 + rsync 拉回產出，見<a href="../../../install/unattended-remote-work/">讓機器跑無人值守的長任務</a>。</li>
</ul>
<p>判準是先分清「你缺的是連線存活、可達性、還是檔案一致」，再在對應那一層挑工具——三層各挑各的，不要拿同步工具去解連線問題。</p>
<h2 id="下一步">下一步</h2>
<ul>
<li>保住遠端 session 的多工器（tmux / zellij）配置與比較：<a href="../">遠端工具總覽</a> 與 <a href="../../cli/">CLI 環境工具</a> 的多工器篇。</li>
<li>連不上、終端機噴亂碼、要從 SSH 操控圖形桌面等連線本身的問題：<a href="../../../debug/ssh-and-terminal-troubleshooting/">除錯與診斷：遠端連線與終端機問題</a>。</li>
<li>機器完全沒回應、域名解析不了、虛擬機起不來：<a href="../../../debug/machine-unreachable/">機器連不到或起不來</a>。</li>
<li>把遠端機器設成無人值守、離開後自己跑完長任務送回成果：<a href="../../../install/unattended-remote-work/">讓機器跑無人值守的長任務</a>。</li>
</ul>
]]></content:encoded></item><item><title>跨機器同步、Secret 管理與環境重建流程</title><link>https://tarrragon.github.io/blog/linux/dotfile/08-sync-bootstrap/sync-strategy-secret/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/08-sync-bootstrap/sync-strategy-secret/</guid><description>&lt;h2 id="跨機器同步策略">跨機器同步策略&lt;/h2>
&lt;p>多台機器共用 dotfile repo 時，需要一套同步策略來處理「改了配置後怎麼讓其他機器也更新」。&lt;/p>
&lt;h3 id="git-pushpull手動">Git push/pull（手動）&lt;/h3>
&lt;p>最基本的做法：改了就 commit + push，另一台機器 pull + 重新 apply。優點是簡單、沒有額外依賴。缺點是容易忘記——在公司機器上改了一個 alias，回家忘記 push，隔天公司又改了一版，兩邊 diverge。&lt;/p>
&lt;p>適合只有一兩台機器、改動不頻繁的人。&lt;/p>
&lt;h3 id="自動同步">自動同步&lt;/h3>
&lt;p>chezmoi 內建 &lt;code>chezmoi update&lt;/code> 指令（pull + apply 一步完成），搭配 cron 或 systemd timer 定期執行：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># ~/.config/systemd/user/chezmoi-update.timer&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="k">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="na">Description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">Update dotfiles daily&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="k">[Timer]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="na">OnCalendar&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">daily&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="na">Persistent&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="k">[Install]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="na">WantedBy&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">timers.target&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># ~/.config/systemd/user/chezmoi-update.service&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="na">Description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">chezmoi update&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="k">[Service]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="na">Type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">oneshot&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="na">ExecStart&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">/usr/bin/chezmoi update --no-tty&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>自動同步減少手動操作，但要注意衝突處理——如果兩台機器同時改了同一個檔案且都 push，後面那台的自動 pull 會遇到 merge conflict。實務上 dotfile 很少有真正的衝突（兩台機器同時改同一行的機率低），但偶爾發生時需要手動介入。&lt;/p>
&lt;h3 id="機器差異的處理">機器差異的處理&lt;/h3>
&lt;p>推薦的模式是 main branch 放所有共用配置，機器差異用條件判斷處理。&lt;/p>
&lt;p>用 shell 的 OS 判斷：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># ~/.zshrc&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="o">[[&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>uname -s&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;Darwin&amp;#34;&lt;/span> &lt;span class="o">]]&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="k">then&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nb">export&lt;/span> &lt;span class="nv">PATH&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;/opt/homebrew/bin:&lt;/span>&lt;span class="nv">$PATH&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nb">alias&lt;/span> &lt;span class="nv">ls&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ls -G&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="k">else&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nb">alias&lt;/span> &lt;span class="nv">ls&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ls --color=auto&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="k">fi&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用 chezmoi template（Go template 語法）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># chezmoi 管理的 .zshrc.tmpl&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="o">{{&lt;/span> &lt;span class="k">if&lt;/span> eq .chezmoi.os &lt;span class="s2">&amp;#34;darwin&amp;#34;&lt;/span> -&lt;span class="o">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">PATH&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;/opt/homebrew/bin:&lt;/span>&lt;span class="nv">$PATH&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="o">{{&lt;/span> end -&lt;span class="o">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="o">{{&lt;/span> &lt;span class="k">if&lt;/span> eq .chezmoi.hostname &lt;span class="s2">&amp;#34;work-laptop&amp;#34;&lt;/span> -&lt;span class="o">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">HTTP_PROXY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://proxy.corp:8080&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="o">{{&lt;/span> end -&lt;span class="o">}}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>chezmoi template 的優勢是條件判斷發生在 apply 階段，產出的檔案裡看不到 template 語法，乾淨且不依賴 shell 的 runtime 判斷。&lt;/p>
&lt;p>不推薦每台機器一個 branch 的做法。短期可行，長期一定 diverge——main 加了新配置，各 branch 要 rebase 或 merge，忘了就漂移。一份 main + template 條件判斷是長期可維護的結構。&lt;/p>
&lt;h2 id="secret-排除與管理">Secret 排除與管理&lt;/h2>
&lt;p>dotfile repo 通常是 public 或至少多人可見的。以下東西進了 repo 等於把鑰匙掛在門口：&lt;/p>
&lt;ul>
&lt;li>SSH 私鑰（&lt;code>~/.ssh/id_*&lt;/code>、&lt;code>*.pem&lt;/code>）&lt;/li>
&lt;li>API token、password、.env 檔案&lt;/li>
&lt;li>GPG 私鑰&lt;/li>
&lt;li>cloud provider 的 credential 檔案（&lt;code>~/.aws/credentials&lt;/code>、&lt;code>~/.config/gcloud/application_default_credentials.json&lt;/code>）&lt;/li>
&lt;li>browser profile 裡的 cookie / session&lt;/li>
&lt;/ul>
&lt;h3 id="gitignore-是第一道防線">.gitignore 是第一道防線&lt;/h3>





&lt;pre tabindex="0">&lt;code class="language-gitignore" data-lang="gitignore"># SSH 私鑰
*.pem
id_*
known_hosts
authorized_keys

# 環境變數
.env
.env.*

# Cloud credentials
credentials
application_default_credentials.json&lt;/code>&lt;/pre>&lt;p>但 .gitignore 只防「不小心 add」，不防「故意 add -f」。更重要的是建立習慣：repo 裡永遠只放「看到了也沒關係」的東西。&lt;/p>
&lt;h3 id="ssh-config-的特殊處理">SSH config 的特殊處理&lt;/h3>
&lt;p>&lt;code>~/.ssh/config&lt;/code>（host alias、ProxyJump 設定、port forwarding）本身不含 secret，可以進 repo——它記錄的是「連線要怎麼走」而不是「憑證是什麼」。但同一個 &lt;code>~/.ssh/&lt;/code> 目錄下的私鑰絕對排除。&lt;/p></description><content:encoded><![CDATA[<h2 id="跨機器同步策略">跨機器同步策略</h2>
<p>多台機器共用 dotfile repo 時，需要一套同步策略來處理「改了配置後怎麼讓其他機器也更新」。</p>
<h3 id="git-pushpull手動">Git push/pull（手動）</h3>
<p>最基本的做法：改了就 commit + push，另一台機器 pull + 重新 apply。優點是簡單、沒有額外依賴。缺點是容易忘記——在公司機器上改了一個 alias，回家忘記 push，隔天公司又改了一版，兩邊 diverge。</p>
<p>適合只有一兩台機器、改動不頻繁的人。</p>
<h3 id="自動同步">自動同步</h3>
<p>chezmoi 內建 <code>chezmoi update</code> 指令（pull + apply 一步完成），搭配 cron 或 systemd timer 定期執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># ~/.config/systemd/user/chezmoi-update.timer</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Update dotfiles daily</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">[Timer]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">daily</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">Persistent</span><span class="o">=</span><span class="s">true</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">timers.target</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># ~/.config/systemd/user/chezmoi-update.service</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">chezmoi update</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/chezmoi update --no-tty</span></span></span></code></pre></div><p>自動同步減少手動操作，但要注意衝突處理——如果兩台機器同時改了同一個檔案且都 push，後面那台的自動 pull 會遇到 merge conflict。實務上 dotfile 很少有真正的衝突（兩台機器同時改同一行的機率低），但偶爾發生時需要手動介入。</p>
<h3 id="機器差異的處理">機器差異的處理</h3>
<p>推薦的模式是 main branch 放所有共用配置，機器差異用條件判斷處理。</p>
<p>用 shell 的 OS 判斷：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># ~/.zshrc</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="k">$(</span>uname -s<span class="k">)</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;Darwin&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nb">export</span> <span class="nv">PATH</span><span class="o">=</span><span class="s2">&#34;/opt/homebrew/bin:</span><span class="nv">$PATH</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nb">alias</span> <span class="nv">ls</span><span class="o">=</span><span class="s2">&#34;ls -G&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nb">alias</span> <span class="nv">ls</span><span class="o">=</span><span class="s2">&#34;ls --color=auto&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="k">fi</span></span></span></code></pre></div><p>用 chezmoi template（Go template 語法）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># chezmoi 管理的 .zshrc.tmpl</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="o">{{</span> <span class="k">if</span> eq .chezmoi.os <span class="s2">&#34;darwin&#34;</span> -<span class="o">}}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">export</span> <span class="nv">PATH</span><span class="o">=</span><span class="s2">&#34;/opt/homebrew/bin:</span><span class="nv">$PATH</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="o">{{</span> end -<span class="o">}}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="o">{{</span> <span class="k">if</span> eq .chezmoi.hostname <span class="s2">&#34;work-laptop&#34;</span> -<span class="o">}}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nb">export</span> <span class="nv">HTTP_PROXY</span><span class="o">=</span><span class="s2">&#34;http://proxy.corp:8080&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="o">{{</span> end -<span class="o">}}</span></span></span></code></pre></div><p>chezmoi template 的優勢是條件判斷發生在 apply 階段，產出的檔案裡看不到 template 語法，乾淨且不依賴 shell 的 runtime 判斷。</p>
<p>不推薦每台機器一個 branch 的做法。短期可行，長期一定 diverge——main 加了新配置，各 branch 要 rebase 或 merge，忘了就漂移。一份 main + template 條件判斷是長期可維護的結構。</p>
<h2 id="secret-排除與管理">Secret 排除與管理</h2>
<p>dotfile repo 通常是 public 或至少多人可見的。以下東西進了 repo 等於把鑰匙掛在門口：</p>
<ul>
<li>SSH 私鑰（<code>~/.ssh/id_*</code>、<code>*.pem</code>）</li>
<li>API token、password、.env 檔案</li>
<li>GPG 私鑰</li>
<li>cloud provider 的 credential 檔案（<code>~/.aws/credentials</code>、<code>~/.config/gcloud/application_default_credentials.json</code>）</li>
<li>browser profile 裡的 cookie / session</li>
</ul>
<h3 id="gitignore-是第一道防線">.gitignore 是第一道防線</h3>





<pre tabindex="0"><code class="language-gitignore" data-lang="gitignore"># SSH 私鑰
*.pem
id_*
known_hosts
authorized_keys

# 環境變數
.env
.env.*

# Cloud credentials
credentials
application_default_credentials.json</code></pre><p>但 .gitignore 只防「不小心 add」，不防「故意 add -f」。更重要的是建立習慣：repo 裡永遠只放「看到了也沒關係」的東西。</p>
<h3 id="ssh-config-的特殊處理">SSH config 的特殊處理</h3>
<p><code>~/.ssh/config</code>（host alias、ProxyJump 設定、port forwarding）本身不含 secret，可以進 repo——它記錄的是「連線要怎麼走」而不是「憑證是什麼」。但同一個 <code>~/.ssh/</code> 目錄下的私鑰絕對排除。</p>
<p>stow 管理時的目錄結構範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">dotfiles/
</span></span><span class="line"><span class="ln">2</span><span class="cl">└── ssh/
</span></span><span class="line"><span class="ln">3</span><span class="cl">    └── .ssh/
</span></span><span class="line"><span class="ln">4</span><span class="cl">        └── config        # 進 repo
</span></span><span class="line"><span class="ln">5</span><span class="cl">        # id_rsa 不放這裡
</span></span><span class="line"><span class="ln">6</span><span class="cl">        # known_hosts 不放這裡</span></span></code></pre></div><h3 id="三個層級的-secret-管理">三個層級的 secret 管理</h3>
<p><strong>層級一：手動</strong>。.gitignore 排除 secret 檔案，在 README 記錄「這些東西需要在新機器手動設定」。最低成本、對只有一兩台機器的人足夠。</p>
<p><strong>層級二：密碼管理器整合</strong>。chezmoi 支援從 1Password、Bitwarden、pass（Unix password manager）等拉取 secret：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl"># chezmoi template 語法
</span></span><span class="line"><span class="ln">2</span><span class="cl">{{ (onepasswordRead &#34;op://Personal/SSH Key/private key&#34;).value }}</span></span></code></pre></div><p>配置檔的 template 裡引用密碼管理器的條目，apply 時自動填入。secret 不在 repo 裡，但 repo 知道去哪拉。</p>
<p><strong>層級三：加密存放</strong>。用 age 或 sops 把 secret 加密後直接存在 repo 裡。解密需要對應的 key。chezmoi 原生支援 age 加密：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 加密一個檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">chezmoi add --encrypt ~/.ssh/id_rsa
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># repo 裡看到的是加密後的內容</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">cat ~/.local/share/chezmoi/private_dot_ssh/id_rsa.age</span></span></code></pre></div><p>加密存放的好處是 secret 跟著 repo 走、不用另外設密碼管理器。風險是加密 key 本身變成唯一的依賴——丟了 key，加密的 secret 就拿不回來。</p>
<p>層級選擇取決於安全需求和便利需求的平衡。多數情況從層級一開始，覺得手動處理太煩再往上升級。</p>
<h2 id="環境重建的實際流程">環境重建的實際流程</h2>
<p>假設拿到一台全新的 Arch Linux 機器，要從零重建完整的 Hyprland 桌面環境。以下是 end-to-end 的步驟，對應 <a href="/blog/linux/dotfile/08-sync-bootstrap/bootstrap-script-packages/" data-link-title="Bootstrap Script 與套件清單管理" data-link-desc="寫 dotfile 的 install script、或整理「這台機器裝了什麼」的套件清單時回來讀">bootstrap script</a> 的每個階段。</p>
<h3 id="階段一最小可用環境">階段一：最小可用環境</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Arch 安裝完成後，base system 只有 bash 和基本工具</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo pacman -S git base-devel</span></span></code></pre></div><p>這是 bootstrap script 的唯一外部前提：有 Git 能 clone repo、有 base-devel 能編譯 AUR 套件。其他一切由 script 處理。</p>
<h3 id="階段二取得-dotfile-repo">階段二：取得 dotfile repo</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git clone https://github.com/you/dotfiles ~/dotfiles
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">cd</span> ~/dotfiles</span></span></code></pre></div><p>如果 repo 是 private，這一步需要先設定 SSH key 或用 HTTPS + token。這是前面提到的 secret 雞生蛋問題——clone 含有 SSH config 的 repo 本身就需要 SSH key。解法通常是：第一次用 HTTPS clone，deploy 完 SSH config 後把 remote 改成 SSH。</p>
<h3 id="階段三執行-bootstrap">階段三：執行 bootstrap</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">./scripts/install.sh</span></span></code></pre></div><p>script 依序：安裝套件（Hyprland、waybar、rofi、wezterm、zsh、neovim、stow 等）、用 stow 部署配置到 <code>$HOME</code>、執行初始化（換 shell、安裝 neovim plugin）。</p>
<h3 id="階段四手動處理">階段四：手動處理</h3>
<p>bootstrap 處理不了（或不該處理）的部分：</p>
<ul>
<li><strong>SSH 私鑰</strong>：從備份或密碼管理器取回，放到 <code>~/.ssh/</code>，設定正確權限（<code>chmod 600</code>）</li>
<li><strong>Git 簽署用的 GPG key</strong>：如果有用 commit signing</li>
<li><strong>密碼管理器登入</strong>：如果 secret 管理用了層級二或三</li>
</ul>
<h3 id="階段五硬體相關調整">階段五：硬體相關調整</h3>
<p>Hyprland 的 monitor 設定（解析度、縮放、排列位置）跟實際接的螢幕有關，這部分配置每台機器都不同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># ~/.config/hypr/hyprland.conf 的 monitor 段</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 這幾行在每台機器上都要調</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">monitor</span><span class="o">=</span><span class="s">DP-1, 2560x1440@144, 0x0, 1</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">monitor</span><span class="o">=</span><span class="s">HDMI-A-1, 1920x1080@60, 2560x0, 1</span></span></span></code></pre></div><p>處理方式有兩種：把 monitor 設定拆成獨立的 <code>monitor.conf</code>，主配置用 <code>source</code> 引入，<code>monitor.conf</code> 不進 repo（加進 .gitignore）、每台機器本地維護；或者用 chezmoi template 按 hostname 判斷。</p>
<p>顯卡驅動（Intel/AMD 通常自動、NVIDIA 需要額外安裝 <code>nvidia-dkms</code> 和設定環境變數）也是硬體相關的步驟，可以放在 bootstrap script 的 OS 判斷裡，但通常 Arch 安裝階段就已經處理。</p>
<h3 id="階段六驗證">階段六：驗證</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 登出 TTY，重新用 Hyprland 登入</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 或者直接在 TTY 執行</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">Hyprland</span></span></code></pre></div><p>登入後確認：視窗管理器正常運作、keybind 正確、狀態列出現、字型正確渲染、終端機配色正常。如果某個元件沒反應，通常是套件沒裝或配置路徑不對——回去檢查 bootstrap 的套件清單和 stow 的 symlink。</p>
<h3 id="時間預估">時間預估</h3>
<p>整個流程在網路順暢的情況下，大約 30 分鐘到 1 小時，取決於套件數量和下載速度。主要時間花在套件安裝（pacman 下載 + 編譯 AUR 套件）。配置 deploy 本身是秒級操作（stow 只建 symlink）。</p>
<p>對比沒有 dotfile 管理時的重建：邊想邊裝、裝了忘記某個工具的名稱、配置靠記憶手打、兩天後還在調某個快捷鍵為什麼不對。差距在「可預期 vs 碰運氣」。</p>
<h2 id="維護節奏">維護節奏</h2>
<p>環境重建能力需要持續維護，不是設定完就一勞永逸。</p>
<p>日常習慣：新裝一個工具時，順手更新套件清單（<code>brew bundle dump</code> 或手動加一行到 <code>packages.txt</code>）。改了一個配置後，commit + push。這個習慣的建立成本低，但需要刻意練幾週才會變成反射動作。</p>
<p>定期檢查：每隔幾個月在 VM 或 container 裡跑一次完整的 bootstrap，驗證 script 還能從零跑通。配置會演進、套件會改名或被取代、script 裡硬寫的路徑可能失效——定期驗證才能確保「這份重建指令真的能重建」，而不是一份過期的紀錄。</p>
]]></content:encoded></item><item><title>模組八：同步、Bootstrap 與環境重建</title><link>https://tarrragon.github.io/blog/linux/dotfile/08-sync-bootstrap/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/08-sync-bootstrap/</guid><description>&lt;p>環境重建是 dotfile 管理的最終目的：拿到一台空白機器，能在可預期的時間內還原成你熟悉的工作環境。這件事有兩條根本不同的路線——「拍照」（VM 快照）和「重建指令」（dotfile + install script），選哪條決定了你之後所有的管理策略。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/08-sync-bootstrap/snapshot-vs-rebuild/" data-link-title="拍照 vs 重建指令：環境重建的兩種思路" data-link-desc="猶豫該用 VM 快照還是 dotfile 重建來管理環境時回來讀">拍照 vs 重建指令：環境重建的兩種思路&lt;/a>&lt;/td>
 &lt;td>VM 快照和 dotfile 重建的本質差異、各自的守備範圍與場景判讀&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/08-sync-bootstrap/bootstrap-script-packages/" data-link-title="Bootstrap Script 與套件清單管理" data-link-desc="寫 dotfile 的 install script、或整理「這台機器裝了什麼」的套件清單時回來讀">Bootstrap Script 與套件清單管理&lt;/a>&lt;/td>
 &lt;td>install script 的冪等性設計、OS 分流、Brewfile / packages.txt 套件清單管理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/08-sync-bootstrap/sync-strategy-secret/" data-link-title="跨機器同步、Secret 管理與環境重建流程" data-link-desc="多台機器的 dotfile 怎麼同步、哪些東西不該進 repo 時回來讀">跨機器同步、Secret 管理與環境重建流程&lt;/a>&lt;/td>
 &lt;td>Git push/pull vs 自動同步、secret 三層級管理、從空白機器到完整工作環境的 end-to-end（範例用 Arch + Hyprland，macOS 同樣適用）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/01-dotfile-management/" data-link-title="模組一：管理工具與目錄結構" data-link-desc="要把散落在家目錄的配置檔集中版控時，選 bare repo、stow 還是 chezmoi、目錄該怎麼組織">模組一：管理工具與目錄結構&lt;/a>：stow / chezmoi 選型與跨平台三層模型&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/05-hyprland-config/" data-link-title="模組五：Hyprland 配置" data-link-desc="要在 Linux 上設定 Hyprland 平鋪式桌面時回來讀">模組五：Hyprland 配置&lt;/a>：環境重建流程裡 monitor 設定的硬體相關調整&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/09-team-environment/" data-link-title="模組九：從個人到團隊" data-link-desc="個人 dotfile 管理的思想要延伸到團隊開發環境標準化時回來讀 — devcontainer、nix、商業環境配置管理">模組九：從個人到團隊&lt;/a>：個人 bootstrap 的思想怎麼延伸到團隊 onboarding&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>環境重建是 dotfile 管理的最終目的：拿到一台空白機器，能在可預期的時間內還原成你熟悉的工作環境。這件事有兩條根本不同的路線——「拍照」（VM 快照）和「重建指令」（dotfile + install script），選哪條決定了你之後所有的管理策略。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/linux/dotfile/08-sync-bootstrap/snapshot-vs-rebuild/" data-link-title="拍照 vs 重建指令：環境重建的兩種思路" data-link-desc="猶豫該用 VM 快照還是 dotfile 重建來管理環境時回來讀">拍照 vs 重建指令：環境重建的兩種思路</a></td>
          <td>VM 快照和 dotfile 重建的本質差異、各自的守備範圍與場景判讀</td>
      </tr>
      <tr>
          <td><a href="/blog/linux/dotfile/08-sync-bootstrap/bootstrap-script-packages/" data-link-title="Bootstrap Script 與套件清單管理" data-link-desc="寫 dotfile 的 install script、或整理「這台機器裝了什麼」的套件清單時回來讀">Bootstrap Script 與套件清單管理</a></td>
          <td>install script 的冪等性設計、OS 分流、Brewfile / packages.txt 套件清單管理</td>
      </tr>
      <tr>
          <td><a href="/blog/linux/dotfile/08-sync-bootstrap/sync-strategy-secret/" data-link-title="跨機器同步、Secret 管理與環境重建流程" data-link-desc="多台機器的 dotfile 怎麼同步、哪些東西不該進 repo 時回來讀">跨機器同步、Secret 管理與環境重建流程</a></td>
          <td>Git push/pull vs 自動同步、secret 三層級管理、從空白機器到完整工作環境的 end-to-end（範例用 Arch + Hyprland，macOS 同樣適用）</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/linux/dotfile/01-dotfile-management/" data-link-title="模組一：管理工具與目錄結構" data-link-desc="要把散落在家目錄的配置檔集中版控時，選 bare repo、stow 還是 chezmoi、目錄該怎麼組織">模組一：管理工具與目錄結構</a>：stow / chezmoi 選型與跨平台三層模型</li>
<li>→ <a href="/blog/linux/dotfile/05-hyprland-config/" data-link-title="模組五：Hyprland 配置" data-link-desc="要在 Linux 上設定 Hyprland 平鋪式桌面時回來讀">模組五：Hyprland 配置</a>：環境重建流程裡 monitor 設定的硬體相關調整</li>
<li>→ <a href="/blog/linux/dotfile/09-team-environment/" data-link-title="模組九：從個人到團隊" data-link-desc="個人 dotfile 管理的思想要延伸到團隊開發環境標準化時回來讀 — devcontainer、nix、商業環境配置管理">模組九：從個人到團隊</a>：個人 bootstrap 的思想怎麼延伸到團隊 onboarding</li>
</ul>
]]></content:encoded></item><item><title>SQLite Local-first Sync Boundary</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/local-first-sync-boundary/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/local-first-sync-boundary/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合 local-first / offline-first 場景；本文聚焦 &lt;em>SQLite local store 與 multi-device sync protocol 的責任分界&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;p>SQLite local-first sync boundary 的核心責任是把「本機可用」和「多端一致」分成兩個問題。SQLite 很適合保存 device-local state；但它不提供 identity、transport、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/conflict-resolution/" data-link-title="Conflict Resolution" data-link-desc="說明並發或離線寫入產生衝突時，如何偵測、呈現與合併成可接受狀態">conflict resolution&lt;/a>、delete propagation、server authority 或 audit trail。當資料要跨裝置、跨使用者或跨服務同步時，SQLite 只是 local replica / working copy。&lt;/p>
&lt;p>本文的判讀錨點是：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/local-first/" data-link-title="Local-First" data-link-desc="說明本機優先的資料架構如何讓離線可用，並把同步當成獨立問題">local-first&lt;/a> 的產品價值來自離線可用，工程成本來自同步語意。SQLite 解的是 local durability；sync layer 解的是資料合併、順序、權威來源與錯誤修復。&lt;/p>
&lt;h2 id="local-state-taxonomy">Local state taxonomy&lt;/h2>
&lt;p>Local-first 設計的第一步是標記本機資料角色。不同資料角色對 sync、backup、conflict 與 delete 的要求不同。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>資料角色&lt;/th>
 &lt;th>例子&lt;/th>
 &lt;th>Sync 語意&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Local cache&lt;/td>
 &lt;td>API response cache、thumbnail metadata&lt;/td>
 &lt;td>可清除、可重抓&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Draft / working copy&lt;/td>
 &lt;td>草稿、離線表單、未送出 action&lt;/td>
 &lt;td>需要 upload / retry / conflict handling&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Local source of truth&lt;/td>
 &lt;td>單裝置日記、CLI state&lt;/td>
 &lt;td>需要 backup / export，可能不需要 server&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Local replica&lt;/td>
 &lt;td>server record 的本地副本&lt;/td>
 &lt;td>server authority、stale read、sync lag&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sync queue&lt;/td>
 &lt;td>pending mutation / event log&lt;/td>
 &lt;td>ordering、idempotency、replay&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的重點是資料角色先於 sync 工具。若所有資料都只是 cache，SQLite + TTL 足夠；若有 pending mutation 或 multi-device edit，就需要 sync protocol。&lt;/p>
&lt;h2 id="authority-boundary">Authority boundary&lt;/h2>
&lt;p>Authority boundary 的核心責任是決定衝突時誰說了算。Local-first app 可以讓 device、server、field-level merge 或 CRDT 成為不同層的 authority；SQLite 本身只保存狀態，不替系統決策。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Authority model&lt;/th>
 &lt;th>適合情境&lt;/th>
 &lt;th>代價&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Server authority&lt;/td>
 &lt;td>帳務、權限、共享資料&lt;/td>
 &lt;td>離線寫入要排隊，回線後可能被拒絕&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Device authority&lt;/td>
 &lt;td>單使用者、單裝置資料&lt;/td>
 &lt;td>多裝置同步能力弱&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Last-write-wins&lt;/td>
 &lt;td>低價值設定、簡單 preference&lt;/td>
 &lt;td>資料覆蓋風險&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Field merge&lt;/td>
 &lt;td>profile、表單、可分欄位資料&lt;/td>
 &lt;td>merge rule 要測，使用者理解成本上升&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CRDT / operation log&lt;/td>
 &lt;td>協作編輯、順序敏感操作&lt;/td>
 &lt;td>實作與除錯成本高&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Authority model 要和 product semantics 對齊。庫存、付款、權限這類資料通常需要 server authority；notes、draft、local settings 可以接受更偏 local 的權威模型。&lt;/p>
&lt;h2 id="sync-transport-與-local-log">Sync transport 與 local log&lt;/h2>
&lt;p>Sync transport 的核心責任是把 SQLite local state 轉成可重送、可去重、可驗證的資料流。最常見做法是本地維護 pending mutation table 或 change log，再由 background sync worker 送到 server。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite</a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合 local-first / offline-first 場景；本文聚焦 <em>SQLite local store 與 multi-device sync protocol 的責任分界</em>。</p></blockquote>
<p>SQLite local-first sync boundary 的核心責任是把「本機可用」和「多端一致」分成兩個問題。SQLite 很適合保存 device-local state；但它不提供 identity、transport、<a href="/blog/backend/knowledge-cards/conflict-resolution/" data-link-title="Conflict Resolution" data-link-desc="說明並發或離線寫入產生衝突時，如何偵測、呈現與合併成可接受狀態">conflict resolution</a>、delete propagation、server authority 或 audit trail。當資料要跨裝置、跨使用者或跨服務同步時，SQLite 只是 local replica / working copy。</p>
<p>本文的判讀錨點是：<a href="/blog/backend/knowledge-cards/local-first/" data-link-title="Local-First" data-link-desc="說明本機優先的資料架構如何讓離線可用，並把同步當成獨立問題">local-first</a> 的產品價值來自離線可用，工程成本來自同步語意。SQLite 解的是 local durability；sync layer 解的是資料合併、順序、權威來源與錯誤修復。</p>
<h2 id="local-state-taxonomy">Local state taxonomy</h2>
<p>Local-first 設計的第一步是標記本機資料角色。不同資料角色對 sync、backup、conflict 與 delete 的要求不同。</p>
<table>
  <thead>
      <tr>
          <th>資料角色</th>
          <th>例子</th>
          <th>Sync 語意</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Local cache</td>
          <td>API response cache、thumbnail metadata</td>
          <td>可清除、可重抓</td>
      </tr>
      <tr>
          <td>Draft / working copy</td>
          <td>草稿、離線表單、未送出 action</td>
          <td>需要 upload / retry / conflict handling</td>
      </tr>
      <tr>
          <td>Local source of truth</td>
          <td>單裝置日記、CLI state</td>
          <td>需要 backup / export，可能不需要 server</td>
      </tr>
      <tr>
          <td>Local replica</td>
          <td>server record 的本地副本</td>
          <td>server authority、stale read、sync lag</td>
      </tr>
      <tr>
          <td>Sync queue</td>
          <td>pending mutation / event log</td>
          <td>ordering、idempotency、replay</td>
      </tr>
  </tbody>
</table>
<p>這張表的重點是資料角色先於 sync 工具。若所有資料都只是 cache，SQLite + TTL 足夠；若有 pending mutation 或 multi-device edit，就需要 sync protocol。</p>
<h2 id="authority-boundary">Authority boundary</h2>
<p>Authority boundary 的核心責任是決定衝突時誰說了算。Local-first app 可以讓 device、server、field-level merge 或 CRDT 成為不同層的 authority；SQLite 本身只保存狀態，不替系統決策。</p>
<table>
  <thead>
      <tr>
          <th>Authority model</th>
          <th>適合情境</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Server authority</td>
          <td>帳務、權限、共享資料</td>
          <td>離線寫入要排隊，回線後可能被拒絕</td>
      </tr>
      <tr>
          <td>Device authority</td>
          <td>單使用者、單裝置資料</td>
          <td>多裝置同步能力弱</td>
      </tr>
      <tr>
          <td>Last-write-wins</td>
          <td>低價值設定、簡單 preference</td>
          <td>資料覆蓋風險</td>
      </tr>
      <tr>
          <td>Field merge</td>
          <td>profile、表單、可分欄位資料</td>
          <td>merge rule 要測，使用者理解成本上升</td>
      </tr>
      <tr>
          <td>CRDT / operation log</td>
          <td>協作編輯、順序敏感操作</td>
          <td>實作與除錯成本高</td>
      </tr>
  </tbody>
</table>
<p>Authority model 要和 product semantics 對齊。庫存、付款、權限這類資料通常需要 server authority；notes、draft、local settings 可以接受更偏 local 的權威模型。</p>
<h2 id="sync-transport-與-local-log">Sync transport 與 local log</h2>
<p>Sync transport 的核心責任是把 SQLite local state 轉成可重送、可去重、可驗證的資料流。最常見做法是本地維護 pending mutation table 或 change log，再由 background sync worker 送到 server。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">pending_mutations</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="n">id</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="n">entity_type</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="n">entity_id</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="k">operation</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="n">payload</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="n">created_at</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="n">retry_count</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="n">last_error</span><span class="w"> </span><span class="nb">TEXT</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>設計點</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>idempotency</td>
          <td>每個 mutation 需要穩定 id，避免重送副作用</td>
      </tr>
      <tr>
          <td>ordering</td>
          <td>同 entity 操作是否必須按順序</td>
      </tr>
      <tr>
          <td>retry</td>
          <td>transient failure、backoff、dead-letter</td>
      </tr>
      <tr>
          <td>compaction</td>
          <td>已同步 local log 何時清除</td>
      </tr>
      <tr>
          <td>reconciliation</td>
          <td>server / local 差異如何修復</td>
      </tr>
  </tbody>
</table>
<p>這裡和 backend queue 概念相通：pending mutation table 是本機版 durable queue。它需要 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a>、retry 與 replay 思維，而不只是「存一張表」。</p>
<h2 id="conflict-resolution">Conflict resolution</h2>
<p>Conflict resolution 的核心責任是讓兩個合法 local write 合併成可接受狀態。SQLite 可以保存 local write；sync layer 要決定衝突偵測、呈現與合併。</p>
<table>
  <thead>
      <tr>
          <th>衝突型態</th>
          <th>例子</th>
          <th>處理策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Same field update</td>
          <td>兩台裝置改同一個 display name</td>
          <td>LWW、server reject、manual merge</td>
      </tr>
      <tr>
          <td>Disjoint field update</td>
          <td>一台改 phone，一台改 address</td>
          <td>field merge</td>
      </tr>
      <tr>
          <td>Delete vs update</td>
          <td>一台刪除，一台修改</td>
          <td>tombstone、manual review</td>
      </tr>
      <tr>
          <td>Ordered operation</td>
          <td>task reorder、ledger append</td>
          <td>operation log、server sequence</td>
      </tr>
  </tbody>
</table>
<p>Conflict policy 要在資料模型設計時決定。等衝突發生後才補策略，通常會導致資料修復、客服流程與 audit evidence 同時缺位。</p>
<h2 id="delete-propagation-與-privacy">Delete propagation 與 privacy</h2>
<p>Delete propagation 的核心責任是讓 server、device、backup 與 sync queue 對「刪除」有一致語意。Local-first app 常見風險是 server 已刪，但 device local DB、pending queue 或 OS backup 還留著資料。</p>
<table>
  <thead>
      <tr>
          <th>刪除語意</th>
          <th>適合情境</th>
          <th>SQLite 設計</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Soft delete</td>
          <td>可恢復、需要 sync tombstone</td>
          <td><code>deleted_at</code>、sync tombstone、retention job</td>
      </tr>
      <tr>
          <td>Hard delete</td>
          <td>privacy / compliance</td>
          <td>local purge、backup exclusion、sync confirmation</td>
      </tr>
      <tr>
          <td>Redaction</td>
          <td>support bundle / log</td>
          <td>export 時遮罩 sensitive fields</td>
      </tr>
  </tbody>
</table>
<p>刪除在同步系統裡是一個跨裝置生命週期。若資料跨裝置同步，delete 需要 <a href="/blog/backend/knowledge-cards/tombstone/" data-link-title="Tombstone" data-link-desc="說明刪除如何用一筆標記記錄下來，讓刪除事件能跨副本與裝置傳播">tombstone</a>、ack、retry、backup retention 與 evidence；這些責任要接到 <a href="/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">Data Protection</a>。</p>
<h2 id="production-踩雷">Production 踩雷</h2>
<h3 id="case-1pending-mutation-沒有-idempotency-key">Case 1：pending mutation 沒有 idempotency key</h3>
<p>Pending mutation 沒有 idempotency key 的核心風險是重送造成重複副作用。網路 timeout 後 worker 重送，server 已經處理第一次請求，第二次又建立一筆資料或扣一次庫存。</p>
<p>修正方向是每個 mutation 生成 stable id，server 以 idempotency key 去重，local SQLite 保存 retry state 與 server ack。</p>
<h3 id="case-2lww-覆蓋使用者資料">Case 2：LWW 覆蓋使用者資料</h3>
<p>Last-write-wins 的核心風險是把衝突靜默變成資料遺失。Preference 類資料可接受；草稿、文件、表單、付款資料通常需要更清楚的 conflict handling。</p>
<p>修正方向是依資料價值分層。低價值設定用 LWW；高價值內容用 field merge、manual conflict 或 operation log。</p>
<h3 id="case-3delete-沒傳到離線裝置">Case 3：delete 沒傳到離線裝置</h3>
<p>Delete propagation 失敗的核心風險是 privacy / compliance 失效。使用者刪除 server 資料後，一台長期離線裝置重新上線又把舊資料同步回來。</p>
<p>修正方向是 tombstone + server authority。Server 要能拒絕過期 mutation，device 要能接收 delete tombstone 並 purge local state。</p>
<h2 id="操作檢查清單">操作檢查清單</h2>
<p>Local-first SQLite 設計要回答：</p>
<ol>
<li>哪些 table 是 local source of truth，哪些是 server replica。</li>
<li>Pending mutation 是否有 idempotency key 與 retry state。</li>
<li>Conflict policy 是 LWW、field merge、manual merge 還是 operation log。</li>
<li>Delete 是否有 tombstone、ack 與 local purge。</li>
<li>Sync worker 是否有 backoff、dead-letter、reconciliation。</li>
<li>Device backup 是否會保存已刪資料。</li>
<li>Server 是否能拒絕過期 local write。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/vendors/sqlite/mobile-desktop-embedded-store/" data-link-title="SQLite Mobile / Desktop Embedded Store" data-link-desc="SQLite 在 mobile、desktop、CLI、browser profile 與 embedded device 中承擔 local formal state 的資料責任、backup、privacy 與 sync boundary">Mobile / Desktop Embedded Store</a></li>
<li>Sibling：<a href="/blog/backend/01-database/vendors/sqlite/migrate-to-d1-turso/" data-link-title="SQLite to D1 / Turso Migration" data-link-desc="SQLite 轉向 Cloudflare D1、Turso / libSQL 的 edge driver、compatibility audit、data movement 與 rollback">SQLite to D1 / Turso</a>、<a href="/blog/backend/01-database/vendors/sqlite/d1-turso-libsql-comparison/" data-link-title="SQLite D1 / Turso / libSQL Comparison" data-link-desc="Cloudflare D1、Turso、libSQL 與 local SQLite 在 edge、replication、consistency、migration 與 vendor boundary 的比較">D1 / Turso / libSQL Comparison</a></li>
<li>卡片：<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">Idempotency</a>、<a href="/blog/backend/knowledge-cards/eventual-consistency/" data-link-title="Eventual Consistency" data-link-desc="允許短暫不一致、最終收斂到同一資料狀態的一致性語意">Eventual Consistency</a>、<a href="/blog/backend/knowledge-cards/stale-read/" data-link-title="Stale Read" data-link-desc="讀取到落後於最新寫入版本的舊資料">Stale Read</a></li>
<li>跨模組：<a href="/blog/backend/07-security-data-protection/data-protection-and-masking-governance/" data-link-title="7.4 資料保護與遮罩治理" data-link-desc="以問題驅動方式整理資料分級、遮罩、匯出與備份治理">Data Protection</a></li>
</ul>
]]></content:encoded></item></channel></rss>