Skip to content

Latest commit

 

History

History
106 lines (78 loc) · 3.65 KB

File metadata and controls

106 lines (78 loc) · 3.65 KB

Pattern 1.4 — Container Isolation

Container Isolation

Problem

Running Docker-based tests concurrently creates collision chaos: containers share names, ports conflict, volumes leak state, and Docker hits hard resource limits. Tests pass in isolation, fail in parallel. CI becomes flaky. Developers lose trust.

Solution

Container isolation provides four mechanisms to ensure tests never interfere:

  1. Unique container names: {test-name}-{pid}-{random}-{service} guarantees no name collisions
  2. Dynamic port allocation: Check available ports from range (10000-65535), assign only confirmed-free ports
  3. Transient volumes: Named volumes disappear with docker compose down -v — no state leakage
  4. Per-test compose files: docker-compose-{test-name}-{pid}-{random}-{timestamp}.yml ensures file-level isolation

Beyond collision prevention, isolation solves Docker resource exhaustion. Docker has hard limits: ~31 networks per bridge driver, finite containers and volumes. Without cleanup hygiene, concurrent tests exhaust these limits and crash the Docker daemon. Aggressive cleanup (down -v, volume removal, network pruning) combined with isolation prevents resource exhaustion.

In Practice

Port allocation utility:

async function allocatePorts(count: number): Promise<number[]> {
  const ports: number[] = [];
  const portRange = { min: 10000, max: 65535 };

  for (let i = 0; i < count; i++) {
    let port: number;
    let available = false;
    let attempts = 0;

    while (!available && attempts < 100) {
      port = randomInt(portRange.min, portRange.max);
      available = await isPortAvailable(port);
      attempts++;
    }

    if (!available) {
      throw new Error(`Could not allocate ${count} ports`);
    }
    ports.push(port);
  }

  return ports;
}

Compose file generation:

function generateComposeFile(testName: string, ports: Record<string, number>): string {
  const filename = `docker-compose-${testName}-${process.pid}-${randomBytes(4).toString('hex')}-${Date.now()}.yml`;

  const content = `
version: '3.8'
services:
  app:
    container_name: ${testName}-${process.pid}-${randomBytes(4).toString('hex')}-app
    ports:
      - "${ports.app}:3000"
    environment:
      - DB_HOST=postgres
      - DB_PORT=${ports.postgres}
    # ...
  postgres:
    container_name: ${testName}-${process.pid}-${randomBytes(4).toString('hex')}-postgres
    ports:
      - "${ports.postgres}:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
volumes:
  postgres-data:
`;

  await writeFile(filename, content);
  return filename;
}

Cleanup is critical:

async function cleanup(composeFile: string): Promise<void> {
  await exec('docker', ['compose', '-f', composeFile, 'down', '-v', '--remove-orphans']);
  await unlink(composeFile);
}

Anti-Pattern

Don't use hardcoded ports like 3000, 5432, 6379. These collide on the developer's machine and in CI. Always allocate dynamically.

Don't use shared volumes for "performance." State leakage causes flaky tests. Transient volumes are slightly slower but reliable.

Don't rely on Docker's auto-cleanup. Networks and orphaned containers accumulate. Explicit removal in every test's teardown is required.

Cross-References


Back to L1 Overview | Previous: Pattern 1.3 | Next: Pattern 1.5