feat(dağıtım): Docker imajı ve Axum'un arayüzü sunması

- Çok aşamalı Dockerfile: Node (arayüz) → Rust (release) → debian-slim, root olmayan kullanıcı
- Axum, STATIC_DIR tanımlıysa derlenmiş arayüzü sunar (SPA yönlendirmesi)
- Önbellek başlıkları (HTML no-cache, /assets 1 yıl), gzip/brotli sıkıştırma, istek logları
- Bilinmeyen /api/... → JSON 404; SIGTERM'de düzgün kapanış
- HEALTHCHECK (/api/health), .dockerignore, docs/DEPLOY.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 01:26:34 +03:00
parent e188922c0f
commit ad90551c6d
14 changed files with 391 additions and 6 deletions
+10 -1
View File
@@ -1,6 +1,6 @@
//! Ortam değişkenlerinden uygulama ayarlarını okur.
use std::env;
use std::{env, path::PathBuf};
#[derive(Debug, Clone)]
pub struct Config {
@@ -9,6 +9,9 @@ pub struct Config {
pub app_url: String,
pub port: u16,
pub registration: RegistrationMode,
/// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar
/// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
pub static_dir: Option<PathBuf>,
}
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
@@ -54,6 +57,11 @@ impl Config {
}
};
let static_dir = env::var("STATIC_DIR")
.ok()
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from);
let port = match env::var("PORT") {
Ok(p) => p
.parse()
@@ -67,6 +75,7 @@ impl Config {
app_url,
port,
registration,
static_dir,
})
}
+28 -2
View File
@@ -43,7 +43,7 @@ async fn main() -> ExitCode {
let port = config.port;
let state = AppState::new(db, config);
let app = routes::router(state);
let app = routes::app(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = match tokio::net::TcpListener::bind(addr).await {
@@ -57,9 +57,35 @@ async fn main() -> ExitCode {
};
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
if let Err(e) = axum::serve(listener, app).await {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
{
tracing::error!("Sunucu hatası: {e}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
/// Docker/Dokploy durdururken (SIGTERM) veya Ctrl+C'de: yeni istek almayı bırak,
/// süren istekleri bitir, sonra kapan (yarım kalan yazma olmasın).
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
s.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("Kapatma sinyali alındı, sunucu kapanıyor…");
}
+18 -2
View File
@@ -4,13 +4,27 @@ mod auth;
mod catalog;
mod health;
mod household;
mod spa;
use axum::{
Router,
routing::{delete, get, patch, post, put},
};
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use crate::state::AppState;
use crate::{error::AppError, state::AppState};
/// Sunucunun tamamı: API + (STATIC_DIR tanımlıysa) arayüz dosyaları,
/// sıkıştırma ve istek logları. `main.rs` bunu kullanır.
pub fn app(state: AppState) -> Router {
let static_dir = state.config.static_dir.clone();
let mut app = router(state);
if let Some(dir) = static_dir {
app = app.fallback_service(spa::router(&dir));
}
app.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
}
/// Tüm API route'ları. Testler de bu fonksiyonu kullanır.
pub fn router(state: AppState) -> Router {
@@ -44,7 +58,9 @@ pub fn router(state: AppState) -> Router {
.route(
"/payment-methods/{id}",
patch(catalog::update_payment_method),
);
)
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
.fallback(|| async { AppError::NotFound });
Router::new().nest("/api", api).with_state(state)
}
+42
View File
@@ -0,0 +1,42 @@
//! Derlenmiş arayüzün (frontend/dist) sunulması — tek sayfa uygulama (SPA).
//!
//! - `/assets/*` : adları içerik özetli (hash) → 1 yıl önbellek, `immutable`
//! - diğer dosyalar (favicon, ikonlar): 1 saat önbellek
//! - olmayan yol (`/defter`, `/plan` ...) → `index.html` (yönlendirmeyi React Router yapar)
//! - HTML hiç önbelleğe alınmaz → yeni sürüm yayınlanınca hemen yüklenir
use std::path::Path;
use axum::{
Router,
extract::Request,
http::{HeaderValue, header},
middleware::{self, Next},
response::Response,
};
use tower_http::services::{ServeDir, ServeFile};
pub fn router(dir: &Path) -> Router {
let files = ServeDir::new(dir).fallback(ServeFile::new(dir.join("index.html")));
Router::new()
.fallback_service(files)
.layer(middleware::from_fn(cache_headers))
}
async fn cache_headers(req: Request, next: Next) -> Response {
let is_asset = req.uri().path().starts_with("/assets/");
let mut res = next.run(req).await;
let is_html = res
.headers()
.get(header::CONTENT_TYPE)
.is_some_and(|v| v.as_bytes().starts_with(b"text/html"));
let policy = match (is_html, is_asset) {
(true, _) => "no-cache",
(false, true) => "public, max-age=31536000, immutable",
(false, false) => "public, max-age=3600",
};
res.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static(policy));
res
}