Nuxt+Prisma+Docker für automatische Datenbank-Synchronisation (PostgreSQL)
Datenbankmigrationen werden in der Docker-Image-Build-Phase nicht ausgeführt; die Synchronisation erfolgt nach dem Start des Containers und dem Hochfahren des Anwendungsprozesses.
Wird gerendert...
Dieser Artikel beschreibt eine Lösung zur Synchronisierung von Datenbanken in Docker. Die grundlegende Entwicklungsumgebung dieses Artikels ist Nuxt4 + Prisma + Docker. Die Synchronisierung erfolgt nach dem Start des Containers und dem Hochfahren des Anwendungsprozesses. Die Kernlogik besteht aus einem Skript zur Datenbank-Synchronisation und einem Skript, das beim Start des Projekts ausgeführt wird. Nuxt bietet einen Plugin-Entwicklungs-Hook vom Typ `Server Start`, was die Implementierung sehr einfach macht. Bitte konsultieren Sie für andere Entwicklungsumgebungen die Implementierung selbst.
Die Datenbankmigration wird **nicht während der Docker-Image-Build-Phase ausgeführt**. Die Synchronisierung erfolgt **nach dem Start des Containers und dem Hochfahren des Anwendungsprozesses**. Nitro-Start-Plugins rufen einen benutzerdefinierten Migrationsläufer auf, der `prisma/migrations/*/migration.sql` im Image liest und in die Prisma-kompatible `_prisma_migrations`-Tabelle schreibt.
Die wichtigsten Punkte zur Reproduktion sind drei Teile:
1. **Fügen Sie `prisma/migrations` (und die Laufzeit-Prisma-Client-Artefakte `prisma/generated`) zum Image hinzu.**
2. **Implementieren Sie `server/lib/db-migrations.ts` (vollständiger Code unten).**
3. **Rufen Sie `ensureDatabaseMigrations()` in der frühesten Phase des Anwendungsstarts auf (Plugin-Beispiel unten).**
Der Inhalt des Verzeichnisses `prisma/migrations` wird automatisch durch Befehle wie `prisma migrate dev` generiert. Das Schema jedes Projekts ist unterschiedlich, daher wird die Migration von SQL selbst hier nicht weiter ausgeführt.
## Gesamtfluss
```mermaid
sequenceDiagram
participant Docker as Docker Build
participant CMD as node server/index.mjs
participant Plugin as Nitro Startup Plugin
participant Mig as db-migrations.ts
participant PG as PostgreSQL
Docker->>Docker: pnpm build (inkl. prisma generate)
Docker->>Docker: COPY .output + prisma/migrations + prisma/generated
Note over Docker: Keine Datenbankverbindung, keine Migration während des Builds
CMD->>Plugin: Prozessstart
Plugin->>Mig: ensureDatabaseMigrations()
Mig->>PG: CREATE DATABASE, falls Datenbank nicht existiert
Mig->>PG: Advisory Lock + Lesen/Schreiben von _prisma_migrations
Mig->>PG: Ausführen von nicht angewendeten migration.sql
Plugin->>CMD: Fortsetzung der Initialisierung wie Seeding nach Abschluss der Migration
```
Im Vergleich zu " `npx prisma migrate deploy` in `entrypoint.sh` " startet das aktuelle **Produktions-Dockerfile CMD direkt Node** und ist nicht auf die Installation des Prisma CLI im Image angewiesen. Die Migrationslogik wird vollständig innerhalb der Anwendung mit dem `pg`-Treiber abgeschlossen.
Das Repository behält immer noch `docker/entrypoint.sh` (Prisma Official CLI-Methode) bei, ist aber **nicht an den ENTRYPOINT/CMD des aktuellen Dockerfile gebunden**. Es dient nur als optionale Referenz, siehe Ende des Artikels.
---
## Umgebungsvariablen
| Variable | Erforderlich | Beschreibung |
| :-------------------- | :----------- | :-------------------------------------------------------------------------------- |
| `DATABASE_URL` | Ja | Verbindungszeichenfolge der Zieldatenbank, muss den Datenbanknamen enthalten; `?schema=public` gibt das PostgreSQL-Schema an |
| `DATABASE_AUTO_MIGRATE` | Nein | Setzen Sie dies auf `false`, um die automatische Migration zu überspringen |
| `DATABASE_BOOTSTRAP_URL`| Nein | Verwaltungsserver für `CREATE DATABASE`, Standard ist die Änderung des Datenbanknamens von `DATABASE_URL` zu `postgres` |
| `DATABASE_SCHEMA` | Nein | Schemaname, wenn kein `schema`-Parameter in der URL angegeben ist, Standard ist `public` |
Compose-Beispiel (nur datenbankbezogene Teile werden angezeigt):
```yaml
environment:
DATABASE_URL: "postgresql://postgres:password@host:5432/dbname?schema=public"
# DATABASE_AUTO_MIGRATE: "false" # Kann deaktiviert werden, wenn die Migration von externen Operationen durchgeführt wird
# DATABASE_BOOTSTRAP_URL: "postgresql://postgres:password@host:5432/postgres"
```
---
## Docker-Image: Was während des Builds passiert
Während der Build-Phase werden nur die Anwendungspakete und Migrationsdateien kopiert. **Es wird keine Verbindung zur Datenbank hergestellt.**
```dockerfile
FROM node:22.21.1-alpine3.22 AS builder
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22.21.1-alpine3.22 AS runner
WORKDIR /app
COPY --from=builder /app/.output ./
COPY --from=builder /app/prisma/generated ./prisma/generated
COPY --from=builder /app/prisma/migrations ./prisma/migrations
ENV DATABASE_URL="postgresql://user:pass@host:5432/dbname?schema=public"
EXPOSE 9090
CMD ["node", "/app/server/index.mjs"]
```
Anmerkungen:
- Vor `pnpm build` muss `prisma generate` auf der Entwicklungsmaschine/CI ausgeführt worden sein, damit `prisma/generated` vorhanden ist und in das Image kopiert wird.
- Das Arbeitsverzeichnis zur Laufzeit ist `/app`. Der Migrationsläufer liest SQL aus `process.cwd()/prisma/migrations`, was mit dem obigen COPY-Pfad übereinstimmt.
- Das endgültige Image **benötigt nicht** das `prisma` CLI-Paket.
---
## Start-Plugin: Migrationen vor der Geschäftsinitialisierung ausführen
Migrationen müssen vor jeder Prisma-Abfrage abgeschlossen sein. Dies wird durch ein Nitro-Plugin beim Start mit `await` erreicht:
```typescript
import { ensureDatabaseMigrations } from "~~/server/lib/db-migrations";
async function runStartupInitialization() {
await ensureDatabaseMigrations();
}
export default defineNitroPlugin(async () => {
await runStartupInitialization();
});
```
Nachfolgende `prisma.systemConfig.create` usw. in derselben Plugin-Datei gehören zum **Daten-Seeding** und sind keine strukturelle Synchronisation. Bei der Reproduktion reicht es aus, den `ensureDatabaseMigrations()`-Aufruf beizubehalten. Die Seeding-Logik kann je nach Projekt gekürzt werden.
---
## Kern: Benutzerdefinierter Migrationsläufer (vollständiger Code)
Pfadkonvention: `server/lib/db-migrations.ts`.
Zusammenfassung des Verhaltens:
1. Liest `prisma/migrations/<name>/migration.sql` nach Verzeichnisnamen sortiert.
2. Wenn die Zieldatenbank nicht existiert (PostgreSQL `3D000`), wird `CREATE DATABASE` mit der Bootstrap-Verbindung ausgeführt.
3. Erstellt/verwendet das Schema und ruft `pg_advisory_lock` ab, um die gleichzeitige Migration mehrerer Instanzen zu verhindern.
4. Wartet die Tabelle `_prisma_migrations` (Felder sind mit Prisma identisch), wobei der Checksum die SHA-256 des SQL ist.
5. **Baseline**: Wenn bereits Geschäftstabellen vorhanden sind, aber keine Migrationshistorie, und die `validateExistingSchemaIsLatest`-Prüfung erfolgreich ist, werden nur Migrationsdatensätze geschrieben, und SQL wird nicht wiederholt ausgeführt (siehe Anpassungshinweise im nächsten Abschnitt).
6. Führt SQL für verbleibende nicht angewendete Migrationen in einer Transaktion aus und protokolliert sie.
Abhängigkeit: `pg` (bereits in den `dependencies` von `package.json`).
```typescript
import { createHash, randomUUID } from "node:crypto";
import { access, readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { Client } from "pg";
const MIGRATIONS_DIR = path.resolve(process.cwd(), "prisma", "migrations");
const MIGRATIONS_TABLE = "_prisma_migrations";
const LOCK_KEY_1 = 20260328;
const LOCK_KEY_2 = 1;
type MigrationFile = {
name: string;
sql: string;
checksum: string;
};
type DatabaseTarget = {
databaseUrl: string;
adminUrl: string;
databaseName: string;
schemaName: string;
};
// Liste der Tabellennamen, hier nur als Beispiel
const EXPECTED_TABLES = [
"users",
"settings",
];
let migrationTask: Promise<void> | null = null;
function getChecksum(sql: string) {
return createHash("sha256").update(sql).digest("hex");
}
function quoteIdentifier(value: string) {
return `"${value.replaceAll(`"`, `""`)}"`;
}
function getDatabaseTarget(): DatabaseTarget {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is required before applying migrations.");
}
const parsed = new URL(databaseUrl);
const databaseName = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
if (!databaseName) {
throw new Error("DATABASE_URL must include a database name.");
}
const schemaName =
parsed.searchParams.get("schema") ||
process.env.DATABASE_SCHEMA ||
"public";
const adminUrl =
process.env.DATABASE_BOOTSTRAP_URL ||
(() => {
const bootstrap = new URL(databaseUrl);
bootstrap.pathname = "/postgres";
return bootstrap.toString();
})();
return {
databaseUrl,
adminUrl,
databaseName,
schemaName,
};
}
async function readMigrationFiles(): Promise<MigrationFile[]> {
try {
await access(MIGRATIONS_DIR);
} catch {
return [];
}
const entries = await readdir(MIGRATIONS_DIR, { withFileTypes: true });
const migrations = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.sort((a, b) => a.name.localeCompare(b.name))
.map(async (entry) => {
const sql = await readFile(
path.join(MIGRATIONS_DIR, entry.name, "migration.sql"),
"utf8"
);
return {
name: entry.name,
sql,
checksum: getChecksum(sql),
};
})
);
return migrations;
}
async function ensureMigrationsTable(client: Client) {
await client.query(`
CREATE TABLE IF NOT EXISTS "${MIGRATIONS_TABLE}" (
"id" TEXT PRIMARY KEY,
"checksum" TEXT NOT NULL,
"finished_at" TIMESTAMPTZ,
"migration_name" TEXT NOT NULL UNIQUE,
"logs" TEXT,
"rolled_back_at" TIMESTAMPTZ,
"started_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"applied_steps_count" INTEGER NOT NULL DEFAULT 0
);
`);
}
async function ensureSchema(client: Client, schemaName: string) {
await client.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
}
async function loadAppliedMigrations(client: Client) {
const result = await client.query<{
migration_name: string;
checksum: string;
}>(
`SELECT "migration_name", "checksum"
FROM "${MIGRATIONS_TABLE}"
WHERE "rolled_back_at" IS NULL`
);
return new Map(
result.rows.map((row) => [row.migration_name, row.checksum] as const)
);
}
async function hasUserTables(client: Client) {
const result = await client.query<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = ANY($1::text[])
) AS "exists"
`,
[EXPECTED_TABLES]
);
return Boolean(result.rows[0]?.exists);
}
async function validateExistingSchemaIsLatest(client: Client) {
const tableCountResult = await client.query<{ count: string }>(
`
SELECT COUNT(*)::text AS "count"
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = ANY($1::text[])
`,
[EXPECTED_TABLES]
);
const existingTableCount = Number(tableCountResult.rows[0]?.count ?? 0);
if (existingTableCount !== EXPECTED_TABLES.length) {
return false;
}
const metaColumnResult = await client.query<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'user_chat_messages'
AND column_name = 'meta'
) AS "exists"
`
);
const removedColumnsResult = await client.query<{
fund_account_type_exists: boolean;
flow_pay_type_exists: boolean;
fixed_flow_pay_type_exists: boolean;
}>(`
SELECT
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'user_fund_accounts'
AND column_name = 'accountType'
) AS "fund_account_type_exists",
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'user_flows'
AND column_name = 'payType'
) AS "flow_pay_type_exists",
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'user_fixed_flows'
AND column_name = 'payType'
) AS "fixed_flow_pay_type_exists"
`);
const removed = removedColumnsResult.rows[0];
return (
Boolean(metaColumnResult.rows[0]?.exists) &&
!removed?.fund_account_type_exists &&
!removed?.flow_pay_type_exists &&
!removed?.fixed_flow_pay_type_exists
);
}
async function ensureDatabaseExists(target: DatabaseTarget) {
const probeClient = new Client({
connectionString: target.databaseUrl,
});
try {
await probeClient.connect();
await probeClient.end();
return;
} catch (error: any) {
try {
await probeClient.end();
} catch {
// Ignore close errors after failed connect.
}
if (error?.code !== "3D000") {
throw error;
}
}
const adminClient = new Client({
connectionString: target.adminUrl,
});
await adminClient.connect();
try {
const existsResult = await adminClient.query<{ exists: boolean }>(
"SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1) AS exists",
[target.databaseName]
);
if (!existsResult.rows[0]?.exists) {
await adminClient.query(
`CREATE DATABASE ${quoteIdentifier(target.databaseName)}`
);
console.log(`[db:migrate] created database ${target.databaseName}`);
}
} finally {
await adminClient.end();
}
}
async function baselineMigrations(
client: Client,
migrations: MigrationFile[],
applied: Map<string, string>
) {
if (applied.size > 0) {
return;
}
const hasTables = await hasUserTables(client);
if (!hasTables) {
return;
}
const isLatestSchema = await validateExistingSchemaIsLatest(client);
if (!isLatestSchema) {
throw new Error(
"Existing database schema detected without _prisma_migrations history, and the schema does not match the current expected version. Refusing automatic baseline to avoid duplicate execution or destructive drift."
);
}
await client.query("BEGIN");
try {
for (const migration of migrations) {
await client.query(
`INSERT INTO "${MIGRATIONS_TABLE}" ("id", "checksum", "finished_at", "migration_name", "started_at", "applied_steps_count") VALUES ($1, $2, NOW(), $3, NOW(), 1)`,
[randomUUID(), migration.checksum, migration.name]
);
applied.set(migration.name, migration.checksum);
}
await client.query("COMMIT");
console.log("[db:migrate] baselined existing schema");
} catch (error) {
await client.query("ROLLBACK");
throw error;
}
}
async function applyPendingMigrations() {
const target = getDatabaseTarget();
const migrations = await readMigrationFiles();
if (migrations.length < 1) {
return;
}
await ensureDatabaseExists(target);
const client = new Client({
connectionString: target.databaseUrl,
});
await client.connect();
try {
await ensureSchema(client, target.schemaName);
await client.query("SELECT pg_advisory_lock($1, $2)", [
LOCK_KEY_1,
LOCK_KEY_2,
]);
await ensureMigrationsTable(client);
const applied = await loadAppliedMigrations(client);
await baselineMigrations(client, migrations, applied);
for (const migration of migrations) {
const appliedChecksum = applied.get(migration.name);
if (appliedChecksum) {
if (appliedChecksum !== migration.checksum) {
throw new Error(
`Migration checksum mismatch: ${migration.name}. Existing databases cannot safely apply a modified migration file.`
);
}
continue;
}
await client.query("BEGIN");
try {
await client.query(migration.sql);
await client.query(
`INSERT INTO "${MIGRATIONS_TABLE}" ("id", "checksum", "finished_at", "migration_name", "started_at", "applied_steps_count") VALUES ($1, $2, NOW(), $3, NOW(), 1)`,
[randomUUID(), migration.checksum, migration.name]
);
await client.query("COMMIT");
console.log(`[db:migrate] applied ${migration.name}`);
} catch (error) {
await client.query("ROLLBACK");
throw error;
}
}
} finally {
try {
await client.query("SELECT pg_advisory_unlock($1, $2)", [
LOCK_KEY_1,
LOCK_KEY_2,
]);
} catch {
// Ignore unlock failures during shutdown/error handling.
}
await client.end();
}
}
export async function ensureDatabaseMigrations() {
if (process.env.DATABASE_AUTO_MIGRATE === "false") {
return;
}
migrationTask ??= applyPendingMigrations();
return migrationTask;
}
```
## Optionale Lösung: Prisma CLI im Entrypoint aufrufen
Die `npx prisma migrate deploy`-Fähigkeit des Prisma CLI kann genutzt werden, um die Datenbanksynchronisation zu realisieren. Diese Methode erfordert jedoch die Installation des Prisma CLI im Container, was die Image-Größe erheblich erhöht und daher nicht empfohlen wird.
## Betrieb und Fehlerbehebung
| Phänomen | Mögliche UrsacheKommentar
Melde dich an, um Kommentare anzuzeigen und zu veröffentlichen
Zur Anmeldung