feat: graceful shutdown and frontend build in Docker

- Add SIGTERM/Ctrl+C signal handling with graceful connection draining
- Close database pool cleanly on shutdown
- Add frontend-builder stage to Dockerfile (node:22-alpine, npm ci + build)
- Move Docker build context to project root so both frontend/ and backend/ are accessible
- Frontend dist/ copied into container at ./static/ for the backend to serve

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent b961f82f01
commit 069a4f2022

@ -1,12 +1,29 @@
# =================================================================== # ===================================================================
# Stage 1: Build the Rust backend # Stage 1: Build the frontend
# ===================================================================
FROM node:22-alpine AS frontend-builder
WORKDIR /app
# Copy package files first for dependency caching
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Copy source and build
COPY frontend/ ./
RUN npm run build
# ===================================================================
# Stage 2: Build the Rust backend
# =================================================================== # ===================================================================
FROM rust:1.85-bookworm AS builder FROM rust:1.85-bookworm AS builder
WORKDIR /app WORKDIR /app
# Copy manifests first for dependency caching # Copy manifests first for dependency caching
COPY Cargo.toml Cargo.lock ./ COPY backend/Cargo.toml backend/Cargo.lock ./
# Create a dummy main.rs to build dependencies # Create a dummy main.rs to build dependencies
RUN mkdir src && echo "fn main() {}" > src/main.rs RUN mkdir src && echo "fn main() {}" > src/main.rs
@ -14,8 +31,8 @@ RUN cargo build --release
RUN rm -rf src RUN rm -rf src
# Copy the actual source code and migrations # Copy the actual source code and migrations
COPY src/ src/ COPY backend/src/ src/
COPY migrations/ migrations/ COPY backend/migrations/ migrations/
# Set sqlx offline mode (no live DB needed during build) # Set sqlx offline mode (no live DB needed during build)
ENV SQLX_OFFLINE=true ENV SQLX_OFFLINE=true
@ -26,7 +43,7 @@ RUN touch src/main.rs
RUN cargo build --release RUN cargo build --release
# =================================================================== # ===================================================================
# Stage 2: Minimal runtime image # Stage 3: Minimal runtime image
# =================================================================== # ===================================================================
FROM debian:bookworm-slim AS runtime FROM debian:bookworm-slim AS runtime
@ -50,6 +67,9 @@ COPY --from=builder /app/target/release/ai-synth-backend ./ai-synth-backend
# Copy migrations (run at startup) # Copy migrations (run at startup)
COPY --from=builder /app/migrations/ ./migrations/ COPY --from=builder /app/migrations/ ./migrations/
# Copy built frontend
COPY --from=frontend-builder /app/dist/ ./static/
# Set ownership # Set ownership
RUN chown -R appuser:appuser /app RUN chown -R appuser:appuser /app

@ -59,6 +59,7 @@ async fn main() -> anyhow::Result<()> {
tracing::warn!("Failed to load provider rate limits from DB: {:?}. Using defaults.", e); tracing::warn!("Failed to load provider rate limits from DB: {:?}. Using defaults.", e);
} }
let shutdown_pool = state.pool.clone();
let app = router::build_router(state, &config); let app = router::build_router(state, &config);
let addr = format!("0.0.0.0:{}", config.port); let addr = format!("0.0.0.0:{}", config.port);
@ -69,8 +70,13 @@ async fn main() -> anyhow::Result<()> {
.context("Failed to bind to address")?; .context("Failed to bind to address")?;
axum::serve(listener, app) axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await .await
.context("Server error")?; .context("Server error")?;
tracing::info!("Server shut down, closing database pool...");
shutdown_pool.close().await;
tracing::info!("Shutdown complete.");
} }
Commands::CreateAdmin { email } => { Commands::CreateAdmin { email } => {
tracing::info!("Creating admin user: {}", email); tracing::info!("Creating admin user: {}", email);
@ -103,3 +109,28 @@ async fn run_migrations(pool: &sqlx::PgPool) -> anyhow::Result<()> {
tracing::info!("Migrations complete."); tracing::info!("Migrations complete.");
Ok(()) Ok(())
} }
/// Wait for SIGTERM or Ctrl+C, then return to trigger graceful shutdown.
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => tracing::info!("Received Ctrl+C, starting graceful shutdown..."),
_ = terminate => tracing::info!("Received SIGTERM, starting graceful shutdown..."),
}
}

@ -1,8 +1,8 @@
services: services:
app: app:
build: build:
context: ./backend context: .
dockerfile: Dockerfile dockerfile: backend/Dockerfile
container_name: ai-synth container_name: ai-synth
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env

Loading…
Cancel
Save