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:
paperclipstattactive_storage,will_paginatestattpagy - 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:
- Authentifizierung (Session-Management, OAuth)
- Admin-Bereich (geringes Risiko, hohe interne Sichtbarkeit)
- API-Endpoints (klar definierte Schnittstellen)
- 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
| Upgrade | Häufigste Probleme |
|---|---|
| 4.2 → 5.0 | ApplicationRecord Basis-Klasse, belongs_to ist jetzt required: true by default |
| 5.0 → 5.1 | Encrypted secrets, form_with statt form_for |
| 5.2 → 6.0 | Autoloading wechselt zu Zeitwerk, update_attributes entfernt |
| 6.0 → 6.1 | rails db:prepare statt db:create db:migrate, Strict Loading |
| 6.1 → 7.0 | Ruby 2.7+ Pflicht, Keyword-Argument-Änderungen, config.load_defaults 7.0 |
| 7.0 → 7.1 | Normalised SECRET_KEY_BASE, Dockerfile generiert, async Queries |
Werkzeuge für den Upgrade-Prozess
rails app:update: Generiert Diff der Config-Dateiennext_railsGem: Erlaubt paralleles Testen gegen zwei Rails-Versionendeprecation_toolkit: Fängt Deprecation-Warnings strukturiert abbundler-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-Gem | Ersatz | Grund |
|---|---|---|
paperclip | active_storage | Paperclip offiziell deprecated seit 2018 |
will_paginate | pagy | 40x schneller, weniger Memory |
cancancan | action_policy | Besser testbar, explizite Policies |
delayed_job | solid_queue (Rails 8) / sidekiq | Active Job Adapter, besseres Monitoring |
carrierwave | active_storage | Native Rails-Integration |
coffee-rails | Entfernen, ES6+ nutzen | CoffeeScript ist tot |
sass-rails | dartsass-rails oder cssbundling-rails | LibSass ist deprecated |
webpacker | jsbundling-rails + propshaft | Webpacker wurde eingestellt |
devise | authentication-zero oder devise (aktuell halten) | Nur ersetzen wenn Devise zu komplex |
rest-client | faraday oder httpx | Bessere Middleware-Architektur |
Vorgehen beim Gem-Austausch
- Einen Gem pro PR: Nie mehrere gleichzeitig tauschen
- Feature-Parity sicherstellen: Altes Verhalten mit Tests fixieren, dann Gem tauschen
- Daten-Migration einplanen:
paperclip→active_storagebraucht eine Daten-Migration für alle Attachments - 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_migrationsGem: Blockiert gefährliche Migrationen automatischonline_migrationsGem: Generiert sichere Migrations-Patternspg_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:
- Batch-Processing mit
find_in_batches(batch_size: 5000) - Background-Jobs für Daten-Transformationen
- Shadow Tables: Neue Tabelle parallel befüllen, dann per
RENAME TABLEumschalten - Read/Write Splitting: Reads auf neues Schema, Writes auf beide
Unsere Fight Against Super Bad Patterns in Legacy Rails Apps - RedDotRubyConf 2016

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-corsGem, 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össe | Models | Dauer | Team |
|---|---|---|---|
| Klein | < 30 | 2-3 Monate | 2 Entwickler |
| Mittel | 30-100 | 4-6 Monate | 3-4 Entwickler |
| Gross | 100+ | 8-12 Monate | 4-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
- Testabdeckung nachrüsten (30% der Gesamtzeit): Legacy-Apps haben selten mehr als 20% Coverage
- Gem-Kompatibilität (20%): Alternativen finden, Daten migrieren, Adapter schreiben
- Datenbank-Bereinigung (20%): Verwaiste Records, fehlende Constraints, Schema-Normalisierung
- Unerwartete Abhängigkeiten (15%): Monkey-Patches, Meta-Programming, undokumentierte API-Contracts
- Eigentliches Rails-Upgrade (15%): Der technisch einfachste Teil
Checkliste vor dem Start
- Ruby- und Rails-Version dokumentiert
- Alle Gems mit
bundle outdatedgeprüft -
bundler-auditzeigt 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