Warum Legacy-Rails-Apps zum Risiko werden

Eine Rails-4-App, die 2015 deployed wurde, läuft heute auf Ruby 2.3 ohne Security-Patches. Die Gem-Dependencies haben keine Maintainer mehr. Jedes Deployment ist ein kalkuliertes Risiko.

Das ist kein Hypothese. Bei einem HR-Portal-Projekt haben wir die Migration von Rails 4.2 auf Rails 7 in 4 Monaten durchgeführt. Die App hatte 120 Models, 45 Gems ohne aktiven Support und eine PostgreSQL-Datenbank mit 8 Jahren gewachsenen Altlasten. Der Auslöser: Eine kritische Devise-Schwachstelle, für die es keinen Patch mehr gab.

Die typischen Symptome, die wir bei Legacy-Rails-Projekten sehen:

  • Ruby < 2.7: Kein Support, keine Security-Fixes
  • Rails < 6.0: Fehlende Zeitgemässe Features (Action Mailbox, Action Text, multiple databases)
  • Asset Pipeline statt Webpacker/Propshaft: Build-Zeiten von 10+ Minuten
  • Gems ohne Maintainer: paperclip statt active_storage, will_paginate statt pagy
  • Keine CI/CD: Manuelles Deployment per Capistrano auf Bare-Metal-Servern

Das Strangler Fig Pattern in der Praxis

Ein Big-Bang-Rewrite scheitert in 70% der Fälle. Das Strangler Fig Pattern funktioniert anders: Die neue Anwendung wächst um die alte herum, bis sie vollständig ersetzt ist.

So setzen wir das konkret um

Phase 1: Reverse Proxy vor die Legacy-App

Nginx oder Traefik leitet Requests selektiv an die neue Rails-7-App oder die alte App weiter. Initial geht 100% des Traffics an die Legacy-App.

# Neue Rails-7-App: Health-Check-Endpoint als erster Schritt
# config/routes.rb
get "/health", to: proc { [200, {}, ["ok"]] }

Phase 2: Modul für Modul migrieren

Wir identifizieren abgegrenzte Bounded Contexts. Typische Reihenfolge:

  1. Authentifizierung (Session-Management, OAuth)
  2. Admin-Bereich (geringes Risiko, hohe interne Sichtbarkeit)
  3. API-Endpoints (klar definierte Schnittstellen)
  4. Kerngeschäftslogik (zuletzt, mit höchster Testabdeckung)

Phase 3: Traffic-Shift

Nach jedem migrierten Modul wird der Reverse Proxy umkonfiguriert. Die alte App verliert schrittweise Routen, bis sie abgeschaltet werden kann.

USEO’s Erfahrung mit dem Strangler Fig Pattern

Bei einem Schweizer Logistik-Portal haben wir diese Strategie über 6 Monate angewandt. Die alte Rails-4-App lief während der gesamten Migration weiter. Kein einziger Tag Downtime. Die kritischste Phase war die Umstellung der Authentifizierung, weil beide Apps temporär Session-Daten teilen mussten. Lösung: Redis als gemeinsamer Session-Store mit einem Shared-Secret.

Rails-Versionen schrittweise upgraden

Niemals mehr als eine Major-Version auf einmal springen. Der Upgrade-Pfad für eine Rails-4.2-App:

Rails 4.2 → 5.0 → 5.1 → 5.2 → 6.0 → 6.1 → 7.0 → 7.1 → 7.2 → 8.0

Was bei jedem Versionssprung typischerweise bricht

UpgradeHäufigste Probleme
4.2 → 5.0ApplicationRecord Basis-Klasse, belongs_to ist jetzt required: true by default
5.0 → 5.1Encrypted secrets, form_with statt form_for
5.2 → 6.0Autoloading wechselt zu Zeitwerk, update_attributes entfernt
6.0 → 6.1rails db:prepare statt db:create db:migrate, Strict Loading
6.1 → 7.0Ruby 2.7+ Pflicht, Keyword-Argument-Änderungen, config.load_defaults 7.0
7.0 → 7.1Normalised SECRET_KEY_BASE, Dockerfile generiert, async Queries

Werkzeuge für den Upgrade-Prozess

  • rails app:update: Generiert Diff der Config-Dateien
  • next_rails Gem: Erlaubt paralleles Testen gegen zwei Rails-Versionen
  • deprecation_toolkit: Fängt Deprecation-Warnings strukturiert ab
  • bundler-audit: Prüft Gems auf bekannte Schwachstellen
  • RuboCop mit rubocop-rails: Findet veraltete Patterns automatisch

Ruby-Upgrade nicht vergessen

Rails 7.0 verlangt mindestens Ruby 2.7, Rails 7.1 mindestens Ruby 3.0. Die Keyword-Argument-Änderungen in Ruby 3.0 betreffen fast jede grössere Codebase:

# Ruby 2.x: Funktioniert
def create(name, options = {})
  # Hash wird implizit von Keyword-Argumenten getrennt
end

# Ruby 3.x: Muss explizit sein
def create(name, **options)
  # Keyword-Argumente sind jetzt strikt getrennt
end

Gem-Ersatz: Was muss raus, was kommt rein?

Legacy-Apps haben Gems, die seit Jahren keine Updates mehr bekommen. Hier die häufigsten Austausche:

Legacy-GemErsatzGrund
paperclipactive_storagePaperclip offiziell deprecated seit 2018
will_paginatepagy40x schneller, weniger Memory
cancancanaction_policyBesser testbar, explizite Policies
delayed_jobsolid_queue (Rails 8) / sidekiqActive Job Adapter, besseres Monitoring
carrierwaveactive_storageNative Rails-Integration
coffee-railsEntfernen, ES6+ nutzenCoffeeScript ist tot
sass-railsdartsass-rails oder cssbundling-railsLibSass ist deprecated
webpackerjsbundling-rails + propshaftWebpacker wurde eingestellt
deviseauthentication-zero oder devise (aktuell halten)Nur ersetzen wenn Devise zu komplex
rest-clientfaraday oder httpxBessere Middleware-Architektur

Vorgehen beim Gem-Austausch

  1. Einen Gem pro PR: Nie mehrere gleichzeitig tauschen
  2. Feature-Parity sicherstellen: Altes Verhalten mit Tests fixieren, dann Gem tauschen
  3. Daten-Migration einplanen: paperclipactive_storage braucht eine Daten-Migration für alle Attachments
  4. Adapter-Pattern nutzen: Eigenen Wrapper um externe Gems, um spätere Wechsel zu vereinfachen

Datenbank-Migration ohne Downtime

Schema-Änderungen in Produktion

Naive Schema-Änderungen locken Tabellen und verursachen Downtime. Tools dafür:

  • strong_migrations Gem: Blockiert gefährliche Migrationen automatisch
  • online_migrations Gem: Generiert sichere Migrations-Patterns
  • pg_repack: Reorganisiert PostgreSQL-Tabellen ohne Lock
# FALSCH: Lockt die gesamte Tabelle
add_column :users, :status, :string, default: "active"

# RICHTIG: Zwei Schritte, kein Lock
add_column :users, :status, :string
change_column_default :users, :status, "active"

Altlast-Daten bereinigen

Legacy-Datenbanken haben fast immer:

  • Verwaiste Records: Foreign Keys ohne referenzierten Datensatz
  • Duplizierte Daten: Gleiche Information in mehreren Tabellen
  • Inkonsistente Typen: string-Spalten mit Integer-Werten
  • Fehlende Constraints: Keine NOT NULL, keine Unique-Indices

Bereinigung vor der Migration spart massive Probleme. Wir erstellen dafür Rake-Tasks, die Reports generieren:

# lib/tasks/data_audit.rake
namespace :data do
  desc "Verwaiste Records finden"
  task orphans: :environment do
    Order.left_joins(:customer)
         .where(customers: { id: nil })
         .find_each do |order|
      Rails.logger.warn "Orphaned Order ##{order.id}"
    end
  end
end

Grosse Tabellen migrieren

Für Tabellen mit Millionen von Rows:

  1. Batch-Processing mit find_in_batches(batch_size: 5000)
  2. Background-Jobs für Daten-Transformationen
  3. Shadow Tables: Neue Tabelle parallel befüllen, dann per RENAME TABLE umschalten
  4. Read/Write Splitting: Reads auf neues Schema, Writes auf beide

Unsere Fight Against Super Bad Patterns in Legacy Rails Apps - RedDotRubyConf 2016

RedDotRubyConf

Authentifizierung modernisieren

Legacy-Apps nutzen oft veraltete Session-basierte Auth oder selbstgebaute Token-Systeme. Der Modernisierungsplan:

Token-basierte Authentifizierung

# JWT mit dem jwt Gem
payload = { user_id: user.id, exp: 24.hours.from_now.to_i }
token = JWT.encode(payload, Rails.application.secret_key_base, "HS256")

Für OAuth 2.0 Provider: doorkeeper Gem. Für MFA: devise-two-factor mit TOTP-Support.

API-Absicherung

  • rack-attack: Rate-Limiting pro IP und pro User
  • CORS: rack-cors Gem, nur erlaubte Domains
  • API-Versionierung: /api/v1/ Namespace von Anfang an
# config/initializers/rack_attack.rb
Rack::Attack.throttle("api/ip", limit: 100, period: 1.minute) do |req|
  req.ip if req.path.start_with?("/api/")
end

Monolith aufbrechen: Wann und wie?

Nicht jeder Monolith muss in Microservices zerlegt werden. Rails Engines sind oft die bessere Lösung:

# engines/billing/lib/billing/engine.rb
module Billing
  class Engine < ::Rails::Engine
    isolate_namespace Billing
  end
end

Entscheidungshilfe

  • Monolith behalten: Team < 10 Entwickler, eine Deployment-Pipeline reicht
  • Rails Engines: Klare Modul-Grenzen gewünscht, aber ein Repository
  • Microservices: Unabhängige Teams, unterschiedliche Scaling-Anforderungen, polyglotte Technologie

Realistische Zeitplanung

Basierend auf unseren Projekten bei USEO:

ProjektgrösseModelsDauerTeam
Klein< 302-3 Monate2 Entwickler
Mittel30-1004-6 Monate3-4 Entwickler
Gross100+8-12 Monate4-6 Entwickler

Diese Zahlen beinhalten Testabdeckung aufbauen, Gems ersetzen, Rails upgraden und Datenbank bereinigen. Nicht enthalten: Feature-Entwicklung parallel zur Migration.

Was die meiste Zeit frisst

  1. Testabdeckung nachrüsten (30% der Gesamtzeit): Legacy-Apps haben selten mehr als 20% Coverage
  2. Gem-Kompatibilität (20%): Alternativen finden, Daten migrieren, Adapter schreiben
  3. Datenbank-Bereinigung (20%): Verwaiste Records, fehlende Constraints, Schema-Normalisierung
  4. Unerwartete Abhängigkeiten (15%): Monkey-Patches, Meta-Programming, undokumentierte API-Contracts
  5. Eigentliches Rails-Upgrade (15%): Der technisch einfachste Teil

Checkliste vor dem Start

  • Ruby- und Rails-Version dokumentiert
  • Alle Gems mit bundle outdated geprüft
  • bundler-audit zeigt keine kritischen Schwachstellen
  • Test-Coverage gemessen (Ziel: mindestens 80% vor dem Upgrade)
  • Datenbank-Backup-Restore getestet (nicht nur Backup!)
  • Staging-Umgebung identisch zu Production
  • Deployment-Pipeline automatisiert
  • Rollback-Prozedur dokumentiert und getestet
  • Stakeholder über Timeline informiert

Verwandte Artikel