Gebacken: Django und statische Websites

Eigentlich hatte ich mit dem Thema Gebackenes Weblog schon vor einiger Zeit abgeschlossen, zumal das QoS-Log meines Servers seit der letzten Optimierungsrunde meist leer bleibt und meine Website auch sonst weitgehend bibelkonform operiert.

Und doch – mein wohlgefälliger Blick auf das lautlos werkelnde Ensemble von Apache, mod_wsgi, PostgreSQL, psycopg2, Python/Django und nginx weicht manchmal einem unbehaglichen Zweifel: Werde ich mir auch in der nächsten Finanzkrise noch einen virtuellen Server leisten können? Was tun, wenn ich auf eine einfache Apache-Instanz zurückgeworfen werde? Zu diesen etwas irrationalen Ängsten kommen noch deutlich irrationalere Sorgen um die Übergabe der Website an meine Enkel in knapp 100 Jahren, die zudem auf der Voraussetzung basieren, dass die jungen Leute für Opas digitales Vermächtnis mehr Interesse zeigen als ich für die umfangreiche Münzsammlung meines Großvaters. Zur Not beglücke ich das Stadtarchiv, das sich ja immer sehr über umfangreiche Sammlungen engagierter Privatpersonen freut.

Was spricht gegen eine statische Website? Sie müsste auf eine interne Suchfunktion verzichten, andererseits wird die Website in der Regel über Google gefunden. Die Einbettung eines Google-Suchfeldes würde nur eine faktische Abhängigkeit förmlich anerkennen. Eine Reihe kleiner Skripte würde nicht mehr funktionieren. Das Kontaktformular könnte keine elaborierten Funktionen mehr nutzen und würde auf ein schlichtes HTML-Formular reduziert.

Diese kleinlichen Einwände haben mich allerdings schon vor vielen Jahren nicht abgehalten, und mit der Zeit bin ich tendenziell noch beratungsresistenter geworden. Und ich wäre nicht ich selbst, wenn ich meinen Wunsch nach Portabilität (und maximaler Performance) mit vorgefertigten Lösungen befriedigen würde. Ganz abgesehen davon scheint es solche Lösungen für Django-Websites nicht zu geben.

Der erste Entwurf eines Backrezepts funktioniert erstaunlich gut:

for page in Page.objects.all(): rendered_page = render_page(page) filename = reverse('alpha', kwargs = { 'page_url' : page.url_name }) write_file(filename, rendered_page)

Die Funktion render_page() ist im Wesentlichen ein Wrapper um render_to_string(), während write_file() möglichst effizient das Zielverzeichnis mit HTML-Dateien füllt (bei der Optimierung stolpere ich ein wenig über die unterschiedliche Kodierung von Zeilenumbrüchen durch Python bzw. PostgreSQL):

def write_file(filename, content, extension='html'): filename = '{0}{1}.{2}'.format(BASEDIR, filename, extension) gzip_filename = filename+'.gz' path = os.path.dirname(filename) content = content.replace('\r\n', '\n') changed = False if os.path.exists(filename): existing_file = io.open(filename, encoding='utf-8') existing_content = existing_file.read() existing_file.close() if existing_content != content: changed = True else: changed = True if changed: print "Page was added/changed, writing to {0}".format(filename) if not os.path.exists(path): os.makedirs(path) encoded_content = content.encode('utf-8') html_file = open(filename, 'w') html_file.write(encoded_content) html_file.close() gzip_file = gzip.open(gzip_filename, 'wb') gzip_file.write(encoded_content) gzip_file.close()

Die Abbildung des überaus komplexen Labyrinths verschiedener Views kostet zwar noch etwas Zeit, aber nach wenigen Stunden ruht die gesamte Website schockgefroren und aufgebläht (von 33 auf 75 MB) im Dateisystem und lässt sich mit einer minimalen nginx-Konfiguration ausliefern:

http { gzip_static on; server { listen 80; server_name site.static; root /path/to/dir; index blog.html; error_page 404 /404.html; error_page 403 /403.html; error_page 401 /401.html; error_page 500 /500.html; try_files $uri.html $uri.xml $uri/ =404; location /private { auth_basic 'Restricted'; auth_basic_user_file /path/to/pwdfile; try_files $uri.html =404; } } }

Die beiden simplen Kommandos gzip_static und try_files bewirken die Auslieferung der durch write_file() generierten .gz-Dateien (statt der unkomprimierten HTML-Dateien) und legen vor allem die Logik fest, nach der die passende Ressource für eine bestimmte URL ausgewählt wird: blog.html hat demnach für die URL /blog Vorrang vor dem Verzeichnis blog/.

Bei diesem erfreulichen Ergebnis könnte ich es bewenden lassen, wäre da nicht eine leise warnende Stimme – was ist, wenn nginx trotz der erfolgreichen ersten Finanzierungsrunde in wenigen Jahren eingestellt wird? Sollte ich nicht eine Alternative haben?

Die naheliegende Alternative ist natürlich der Apache-Server, dessen Komplexität mir angesichts der Eleganz von nginx wieder einmal schmerzlich bewusst wird. Zunächst hat es den Anschein, als könne die Option MultiViews alle Anforderungen erfüllen: Wird eine bestimmte URL aufgerufen, sucht Apache nach der am besten geeigneten Ressource und berücksichtigt sowohl blog.html als auch blog.html.gz, falls der entsprechende Header Accept-Encoding: gzip vom Client geschickt wurde. Leider funktioniert diese Inhaltsverhandlung nur dann, wenn es keine Datei/kein Verzeichnis gibt, das der aufgerufenen URL exakt entspricht:

The effect of MultiViews is as follows: if the server receives a request for /some/dir/foo, if /some/dir has MultiViews enabled, and /some/dir/foo does not exist, then the server reads the directory looking for files named foo.*

Anders ausgedrückt: Das Verzeichnis blog/ wird grundsätzlich für die URL /blog ausgeliefert. Man könnte nun auf die Logik des Apache Rücksicht nehmen und das Backrezept so anpassen, dass es in jedem Verzeichnis Indexdateien anlegt, deren Inhalt dem einer gleichnamigen HTML-Datei im jeweils übergeordneten Verzeichnis entspricht. Man könnte aber auch versuchen, dem störrischen Server die Funktionsweise von try_files beizubringen. Das erfordert den beherzten Einsatz einer RewriteRule:

RewriteRule ^/([^\.]+)$ /$1.html [L]

Da aber diese Regel explizit auf existierende Dateien verweist, kommt MultiViews wieder nicht zum Zuge, und komprimierte Dateien werden nicht ausgeliefert. Um das zu beheben, muss die obige Regel erweitert werden (die RewriteCond bezieht sich nur auf die unmittelbar folgende RewriteRule):

RewriteCond %{HTTP:Accept-Encoding} gzip RewriteRule ^/([^\.]+)$ /$1.html.gz [L] RewriteRule ^/([^\.]+)$ /$1.html [L]

Nun ergibt sich ein reichlich mysteriöses Problem: Aus zunächst unerfindlichen Gründen liefern sämtliche URLs der Form /private/... den Status 404. Es dauert eine Weile, bis ich die relevante Stelle der Dokumentation gefunden habe:

Note that mod_rewrite tries to guess whether you have specified a file-system path or a URL-path by checking to see if the first segment of the path exists at the root of the file-system. For example, if you specify a Substitution string of /www/file.html, then this will be treated as a URL-path unless a directory named www exists at the root or your file-system, in which case it will be treated as a file-system path. If you wish other URL-mapping directives (such as Alias) to be applied to the resulting URL-path, use the [PT] flag as described below.

Mit anderen Worten:

RewriteCond %{HTTP:Accept-Encoding} gzip RewriteRule ^/([^\.]+)$ /$1.html.gz [L,PT] RewriteRule ^/([^\.]+)$ /$1.html [L,PT]

Dasselbe Prinzip muss natürlich auch für TeX-, CSS- und XML-Dateien angewandt werden, so dass die vollständige Konfiguration etwas umfangreicher ist:

<VirtualHost 127.0.0.1:8080> ServerName server.static DocumentRoot /path/to/dir DirectoryIndex blog.html ErrorDocument 404 /404 ErrorDocument 403 /403 ErrorDocument 401 /401 ErrorDocument 500 /500 AddEncoding x-gzip .gz RewriteEngine On RewriteCond %{HTTP:Accept-Encoding} gzip RewriteRule ^/(atom/[^\.]+)$ /$1.xml.gz [L,PT] RewriteRule ^/(atom/[^\.]+)$ /$1.xml [L,PT] RewriteCond %{HTTP:Accept-Encoding} gzip RewriteRule ^/([^\.]+)$ /$1.html.gz [L,PT] RewriteRule ^/([^\.]+)$ /$1.html [L,PT] RewriteCond %{HTTP:Accept-Encoding} gzip RewriteRule ^/(.+\.(css|tex))$ /$1.gz [L,PT] <Directory /path/to/dir/private> AuthType Basic AuthName "Restricted" AuthUserFile /path/to/pwdfile Require user myuser </Directory> </VirtualHost>

Das war ein wenig Arbeit, gekrönt vom beruhigenden Gefühl, meine Website fast überall mit minimalem Aufwand publizieren und archivieren zu können.