<?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>Openwebui on Wilson Wu</title><link>https://wilsonwu.me/zh/tags/openwebui/</link><description>Recent content in Openwebui on Wilson Wu</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Mon, 28 Sep 2026 00:00:00 -0400</lastBuildDate><atom:link href="https://wilsonwu.me/zh/tags/openwebui/index.xml" rel="self" type="application/rss+xml"/><item><title>用 Microsoft Web IQ 增强 OpenWebUI：零代码实现联网搜索与链接自动读取</title><link>https://wilsonwu.me/zh/blog/2026/microsoft-web-iq-in-openwebui/</link><pubDate>Mon, 28 Sep 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/microsoft-web-iq-in-openwebui/</guid><description>&lt;p&gt;&lt;img alt="OpenWebUI 接入 Microsoft Web IQ" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/microsoft-web-iq-in-openwebui/1-openwebui-web-iq.png"&gt;&lt;/p&gt;
&lt;p&gt;在&lt;a href="https://wilsonwu.me/zh/blog/2026/migrate-openwebui-to-azure-pg/"&gt;上一篇文章&lt;/a&gt;中，我把 OpenWebUI 的数据库迁移到了 Azure 托管 PostgreSQL。在日常使用 OpenWebUI 的过程中，我经常遇到两个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;问不到最新的信息&lt;/strong&gt;：模型有知识截止时间，问到最近的版本发布、新闻或价格，要么答不上来，要么答错。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;贴进去的链接不会被打开&lt;/strong&gt;：把文章、文档或 GitHub 页面的 URL 贴进对话框，想让模型总结、翻译或对比，模型却不会去读这个链接，有时甚至会根据 URL 猜出一段“看起来很像”的内容。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这两个问题都可以靠 OpenWebUI 的联网能力解决。本文使用微软新推出的 &lt;strong&gt;Microsoft Web IQ&lt;/strong&gt; 作为联网搜索引擎，并让模型自动识别消息中的 URL、读取网页后再回答。整个过程只需要在 Azure 门户中创建一个 Web IQ 资源，再到 OpenWebUI 的管理界面中完成配置，&lt;strong&gt;不需要写代码，也不需要部署额外的服务&lt;/strong&gt;。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="OpenWebUI 接入 Microsoft Web IQ" loading="lazy" src="/zh/blog/2026/microsoft-web-iq-in-openwebui/1-openwebui-web-iq.png"></p>
<p>在<a href="/zh/blog/2026/migrate-openwebui-to-azure-pg/">上一篇文章</a>中，我把 OpenWebUI 的数据库迁移到了 Azure 托管 PostgreSQL。在日常使用 OpenWebUI 的过程中，我经常遇到两个问题：</p>
<ol>
<li><strong>问不到最新的信息</strong>：模型有知识截止时间，问到最近的版本发布、新闻或价格，要么答不上来，要么答错。</li>
<li><strong>贴进去的链接不会被打开</strong>：把文章、文档或 GitHub 页面的 URL 贴进对话框，想让模型总结、翻译或对比，模型却不会去读这个链接，有时甚至会根据 URL 猜出一段“看起来很像”的内容。</li>
</ol>
<p>这两个问题都可以靠 OpenWebUI 的联网能力解决。本文使用微软新推出的 <strong>Microsoft Web IQ</strong> 作为联网搜索引擎，并让模型自动识别消息中的 URL、读取网页后再回答。整个过程只需要在 Azure 门户中创建一个 Web IQ 资源，再到 OpenWebUI 的管理界面中完成配置，<strong>不需要写代码，也不需要部署额外的服务</strong>。</p>

<blockquote>
  <p>本文基于 Open WebUI v0.11.4（2026 年 9 月发布），界面名称以英文界面为准。文中的 API Key 均为占位符。</p>

</blockquote>
<h2 id="最终效果">最终效果</h2>
<p>配置完成后：</p>
<ul>
<li>提问涉及最新信息时，模型会自己调用 <code>search_web</code>，由 Web IQ 返回搜索结果，回答附带参考链接；</li>
<li>消息中出现 <code>http://</code> 或 <code>https://</code> 链接时，模型会自己调用 <code>fetch_url</code>，通过 Web IQ 读取页面正文后再回答，读取过的页面会出现在引用来源中；</li>
<li>两者可以在同一次回答中组合使用，例如先读取你给出的文档，再搜索最新的变更说明做对比。</li>
</ul>
<h2 id="为什么选择-microsoft-web-iq">为什么选择 Microsoft Web IQ</h2>
<p><img alt="Bing Search v7、Grounding with Bing Search 与 Web IQ 的接入方式对比" loading="lazy" src="/zh/blog/2026/microsoft-web-iq-in-openwebui/2-bing-vs-web-iq.png"></p>
<p>打开 OpenWebUI 的 <strong>Settings → Admin → Web Search</strong>，在 <strong>Web Search Engine</strong> 中可以找到 <code>bing</code>。选中后需要填写 <strong>Bing Search V7 Endpoint</strong> 和 <strong>Bing Search V7 Subscription Key</strong>，看起来只要填上 Azure 上 Bing 资源的密钥就能用，但这条路已经走不通了：</p>
<ul>
<li>Bing Search v7 API（包括 Bing Custom Search）已于 <strong>2025 年 8 月 11 日退役</strong>，已有实例全部下线，也无法再创建新资源；</li>
<li>现在 Azure 上能创建的 Bing 资源是 <strong>Grounding with Bing Search</strong>，它只能作为 Microsoft Foundry 智能体的工具使用，不能拿密钥直接调用搜索接口。想接入 OpenWebUI，就得自己开发一个桥接服务。</li>
</ul>
<p><strong>Microsoft Web IQ</strong> 是微软在 2026 年 6 月发布的一组面向 AI 应用的 API，用来为模型提供实时的网络信息（grounding），可以理解为“给 AI 智能体用的搜索引擎”：</p>
<ul>
<li>基于 Bing 的全球索引构建，并针对智能体的多轮检索重新设计；</li>
<li>返回的不只是链接列表，而是带来源的相关段落，可以直接放进模型上下文，用更少的 token 得到更好的回答；</li>
<li>覆盖网页、新闻、图片、视频等内容，支持 100 多种语言和市场。</li>
</ul>
<p>更重要的是，OpenWebUI 已经内置了 <code>microsoft_web_iq</code> 引擎。它既可以作为<strong>搜索引擎</strong>，也可以作为读取网页的<strong>网页加载引擎</strong>，只需要一个 API Key。</p>
<table>
	<thead>
			<tr>
					<th>方案</th>
					<th>在 OpenWebUI 中接入</th>
					<th>现状</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Bing Search v7</td>
					<td>内置 <code>bing</code> 引擎</td>
					<td>已退役，不可用</td>
			</tr>
			<tr>
					<td>Grounding with Bing Search</td>
					<td>自己开发桥接服务，通过 Foundry 调用</td>
					<td>主要面向 Foundry 智能体</td>
			</tr>
			<tr>
					<td>Microsoft Web IQ</td>
					<td>内置 <code>microsoft_web_iq</code> 引擎，填写 API Key 即可</td>
					<td>2026 年 6 月发布，目前为有限访问</td>
			</tr>
	</tbody>
</table>
<h2 id="工作原理">工作原理</h2>
<p><img alt="OpenWebUI 通过 Web IQ 搜索和读取网页" loading="lazy" src="/zh/blog/2026/microsoft-web-iq-in-openwebui/3-how-it-works.png"></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>OpenWebUI（模型以原生工具调用方式工作）
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  ├── search_web：联网搜索
</span></span><span style="display:flex;"><span>  │     └── Web IQ /search/web：返回标题、链接和相关段落
</span></span><span style="display:flex;"><span>  │
</span></span><span style="display:flex;"><span>  └── fetch_url：读取消息中的链接
</span></span><span style="display:flex;"><span>        └── Web IQ /browse：读取网页，返回 Markdown 格式的正文
</span></span></code></pre></div><p>几点说明：</p>
<ul>
<li><code>search_web</code> 和 <code>fetch_url</code> 是 OpenWebUI 的两个内置工具，同属 Web Search 类别，由模型自己决定何时调用；</li>
<li>这两个工具依赖原生工具调用（Native Function Calling），这是 v0.10.0 起的默认模式；</li>
<li>网页加载引擎也设为 Web IQ 之后，网页由 Web IQ 读取，需要 JavaScript 渲染的页面也能读到内容。PDF 等文件仍然由 OpenWebUI 下载后自行解析。</li>
</ul>
<h2 id="第一步创建-web-iq-资源并获取-api-key">第一步：创建 Web IQ 资源并获取 API Key</h2>
<p>Web IQ 以 Azure 资源的形式提供。在 Azure 门户中搜索 <strong>Microsoft Web IQ</strong>，进入 <strong>Microsoft Web IQ resources</strong>，点击 <strong>Create</strong>，在 <strong>Basics</strong> 页填写：</p>
<ul>
<li><strong>Subscription</strong> 和 <strong>Resource group</strong>：选择用于管理和计费的订阅与资源组；</li>
<li><strong>Name</strong>：资源名称；</li>
<li><strong>Region</strong>：固定为 <strong>Global</strong>，这是一个可以跨 Azure 区域使用的全局资源。</li>
</ul>
<p><img alt="在 Azure 门户中创建 Microsoft Web IQ 资源" loading="lazy" src="/zh/blog/2026/microsoft-web-iq-in-openwebui/4-create-web-iq-resource.png"></p>
<p>点击 <strong>Review + create</strong> 完成创建，然后在资源页面中复制 API Key，下一步会用到。</p>
<p>Web IQ 目前仍处于<strong>有限访问</strong>（limited access）阶段。如果在 Azure 门户中找不到或者无法创建这个资源，可以先到 <a href="https://www.microsoft.com/en-us/WebIQ">Microsoft Web IQ 官网</a> 点击 <strong>Join the waitlist</strong> 申请。</p>
<h2 id="第二步配置-web-iq-搜索和网页读取">第二步：配置 Web IQ 搜索和网页读取</h2>
<p>以管理员身份登录 OpenWebUI，点击左下角的头像，进入 <strong>Settings → Admin → Web Search</strong>。</p>
<h3 id="1-search-部分">1. Search 部分</h3>
<table>
	<thead>
			<tr>
					<th>设置项</th>
					<th>值</th>
					<th>说明</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Web Search</td>
					<td>开启</td>
					<td>全局开关</td>
			</tr>
			<tr>
					<td>Web Search Engine</td>
					<td><code>microsoft_web_iq</code></td>
					<td>选中后会出现下面三项</td>
			</tr>
			<tr>
					<td>Microsoft Web IQ API Base URL</td>
					<td><code>https://api.microsoft.ai/v3</code></td>
					<td>保持默认值</td>
			</tr>
			<tr>
					<td>Microsoft Web IQ API Key</td>
					<td><code>&lt;WEB_IQ_API_KEY&gt;</code></td>
					<td>第一步获取的 API Key</td>
			</tr>
			<tr>
					<td>Language</td>
					<td><code>en</code></td>
					<td>搜索结果的语言代码，默认为 <code>en</code></td>
			</tr>
			<tr>
					<td>Search Result Count</td>
					<td><code>5</code></td>
					<td>每次搜索返回的结果数量</td>
			</tr>
			<tr>
					<td>Fetch URL Content Length Limit</td>
					<td><code>50000</code></td>
					<td><code>fetch_url</code> 最多返回的字符数，留空表示不限制</td>
			</tr>
	</tbody>
</table>
<p>几点说明：</p>
<ul>
<li><strong>Microsoft Web IQ API Base URL</strong>：OpenWebUI 已经预先填好，一般不需要修改；如果 Web IQ 资源页面显示了其他终结点，以资源页面为准；</li>
<li><strong>Language</strong>：技术类内容用 <code>en</code> 通常效果更好；如果主要查询中文资讯，可以改为 <code>zh</code>（以 Web IQ 支持的语言代码为准）；</li>
<li><strong>Fetch URL Content Length Limit</strong>：默认不截断，遇到很长的页面会占用大量上下文，建议设置上限；</li>
<li><strong>Web Search Confirmation</strong>：可选。开启后，用户在对话中使用联网搜索前会先看到确认提示，例如默认的 “Your query will be sent to the configured web search provider.”，提醒用户查询内容会发送到外部服务。</li>
</ul>
<h3 id="2-loader-部分">2. Loader 部分</h3>
<table>
	<thead>
			<tr>
					<th>设置项</th>
					<th>值</th>
					<th>说明</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Web Loader Engine</td>
					<td><code>microsoft_web_iq</code></td>
					<td>读取网页也交给 Web IQ，与搜索共用上面的 API Key</td>
			</tr>
	</tbody>
</table>
<p>点击 <strong>Save</strong> 保存，配置立即生效，不需要重启容器。</p>
<p>Web Loader Engine 也可以保持 <strong>Default</strong>，由 OpenWebUI 服务器自己抓取网页。但默认加载器读不到需要 JavaScript 渲染的内容，它的 User-Agent 也容易被部分网站拦截，交给 Web IQ 读取会省心很多。</p>
<h2 id="第三步让模型自动识别并读取消息中的-url">第三步：让模型自动识别并读取消息中的 URL</h2>
<p><img alt="没有 fetch_url 时模型只能猜，有 fetch_url 时先读后答" loading="lazy" src="/zh/blog/2026/microsoft-web-iq-in-openwebui/5-read-before-answer.png"></p>
<h3 id="1-模型设置">1. 模型设置</h3>
<p>进入 <strong>Settings → Admin → Models</strong>，编辑对话使用的模型：</p>
<table>
	<thead>
			<tr>
					<th>位置</th>
					<th>设置</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Capabilities</td>
					<td>勾选 <strong>Web Search</strong>、<strong>Builtin Tools</strong>、<strong>Citations</strong>、<strong>Status Updates</strong></td>
			</tr>
			<tr>
					<td>Builtin Tools</td>
					<td>确认 <strong>Web Search</strong>（Search the web and fetch URLs）已勾选</td>
			</tr>
			<tr>
					<td>Default Features</td>
					<td>勾选 <strong>Web Search</strong>，新对话默认打开联网开关</td>
			</tr>
			<tr>
					<td>Advanced Params</td>
					<td><strong>Function Calling</strong> 设为 <strong>Native</strong>（默认值）</td>
			</tr>
	</tbody>
</table>
<p>其中 <strong>Default Features</strong> 很关键：原生模式下，只有模型启用了 Web Search 能力，并且当前对话打开了联网开关，<code>search_web</code> 和 <code>fetch_url</code> 才会提供给模型。不勾选的话，用户每次都要在输入框的 <strong>+</strong> 菜单中手动打开联网搜索，否则贴进来的链接不会被读取。</p>
<p>原生模式要求模型具备可靠的工具调用能力，建议使用较新的模型，太小或太旧的模型可能不会正确调用工具。</p>
<h3 id="2-系统提示词">2. 系统提示词</h3>
<p>是否调用 <code>fetch_url</code> 由模型自己决定。为了让行为更稳定，在同一个页面的 <strong>System Prompt</strong> 中加入以下规则：</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>你可以使用 search_web 和 fetch_url 两个工具：
</span></span><span style="display:flex;"><span>1. 用户消息中出现以 http:// 或 https:// 开头的链接时，先调用 fetch_url 读取链接内容，再根据读取结果回答；有多个链接时逐个读取。
</span></span><span style="display:flex;"><span>2. 问题涉及最新动态、版本、价格等时效性信息，或者你不确定答案时，调用 search_web 搜索；搜索结果中的摘要不够时，再用 fetch_url 打开最相关的结果。
</span></span><span style="display:flex;"><span>3. 链接无法访问或内容为空时，如实告诉用户，不要根据链接地址推测页面内容。
</span></span><span style="display:flex;"><span>4. 在回答末尾列出参考链接。
</span></span></code></pre></div><p>第 3 条用来防止模型在读取失败后“编”一段内容；第 4 条则是因为原生模式下，<code>search_web</code> 的结果不会出现在引用来源中，只有 <code>fetch_url</code> 读取过的页面才会，所以需要模型在回答中写出来。</p>
<h3 id="3-手动附加网页">3. 手动附加网页</h3>
<p>如果希望某个链接一定被读取，也可以手动附加：</p>
<ul>
<li>在输入框中输入 <code>#</code> 后粘贴链接，然后选择该网页；</li>
<li>或者通过输入框的 <strong>+ → Attach Webpage</strong> 添加链接。</li>
</ul>
<p>这两种方式由 OpenWebUI 直接读取页面（同样使用上面配置的网页加载引擎），不依赖模型的判断。</p>
<h2 id="效果验证">效果验证</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-text" data-lang="text"><span style="display:flex;"><span>帮我总结这篇文档的要点：https://docs.openwebui.com/features/chat-conversations/web-search/providers/microsoft-web-iq
</span></span></code></pre></div><p>预期：</p>
<ul>
<li>回答过程中显示模型调用了 <code>fetch_url</code>；</li>
<li>回答下方的引用来源中出现这篇文档；</li>
<li>回答内容与文档一致，而不是泛泛而谈。</li>
</ul>
<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-text" data-lang="text"><span style="display:flex;"><span>Open WebUI 最新发布的版本是多少？有哪些主要更新？
</span></span></code></pre></div><p>预期：</p>
<ul>
<li>模型调用 <code>search_web</code>，结果来自 Web IQ；</li>
<li>如果摘要不够，模型会继续调用 <code>fetch_url</code> 打开发布说明页面；</li>
<li>回答末尾列出参考链接。</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-text" data-lang="text"><span style="display:flex;"><span>先读取 https://github.com/open-webui/open-webui/releases 的内容，再搜索一下社区对最新版本的反馈，最后给我一个是否升级的建议。
</span></span></code></pre></div><p>预期模型会先读取给出的链接，再根据需要多次搜索，最后综合两部分信息给出建议。</p>
<h3 id="4-排查问题">4. 排查问题</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 logs --since 10m open-webui 2&gt;&amp;<span style="color:#ae81ff">1</span> |
</span></span><span style="display:flex;"><span>grep -Ei <span style="color:#e6db74">&#39;web iq|microsoft browse|fetch_url|blocked&#39;</span>
</span></span></code></pre></div><p>常见的日志：</p>
<ul>
<li><code>Error searching with Microsoft Web IQ API</code>：搜索请求失败，通常是 API Key 不正确或者尚未开通；</li>
<li><code>Error browsing ... with Microsoft Web IQ</code>：Web IQ 读取网页失败；</li>
<li><code>Blocked ...</code>：链接指向内网地址或者在过滤列表中，被 OpenWebUI 拦截。</li>
</ul>
<p>也可以直接用 curl 验证 API Key，下面的请求格式与 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>curl -sS https://api.microsoft.ai/v3/search/web <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -H <span style="color:#e6db74">&#34;x-apikey: </span>$WEB_IQ_API_KEY<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -H <span style="color:#e6db74">&#34;Content-Type: application/json&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d <span style="color:#e6db74">&#39;{&#34;query&#34;: &#34;Open WebUI&#34;, &#34;maxResults&#34;: 3, &#34;language&#34;: &#34;en&#34;, &#34;contentFormat&#34;: &#34;passage&#34;}&#39;</span>
</span></span></code></pre></div><p>正常情况下会返回包含 <code>webResults</code> 的 JSON，每条结果都有 <code>url</code>、<code>title</code> 和 <code>content</code>。</p>
<h2 id="注意事项">注意事项</h2>
<ul>
<li><strong>数据会发送到 Web IQ</strong>：搜索词和需要读取的链接会发送到 Web IQ API（<code>api.microsoft.ai</code>），数据如何处理以 Web IQ 的服务条款为准。敏感对话可以关闭联网开关，或者开启 Web Search Confirmation 提醒用户。</li>
<li><strong>请求中会附带用户信息</strong>：从 v0.11.4 的源码看，调用 Web IQ 搜索时，OpenWebUI 会在请求头中附带当前用户的名称、ID、邮箱和角色（<code>X-OpenWebUI-User-*</code>），目前无法通过配置关闭。如果组织对此有要求，上线前需要评估。</li>
<li><strong>内网链接无法读取</strong>：读取链接之前，OpenWebUI 会先校验 URL，默认禁止访问内网地址和云元数据地址。这些防护默认开启，无需额外配置，Web IQ 本身也访问不到内网页面。</li>
<li><strong>调用次数与成本</strong>：每次 <code>search_web</code> 和 <code>fetch_url</code> 都是一次 Web IQ 调用，原生模式下模型还可能为一个问题连续调用多次。建议 Search Result Count 保持在 3～5，只在需要联网的模型上把 Web Search 设为默认功能。Web IQ 的费用计入创建资源时选择的 Azure 订阅，可以为资源组设置预算告警。</li>
</ul>
<h2 id="常见问题">常见问题</h2>
<table>
	<thead>
			<tr>
					<th>现象</th>
					<th>可能原因</th>
					<th>处理方法</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>贴了链接，模型说无法访问网页</td>
					<td>对话没有打开联网开关，或者模型没有启用 Web Search 能力和内置工具</td>
					<td>检查模型的 Capabilities、Default Features 和对话中的联网开关</td>
			</tr>
			<tr>
					<td>模型根据 URL 编造内容</td>
					<td>没有调用 <code>fetch_url</code></td>
					<td>确认 Function Calling 为 Native，并加入系统提示词</td>
			</tr>
			<tr>
					<td>普通用户看不到联网开关</td>
					<td>用户组没有 Web Search 功能权限</td>
					<td>在用户组的功能权限中允许 Web Search</td>
			</tr>
			<tr>
					<td>搜索始终没有结果</td>
					<td>API Key 错误或者尚未开通</td>
					<td>查看日志，并用 curl 验证 API Key</td>
			</tr>
			<tr>
					<td>搜索结果的语言不符合预期</td>
					<td>Language 与查询的语言不一致</td>
					<td>修改 Language</td>
			</tr>
			<tr>
					<td>部分网页读取失败</td>
					<td>页面需要登录、禁止抓取，或者链接指向内网</td>
					<td>换一个可以公开访问的链接，或者直接把正文粘贴到对话中</td>
			</tr>
	</tbody>
</table>
<h2 id="总结">总结</h2>
<p>Bing Search API 退役之后，OpenWebUI 内置的 <code>bing</code> 引擎已经无法使用。借助 OpenWebUI 内置的 <code>microsoft_web_iq</code> 引擎，只需要在 Azure 上创建一个 Web IQ 资源并拿到 API Key，就可以在管理界面中完成联网搜索的接入：</p>
<ul>
<li>搜索和网页读取都交给 Web IQ，返回的是适合直接放进模型上下文的段落和正文；</li>
<li>在原生工具调用模式下，<code>search_web</code> 负责搜索，<code>fetch_url</code> 负责读取消息中的链接；</li>
<li>通过模型的 Default Features 和系统提示词，让模型自动识别 URL、先读后答，并在回答中给出参考链接。</li>
</ul>
<p>整个过程不需要写一行代码。无论是询问最新的信息，还是直接丢一个链接让模型总结，现在都可以在 OpenWebUI 中完成。</p>
<h2 id="参考资料">参考资料</h2>
<ul>
<li><a href="https://blogs.bing.com/search/2026/6/Announcing-Microsoft-Web-IQ/">Announcing Microsoft Web IQ</a></li>
<li><a href="https://www.microsoft.com/en-us/WebIQ">Microsoft Web IQ</a></li>
<li><a href="https://docs.openwebui.com/features/chat-conversations/web-search/providers/microsoft-web-iq">Open WebUI: Microsoft Web IQ</a></li>
<li><a href="https://docs.openwebui.com/features/chat-conversations/web-search/agentic-search">Open WebUI: Agentic Web Search &amp; URL Fetching</a></li>
<li><a href="https://docs.openwebui.com/features/chat-conversations/web-search/providers/bing">Open WebUI: Bing</a></li>
<li><a href="https://docs.openwebui.com/getting-started/advanced-topics/hardening">Open WebUI: Hardening</a></li>
</ul>
]]></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>