Implementing Strangler Pattern using IIS & URL Rewriting for Legacy Systems Migration

Strangler pattern is a way of migrating from legacy applications to new ones with both being operational at the same time and the upgrade happening behind the scenes gradually.

The picture below shows the crux of this pattern:

strangler
Strangler Pattern in Action (from left to right)

The idea is that you build and deploy the new system side-by-side to the existing legacy system and route traffic between them accordingly. Gradually, the new system will grow while the legacy system will shrink as functionality gets re-written in the new system and eventually, the legacy system will go away and the new one will take over. All through this process, users have no idea that the entire system has been replaced while still remaining operational.

Ever since reading about this pattern I had been thinking of trying it out in some way but wasn’t sure how so I decided to use IIS and the URL Rewrite module to do simple path based URL re-writing to distribute traffic between the legacy and new application, effectively turning IIS into a reverse proxy (IIS already works as a reverse proxy to Kestrel for ASP.NET Core apps). To simulate the applications I created a dummy ASP.NET WebForms project which will play the legacy application role and a new ASP.NET Core 2.0 MVC project which will play the new application role, in Visual Studio 2017.

Disclaimer: this may not be, infact, I am certain it’s not, the most efficient or the best way to implement strangler pattern but I think it demonstrates the idea at its very core and I am certainly open to ideas to make this design scale better because this is still an ongoing study for me. 🙂 I am also assuming the readers are fairly comfortable with IIS, URL Rewrite module and Regular Expressions.

I hosted the legacy application on http://localhost:88 and the new application on http://localhost:8021, in IIS. Straight away you can start to see the benefits of this approach – decoupling:

Hosting
app1 and LegacyApp hosted in IIS

I added a little footer in both apps to distinguish them:

To simulate migration, I decided to serve the Contact page from the new app and the rest from the legacy app. This means all the other links in the legacy app will use legacy resources but the Contact link will be re-routed to the new app and the response will be rendered as though it was being served by the legacy app. I made sure all the navigation layout and naming is consistent across both apps which is crucial for the stealth of the migration.

Enter URL Rewrite Module.

URL rewriting rules can either be set up at the server level where they will apply to all the requests coming in regardless of the application or they can be set up at the application level where they will only apply to that application in question. Because I am doing this in 2018, it seems the ASP.NET WebForms project template has undergone a bit of transformation and it now includes an ASP.NET routing module called “FriendlyUrls” which converts URLs like:

http://localhot:88/Contact.aspx to http://localhost:88/Contact.

It registers these application level routes at app start in Global.asax.cs:

public class Global : HttpApplication
{
private void Application_Start(object sender, EventArgs e)
{
// Register application level routes here.
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
}
}
view raw Global.asax.cs hosted with ❤ by GitHub

You can see the RouteConfig file enables these friendly urls:

public static class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
var settings = new FriendlyUrlSettings();
settings.AutoRedirectMode = RedirectMode.Temporary;
routes.EnableFriendlyUrls(settings);
}
}
view raw RoutConfig.cs hosted with ❤ by GitHub

So now if you set up a URL rewriting rule like this on the application level (you can either set these in the web.config of the legacy or via IIS Manager application):

<rewrite>
<rules>
<rule name="ReverseProxyRule2"
enabled="true" stopProcessing="true">
<match url="^Contact" />
<action type="Rewrite" url="http://localhost:8021/Home/Contact&quot; />
</rule>
</rules>
</rewrite>

It would be ignored as ASP.NET routing seems to take precedence and navigates to the route registered during Application_Start above (this is my theory at this point perhaps someone has a clearer idea?).

The URL rewriting module is handler agnostic and rewrites the URL based on configuration and kicks in early on in the pipeline. The ASP.NET routing however, kicks in when the application handler (instance of HttpApplication) has been selected to handle the request, it registers the routes in the Application_Start event and proceeds on to make requests to the URL. You can check out this Microsoft link for more information on this.

One way to solve this issue would have been to simply disable friendly url settings for urls that I wanted to rewrite (by commenting the code out and manually re-jigging all links) but that would’ve required a bit of work in the app specifically for migration reasons and I wanted to avoid that as far as possible. I wanted to make strangulation changes on the outside relying more on a rules based proxying rather than code level changes.

Therefore a more approach I settled for was to put the inbound rules on the server level in the applicationHost.config file (found in C:\Windows\System32\inetsrv\config). Something of note here, since this rule is set up at server level, it gets activated for every single request to the server even to other apps on the server. To prevent this, I added a SERVER_PORT condition to make sure it redirects requests only when made by the legacy app i.e. the one on port 88.

<rewrite>
<globalRules>
<rule name="ReverseProxyRule1"
enabled="true" stopProcessing="true">
<match url="^Contact" />
<action type="Rewrite" url="http://localhost:8021/Home/Contact&quot; />
<conditions>
<add input="{SERVER_PORT}" pattern="88" />
</conditions>
</rule>
</globalRules>
<outboundRules>
<preConditions>
<preCondition name="IsHTML">
<add input="{RESPONSE_CONTENT_TYPE}" pattern="^text/html" />
</preCondition>
</preConditions>
</outboundRules>
</rewrite>

This way the URL rewriter was effectively able to by-pass the ASP.NET application routing for the strangled URLs, in this case “/Contact” and I didn’t need to modify anything else in the application for this. Other urls in the legacy app still continued to work “as is”. Having done this, I navigated to http://localhost:88/Contact and Voila! it navigated to the new Contact page hosted in the new app (you can tell that by the footer):

Navigating to new Contact page
New Contact page from the new application

More importantly, the URL in the browser address bar didn’t change (stealth FTW!):

URL Doesn't change

This was only half the work as now the redirection worked but any links inside that page were still relative to the new app and navigating to those links was causing a 404.

links in response
Links in responses also need re-writing
404 on form post
Navigating to /Home/Contact causes 404

To fix this, I set-up outbound rules that dynamically changed the URLs in the HTML response to point relative to the legacy app. These outbound rules, in contrast to inbound rules, were set up at the application level in the legacy app’s web.config:

This changed the HTML response from the new app on the fly and updated the anchor tags according to the rule above:

rewritten-content-a
Rewritten response link

I also updated all the custom CSS/JS content links and the form action attribute in the response HTML by writing 2 more outbound rules:

<rule name="LinkNScriptFixer" preCondition="IsHTML"
enabled="true" stopProcessing="true">
<match filterByTags="Link, Script" pattern="^/(css|js)/(.*)"
negate="false" />
<action type="Rewrite" value="http://localhost:8021{R:0}" />
</rule>
<rule name="FormActionFixer" preCondition="IsHTML"
enabled="true" stopProcessing="true">
<match filterByTags="Form" pattern="^/(.*)/(.*)" />
<action type="Rewrite" value="/{R:2}" />
</rule>

Any third party CSS/JS content (like Bootstrap, jQuery etc) should ideally be served via a CDN rather than from the app root this will keep the number of re-write operations required low. The rules above only rewrote custom CSS/JS content links this made sure that the new app page rendered with its own CSS/JS without disturbing any in the legacy app which were not changed.

The FormActionFixer rule changed the form action attribute from:

form action_un
Form Action also need rewriting

to:

form action
That’s more like it!

but this form post still didn’t work because there was no URL called http://localhost:88/Submit in the legacy app. To fix this, I just re-directed this url to http://localhost:8021/Home/Submit using another server level inbound rule:

<rule name="FormSubmitter" enabled="true"
stopProcessing="true">
<match url="^Submit" />
<action type="Rewrite"
url="http://localhost:8021/Home/Submit&quot; />
</rule>
view raw FormSubmitter hosted with ❤ by GitHub

The form post now posted correctly but since I was redirecting back to the original Contact action in my HomeController:

public class HomeController : Controller
{
public IActionResult Contact()
{
ViewData["Message"] = "Your contact page.";
return View();
}
[HttpPost]
public IActionResult Submit(string comment)
{
TempData["Comment"] = comment;
return RedirectToAction(nameof(Contact));
}
}

the response contained the Location header pointing to “/Home/Contact” which was once again relative to the new app on port 8021 and navigating to it resulted in a 404 error:

location header
Location header in the response
404 on form post
404 after form post redirect

To fix this, I added one last outbound rule to my legacy app config. This time instead of matching the URL path, I needed to match the value of the RESPONSE_LOCATION server variable, look for “/Home/Contact” and rewrite the header value to point to “/Contact” so that the pre-existing inbound rule can take care of routing the Contact page. Of course the pre-condition to this rule was that only re-direct response types needed to be handled i.e. the value of RESPONSE_STATUS server variable should be either 301/302/307:

<rule name="ResponseLocationFixer"
preCondition="IsRedirection"
enabled="true" stopProcessing="true">
<match serverVariable="RESPONSE_LOCATION" pattern="^/(.*)/(.*)" />
<action type="Rewrite" value="/{R:2}" />
</rule>
<preConditions>
<preCondition name="IsRedirection">
<add input="{RESPONSE_STATUS}" pattern="3\d\d" />
</preCondition>
</preConditions>

Now when I retried submitting the form post, the redirection back to Contact page finally worked with the comment I posted being displayed back on the page:

form post success
Voila! form post redirect now works!

The final inbound rules list looked like this (in applicationHost.config):

<rewrite>
<globalRules>
<rule name="ReverseProxyRule1" enabled="false">
<match url="^About" />
<action type="Rewrite" url="http://localhost:8021/Home/About&quot; />
<conditions>
<add input="{SERVER_PORT}" pattern="88" />
</conditions>
</rule>
<rule name="ReverseProxyRule2" enabled="true" stopProcessing="true">
<match url="^Contact" />
<action type="Rewrite" url="http://localhost:8021/Home/Contact&quot; />
<conditions>
<add input="{SERVER_PORT}" pattern="88" />
</conditions>
<serverVariables>
<set name="ORIGINAL_HOST" value="{HTTP_HOST}" />
</serverVariables>
</rule>
<rule name="FormSubmitter" enabled="true" stopProcessing="true">
<match url="^Submit" />
<action type="Rewrite" url="http://localhost:8021/Home/Submit&quot; />
</rule>
</globalRules>
<outboundRules>
<preConditions>
<preCondition name="IsHTML">
<add input="{RESPONSE_CONTENT_TYPE}" pattern="^text/html" />
</preCondition>
</preConditions>
</outboundRules>
</rewrite>

The final outbound rules list looked like this (in web.config of the legacy app):

<rewrite>
<outboundRules>
<rule name="AnchorFixer" preCondition="IsHTML"
enabled="true" stopProcessing="true">
<match filterByTags="A" pattern="^/Home/(.*)" />
<action type="Rewrite" value="/{R:1}" />
</rule>
<rule name="LinkNScriptFixer" preCondition="IsHTML"
enabled="true" stopProcessing="true">
<match filterByTags="Link, Script" pattern="^/(css|js)/(.*)"
negate="false" />
<action type="Rewrite" value="http://localhost:8021{R:0}" />
</rule>
<rule name="FormActionFixer" preCondition="IsHTML"
enabled="true" stopProcessing="true">
<match filterByTags="Form" pattern="^/(.*)/(.*)" />
<action type="Rewrite" value="/{R:2}" />
</rule>
<rule name="ResponseLocationFixer" preCondition="IsRedirection"
enabled="true" stopProcessing="true">
<match serverVariable="RESPONSE_LOCATION" pattern="^/(.*)/(.*)" />
<conditions>
</conditions>
<action type="Rewrite" value="/{R:2}" />
</rule>
<preConditions>
<preCondition name="IsRedirection">
<add input="{RESPONSE_STATUS}" pattern="3\d\d" />
</preCondition>
</preConditions>
</outboundRules>
</rewrite>

There is some stuff that I couldn’t get round to :

a) How would I flow the authentication cookies from the legacy app to the new app without having to authenticate twice? Security is an important consideration in this kind of architecture.

b) The finer details like re-writing URLs for AJAX calls and caching of responses at the proxy level etc.

b) How would I do rate-limiting? This is kind of venturing into application gateway territory that is usually put in front of back end services in a micro-services architecture but could still be useful here.

c) How would this particular approach scale? The approach to accessing back end services via a reverse proxy is going to be a bit slower due to extra hops and URL and content rewriting. I did some preliminary load testing (on localhost) with Apache Bench (both the applications were only running in single worker processes with all other settings in IIS at their defaults):

At concurrency level of 300 (averaged over several runs):

load test c 300
This was the max RPS at this configuration.

Accessing the new app directly with the same test parameters, I got:

load test direct c 300
I got 400 more RPS accessing the new app directly.

A difference of around 400 RPS which on localhost doesn’t mean much, plus the result is only static text but the question is would we rather have the users experience a slightly degraded app performance while the migration happens but still be able to provide value out of both the systems or we maintain both applications but the new app doesn’t get deployed and used until ready which means a slower feedback loop and chances of getting it wrong. Slow but likely right or fast but potentially wrong? Perhaps there is a happy medium here that could be achieved if this design was tweaked a bit.

d) I also need to figure out how this architecture would work in cloud environment. Perhaps an exercise for the next post.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.