0e9377739e
- 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.
382 lines
17 KiB
TeX
382 lines
17 KiB
TeX
% ============================================
|
||
% 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} |