OPTEN, das einzige Umbraco-zertifizierte Unternehmen der Schweiz

Umbraco + Varnish = ♥

English Version

 

Oft sah ich, dass im Varnish Configuration Language-File (kurz vcl) eine fixe Ablaufzeit des Caches angegeben wird. Ziel war es wohl so schnell wie möglich eine bessere Performance herauszuholen. Daher wurde die Ablaufzeit für jede Seite auf eine fixe Dauer gesetzt.

Zum Beispiel kann folgendermassen die Ablaufzeit für alle Seiten/Dateien auf eine Stunde gesetzt werden:

sub vcl_fetch {
      set beresp.ttl = 1h;
      return (deliver);
}

Dies fordert Varnish auf, alle Seiten bzw. Dateien, welche in vcl_fetch() kommen, für eine Stunde im Cache
zu halten.

Will man eine Seite nicht im Cache haben, müsste man die URL wie folgt ignorieren:

if (req.url ~ "/liveticker”) {
      return (pass);
}

Falls die Seite /liveticker beinhaltet (/liveticker, /liveticker/, /liveticker?success=false), wird Varnish
nicht prüfen, ob diese Seite im Cache ist.

Dies ist keineswegs falsch – trotzdem will ich mit diesem Blog-Eintrag zeigen, wie Umbraco und Varnish zusammenarbeiten können, um eine höhere Performance herauszuholen.

Varnish ist intelligenter als man denkt denn Varnish kann die Ablaufzeit durch die standardisierten HTTP-Cache-Header wie Expires und Cache-Control berechnen. Somit kann man beresp.ttl = 1h aus vcl_fetch löschen und jedem Request Cache-Control: max-age(3600) beifügen.

Aha... und wie geht das mit Umbraco?

Nun, die Applikation setzt für jeden (Content) Request den Cache-Control Header auf eine Stunde:

protected override void ApplicationStarted(...)
{
      // Add Cache-Control HTTP header
      PublishedContentRequest.Prepared += PublishedContentRequest_Prepared;
}

void PublishedContentRequest_Prepared(object sender, EventArgs e)
{
      PublishedContentRequest request = sender as PublishedContentRequest;

      if (request == null || request.HasPublishedContent == false) return;

      HttpContext = HttpContext.Current;

      if (httpContext == null) return;

      HttpResponse response = httpContext.Response;

      int maxAge = 3600; // 1 hour

      response.Cache.SetCacheability(HttpCacheability.Public);
      response.Cache.SetExpires(DateTime.Now.AddSeconds(maxAge));
      response.Cache.SetMaxAge(new TimeSpan(0, 0, maxAge));
}

Verändert hat sich dadurch nicht viel – ausser dass nun die Applikation über die Ablaufzeit des Caches bestimmt (so kann dasselbe vcl-File für verschiedene Applikationen benützt werden).

Wie wäre es jetzt aber, wenn man im CMS angeben könnte, wie lange jede einzelne Seite im Cache bleibt?

Dafür fügen wir dem Document Type ein Numeric-Property varnishCacheControlMaxAge hinzu. Mein Vorschlag ist zusätzlich, dass es einen standard maxAge-Wert geben sollte (z.B. 3600) welcher im web.config gesetzt werden kann.

int maxAge;
int.TryParse(ConfigurationManager.AppSettings["varnish:maxAge"], out maxAge);

IPublishedContent content = request.PublishedContent;

if (content.HasProperty("varnishCacheControlMaxAge") &&
    content.HasValue("varnishCacheControlMaxAge"))
{
      maxAge = content.GetPropertyValue<int>("varnishCacheControlMaxAge");
}

// Only set cache headers if we want to cache the page
if (maxAge > 0)
{
      response.Cache.SetCacheability(HttpCacheability.Public);
      response.Cache.SetExpires(DateTime.Now.AddSeconds(maxAge));
      response.Cache.SetMaxAge(new TimeSpan(0, 0, maxAge));
}

Mit dieser Änderung kann nun auch das Caching für die Liveticker-Seite (wie oben im Beispiel) abgeschaltet werden. Dafür muss das Property varnishCacheControlMaxAge lediglich auf 0 gesetzt werden. Noch besser: die if (reql.url ~ "/liveticker") Zeile im vcl-File kann nun gelöscht werden!

Backend

Ich will meine Änderungen sofort sehen! 

Kein Problem, hierfür gibt es die smart bans.

Dafür müssen wir einen BAN-Request (wie GET und POST) zum Varinsh Server schicken. Dies tun wir immer dann, wenn etwas publiziert wird.

Und wie weiss Varnish, welche Seite aus dem Cache gelöscht werden muss?
Ganz einfach: Wir schicken Varnish die ID der Seite im HTTP Header und lassen diese auch cachen.

(Wichtig ist zu wissen, dass Varnish nicht nur die Seite an sich im Cache hat, sondern auch die dazugehörigen Cookies (falls nicht entfernt), HTTP Header und anderes).

Somit können wir im BAN Request diese ID beifügen und Varnish löscht alle Seiten aus dem Cache, welche diese ID beinhalten.

Dafür passen wir den Request einer Seite so an, dass die ID mitgeliefert wird. Hierfür ändere ich die Methode, welche wir vorhin für das senden des Cache-Control Headers verwendet haben.

void PublishedContentRequest_Prepared(object sender, EventArgs e)
{
      
      // Only set cache headers if we want to cache the page
      if (maxAge > 0)
      {
            //TODO: Umbraco 7.3.x add GUID?
            response.Headers.Add("Umb-PageId", content.Id.ToString());
            
      }
}

Als nächtes implementieren wir das Event welches beim Publizieren aufgerufen wird.

protected override void ApplicationStarted(...)
{
      
      // Issue BAN for content when published
      ContentService.Published += ContentService_Published;
}

void ContentService_Published(IPublishingStrategy sender, PublishEventArgs<IContent> e)
{
      try
      {
            foreach (IContent content in e.PublishedEntities)
            {
                  if (content.HasProperty("varnishInvalidateCacheOnPublish") &&
                      content.GetValue<bool>("varnishInvalidateCacheOnPublish"))
                  {
                        //TODO: This could be in a separate class/method
                        using (HttpClient = new HttpClient())
                        {
                              httpClient.BaseAddress = new Uri("http://localhost"); //TODO: Get from web.config?
                              httpClient.DefaultRequestHeaders.Clear();
                              httpClient.DefaultRequestHeaders.Add("Varnish-Ban-Umb-PageId", content.Id.ToString());

                              HttpMethod method = new HttpMethod("BAN");
                              HttpRequestMessage request = new HttpRequestMessage(method, httpClient.BaseAddress);

                              await httpClient.SendAsync(request);
                        }
                  }
            }
      }
      catch (Exception ex)
      {
            LogHelper.Error<Startup>("Error issuing BAN to Varnish.", ex);
      }
}

Das varnishInvalidateCacheOnPublish-Property ist ein True/false Datentyp. Dadurch will ich erreichen, dass nur bestimmte Seiten den BAN Request zum Varnish schicken (z.B. News welche oft geändert werden).

Zum Schluss noch eine kleine Anpassung im vcl-File:

sub vcl_recv {
      # Catch BAN Command
      if (req.request == "BAN" && client.ip ~ ban) {

            if (req.http.Varnish-Ban-Umb-PageId) {
                  ban("obj.http.Umb-PageId == " + req.http.Varnish-Ban-Umb-PageId);
                  error 200 "Banned Umbraco Page " + req.http.Varnish-Ban-Umb-PageId;
            }
      }
}

Da nun Umb-PageId für jede Seite im HTTP Header geschickt wird (falls maxAge > 0), können wir das vcl-File weiter verschönern.

sub vcl_fetch {
      …

      # Cache static Pages
      if (beresp.http.Umb-PageId) {
            unset beresp.http.Set-Cookie;
            return (deliver);
      }

      # do not cache everything else
      return (hit_for_pass);
}

sub vcl_deliver {
      # Expires Header set by Umbraco are used to define Varnish caching only
      # therefore do not send them to the Client
      if (resp.http.Umb-PageId) {
            unset resp.http.expires;
            unset resp.http.pragma;
            unset resp.http.cache-control;
            unset resp.http.Etag;
      }

      # smart Ban related
      unset resp.http.Umb-PageId;
      
      return (deliver);
}

Jetzt werden nur noch Seiten – welche das Umb-PageId im Header haben – in die Methode vcl_deliver
überführt (natürlich auch alles, was man vorher noch abfängt wie z.B. Bilder). Im vcl_deliver werden
noch alle Cache-Header entfernt, der Client (Browser) benötigt diese nicht mehr, da Varnish für das
Caching verantwortlich ist. Zudem wollen wir die Umb-PageId auch nicht herausgeben.

Hier gibt es eine detaillierte Beschreibung der BANs.

Umbraco API- und Surface-Controller

Diese sollten natürlich auch über den Varnish geschleust werden. Durch das neu hinzugefügte resp.http.Umb-PageId werden diese jedoch möglicherweise noch ignoriert.

Ich habe das wie folgt gelöst (Eventuell hat hier jemand eine schönere Lösung?).

public class TestApiController : UmbracoApiController {

      [VarnishCacheOutput(ClientTimeSpan = 3600)]
      public string Get() {
      
      }
}

Wenn die Action ausgeführt wird, werden dem Response weitere HTTP Header hinzugefügt.

public override void OnActionExecuting(HttpActionContext actionContext)
{
      // This is for Varnish resp.http.Varnish-Cache-Output
      response.Headers.Add("Varnish-Cache-Output", "true"); //TODO: Is there a better name?
      response.Headers.CacheControl = new CacheControlHeaderValue
      {
            MaxAge = new TimeSpan(0, 0, ClientTimeSpan),
            MustRevalidate = false, // Could be defined through property
            Private = false // Could be defined through property
      };
}

Im vcl-File müssen noch folgende Zeilen angepasst werden:

sub vcl_fetch {

      
      # Cache static Pages
      if (beresp.http.Umb-PageId || beresp.http.Varnish-Cache-Output) {
            unset beresp.http.Set-Cookie;
            return (deliver);
      }

      # do not cache everything else
      return (hit_for_pass);
}

sub vcl_deliver {
      # Expires Header set by Umbraco are used to define Varnish caching only
      # therefore do not send them to the Client
      if (resp.http.Umb-PageId || resp.http.Varnish-Cache-Output) {
            unset resp.http.expires;
            unset resp.http.pragma;
            unset resp.http.cache-control;
            unset resp.http.Etag;
      }

      # smart Ban related
      unset resp.http.Umb-PageId;
      unset resp.http.Varnish-Cache-Output;

      return (deliver);
}

Mit diesem Attribut kann man nun bestimmen, welcher Controller wie lange im Cache bleibt.

Bei Mehrsprachigkeit ist zu beachten, dass die URLs verschieden sind. Allenfalls muss man einen Parameter à la &language=de und &language=en hinzufügen (kann natürlich auch anders heissen).

Backend Healthy

Oft musste ich leider feststellen, dass dies nicht konfiguriert wird.

Unter anderem wird dadurch erreicht, dass wenn die Seite nicht mehr erreichbar ist (z.B. MSSQL Server ist abgestürzt), Varnish die Seiten trotzdem noch ausliefert (sofern diese Seiten vor dem Absturz und vor der Invalidierung noch im Cache sind).

Dies Backend Healthy Checks sollten wenn möglich "einfache" Calls sein. Wenn die URL nicht aufrufbar ist, weiss Varnish, dass der Server nicht erreichbar ist.

Achtung: Diese Calls müssen einen Statuscode 200 zurückgeben. Zum Beispiel Weiterleitungen (301, 302) dürfen nicht als Calls angegeben werden.

[PluginController("Varnish")]
public sealed class HealthApiController : UmbracoApiController
{
      public string ApplicationIsRunning()
      {
            // Just a simple return, because if Varnish do not get anything, it know the Server is not healthy.
            return "Application is running and healthy.";
      }

      public bool DatabaseCanConnect()
      {
            // If we cannot connect, Varnish should hold the cache longer until the database is healthy.
            if (base.DatabaseContext == null) return false;
            return base.DatabaseContext.CanConnect;
      }
}

Diese müssen nur noch im vcl-File hinzugefügt werden:

backend default {
      .host = "127.0.0.1";
      .probe = {
            .url = "/Umbraco/Varnish/HealthApi/ApplicationIsRunning ";
            .interval = 1s;
            .timeout = 50ms;
            .window = 5;
            .threshold = 3;
      }
      .probe = {
            .url = "/Umbraco/Varnish/HealthApi/DatabaseCanConnect";
            .interval = 60s;
            .timeout = 1s;
            .window = 5;
            .threshold = 3;
      }
}

ApplicationIsRunning() wird alle Sekunden aufgerufen. Der Aufruf darf nicht länger als 50ms dauern.
Es wird fünf Mal überprüft - mindestens drei Aufrufe davon müssen erfolgreich sein.

Hier noch eine detaillierte Beschreibung der Health Checks.

Auch die kleinsten Dateien spielen eine Rolle

Grundsätzlich könnte konfiguriert werden, dass alles im /images/-Ordner (oder /img/) für ein Jahr im Cache bleibt, denn diese werden sich normalerweise nicht mehr ändern. Das selbige gilt für /fonts/ (oder /font/ – ausser man verwendet z.B. Google Fonts).

<location path="Images">
      <system.webServer>
            <staticContent>
                  <clientCache cacheControlCustom="public" cacheControlMode="UseMaxAge"
                                       cacheControlMaxAge="24:00:00" />
            </staticContent>
      </system.webServer>
</location>
<location path="Fonts">
      <system.webServer>
            <staticContent>
                  <clientCache cacheControlCustom="public" cacheControlMode="UseMaxAge"
                                       cacheControlMaxAge="24:00:00" />
            </staticContent>
      </system.webServer>
</location>

Somit verringern sich die Requests auf den IIS drastisch (auch wenn es nur Static Files sind).

Wenn man von ASP.NET das Bundle & Minification braucht, kann es durchaus sein, dass die CSS- und JavaScript-Files keine Endung mehr haben:

bundles.Add(new ScriptBundle("~/bundles/jquery").Include("~/Scripts/jquery-{version}.js"));

Im HTML wäre jQuery wie folgt eingebunden: <script src=“/bundles/jquery“></script>

Sehr wahrscheinlich wird das dann nicht optimal vom Varnish gehandhabt. Daher würde ich folgendes noch im vcl-File hinzufügen:

sub vcl_fetch {
      

      # Cache static files
      if (req.url ~
          "^[^?]*\.(css|js|htc|txt|swf|flv|pdf|gif|jpe?g|png|ico|woff|ttf|eot|otf|xml|md5|json)($|\?)") {
            return (deliver);
      }

      # Catch static files generated from System.Web.Optimization (because these could have no extension)
      if (req.url ~ "(?i)^/scripts/" || req.url ~ "(?i)^/bundles/" || req.url ~ "(?i)^/css/") {
            return (deliver);
      }

      
}

(?i) bedeutet case insensitive (der String kann also klein oder gross geschrieben werden)

Nützlich ist auch folgender Befehl, mit diesem erhält man alle URLs welche nicht vom Varnish im Cache gefunden wurden:

varnishlog -m "VCL_call:miss" | grep "RxURL"

(Natürlich ist dies beim ersten Aufruf auch der Fall). Diese Liste sollte so wenig wie möglich beinhalten.

Epilog

Ich persönlich finde es besser, wenn Varnish so wenig Verantwortung übernimmt wie möglich – dieser sollte nur den Cache ausliefern (manchmal sah ich, dass sogar 404 und 500er Errors über Varnish gelöst werden). Denn das vcl-File kann sehr schnell kompliziert und unübersichtlich werden – vor allem durch falsche regex Abfragen könnten einige Seiten nicht in den Cache überführt werden.

Zudem finde ich, dass jede URL über den Varnish laufen soll. Als Beispiel hatten wir eine Applikation welche zwar schnell angezeigt wurde, einzelne Module welche mit einer API gelöst wurden aber zum Teil sehr langsam waren. Da diese APIs (UmbracoApiController) nicht über den Varnish liefen, verursachten diese eine hohe CPU Auslastung auf dem Windows Server. Sobald diese APIs und viele andere kleine Files (meistens JSON, JavaScript und CSS) über den Varnish liefen, war die CPU vom Windows Server beinahe gelangweilt.

Nützliche Links


kommentieren


0 Kommentar(e):