<?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>Postgresql on Wilson Wu</title><link>https://wilsonwu.me/zh/tags/postgresql/</link><description>Recent content in Postgresql on Wilson Wu</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Fri, 14 Aug 2026 00:00:00 -0400</lastBuildDate><atom:link href="https://wilsonwu.me/zh/tags/postgresql/index.xml" rel="self" type="application/rss+xml"/><item><title>使用 Azure 托管 PostgreSQL 降低自建数据库的安全风险</title><link>https://wilsonwu.me/zh/blog/2026/azure-managed-postgresql-vs-self-hosted-security/</link><pubDate>Fri, 14 Aug 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/azure-managed-postgresql-vs-self-hosted-security/</guid><description>&lt;h2 id="问题的背景"&gt;问题的背景&lt;/h2&gt;
&lt;p&gt;&lt;img alt="PostgreSQL 安全问题" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/azure-managed-postgresql-vs-self-hosted-security/1-overview.png"&gt;&lt;/p&gt;
&lt;p&gt;最近，一台部署在 Azure 上的 Linux 服务器突然出现 CPU 持续 100% 的情况。排查后发现，服务器上存在一个以 &lt;code&gt;postgres&lt;/code&gt; 用户身份运行的恶意程序，并通过 &lt;code&gt;cron&lt;/code&gt; 定时任务维持运行。&lt;/p&gt;</description><content:encoded><![CDATA[<h2 id="问题的背景">问题的背景</h2>
<p><img alt="PostgreSQL 安全问题" loading="lazy" src="/zh/blog/2026/azure-managed-postgresql-vs-self-hosted-security/1-overview.png"></p>
<p>最近，一台部署在 Azure 上的 Linux 服务器突然出现 CPU 持续 100% 的情况。排查后发现，服务器上存在一个以 <code>postgres</code> 用户身份运行的恶意程序，并通过 <code>cron</code> 定时任务维持运行。</p>
<p>这台服务器上的 PostgreSQL：</p>
<ul>
<li>没有通过 Azure NSG 向公网开放 <code>5432</code>；</li>
<li>没有被当前业务使用；</li>
<li>业务实际使用的是本机 MySQL；</li>
<li>但 PostgreSQL 服务仍然安装并运行；</li>
<li>系统中保留了 <code>postgres</code> 用户、主目录、cron 和历史脚本。</li>
</ul>
<p>这次事件说明：</p>

<blockquote>
  <p>PostgreSQL 没有向公网开放，并不代表服务器上与 PostgreSQL 相关的组件完全没有风险。</p>

</blockquote>
<p>需要特别说明的是，目前的证据只能证明恶意程序利用了 <code>postgres</code> 操作系统用户及其目录进行持久化，<strong>不能证明攻击者一定是通过 PostgreSQL 的 5432 端口进入服务器的</strong>。初始入口也可能是历史配置、SSH、Web 应用、容器、旧镜像或其他漏洞。</p>
<p>如果生产环境确实需要 PostgreSQL，但没有必须自建数据库服务器的特殊要求，建议优先使用：</p>

<blockquote>
  <p><strong>Azure Database for PostgreSQL，并通过私有网络访问，关闭公共网络入口。</strong></p>

</blockquote>
<p>这样可以减少自建数据库所带来的操作系统用户、SSH、cron、systemd、补丁、备份和主机入侵等风险。</p>
<h2 id="发现问题服务器-cpu-持续占满">发现问题：服务器 CPU 持续占满</h2>
<p>最初是在服务器上执行 <code>htop</code> 时发现异常。</p>
<p><img alt="服务器 htop 中 CPU 占用异常" loading="lazy" src="/zh/blog/2026/azure-managed-postgresql-vs-self-hosted-security/2-htop.png"></p>
<p>从截图中可以看到：</p>
<ul>
<li>服务器共有 4 个 CPU 核心；</li>
<li>4 个核心全部达到 <code>100%</code>；</li>
<li>Load Average 长期维持在 4 左右；</li>
<li>多个进程以 <code>postgres</code> 用户运行；</li>
<li>进程名称为随机字符串 <code>dzZ7yVrxv7</code>；</li>
<li>单个进程的 CPU 使用率超过 <code>200%</code>；</li>
<li>进程占用了大量内存。</li>
</ul>
<p>进一步执行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo ps -eo user,pid,ppid,lstart,etime,%cpu,%mem,stat,comm,args <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --sort<span style="color:#f92672">=</span>-%cpu | head -20
</span></span></code></pre></div><p>其中一个主要进程如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>USER       PID   PPID  %CPU  %MEM  STAT  COMMAND
</span></span><span style="display:flex;"><span>postgres   9918     1   377  29.8  Ssl   dzZ7yVrxv7
</span></span></code></pre></div><p>这不是正常的 PostgreSQL 进程。</p>
<p>正常情况下，PostgreSQL 进程名称通常类似：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>postgres
</span></span><span style="display:flex;"><span>postgres: checkpointer
</span></span><span style="display:flex;"><span>postgres: background writer
</span></span><span style="display:flex;"><span>postgres: walwriter
</span></span><span style="display:flex;"><span>postgres: autovacuum launcher
</span></span></code></pre></div><p>而本次进程具有以下异常特征：</p>
<ul>
<li>名称为随机字符串；</li>
<li>以 <code>postgres</code> 用户运行；</li>
<li>CPU 使用率接近 4 个核心；</li>
<li>父进程已经变成 PID 1；</li>
<li>内存占用异常高。</li>
</ul>
<p>因此，初步判断这不是正常数据库负载，而是疑似挖矿程序或其他恶意程序。</p>
<h2 id="分析问题确认恶意程序来源">分析问题：确认恶意程序来源</h2>
<h3 id="1-检查进程真实文件">1. 检查进程真实文件</h3>
<p>Linux 进程名称可以被修改或伪装，所以不能只看 <code>COMMAND</code>，需要检查 <code>/proc/&lt;PID&gt;/exe</code>。</p>
<p>执行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo sh -c <span style="color:#e6db74">&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">echo &#34;EXE:&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">ls -l /proc/9918/exe
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">echo &#34;CWD:&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">ls -l /proc/9918/cwd
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">echo &#34;CMDLINE:&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">tr &#34;\0&#34; &#34; &#34; &lt; /proc/9918/cmdline
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">echo
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">echo &#34;CGROUP:&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">cat /proc/9918/cgroup
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#39;</span>
</span></span></code></pre></div><p>输出如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>EXE:
</span></span><span style="display:flex;"><span>/proc/9918/exe -&gt; /dev/shm/gJppJl6TbG (deleted)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>CWD:
</span></span><span style="display:flex;"><span>/proc/9918/cwd -&gt; /var/lib/postgresql
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>CMDLINE:
</span></span><span style="display:flex;"><span>dzZ7yVrxv7
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>CGROUP:
</span></span><span style="display:flex;"><span>0::/system.slice/cron.service
</span></span></code></pre></div><p>这几项信息非常关键。</p>
<h3 id="可执行文件位于-devshm">可执行文件位于 <code>/dev/shm</code></h3>
<p>真实程序路径为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/dev/shm/gJppJl6TbG
</span></span></code></pre></div><p><code>/dev/shm</code> 是基于内存的临时文件系统。恶意程序经常将文件下载到这里，再从内存文件系统中执行，以减少在磁盘上的痕迹。</p>
<h3 id="原文件已经被删除">原文件已经被删除</h3>
<p>路径末尾显示：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>(deleted)
</span></span></code></pre></div><p>说明程序启动后，磁盘上的原文件已被删除。但由于进程仍然持有该文件，程序依然可以继续运行。</p>
<p>这种“启动后删除自身”的行为是常见的恶意程序隐藏手段。</p>
<h3 id="工作目录属于-postgresql-用户">工作目录属于 PostgreSQL 用户</h3>
<p>进程工作目录是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/var/lib/postgresql
</span></span></code></pre></div><p>这说明恶意程序与 <code>postgres</code> 用户的主目录有关。</p>
<h3 id="进程由-cron-启动">进程由 cron 启动</h3>
<p>cgroup 显示：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/system.slice/cron.service
</span></span></code></pre></div><p>这说明程序是通过定时任务启动的。</p>
<h3 id="2-从运行中的进程保存样本">2. 从运行中的进程保存样本</h3>
<p>因为原始文件已经被删除，如果立即结束进程，可能无法再提取样本。因此先从 <code>/proc</code> 保存程序：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo mkdir -p /root/incident
</span></span><span style="display:flex;"><span>sudo chmod <span style="color:#ae81ff">700</span> /root/incident
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>sudo cp -L /proc/9918/exe <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/suspect-9918.bin
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>sudo chmod <span style="color:#ae81ff">600</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/suspect-9918.bin
</span></span></code></pre></div><p>检查文件类型：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo file /root/incident/suspect-9918.bin
</span></span></code></pre></div><p>结果如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ELF 64-bit LSB shared object, x86-64,
</span></span><span style="display:flex;"><span>statically linked, no section header
</span></span></code></pre></div><p>计算 SHA-256：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo sha256sum /root/incident/suspect-9918.bin
</span></span></code></pre></div><p>得到：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>d32f9eef63c496a146a6a811677dd27e337db993d6f1a4b1af145c3135d391b3
</span></span></code></pre></div><p>该文件具有以下特征：</p>
<ul>
<li>随机文件名；</li>
<li>位于 <code>/dev/shm</code>；</li>
<li>静态链接；</li>
<li>没有节区表；</li>
<li>启动后删除原文件；</li>
<li>持续占用全部 CPU。</li>
</ul>
<p>这些行为进一步证明它不是正常的 PostgreSQL 组件。</p>
<h3 id="3-检查-postgresql-用户的定时任务">3. 检查 PostgreSQL 用户的定时任务</h3>
<p>由于进程是从 <code>cron.service</code> 启动的，继续检查 <code>postgres</code> 用户的 crontab：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo crontab -u postgres -l
</span></span></code></pre></div><p>发现如下任务：</p>
<pre tabindex="0"><code class="language-cron" data-lang="cron">24 * * * * /var/lib/postgresql/.dbus-XGodM5jOyL
</code></pre><p>该任务会在每小时第 24 分钟执行一次隐藏脚本：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/var/lib/postgresql/.dbus-XGodM5jOyL
</span></span></code></pre></div><p>文件名使用了以点开头的隐藏形式，并模仿了 D-Bus 的命名方式，很容易被误认为系统文件。</p>
<p>检查 cron 日志：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo journalctl -u cron.service <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --since <span style="color:#e6db74">&#34;2026-08-15 07:15:00&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --until <span style="color:#e6db74">&#34;2026-08-15 09:15:00&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-pager
</span></span></code></pre></div><p>日志中可以看到：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Aug 15 07:24:01 CRON: (postgres) CMD (/var/lib/postgresql/.dbus-XGodM5jOyL)
</span></span><span style="display:flex;"><span>Aug 15 08:24:01 CRON: (postgres) CMD (/var/lib/postgresql/.dbus-XGodM5jOyL)
</span></span></code></pre></div><p>这证明恶意脚本至少在 <code>07:24</code> 和 <code>08:24</code> 被执行过。</p>
<h3 id="4-分析恶意启动脚本">4. 分析恶意启动脚本</h3>
<p>检查文件：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo file /var/lib/postgresql/.dbus-XGodM5jOyL
</span></span><span style="display:flex;"><span>sudo stat /var/lib/postgresql/.dbus-XGodM5jOyL
</span></span><span style="display:flex;"><span>sudo sha256sum /var/lib/postgresql/.dbus-XGodM5jOyL
</span></span></code></pre></div><p>结果显示它是一个 Bash 脚本：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Bourne-Again shell script, ASCII text executable,
</span></span><span style="display:flex;"><span>with very long lines
</span></span></code></pre></div><p>对应 SHA-256：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>36b1dcf43876f55aae2b8be7dada848b41f60ec61cd4ca7ab2a1278193f286f0
</span></span></code></pre></div><p>这份脚本负责下载、启动或恢复恶意程序，也是恶意进程能够周期性出现的主要持久化机制。</p>
<p>如果只结束高 CPU 进程，而不清理这条 cron，恶意程序会在下一个小时的第 24 分钟再次出现。</p>
<h3 id="5-在用户目录中发现第二个恶意下载器">5. 在用户目录中发现第二个恶意下载器</h3>
<p>停止并卸载 PostgreSQL 后，我继续检查 <code>/var/lib/postgresql</code> 的残留文件：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo find /var/lib/postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -xdev -maxdepth <span style="color:#ae81ff">10</span> -ls
</span></span></code></pre></div><p>发现了另一个可疑文件：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/var/lib/postgresql/.config/systemd/user/
</span></span><span style="display:flex;"><span>└── systemd-tmpfiles-cleanup/
</span></span><span style="display:flex;"><span>    └── systemd-tmpfiles-cleanup-TNWow7.sh
</span></span></code></pre></div><p>这个文件使用了：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>systemd-tmpfiles-cleanup
</span></span></code></pre></div><p>作为目录名，试图伪装成正常的 systemd 临时文件清理组件。</p>
<p>检查文件类型和哈希：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo file <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/lib/postgresql/.config/systemd/user/systemd-tmpfiles-cleanup/systemd-tmpfiles-cleanup-TNWow7.sh
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>sudo sha256sum <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/lib/postgresql/.config/systemd/user/systemd-tmpfiles-cleanup/systemd-tmpfiles-cleanup-TNWow7.sh
</span></span></code></pre></div><p>结果为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Bourne-Again shell script, ASCII text executable,
</span></span><span style="display:flex;"><span>with very long lines
</span></span></code></pre></div><p>SHA-256：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>9962de5e0c68ea6e85879c5f62f2d0806360dbe186dc15e1d59abf8e4209de11
</span></span></code></pre></div><p>脚本内容经过 Base64 编码，末尾直接执行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>base64 -d | bash
</span></span></code></pre></div><p>解码后的逻辑显示，它具备以下行为：</p>
<ul>
<li>通过 Tor、SOCKS5 或 Tor2web 连接外部地址；</li>
<li>获取服务器公网 IP；</li>
<li>收集当前用户名、主机名和内核版本；</li>
<li>读取机器 ID；</li>
<li>从外部下载其他程序；</li>
<li>在 <code>/dev/shm</code>、<code>/tmp</code>、<code>/var/tmp</code> 等目录测试和执行文件；</li>
<li>启动程序后删除临时文件。</li>
</ul>
<p>这已经不只是一个单纯的高 CPU 进程，而是一套包含下载、执行、隐藏和持久化能力的恶意程序。</p>
<h2 id="为什么-postgresql-没有开放公网端口仍然存在风险">为什么 PostgreSQL 没有开放公网端口，仍然存在风险？</h2>
<p>Azure 网络安全组当时只允许公网访问：</p>
<ul>
<li>TCP 22；</li>
<li>TCP 80；</li>
<li>TCP 443。</li>
</ul>
<p>并没有允许公网直接访问 PostgreSQL 的 <code>5432</code> 端口。</p>
<p>但检查服务器本机监听状态时发现：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo ss -lntp | grep <span style="color:#e6db74">&#39;:5432&#39;</span>
</span></span></code></pre></div><p>输出为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>LISTEN 0 244 0.0.0.0:5432 0.0.0.0:*
</span></span><span style="display:flex;"><span>LISTEN 0 244 [::]:5432    [::]:*
</span></span></code></pre></div><p>这说明 PostgreSQL 在操作系统层面监听了所有 IPv4 和 IPv6 网卡。</p>
<p>虽然 Azure NSG 没有向公网放行 <code>5432</code>，但以下风险依然存在。</p>
<h3 id="1-同一虚拟网络中的主机仍可能访问">1. 同一虚拟网络中的主机仍可能访问</h3>
<p>Azure NSG 默认存在 <code>AllowVnetInBound</code> 规则，同一虚拟网络中的其他服务器可能访问该端口。</p>
<p>如果同一 VNet 内的其他服务器被入侵，攻击者就可能通过内网继续攻击数据库服务器。</p>
<h3 id="2-本机应用或容器仍可能访问">2. 本机应用或容器仍可能访问</h3>
<p>即使公网无法连接，服务器本机运行的以下组件仍然可能访问 PostgreSQL：</p>
<ul>
<li>Web 应用；</li>
<li>Docker 容器；</li>
<li>CI/CD Agent；</li>
<li>其他被攻陷的服务；</li>
<li>本机恶意程序。</li>
</ul>
<p>网络安全组无法阻止服务器本机进程访问本机数据库。</p>
<h3 id="3-风险不只来自数据库端口">3. 风险不只来自数据库端口</h3>
<p>本次恶意程序的实际持久化位置是：</p>
<ul>
<li><code>postgres</code> 用户的 crontab；</li>
<li><code>/var/lib/postgresql</code> 用户目录；</li>
<li>用户级 systemd 配置目录；</li>
<li><code>/dev/shm</code> 临时执行目录。</li>
</ul>
<p>即使 PostgreSQL 服务停止，只要这些用户、目录和定时任务仍然存在，恶意程序就可能继续运行。</p>
<p>所以，数据库的安全边界不只是 <code>5432</code> 端口，还包括：</p>
<ul>
<li>操作系统账户；</li>
<li>数据目录；</li>
<li>用户主目录；</li>
<li>cron；</li>
<li>systemd；</li>
<li>SSH；</li>
<li>软件包；</li>
<li>配置文件；</li>
<li>历史镜像和备份。</li>
</ul>
<h3 id="4-未使用的软件往往更容易被忽略">4. 未使用的软件往往更容易被忽略</h3>
<p>这台服务器的业务实际使用 MySQL，PostgreSQL 并没有业务用途。</p>
<p>由于“不使用”，它反而容易长期处于以下状态：</p>
<ul>
<li>没有人检查配置；</li>
<li>没有人检查系统用户；</li>
<li>没有人检查数据库日志；</li>
<li>没有人检查用户的 cron；</li>
<li>没有人检查相关目录；</li>
<li>没有人确认是否仍然需要；</li>
<li>没有人关注其补丁和版本。</li>
</ul>
<p>因此：</p>

<blockquote>
  <p>未使用但仍然安装和运行的软件，并不是备用能力，而是未被管理的攻击面。</p>

</blockquote>
<h2 id="解决问题清除恶意程序并卸载-postgresql">解决问题：清除恶意程序并卸载 PostgreSQL</h2>
<h3 id="1-暂停恶意进程">1. 暂停恶意进程</h3>
<p>取证完成后，先暂停进程：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo kill -STOP <span style="color:#ae81ff">9918</span>
</span></span></code></pre></div><p>检查进程状态：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ps -p <span style="color:#ae81ff">9918</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o user,pid,ppid,%cpu,%mem,stat,comm,args
</span></span></code></pre></div><p>状态中出现 <code>T</code>，说明进程已暂停。</p>
<p>暂停的好处是：</p>
<ul>
<li>立即停止继续占用 CPU；</li>
<li>暂时停止潜在网络行为；</li>
<li>保留进程，便于继续调查；</li>
<li>避免在完成取证前丢失内存中的样本。</li>
</ul>
<h3 id="2-备份并删除恶意-cron">2. 备份并删除恶意 cron</h3>
<p>先保存 crontab：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo crontab -u postgres -l <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  &gt; /root/incident/postgres-crontab.txt
</span></span></code></pre></div><p>然后删除：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo crontab -u postgres -r
</span></span></code></pre></div><p>确认已删除：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo crontab -u postgres -l
</span></span></code></pre></div><p>预期输出：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>no crontab for postgres
</span></span></code></pre></div><h3 id="3-隔离恶意启动脚本">3. 隔离恶意启动脚本</h3>
<p>先备份脚本：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo cp -a <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/lib/postgresql/.dbus-XGodM5jOyL <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/cron-payload
</span></span></code></pre></div><p>再从原位置移走：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo mv <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/lib/postgresql/.dbus-XGodM5jOyL <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/original-dbus-XGodM5jOyL
</span></span></code></pre></div><p>限制证据文件权限：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo chown root:root <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/original-dbus-XGodM5jOyL <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/cron-payload
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>sudo chmod <span style="color:#ae81ff">600</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/original-dbus-XGodM5jOyL <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/cron-payload
</span></span></code></pre></div><h3 id="4-结束恶意进程">4. 结束恶意进程</h3>
<p>确认恶意 cron 和启动脚本已隔离后，结束进程：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo kill -KILL <span style="color:#ae81ff">9918</span>
</span></span></code></pre></div><p>检查进程是否消失：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ps -p <span style="color:#ae81ff">9918</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o user,pid,ppid,%cpu,%mem,stat,comm,args
</span></span></code></pre></div><p>随后查看系统负载：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>uptime
</span></span></code></pre></div><p>清理后的负载变为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>load average: 0.36, 2.71, 3.68
</span></span></code></pre></div><p>1 分钟负载已经从 4 以上下降到 <code>0.36</code>。</p>
<p>5 分钟和 15 分钟负载仍然较高是正常的，因为它们反映的是一段时间内的平均值，会逐渐下降。</p>
<h3 id="5-停止并禁用-postgresql">5. 停止并禁用 PostgreSQL</h3>
<p>因为业务并不使用 PostgreSQL，所以先停止并禁止开机启动：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo systemctl disable --now postgresql
</span></span></code></pre></div><p>确认服务状态：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>systemctl status postgresql --no-pager
</span></span></code></pre></div><p>确认端口不再监听：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo ss -lntp | grep <span style="color:#e6db74">&#39;:5432&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#f92672">||</span> echo <span style="color:#e6db74">&#34;5432 端口未监听&#34;</span>
</span></span></code></pre></div><p>最终结果为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>5432 端口未监听
</span></span></code></pre></div><h3 id="6-卸载-postgresql">6. 卸载 PostgreSQL</h3>
<p>确认业务没有受到影响后，卸载 PostgreSQL 相关软件包：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt-get purge -y <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-14 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-client-14 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-client-common <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-common <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-contrib
</span></span></code></pre></div><p>需要注意：</p>

<blockquote>
  <p>卸载软件包不等于完成恶意程序清理。</p>

</blockquote>
<p>还应继续检查以下位置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/var/lib/postgresql
</span></span><span style="display:flex;"><span>/etc/postgresql
</span></span><span style="display:flex;"><span>/etc/postgresql-common
</span></span><span style="display:flex;"><span>/var/spool/cron
</span></span><span style="display:flex;"><span>/tmp
</span></span><span style="display:flex;"><span>/var/tmp
</span></span><span style="display:flex;"><span>/dev/shm
</span></span><span style="display:flex;"><span>/home/*/.config/systemd
</span></span></code></pre></div><p>本次发现的第二个恶意下载器，就是在卸载 PostgreSQL 后检查残留目录时找到的。</p>
<h3 id="7-隔离第二个恶意下载器">7. 隔离第二个恶意下载器</h3>
<p>将伪装成 systemd 清理任务的脚本移入证据目录：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo mv <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/lib/postgresql/.config/systemd/user/systemd-tmpfiles-cleanup/systemd-tmpfiles-cleanup-TNWow7.sh <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/systemd-tmpfiles-cleanup-TNWow7.sh
</span></span></code></pre></div><p>限制权限：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo chown root:root <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/systemd-tmpfiles-cleanup-TNWow7.sh
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>sudo chmod <span style="color:#ae81ff">600</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /root/incident/systemd-tmpfiles-cleanup-TNWow7.sh
</span></span></code></pre></div><p>然后搜索是否存在同类文件：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo grep -RIlE <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;TNWow7|XGodM5jOyL|tor2socks|tor2web|relay\.tor2socks&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /etc <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/spool/cron <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/lib/postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /tmp <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/tmp <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /dev/shm <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /home <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  2&gt;/dev/null
</span></span></code></pre></div><h2 id="azure-网络安全配置整改">Azure 网络安全配置整改</h2>
<h3 id="1-不需要删除不存在的-5432-入站规则">1. 不需要删除不存在的 5432 入站规则</h3>
<p>Azure NSG 中原本没有允许公网访问 <code>5432</code> 的规则，所以不需要专门删除 PostgreSQL 入站规则。</p>
<p>停止并卸载 PostgreSQL 后，本机也已经不再监听 <code>5432</code>。</p>
<p>但是，不能因为 NSG 没有开放 <code>5432</code> 就认为服务器已经安全。</p>
<h3 id="2-收紧-ssh-入站范围">2. 收紧 SSH 入站范围</h3>
<p>排查过程中，SSH 日志中出现了多个公网 IP 对 <code>postgres</code> 用户进行密码尝试：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Failed password for postgres from 172.83.83.194
</span></span><span style="display:flex;"><span>Failed password for postgres from 101.36.110.41
</span></span></code></pre></div><p>这些记录显示的是失败登录，不能说明对方已经通过 SSH 登录成功。但它们说明服务器的 SSH 正在被公网持续扫描。</p>
<p>原 Azure SSH 规则为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Source: Any
</span></span><span style="display:flex;"><span>Destination port: 22
</span></span><span style="display:flex;"><span>Action: Allow
</span></span></code></pre></div><p>建议改为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Source: 管理员固定公网 IP/32
</span></span><span style="display:flex;"><span>Destination port: 22
</span></span><span style="display:flex;"><span>Protocol: TCP
</span></span><span style="display:flex;"><span>Action: Allow
</span></span></code></pre></div><p>更安全的方式包括：</p>
<ul>
<li>Azure Bastion；</li>
<li>VPN；</li>
<li>Just-In-Time VM Access；</li>
<li>禁止 SSH 密码登录；</li>
<li>只使用 SSH Key；</li>
<li>禁止 root 远程登录；</li>
<li>配置失败登录告警。</li>
</ul>
<h3 id="3-限制服务器出站访问">3. 限制服务器出站访问</h3>
<p>恶意脚本需要连接外部地址下载程序，而当时 Azure NSG 允许任意出站访问。</p>
<p>如果业务条件允许，应限制服务器的出站范围：</p>
<ul>
<li>只允许必要的 DNS；</li>
<li>只允许必要的软件源；</li>
<li>只允许业务需要访问的 API；</li>
<li>使用 Azure Firewall 或集中式代理；</li>
<li>记录 DNS 和出站网络日志；</li>
<li>对 Tor、代理、矿池和异常域名建立告警。</li>
</ul>
<p>仅限制入站流量是不够的。恶意程序一旦进入服务器，通常还需要通过出站网络下载载荷、连接控制服务器或矿池。</p>
<h2 id="为什么建议使用-azure-托管-postgresql">为什么建议使用 Azure 托管 PostgreSQL？</h2>
<p>如果业务确实需要 PostgreSQL，建议优先使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Azure Database for PostgreSQL
</span></span></code></pre></div><p>而不是直接在 Azure VM 上安装 PostgreSQL。</p>
<h3 id="1-减少操作系统层面的攻击面">1. 减少操作系统层面的攻击面</h3>
<p>在 VM 上自建 PostgreSQL，需要自行维护：</p>
<ul>
<li>Linux 操作系统；</li>
<li>PostgreSQL 软件包；</li>
<li><code>postgres</code> 系统用户；</li>
<li>SSH；</li>
<li>cron；</li>
<li>systemd；</li>
<li>数据目录权限；</li>
<li>安全补丁；</li>
<li>版本升级；</li>
<li>网络防火墙；</li>
<li>日志；</li>
<li>备份；</li>
<li>高可用；</li>
<li>恶意进程检测。</li>
</ul>
<p>使用 Azure 托管 PostgreSQL 后，用户不会直接管理数据库所在主机的 root 权限，也不会在数据库主机上创建 cron、systemd 服务或运行 <code>/dev/shm</code> 中的程序。</p>
<p>这可以显著减少本次事件中出现的主机级风险。</p>
<h3 id="2-使用私有网络关闭公网访问">2. 使用私有网络，关闭公网访问</h3>
<p>建议采用以下网络架构：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Azure VM / App Service / AKS
</span></span><span style="display:flex;"><span>          |
</span></span><span style="display:flex;"><span>      Private Network
</span></span><span style="display:flex;"><span>          |
</span></span><span style="display:flex;"><span>Azure Database for PostgreSQL
</span></span></code></pre></div><p>数据库应配置为：</p>
<ul>
<li>禁止公共网络访问；</li>
<li>使用 Private Endpoint 或私有网络集成；</li>
<li>只允许应用所在子网访问；</li>
<li>不向公网开放数据库端口；</li>
<li>数据库管理也通过 VPN、Bastion 或受控网络完成。</li>
</ul>
<h3 id="3-自动备份和维护">3. 自动备份和维护</h3>
<p>Azure 托管 PostgreSQL 可以提供：</p>
<ul>
<li>自动备份；</li>
<li>时间点恢复；</li>
<li>高可用选项；</li>
<li>数据加密；</li>
<li>监控指标；</li>
<li>日志与告警；</li>
<li>平台维护；</li>
<li>安全更新；</li>
<li>版本支持。</li>
</ul>
<p>这些能力可以降低因长期无人维护而产生的风险。</p>
<h3 id="4-责任边界更清晰">4. 责任边界更清晰</h3>
<p>自建 PostgreSQL 经常出现这种情况：</p>

<blockquote>
  <p>为了测试临时安装，后来业务不用了，但服务、用户和目录一直留在服务器上。</p>

</blockquote>
<p>托管数据库的资源边界更加清晰：</p>
<ul>
<li>需要时创建；</li>
<li>通过私有网络连接；</li>
<li>不再使用时导出数据；</li>
<li>删除数据库实例；</li>
<li>删除访问权限和私有连接；</li>
<li>不会在业务 VM 上留下数据库系统用户和历史脚本。</li>
</ul>
<h3 id="5-托管服务并不等于绝对安全">5. 托管服务并不等于绝对安全</h3>
<p>Azure 托管 PostgreSQL 可以减少操作系统和基础设施维护风险，但仍需要正确管理：</p>
<ul>
<li>数据库账号；</li>
<li>强密码或身份认证；</li>
<li>最小权限；</li>
<li>网络访问范围；</li>
<li>SQL 注入；</li>
<li>连接字符串；</li>
<li>Azure Key Vault；</li>
<li>日志审计；</li>
<li>数据加密；</li>
<li>应用依赖；</li>
<li>凭据轮换。</li>
</ul>
<p>正确的理解是：</p>

<blockquote>
  <p>Azure 托管 PostgreSQL 缩小了需要自己负责的安全范围，但不会消除应用和账号层面的安全责任。</p>

</blockquote>
<h2 id="本次事件的-ioc">本次事件的 IOC</h2>
<p>以下指标可用于内部服务器排查。</p>
<h3 id="可疑进程及文件名">可疑进程及文件名</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>dzZ7yVrxv7
</span></span><span style="display:flex;"><span>gJppJl6TbG
</span></span><span style="display:flex;"><span>.dbus-XGodM5jOyL
</span></span><span style="display:flex;"><span>systemd-tmpfiles-cleanup-TNWow7.sh
</span></span></code></pre></div><h3 id="sha-256">SHA-256</h3>
<p>恶意 ELF 程序：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>d32f9eef63c496a146a6a811677dd27e337db993d6f1a4b1af145c3135d391b3
</span></span></code></pre></div><p>恶意 cron 启动脚本：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>36b1dcf43876f55aae2b8be7dada848b41f60ec61cd4ca7ab2a1278193f286f0
</span></span></code></pre></div><p>恶意下载器：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>9962de5e0c68ea6e85879c5f62f2d0806360dbe186dc15e1d59abf8e4209de11
</span></span></code></pre></div><h3 id="行为特征">行为特征</h3>
<ul>
<li>以 <code>postgres</code> 用户运行；</li>
<li>从 <code>/dev/shm</code> 执行随机名称文件；</li>
<li>启动后删除原文件；</li>
<li>利用用户 crontab 定时启动；</li>
<li>Base64 解码后直接交给 Bash 执行；</li>
<li>使用 Tor、SOCKS5 或 Tor2web；</li>
<li>收集公网 IP、用户名、主机名和机器 ID；</li>
<li>从外部下载其他程序；</li>
<li>在 <code>/tmp</code>、<code>/var/tmp</code> 和 <code>/dev/shm</code> 中执行程序；</li>
<li>持续占用全部 CPU；</li>
<li>伪装成 D-Bus 或 systemd 组件。</li>
</ul>
<h2 id="最终总结">最终总结</h2>
<p>本次事件从表面上看，是一次服务器 CPU 占用过高的问题。</p>
<p>但深入排查后发现，真正的问题包括：</p>
<ul>
<li>以 <code>postgres</code> 用户运行的恶意程序；</li>
<li>位于 <code>/dev/shm</code> 的随机 ELF 文件；</li>
<li><code>postgres</code> 用户的恶意 cron；</li>
<li><code>/var/lib/postgresql</code> 下的隐藏启动脚本；</li>
<li>伪装成 systemd 组件的恶意下载器；</li>
<li>可连接 Tor 和外部下载地址的恶意逻辑。</li>
</ul>
<p>最值得关注的是：</p>

<blockquote>
  <p>这台服务器没有通过 Azure NSG 向公网开放 PostgreSQL 的 5432 端口，而且业务也没有使用 PostgreSQL，但与 PostgreSQL 有关的系统用户、主目录、cron 和残留文件仍然构成了安全风险。</p>

</blockquote>
<p>当然，现有证据并不能证明初始入侵入口就是 PostgreSQL。恶意程序使用 <code>postgres</code> 用户运行，不等于攻击者一定通过 PostgreSQL 端口进入服务器。</p>
<p>但这次事件依然说明了几个重要原则。</p>
<h3 id="第一不使用的服务应立即停止和卸载">第一，不使用的服务应立即停止和卸载</h3>
<p>不要因为“没有向公网开放”就长期保留闲置服务。</p>
<p>不使用的软件应当：</p>
<ul>
<li>停止服务；</li>
<li>禁止开机启动；</li>
<li>卸载软件包；</li>
<li>检查系统用户；</li>
<li>检查 cron；</li>
<li>检查 systemd；</li>
<li>检查残留目录和脚本。</li>
</ul>
<h3 id="第二云安全组不是唯一安全边界">第二，云安全组不是唯一安全边界</h3>
<p>NSG 没有开放 <code>5432</code>，只能说明公网无法通过该规则直接连接数据库，不能防止：</p>
<ul>
<li>内网访问；</li>
<li>本机访问；</li>
<li>容器访问；</li>
<li>历史持久化；</li>
<li>用户级 cron；</li>
<li>本机恶意程序；</li>
<li>SSH 和应用漏洞。</li>
</ul>
<h3 id="第三生产-postgresql-优先使用-azure-托管服务">第三，生产 PostgreSQL 优先使用 Azure 托管服务</h3>
<p>如果没有必须自建数据库服务器的明确需求，建议使用：</p>

<blockquote>
  <p><strong>Azure Database for PostgreSQL + 私有网络 + 禁止公共访问。</strong></p>

</blockquote>
<p>这样可以减少数据库主机操作系统、SSH、系统用户、cron、systemd、补丁、备份和高可用等方面的维护风险。</p>
<h3 id="第四杀掉恶意进程不等于完成安全恢复">第四，杀掉恶意进程不等于完成安全恢复</h3>
<p>结束高 CPU 进程只解决了当前资源占用问题，还应继续：</p>
<ul>
<li>查找持久化；</li>
<li>调查入侵入口；</li>
<li>检查 SSH 成功登录记录；</li>
<li>检查 Web 应用和容器；</li>
<li>轮换服务器上的全部凭据；</li>
<li>检查同一 VNet 中的其他主机；</li>
<li>保存日志和磁盘快照；</li>
<li>必要时从可信镜像重建服务器。</li>
</ul>
<p>最终可以将这次事件概括为一句话：</p>

<blockquote>
  <p><strong>不使用的数据库不要留在服务器上；需要 PostgreSQL 时，优先使用 Azure 托管 PostgreSQL，并通过私有网络访问。</strong></p>

</blockquote>
]]></content:encoded></item><item><title>OpenWebUI 数据库迁移实践：从 VM 自建 PostgreSQL 平滑迁移到 Azure 托管 PostgreSQL</title><link>https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/</link><pubDate>Thu, 13 Aug 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/</guid><description>&lt;p&gt;&lt;img alt="迁移" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/1-overview.png"&gt;&lt;/p&gt;
&lt;p&gt;随着 OpenWebUI 中的用户、对话、文件和知识库数据不断增长，原先部署在 Azure VM 上的自建 PostgreSQL 带来了越来越多的运维工作，包括数据库补丁、备份、故障恢复、存储监控和安全维护。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="迁移" loading="lazy" src="/zh/blog/2026/migrate-openwebui-to-azure-pg/1-overview.png"></p>
<p>随着 OpenWebUI 中的用户、对话、文件和知识库数据不断增长，原先部署在 Azure VM 上的自建 PostgreSQL 带来了越来越多的运维工作，包括数据库补丁、备份、故障恢复、存储监控和安全维护。</p>
<p>为了降低维护成本并提高数据库可靠性，我将 <strong>OpenWebUI 的业务数据库</strong>从 VM 本地 PostgreSQL 迁移到了 <strong>Azure Database for PostgreSQL Flexible Server</strong>。</p>
<p>这次迁移采用了：</p>

<blockquote>
  <p>在线全量迁移 + 数据一致性验证 + 活跃对话增量同步 + 快速切换</p>

</blockquote>
<p>全量数据库导出和恢复期间，OpenWebUI 始终保持在线。最终切换仅涉及一次很短的 Docker 容器重建，用户侧基本无感。</p>

<blockquote>
  <p>本文中的服务器地址、数据库名称、用户名、密码和资源标识均已隐藏或替换为占位符。</p>

</blockquote>
<h2 id="迁移背景">迁移背景</h2>
<p>OpenWebUI 以 Docker 容器形式运行在 Azure VM 中，最初使用安装在同一台 VM 上的 PostgreSQL。</p>
<p>迁移前的架构如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>用户
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  ▼
</span></span><span style="display:flex;"><span>Azure VM
</span></span><span style="display:flex;"><span>  ├── OpenWebUI Docker 容器
</span></span><span style="display:flex;"><span>  │      └── 连接 VM 宿主机 PostgreSQL
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  └── PostgreSQL
</span></span><span style="display:flex;"><span>         └── 数据保存在 VM 本地磁盘
</span></span></code></pre></div><p>这种部署方式比较简单，但随着应用逐渐稳定运行，数据库维护也成为了新的负担。</p>
<p>需要自行处理的工作包括：</p>
<ul>
<li>PostgreSQL 安全更新和补丁</li>
<li>数据库备份及备份轮换</li>
<li>数据恢复和恢复演练</li>
<li>磁盘容量监控</li>
<li>数据库服务监控</li>
<li>PostgreSQL 版本升级</li>
<li>故障处理</li>
<li>网络和访问权限管理</li>
</ul>
<p>对于 OpenWebUI 这样的应用来说，数据库是重要的状态组件，但并不是应用的核心运维目标。因此，将 PostgreSQL 迁移到 Azure 托管服务是一个更合理的选择。</p>
<h2 id="迁移后的架构">迁移后的架构</h2>
<p>迁移后的架构如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>用户
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  ▼
</span></span><span style="display:flex;"><span>Azure VM
</span></span><span style="display:flex;"><span>  └── OpenWebUI Docker 容器
</span></span><span style="display:flex;"><span>          │
</span></span><span style="display:flex;"><span>          ├── Private Endpoint
</span></span><span style="display:flex;"><span>          │       └── Azure Database for PostgreSQL
</span></span><span style="display:flex;"><span>          │
</span></span><span style="display:flex;"><span>          └── 宿主机持久化目录
</span></span><span style="display:flex;"><span>                  ├── uploads
</span></span><span style="display:flex;"><span>                  ├── vector_db
</span></span><span style="display:flex;"><span>                  └── cache
</span></span></code></pre></div><p>最终实现了：</p>
<ul>
<li>OpenWebUI 使用 Azure 托管 PostgreSQL</li>
<li>数据库通过 Private Endpoint 私网访问</li>
<li>数据库连接启用 TLS</li>
<li>PostgreSQL 不再占用 VM 本地端口和磁盘</li>
<li>OpenWebUI 上传文件和向量数据实现持久化挂载</li>
<li>Azure 自动执行数据库备份</li>
<li>VM 上只保留 PostgreSQL 客户端，用于日常管理 Azure 数据库</li>
</ul>
<p><img alt="架构" loading="lazy" src="/zh/blog/2026/migrate-openwebui-to-azure-pg/2-architecture.png"></p>
<h2 id="为什么选择-azure-database-for-postgresql">为什么选择 Azure Database for PostgreSQL</h2>
<h3 id="1-降低数据库运维成本">1. 降低数据库运维成本</h3>
<p>Azure Database for PostgreSQL Flexible Server 是一项托管数据库服务。</p>
<p>与 VM 自建 PostgreSQL 相比，Azure 可以帮助处理大量底层工作，例如：</p>
<ul>
<li>数据库基础设施维护</li>
<li>自动备份</li>
<li>事务日志备份</li>
<li>时间点恢复</li>
<li>监控指标采集</li>
<li>存储管理</li>
<li>计划性维护</li>
<li>可选高可用</li>
<li>可选只读副本</li>
</ul>
<p>迁移完成后，VM 只需要负责运行 OpenWebUI，数据库生命周期则交给 Azure 管理。</p>
<h3 id="2-自动备份和时间点恢复">2. 自动备份和时间点恢复</h3>
<p>Azure PostgreSQL 提供内置自动备份，并持续备份事务日志，支持时间点恢复（Point-in-Time Restore，PITR）。</p>
<p>当前环境配置了自动备份保留策略。发生误操作时，可以在保留期内创建一台恢复到指定时间点的新服务器。</p>
<p>与自行编写 <code>pg_dump</code> 和 Cron 脚本相比，Azure 托管备份具有以下优势：</p>
<ul>
<li>不需要维护备份脚本</li>
<li>不需要手动轮换备份文件</li>
<li>不会占用 VM 本地磁盘</li>
<li>可以恢复到保留期内的特定时间</li>
<li>备份操作由平台统一管理</li>
</ul>
<p>需要注意的是，Azure 内置备份主要用于平台恢复，不等同于可随意下载的逻辑备份文件。</p>
<p>如果有以下需求，仍建议额外定期执行 <code>pg_dump</code>：</p>
<ul>
<li>长期离线归档</li>
<li>跨平台恢复</li>
<li>数据审计</li>
<li>跨订阅或跨云迁移</li>
<li>保留期超过平台备份策略</li>
</ul>
<h3 id="3-使用-private-endpoint-私网访问">3. 使用 Private Endpoint 私网访问</h3>
<p>本次迁移没有将 PostgreSQL 直接暴露到公网，而是为 Azure PostgreSQL 配置了 Private Endpoint。</p>
<p>OpenWebUI 访问数据库时，数据库域名通过 Private DNS 解析到 VNet 中的私有 IP：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Azure PostgreSQL FQDN
</span></span><span style="display:flex;"><span>        ↓
</span></span><span style="display:flex;"><span>Private DNS
</span></span><span style="display:flex;"><span>        ↓
</span></span><span style="display:flex;"><span>Private Endpoint 私有 IP
</span></span></code></pre></div><p>从 OpenWebUI 容器内部进行了两层验证：</p>
<ol>
<li>DNS 是否解析到私有地址</li>
<li>TCP 5432 是否可以通过私网访问</li>
</ol>
<p>示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker exec -i open-webui python3 - <span style="color:#e6db74">&lt;&lt;&#39;PY&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import socket
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">host = &#34;&lt;AZURE_POSTGRESQL_PRIVATE_FQDN&gt;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">port = 5432
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">addresses = sorted({
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    item[4][0]
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    for item in socket.getaddrinfo(
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        host,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        port,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        type=socket.SOCK_STREAM
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    )
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">})
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;Resolved addresses:&#34;, addresses)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">with socket.create_connection((host, port), timeout=5) as sock:
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    print(&#34;TCP connection: OK&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    print(&#34;Connected peer:&#34;, sock.getpeername())
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">PY</span>
</span></span></code></pre></div><p>数据库安全性并不只是来自 Private Endpoint，而是来自以下措施的组合：</p>
<ul>
<li>Private Endpoint</li>
<li>Private DNS</li>
<li>VNet 隔离</li>
<li>NSG 和路由控制</li>
<li>禁用或限制公网访问</li>
<li>PostgreSQL 身份认证</li>
<li>TLS 链路加密</li>
<li>最小权限数据库账号</li>
</ul>
<h3 id="4-数据库连接启用-tls">4. 数据库连接启用 TLS</h3>
<p>OpenWebUI 的数据库连接串启用了：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>sslmode=require
</span></span></code></pre></div><p>脱敏后的连接串格式如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>postgresql://&lt;APP_USER&gt;:&lt;PASSWORD&gt;@&lt;AZURE_POSTGRESQL_FQDN&gt;:5432/&lt;APP_DATABASE&gt;?sslmode=require
</span></span></code></pre></div><p>迁移前还通过 OpenWebUI 使用的 Python 和 SQLAlchemy 环境实际验证了 TLS：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> os
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sqlalchemy <span style="color:#f92672">import</span> create_engine, text
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>engine <span style="color:#f92672">=</span> create_engine(os<span style="color:#f92672">.</span>environ[<span style="color:#e6db74">&#34;DATABASE_URL&#34;</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">with</span> engine<span style="color:#f92672">.</span>connect() <span style="color:#66d9ef">as</span> conn:
</span></span><span style="display:flex;"><span>    ssl_enabled <span style="color:#f92672">=</span> conn<span style="color:#f92672">.</span>execute(text(<span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        SELECT ssl
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        FROM pg_stat_ssl
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        WHERE pid = pg_backend_pid()
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;&#34;&#34;</span>))<span style="color:#f92672">.</span>scalar_one()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">&#34;TLS enabled:&#34;</span>, ssl_enabled)
</span></span></code></pre></div><p>如果安全要求更高，可以进一步使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>sslmode=verify-full
</span></span></code></pre></div><p>这种模式不仅加密连接，还会验证证书和服务器主机名，但需要正确配置 CA 证书，并使用与证书匹配的服务器 FQDN。</p>
<h3 id="5-更容易扩展可靠性能力">5. 更容易扩展可靠性能力</h3>
<p>迁移到 Azure PostgreSQL 后，可以根据 OpenWebUI 的使用规模继续配置：</p>
<ul>
<li>计算规格扩缩容</li>
<li>存储扩容</li>
<li>可用区高可用</li>
<li>只读副本</li>
<li>慢查询分析</li>
<li>数据库连接监控</li>
<li>CPU、内存和存储告警</li>
<li>更长的备份保留期</li>
<li>异地恢复策略</li>
</ul>
<p>这使 OpenWebUI 的数据库层从“单 VM 上的本地服务”升级为独立、可管理的托管数据服务。</p>
<h2 id="迁移目标让-openwebui-尽量保持在线">迁移目标：让 OpenWebUI 尽量保持在线</h2>
<p>传统迁移通常采用以下流程：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>停止应用
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>导出数据库
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>恢复目标数据库
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>修改连接配置
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>重新启动应用
</span></span></code></pre></div><p>这种方式虽然简单，但数据库越大，停机时间越长。</p>
<p>本次迁移采用了不同策略：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>在线全量备份
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>在线恢复到 Azure PostgreSQL
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>验证结构、权限和数据
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>同步迁移期间新增的活跃对话
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>快速重建 OpenWebUI 容器
</span></span></code></pre></div><p>在以下阶段，OpenWebUI 都保持在线：</p>
<ul>
<li>全量 <code>pg_dump</code></li>
<li>Azure PostgreSQL 数据恢复</li>
<li>表结构验证</li>
<li>数据量对比</li>
<li>Private Endpoint 验证</li>
<li>TLS 验证</li>
<li>增量数据同步</li>
</ul>
<p>最终只有更换 <code>DATABASE_URL</code> 并重建容器时发生了短暂连接中断。</p>
<p>因此，更准确地说，这是一种：</p>

<blockquote>
  <p><strong>近零停机、用户侧近乎无感知的 OpenWebUI 数据库迁移方案。</strong></p>

</blockquote>
<h2 id="迁移前检查-openwebui">迁移前检查 OpenWebUI</h2>
<h3 id="1-确认-openwebui-使用的数据库类型">1. 确认 OpenWebUI 使用的数据库类型</h3>
<p>首先检查 OpenWebUI 容器环境变量：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker inspect open-webui <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --format <span style="color:#e6db74">&#39;{{range .Config.Env}}{{println .}}{{end}}&#39;</span> |
</span></span><span style="display:flex;"><span>grep -E <span style="color:#e6db74">&#39;DATABASE_URL|DATA_DIR|WEBUI_SECRET_KEY&#39;</span>
</span></span></code></pre></div><p>确认 OpenWebUI 已经使用 PostgreSQL，而不是默认 SQLite：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>DATABASE_URL=postgresql://&lt;LOCAL_USER&gt;:***@&lt;LOCAL_HOST&gt;:5432/&lt;LOCAL_DATABASE&gt;
</span></span></code></pre></div><p>由于源端和目标端都是 PostgreSQL，因此可以直接使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>pg_dump → pg_restore
</span></span></code></pre></div><p>不需要使用 SQLite 到 PostgreSQL 的转换工具。</p>
<h3 id="2-检查源数据库">2. 检查源数据库</h3>
<p>检查 PostgreSQL 版本和数据库大小：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo -u postgres psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d &lt;LOCAL_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">SELECT
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  current_database(),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  current_setting(&#39;server_version&#39;),
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  pg_size_pretty(
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    pg_database_size(current_database())
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  );
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>同时检查源数据库使用了哪些扩展：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo -u postgres psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d &lt;LOCAL_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">SELECT extname, extversion
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">FROM pg_extension
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">ORDER BY extname;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>本次源数据库没有使用额外的第三方扩展，因此降低了跨环境迁移的兼容性风险。</p>
<h3 id="3-检查-openwebui-的本地文件">3. 检查 OpenWebUI 的本地文件</h3>
<p>数据库并不是 OpenWebUI 的全部数据。</p>
<p>检查容器挂载后发现，原容器没有配置持久化 volume，而 <code>/app/backend/data</code> 中已经存在大量数据：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/app/backend/data
</span></span><span style="display:flex;"><span>├── cache
</span></span><span style="display:flex;"><span>├── uploads
</span></span><span style="display:flex;"><span>└── vector_db
</span></span></code></pre></div><p>这些内容包括：</p>
<ul>
<li>用户上传文件</li>
<li>向量数据库</li>
<li>缓存</li>
<li>其他应用本地状态</li>
</ul>
<p>它们不会进入 PostgreSQL 的 <code>pg_dump</code>，因此必须单独处理。</p>
<p>这也是本次迁移中一个非常重要的发现：</p>

<blockquote>
  <p>迁移 OpenWebUI 的 PostgreSQL，并不等于迁移了 OpenWebUI 的全部数据。</p>

</blockquote>
<h2 id="准备-azure-postgresql">准备 Azure PostgreSQL</h2>
<h3 id="1-创建独立应用账号">1. 创建独立应用账号</h3>
<p>不建议让 OpenWebUI 长期使用 Azure PostgreSQL 管理员账号。</p>
<p>在目标服务器中创建专用角色：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">ROLE</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span> LOGIN;
</span></span></code></pre></div><p>通过 <code>psql</code> 交互方式设置密码，可以避免密码出现在 Shell 历史记录中：</p>
<pre tabindex="0"><code class="language-psql" data-lang="psql">\password &lt;APP_USER&gt;
</code></pre><p>然后创建目标数据库：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">DATABASE</span> <span style="color:#f92672">&lt;</span>APP_DATABASE<span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">OWNER</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span>;
</span></span></code></pre></div><h3 id="2-验证应用账号">2. 验证应用账号</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;host=&lt;AZURE_POSTGRESQL_FQDN&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   port=5432 \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   dbname=&lt;APP_DATABASE&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   user=&lt;APP_USER&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   sslmode=require&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -W <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;SELECT current_user, current_database();&#34;</span>
</span></span></code></pre></div><p>同时确认目标数据库为空：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> information_schema.tables
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> table_schema <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;public&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> table_type <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;BASE TABLE&#39;</span>;
</span></span></code></pre></div><h3 id="3-处理-public-schema-权限">3. 处理 <code>public</code> schema 权限</h3>
<p>第一次测试恢复时遇到了：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ERROR: permission denied for schema public
</span></span></code></pre></div><p>原因是应用账号虽然可以连接数据库，但没有在 <code>public</code> schema 中创建对象的权限。</p>
<p>使用管理员账号连接目标数据库后执行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">SCHEMA</span> <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">OWNER</span> <span style="color:#66d9ef">TO</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GRANT</span> <span style="color:#66d9ef">USAGE</span>, <span style="color:#66d9ef">CREATE</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">SCHEMA</span> <span style="color:#66d9ef">public</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">TO</span> <span style="color:#f92672">&lt;</span>APP_USER<span style="color:#f92672">&gt;</span>;
</span></span></code></pre></div><p>这个问题说明，仅验证账号“能否登录”还不够，还需要确认该账号能否：</p>
<ul>
<li>创建表</li>
<li>创建索引</li>
<li>创建序列</li>
<li>创建约束</li>
<li>执行 OpenWebUI 的 Alembic 数据库迁移</li>
</ul>
<h2 id="在线执行全量数据库迁移">在线执行全量数据库迁移</h2>
<h3 id="1-在线导出-openwebui-数据库">1. 在线导出 OpenWebUI 数据库</h3>
<p>PostgreSQL 的 <code>pg_dump</code> 会基于一致性事务快照导出数据，因此可以在 OpenWebUI 继续运行时执行。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo -u postgres pg_dump <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --dbname<span style="color:#f92672">=</span>&lt;LOCAL_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --format<span style="color:#f92672">=</span>custom <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-owner <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-acl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --file<span style="color:#f92672">=</span>/tmp/openwebui-pre-migration.dump
</span></span></code></pre></div><p>参数含义：</p>
<ul>
<li><code>--format=custom</code>：使用 PostgreSQL 自定义归档格式</li>
<li><code>--no-owner</code>：不恢复源端对象 owner</li>
<li><code>--no-acl</code>：不恢复源端权限</li>
<li><code>--file</code>：指定备份文件</li>
</ul>
<p>检查归档是否有效：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pg_restore <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --list <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /tmp/openwebui-pre-migration.dump |
</span></span><span style="display:flex;"><span>head -30
</span></span></code></pre></div><p>自定义格式会保留：</p>
<ul>
<li>表结构</li>
<li>表数据</li>
<li>主键</li>
<li>唯一约束</li>
<li>索引</li>
<li>外键</li>
<li>序列</li>
<li>Alembic 迁移版本</li>
</ul>
<h3 id="2-恢复到-azure-postgresql">2. 恢复到 Azure PostgreSQL</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pg_restore <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --host<span style="color:#f92672">=</span>&lt;AZURE_POSTGRESQL_FQDN&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --port<span style="color:#f92672">=</span><span style="color:#ae81ff">5432</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --username<span style="color:#f92672">=</span>&lt;APP_USER&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --dbname<span style="color:#f92672">=</span>&lt;APP_DATABASE&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-owner <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --no-acl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --exit-on-error <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --verbose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /tmp/openwebui-pre-migration.dump
</span></span></code></pre></div><p>使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>--exit-on-error
</span></span></code></pre></div><p>可以确保遇到恢复错误时立即停止，避免忽略关键问题。</p>
<p>恢复成功后更新统计信息：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>psql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;host=&lt;AZURE_POSTGRESQL_FQDN&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   port=5432 \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   dbname=&lt;APP_DATABASE&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   user=&lt;APP_USER&gt; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">   sslmode=require&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -W <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -c <span style="color:#e6db74">&#34;ANALYZE;&#34;</span>
</span></span></code></pre></div><h2 id="对比源端与目标端数据">对比源端与目标端数据</h2>
<p>恢复完成后，需要对 OpenWebUI 的核心表进行对比。</p>
<p>示例查询：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> <span style="color:#e6db74">&#34;user&#34;</span>) <span style="color:#66d9ef">AS</span> users,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> chat) <span style="color:#66d9ef">AS</span> chats,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> chat_message) <span style="color:#66d9ef">AS</span> chat_messages,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> file) <span style="color:#66d9ef">AS</span> files,
</span></span><span style="display:flex;"><span>  (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> knowledge) <span style="color:#66d9ef">AS</span> knowledge,
</span></span><span style="display:flex;"><span>  (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">FROM</span> information_schema.tables
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> table_schema <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;public&#39;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">AND</span> table_type <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;BASE TABLE&#39;</span>
</span></span><span style="display:flex;"><span>  ) <span style="color:#66d9ef">AS</span> tables;
</span></span></code></pre></div><p>重点检查：</p>
<ul>
<li>用户数量</li>
<li>对话数量</li>
<li>消息数量</li>
<li>文件记录数量</li>
<li>知识库数量</li>
<li>业务表数量</li>
<li>Alembic 版本</li>
<li>表 owner</li>
<li>序列状态</li>
</ul>
<p>对比时发现，目标端的对话消息比源端少了少量记录。</p>
<p>这并不是恢复失败，而是因为：</p>

<blockquote>
  <p>在全量备份和恢复过程中，OpenWebUI 仍然在线，当前迁移操作的对话还在不断产生新消息。</p>

</blockquote>
<p>因此，下一步需要同步全量快照之后新增的数据。</p>
<h2 id="同步迁移期间新增的活跃对话">同步迁移期间新增的活跃对话</h2>
<p>本次迁移期间，主要只有当前操作对话仍在更新，因此没有再次恢复整个数据库，而是只同步当前活跃对话。</p>
<p>涉及两张表：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>chat
</span></span><span style="display:flex;"><span>chat_message
</span></span></code></pre></div><h3 id="1-找到最近更新的对话">1. 找到最近更新的对话</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.id,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.title,
</span></span><span style="display:flex;"><span>  to_timestamp(<span style="color:#66d9ef">c</span>.updated_at) <span style="color:#66d9ef">AS</span> updated_at,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">count</span>(cm.id) <span style="color:#66d9ef">AS</span> message_count
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> chat <span style="color:#66d9ef">c</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LEFT</span> <span style="color:#66d9ef">JOIN</span> chat_message cm
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">ON</span> cm.chat_id <span style="color:#f92672">=</span> <span style="color:#66d9ef">c</span>.id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GROUP</span> <span style="color:#66d9ef">BY</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.id,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.title,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">c</span>.updated_at
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> <span style="color:#66d9ef">c</span>.updated_at <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">5</span>;
</span></span></code></pre></div><p>确定活跃对话后，后续脚本通过环境变量或参数接收对话 ID，而不是将其直接写入公开文档：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>CHAT_ID<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;&lt;ACTIVE_CHAT_ID&gt;&#34;</span>
</span></span></code></pre></div><h3 id="2-使用一致性快照导出对话数据">2. 使用一致性快照导出对话数据</h3>
<p>为了保证 <code>chat</code> 和 <code>chat_message</code> 来自同一个时间点，使用只读、可重复读事务：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span> <span style="color:#66d9ef">TRANSACTION</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ISOLATION</span> <span style="color:#66d9ef">LEVEL</span> <span style="color:#66d9ef">REPEATABLE</span> <span style="color:#66d9ef">READ</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">READ</span> <span style="color:#66d9ef">ONLY</span>;
</span></span></code></pre></div><p>然后在同一事务中导出：</p>
<pre tabindex="0"><code class="language-psql" data-lang="psql">\copy (
  SELECT *
  FROM chat
  WHERE id = &#39;&lt;ACTIVE_CHAT_ID&gt;&#39;
) TO &#39;/tmp/chat.csv&#39;
WITH (FORMAT CSV);
</code></pre><pre tabindex="0"><code class="language-psql" data-lang="psql">\copy (
  SELECT *
  FROM chat_message
  WHERE chat_id = &#39;&lt;ACTIVE_CHAT_ID&gt;&#39;
  ORDER BY created_at, id
) TO &#39;/tmp/chat-messages.csv&#39;
WITH (FORMAT CSV);
</code></pre><p>最后提交：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">COMMIT</span>;
</span></span></code></pre></div><p>需要注意：<code>psql</code> 的 <code>\copy</code> 是客户端元命令，复杂字段列表最好写在同一行，否则可能出现：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>\copy: parse error at end of line
</span></span></code></pre></div><h3 id="3-在-azure-中使用临时表和-upsert">3. 在 Azure 中使用临时表和 UPSERT</h3>
<p>目标端创建临时 staging 表：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> TEMP <span style="color:#66d9ef">TABLE</span> stage_chat
</span></span><span style="display:flex;"><span>(<span style="color:#66d9ef">LIKE</span> <span style="color:#66d9ef">public</span>.chat <span style="color:#66d9ef">INCLUDING</span> <span style="color:#66d9ef">DEFAULTS</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> TEMP <span style="color:#66d9ef">TABLE</span> stage_chat_message
</span></span><span style="display:flex;"><span>(<span style="color:#66d9ef">LIKE</span> <span style="color:#66d9ef">public</span>.chat_message <span style="color:#66d9ef">INCLUDING</span> <span style="color:#66d9ef">DEFAULTS</span>);
</span></span></code></pre></div><p>将 CSV 导入临时表后，对 <code>chat</code> 主记录执行 UPSERT：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> <span style="color:#66d9ef">public</span>.chat (...)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> ...
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> stage_chat
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> CONFLICT (id) <span style="color:#66d9ef">DO</span> <span style="color:#66d9ef">UPDATE</span> <span style="color:#66d9ef">SET</span>
</span></span><span style="display:flex;"><span>  title              <span style="color:#f92672">=</span> EXCLUDED.title,
</span></span><span style="display:flex;"><span>  updated_at         <span style="color:#f92672">=</span> EXCLUDED.updated_at,
</span></span><span style="display:flex;"><span>  chat               <span style="color:#f92672">=</span> EXCLUDED.chat,
</span></span><span style="display:flex;"><span>  meta               <span style="color:#f92672">=</span> EXCLUDED.meta,
</span></span><span style="display:flex;"><span>  current_message_id <span style="color:#f92672">=</span> EXCLUDED.current_message_id;
</span></span></code></pre></div><p>对消息表执行类似操作：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> <span style="color:#66d9ef">public</span>.chat_message (...)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> ...
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> stage_chat_message
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> CONFLICT (id) <span style="color:#66d9ef">DO</span> <span style="color:#66d9ef">UPDATE</span> <span style="color:#66d9ef">SET</span>
</span></span><span style="display:flex;"><span>  content         <span style="color:#f92672">=</span> EXCLUDED.content,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">output</span>          <span style="color:#f92672">=</span> EXCLUDED.<span style="color:#66d9ef">output</span>,
</span></span><span style="display:flex;"><span>  status_history  <span style="color:#f92672">=</span> EXCLUDED.status_history,
</span></span><span style="display:flex;"><span>  error           <span style="color:#f92672">=</span> EXCLUDED.error,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">usage</span>           <span style="color:#f92672">=</span> EXCLUDED.<span style="color:#66d9ef">usage</span>,
</span></span><span style="display:flex;"><span>  done            <span style="color:#f92672">=</span> EXCLUDED.done,
</span></span><span style="display:flex;"><span>  updated_at      <span style="color:#f92672">=</span> EXCLUDED.updated_at,
</span></span><span style="display:flex;"><span>  context_summary <span style="color:#f92672">=</span> EXCLUDED.context_summary;
</span></span></code></pre></div><p>为了让 Azure 中该对话的消息集合与源快照完全一致，还会删除目标端多余记录：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">DELETE</span> <span style="color:#66d9ef">FROM</span> <span style="color:#66d9ef">public</span>.chat_message target
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> target.chat_id <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&lt;ACTIVE_CHAT_ID&gt;&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">EXISTS</span> (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SELECT</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">FROM</span> stage_chat_message staged
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> staged.id <span style="color:#f92672">=</span> target.id
</span></span><span style="display:flex;"><span>  );
</span></span></code></pre></div><p>整个导入和 UPSERT 过程放在一个事务中执行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- 创建临时表
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">-- 导入数据
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">-- UPSERT
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">-- 清理多余记录
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">COMMIT</span>;
</span></span></code></pre></div><p>如果任何步骤失败，事务会自动回滚，不会留下部分同步数据。</p>
<h2 id="最终快速切换-openwebui">最终快速切换 OpenWebUI</h2>
<p>完成在线全量迁移后，最终操作为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>最后同步一次当前活跃对话
</span></span><span style="display:flex;"><span>              ↓
</span></span><span style="display:flex;"><span>重建 OpenWebUI 容器
</span></span><span style="display:flex;"><span>              ↓
</span></span><span style="display:flex;"><span>新容器连接 Azure PostgreSQL
</span></span></code></pre></div><p>示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>./sync-active-chat-to-azure.sh <span style="color:#f92672">&amp;&amp;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  up -d --force-recreate
</span></span></code></pre></div><p>完整数据库已经提前迁移并验证，因此最终阶段不需要再次等待几百 MB 数据恢复，只需要同步少量增量记录并快速重建容器。</p>
<p>这将切换窗口缩短到了容器启动所需的时间。</p>
<h2 id="安全保存-openwebui-数据库连接串">安全保存 OpenWebUI 数据库连接串</h2>
<p>数据库连接串没有直接写入 Compose 文件，而是放在独立环境文件中：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>open-webui-azure-db.env
</span></span></code></pre></div><p>文件内容形式：</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">DATABASE_URL=postgresql://&lt;APP_USER&gt;:&lt;URL_ENCODED_PASSWORD&gt;@&lt;AZURE_POSTGRESQL_FQDN&gt;:5432/&lt;APP_DATABASE&gt;?sslmode=require
</code></pre><p>文件权限设置为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>chmod <span style="color:#ae81ff">600</span> open-webui-azure-db.env
</span></span></code></pre></div><p>如果密码包含特殊字符，需要先进行 URL 编码：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>python3 - <span style="color:#e6db74">&lt;&lt;&#39;PY&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import getpass
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import urllib.parse
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">password = getpass.getpass(&#34;Database password: &#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(urllib.parse.quote(password, safe=&#34;&#34;))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">PY</span>
</span></span></code></pre></div><p>新的 Compose 文件示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">open-webui</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/open-webui/open-webui:&lt;VERSION&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">open-webui</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">always</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;8080:8080&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">env_file</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./open-webui.env</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./open-webui-azure-db.env</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/opt/open-webui/data:/app/backend/data</span>
</span></span></code></pre></div><h2 id="持久化-openwebui-本地文件">持久化 OpenWebUI 本地文件</h2>
<p>迁移前，OpenWebUI 的 <code>/app/backend/data</code> 没有挂载到宿主机。</p>
<p>先将数据复制出来：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo install <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -m <span style="color:#ae81ff">700</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>id -un<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -g <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>id -gn<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /opt/open-webui/data
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker cp <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  open-webui:/app/backend/data/. <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /opt/open-webui/data/
</span></span></code></pre></div><p>在 Compose 中加入：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">/opt/open-webui/data:/app/backend/data</span>
</span></span></code></pre></div><p>这样，即使未来重建或升级 OpenWebUI 容器，以下内容仍会保留：</p>
<ul>
<li>上传文件</li>
<li>向量数据库</li>
<li>缓存</li>
<li>其他本地应用数据</li>
</ul>
<p>迁移后验证：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker inspect open-webui <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --format <span style="color:#e6db74">&#39;{{range .Mounts}}{{println .Source &#34;-&gt;&#34; .Destination &#34;rw=&#34; .RW}}{{end}}&#39;</span>
</span></span></code></pre></div><p>预期：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>/opt/open-webui/data -&gt; /app/backend/data rw=true
</span></span></code></pre></div><h2 id="迁移后的验证">迁移后的验证</h2>
<h3 id="1-openwebui-健康检查">1. OpenWebUI 健康检查</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -sS <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o /dev/null <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -w <span style="color:#e6db74">&#39;HTTP %{http_code}\n&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --max-time <span style="color:#ae81ff">10</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  http://127.0.0.1:8080/health
</span></span></code></pre></div><p>预期：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>HTTP 200
</span></span></code></pre></div><h3 id="2-验证-openwebui-当前数据库">2. 验证 OpenWebUI 当前数据库</h3>
<p>为了避免输出密码，只解析连接串中的非敏感部分：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker exec -i open-webui python3 - <span style="color:#e6db74">&lt;&lt;&#39;PY&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">import os
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">from urllib.parse import urlsplit
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">u = urlsplit(os.environ[&#34;DATABASE_URL&#34;])
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;Database host configured:&#34;, bool(u.hostname))
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;Database port:&#34;, u.port)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">print(&#34;TLS required:&#34;, &#34;sslmode=require&#34; in u.query)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">PY</span>
</span></span></code></pre></div><p>验证重点：</p>
<ul>
<li>Host 已不再指向 VM 本地 PostgreSQL</li>
<li>端口为 5432</li>
<li>已启用 <code>sslmode=require</code></li>
</ul>
<h3 id="3-检查启动日志">3. 检查启动日志</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker logs <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --since 15m <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  open-webui 2&gt;&amp;<span style="color:#ae81ff">1</span> |
</span></span><span style="display:flex;"><span>grep -Ei <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;error|fatal|exception|traceback|permission denied|database|postgres|alembic|ssl|timeout&#39;</span>
</span></span></code></pre></div><p>正常情况下只会看到类似：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
</span></span><span style="display:flex;"><span>INFO [alembic.runtime.migration] Will assume transactional DDL.
</span></span></code></pre></div><p>不应出现：</p>
<ul>
<li>数据库连接失败</li>
<li>密码认证失败</li>
<li>TLS 错误</li>
<li>Schema 权限错误</li>
<li>Alembic 迁移失败</li>
<li>Python Traceback</li>
</ul>
<h2 id="卸载-vm-本地-postgresql">卸载 VM 本地 PostgreSQL</h2>
<p>确认 OpenWebUI 已稳定连接 Azure PostgreSQL 后，可以删除 VM 上的本地 PostgreSQL。</p>
<p>首先确认：</p>
<ul>
<li>OpenWebUI 健康检查正常</li>
<li>数据库连接已切换</li>
<li>Azure PostgreSQL 中存在最新数据</li>
<li>自动备份策略已经启用</li>
<li>本地 PostgreSQL 不再有应用连接</li>
</ul>
<p>停止并删除本地集群：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo pg_dropcluster <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --stop <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  &lt;POSTGRESQL_VERSION&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  main
</span></span></code></pre></div><p>卸载 PostgreSQL 服务端：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt-get purge -y <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-&lt;POSTGRESQL_VERSION&gt; <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  postgresql-contrib
</span></span></code></pre></div><p>删除遗留数据和配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo rm -rf <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/lib/postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /etc/postgresql <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  /var/log/postgresql
</span></span></code></pre></div><p>可以继续保留 PostgreSQL 客户端：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>postgresql-client
</span></span><span style="display:flex;"><span>postgresql-client-common
</span></span></code></pre></div><p>这样仍然可以从 VM 使用 <code>psql</code> 管理 Azure PostgreSQL。</p>
<p>最终检查本地 5432：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> sudo ss -lntp | grep -q <span style="color:#e6db74">&#39;:5432&#39;</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>  sudo ss -lntp | grep <span style="color:#e6db74">&#39;:5432&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;No local PostgreSQL listener&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><h2 id="迁移过程中的经验总结">迁移过程中的经验总结</h2>
<h3 id="1-数据库迁移不等于-openwebui-全部数据迁移">1. 数据库迁移不等于 OpenWebUI 全部数据迁移</h3>
<p>OpenWebUI 除了 PostgreSQL，还可能在 <code>/app/backend/data</code> 中保存：</p>
<ul>
<li>上传文件</li>
<li>向量数据库</li>
<li>缓存</li>
<li>本地状态文件</li>
</ul>
<p>只执行 <code>pg_dump</code> 并不能迁移这些内容。</p>
<h3 id="2-不要只测试端口连通">2. 不要只测试端口连通</h3>
<p><code>nc</code> 成功只能证明 TCP 5432 可达，并不能证明：</p>
<ul>
<li>PostgreSQL 用户可以登录</li>
<li>密码正确</li>
<li>TLS 正常</li>
<li>数据库权限正确</li>
<li>OpenWebUI 使用的驱动兼容</li>
</ul>
<p>应至少完成以下验证：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>DNS → TCP → PostgreSQL 登录 → TLS → SQL 查询 → OpenWebUI 实际连接
</span></span></code></pre></div><h3 id="3-数据库-owner-不代表一定拥有-schema-创建权限">3. 数据库 owner 不代表一定拥有 schema 创建权限</h3>
<p>即使应用用户是数据库 owner，也可能遇到：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>permission denied for schema public
</span></span></code></pre></div><p>迁移前应验证应用账号是否具备：</p>
<ul>
<li><code>USAGE</code></li>
<li><code>CREATE</code></li>
<li>表和序列所有权</li>
<li>Alembic DDL 权限</li>
</ul>
<h3 id="4-在线全量备份后必须处理增量写入">4. 在线全量备份后必须处理增量写入</h3>
<p><code>pg_dump</code> 提供的是开始备份时的一致性快照。</p>
<p>备份期间 OpenWebUI 新产生的对话和消息不会自动进入已经完成的 dump。因此在线迁移必须增加以下机制之一：</p>
<ul>
<li>最终停写后重新完整备份</li>
<li>逻辑复制</li>
<li>CDC</li>
<li>应用双写</li>
<li>按业务对象执行增量同步</li>
</ul>
<p>本次采用的是“全量快照 + 活跃对话 UPSERT”。</p>
<h3 id="5-不要在迁移时顺便升级-openwebui">5. 不要在迁移时顺便升级 OpenWebUI</h3>
<p>本次迁移始终保持 OpenWebUI 镜像版本不变。</p>
<p>如果同时升级应用和迁移数据库，一旦发生问题，很难快速判断原因来自：</p>
<ul>
<li>PostgreSQL 版本</li>
<li>数据迁移</li>
<li>Azure 网络</li>
<li>数据库权限</li>
<li>OpenWebUI 版本</li>
<li>Alembic schema 变化</li>
</ul>
<p>更稳妥的做法是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>先迁移数据库并稳定运行
</span></span><span style="display:flex;"><span>          ↓
</span></span><span style="display:flex;"><span>再单独升级 OpenWebUI
</span></span></code></pre></div><h2 id="这算不算真正的零停机">这算不算真正的零停机？</h2>
<p>从用户体验看，这次迁移接近零停机：</p>
<ul>
<li>在线完成全量备份</li>
<li>在线恢复目标数据库</li>
<li>在线完成数据验证</li>
<li>在线同步活跃对话</li>
<li>仅在最后重建了一次 OpenWebUI 容器</li>
</ul>
<p>但如果采用严格工程定义，真正的零停机还需要满足：</p>
<ul>
<li>任意时刻都能接受新连接</li>
<li>最后时刻写入绝不丢失</li>
<li>没有单实例容器重启窗口</li>
<li>客户端连接完全不中断</li>
<li>具备自动流量切换能力</li>
</ul>
<p>更大规模的 OpenWebUI 部署可以考虑：</p>
<ul>
<li>PostgreSQL 逻辑复制</li>
<li>Azure Database Migration Service</li>
<li>CDC 增量迁移</li>
<li>应用双写</li>
<li>蓝绿部署</li>
<li>多个 OpenWebUI 实例</li>
<li>反向代理健康检查</li>
<li>连接排空和流量切换</li>
</ul>
<p>对于单 VM、单 OpenWebUI 容器和中小规模数据库，本次采用的方案在复杂度、风险和停机时间之间取得了较好的平衡。</p>
<h2 id="迁移后的日常运维">迁移后的日常运维</h2>
<p>启动 OpenWebUI：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  up -d
</span></span></code></pre></div><p>查看状态：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  ps
</span></span></code></pre></div><p>查看日志：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f run-with-azure-pg.yml <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  logs -f --tail<span style="color:#f92672">=</span><span style="color:#ae81ff">200</span>
</span></span></code></pre></div><p>健康检查：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -sS <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -o /dev/null <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -w <span style="color:#e6db74">&#39;HTTP %{http_code}\n&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  http://127.0.0.1:8080/health
</span></span></code></pre></div><p>日常还应重点监控 Azure PostgreSQL 的：</p>
<ul>
<li>CPU 使用率</li>
<li>内存使用情况</li>
<li>存储空间</li>
<li>活跃连接数</li>
<li>失败连接数</li>
<li>查询延迟</li>
<li>自动备份状态</li>
<li>维护事件</li>
<li>Private Endpoint 状态</li>
</ul>
<h2 id="总结">总结</h2>
<p>这次迁移将 OpenWebUI 的数据库从 Azure VM 自建 PostgreSQL 平滑迁移到了 Azure Database for PostgreSQL Flexible Server。</p>
<p>最终实现了：</p>
<ul>
<li>OpenWebUI 数据库由 Azure 托管</li>
<li>通过 Private Endpoint 私网访问</li>
<li>数据库连接启用 TLS</li>
<li>自动备份和时间点恢复</li>
<li>OpenWebUI 本地文件持久化</li>
<li>VM 本地 PostgreSQL 完全下线</li>
<li>本地 5432 端口关闭</li>
<li>全量迁移期间应用持续在线</li>
<li>最终切换对用户影响极低</li>
</ul>
<p>本次最关键的经验是：</p>

<blockquote>
  <p>对 OpenWebUI 进行低停机数据库迁移，不应只关注 <code>pg_dump</code> 和 <code>pg_restore</code>，还必须同时考虑增量写入、Private Endpoint、TLS、数据库权限、Alembic 迁移以及 <code>/app/backend/data</code> 中的本地文件。</p>

</blockquote>
<p>通过“<strong>在线全量迁移、目标端预验证、活跃数据增量同步、快速容器切换</strong>”，可以在不引入复杂 CDC 平台的情况下，将中小规模 OpenWebUI 平滑迁移到 Azure 托管 PostgreSQL。</p>
]]></content:encoded></item></channel></rss>