Coolify ile kendi sunucunuzda Flask uygulaması yayınlamak
Dockerfile, gunicorn, kalıcı disk, ters vekil başlıkları ve ortam değişkenleriyle küçük bir Flask uygulamasını kendi sunucunuzda canlıya almak.
Küçük bir Flask uygulamasını yayına almanın birçok yolu var: yönetilen platformlar, sanal sunucuya elle kurulum, konteyner servisleri. Biz ürünlerimizin çoğunu kendi sunucumuzda, açık kaynaklı bir kendi-barındırma paneli olan Coolify ile çalıştırıyoruz. Coolify, Git deposundan imaj üretir, konteyneri çalıştırır, alan adını ve TLS sertifikasını bağlar, ortam değişkenlerini ve kalıcı diskleri yönetir. Kısacası yönetilen bir platformun sunduğu rahatlığın önemli bir kısmını, sunucu ve veri sizde kalarak sağlar.
Bu yazıda, okuduğunuz sitenin kendisini nasıl yayınladığımızı adım adım anlatıyoruz. Odak Coolify'ın arayüzü değil; uygulamanın bir ters vekil arkasında doğru çalışması için kodda yapılması gerekenler. Bu ayrıntılar hangi platformu seçerseniz seçin geçerlidir ve atlandığında hatalar genellikle canlıya çıktıktan sonra, en sessiz biçimde ortaya çıkar.
Sade bir Dockerfile
Coolify, depodaki Dockerfile'ı okuyup imajı kendisi üretebilir. Flask uygulaması için uzun bir Dockerfile'a gerek yok. Bizimki şuna çok yakındır:
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PORT=8020
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY . .
# SQLite dosyası kalıcı diske bağlanır
VOLUME ["/app/instance"]
EXPOSE 8020
CMD ["sh", "-c", "gunicorn -w 2 -b 0.0.0.0:${PORT} 'app:create_app()'"]
Birkaç ayrıntı önemli. PYTHONUNBUFFERED=1, uygulamanın yazdığı günlük satırlarının tampona takılmadan hemen konteyner günlüğüne düşmesini sağlar; panelde günlük izlerken bu fark büyüktür. requirements.txt dosyasını kodun geri kalanından önce kopyalamak, Docker'ın katman önbelleğini korur: yalnız kod değiştiğinde paketler yeniden kurulmaz. Gunicorn'a 'app:create_app()' biçiminde bir uygulama fabrikası verilebilir; böylece uygulama bir modül düzeyi değişken yerine fabrika fonksiyonuyla kurulur ve testlerde kullanılan yapı ile canlıdaki yapı aynı kalır.
Port numarasını ortam değişkeninden almak da küçük ama kullanışlı bir alışkanlık. Platform başka bir port beklerse yalnız değişkeni değiştirirsiniz, imajı değil.
Ters vekil arkasında doğru IP ve şema
Coolify'da uygulamanız doğrudan internete açılmaz; önünde HTTPS'i sonlandıran ve isteği konteynere ileten bir ters vekil (proxy) durur. Bu durumda Flask'ın gördüğü istek, vekilden gelen düz HTTP isteğidir. Uzak adres vekilin adresi, şema ise http olur. Sonuçları sinsi olabilir: kullanıcı başına hız sınırı bütün ziyaretçileri tek kişi sayar, url_for(..., _external=True) http:// adresler üretir, güvenli çerez kararları yanlış verilir.
Çözüm, Werkzeug'un ProxyFix ara katmanıdır. Vekilin eklediği X-Forwarded-For, X-Forwarded-Proto ve X-Forwarded-Host başlıklarını okuyup isteği gerçek değerlerle günceller. Burada dikkat edilmesi gereken tek şey, kaç vekile güvendiğinizi doğru söylemektir. Değer fazla verilirse ziyaretçi bu başlıkları kendisi yazarak IP adresini taklit edebilir. Bu yüzden sayıyı koda gömmek yerine ortam değişkeninden alıyor ve varsayılanı sıfır tutuyoruz:
import os
from flask import Flask
def create_app() -> Flask:
app = Flask(__name__, instance_relative_config=True)
vekil = int(os.environ.get("PROXY_SAYISI", "0") or 0)
if vekil > 0:
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=vekil, x_proto=vekil, x_host=vekil)
return app
Yerelde değişken tanımlı olmadığı için ara katman devreye girmez; canlıda tek bir ters vekil olduğu için değeri 1 yapıyoruz. Önüne ayrıca bir CDN koyarsanız zincirdeki vekil sayısı değişir ve bu değeri buna göre güncellemeniz gerekir.
Tek adres: www'den köke yönlendirme ve HSTS
Alan adının hem www hem kök hâli aynı içeriği 200 koduyla dönerse, arama motorları aynı sayfanın iki kopyasını görür. Canlıda yalnız bir adres olmalı, diğeri kalıcı yönlendirme (301) vermeli. Bunu vekil düzeyinde de yapabilirsiniz; biz uygulama içinde, tek bir kancayla yapmayı tercih ettik, çünkü kural kodla birlikte sürümleniyor ve test edilebiliyor:
from flask import redirect, request
SITE_URL = os.environ.get("SITE_URL", "https://ornek.com").rstrip("/")
@app.before_request
def www_kok():
host = request.host.split(":")[0].lower()
if host.startswith("www."):
return redirect(SITE_URL + request.full_path.rstrip("?"), code=301)
request.full_path, sorgu dizesi boşken sona tek bir ? ekler; rstrip("?") bu yüzden var. Hedef adresi istekteki Host başlığından değil, yapılandırmadaki sabit adresten kurmak da bilinçli: böylece başlığı değiştiren bir istek yönlendirmeyi başka bir alana çeviremez.
HTTPS'in kalıcı olduğunu tarayıcıya bildirmek için Strict-Transport-Security başlığını kullanıyoruz. Ancak bu başlığın yalnız HTTPS üzerinden gelen yanıtlarda gönderilmesi gerekir. Vekil arkasında isteğin gerçekten HTTPS ile gelip gelmediğini X-Forwarded-Proto söyler:
@app.after_request
def guvenlik_basliklari(yanit):
yanit.headers.setdefault("X-Content-Type-Options", "nosniff")
yanit.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
proto = request.headers.get("X-Forwarded-Proto", "").split(",")[0].strip()
if request.is_secure or proto == "https":
yanit.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
return yanit
includeSubDomains eklemeden önce bütün alt alan adlarınızın HTTPS ile çalıştığından emin olun; bu başlık tarayıcıda bir yıl boyunca hatırlanır ve geri almak kolay değildir. Güvenlik başlıklarının daha ileri adımı olan Content Security Policy'yi ayrı bir yazıda anlattık.
Ortam değişkenleri ve SECRET_KEY
Parolalar, API anahtarları ve oturum imzalama anahtarı depoya girmez; Coolify'da uygulamanın ortam değişkenleri olarak tanımlanır. Coolify bunları konteynere çalışma anında verir. Burada en çok gözden kaçan değişken Flask'ın SECRET_KEY değeridir. Flask oturum çerezini bu anahtarla imzalar; anahtar herkesin bildiği bir varsayılan olarak kalırsa, saldırgan geçerli bir yönetici oturumu çerezini kendisi üretebilir.
Bu riski "unutmayalım" diyerek değil, kodla kapatıyoruz. Üretim ortamında anahtar varsayılan değerdeyse yönetim paneline giriş tamamen kapanır ve iç izleme ucumuz bunu bir uyarı olarak gösterir:
GELISTIRME_ANAHTARI = "gelistirme-anahtari-degistir"
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", GELISTIRME_ANAHTARI)
app.config["GIZLI_ANAHTAR_VARSAYILAN"] = (
app.config["SECRET_KEY"] == GELISTIRME_ANAHTARI
and os.environ.get("FLASK_ENV") == "production"
)
def yonetici_mi() -> bool:
# Varsayılan anahtarla imzalanmış oturuma hiçbir zaman güvenme
return not app.config["GIZLI_ANAHTAR_VARSAYILAN"] and bool(session.get("yonetici"))
Yeterince uzun ve rastgele bir anahtar üretmek için python -c "import secrets; print(secrets.token_hex(32))" komutu yeterlidir. Anahtarı değiştirmek bütün açık oturumları geçersiz kılar; bu genellikle istenen bir sonuçtur, ama bilerek yapılmalıdır.
SQLite, kalıcı disk ve doğrulanan yedek
Konteynerler geçicidir: her dağıtımda yeni bir konteyner oluşur ve içine yazılan dosyalar kaybolur. İletişim formundan gelen talepleri sakladığımız SQLite dosyası bu yüzden konteynerin içinde değil, Coolify'da tanımlanan kalıcı bir diskte durur. Dockerfile'daki VOLUME ["/app/instance"] satırı bu klasörün kalıcı olması gerektiğini belgeler; asıl bağlama işlemi panelde, aynı yolu hedef gösteren bir kalıcı depolama tanımıyla yapılır. Bu tanımı unutmak, ilk yeniden dağıtımda bütün kayıtların sessizce kaybolması demektir.
Kalıcı disk yedek değildir. Dosya bozulabilir ya da yanlışlıkla silinebilir. Bu yüzden uygulama günde bir kez kendi yedeğini alır ve yedeği almakla yetinmez, onu geri yükleyip doğrular. SQLite'ın çevrimiçi yedek API'si, uygulama çalışırken tutarlı bir kopya almayı sağlar:
import sqlite3
from contextlib import closing
from pathlib import Path
def yedekle_ve_dogrula(kaynak: Path, hedef: Path) -> None:
uri = kaynak.resolve().as_uri() + "?mode=ro" # kaynak salt okunur açılır
with closing(sqlite3.connect(uri, uri=True, timeout=30)) as k, \
closing(sqlite3.connect(hedef)) as h:
k.backup(h)
with closing(sqlite3.connect(hedef)) as h:
sonuc = h.execute("PRAGMA integrity_check").fetchone()[0]
if sonuc != "ok":
hedef.unlink(missing_ok=True)
raise RuntimeError(f"yedek bozuk: {sonuc}")
Gerçek sürümümüz bunun biraz daha fazlasını yapıyor: kopyayı ayrı bir geçici dosyaya geri yükleyip tablo ve satır sayılarını kaynakla kıyaslıyor, her kopyanın SHA-256 özetini saklıyor, son on dört doğrulanmış kopyayı tutuyor ve bir tablo bir anda yarısının altına inerse eski kopyaları silmiyor. Dürüst bir sınır da var: bu yedekler veritabanıyla aynı diskte duruyor. Bozulmaya ve yanlış silmeye karşı korur, diskin tamamen kaybına karşı korumaz; sunucu dışı kopya ayrı bir karardır.
Gunicorn işçileri ve süreç içi durum
Gunicorn'u iki işçiyle çalıştırmak, iki ayrı Python sürecinin istekleri paylaşması demektir. Bellekte tutulan her şey işçi başınadır. Yönetim girişindeki deneme freni bunun tipik örneği: başarısız denemeleri bellekteki bir sözlükte sayıyoruz, dolayısıyla her işçi kendi sayacını tutar. Pratikte bu, sınırın işçi sayısı kadar gevşemesi anlamına gelir. Küçük bir site için bu kabul edilebilir bir ödünleşim; sınırın kesin olması gerekiyorsa sayaç veritabanına ya da paylaşılan bir önbelleğe taşınmalıdır.
Aynı mantık arka plan işleri için de geçerli. Yedek döngüsü her işçide ayrı bir iş parçacığı olarak başlar. İki işçinin aynı anda yedek almaması için bir kilit dosyası kullanıyoruz ve işçilerin başlangıcına rastgele birkaç saniyelik bir bekleme ekliyoruz. Daha büyük bir sistemde bu tür işleri ayrı bir zamanlanmış göreve taşımak daha temizdir; tek konteynerlik bir sitede kilit dosyası yeterli oluyor.
Sağlık kontrolü ve Git ile dağıtım
Coolify, depoya bağlandığında seçtiğiniz dala yapılan bir push ile yeni bir dağıtım başlatacak şekilde ayarlanabilir. Yeni imaj üretilir, konteyner başlatılır ve trafik ona aktarılır. Bu akışın güvenilir olması için platformun yeni konteynerin gerçekten hazır olduğunu anlayabilmesi gerekir. Bunun için sağlık kontrolü tanımlanır: platform belirli bir adrese istek atar ve başarılı yanıt bekler.
Ana sayfayı sağlık kontrolü olarak kullanmak işe yarar ama ideal değildir; ana sayfa ağırlaşabilir ya da bakım modunda 503 dönebilir. Ayrı, hafif bir uç daha nettir. Bu site için henüz böyle bir uç tanımlamadık; başka ürünlerimizde kullandığımız biçim şuna benzer:
@app.get("/saglik")
def saglik():
try:
with closing(sqlite3.connect(app.config["DATABASE"], timeout=2)) as con:
con.execute("SELECT 1")
except sqlite3.Error:
return {"durum": "veritabani"}, 503
return {"durum": "ok"}, 200
Dağıtımdan önce yerelde testleri çalıştırmak, push ile canlıya giden yolu güvenli kılar. Biz her değişikliği önce tam test takımından geçiriyor, sonra push ediyoruz; canlıda da ilk iş olarak ana sayfa, site haritası ve birkaç kritik sayfanın durum kodlarını kontrol ediyoruz.
Sonuç
Kendi sunucunuzda yayın yapmak, bir panelin sunduğu kolaylığı kodun doğru davranışıyla birleştirmeyi gerektirir. Sade bir Dockerfile, vekil sayısı bilinçli seçilmiş bir ProxyFix, tek adrese yönlendirme, yalnız HTTPS'te gönderilen HSTS, varsayılanla çalışmayı reddeden bir gizli anahtar, kalıcı diske bağlanmış ve doğrulanarak yedeklenen bir veritabanı: bunların her biri birkaç satırdır ama birlikte, yayından sonraki sessiz hataların çoğunu önler. Stüdyomuzun çalışma biçimini hakkında sayfamızda bulabilir, kendi projeniz için bize yazabilirsiniz.
Benzer bir sistem mi kuruyorsunuz?
İhtiyacınızı birkaç cümleyle yazın; uygun bir yol varsa birlikte netleştirelim.
Bize yazın