<?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>Restic on Tarragon</title><link>https://tarrragon.github.io/blog/tags/restic/</link><description>Recent content in Restic on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 03 Nov 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/restic/index.xml" rel="self" type="application/rss+xml"/><item><title>7.45 加密備份的密碼、key 檔與主金鑰：換密碼與換主金鑰的差別、密碼外洩與遺失的處置，以 restic 為例</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/backup-encryption-password-and-master-key/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/backup-encryption-password-and-master-key/</guid><description>&lt;p>這一章整理加密備份工具的密碼與金鑰結構，以 restic 為例。restic 把備份加密之後存進一個 repository（restic 存放加密備份的位置，可以是本機資料夾、NAS 或物件儲存），而 repository 裡跟密碼有關的有兩樣：加密資料用的主金鑰，以及 key 檔（repository 的 &lt;code>keys/&lt;/code> 目錄底下、用密碼包著主金鑰的檔案）。各節依序是：資料、主金鑰、key 檔與密碼各自的角色，以及一個 repository 為什麼可以有好幾組密碼；換密碼時 repository 裡實際改了什麼、沒改什麼；換密碼之後，之前複製出去的副本與已經外流的 key 檔還解得開哪些資料；什麼條件下只換密碼就足夠、什麼條件下要建一個新的 repository 把主金鑰也換掉，以及這兩條路各自的代價；最後是密碼遺失的後果與事先能做的準備。restic 在 Docker Compose 裡以 sidecar 容器（跟主服務並排、在同一個 Compose 專案裡執行的輔助容器）執行時，換密碼的實際步驟、依 exit code 判斷 repository 狀態的啟動腳本、外洩之後搬到新 repository 與唯讀還原演練，在 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/hands-on/restic-sidecar-password-operations/" data-link-title="restic sidecar 容器的密碼操作：啟動腳本依 exit code 判斷、換密碼、外洩後搬到新 repository 與唯讀還原演練" data-link-desc="restic 以 Docker Compose 的 sidecar 容器做自架備份、要換密碼或密碼可能外洩時：啟動腳本怎麼分辨 repository 不存在與密碼錯誤、只改 .env 為什麼讓備份停擺、容器還在跑時換密碼的步驟、外洩後建新 repository 並用 restic copy 搬快照，以及唯讀掛載時 restore 重試十幾分鐘才失敗的成因">restic sidecar 容器的密碼操作&lt;/a>。備份的存取憑證被拿去覆寫或刪除備份是另一類風險，那一類在 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/cross-network-connection-security/" data-link-title="7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄" data-link-desc="地端主機與雲端主機、或兩個雲端業者的主機以 VPN 或存取金鑰接起來之後的安全設計與評估：連線清單上各台連得到什麼、每一台各假設一次被入侵、限制要放在被入侵端沒有寫入路徑的地方、連線的發起方向與流入的資料、VPN 之內的應用層認證、監控主機與管理者裝置的位置、WireGuard 與 Tailscale 的金鑰與控制平面、異地備份的寫入憑證，以及連線本身的紀錄">7.43 兩個網路之間的連線安全&lt;/a> 的異地備份那一段。&lt;/p>
&lt;h2 id="資料主金鑰key-檔與密碼各自的角色">資料、主金鑰、key 檔與密碼各自的角色&lt;/h2>
&lt;p>restic 的 repository 裡，備份的檔案內容、目錄樹（tree）與快照（snapshot）都由一把主金鑰（master key）加密。主金鑰在 &lt;code>restic init&lt;/code> 時隨機產生，包含一把加密用的金鑰與一把驗證完整性用的 MAC 金鑰（&lt;a href="https://restic.readthedocs.io/en/stable/100_references.html#keys-encryption-and-mac">restic 的設計文件&lt;/a>）。密碼不直接加密任何資料：每一份 key 檔裡存著用密碼推導出的金鑰加密過的主金鑰，加上推導用的參數（scrypt 這個金鑰推導函式的成本參數 N、r、p，決定每推導一次要花多少記憶體與運算時間，讓猜密碼的人每試一次都要付同樣的成本，見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/memory-hard-function/" data-link-title="Memory-Hard Function" data-link-desc="要解釋為什麼某個密碼雜湊比另一個抗硬體、或判斷沒見過的構造落在哪裡時，用來定位它向攻擊者收的是哪一種費">Memory-Hard Function&lt;/a>；以及讓同一組密碼每次推導出不同金鑰的隨機值 salt，見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/salt/" data-link-title="Salt" data-link-desc="說明 salt 如何讓相同的密碼產生不同的雜湊值，以及它擋掉哪一類攻擊">Salt&lt;/a>）。打開 repository 的順序是：用密碼與 key 檔裡的參數推導出一把金鑰，用它解開 key 檔裡的主金鑰，再用主金鑰讀資料。這種用一把金鑰包著另一把金鑰的做法稱為金鑰包裝（key wrapping），雲端金鑰管理服務（KMS）的信封加密（envelope encryption）是同一個結構，金鑰的生命週期在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/key-management/" data-link-title="Key Management" data-link-desc="說明加密金鑰如何產生、保存、輪替，以及還原時如何依賴金鑰">Key Management&lt;/a>。&lt;/p>
&lt;p>這個分層不是 restic 獨有的：Borg 的 repokey 模式與磁碟加密 LUKS 的 keyslot（LUKS 磁碟標頭裡用一組密碼包著磁碟加密金鑰的欄位，一顆磁碟可以有好幾個）也是用密碼包著一把真正加密資料的金鑰，本章的判斷方式同樣適用。Borg 另有 keyfile 模式，包著金鑰的檔案放在備份用戶端的主機上、不在 repository 裡，repository 副本本身不帶 key 檔，下文「帶著舊 key 檔的副本」在那個模式下要換成「用戶端主機上的 key 檔」；差別在 LUKS 另有原地重新加密資料的工具（&lt;code>cryptsetup reencrypt&lt;/code>，本章沒有實跑），換主金鑰不必另建一份。&lt;/p>
&lt;p>主金鑰與密碼的變動頻率不同，所以中間隔了 key 檔。資料量可能是幾百 GB，主金鑰一換就要把全部資料重新加密；密碼是人要記住、會外洩、會想換的秘密。密碼只拿來打開 key 檔，所以密碼的變動只碰到 key 檔，資料與主金鑰維持不動。&lt;/p>
&lt;p>這個分層換到兩件事：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>一個 repository 可以有好幾組密碼&lt;/strong>：&lt;code>restic key add&lt;/code> 用另一組密碼替同一把主金鑰再包一份 key 檔，&lt;code>restic key list&lt;/code> 列出現有的 key 檔（每一份的 ID、使用者名稱、主機名稱與建立時間），&lt;code>restic key remove&lt;/code> 刪掉其中一份。每一組密碼解開的都是同一把主金鑰。多台主機、多個人各持一組密碼時，刪掉其中一個人那一份 key 檔，他就無法再用 restic 打開這個 repository；他在那之前是否讀出過主金鑰，是另一件事，判斷方式在〈換密碼與換主金鑰的適用條件〉那張表的「人員離開」那一列。&lt;code>key list&lt;/code> 的使用者與主機名稱預設取自執行 &lt;code>key add&lt;/code> 的那個環境，在容器裡加的 key 全是 &lt;code>root&lt;/code> 加上容器名稱，認不出是誰的；&lt;code>key add&lt;/code> 帶 &lt;code>--user&lt;/code> 與 &lt;code>--host&lt;/code> 可以寫成看得出持有人的名稱。&lt;/li>
&lt;li>&lt;strong>換密碼不必重新加密資料&lt;/strong>：&lt;code>restic key passwd&lt;/code> 用新密碼包一份新的 key 檔、刪掉舊的那一份，資料檔與快照沒有被改寫，所以換密碼花的時間跟 repository 的大小無關（這是從結構推論的，實測只在幾 KB 的 repository 上跑過）。&lt;/li>
&lt;/ul>
&lt;p>用 &lt;code>cat&lt;/code> 讀 &lt;code>keys/&lt;/code> 底下任何一份 key 檔，看得到這個結構：它是一段 JSON，&lt;code>kdf&lt;/code> 欄位是 &lt;code>scrypt&lt;/code>，&lt;code>N&lt;/code>、&lt;code>r&lt;/code>、&lt;code>p&lt;/code> 與 &lt;code>salt&lt;/code> 是推導用的參數，&lt;code>data&lt;/code> 是被包起來的主金鑰。&lt;code>restic cat masterkey&lt;/code> 則印出解開之後的主金鑰（&lt;code>encrypt&lt;/code> 與 &lt;code>mac&lt;/code> 兩個欄位），能用任何一組密碼打開 repository 的人，都能用這個指令讀出它。&lt;/p></description><content:encoded><![CDATA[<p>這一章整理加密備份工具的密碼與金鑰結構，以 restic 為例。restic 把備份加密之後存進一個 repository（restic 存放加密備份的位置，可以是本機資料夾、NAS 或物件儲存），而 repository 裡跟密碼有關的有兩樣：加密資料用的主金鑰，以及 key 檔（repository 的 <code>keys/</code> 目錄底下、用密碼包著主金鑰的檔案）。各節依序是：資料、主金鑰、key 檔與密碼各自的角色，以及一個 repository 為什麼可以有好幾組密碼；換密碼時 repository 裡實際改了什麼、沒改什麼；換密碼之後，之前複製出去的副本與已經外流的 key 檔還解得開哪些資料；什麼條件下只換密碼就足夠、什麼條件下要建一個新的 repository 把主金鑰也換掉，以及這兩條路各自的代價；最後是密碼遺失的後果與事先能做的準備。restic 在 Docker Compose 裡以 sidecar 容器（跟主服務並排、在同一個 Compose 專案裡執行的輔助容器）執行時，換密碼的實際步驟、依 exit code 判斷 repository 狀態的啟動腳本、外洩之後搬到新 repository 與唯讀還原演練，在 <a href="/blog/backend/07-security-data-protection/hands-on/restic-sidecar-password-operations/" data-link-title="restic sidecar 容器的密碼操作：啟動腳本依 exit code 判斷、換密碼、外洩後搬到新 repository 與唯讀還原演練" data-link-desc="restic 以 Docker Compose 的 sidecar 容器做自架備份、要換密碼或密碼可能外洩時：啟動腳本怎麼分辨 repository 不存在與密碼錯誤、只改 .env 為什麼讓備份停擺、容器還在跑時換密碼的步驟、外洩後建新 repository 並用 restic copy 搬快照，以及唯讀掛載時 restore 重試十幾分鐘才失敗的成因">restic sidecar 容器的密碼操作</a>。備份的存取憑證被拿去覆寫或刪除備份是另一類風險，那一類在 <a href="/blog/backend/07-security-data-protection/cross-network-connection-security/" data-link-title="7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄" data-link-desc="地端主機與雲端主機、或兩個雲端業者的主機以 VPN 或存取金鑰接起來之後的安全設計與評估：連線清單上各台連得到什麼、每一台各假設一次被入侵、限制要放在被入侵端沒有寫入路徑的地方、連線的發起方向與流入的資料、VPN 之內的應用層認證、監控主機與管理者裝置的位置、WireGuard 與 Tailscale 的金鑰與控制平面、異地備份的寫入憑證，以及連線本身的紀錄">7.43 兩個網路之間的連線安全</a> 的異地備份那一段。</p>
<h2 id="資料主金鑰key-檔與密碼各自的角色">資料、主金鑰、key 檔與密碼各自的角色</h2>
<p>restic 的 repository 裡，備份的檔案內容、目錄樹（tree）與快照（snapshot）都由一把主金鑰（master key）加密。主金鑰在 <code>restic init</code> 時隨機產生，包含一把加密用的金鑰與一把驗證完整性用的 MAC 金鑰（<a href="https://restic.readthedocs.io/en/stable/100_references.html#keys-encryption-and-mac">restic 的設計文件</a>）。密碼不直接加密任何資料：每一份 key 檔裡存著用密碼推導出的金鑰加密過的主金鑰，加上推導用的參數（scrypt 這個金鑰推導函式的成本參數 N、r、p，決定每推導一次要花多少記憶體與運算時間，讓猜密碼的人每試一次都要付同樣的成本，見 <a href="/blog/backend/knowledge-cards/memory-hard-function/" data-link-title="Memory-Hard Function" data-link-desc="要解釋為什麼某個密碼雜湊比另一個抗硬體、或判斷沒見過的構造落在哪裡時，用來定位它向攻擊者收的是哪一種費">Memory-Hard Function</a>；以及讓同一組密碼每次推導出不同金鑰的隨機值 salt，見 <a href="/blog/backend/knowledge-cards/salt/" data-link-title="Salt" data-link-desc="說明 salt 如何讓相同的密碼產生不同的雜湊值，以及它擋掉哪一類攻擊">Salt</a>）。打開 repository 的順序是：用密碼與 key 檔裡的參數推導出一把金鑰，用它解開 key 檔裡的主金鑰，再用主金鑰讀資料。這種用一把金鑰包著另一把金鑰的做法稱為金鑰包裝（key wrapping），雲端金鑰管理服務（KMS）的信封加密（envelope encryption）是同一個結構，金鑰的生命週期在 <a href="/blog/backend/knowledge-cards/key-management/" data-link-title="Key Management" data-link-desc="說明加密金鑰如何產生、保存、輪替，以及還原時如何依賴金鑰">Key Management</a>。</p>
<p>這個分層不是 restic 獨有的：Borg 的 repokey 模式與磁碟加密 LUKS 的 keyslot（LUKS 磁碟標頭裡用一組密碼包著磁碟加密金鑰的欄位，一顆磁碟可以有好幾個）也是用密碼包著一把真正加密資料的金鑰，本章的判斷方式同樣適用。Borg 另有 keyfile 模式，包著金鑰的檔案放在備份用戶端的主機上、不在 repository 裡，repository 副本本身不帶 key 檔，下文「帶著舊 key 檔的副本」在那個模式下要換成「用戶端主機上的 key 檔」；差別在 LUKS 另有原地重新加密資料的工具（<code>cryptsetup reencrypt</code>，本章沒有實跑），換主金鑰不必另建一份。</p>
<p>主金鑰與密碼的變動頻率不同，所以中間隔了 key 檔。資料量可能是幾百 GB，主金鑰一換就要把全部資料重新加密；密碼是人要記住、會外洩、會想換的秘密。密碼只拿來打開 key 檔，所以密碼的變動只碰到 key 檔，資料與主金鑰維持不動。</p>
<p>這個分層換到兩件事：</p>
<ul>
<li><strong>一個 repository 可以有好幾組密碼</strong>：<code>restic key add</code> 用另一組密碼替同一把主金鑰再包一份 key 檔，<code>restic key list</code> 列出現有的 key 檔（每一份的 ID、使用者名稱、主機名稱與建立時間），<code>restic key remove</code> 刪掉其中一份。每一組密碼解開的都是同一把主金鑰。多台主機、多個人各持一組密碼時，刪掉其中一個人那一份 key 檔，他就無法再用 restic 打開這個 repository；他在那之前是否讀出過主金鑰，是另一件事，判斷方式在〈換密碼與換主金鑰的適用條件〉那張表的「人員離開」那一列。<code>key list</code> 的使用者與主機名稱預設取自執行 <code>key add</code> 的那個環境，在容器裡加的 key 全是 <code>root</code> 加上容器名稱，認不出是誰的；<code>key add</code> 帶 <code>--user</code> 與 <code>--host</code> 可以寫成看得出持有人的名稱。</li>
<li><strong>換密碼不必重新加密資料</strong>：<code>restic key passwd</code> 用新密碼包一份新的 key 檔、刪掉舊的那一份，資料檔與快照沒有被改寫，所以換密碼花的時間跟 repository 的大小無關（這是從結構推論的，實測只在幾 KB 的 repository 上跑過）。</li>
</ul>
<p>用 <code>cat</code> 讀 <code>keys/</code> 底下任何一份 key 檔，看得到這個結構：它是一段 JSON，<code>kdf</code> 欄位是 <code>scrypt</code>，<code>N</code>、<code>r</code>、<code>p</code> 與 <code>salt</code> 是推導用的參數，<code>data</code> 是被包起來的主金鑰。<code>restic cat masterkey</code> 則印出解開之後的主金鑰（<code>encrypt</code> 與 <code>mac</code> 兩個欄位），能用任何一組密碼打開 repository 的人，都能用這個指令讀出它。</p>
<h2 id="換密碼之後repository-裡改了什麼沒改什麼">換密碼之後，repository 裡改了什麼、沒改什麼</h2>
<p><code>restic key passwd</code> 改的只有 <code>keys/</code> 目錄：多一份新密碼包的 key 檔，少一份舊密碼包的 key 檔。主金鑰沒有變，資料與快照沒有重新加密。由這一點推出兩個後果，持有 repository 的人換密碼之後，在還原或判斷外洩範圍時會碰到它們：</p>
<p><strong>之前複製出去的副本不會跟著換。</strong> 備份有多份副本時（NAS 上一份、物件儲存上一份、某次搬家時手動複製的一份），換密碼只改了執行 <code>key passwd</code> 的那一份 repository 的 <code>keys/</code>，其他副本的 <code>keys/</code> 還是舊的 key 檔，要用舊密碼打開。換密碼之後還要用某一份舊副本還原時，手上要有舊密碼，或在那一份副本上也做一次 <code>key passwd</code>。</p>
<p><strong>外流的舊 key 檔加上舊密碼，仍然解得開同一把主金鑰。</strong> 主金鑰沒換，所以任何時候拿到的資料，都能用這把主金鑰讀。實測時（restic 0.19.1）先把 <code>keys/</code> 底下的舊 key 檔另存一份、執行 <code>key passwd</code>、再做一次新的備份，接著把舊 key 檔放回一份後來才取得的 repository 副本裡，用舊密碼執行 <code>restic snapshots</code>，換密碼之後才建立的那個快照也列得出來、讀得到。換句話說，對方只要曾經同時拿到過舊 key 檔與舊密碼，之後再取得任何一份 repository 的副本（物件儲存的 bucket 外洩、NAS 被入侵），換密碼都擋不住他讀資料。</p>
<h2 id="換密碼與換主金鑰的適用條件對方可能取得的材料">換密碼與換主金鑰的適用條件：對方可能取得的材料</h2>
<p>要讀 repository 裡的資料，需要主金鑰；要得到主金鑰，需要「某一份 key 檔」加上「包那一份 key 檔的密碼」，兩樣要對得上。所以外洩之後，只換密碼是否足夠、還是要連主金鑰一起換，看對方手上有哪幾樣、它們是不是同一組。下表的「帶著舊 key 檔的副本」指換密碼之前取得的副本，或還沒做過 <code>key passwd</code> 的那幾份副本（NAS、手動複製的那一份）；換密碼之後才取得、而且已經換過的副本，裡面只有新密碼包的 key 檔：</p>
<table>
  <thead>
      <tr>
          <th>對方可能取得的材料</th>
          <th>換密碼之後的結果</th>
          <th>只換密碼的判定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>只有密碼，所有帶著舊 key 檔的副本都在對方碰不到的地方</td>
          <td>舊 key 檔被刪除，對方手上的密碼沒有對應的 key 檔可解</td>
          <td>足夠</td>
      </tr>
      <tr>
          <td>密碼，加上任何一份帶著舊 key 檔的副本，或那份 key 檔本身</td>
          <td>對方從舊 key 檔解出主金鑰，之後拿到的每一份副本都讀得到，包括換密碼之後的快照</td>
          <td>不足，主金鑰也要換</td>
      </tr>
      <tr>
          <td>只有 repository 副本（例如物件儲存的存取金鑰外洩），密碼沒有外洩</td>
          <td>現在讀不到；那份副本裡 key 檔的密碼日後任何時候外洩，它就讀得到，之後再換密碼也保護不了這一份</td>
          <td>密碼強度足以擋住離線猜測時足夠；不確定時換主金鑰</td>
      </tr>
      <tr>
          <td>人員離開：曾經能打開 repository 的人，用的是他自己那一份 key 檔</td>
          <td>刪掉他的 key 檔之後，他無法再用 restic 打開；他若讀出過主金鑰，之後拿到的副本仍讀得到</td>
          <td>他之後仍接觸得到任何一份副本時不足</td>
      </tr>
      <tr>
          <td>例行的定期輪替，沒有外洩跡象</td>
          <td>舊 key 檔被刪除</td>
          <td>足夠</td>
      </tr>
  </tbody>
</table>
<p>「只有密碼」那一列的前提要能查證：repository 放在哪幾個位置、每個位置有哪些人或哪些機器的存取憑證能讀。查不清楚時，「只有密碼」與「密碼加上帶著舊 key 檔的副本」兩列分不開，而把「密碼加上副本」的情形判成「只有密碼」的代價，是對方持續讀得到之後的每一份備份、而且沒有任何訊號。放 <code>RESTIC_PASSWORD</code> 的那份 <code>.env</code> 若同時放著物件儲存的存取金鑰，那份檔案外洩就同時交出了密碼與取得副本的路徑，落在「密碼加上帶著舊 key 檔的副本」那一列。「只有副本」那一列的離線猜測，是對方拿著 key 檔逐一試密碼，scrypt 的成本參數讓每試一次都要花記憶體與時間，擋得住多少取決於密碼的長度與隨機程度。「人員離開」那一列只適用於每個人各有一份 key 檔的情形；團隊共用同一組密碼時，離開的人手上的就是那組密碼，照「只有密碼」與「密碼加上帶著舊 key 檔的副本」兩列判斷。他「讀出過主金鑰」不需要特殊工具，上面的 <code>restic cat masterkey</code> 就做得到。</p>
<p>表中判成「足夠」的兩列，前提是換密碼或 <code>key remove</code> 之後，舊 key 檔確實從儲存端消失了。防止備份被覆寫或刪除的儲存設定，正好讓這個前提不成立：</p>
<ul>
<li><strong>append-only 的 rest-server</strong>：實測（rest-server 0.14.0 以 <code>--append-only</code> 執行）<code>key passwd</code> 寫得進新 key 檔，刪除舊 key 檔時 rest-server 回 <code>403 Forbidden</code>，restic 以 exit 1 結束；之後舊密碼照樣打得開，<code>key list</code> 列出新舊兩份 key 檔。</li>
<li><strong>開了版本保留或物件鎖定的物件儲存</strong>：刪除只留下刪除標記，舊 key 檔成為非現行版本，有權限讀取舊版本的人取回它，用舊密碼就打得開（從物件儲存的版本語意推論，沒有實跑）。</li>
</ul>
<p>在這兩種儲存端上，「只有密碼」與「例行輪替」兩列要改照「密碼加上帶著舊 key 檔的副本」那一列判斷，除非舊 key 檔的舊版本另外清掉。<code>key passwd</code> 與 <code>key remove</code> 要在持有完整存取權、刪得掉檔案的那台機器上執行，跟 7.43 對 <code>forget</code> 與 <code>prune</code> 的安排相同；物件儲存的舊版本要另外刪除，或等保留期滿。存取金鑰本身的撤銷與輪替在 <a href="/blog/backend/07-security-data-protection/cross-network-connection-security/" data-link-title="7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄" data-link-desc="地端主機與雲端主機、或兩個雲端業者的主機以 VPN 或存取金鑰接起來之後的安全設計與評估：連線清單上各台連得到什麼、每一台各假設一次被入侵、限制要放在被入侵端沒有寫入路徑的地方、連線的發起方向與流入的資料、VPN 之內的應用層認證、監控主機與管理者裝置的位置、WireGuard 與 Tailscale 的金鑰與控制平面、異地備份的寫入憑證，以及連線本身的紀錄">7.43 兩個網路之間的連線安全</a> 的異地備份那一段。</p>
<h3 id="換掉主金鑰建新-repository-並用-restic-copy-搬快照">換掉主金鑰：建新 repository 並用 restic copy 搬快照</h3>
<p>restic 沒有在原地更換主金鑰的指令（<code>restic key</code> 只有 add、list、passwd、remove 四個子指令），換主金鑰的方式是建一個新的 repository（新的 <code>restic init</code> 產生一把新的主金鑰），再用 <code>restic copy</code> 把舊 repository 的快照搬過去。<code>copy</code> 會把資料從舊主金鑰解開、用新 repository 的主金鑰重新加密寫入。實測時把同一份舊 key 檔放進新 repository 的 <code>keys/</code>，用舊密碼打開，restic 回報 <code>ciphertext verification failed</code> 並以 exit 1 結束：舊 key 檔解出的是舊主金鑰，對新 repository 的資料不成立。</p>
<p>這條路的代價：</p>
<ul>
<li><strong>全部資料重新加密、重新寫一次</strong>：<code>copy</code> 要把全部快照讀一次、寫一次；快照搬完之前，新舊兩個 repository 都要有空間，新 repository 放在物件儲存時，上傳量是全部資料。<code>copy</code> 不會重新切分檔案，所以搬過來的資料與之後直接 <code>backup</code> 到新 repository 的資料，要用同一組切塊參數（restic 依內容把檔案切成資料塊，切點由每個 repository 在 <code>init</code> 時各自隨機產生的參數決定；相同內容切出相同的資料塊，才認得出是重複的）才能互相做重複資料刪除（deduplication）；<code>restic init</code> 加 <code>--copy-chunker-params</code> 讓新 repository 沿用舊的參數，參數不同時，換過去之後第一次備份會把同樣的內容再存一次（<a href="https://restic.readthedocs.io/en/stable/045_working_with_repos.html">restic 文件的 Ensuring deduplication for copied snapshots</a>）。</li>
<li><strong>舊 repository 要淘汰，包括它的每一份副本</strong>：舊 repository 只要還在某個位置，對方拿到它就讀得到搬遷之前的全部快照；換主金鑰保護的是之後的備份，已經外流的副本救不回來。</li>
<li><strong>備份程式要改指到新 repository</strong>：設定與密碼都換，換完確認新的快照照常產生。</li>
</ul>
<h2 id="密碼遺失的後果與事先的準備另存的密碼與離線保管的備援密碼">密碼遺失的後果與事先的準備：另存的密碼與離線保管的備援密碼</h2>
<p>密碼與 key 檔都在、主金鑰才解得開。所有 key 檔的密碼都遺失時，沒有任何服務、管理員或工具能找回主金鑰，資料就無法還原，<code>restic init</code> 的輸出直接寫著這一點（<code>Losing your password means that your data is irrecoverably lost.</code>）。</p>
<p>密碼只存在被備份的那台主機上時（寫在主機的 <code>.env</code> 或設定檔裡），主機的硬碟壞掉，資料與密碼一起不見：異地的備份完整，卻沒有密碼打得開。所以密碼要另外存在不依賴那台主機的位置（密碼管理器），而且換密碼之後同一次更新那一份。</p>
<p>多份 key 檔的結構另外提供一種準備：用 <code>restic key add</code> 以另一組密碼再包一份 key 檔，這份 key 檔跟其他的一樣留在 repository 的 <code>keys/</code> 裡，離線保存的是它的密碼（紙本放保險箱、或另一個帳號的密碼管理器）。日常用的那一組密碼遺失時，這一組密碼仍然打得開。把現有的 key 檔複製一份放到離線位置沒有這個作用：那份 key 檔還是要原本那組密碼才解得開。代價是多一組要保管的秘密，以及多一條外洩路徑：這一組密碼也要放進〈換密碼與換主金鑰的適用條件〉那張表一起判斷，換密碼時它不會自動跟著換。</p>
<h2 id="延伸閱讀sidecar-容器的實際操作備份的寫入憑證與使用者密碼外洩">延伸閱讀：sidecar 容器的實際操作、備份的寫入憑證與使用者密碼外洩</h2>
<ul>
<li>換密碼、啟動腳本依 exit code 區分「repository 不存在」與「密碼錯誤」、外洩之後搬到新 repository、唯讀掛載的還原演練，實測步驟與輸出在 <a href="/blog/backend/07-security-data-protection/hands-on/restic-sidecar-password-operations/" data-link-title="restic sidecar 容器的密碼操作：啟動腳本依 exit code 判斷、換密碼、外洩後搬到新 repository 與唯讀還原演練" data-link-desc="restic 以 Docker Compose 的 sidecar 容器做自架備份、要換密碼或密碼可能外洩時：啟動腳本怎麼分辨 repository 不存在與密碼錯誤、只改 .env 為什麼讓備份停擺、容器還在跑時換密碼的步驟、外洩後建新 repository 並用 restic copy 搬快照，以及唯讀掛載時 restore 重試十幾分鐘才失敗的成因">restic sidecar 容器的密碼操作</a>。</li>
<li>備份的存取憑證只給上傳權限時仍然覆寫得掉既有備份、版本保留與 append-only 伺服器，在 <a href="/blog/backend/07-security-data-protection/cross-network-connection-security/" data-link-title="7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄" data-link-desc="地端主機與雲端主機、或兩個雲端業者的主機以 VPN 或存取金鑰接起來之後的安全設計與評估：連線清單上各台連得到什麼、每一台各假設一次被入侵、限制要放在被入侵端沒有寫入路徑的地方、連線的發起方向與流入的資料、VPN 之內的應用層認證、監控主機與管理者裝置的位置、WireGuard 與 Tailscale 的金鑰與控制平面、異地備份的寫入憑證，以及連線本身的紀錄">7.43 兩個網路之間的連線安全</a>；物件鎖定的兩種模式在 <a href="/blog/backend/knowledge-cards/worm-storage/" data-link-title="WORM Storage（不可變儲存）" data-link-desc="要讓備份或稽核紀錄在寫入它的存取憑證外洩之後仍然刪不掉、改不了時，查一次寫入多次讀取的儲存怎麼做到、物件鎖定的兩種模式差在哪裡，以及版本保留與它的差別">WORM Storage</a>。</li>
<li>一批使用者密碼外洩時怎麼定重設範圍，是另一個對象（使用者帳號的密碼，不是加密金鑰的密碼），在 <a href="/blog/backend/07-security-data-protection/credential-breach-response/" data-link-title="7.37 密碼外洩之後：範圍判不出來的時候怎麼定處置" data-link-desc="帳號憑證外洩而查不出哪些帳號被讀走時，用來分層定重設範圍、排撤銷順序與判斷通知門檻">7.37 密碼外洩之後</a>。</li>
</ul>
]]></content:encoded></item><item><title>restic sidecar 容器的密碼操作：啟動腳本依 exit code 判斷、換密碼、外洩後搬到新 repository 與唯讀還原演練</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/hands-on/restic-sidecar-password-operations/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/hands-on/restic-sidecar-password-operations/</guid><description>&lt;p>&lt;strong>事件說明&lt;/strong>：2026-11-03 在 macOS 的 OrbStack（Docker 29.4.0）上，以 &lt;code>restic/restic:0.19.1&lt;/code> 映像實測。情境來自一套自架在家用主機上的個人系統：Docker Compose 裡一個 restic 容器以唯讀方式掛載要備份的檔案資料夾，每小時備份、每天依保留規則清理，密碼經由 &lt;code>.env&lt;/code> 的 &lt;code>RESTIC_PASSWORD&lt;/code> 交給容器。實測用的 repository 都是暫時建立的 Docker volume。&lt;/p>
&lt;p>這篇記錄 restic 以 sidecar 容器（跟主服務並排、在同一個 Compose 專案裡執行的輔助容器）做備份時，密碼這一段的操作與失敗形態。各節依序是：&lt;code>restic cat config&lt;/code> 在 repository（restic 存放加密備份的位置）不存在、密碼錯誤、對已經存在的 repository 再 &lt;code>init&lt;/code> 時各回傳什麼；只判斷成功或失敗的啟動腳本，在只改了 &lt;code>.env&lt;/code> 的密碼時為什麼把密碼錯誤誤判成 repository 不存在、讓備份停擺，以及依 exit code 分支的寫法；容器還在跑、&lt;code>.env&lt;/code> 還是舊密碼時換密碼的步驟，以及 &lt;code>docker compose restart&lt;/code> 不會讓容器讀到新密碼；密碼可能外洩時建新 repository、用 &lt;code>restic copy&lt;/code> 搬快照，並確認外流的舊 key 檔打不開新 repository；唯讀掛載 repository 做還原演練時 &lt;code>restore&lt;/code> 重試十幾分鐘才失敗的成因與 &lt;code>--no-lock&lt;/code>。密碼、key 檔與主金鑰的結構，以及什麼條件下只換密碼就足夠、什麼條件下要換主金鑰，在 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/backup-encryption-password-and-master-key/" data-link-title="7.45 加密備份的密碼、key 檔與主金鑰：換密碼與換主金鑰的差別、密碼外洩與遺失的處置，以 restic 為例" data-link-desc="加密備份工具把資料交給一把隨機產生的主金鑰加密、密碼只用來解開包著主金鑰的 key 檔：這個分層換到的多份 key 檔與不重新加密的換密碼、換密碼之後舊副本與外流的 key 檔還解得開什麼、什麼條件下要建新 repository 換掉主金鑰，以及密碼遺失時沒有任何找回的路">7.45 加密備份的密碼、key 檔與主金鑰：換密碼與換主金鑰的差別、密碼外洩與遺失的處置，以 restic 為例&lt;/a>，這篇不重寫。&lt;/p>
&lt;h2 id="實驗的形狀一個-helper-腳本幾個暫時的-repository-與測試資料">實驗的形狀：一個 helper 腳本、幾個暫時的 repository 與測試資料&lt;/h2>
&lt;p>實驗的每一次 restic 指令都在一個新的容器裡執行，repository 是一個 Docker named volume，密碼放在實驗目錄的檔案裡。實驗目錄是自己建的一個空資料夾，下文寫成 &lt;code>&amp;lt;實驗目錄&amp;gt;&lt;/code>；它底下放：&lt;/p>
&lt;ul>
&lt;li>&lt;code>data/&lt;/code>：要備份的測試資料，這次放了兩個小文字檔（&lt;code>echo hello &amp;gt; data/a.txt&lt;/code>、&lt;code>echo world &amp;gt; data/b.txt&lt;/code>）。&lt;/li>
&lt;li>密碼檔 &lt;code>pw-old&lt;/code>、&lt;code>pw-new&lt;/code>、&lt;code>pw-wrong&lt;/code>、&lt;code>pw-fresh&lt;/code>：每個檔案一行密碼，內容自己定，四個互不相同。&lt;code>pw-wrong&lt;/code> 代表「&lt;code>.env&lt;/code> 被改成了 repository 沒有的密碼」。&lt;/li>
&lt;li>&lt;code>stolen-keys/&lt;/code>：外洩情境用的空資料夾（&lt;code>mkdir stolen-keys&lt;/code>）。&lt;/li>
&lt;/ul>
&lt;p>volume 名稱一律用 &lt;code>rlab-&lt;/code> 開頭，清理時一條 &lt;code>grep&lt;/code> 列得出來；&lt;code>docker run -v rlab-xxx:/repo&lt;/code> 第一次用到某個名稱時，Docker 會自動建一個空的 named volume。為了讓指令短一點，用一支 helper 腳本把 volume、密碼檔與 restic 映像組起來，存成 &lt;code>&amp;lt;實驗目錄&amp;gt;/r.sh&lt;/code>，&lt;code>L=&lt;/code> 那一行填實驗目錄的絕對路徑：&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="cp">#!/usr/bin/env bash
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="cp">&lt;/span>&lt;span class="c1"># r.sh &amp;lt;volume&amp;gt; &amp;lt;密碼檔名&amp;gt; &amp;lt;restic 的參數...&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nv">L&lt;/span>&lt;span class="o">=&lt;/span>&amp;lt;實驗目錄&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nv">vol&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$1&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nv">pw&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$2&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nb">shift&lt;/span> &lt;span class="m">2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">docker run --rm -v &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$vol&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>:/repo -v &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$L&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>:/lab -e &lt;span class="nv">RESTIC_REPOSITORY&lt;/span>&lt;span class="o">=&lt;/span>/repo &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -e &lt;span class="nv">RESTIC_PASSWORD_FILE&lt;/span>&lt;span class="o">=&lt;/span>/lab/&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$pw&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> restic/restic:0.19.1 &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$@&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>存檔之後跑一次 &lt;code>chmod +x r.sh&lt;/code>，沒有執行權限時 &lt;code>./r.sh&lt;/code> 回傳 &lt;code>permission denied&lt;/code>。&lt;code>RESTIC_PASSWORD_FILE&lt;/code> 從檔案讀密碼，&lt;code>RESTIC_PASSWORD&lt;/code> 從環境變數讀；正式的 sidecar 用 &lt;code>RESTIC_PASSWORD&lt;/code>，實驗用 &lt;code>RESTIC_PASSWORD_FILE&lt;/code> 是為了換一組密碼只要換檔名。下文的 &lt;code>./r.sh&lt;/code> 都在實驗目錄執行。&lt;/p>
&lt;h2 id="restic-cat-config-的-exit-coderepository-不存在密碼錯誤與重複-init">&lt;code>restic cat config&lt;/code> 的 exit code：repository 不存在、密碼錯誤與重複 init&lt;/h2>
&lt;p>&lt;code>restic cat config&lt;/code> 讀 repository 的設定檔，要先用密碼解開主金鑰才讀得到，所以它的 exit code 同時區分「repository 不存在」與「密碼錯誤」（&lt;a href="https://restic.readthedocs.io/en/stable/075_scripting.html">restic 的 scripting 文件&lt;/a> 列出全部的 exit code，10 從 0.17.0 起、12 從 0.17.1 起）：&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">./r.sh rlab-norepo pw-old cat config &lt;span class="c1"># 空的 volume，還沒 init&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1"># Is there a repository at the following location?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1"># exit 10&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">./r.sh rlab-repo pw-old init &lt;span class="c1"># 建立 repository，之後的指令都對它跑&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">./r.sh rlab-repo pw-wrong cat config &lt;span class="c1"># 用另一組密碼&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1"># Fatal: wrong password or no key found&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1"># exit 12&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">./r.sh rlab-repo pw-old cat config &lt;span class="c1"># 正確的密碼&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1"># exit 0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">./r.sh rlab-repo pw-old init &lt;span class="c1"># 對已經存在的 repository 再 init&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># Fatal: Fatal: create repository at /repo failed: config file already exists&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1"># exit 1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">./r.sh rlab-repo pw-old backup --host lab /lab/data &lt;span class="c1"># 外洩情境要用的第一個快照&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1"># snapshot d46a3984 saved&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>init&lt;/code> 碰到既有的 repository 時拒絕執行、回傳 exit 1，既有的資料不受影響。這一點讓「拿不準就 init」看起來無害，而只判斷成功或失敗的啟動腳本正是靠這個假設寫成的。&lt;/p></description><content:encoded><![CDATA[<p><strong>事件說明</strong>：2026-11-03 在 macOS 的 OrbStack（Docker 29.4.0）上，以 <code>restic/restic:0.19.1</code> 映像實測。情境來自一套自架在家用主機上的個人系統：Docker Compose 裡一個 restic 容器以唯讀方式掛載要備份的檔案資料夾，每小時備份、每天依保留規則清理，密碼經由 <code>.env</code> 的 <code>RESTIC_PASSWORD</code> 交給容器。實測用的 repository 都是暫時建立的 Docker volume。</p>
<p>這篇記錄 restic 以 sidecar 容器（跟主服務並排、在同一個 Compose 專案裡執行的輔助容器）做備份時，密碼這一段的操作與失敗形態。各節依序是：<code>restic cat config</code> 在 repository（restic 存放加密備份的位置）不存在、密碼錯誤、對已經存在的 repository 再 <code>init</code> 時各回傳什麼；只判斷成功或失敗的啟動腳本，在只改了 <code>.env</code> 的密碼時為什麼把密碼錯誤誤判成 repository 不存在、讓備份停擺，以及依 exit code 分支的寫法；容器還在跑、<code>.env</code> 還是舊密碼時換密碼的步驟，以及 <code>docker compose restart</code> 不會讓容器讀到新密碼；密碼可能外洩時建新 repository、用 <code>restic copy</code> 搬快照，並確認外流的舊 key 檔打不開新 repository；唯讀掛載 repository 做還原演練時 <code>restore</code> 重試十幾分鐘才失敗的成因與 <code>--no-lock</code>。密碼、key 檔與主金鑰的結構，以及什麼條件下只換密碼就足夠、什麼條件下要換主金鑰，在 <a href="/blog/backend/07-security-data-protection/backup-encryption-password-and-master-key/" data-link-title="7.45 加密備份的密碼、key 檔與主金鑰：換密碼與換主金鑰的差別、密碼外洩與遺失的處置，以 restic 為例" data-link-desc="加密備份工具把資料交給一把隨機產生的主金鑰加密、密碼只用來解開包著主金鑰的 key 檔：這個分層換到的多份 key 檔與不重新加密的換密碼、換密碼之後舊副本與外流的 key 檔還解得開什麼、什麼條件下要建新 repository 換掉主金鑰，以及密碼遺失時沒有任何找回的路">7.45 加密備份的密碼、key 檔與主金鑰：換密碼與換主金鑰的差別、密碼外洩與遺失的處置，以 restic 為例</a>，這篇不重寫。</p>
<h2 id="實驗的形狀一個-helper-腳本幾個暫時的-repository-與測試資料">實驗的形狀：一個 helper 腳本、幾個暫時的 repository 與測試資料</h2>
<p>實驗的每一次 restic 指令都在一個新的容器裡執行，repository 是一個 Docker named volume，密碼放在實驗目錄的檔案裡。實驗目錄是自己建的一個空資料夾，下文寫成 <code>&lt;實驗目錄&gt;</code>；它底下放：</p>
<ul>
<li><code>data/</code>：要備份的測試資料，這次放了兩個小文字檔（<code>echo hello &gt; data/a.txt</code>、<code>echo world &gt; data/b.txt</code>）。</li>
<li>密碼檔 <code>pw-old</code>、<code>pw-new</code>、<code>pw-wrong</code>、<code>pw-fresh</code>：每個檔案一行密碼，內容自己定，四個互不相同。<code>pw-wrong</code> 代表「<code>.env</code> 被改成了 repository 沒有的密碼」。</li>
<li><code>stolen-keys/</code>：外洩情境用的空資料夾（<code>mkdir stolen-keys</code>）。</li>
</ul>
<p>volume 名稱一律用 <code>rlab-</code> 開頭，清理時一條 <code>grep</code> 列得出來；<code>docker run -v rlab-xxx:/repo</code> 第一次用到某個名稱時，Docker 會自動建一個空的 named volume。為了讓指令短一點，用一支 helper 腳本把 volume、密碼檔與 restic 映像組起來，存成 <code>&lt;實驗目錄&gt;/r.sh</code>，<code>L=</code> 那一行填實驗目錄的絕對路徑：</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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="cp"></span><span class="c1"># r.sh &lt;volume&gt; &lt;密碼檔名&gt; &lt;restic 的參數...&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">L</span><span class="o">=</span>&lt;實驗目錄&gt;
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nv">vol</span><span class="o">=</span><span class="nv">$1</span><span class="p">;</span> <span class="nv">pw</span><span class="o">=</span><span class="nv">$2</span><span class="p">;</span> <span class="nb">shift</span> <span class="m">2</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">docker run --rm -v <span class="s2">&#34;</span><span class="nv">$vol</span><span class="s2">&#34;</span>:/repo -v <span class="s2">&#34;</span><span class="nv">$L</span><span class="s2">&#34;</span>:/lab -e <span class="nv">RESTIC_REPOSITORY</span><span class="o">=</span>/repo <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  -e <span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span>/lab/<span class="s2">&#34;</span><span class="nv">$pw</span><span class="s2">&#34;</span> restic/restic:0.19.1 <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span></span></span></code></pre></div><p>存檔之後跑一次 <code>chmod +x r.sh</code>，沒有執行權限時 <code>./r.sh</code> 回傳 <code>permission denied</code>。<code>RESTIC_PASSWORD_FILE</code> 從檔案讀密碼，<code>RESTIC_PASSWORD</code> 從環境變數讀；正式的 sidecar 用 <code>RESTIC_PASSWORD</code>，實驗用 <code>RESTIC_PASSWORD_FILE</code> 是為了換一組密碼只要換檔名。下文的 <code>./r.sh</code> 都在實驗目錄執行。</p>
<h2 id="restic-cat-config-的-exit-coderepository-不存在密碼錯誤與重複-init"><code>restic cat config</code> 的 exit code：repository 不存在、密碼錯誤與重複 init</h2>
<p><code>restic cat config</code> 讀 repository 的設定檔，要先用密碼解開主金鑰才讀得到，所以它的 exit code 同時區分「repository 不存在」與「密碼錯誤」（<a href="https://restic.readthedocs.io/en/stable/075_scripting.html">restic 的 scripting 文件</a> 列出全部的 exit code，10 從 0.17.0 起、12 從 0.17.1 起）：</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">./r.sh rlab-norepo pw-old cat config   <span class="c1"># 空的 volume，還沒 init</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># Is there a repository at the following location?</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># exit 10</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">./r.sh rlab-repo pw-old init            <span class="c1"># 建立 repository，之後的指令都對它跑</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">./r.sh rlab-repo pw-wrong cat config    <span class="c1"># 用另一組密碼</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># Fatal: wrong password or no key found</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># exit 12</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">./r.sh rlab-repo pw-old cat config      <span class="c1"># 正確的密碼</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># exit 0</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">./r.sh rlab-repo pw-old init            <span class="c1"># 對已經存在的 repository 再 init</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># Fatal: Fatal: create repository at /repo failed: config file already exists</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># exit 1</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">./r.sh rlab-repo pw-old backup --host lab /lab/data   <span class="c1"># 外洩情境要用的第一個快照</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># snapshot d46a3984 saved</span></span></span></code></pre></div><p><code>init</code> 碰到既有的 repository 時拒絕執行、回傳 exit 1，既有的資料不受影響。這一點讓「拿不準就 init」看起來無害，而只判斷成功或失敗的啟動腳本正是靠這個假設寫成的。</p>
<h2 id="只判斷成功或失敗的啟動腳本密碼錯誤被當成-repository-不存在">只判斷成功或失敗的啟動腳本：密碼錯誤被當成 repository 不存在</h2>
<p>sidecar 容器的啟動腳本有一種直覺的寫法：<code>cat config</code> 失敗就 <code>init</code>，再進入備份迴圈。下面是精簡過的版本，備份迴圈換成一次 <code>backup</code>，存成 <code>&lt;實驗目錄&gt;/entry-before.sh</code>。存檔前把 <code>&lt;主機名稱&gt;</code> 換成一個固定的名稱（這次的實驗用 <code>lab</code>），留著尖括號的話 sh 會把 <code>&lt;</code> 當成輸入重新導向而報錯；為什麼要固定，在這一節最後說明：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln">1</span><span class="cl"><span class="cp">#!/bin/sh
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="cp"></span><span class="nb">set</span> -e
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 只判斷成功或失敗</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">if</span> ! restic cat config &gt;/dev/null 2&gt;<span class="p">&amp;</span>1<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  restic init
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">restic backup --host &lt;主機名稱&gt; /lab/data</span></span></code></pre></div><p><code>.env</code> 的 <code>RESTIC_PASSWORD</code> 被改成新密碼、而 repository 裡的 key 檔沒有換時，<code>cat config</code> 回傳 12，腳本把它當成 repository 不存在，接著 <code>init</code> 因為 config 已經存在而回傳 1，<code>set -e</code> 讓腳本當場結束。用 <code>pw-wrong</code> 代表被改過的 <code>.env</code>，以腳本取代映像的進入點執行：</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">docker run --rm --entrypoint /bin/sh -v rlab-repo:/repo -v &lt;實驗目錄&gt;:/lab <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -e <span class="nv">RESTIC_REPOSITORY</span><span class="o">=</span>/repo -e <span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span>/lab/pw-wrong <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  restic/restic:0.19.1 /lab/entry-before.sh
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Fatal: Fatal: create repository at /repo failed: config file already exists</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># exit 1</span></span></span></code></pre></div><p>日誌裡唯一的錯誤是 init 失敗，密碼錯誤這個真正的原因被 <code>cat config</code> 的 <code>2&gt;&amp;1</code> 丟掉了。compose 檔的 <code>restart</code> 設成 <code>always</code>、<code>unless-stopped</code> 或 <code>on-failure</code> 時，容器會一次又一次重啟、每次都停在同一個位置（從 restart 政策推論，這次只用 <code>docker run</code> 跑，沒有實跑 compose 的重啟）：資料不會被破壞，但再也沒有新的備份，看日誌的人會往「config file already exists」的方向查。</p>
<p>依 exit code 分支之後，只有 10 才 init，12 直接報出密碼錯誤並結束，其他未預期的 exit code 再跑一次 <code>cat config</code>、不丟掉 stderr，讓 restic 原本的錯誤訊息印出來。存成 <code>&lt;實驗目錄&gt;/entry-after.sh</code>，<code>&lt;主機名稱&gt;</code> 同樣先換掉：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cp">#!/bin/sh
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp"></span><span class="nb">set</span> -e
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">log<span class="o">()</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="k">$(</span>date -u +%FT%TZ<span class="k">)</span><span class="s2"> </span><span class="nv">$*</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nv">rc</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">restic cat config &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nv">rc</span><span class="o">=</span><span class="nv">$?</span>   <span class="c1"># set -e 底下取得 exit code 而不讓腳本當場結束</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span> in
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  0<span class="o">)</span> <span class="p">;;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  10<span class="o">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    log <span class="s2">&#34;repository 不存在，建立於 </span><span class="nv">$RESTIC_REPOSITORY</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    restic init
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">;;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  12<span class="o">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    log <span class="s2">&#34;ERROR: 密碼打不開 </span><span class="nv">$RESTIC_REPOSITORY</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">;;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  *<span class="o">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    log <span class="s2">&#34;ERROR: 無法開啟 </span><span class="nv">$RESTIC_REPOSITORY</span><span class="s2">（restic exit </span><span class="nv">$rc</span><span class="s2">），訊息如下：&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    restic cat config &gt;/dev/null <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">;;</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="k">esac</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">restic backup --host &lt;主機名稱&gt; /lab/data</span></span></code></pre></div><p>執行方式跟上面相同，最後一個引數換成 <code>/lab/entry-after.sh</code>。用 <code>pw-wrong</code> 跑，它印出 <code>ERROR: 密碼打不開 /repo</code> 並以 exit 1 結束，沒有嘗試 init。對一個空的 volume（<code>rlab-fresh</code>）以 <code>pw-fresh</code> 跑，它印出 <code>repository 不存在，建立於 /repo</code>、<code>created restic repository …</code>，第一次備份 <code>snapshot d162a3e5 saved</code>；用同一組密碼再跑一次，它不 init，直接備份，<code>Added to the repository: 0 B</code> 表示內容跟上一次相同，沒有新增資料。</p>
<p>密碼錯誤時讓腳本結束，而不是記一筆日誌之後繼續迴圈：容器停止或反覆重啟時，<code>docker compose ps</code> 列出的狀態就看得出這個服務不正常；繼續迴圈的話，每一次備份都失敗、容器卻一直是 running，要等到哪一天需要還原才發現。<code>12</code> 那個分支的訊息可以再加一行，寫出處理步驟在哪裡（例如專案 README 裡換密碼那一節），看日誌的人不必猜。</p>
<p><code>backup</code> 的 <code>--host</code> 填一個固定的名稱（<code>&lt;主機名稱&gt;</code>，例如這套系統的名字），理由在清理那一步：<code>restic forget</code> 依 <code>--group-by</code> 把快照分組之後才套保留規則，預設是 <code>host,paths</code>（<code>restic forget --help</code>）。compose 檔沒有設 <code>hostname:</code> 時，容器的主機名稱每次重建都不同，不固定 <code>--host</code> 的話，每一個容器的快照自成一組，保留規則各算各的；換了一台主機之後也一樣。固定成同一個名稱，換機之後的快照仍然跟舊主機的快照算在同一組。整台主機換機時資料庫與 replica 的搬遷順序，在 <a href="/blog/backend/01-database/vendors/sqlite/hands-on/host-migration-drill/" data-link-title="SQLite 換機搬遷演練：Litestream restore、UID 對齊與停用舊主機的複製" data-link-desc="家用主機要換新機、SQLite 資料庫由 Litestream 持續備份時，從 replica 把資料還原到新主機的步驟、只複製資料庫主檔會少掉的資料、還原後容器寫入時出現 attempt to write a readonly database 的成因，以及舊主機的複製沒停掉時 replica 裡會少掉哪些寫入">SQLite 換機搬遷演練</a>。</p>
<h2 id="容器還在跑時換密碼key-passwd-與-env-的先後">容器還在跑時換密碼：key passwd 與 .env 的先後</h2>
<p>換密碼要改的是 repository 裡的 key 檔（<code>restic key passwd</code>），<code>.env</code> 只是讓容器之後拿到新密碼。順序反過來（先改 <code>.env</code>）就是上一節 <code>entry-before.sh</code> 量到的那種停擺。下面的步驟用一個最小的 Compose 專案實測，compose 檔與 <code>.env</code> 放在 <code>&lt;實驗目錄&gt;/compose/</code>。restic 容器只跑 <code>sleep infinity</code>，讓 <code>exec</code> 有一個執行中的容器可以進去；正式的 sidecar 在這個位置跑備份迴圈：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># &lt;實驗目錄&gt;/compose/compose.yml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">rlab</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="nt">services</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="nt">restic</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="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">restic/restic:0.19.1</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">entrypoint</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;/bin/sh&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-c&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;sleep infinity&#34;</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="nt">environment</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="nt">RESTIC_REPOSITORY</span><span class="p">:</span><span class="w"> </span><span class="l">/repo</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span><span class="nt">RESTIC_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${RESTIC_PASSWORD}  </span><span class="w"> </span><span class="c"># 由同目錄的 .env 帶入</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="l">repo:/repo</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">  </span><span class="nt">repo</span><span class="p">:</span><span class="w"> </span>{}</span></span></code></pre></div>




<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="nb">cd</span> &lt;實驗目錄&gt;/compose
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;RESTIC_PASSWORD=old-pass\n&#39;</span> &gt; .env
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">docker compose up -d
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">docker compose <span class="nb">exec</span> restic restic init
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 新密碼寫進主機上的暫時檔，複製進容器，在容器裡換掉 key 檔</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">openssl rand -base64 <span class="m">32</span> &gt; /tmp/restic-new-pw
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">docker compose cp /tmp/restic-new-pw restic:/tmp/new-pw
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">docker compose <span class="nb">exec</span> restic restic key passwd --new-password-file /tmp/new-pw
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># saved new key as &lt;Key of root@…&gt;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">docker compose <span class="nb">exec</span> restic rm /tmp/new-pw</span></span></code></pre></div><p><code>key passwd</code> 用容器環境變數裡的舊密碼打開 repository，再用 <code>--new-password-file</code> 的內容包一份新的 key 檔、刪掉舊的。<code>docker compose cp</code> 與 <code>exec</code> 後面的第一個 <code>restic</code> 是 compose 檔裡的服務名稱，換成自己那一份的名稱；<code>exec</code> 的第二個 <code>restic</code> 才是指令。這次實測的新密碼是一個寫好固定字串的檔案，<code>openssl rand -base64 32</code> 那一行沒有實跑，它產生 32 位元組的隨機值、以 base64 印出，長度沒有和其他值比較過。</p>
<p>新密碼的交付方式有兩種替代做法：</p>
<ul>
<li><strong>走暫時檔（上面的做法）</strong>：新密碼不出現在 shell 的歷史紀錄與 <code>exec</code> 的程序參數裡，可以寫進腳本；代價是主機的 <code>/tmp</code> 與容器的 <code>/tmp</code> 各留過一份，要記得刪掉，主機那一份在 <code>/tmp</code> 的權限照 umask，同一台主機的其他使用者可能讀得到。</li>
<li><strong>不帶 <code>--new-password-file</code></strong>：<code>key passwd</code> 改成在終端機上互動詢問新密碼，不留暫時檔；代價是要用 <code>docker compose exec</code>（不加 <code>-T</code>）取得 TTY，寫不進非互動的腳本。這種做法沒有實跑，行為取自 <code>restic key passwd --help</code>。</li>
</ul>
<p>密碼交給容器的方式本身也有替代做法，這次只實跑了 <code>.env</code> 帶入 <code>RESTIC_PASSWORD</code> 這一種，另外兩種的行為取自 restic 文件與 Compose 的設定方式，沒有實跑：</p>
<ul>
<li><strong><code>.env</code> 的 <code>RESTIC_PASSWORD</code>（這次的做法）</strong>：最直接；代價是密碼成了容器的環境變數，<code>docker inspect</code>、<code>docker compose config</code> 都印得出來，換密碼時要改 <code>.env</code> 再 <code>up -d</code> 重建容器。</li>
<li><strong><code>RESTIC_PASSWORD_FILE</code> 指向掛進容器的檔案</strong>（Compose 的 <code>secrets:</code>，或唯讀掛載的檔案）：密碼不出現在環境變數裡，能讀它的範圍由檔案權限決定；換密碼時換掉那個檔案的內容，容器裡下一次執行 restic 就讀到新值，是否需要重建容器看檔案是怎麼掛進去的。</li>
<li><strong><code>RESTIC_PASSWORD_COMMAND</code> 從密碼管理器的命令列工具取</strong>：密碼不落在主機的檔案裡；代價是容器裡要裝那個工具與它的登入憑證，密碼管理器連不上時備份就停。</li>
</ul>
<p>密碼放在哪裡決定了哪些人或程序讀得到它，判斷方式在 <a href="/blog/backend/07-security-data-protection/cryptographic-primitive-selection/" data-link-title="7.28 密碼學原語選型：金鑰位置決定威脅模型" data-link-desc="決定用加密、簽章、編碼還是單向轉換保護一段資料時，用來判斷各原語的保護範圍與失效條件">7.28 密碼學原語選型：金鑰位置決定威脅模型</a>。</p>
<p><code>key passwd</code> 完成之後、容器還沒拿到新密碼之前，容器裡的 <code>RESTIC_PASSWORD</code> 仍是舊的，實測這時 <code>docker compose exec restic restic cat config</code> 回傳 12：備份迴圈剛好在這段時間醒來的話，那一次備份會失敗。正式的 sidecar 跑的是上一節 <code>entry-after.sh</code> 這類 <code>set -e</code> 的腳本時，失敗的那一次備份會讓腳本結束、容器停下，restart 政策再用舊密碼把它帶起來，啟動檢查回傳 12、再結束；這時 <code>docker compose exec</code> 進不去正在重啟的容器（從前面量到的行為推論，沒有實跑）。所以改 <code>.env</code> 與 <code>up -d</code> 重建容器要緊接著做；要避開這段時間窗，也可以在 <code>key passwd</code> 之前先暫停備份迴圈（例如把排程改成換完才繼續）。</p>
<p><code>.env</code> 用文字編輯器改，<code>RESTIC_PASSWORD=</code> 後面接 <code>/tmp/restic-new-pw</code> 的內容，不加引號：Compose 的 <code>.env</code> 以第一個 <code>=</code> 切開鍵與值，base64 裡的 <code>/</code>、<code>+</code>、<code>=</code> 都照字面帶入（實測 <code>RESTIC_PASSWORD=ab/cd+ef==</code> 在容器裡 <code>printenv</code> 印出 <code>ab/cd+ef==</code>）。值寫錯時，下一步的 <code>snapshots</code> 回傳 exit 12。</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"># .env 改好之後，刪掉主機上的暫時檔</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rm /tmp/restic-new-pw
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker compose up -d restic          <span class="c1"># 重建容器，讓它讀到新的 .env</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">docker compose <span class="nb">exec</span> restic restic snapshots --compact</span></span></code></pre></div><p><code>docker compose restart restic</code> 不會讓容器讀到新的 <code>.env</code>：實測改完 <code>.env</code> 之後 <code>restart</code>，<code>docker compose exec restic printenv RESTIC_PASSWORD</code> 印出的仍是舊密碼；<code>up -d</code> 重建容器之後才印出新密碼，<code>snapshots</code> 回傳 exit 0。Docker 的 <code>compose restart</code> 文件也寫明 compose 設定的變更不會在 restart 之後生效。最後把新密碼更新進密碼管理器，理由在 <a href="/blog/backend/07-security-data-protection/backup-encryption-password-and-master-key/" data-link-title="7.45 加密備份的密碼、key 檔與主金鑰：換密碼與換主金鑰的差別、密碼外洩與遺失的處置，以 restic 為例" data-link-desc="加密備份工具把資料交給一把隨機產生的主金鑰加密、密碼只用來解開包著主金鑰的 key 檔：這個分層換到的多份 key 檔與不重新加密的換密碼、換密碼之後舊副本與外流的 key 檔還解得開什麼、什麼條件下要建新 repository 換掉主金鑰，以及密碼遺失時沒有任何找回的路">7.45 加密備份的密碼、key 檔與主金鑰</a> 的〈密碼遺失的後果與事先的準備〉。</p>
<p>已經先改了 <code>.env</code> 而備份停擺時，把 <code>.env</code> 改回舊密碼、<code>up -d</code>，等備份恢復，再照上面的順序換。舊密碼也想不起來時，只要 repository 還有另一份 key 檔（<code>restic key add</code> 加過的），可以用那一組密碼打開並執行 <code>key passwd</code>；沒有的話，這個 repository 就打不開了。這一段的補救順序是照前面量到的行為推出來的，沒有實跑。</p>
<h2 id="密碼可能外洩時建新-repository-並用-restic-copy-搬快照">密碼可能外洩時：建新 repository 並用 restic copy 搬快照</h2>
<p>換密碼擋不住已經拿到舊 key 檔與舊密碼的人，這一點先實測一次。<code>rlab-repo</code> 在前面已經有一個快照 <code>d46a3984</code>：</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"># 換密碼之前，把 keys/ 底下的 key 檔另存一份，模擬它已經外流</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">docker run --rm -v rlab-repo:/repo -v &lt;實驗目錄&gt;:/lab alpine:3.22 sh -c <span class="s2">&#34;cp /repo/keys/* /lab/stolen-keys/&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">./r.sh rlab-repo pw-old key passwd --new-password-file /lab/pw-new
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">./r.sh rlab-repo pw-old cat config     <span class="c1"># exit 12：舊密碼打不開</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">./r.sh rlab-repo pw-new key list       <span class="c1"># 只剩一份 key 檔，是新的那一份</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nb">echo</span> later &gt; data/c.txt
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">./r.sh rlab-repo pw-new backup --host lab /lab/data   <span class="c1"># 換密碼之後的新快照 af2360cc</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 之後對方又取得一份 repository 副本：整個複製到 rlab-attacker，再把外流的舊 key 檔放回它的 keys/</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">docker run --rm -v rlab-repo:/src:ro -v rlab-attacker:/dst -v &lt;實驗目錄&gt;:/lab alpine:3.22 <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  sh -c <span class="s2">&#34;cp -a /src/. /dst/ &amp;&amp; cp /lab/stolen-keys/* /dst/keys/&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">./r.sh rlab-attacker pw-old snapshots --compact
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># d46a3984  2026-11-03 10:08:48  lab  12 B</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># af2360cc  2026-11-03 10:09:07  lab  18 B</span></span></span></code></pre></div><p>舊密碼加舊 key 檔列得出換密碼之後才建立的 <code>af2360cc</code>。要讓之後的備份不再被讀到，就要換掉主金鑰：建一個新的 repository，再把快照搬過去：</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"># 新 repository 用新密碼 pw-fresh；--copy-chunker-params 沿用舊 repository 的切塊參數</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">docker run --rm -v rlab-repo:/old:ro -v rlab-repo2:/repo -v &lt;實驗目錄&gt;:/lab <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  -e <span class="nv">RESTIC_REPOSITORY</span><span class="o">=</span>/repo -e <span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span>/lab/pw-fresh restic/restic:0.19.1 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  init --from-repo /old --from-password-file /lab/pw-new --copy-chunker-params
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 舊 repository 唯讀掛載，所以 copy 要加 --no-lock（原因在〈唯讀掛載的還原演練〉一節）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">docker run --rm -v rlab-repo:/old:ro -v rlab-repo2:/repo -v &lt;實驗目錄&gt;:/lab <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  -e <span class="nv">RESTIC_REPOSITORY</span><span class="o">=</span>/repo -e <span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span>/lab/pw-fresh restic/restic:0.19.1 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  copy --no-lock --from-repo /old --from-password-file /lab/pw-new
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># snapshot 968bdb1a saved, copied from source snapshot d46a3984</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># snapshot 269c1412 saved, copied from source snapshot af2360cc</span></span></span></code></pre></div><p><code>--from-repo</code> 是舊 repository 的位置，<code>--from-password-file</code> 是能打開它的密碼；<code>RESTIC_REPOSITORY</code> 與 <code>RESTIC_PASSWORD_FILE</code> 指的是新的那一個。<code>--copy-chunker-params</code> 讓之後直接備份到新 repository 的資料，能跟搬過來的快照做重複資料刪除；參數不同時，換過去之後第一次備份會把同樣的內容再存一次。舊 repository 以唯讀掛載，搬遷過程不會改到它；可寫掛載時 <code>--no-lock</code> 就不需要，這種掛法這次沒有實跑。快照在新 repository 裡拿到新的 ID，內容用新的主金鑰重新加密。</p>
<p>外流的舊 key 檔對新 repository 沒有用，這一點也實測一次：</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"># 把外流的舊 key 檔放進新 repository 的 keys/，再用舊密碼打開</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run --rm -v rlab-repo2:/repo -v &lt;實驗目錄&gt;:/lab alpine:3.22 sh -c <span class="s2">&#34;cp /lab/stolen-keys/* /repo/keys/&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">./r.sh rlab-repo2 pw-old cat config
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Fatal: config or key 6c1573d9… is damaged: ciphertext verification failed</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># exit 1</span></span></span></code></pre></div><p>舊 key 檔解出的是舊主金鑰，對新 repository 的設定檔驗證不過。這一步只是為了驗證，做完把那份舊 key 檔從新 repository 的 <code>keys/</code> 刪掉（<code>restic key list</code> 看得到它的 ID，用 <code>restic key remove &lt;ID&gt;</code>，或直接刪掉那個檔案）。</p>
<p>搬完之後，備份程式改指到新 repository、新密碼更新進密碼管理器，舊 repository 與它的每一份副本都要刪掉；舊的那一份只要還在某處，拿到它的人就讀得到搬遷之前的全部快照。</p>
<p>這次實驗第一次跑 <code>copy</code> 時沒有加 <code>--no-lock</code>，它幾分鐘內都沒有結果，日誌裡是不斷重試寫鎖檔的 <code>read-only file system</code>，原因跟〈唯讀掛載的還原演練〉一節的 restore 相同。</p>
<h2 id="唯讀掛載的還原演練restore-重試十幾分鐘才失敗與-no-lock">唯讀掛載的還原演練：restore 重試十幾分鐘才失敗與 &ndash;no-lock</h2>
<p>還原演練時把 repository 以唯讀掛載，可以保證演練不會改到正式的備份。下面的 <code>rlab-repo2</code> 是前一節建的新 repository、<code>pw-fresh</code> 是它的密碼檔、<code>&lt;實驗目錄&gt;</code> 掛在容器的 <code>/lab</code>；在自己的環境裡，換成自己 repository 的 volume 或路徑，以及自己的密碼來源（<code>RESTIC_PASSWORD</code> 或 <code>RESTIC_PASSWORD_FILE</code>）。restic 的大多數指令在開始之前會在 repository 的 <code>locks/</code> 寫一個鎖檔，唯讀掛載寫不進去，restic 就把它當成暫時的錯誤、等一下再試：</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">docker run -d --name rlab-restore-ro -v rlab-repo2:/repo:ro -v &lt;實驗目錄&gt;:/lab <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -e <span class="nv">RESTIC_REPOSITORY</span><span class="o">=</span>/repo -e <span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span>/lab/pw-fresh restic/restic:0.19.1 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  restore latest --target /tmp/out
</span></span><span class="line"><span class="ln">4</span><span class="cl">docker logs rlab-restore-ro
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># Save(&lt;lock/39f5ccb412&gt;) returned error, retrying after 1.442488361s: open /repo/locks/…-tmp-…: read-only file system</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># …</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># Save(&lt;lock/39f5ccb412&gt;) returned error, retrying after 39.164765789s: …</span></span></span></code></pre></div><p>這一次的兩分鐘內它重試了八次，間隔從 1 秒拉長到 39 秒；間隔是隨機的指數退避，另一次重做時前幾次的間隔是 1、2、5、27 秒，接著就超過一分鐘，次數每次都不同。restic 從 0.17.0 起，對儲存端的請求最多重試 15 分鐘（<a href="https://github.com/restic/restic/releases/tag/v0.17.0">0.17.0 的變更紀錄</a>，#4627）；另一次重做讓它跑完，restore 在約 15 分鐘後以 exit 1 結束，最後印出 <code>unable to create lock in backend: … read-only file system</code>。這段期間容器一直是 running，錯誤訊息只印在 stderr（<code>docker logs</code> 看得到；restic 的 stdout 是空的）。腳本把 stderr 丟掉、或只看畫面最後一行的時候，看到的就是一個十幾分鐘沒有輸出的還原。</p>
<p>加上 <code>--no-lock</code> 之後 restic 不寫鎖檔，同一個唯讀掛載的還原在一秒內完成：</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">docker run --rm -v rlab-repo2:/repo:ro -v &lt;實驗目錄&gt;:/lab <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -e <span class="nv">RESTIC_REPOSITORY</span><span class="o">=</span>/repo -e <span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span>/lab/pw-fresh restic/restic:0.19.1 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  restore --no-lock latest --target /tmp/out
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Summary: Restored 5 files/dirs (18 B) in 0:00</span></span></span></code></pre></div><p><code>--target /tmp/out</code> 是容器裡的路徑，容器以 <code>--rm</code> 結束時還原出來的檔案跟著消失；演練要檢查還原出來的內容時，把它換成掛載進來的主機目錄（例如 <code>--target /lab/restored</code>）。</p>
<p>另一種做法是可寫掛載、不加 <code>--no-lock</code>：restore 照常寫鎖檔，跟正式的備份容器互相知道對方在做什麼；代價是演練會在正式的 repository 裡寫入鎖檔，掛載的權限也大到改得了備份，這種做法沒有實跑。</p>
<p><code>--no-lock</code> 的代價是 restic 不再知道有沒有別的程序同時在改 repository：演練當下正式的備份容器剛好在跑 <code>forget --prune</code>（依保留規則刪除快照、並移除不再被任何快照引用的資料），被刪掉的資料區塊可能正是還原要讀的那幾塊（這是從鎖的用途推論的，沒有實跑）。唯讀掛載只擋得住演練去改 repository，擋不住別的程序改，所以演練排在備份與清理都不執行的時段，或先暫停備份容器。<code>copy</code> 的來源 repository 唯讀掛載時同樣要加 <code>--no-lock</code>，這次實測加了之後兩個快照都搬成功。</p>
<h2 id="清理">清理</h2>
<p>實驗留下的容器、Compose 專案與 volume 移除之前，先列出名稱確認都是這次建的：</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">docker rm -f rlab-restore-ro                     <span class="c1"># 還在跑的話</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker compose -f &lt;實驗目錄&gt;/compose/compose.yml down
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker volume ls -q <span class="p">|</span> grep -E <span class="s1">&#39;^rlab[-_]&#39;</span>        <span class="c1"># rlab- 是手動建的，rlab_ 是 Compose 專案 rlab 的</span></span></span></code></pre></div><p>確認清單之後，用清單上的名稱逐一刪除（<code>docker volume rm rlab-repo rlab-repo2 rlab_repo …</code>）。實驗目錄裡的密碼檔、<code>r.sh</code>、兩支 entry 腳本與 <code>stolen-keys/</code> 一起刪掉。</p>
<h2 id="未涵蓋的範圍物件儲存的-repository排程與離線保管的備援密碼">未涵蓋的範圍：物件儲存的 repository、排程與離線保管的備援密碼</h2>
<ul>
<li><strong>repository 放在物件儲存或 rest-server</strong>：這次的 repository 都是本機的 Docker volume。append-only 的 rest-server 上 <code>key passwd</code> 刪不掉舊 key 檔、舊密碼仍然打得開（另外實測過），這會改變換密碼的判斷，寫在 <a href="/blog/backend/07-security-data-protection/backup-encryption-password-and-master-key/" data-link-title="7.45 加密備份的密碼、key 檔與主金鑰：換密碼與換主金鑰的差別、密碼外洩與遺失的處置，以 restic 為例" data-link-desc="加密備份工具把資料交給一把隨機產生的主金鑰加密、密碼只用來解開包著主金鑰的 key 檔：這個分層換到的多份 key 檔與不重新加密的換密碼、換密碼之後舊副本與外流的 key 檔還解得開什麼、什麼條件下要建新 repository 換掉主金鑰，以及密碼遺失時沒有任何找回的路">7.45 加密備份的密碼、key 檔與主金鑰</a> 的適用條件表之後。物件儲存的存取憑證是否覆寫得了既有備份、append-only 的 rest-server，在 <a href="/blog/backend/07-security-data-protection/cross-network-connection-security/" data-link-title="7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄" data-link-desc="地端主機與雲端主機、或兩個雲端業者的主機以 VPN 或存取金鑰接起來之後的安全設計與評估：連線清單上各台連得到什麼、每一台各假設一次被入侵、限制要放在被入侵端沒有寫入路徑的地方、連線的發起方向與流入的資料、VPN 之內的應用層認證、監控主機與管理者裝置的位置、WireGuard 與 Tailscale 的金鑰與控制平面、異地備份的寫入憑證，以及連線本身的紀錄">7.43 兩個網路之間的連線安全</a> 的異地備份那一段。</li>
<li><strong>每小時備份與每天清理的排程</strong>：這次只跑單次的 <code>backup</code>，排程迴圈與 <code>forget --prune</code> 的保留參數沒有實測。</li>
<li><strong>離線保管的備援密碼</strong>：<code>restic key add</code> 以另一組密碼再包一份 key 檔、那組密碼離線保管，這次沒有實跑，判斷方式在 <a href="/blog/backend/07-security-data-protection/backup-encryption-password-and-master-key/" data-link-title="7.45 加密備份的密碼、key 檔與主金鑰：換密碼與換主金鑰的差別、密碼外洩與遺失的處置，以 restic 為例" data-link-desc="加密備份工具把資料交給一把隨機產生的主金鑰加密、密碼只用來解開包著主金鑰的 key 檔：這個分層換到的多份 key 檔與不重新加密的換密碼、換密碼之後舊副本與外流的 key 檔還解得開什麼、什麼條件下要建新 repository 換掉主金鑰，以及密碼遺失時沒有任何找回的路">7.45 加密備份的密碼、key 檔與主金鑰</a> 的〈密碼遺失的後果與事先的準備〉。</li>
</ul>
]]></content:encoded></item></channel></rss>