Files
robre 0e9377739e Add step 03: Docker, Deployment & First Live App
- Introduced a new section on building Docker images and deploying the fitness app.
- Added detailed explanations of Docker concepts, including images, containers, and volumes.
- Included three Dockerfiles for the backend and frontend, with line-by-line explanations.
- Updated main.tex to include step_03.tex.
- Modified main.toc to reflect new sections and subsections.
- Updated main.fls and main.log to include new font inputs and log entries related to the new content.
- Adjusted font configurations for FiraMono and FiraSans in main.fls.
- Updated PDF and synctex files to reflect changes in the document structure.
2026-05-06 17:27:49 +02:00

382 lines
17 KiB
TeX
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
% ============================================
% STEP 03: DOCKER, DEPLOYMENT & ERSTE LIVE-APP
% ============================================
\section{Docker-Images bauen und App deployen}
\label{sec:step03}
In diesem Schritt bringen wir unsere Fitness-App vom lokalen Entwicklungsrechner auf den Server und machen sie weltweit erreichbar. Dafür nutzen wir \textbf{Docker} eine Container-Plattform, die Anwendungen in standardisierten, isolierten Umgebungen verpackt und ausführt.
\subsection{Was ist Docker und warum nutzen wir es?}
Docker funktioniert wie ein \textbf{Versandkarton für Software}. Stell dir vor, du verschickst ein zerbrechliches Paket: Du packst es in einen genormten Karton, der überall auf der Welt gleich behandelt wird egal ob in Deutschland, Japan oder Brasilien. Docker macht dasselbe mit Software:
\begin{itemize}
\item \textbf{Image} = Der Bauplan des Kartons (inkl. Inhalt). Ein Image enthält das Betriebssystem, alle Abhängigkeiten und die Anwendung selbst.
\item \textbf{Container} = Der tatsächliche laufende Karton. Ein Container ist eine laufende Instanz eines Images.
\item \textbf{Volume} = Ein separater Speicherort, der den Container überlebt. Wie ein externer USB-Stick, den man an den Karton anschließt.
\end{itemize}
\textbf{Konkret für unser Projekt:}
\begin{itemize}
\item \texttt{fitness-api:latest} Image mit .NET 8 Backend
\item \texttt{fitness-web:latest} Image mit React Frontend + Nginx
\item \texttt{fitness-data} Volume für die SQLite-Datenbank (überlebt Container-Neustarts)
\end{itemize}
\subsection{Die drei Dockerfiles im Detail}
\subsubsection{Backend-Dockerfile: \texttt{apps/api/Dockerfile}}
\begin{lstlisting}[language=Dockerfile, caption={Dockerfile für das .NET Backend}]
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["apps/api/Api.csproj", "apps/api/"]
RUN dotnet restore "apps/api/Api.csproj"
COPY . .
WORKDIR /src/apps/api
RUN dotnet publish "Api.csproj" -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 5000
ENV ASPNETCORE_URLS=http://+:5000
ENTRYPOINT ["dotnet", "Api.dll"]
\end{lstlisting}
\textbf{Zeile für Zeile erklärt:}
\begin{enumerate}
\item \texttt{FROM ... AS build} Startet mit dem .NET 8 SDK Image (enthält Compiler, Tools). Der Alias \texttt{build} erlaubt später darauf zuzugreifen.
\item \texttt{WORKDIR /src} Setzt das Arbeitsverzeichnis im Container auf \texttt{/src}.
\item \texttt{COPY ["apps/api/Api.csproj", "apps/api/"]} Kopiert NUR die Projektdatei. Dadurch cached Docker diesen Schritt: Solange sich \texttt{Api.csproj} nicht ändert, wird der Cache verwendet $\rightarrow$ schnellere Builds!
\item \texttt{RUN dotnet restore} Lädt alle NuGet-Pakete herunter (Entity Framework, NSwag, Swagger usw.).
\item \texttt{COPY . .} Kopiert den gesamten restlichen Quellcode.
\item \texttt{WORKDIR /src/apps/api} Wechselt ins Backend-Verzeichnis.
\item \texttt{RUN dotnet publish} Kompiliert die Anwendung im Release-Modus in den Ordner \texttt{/app/publish}.
\item \texttt{FROM ... AS final} Startet ein NEUES, schlankeres Image (nur ASP.NET Runtime, kein SDK). Das spart Speicher!
\item \texttt{COPY --from=build ...} Kopiert die kompilierte Anwendung aus dem Build-Image.
\item \texttt{EXPOSE 5000} Dokumentiert, dass der Container auf Port 5000 lauscht.
\item \texttt{ENV ASPNETCORE\_URLS=http://+:5000} Sagt .NET, es soll auf Port 5000 auf ALLEN Netzwerkschnittstellen lauschen.
\item \texttt{ENTRYPOINT ["dotnet", "Api.dll"]} Startet die Anwendung beim Container-Start.
\end{enumerate}
\subsubsection{Frontend-Dockerfile: \texttt{apps/web/Dockerfile}}
\begin{lstlisting}[language=Dockerfile, caption={Dockerfile für das React Frontend}]
FROM node:22-alpine AS build
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json apps/web/
RUN npm install -g pnpm && pnpm install --no-frozen-lockfile
COPY apps/web/ apps/web/
WORKDIR /app/apps/web
RUN pnpm run build
FROM nginx:stable-alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
\end{lstlisting}
\textbf{Zeile für Zeile erklärt:}
\begin{enumerate}
\item \texttt{FROM node:22-alpine} Leichtgewichtiges Node.js 22 Image (Alpine Linux = nur $\sim$5 MB statt $\sim$180 MB bei Ubuntu).
\item \texttt{COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./} Kopiert die Monorepo-Konfiguration aus dem Root. \texttt{pnpm-workspace.yaml} ist nötig, damit pnpm die Workspace-Struktur erkennt.
\item \texttt{COPY apps/web/package.json apps/web/} Kopiert die Frontend-Paketliste an ihren Platz.
\item \texttt{RUN npm install -g pnpm \&\& pnpm install} Installiert pnpm global und dann alle Abhängigkeiten.
\item \texttt{COPY apps/web/ apps/web/} Kopiert den restlichen Frontend-Code.
\item \texttt{WORKDIR /app/apps/web} Wechselt ins Frontend-Verzeichnis.
\item \texttt{RUN pnpm run build} Baut das Frontend mit Vite (erzeugt \texttt{dist/}).
\item \texttt{FROM nginx:stable-alpine} NEUES schlankes Image mit Nginx (Webserver).
\item \texttt{COPY --from=build ... /usr/share/nginx/html} Kopiert den Build-Output in Nginx's Standard-Webverzeichnis.
\item \texttt{COPY apps/web/nginx.conf ...} Unsere eigene Nginx-Konfiguration.
\item \texttt{EXPOSE 80} HTTP-Port.
\item \texttt{CMD ["nginx", "-g", "daemon off;"]} Startet Nginx im Vordergrund (Container bleibt am Leben).
\end{enumerate}
\subsubsection{Nginx-Konfiguration: \texttt{apps/web/nginx.conf}}
\begin{lstlisting}[language=Bash, caption={Nginx-Konfiguration mit Reverse Proxy}]
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://fitness-api:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
\end{lstlisting}
\textbf{Erklärung:} Dies ist ein \textbf{Reverse Proxy}. Nginx nimmt alle Anfragen entgegen und entscheidet, wohin sie weitergeleitet werden:
\begin{itemize}
\item \texttt{location /} Anfragen an die Hauptseite $\rightarrow$ liefert React-Dateien aus \texttt{/usr/share/nginx/html}
\item \texttt{location /api/} Anfragen an \texttt{/api/*} $\rightarrow$ leitet sie an das Backend (\texttt{fitness-api:5000}) weiter
\item \texttt{try\_files \$uri \$uri/ /index.html} Sorgt dafür, dass Reacts Client-Side-Routing funktioniert (z.B. \texttt{/workouts/123} wird an React weitergegeben, nicht als 404 beantwortet)
\end{itemize}
\subsection{Das Backend: Program.cs im Detail}
\begin{lstlisting}[language=CSharp, caption={Vollständige Program.cs}]
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var dbPath = Path.Combine("/app/data", "fitness.db");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApiDocument();
builder.Services.AddSwaggerGen();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
}
if (app.Environment.IsDevelopment())
{
app.UseOpenApi();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapPost("/api/workouts", async (Workout workout, AppDbContext db) => { ... });
app.MapGet("/api/workouts", async (AppDbContext db) => ...);
app.MapGet("/api/workouts/{id:guid}", async (Guid id, AppDbContext db) => ...);
app.MapPut("/api/workouts/{id:guid}", async (Guid id, Workout input, AppDbContext db) => ...);
app.MapDelete("/api/workouts/{id:guid}", async (Guid id, AppDbContext db) => ...);
app.MapGet("/", () => "H Fitness API läuft!");
app.Run();
\end{lstlisting}
\textbf{Die NuGet-Pakete in der .csproj-Datei:}
\begin{itemize}
\item \texttt{Microsoft.EntityFrameworkCore.Sqlite} (8.0.14) EF Core für SQLite
\item \texttt{Microsoft.EntityFrameworkCore.Design} (8.0.14) EF Core Tools für Migrationen
\item \texttt{NSwag.AspNetCore} (14.1.0) OpenAPI/Swagger-Generator
\item \texttt{Swashbuckle.AspNetCore} (6.6.2) Swagger UI (die schöne Oberfläche)
\end{itemize}
\textbf{Wichtige Details:}
\begin{itemize}
\item \texttt{Path.Combine("/app/data", "fitness.db")} Im Docker-Container liegt die Datenbank im Volume-Ordner \texttt{/app/data}. Das stellt sicher, dass die Daten erhalten bleiben, auch wenn der Container gelöscht und neu erstellt wird.
\item \texttt{db.Database.EnsureCreated()} Erstellt die Datenbank-Tabellen automatisch beim Start, falls sie noch nicht existieren. Erspart uns manuelle Migrationen im Produktivbetrieb.
\item Die CRUD-Endpunkte sind \textbf{Minimal API Endpoints} .NET 8's leichtgewichtige Alternative zu Controllern.
\end{itemize}
\subsection{Der API-Client: client.ts im Detail}
\begin{lstlisting}[language=TypeScript, caption={Manueller API-Client}]
const API_BASE = "";
export interface Workout {
id?: string;
name: string;
date: string;
durationMinutes: number;
notes?: string;
}
export const fitnessApi = {
async getWorkouts(): Promise<Workout[]> {
const res = await fetch(`/api/workouts`);
return res.json();
},
async createWorkout(workout: Workout): Promise<Workout> {
const res = await fetch(`/api/workouts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(workout),
});
return res.json();
},
// Weitere Methoden: getWorkout, updateWorkout, deleteWorkout...
};
\end{lstlisting}
\textbf{Erklärung:}
\begin{itemize}
\item \texttt{API\_BASE = ""} Keine absolute URL! Stattdessen relative Pfade wie \texttt{/api/workouts}. Der Browser sendet die Anfrage dann an dieselbe Domain, auf der die Seite gehostet ist. Nginx leitet sie an das Backend weiter.
\item \texttt{interface Workout} TypeScript-Interface für Typsicherheit. Stellt sicher, dass wir keine falschen Felder an die API senden.
\item \texttt{fetch()} Native Browser-API für HTTP-Anfragen. Kein Axios, kein jQuery nötig!
\item Die Methoden geben direkt das geparste JSON zurück.
\end{itemize}
\subsection{Das Frontend: App.tsx im Detail}
\begin{lstlisting}[language=TypeScript, caption={Hauptkomponente mit Workout-Liste und Formular}]
function App() {
const [workouts, setWorkouts] = useState<Workout[]>([]);
const [form, setForm] = useState<Workout>({ ... });
const loadWorkouts = async () => {
const data = await fitnessApi.getWorkouts();
setWorkouts(data);
};
useEffect(() => { loadWorkouts(); }, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await fitnessApi.createWorkout({ ...form });
loadWorkouts();
};
return (
<div className="min-h-screen bg-gray-950 text-white p-4 max-w-md mx-auto">
{/* Formular */}
<form onSubmit={handleSubmit}>...</form>
{/* Workout-Liste */}
{workouts.map(w => ( ... ))}
</div>
);
}
\end{lstlisting}
\textbf{Erklärung:}
\begin{itemize}
\item \texttt{useState} Reacts State-Management für die Workout-Liste und das Formular.
\item \texttt{useEffect} Lädt die Workouts einmalig beim ersten Rendern der Komponente.
\item \texttt{handleSubmit} Wird beim Absenden des Formulars aufgerufen. Verhindert den Standard-Seiten-Reload (\texttt{e.preventDefault()}), sendet das Workout an die API und lädt die Liste neu.
\item Tailwind CSS-Klassen wie \texttt{bg-gray-950}, \texttt{text-white}, \texttt{p-4} gestalten die App im Dark-Mode.
\end{itemize}
\subsection{Images bauen}
Am Anfang war das Frontend-Image fehlerhaft. Hier die drei wichtigsten Fixes:
\textbf{Fehler 1: \texttt{pnpm-lock.yaml} nicht gefunden}
\begin{itemize}
\item Ursache: Dockerfile suchte in \texttt{apps/web/}, aber die Datei liegt im Root.
\item Lösung: \texttt{COPY pnpm-lock.yaml ./} (vom Root kopieren).
\end{itemize}
\textbf{Fehler 2: \texttt{--frozen-lockfile} schlug fehl}
\begin{itemize}
\item Ursache: pnpm-Lockfile war nicht aktuell mit der Root-\texttt{package.json}.
\item Lösung: \texttt{--no-frozen-lockfile} verwenden, damit pnpm fehlende Pakete nachinstalliert.
\end{itemize}
\textbf{Fehler 3: TypeScript Compiler (\texttt{tsc}) nicht gefunden}
\begin{itemize}
\item Ursache: \texttt{tsc -b} benötigt TypeScript als Abhängigkeit, die im Container fehlte.
\item Lösung: Build-Script von \texttt{"tsc -b \&\& vite build"} auf \texttt{"vite build"} geändert. Vite führt den TypeScript-Check beim Dev-Server durch im Produktions-Build reicht die reine Vite-Kompilierung.
\end{itemize}
\subsection{Images exportieren und auf den Server kopieren}
\begin{lstlisting}[language=Bash, caption={Images exportieren und kopieren}]
# Images als tar-Datei speichern
docker save fitness-api:latest fitness-web:latest -o fitness-images.tar
# Auf den Server kopieren (scp = Secure Copy über SSH)
scp fitness-images.tar testserver:/root/
# Auf dem Server importieren
ssh testserver
docker load -i /root/fitness-images.tar
docker images | grep fitness
\end{lstlisting}
\textbf{Befehle erklärt:}
\begin{itemize}
\item \texttt{docker save} Exportiert Docker-Images in eine portable tar-Datei.
\item \texttt{scp} Secure Copy: Kopiert Dateien verschlüsselt über SSH.
\item \texttt{testserver:/root/} Der Alias aus unserer \texttt{\textasciitilde/.ssh/config}. Die Datei landet im \texttt{/root/}-Verzeichnis des Servers.
\item \texttt{docker load -i} Importiert Images aus einer tar-Datei in Docker.
\item \texttt{docker images} Listet alle lokal verfügbaren Docker-Images auf.
\end{itemize}
\subsection{Container auf dem Server starten}
\begin{lstlisting}[language=Bash, caption={Docker-Netzwerk, Volume und Container anlegen}]
# Volume für die Datenbank (überlebt Container-Neustarts)
docker volume create fitness-data
# Netzwerk (damit Backend und Frontend kommunizieren können)
docker network create fitness-net
# Backend starten
docker run -d \
--name fitness-api \
--network fitness-net \
-v fitness-data:/app/data \
-p 5000:5000 \
fitness-api:latest
# Frontend starten
docker run -d \
--name fitness-web \
--network fitness-net \
-p 80:80 \
fitness-web:latest
\end{lstlisting}
\textbf{Optionen erklärt:}
\begin{itemize}
\item \texttt{-d} Detached Mode: Container läuft im Hintergrund (gibt die Konsole frei).
\item \texttt{--name fitness-api} Gibt dem Container einen festen Namen (sonst vergibt Docker Zufallsnamen).
\item \texttt{--network fitness-net} Bindet den Container in unser Docker-Netzwerk ein. Container im selben Netzwerk können sich über ihren Namen erreichen (z.B. \texttt{fitness-api}).
\item \texttt{-v fitness-data:/app/data} Bindet das Volume \texttt{fitness-data} in den Container-Pfad \texttt{/app/data} ein. Alles, was im Container unter \texttt{/app/data} gespeichert wird, landet tatsächlich im Volume und überlebt.
\item \texttt{-p 5000:5000} Port-Mapping: Leitet Port 5000 des Hosts (Server) an Port 5000 des Containers weiter.
\end{itemize}
\subsection{Aufgetretene Probleme und Lösungen}
\textbf{Problem 1: Nginx konnte Host "backend" nicht auflösen}
\begin{itemize}
\item Ursache: In \texttt{nginx.conf} stand \texttt{proxy\_pass http://backend:5000}, aber der Backend-Container heißt \texttt{fitness-api}.
\item Lösung: \texttt{backend} $\rightarrow$ \texttt{fitness-api} in \texttt{nginx.conf} ändern, Image neu bauen.
\end{itemize}
\textbf{Problem 2: Frontend lud Assets mit Pfad \texttt{/app/...}}
\begin{itemize}
\item Ursache: In \texttt{vite.config.ts} war \texttt{base: "/app/"} für PWA-Zwecke gesetzt.
\item Lösung: Geändert auf \texttt{base: "/"}.
\end{itemize}
\textbf{Problem 3: API-Anfragen gingen an lokale IP \texttt{192.168.178.189}}
\begin{itemize}
\item Ursache: Im Client stand \texttt{const API\_BASE = "http://192.168.178.189:5107"}.
\item Lösung: Geändert auf relative Pfade (\texttt{/api/workouts}), sodass Nginx die Anfragen per Reverse Proxy ans Backend weiterleitet.
\end{itemize}
\textbf{Problem 4: Doppeltes \texttt{/api} im Pfad}
\begin{itemize}
\item Ursache: Client hatte \texttt{API\_BASE = "/api"} und Endpunkte begannen ebenfalls mit \texttt{/api}.
\item Ergebnis: Anfragen gingen an \texttt{/api/api/workouts}.
\item Lösung: API-Base auf leeren String gesetzt und Endpunkte mit \texttt{/api/} beginnen lassen.
\end{itemize}
\subsection{Zusammenfassung}
Nach diesem Schritt ist die Fitness-App produktiv auf dem VPS im Einsatz:
\begin{itemize}
\item Die App ist unter \texttt{http://185.209.229.167} weltweit erreichbar
\item Workouts werden persistent in einer SQLite-Datenbank gespeichert
\item Docker-Volumes stellen sicher, dass Daten Container-Neustarts überleben
\item Das Backend läuft auf .NET 8, das Frontend auf Nginx
\item Ein Reverse Proxy (Nginx) leitet API-Anfragen intern an das Backend weiter
\end{itemize}