实践笔记:IIS + URL Rewrite + ARR 实现 ASP.NET Core 蓝绿部署
<p>最近用户有个需求:更新 ASP.NET Core 应用时,要让访问不中断且用户无感知,部署环境为 Windows Server + IIS。自然想到了蓝绿部署,之前没有应用过 URL Rewrite + ARR,就趁此实践一下。<br>原本想着很简单:对 URL 重写规则不熟,直接问 ai。结果反倒被 ai 误导,折腾了好一阵子才搞好,在此分享配置过程、重写规则以及相关代码。</p>
<hr>
<h2 id="1-概念简介">1 概念简介</h2>
<p>开始之前,简要说明一下本文涉及的三个关键概念及其在本方案中的作用:</p>
<ul>
<li>
<p>URL Rewrite<br>
IIS 的 URL 重写模块,可根据设置的规则匹配并处理请求。在本方案中,它承担关键的请求分发工作:通过在一个 Switch 站点中配置重写规则,将请求按需转发到 Blue 站点或 Green 站点,实现版本之间的快速切换。</p>
</li>
<li>
<p>ARR(Application Request Routing)<br>
IIS 的反向代理扩展,提供代理与转发能力。需要说明的是本方案没有使用 ARR 的 Server Farms 功能,而是依赖其反向代理能力以便支持 URL Rewrite 的 “代理式重写”:有了 ARR 重写才能有反代效果。</p>
</li>
<li>
<p>蓝绿部署(Blue-Green Deployment)<br>
一种应用程序发布策略,即准备两套功能一致的环境(蓝/绿),在同一时间只有一个环境(如蓝)承载线上流量。新版本部署到闲置环境(绿),测试通过后,通过切换流量瞬间完成发布,实现零停机和快速回滚。</p>
</li>
</ul>
<hr>
<h2 id="2-最终部署结构">2 最终部署结构</h2>
<p>先说结果:</p>
<p><img src="https://img2024.cnblogs.com/blog/3640993/202511/3640993-20251120082753757-648077762.gif"></p>
<p>示例里的部署目录结构(本文里会以此目录为例):</p>
<p><img src="https://img2024.cnblogs.com/blog/3640993/202511/3640993-20251119111221862-1640157575.png"></p>
<p>即:在 IIS 里创建 3 个站点:Switch 站点、Blue 站点以及 Green 站点,需要共享的配置、缓存、附件等位于站外。数据库自然也用同一数据库。</p>
<p>用户通过 Switch 站点 <em>http://192.168.0.116:9080</em> 访问系统(各站点端口可根据你的实际情况设置),Switch 站点再根据设置的重写规则,将用户请求导向 Blue 站点或 Green 站点。<strong>蓝绿站点同时只有其中的一个为用户提供服务。</strong></p>
<p>初始部署时(<em>v1.0.0</em>),应用发布到 Blue 站点,并让 Switch 站点将请求导向 Blue 站点,用户开始正常访问。<br>
第一次更新(<em>v1.0.1</em>),新版本发布到 Green 站点并进行测试、预热,然后让Switch 站点将请求导向 Green 站点,用户访问不中断。<br>
第二次更新(<em>v1.0.2</em>),Blue 站点此时处于空闲状态,因此可以安全地将其停掉,并删除旧版本、放入新版本进行测试、预热,然后让 Switch 站点再将请求导向 Blue 站点,用户访问仍不会中断。<br>
如此重复,滚动更新。</p>
<p>这里 有个蓝绿部署示例应用,可分别用两个浏览器去登录切换试试:在浏览器A里打开一个列表或编辑页面,然后在浏览器B里切换一下站点,再回到浏览器A里继续进行分页查询或点击保存按钮,响应将依然正常。如果只有一个浏览器,可分别用正常模式和无痕模式去登录。</p>
<hr>
<h2 id="3-环境准备">3 环境准备</h2>
<p>先确保 IIS 与 ASP.NET Core 运行环境已安装好,运行环境版本要与你发布应用时指定的一致。</p>
<h3 id="31-安装-url-rewrite">3.1 安装 URL Rewrite</h3>
<p>下载地址:https://www.iis.net/downloads/microsoft/url-rewrite,到页面底部下载适用自己的安装包。<br>
要确认是否安装:打开 IIS 管理器,在左侧选中一个站点,看看右侧功能列表里有没有 <em>"URL 重写"</em>。</p>
<h3 id="32-arr-安装与配置">3.2 ARR 安装与配置</h3>
<p>下载地址:https://www.iis.net/downloads/microsoft/application-request-routing。<br>
安装好后,打开 IIS 管理器,在左侧选中<strong>计算机名</strong>或<strong>服务器名</strong>,在右侧功能列表里找到 <em>"Application Request Routing Cache"</em>:</p>
<p><img src="https://img2024.cnblogs.com/blog/3640993/202511/3640993-20251119095834026-31411367.png"></p>
<p>双击打开,在右侧找到并点击 <em>"Server Proxy Settings"</em>:</p>
<p><img src="https://img2024.cnblogs.com/blog/3640993/202511/3640993-20251119095837618-1506349383.png"></p>
<p>然后按照下图配置:</p>
<p><img src="https://img2024.cnblogs.com/blog/3640993/202511/3640993-20251119095841959-1890118552.png"></p>
<p>为何取消选中 <em>"Reverse rewrite host in response headers"</em>:因为选中后响应头中的 Host 会被强行替换成 Switch 的 Host,这在跨域回调时可能会有问题。</p>
<p>至此 ARR 就配置好了,因为我们不需要使用其 Server Farms 功能。</p>
<hr>
<h2 id="4-蓝绿站点创建与配置">4 蓝绿站点创建与配置</h2>
<p>创建 Blue 站点和 Green 站点,路径分别指向 QAdminAppBlue 目录和 QAdminAppGreen 目录,将用来放置应用程序文件。<br>
让两个站点分别监听 5001 和 5002 端口(端口号你可自行调整),各自使用独立的应用程序池并把应用程序池的 .NET CLR 版本均置为 <em>"无托管代码"</em>。</p>
<p>另外,<strong>把蓝绿站点均绑定到 IP 地址 127.0.0.1 上</strong>:因为你不应允许用户绕过 Switch 站点直接访问 Blue 站点和 Green 站点。<br>
当然也可通过其它途径达到此目的,比如用 Windows 防火墙。</p>
<hr>
<h2 id="5-switch-站点创建与配置">5 Switch 站点创建与配置</h2>
<p>这里是本方案里最关键的配置部分。</p>
<h3 id="51-创建-switch-站点">5.1 创建 Switch 站点</h3>
<p>创建 Switch 站点,路径指向 QAdminAppSwitch目录,该目录下将只有个 web.config 文件,内容为 URL 重写规则。<br>
让 Switch 站点监听 9080 端口(我本机 80 已被占用,你按实际情况设置),也使用独立的应用程序池并把其 .NET CLR 版本置为 <em>"无托管代码"</em>。<br>
将 Switch 站点绑定到对外使用的一个 IP 地址上(比如 192.168.0.116),如果是要通过域名访问,绑定时再设置一下主机名为你的域名。<br>
用户将通过你设置的 IP 或域名访问应用系统。</p>
<h3 id="52-书写-url-重写规则">5.2 书写 URL 重写规则</h3>
<p>在 IIS 管理器 => Switch 站点 => URL 重写 里,可进行重写规则的配置,配置将存到站点根目录下的 web.config 文件里。<br>
以下是所需要的完整的重写规则,你可直接拷贝到 Switch 站点的 web.config 里使用。其中的蓝绿站点端口你按实际情况修改。</p>
<pre><code class="language-xml"><?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<rewrite>
<rules useOriginalURLEncoding="false">
<clear />
<!-- 蓝站点 https 规则-->
<rule name="RouteToBlueHttps" enabled="true" stopProcessing="true">
<match url="(.*)" />
<conditions>
<add input="{HTTPS}" pattern="on" ignoreCase="true" />
</conditions>
<serverVariables>
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
</serverVariables>
<action type="Rewrite" url="http://127.0.0.1:5001{UNENCODED_URL}" appendQueryString="false" />
</rule>
<!-- 蓝站点 http 规则-->
<rule name="RouteToBlueHttp" enabled="true" stopProcessing="true">
<match url="(.*)" />
<conditions>
<add input="{HTTPS}" pattern="off" ignoreCase="true" />
</conditions>
<serverVariables>
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
<set name="HTTP_X_FORWARDED_PROTO" value="http" />
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
</serverVariables>
<action type="Rewrite" url="http://127.0.0.1:5001{UNENCODED_URL}" appendQueryString="false" />
</rule>
<!-- 绿站点 https 规则-->
<rule name="RouteToGreenHttps" enabled="false" stopProcessing="true">
<match url="(.*)" />
<conditions>
<add input="{HTTPS}" pattern="on" ignoreCase="true" />
</conditions>
<serverVariables>
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
</serverVariables>
<action type="Rewrite" url="http://127.0.0.1:5002{UNENCODED_URL}" appendQueryString="false" />
</rule>
<!-- 绿站点 http 规则-->
<rule name="RouteToGreenHttp" enabled="false" stopProcessing="true">
<match url="(.*)" />
<conditions>
<add input="{HTTPS}" pattern="off" ignoreCase="true" />
</conditions>
<serverVariables>
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
<set name="HTTP_X_FORWARDED_PROTO" value="http" />
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
</serverVariables>
<action type="Rewrite" url="http://127.0.0.1:5002{UNENCODED_URL}" appendQueryString="false" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>
</code></pre>
<h3 id="53-添加允许的服务器变量">5.3 添加允许的服务器变量</h3>
<p>规则里写的转发相关服务器变量需要添加进来才能正常使用。<br>
打开 IIS 管理器,选中 Switch 站点,在右侧功能列表里找到 <em>"URL 重写"</em>:</p>
<p><img src="https://img2024.cnblogs.com/blog/3640993/202511/3640993-20251119174505321-839020532.png"></p>
<p>双击打开 <em>"URL 重写"</em>,然后点击右侧的 <em>"查看服务器变量"</em>:</p>
<p><img src="https://img2024.cnblogs.com/blog/3640993/202511/3640993-20251119124440396-161946200.png"></p>
<p>在该界面将 <em>"HTTP_X_FORWARDED_HOST"</em>、<em>"HTTP_X_FORWARDED_PROTO"</em>、<em>"HTTP_X_FORWARDED_FOR"</em> 添加进来:</p>
<p><img src="https://img2024.cnblogs.com/blog/3640993/202511/3640993-20251119124443179-1614749483.png"></p>
<h3 id="54-规则的简要解释">5.4 规则的简要解释</h3>
<ul>
<li>
<p>最关键的要求是:用户在浏览器里输入的 URL,能够<strong>完整的、不被做任何改动的</strong>转给蓝绿站点里的 App<br>
这个费了点周折,比如 URL:<em>"/TestPage/aa%2Fbb"</em>,本意是请求 <em>"/TestPage"</em> 页面,路由参数为 <em>"aa/bb"</em>,因为该参数里有斜杠,因此用编码后的 <em>"aa%2Fbb"</em> 传递。但测试时发现转给 App 的请求是 <em>"/TestPage/aa/bb"</em>,造成 404。<br>
最终在 这里 找到了答案:使用 <code>{UNENCODED_URL}</code> 并设置 <code>useOriginalURLEncoding</code> 为 <code>false</code>。</p>
</li>
<li>
<p>里边的服务器变量设置用来确保传递正确的 host、scheme 以及客户端 IP 地址给 App<br>
比如 App 里拿到的 host 将是 <em>"192.168.0.116:9080"</em> 而不是 <em>"127.0.0.1:5001"</em> 或 <em>"127.0.0.1:5002"</em>。<br>
要与代码配合实现,见后边章节。</p>
</li>
<li>
<p>为何给蓝绿分别设置了两条规则<br>
因为 URL 重写没法自适应 http/https,只有个 <code>{HTTPS}</code> 变量(值为 <em>"on"</em>/<em>"off"</em>),为了让 http、https 均能正常访问,只能各自写两条规则。<br>
如果你的应用只需要 http/https 中的一种访问,可以删掉不需要的规则。</p>
</li>
<li>
<p>蓝绿的切换<br>
蓝绿的切换就是对应规则的启用与停用,哪个站点规则启用(<code>enabled="true"</code>),就导向哪个站点。不能同时都启用。<br>
不能在 IIS 里手动去启用、禁用规则,这会造成访问中断,而是要通过代码去实现,见后边章节。</p>
</li>
</ul>
<hr>
<h2 id="6-应用调整">6 应用调整</h2>
<p>应用也需要加入一些初始化代码,以及做出一些相应的调整才能适应蓝绿部署环境。</p>
<h3 id="61-应用初始化中的两项必要配置">6.1 应用初始化中的两项必要配置</h3>
<ul>
<li><strong>配置数据保护(Data Protection)以共享密钥</strong><br>
必须使用 AddDataProtection() 指定蓝绿站点使用同一套密钥存储,不然会出现 Cookie 无法识别等问题。<br>
比如用共享文件夹:</li>
</ul>
<pre><code class="language-csharp">builder.Services.AddDataProtection()
.SetApplicationName("myApp")
// 应用上一级目录的 myAppKeys 目录下
.PersistKeysToFileSystem(new DirectoryInfo($"{AppContext.BaseDirectory}../myAppKeys"));
</code></pre>
<p>或存于 Redis:</p>
<pre><code class="language-csharp">var redis = ConnectionMultiplexer.Connect("<URI>");
builder.Services.AddDataProtection()
.SetApplicationName("myApp")
.PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys");
</code></pre>
<ul>
<li><strong>配置转发头中间件(Forwarded Headers)</strong><br>
必须使用 UseForwardedHeaders() 配置转发头中间件以确保 App 够获取真实的 host、scheme 以及客户端 IP 地址,比如 App 里拿到的 host 将是 <em>"192.168.0.116:9080"</em> 而不是 <em>"127.0.0.1:5001"</em> 或 <em>"127.0.0.1:5002"</em>,拿到的 scheme 则是实际的 scheme(http/https)。</li>
</ul>
<pre><code class="language-csharp">// 在 builder.Build() 后立即调用:
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
});
</code></pre>
<h3 id="62-其它事项">6.2 其它事项</h3>
<ul>
<li>识别自己所在环境<br>
蓝绿部署环境下,应用通常需要知道自己运行在 Blue 还是 Green 里,比如日志要增加 <code>ServerNode</code> 项,以便记录在哪个登录、在哪个执行的操作等等。<br>
至于如何识别自己所在的环境,可直接根据应用自己所在的目录名称来判断:</li>
</ul>
<pre><code class="language-csharp">private static string _whoami()
{
string dirName = new DirectoryInfo(AppContext.BaseDirectory).Name;
bool isBlue = dirName.Contains("blue", StringComparison.OrdinalIgnoreCase);
bool isGreen = dirName.Contains("green", StringComparison.OrdinalIgnoreCase);
if (isBlue && isGreen)
return "Ambiguous";
if ((!isBlue) && (!isGreen))
return "Unknown";
return isBlue ? "Blue" : "Green";
});
</code></pre>
<ul>
<li>配置共享<br>
把配置文件放到一个共享目录下,比如应用上一级目录下的 configs 目录。<br>
就是说应用目录下不要有发布后需要更改的配置文件,这样更新时就可放心地删除旧版、拷贝新版了,不然一旦疏忽会造成混乱或异常。<br>
以下代码将使应用使用其上一级目录下的 configs 目录下的 appConfig.json 配置文件,供你参考:</li>
</ul>
<pre><code class="language-csharp">string appConfigFile = $"{AppContext.BaseDirectory}../configs/appConfig.json";
string appConfigFileEnv = $"{AppContext.BaseDirectory}../configs/appConfig.{builder.Environment.EnvironmentName}.json";
builder.Configuration.AddJsonFile(appConfigFile, false, true);
builder.Configuration.AddJsonFile(appConfigFileEnv, true, true);
</code></pre>
<p>如果蓝绿下的 App 需要不同的配置:在共享配置文件里书写两套配置,App 里则用一套代码就可让蓝绿各自读取自己的:<br>
配置:</p>
<pre><code class="language-json">{
"MyApp_Blue": {
"Foo": "abc",
},
"MyApp_Green": {
"Foo": "def",
},
}
</code></pre>
<p>读取:</p>
<pre><code class="language-csharp">// 参见前边的 _whoami()
string foo= builder.Configuration[$"MyApp_{_whoami()}:Foo"];
</code></pre>
<ul>
<li>
<p>分布式缓存<br>
如果有需要共享的缓存,则需要改用分布式缓存。比如用到了 Session。</p>
</li>
<li>
<p>文件上传<br>
若有附件上传,则同样要使用同一个共享目录。</p>
</li>
<li>
<p>后台任务/定时任务<br>
若有后台任务或定时任务,蓝绿将都在执行。<br>
如果任务允许蓝绿同时运行,或允许一前一后运行,或者不允许同时运行但可中断,就没什么问题。否则需要将任务独立出来,并独立运行(比如用 Windows Service)。</p>
</li>
<li>
<p>向后兼容<br>
应用需要考虑向后兼容性。<br>
如果新版本使用了与旧版不兼容的会话结构、加密格式、字段结构等等,就无法进行平滑切换,因此需要考虑向后的兼容性,比如新增的字段要确保允许 NULL 或设有默认值等。<br>
如果确实无法兼容,就只能短时中断访问了,根据实际情况可采取提前通知、低峰操作等方式升级。</p>
</li>
</ul>
<hr>
<h2 id="7-蓝绿如何切换">7 蓝绿如何切换</h2>
<p>蓝绿的切换过程实际上就是启用/禁用 Switch 站点里的对应规则。<br>
但是不能在 IIS 里手动去启用、禁用规则,这会造成访问中断,而是通过用脚本或代码修改 Switch 站点里的 web.config 文件来进行切换:修改对应规则的 <code>enabled</code> 为 <code>true</code>/<code>false</code>,比如用 PowerShell 脚本。</p>
<p>我是在应用里设计了一个只有超级管理员用户访问的页面,在其中进行切换操作。<br>
以下是用来获取当前启用的环境以及进行蓝绿切换的 C# 方法,你可直接使用。</p>
<pre><code class="language-csharp">/// <summary>
/// 获取当前 web.config 里启用的环境。
/// </summary>
/// <param name="webConfigPath">Switch 站点的 web.config 文件完整路径。</param>
/// <returns></returns>
private static string _getCurrentEnvironmentInConfiguration(string webConfigPath)
{
if (!System.IO.File.Exists(webConfigPath))
throw new FileNotFoundException("未找到 Switch 站点的 web.config 文件。", webConfigPath);
XDocument doc = XDocument.Load(webConfigPath);
// 所有 Blue 规则节点
var blueRules = doc
.Descendants("rule")
.Where(r => ((string)r.Attribute("name")).StartsWith("RouteToBlue", StringComparison.OrdinalIgnoreCase))
.ToList();
// 所有 Green 规则节点
var greenRules = doc
.Descendants("rule")
.Where(r => ((string)r.Attribute("name")).StartsWith("RouteToGreen", StringComparison.OrdinalIgnoreCase))
.ToList();
if (blueRules.Count == 0 || greenRules.Count == 0)
throw new InvalidOperationException("未找到 RouteToBlue 或 RouteToGreen 规则,请检查 web.config。");
bool blueEnabled = blueRules.All(r => (string)r.Attribute("enabled") != "false");
bool greenEnabled = greenRules.All(r => (string)r.Attribute("enabled") != "false");
if (blueEnabled && greenEnabled)
return "All";
if ((!blueEnabled) && (!greenEnabled))
return "NoneOrAmbiguous";
return blueEnabled ? "Blue" : "Green";
}
/// <summary>
/// 切换蓝绿环境。
/// </summary>
/// <param name="webConfigPath">Switch 站点的 web.config 文件完整路径。</param>
/// <returns>返回已启用的环境。</returns>
private static string _toggleEnvironment(string webConfigPath)
{
if (!System.IO.File.Exists(webConfigPath))
throw new FileNotFoundException("未找到 Switch 站点的 web.config 文件。", webConfigPath);
XDocument doc = XDocument.Load(webConfigPath);
// 所有 Blue 规则节点
var blueRules = doc
.Descendants("rule")
.Where(r => ((string)r.Attribute("name")).StartsWith("RouteToBlue", StringComparison.OrdinalIgnoreCase))
.ToList();
// 所有 Green 规则节点
var greenRules = doc
.Descendants("rule")
.Where(r => ((string)r.Attribute("name")).StartsWith("RouteToGreen", StringComparison.OrdinalIgnoreCase))
.ToList();
if (blueRules.Count == 0 || greenRules.Count == 0)
throw new InvalidOperationException("未找到 RouteToBlue 或 RouteToGreen 规则,请检查 web.config。");
bool blueEnabled = blueRules.All(r => (string)r.Attribute("enabled") != "false");
bool greenEnabled = greenRules.All(r => (string)r.Attribute("enabled") != "false");
string targetEnv;
if (blueEnabled && !greenEnabled)
{
// 当前是蓝 → 切换到绿
foreach (var r in blueRules)
r.SetAttributeValue("enabled", "false");
foreach (var r in greenRules)
r.SetAttributeValue("enabled", "true");
targetEnv = "Green";
}
else if (greenEnabled && !blueEnabled)
{
// 当前是绿 → 切换到蓝
foreach (var r in blueRules)
r.SetAttributeValue("enabled", "true");
foreach (var r in greenRules)
r.SetAttributeValue("enabled", "false");
targetEnv = "Blue";
}
else
{
// 若都已启用、都已停用或状态混杂,则切换到蓝
foreach (var r in blueRules)
r.SetAttributeValue("enabled", "true");
foreach (var r in greenRules)
r.SetAttributeValue("enabled", "false");
targetEnv = "Blue";
}
// UTF-8 编码保存,并确保不写入 BOM,以防止 IIS 读取出错
using (var writer = new StreamWriter(webConfigPath, false, new System.Text.UTF8Encoding(false)))
{
doc.Save(writer);
}
return targetEnv;
}
</code></pre>
<hr>
<h2 id="8-切换测试">8 切换测试</h2>
<p>用 k6 分别对 Windows Server 2012 R2 + IIS8.5 和 Win11 + IIS10 下的蓝绿部署进行了切换测试,尚未出现访问中断的情况。</p>
<br>
<blockquote class="myclaims" style="background-color: rgba(249, 249, 249, 1); border-left: 4px solid rgba(208, 208, 208, 1); padding: 10px 15px; margin: 20px 0; font-size: 0.9em">
<p style="margin: 5px 0"><strong>作者:</strong>木南W</p>
<p style="margin: 5px 0"><strong>出处:</strong>https://www.cnblogs.com/munanwang/p/19234857</p>
<p style="margin: 5px 0">转载请注明作者并在页面明显位置给出原文链接。</p>
</blockquote><br><br>
来源:https://www.cnblogs.com/munanwang/p/19234857
頁:
[1]