32 Mongo Shell Scripting: Von Ad-hoc zu Automatisierung

mongosh ist nicht nur ein interaktives Tool, sondern eine vollwertige JavaScript-Runtime für MongoDB-Automatisierung. Was in der Shell interaktiv funktioniert, kann in Scripts verpackt und automatisiert werden – Daten-Migrationen, Backup-Verifications, Cleanup-Jobs, Report-Generierung, Schema-Validierungen. Für Teams, die MongoDB in Produktion betreiben, ist Scripting nicht optional, sondern essentiell.

Der Unterschied zwischen “schnell etwas in der Shell ausprobieren” und “robustes Production-Script schreiben” ist erheblich. Interactive Commands können fehlschlagen und man sieht den Error sofort. Scripts laufen unbeaufsichtigt, oft in CI/CD-Pipelines oder Cron-Jobs. Sie müssen robust sein – Fehler handhaben, loggen, sauber aufräumen und mit aussagekräftigen Exit-Codes terminieren. Dieses Kapitel behandelt, wie man von simplen Scripts zu production-ready Automation kommt.

32.1 Das erste Script: Von der Shell zur Datei

Der Übergang von interaktiver Shell zu Script ist trivial. Was in mongosh interaktiv funktioniert, funktioniert in einer .js-Datei:

// insert-users.js
db.users.insertMany([
  { username: "alice", email: "alice@example.com", createdAt: new Date() },
  { username: "bob", email: "bob@example.com", createdAt: new Date() },
  { username: "charlie", email: "charlie@example.com", createdAt: new Date() }
])

print("Inserted 3 users")

Ausführung:

mongosh localhost:27017/myapp --quiet --file insert-users.js

Das --quiet-Flag unterdrückt den mongosh-Banner und Startup-Messages, was Output cleaner macht für Scripting. Das --file-Flag lädt und executed die Datei.

Die Alternative zu --file ist Stdin-Redirection:

mongosh localhost:27017/myapp < insert-users.js

Beide Methoden funktionieren identisch. Die --file-Syntax ist expliziter und moderner.

32.2 Script-Struktur: Von linear zu modular

Einfache Scripts sind linear – eine Sequenz von Statements. Aber komplexere Tasks profitieren von Modularisierung: Funktionen, die einzelne Aufgaben kapseln, wiederverwendbar sind und testbar.

// migration.js
// Funktion für einzelne Migration
function addCountryFieldToUsers() {
  const result = db.users.updateMany(
    { country: { $exists: false } },
    { $set: { country: "UNKNOWN" } }
  )
  
  print(`Added country field to ${result.modifiedCount} users`)
  return result.modifiedCount
}

// Funktion für Validation
function validateAllUsersHaveCountry() {
  const missingCount = db.users.countDocuments({ 
    country: { $exists: false } 
  })
  
  if (missingCount > 0) {
    throw new Error(`${missingCount} users still missing country field`)
  }
  
  print("Validation passed: All users have country field")
}

// Haupt-Workflow
print("Starting migration...")
const modifiedCount = addCountryFieldToUsers()

print("Validating migration...")
validateAllUsersHaveCountry()

print("Migration completed successfully")

Diese Struktur ist testbar – jede Funktion kann isoliert gecalled werden. Sie ist auch lesbar – der Haupt-Workflow ist klar, die Details sind in Funktionen gekapselt.

32.3 Error Handling: Robust gegen Failures

Scripts, die in Production laufen, müssen Fehler graceful handhaben. Ein Crash mitten in einer Migration kann inkonsistente Daten hinterlassen. JavaScript’s try-catch ist essentiell:

// robust-insert.js
function insertUser(userData) {
  try {
    const result = db.users.insertOne(userData)
    print(`Inserted user: ${userData.username}`)
    return { success: true, id: result.insertedId }
  } catch (error) {
    print(`ERROR inserting user ${userData.username}: ${error.message}`)
    return { success: false, error: error.message }
  }
}

const usersToInsert = [
  { username: "alice", email: "alice@example.com" },
  { username: "bob", email: "bob@example.com" },
  { username: "alice", email: "alice2@example.com" }  // Duplicate username
]

const results = usersToInsert.map(insertUser)

// Summary
const successCount = results.filter(r => r.success).length
const failureCount = results.filter(r => !r.success).length

print(`\nSummary: ${successCount} succeeded, ${failureCount} failed`)

if (failureCount > 0) {
  print("Some inserts failed. Check errors above.")
  quit(1)  // Exit mit Error-Code
}

Dieses Script versucht, alle Users einzufügen, selbst wenn manche fehlschlagen (etwa wegen Duplicate-Key-Errors). Es sammelt Results, gibt ein Summary und exitiert mit Error-Code wenn Failures auftraten. Dies ist robuster als “fail on first error” – man sieht alle Probleme auf einmal, nicht eins nach dem anderen.

32.4 Exit Codes: Success oder Failure signalisieren

In CI/CD-Pipelines oder Cron-Jobs muss ein Script seinen Success-Status kommunizieren. Unix-Convention: Exit-Code 0 bedeutet Success, Non-Zero bedeutet Failure. mongosh unterstützt dies mit quit():

// check-health.js
const userCount = db.users.countDocuments()
const minExpectedUsers = 100

if (userCount < minExpectedUsers) {
  print(`ERROR: Only ${userCount} users, expected at least ${minExpectedUsers}`)
  quit(1)
}

print(`Health check passed: ${userCount} users found`)
quit(0)

Shell-Scripts können den Exit-Code prüfen:

#!/bin/bash
mongosh localhost:27017/myapp --quiet --file check-health.js

if [ $? -ne 0 ]; then
  echo "Health check failed! Alerting team..."
  # Send alert
  exit 1
fi

echo "Health check passed"

Der $? in Bash ist der Exit-Code des letzten Commands. Non-Zero triggert das Alert-System.

32.5 Parameter übergeben: Scripts konfigurierbar machen

Hardcoded-Values in Scripts sind inflexibel. Was wenn man denselben Migration-Script in Dev, Staging und Production laufen will, aber mit unterschiedlichen Parametern?

mongosh-Scripts können auf Environment-Variables zugreifen:

// configurable-cleanup.js
const daysThreshold = parseInt(process.env.DAYS_THRESHOLD || "90")
const dryRun = process.env.DRY_RUN === "true"

print(`Cleanup configuration:`)
print(`  Days threshold: ${daysThreshold}`)
print(`  Dry run: ${dryRun}`)

const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - daysThreshold)

print(`\nFinding old records (before ${cutoffDate.toISOString()})...`)
const oldRecords = db.logs.find({ 
  createdAt: { $lt: cutoffDate } 
}).toArray()

print(`Found ${oldRecords.length} old records`)

if (dryRun) {
  print("DRY RUN: Would delete these records (but not actually deleting)")
  quit(0)
}

print("Deleting old records...")
const result = db.logs.deleteMany({ 
  createdAt: { $lt: cutoffDate } 
})

print(`Deleted ${result.deletedCount} records`)

Nutzung:

# Dry run mit 30 Tagen
DAYS_THRESHOLD=30 DRY_RUN=true mongosh --quiet --file configurable-cleanup.js

# Production run mit 90 Tagen
DAYS_THRESHOLD=90 DRY_RUN=false mongosh --quiet --file configurable-cleanup.js

Environment-Variables sind der idiomatische Weg für Script-Konfiguration in Unix-Umgebungen. Sie integrieren sauber mit CI/CD-Systemen, die oft Environment-Variables für Configuration nutzen.

32.6 Load: Modulare Scripts mit Wiederverwendung

Größere Projekte sollten Code nicht duplizieren. Die load()-Funktion erlaubt, JavaScript-Files zu importieren:

// utils.js
function logWithTimestamp(message) {
  const timestamp = new Date().toISOString()
  print(`[${timestamp}] ${message}`)
}

function countDocuments(collectionName) {
  return db.getCollection(collectionName).countDocuments()
}
// main-script.js
load("./utils.js")

logWithTimestamp("Starting migration...")

const userCount = countDocuments("users")
logWithTimestamp(`Found ${userCount} users`)

// ... rest of script

Die load()-Function executed die File im Current-Scope. Alle Funktionen und Variablen aus utils.js sind in main-script.js verfügbar. Dies ist kein ES6-Module-System (kein import/export), sondern einfache Script-Inclusion, aber es funktioniert für Modularisierung.

Der relative Path zu load() ist relativ zum Current-Working-Directory, nicht zur Script-File. Für robustere Path-Handling kann man __dirname-ähnliche Patterns emulieren:

// Nicht direkt in mongosh verfügbar, aber pattern:
const scriptDir = "./scripts"
load(scriptDir + "/utils.js")

32.7 Iterationen und Bulk-Operations: Effizient über viele Dokumente

Ein häufiger Use-Case: Iteriere über alle Dokumente einer Collection, transformiere sie, update sie. Die naive Implementierung ist ineffizient:

// Langsam: Einzelne Updates
db.users.find().forEach(user => {
  db.users.updateOne(
    { _id: user._id },
    { $set: { updatedAt: new Date() } }
  )
})

Für jedes Dokument: Ein Find, ein Update – tausende Roundtrips bei großen Collections. Besser: Batch-Updates mit Aggregation oder Bulk-Operations:

// Schneller: Single Bulk Update
db.users.updateMany(
  {},  // Alle Dokumente
  { $set: { updatedAt: new Date() } }
)

Wenn die Transformation komplex ist und nicht als Update-Operator ausdrückbar, nutze Bulk-Write-API:

// Bulk-Write für komplexe Transformationen
const operations = []

db.users.find().forEach(user => {
  const newEmail = user.email.toLowerCase()  // Transformation
  operations.push({
    updateOne: {
      filter: { _id: user._id },
      update: { $set: { email: newEmail } }
    }
  })
  
  // Flush alle 1000 Operations
  if (operations.length >= 1000) {
    db.users.bulkWrite(operations)
    print(`Processed ${operations.length} documents`)
    operations.length = 0  // Clear array
  }
})

// Flush remaining
if (operations.length > 0) {
  db.users.bulkWrite(operations)
  print(`Processed final ${operations.length} documents`)
}

Diese Batching-Strategy reduziert Roundtrips dramatisch. Statt 1 Million einzelner Updates gibt es 1000 Batch-Updates mit je 1000 Operations. Die Performance-Differenz ist Größenordnungen.

32.8 Transactions: Atomic Multi-Document-Operations

Für Operations, die mehrere Collections oder Dokumente atomar ändern müssen, nutzt man Transactions:

// transfer-balance.js
const session = db.getMongo().startSession()

try {
  session.startTransaction()
  
  const fromAccount = session.getDatabase("bank").accounts.findOne(
    { accountId: "A123" },
    { session }
  )
  
  const toAccount = session.getDatabase("bank").accounts.findOne(
    { accountId: "B456" },
    { session }
  )
  
  const amount = 100
  
  if (fromAccount.balance < amount) {
    throw new Error("Insufficient balance")
  }
  
  session.getDatabase("bank").accounts.updateOne(
    { accountId: "A123" },
    { $inc: { balance: -amount } },
    { session }
  )
  
  session.getDatabase("bank").accounts.updateOne(
    { accountId: "B456" },
    { $inc: { balance: amount } },
    { session }
  )
  
  session.commitTransaction()
  print(`Transferred ${amount} from A123 to B456`)
  
} catch (error) {
  print(`ERROR: ${error.message}`)
  session.abortTransaction()
  quit(1)
} finally {
  session.endSession()
}

Transactions garantieren, dass entweder beide Updates passieren oder keine. Im Fehlerfall (Insufficient Balance, Netzwerk-Fehler) werden alle Änderungen rollbacked.

Wichtig: Die session-Parameter überall – findOne(), updateOne() etc. Ohne Session sind die Operations nicht Teil der Transaction.

32.9 Logging und Debugging: Visibility in Scripts

Production-Scripts sollten ausführlich loggen, was sie tun. Dies hilft beim Debugging wenn etwas schiefgeht. MongoDB hat keine native Logging-Library in Scripts, aber man kann einfache Patterns nutzen:

// logger.js
const LOG_LEVEL = process.env.LOG_LEVEL || "INFO"

const levels = {
  DEBUG: 0,
  INFO: 1,
  WARN: 2,
  ERROR: 3
}

function log(level, message) {
  if (levels[level] >= levels[LOG_LEVEL]) {
    const timestamp = new Date().toISOString()
    print(`[${timestamp}] [${level}] ${message}`)
  }
}

function debug(msg) { log("DEBUG", msg) }
function info(msg) { log("INFO", msg) }
function warn(msg) { log("WARN", msg) }
function error(msg) { log("ERROR", msg) }

Nutzung:

load("./logger.js")

info("Starting data migration")
debug(`Connection to ${db.getMongo().host}`)

const count = db.users.countDocuments()
info(`Found ${count} users to migrate`)

// ... migration logic

info("Migration completed successfully")

Mit LOG_LEVEL=DEBUG sieht man detaillierte Logs, mit LOG_LEVEL=ERROR nur Errors. Dies ist nützlich in Production (weniger Noise) vs. Debugging (mehr Details).

32.10 Real-World Use-Case: Data Migration Script

Ein realistisches Migrations-Script kombiniert all diese Patterns:

// migrate-user-schema-v2.js
load("./logger.js")
load("./utils.js")

const DRY_RUN = process.env.DRY_RUN === "true"
const BATCH_SIZE = parseInt(process.env.BATCH_SIZE || "1000")

info("=== User Schema Migration v2 ===")
info(`Configuration: DRY_RUN=${DRY_RUN}, BATCH_SIZE=${BATCH_SIZE}`)

// Validation
function validatePreconditions() {
  info("Validating preconditions...")
  
  const usersCount = db.users.countDocuments()
  if (usersCount === 0) {
    throw new Error("No users found - wrong database?")
  }
  
  info(`Found ${usersCount} users`)
}

// Migration Logic
function migrateUsers() {
  info("Starting user migration...")
  
  let processedCount = 0
  let migratedCount = 0
  const operations = []
  
  db.users.find({ 
    schemaVersion: { $ne: 2 } 
  }).forEach(user => {
    processedCount++
    
    // Transform: Add fullName field
    const fullName = `${user.firstName || ""} ${user.lastName || ""}`.trim()
    
    // Transform: Normalize email
    const email = user.email ? user.email.toLowerCase() : null
    
    operations.push({
      updateOne: {
        filter: { _id: user._id },
        update: {
          $set: {
            fullName: fullName,
            email: email,
            schemaVersion: 2,
            migratedAt: new Date()
          }
        }
      }
    })
    
    // Batch execute
    if (operations.length >= BATCH_SIZE) {
      if (!DRY_RUN) {
        const result = db.users.bulkWrite(operations)
        migratedCount += result.modifiedCount
      }
      
      info(`Processed ${processedCount} users (${migratedCount} migrated)`)
      operations.length = 0
    }
  })
  
  // Flush remaining
  if (operations.length > 0) {
    if (!DRY_RUN) {
      const result = db.users.bulkWrite(operations)
      migratedCount += result.modifiedCount
    }
  }
  
  info(`Total: Processed ${processedCount}, Migrated ${migratedCount}`)
  return { processedCount, migratedCount }
}

// Post-Migration Validation
function validateMigration() {
  info("Validating migration results...")
  
  const unmigrated = db.users.countDocuments({ 
    schemaVersion: { $ne: 2 } 
  })
  
  if (unmigrated > 0) {
    throw new Error(`${unmigrated} users still on old schema!`)
  }
  
  info("Validation passed: All users on schema v2")
}

// Main Execution
try {
  validatePreconditions()
  
  const results = migrateUsers()
  
  if (DRY_RUN) {
    info("DRY RUN: No changes made to database")
    info(`Would have migrated ${results.processedCount} users`)
  } else {
    validateMigration()
    info("Migration completed successfully!")
  }
  
  quit(0)
  
} catch (error) {
  error(`FATAL: ${error.message}`)
  error(error.stack)
  quit(1)
}

Dieses Script: - Nutzt Environment-Variables für Configuration - Logged ausführlich mit Timestamps - Batched Updates für Performance - Unterstützt Dry-Run-Mode - Validiert Preconditions und Post-Conditions - Handelt Errors gracefully mit Exit-Codes

Für Production-Deployments würde man es in einem Wrapper-Script callen:

#!/bin/bash
# run-migration.sh

set -e  # Exit on error

echo "=== Starting Migration ==="

# Dry run first
echo "Running dry run..."
DRY_RUN=true LOG_LEVEL=INFO mongosh "$MONGODB_URI" \
  --quiet --file migrate-user-schema-v2.js

echo ""
read -p "Dry run completed. Proceed with actual migration? (yes/no) " confirm

if [ "$confirm" != "yes" ]; then
  echo "Migration aborted by user"
  exit 0
fi

# Actual migration
echo "Running actual migration..."
DRY_RUN=false LOG_LEVEL=INFO mongosh "$MONGODB_URI" \
  --quiet --file migrate-user-schema-v2.js

echo ""
echo "=== Migration Completed ==="

Dieses Wrapper-Script führt erst einen Dry-Run aus, wartet auf User-Confirmation und führt dann die eigentliche Migration aus. Für automatisierte Environments würde man die Confirmation skippen.

32.11 Performance Considerations: Scripts optimieren

Große Collections erfordern Performance-Awareness in Scripts:

Index Nutzen: Queries in Scripts sollten indiziert sein. Ein Script, das Millionen Dokumente scannt, ist inakzeptabel langsam:

// Langsam: Full Collection Scan
db.users.find({ email: "alice@example.com" }).forEach(...)

// Schnell: Nutzt Index auf email
db.users.createIndex({ email: 1 })  // Falls noch nicht existiert
db.users.find({ email: "alice@example.com" }).forEach(...)

Projection nutzen: Wenn man nur wenige Felder braucht, nicht alle laden:

// Lädt gesamte Dokumente (potentiell MB pro Dokument)
db.users.find().forEach(user => { /* ... */ })

// Lädt nur benötigte Felder
db.users.find({}, { username: 1, email: 1 }).forEach(user => { /* ... */ })

Cursor-Timeouts handhaben: Lange-laufende Scripts können Cursor-Timeouts erleben. Der Default-Timeout ist 10 Minuten. Für sehr lange Operationen:

// Disable Cursor Timeout
db.users.find().noCursorTimeout().forEach(user => {
  // Lange-laufende Verarbeitung
})

Aber: noCursorTimeout() bedeutet, der Cursor bleibt offen bis manuell geschlossen. Bei Crashes oder Script-Fehlern können Cursors leaken. Besser: Design Scripts so, dass sie innerhalb Timeout fertig sind, oder verwende Resumable-Patterns.

32.12 CI/CD Integration: Scripts in Pipelines

MongoDB-Scripts integrieren natürlich in CI/CD-Pipelines. Ein typisches Szenario: Automatische Migrations beim Deployment.

# .gitlab-ci.yml
migrate_db:
  stage: deploy
  script:
    - echo "Running database migration..."
    - mongosh "$MONGODB_URI" --quiet --file migrations/v2-add-country-field.js
    - echo "Migration completed"
  only:
    - main
  environment:
    name: production

Die Pipeline führt das Migrations-Script bei jedem Deployment aus. Der Exit-Code des Scripts bestimmt, ob die Pipeline succeeds oder fails.

Die folgende Tabelle fasst Best Practices zusammen:

Aspekt Bad Practice Good Practice
Error Handling Keine try-catch Umfassende try-catch mit Logging
Exit Codes Immer exit 0 exit 0 bei Success, 1+ bei Failure
Configuration Hardcoded Values Environment Variables
Logging Kein oder minimales Logging Strukturiertes Logging mit Levels
Performance Einzelne Updates in Loop Batch-Operations
Testing Direkt in Production Dry-Run-Mode, Test in Staging
Validation Keine Post-Checks Pre- und Post-Validierung
Modularität Monolithische Scripts Funktionen, load() für Reuse

MongoDB Shell Scripting ist mächtig, aber mit Power kommt Verantwortung. Scripts, die in Production laufen, können Daten korrumpieren, Performance beeinträchtigen oder Downtime verursachen wenn schlecht geschrieben. Die Best Practices – robustes Error-Handling, ausführliches Logging, Dry-Run-Support, Validation, Performance-Awareness – sind nicht optional für ernsthafte Automation. Der Unterschied zwischen einem Quick-Hack-Script und einem Production-Ready-Script ist die Disziplin, diese Patterns konsequent anzuwenden.