İçeriğe geç
Muhammet Şafak
en
Güncelleme 1 dk okuma

Tek VPS Üzerinde Çoklu Proje Mimarisi

Bir VPS, birden çok bağımsız uygulama. Kubernetes yerine bilinçli minimalizm: kullanıcı izolasyonu, ayrı PHP-FPM pool, paylaşılan PostgreSQL/Redis.

Kısaca

3–5 bağımsız Laravel uygulaması, 40 doların altındaki tek bir VPS'e sığıyor: her uygulama kendi unix kullanıcısı, PHP-FPM pool'u ve veritabanı rolüyle çalışıyor, PostgreSQL ve Redis paylaşılıyor. Toplam yük tek makineye sığdığı sürece Kubernetes'in operasyon yükü değmiyor; CPU'nun sürekli %70'in üzerine çıkması gibi sinyaller mimariyi parçalama zamanını haber veriyor.

Tek VPS mimarisinin katmanları: 80/443 trafiği Nginx’e girer, Nginx unix soket üzerinden proje başına ayrı PHP-FPM pool’larına dağıtır (billing, notify, blog — her biri kendi unix kullanıcısıyla), üç pool da localhost üzerinden paylaşılan servislere bağlanır: pgBouncer arkasında PostgreSQL (proje başına ayrı database ve role), prefix ile izole edilmiş Redis ve proje başına queue worker çalıştıran Supervisor.
Bir makine, üç bağımsız uygulama: izolasyon süreç ve kullanıcı seviyesinde, paylaşım veri katmanında.

Küçük ve orta SaaS projeler için tek bir VPS, birden fazla uygulamayı barındırmak için fazlasıyla yeterli. Kubernetes, mesh, “cloud native” baskısı pek çok ekibi bu yoldan saptırıyor — ama eğer toplam yük 1 makineye sığıyorsa, dağıtım maliyeti neredeyse her zaman değmez.

Bu yazıda kullandığım örüntüyü adım adım göstereceğim.

Hedef

  • 3–5 bağımsız Laravel uygulaması.
  • Her uygulama kendi domain’i, kendi süreci, kendi kullanıcısı.
  • PostgreSQL ve Redis paylaşılıyor: PostgreSQL’de role bazında izole, Redis’te prefix’le ayrılmış.
  • Deploy bir komutla, zero-downtime.
  • Backup ve monitoring tek yerden.
  • Tüm bunlar < $40/aylık bir VPS’te.

Kullanıcı ve dosya yerleşimi

Her uygulama kendi unix kullanıcısını alıyor:

billing:billing  /var/www/billing
notify:notify    /var/www/notify
blog:blog        /var/www/blog

www-data (Nginx) gruba dahil ediliyor, kod dizinine okuma erişimi için. Yazma sadece kendi uygulamasının kullanıcısında. Bu izolasyon hatalı davranan bir uygulama diğerlerine zarar veremesin diye değil; bir geliştirici hatasının diğer projeyi etkilemesini önlemek için — ki bu çok daha sık olan senaryo.

PHP-FPM pool’ları

Nginx + PHP-FPM Pool Ayrımı yazısında detaylı. Burada özet: her uygulama kendi pool.d/*.conf dosyasında pm.max_children, memory_limit ve unix kullanıcısıyla tanımlanıyor.

Veritabanı: paylaşılan PostgreSQL

Tek bir PostgreSQL cluster, her uygulama için ayrı bir database ve role:

CREATE ROLE billing_app LOGIN PASSWORD '...';
CREATE DATABASE billing OWNER billing_app;
REVOKE ALL ON DATABASE billing FROM PUBLIC;

pg_hba.conf:

host  billing  billing_app  127.0.0.1/32  scram-sha-256
host  notify   notify_app   127.0.0.1/32  scram-sha-256
host  blog     blog_app     127.0.0.1/32  scram-sha-256

Hiçbir uygulama diğerinin DB’sini göremez bile.

pgBouncer’la bağlantı havuzu

PHP-FPM bağlantıyı varsayılan olarak per-request açıp kapatıyor — kalıcı bağlantıyla (PDO::ATTR_PERSISTENT) bile bağlantı tek bir worker’a bağlı kaldığı için paylaşılan bir havuz yok. PostgreSQL bağlantı kurulumu (~10ms) kümülatifte ciddi. pgBouncer önünde pool_mode = transaction ile bunu çözüyoruz. auth_query yazısı detayı veriyor.

Redis: paylaşılan, namespace’le izole

Tek bir Redis instance. Her Laravel uygulaması farklı bir prefix:

'redis' => [
    'cache' => [
        'host' => '127.0.0.1',
        'database' => 0,
        'prefix' => 'prod:billing:cache:',
    ],
    'queue' => [
        'host' => '127.0.0.1',
        'database' => 0,
        'prefix' => 'prod:billing:queue:',
    ],
],

Prefix bir adlandırma sözleşmesi, bir sınır değil: üç uygulama da aynı instance’a, aynı database 0 üzerine, requirepass ve ACL olmadan bağlanıyor — yani biri diğerinin anahtarlarını okuyabilir. Bunun zorlanması gerekiyorsa her uygulamaya kendi Redis ACL kullanıcısını verin.

Detay namespace izolasyonu yazısında.

Queue worker’ları

Supervisor her uygulamanın queue worker’ını ayrı bir process grubu olarak yönetiyor:

/etc/supervisor/conf.d/
├── billing-worker.conf
├── notify-worker.conf
└── blog-worker.conf

Her dosyada uygulama kendi kullanıcısı ile çalışıyor:

[program:billing-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/billing/current/artisan queue:work rabbitmq --queue=default --sleep=1 --max-time=3600
user=billing
numprocs=4
autostart=true
autorestart=true
stopwaitsecs=3600

Her uygulama dizininde:

/var/www/billing/
├── releases/
│   ├── 2026-05-12-1430/
│   ├── 2026-05-13-0900/
│   └── 2026-05-13-1100/    ← yeni release
├── shared/
│   ├── .env
│   ├── storage/
│   └── uploads/
└── current → releases/2026-05-13-1100

Deploy adımları:

  1. Yeni release klasörünü oluştur, git checkout, composer install, build.
  2. shared/ içeriklerini sembolik linkle.
  3. Migrate (forward-compatible olmalı).
  4. current symlink’i atomik olarak yeni klasöre çevir (ln -sfn).
  5. PHP-FPM’e kill -USR2 ile graceful reload gönder (opcache invalidation).
  6. Supervisor’ı sadece queue worker’ları için yeniden başlat.

Beşinci adımda bir uyarı var: USR2 FPM master’ına gidiyor ve bütün pool’ları graceful biçimde yeniden yüklüyor — tek bir pool’u yeniden başlatan bir sinyal yok. Yani bir projenin deploy’u bütün projelerin worker’larını geri döndürüyor ve ortak opcache’i boşaltıyor; yukarıdaki izolasyon çalışma zamanında geçerli, deploy yolunda değil.

Eski release’leri saklamak (örn. son 5) rollback’i bir komuta indirgiyor: symlink’i eski klasöre çevir, PHP-FPM reload.

Backup

  • PostgreSQL: pgBackRest, repository S3-uyumlu kovaya yazıyor.
  • Redis: AOF kapalı, RDB her saat. Cache-only kullanım için yeterli.
  • Uploads (shared/uploads): rclone ile saatlik S3 sync.
  • /etc: günlük tar + S3 (config’in versiyon kontrolü).

Monitoring

Self-hosted bir Prometheus + Grafana abartı olur bu ölçek için. Tercih ettiğim:

  • uptime-kuma — her domain’i HTTP check’le izliyor, Slack/Telegram alarmı.
  • node_exporter + Cloudflare metrics — sunucu sağlığı.
  • PHP-FPM /status endpoint — slow request sayısını izle.
  • logrotate — /var/log/{nginx,php-fpm,laravel}/*.log günlük.

Limitler ve büyüme sinyalleri

Bu mimari aşağıdaki sinyallerden biri görülünce kırılır:

  • CPU sürekli > %70. Vertical scale (daha büyük VPS) bir kez yapılır, sonra uygulama ayrıştırması.
  • Tek bir uygulama DB’de baskın hale gelirse. O uygulama kendi PostgreSQL’ine taşınır — replica veya managed servis.
  • Coğrafi gecikme şikayetleri. Bu durumda CDN’e statik asset taşıma yeterli; app server’ı taşımak ekstrem.

Niye Kubernetes değil?

Üç sebep:

  1. Operasyon yükü. Tek başınıza bir k8s cluster’ı çalıştırmak, üzerinde uygulama yazmaktan daha fazla zaman alır. Birkaç kümeye büyüdüğünüzde statik yetkiden kurtulmak da ayrı bir iş olur.
  2. Maliyet. Control plane’i ayrıca ücretlendiren sağlayıcılarda — EKS cluster başına saatlik $0.10, yani tek node eklenmeden ~$73/ay — 3 küçük node’lu managed k8s, eşdeğer VPS’in 3–5 katı tutuyor. Ücretsiz verenlerde fark kapanıyor: DigitalOcean’da 3 × $12/ay node ücreti VPS parasına yakın. GPU iş yüküyle Kubernetes’e geçerseniz GPU maliyetini ekip ve pod bazında atfetmenin bir yolunu da bulmanız gerekir.
  3. Karmaşıklık ödülü yok. Bu uygulamalar bağımsız ölçeklenmek zorunda değil. Konteyner kullanmak da bir orchestrator gerektirmez: tek sunucuda docker compose up çoğu küçük-orta proje için yeterlidir.

Bu mimari “lazy-eval k8s” değil — başka bir paradigma. Sade, anlaşılır, operasyonel olarak küçük bir mimari. Lazım olduğunda parçalanır, ondan önce değil.

Adımlar

Symlink swap ile sıfır kesintili deploy

Bir release’i, çalışan istekleri kesmeden ve opcache’i bayat bırakmadan canlıya almak.

  1. Yeni release

    Yeni release klasörünü oluştur: git checkout, composer install, build.

  2. Paylaşılanları bağla

    shared/ içeriklerini sembolik linkle.

  3. Migrate

    Migration’ı çalıştır — forward-compatible olmak zorunda.

  4. Symlink’i çevir

    current symlink’ini ln -sfn ile atomik olarak yeni klasöre çevir.

  5. PHP-FPM reload

    PHP-FPM’e kill -USR2 ile graceful reload gönder; opcache böylece geçersizleşir.

  6. Worker restart

    Supervisor’ı yalnızca queue worker’ları için yeniden başlat.

Sık sorulanlar

3 soru

  • Bir projenin deploy edilmesi aynı VPS üzerindeki diğerlerini etkiler mi?

    Deploy yolunda evet. USR2 sinyali PHP-FPM master'ına gidiyor ve bütün pool'ları graceful biçimde yeniden yüklüyor; tek bir pool'u yeniden başlatan bir sinyal yok. Yani bir projenin deploy'u bütün projelerin worker'larını geri döndürüyor ve ortak opcache'i boşaltıyor. İzolasyon çalışma zamanına ait bir özellik, deploy anına değil.

  • Redis prefix uygulamalar arasında gerçek bir izolasyon mu?

    Hayır. Prefix bir adlandırma sözleşmesi. Üç uygulama da aynı instance'a, aynı database 0 üzerine, requirepass ve ACL olmadan bağlanıyor; yani biri diğerinin anahtarlarını okuyabilir. Bu sınırın zorlanması gerekiyorsa her uygulamaya kendi Redis ACL kullanıcısını verin.

  • Tek VPS ne zaman doğru cevap olmaktan çıkar?

    Üç sinyalden biri göründüğünde: CPU sürekli %70 üzerindeyse, tek bir uygulama veritabanında baskın hâle geldiyse ya da coğrafi gecikme şikayetleri geliyorsa. İlk ikisi uygulamaları ayrıştırmak demek; üçüncüsü genelde yalnızca statik asset'leri CDN'e taşımak demek.

Kaynaklar

İlgili

Yorumlar

Yorum yapmak için GitHub hesabınızla giriş yapmanız yeterli. Yorumlar GitHub Discussions üzerinde saklanır.

Sitede Ara

Yazı, proje ve sayfalarda arama yapmak için yazmaya başlayın.

Esc ile kapat Pagefind ile güçlendirildi