Настройка среды: VSCode + OpenCode + Dockerized Spark + Local Lakehouse

Собираем локальный production-like стенд: Spark + Iceberg + Nessie + MinIO + PostgreSQL через docker-compose, подключаем OpenCode CLI и настраиваем DE-workflow.

platform

Зачем локальный production-like стенд

Разрыв между обучением в cloud sandbox и реальной production-средой - одна из главных болей Data Engineer. В облаке один клик создаёт кластер, но не учит понимать, что внутри. Локальный стенд даёт:

  • Воспроизводимость: docker-compose up - и среда идентична у всей команды
  • Скорость итерации: тест нового кода за секунды, без ожидания кластера
  • Понимание архитектуры: видишь каждый компонент, его конфигурацию и логи
  • Безопасность: тестируешь на копии схемы, не трогая production
  • Офлайн-работа: среда работает без интернета

Целевая архитектура стенда

Роли компонентов:

Компонент Роль Аналог в production
Spark Вычислительный движок EMR, Dataproc, Databricks
MinIO Объектное хранилище (S3-совместимое) AWS S3, Google GCS, Azure ADLS
Apache Iceberg Табличный формат (ACID, time travel) Databricks Delta, Hudi
Project Nessie Каталог с Git-версионированием AWS Glue Catalog, Unity Catalog
PostgreSQL Хранилище метаданных Nessie + operational DB RDS, Cloud SQL
OpenCode AI-агент для разработки -

Компоненты стека: что и зачем

MinIO: локальный S3

MinIO - S3-совместимый объектный сервер с открытым исходным кодом. API полностью совместим с AWS S3 - один и тот же PySpark-код работает с MinIO и с S3 без изменений (только endpoint URL):

# Код работает одинаково с MinIO и с AWS S3
spark.read.parquet("s3a://data-lake/events/")
# MinIO: s3a://data-lake/events/ → http://minio:9000/data-lake/events/
# AWS S3: s3a://data-lake/events/ → https://s3.amazonaws.com/data-lake/events/

Project Nessie: Git для данных

Nessie - каталог метаданных Iceberg-таблиц с версионированием в стиле Git. Вместо Hive Metastore у него есть:

Это позволяет:

  • Создать ветку dev, поэкспериментировать со схемой, не сломав main
  • Сделать тег перед deployment - возможность откатиться
  • Изолировать разные пайплайны в разных ветках

Apache Iceberg: табличный формат

Iceberg - открытый табличный формат поверх Parquet/ORC/Avro. Добавляет:

  • ACID-транзакции: атомарный INSERT, UPDATE, DELETE
  • Time Travel: SELECT * FROM events FOR SYSTEM_TIME AS OF '2024-01-01'
  • Schema Evolution: добавление/переименование столбцов без перезаписи данных
  • Partition Evolution: изменение стратегии партиционирования без миграции

Физически таблица Iceberg в MinIO выглядит так:

s3a://warehouse/
└── db/
    └── events/
        ├── metadata/
        │   ├── v1.metadata.json   ← схема, партиции, снимки
        │   ├── v2.metadata.json
        │   └── snap-12345.avro    ← манифест снимка
        └── data/
            └── year=2024/month=01/
                ├── part-00000-abc.parquet
                └── part-00001-def.parquet

Docker Compose: полный файл

Создаём docker-compose.yml для нашего стенда:

version: "3.8"

networks:
  spark-net:
    driver: bridge

volumes:
  minio-data:
  postgres-data:
  nessie-data:

services:

  # ─────────────────────────────────────────
  # PostgreSQL: метаданные Nessie + source DB
  # ─────────────────────────────────────────
  postgres:
    image: postgres:15
    container_name: postgres
    networks: [spark-net]
    ports: ["5432:5432"]
    environment:
      POSTGRES_USER: spark
      POSTGRES_PASSWORD: spark
      POSTGRES_DB: nessie
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./init-scripts:/docker-entrypoint-initdb.d  # SQL для начальных данных
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "spark"]
      interval: 5s
      retries: 5

  # ─────────────────────────────────────────
  # MinIO: S3-совместимое хранилище
  # ─────────────────────────────────────────
  minio:
    image: minio/minio:latest
    container_name: minio
    networks: [spark-net]
    ports:
      - "9000:9000"   # S3 API
      - "9001:9001"   # Web Console
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: server /data --console-address ":9001"
    volumes:
      - minio-data:/data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 5s
      retries: 5

  # Создание bucket при старте
  minio-init:
    image: minio/mc:latest
    container_name: minio-init
    networks: [spark-net]
    depends_on:
      minio:
        condition: service_healthy
    entrypoint: >
      /bin/sh -c "
        mc alias set local http://minio:9000 minioadmin minioadmin &&
        mc mb local/warehouse --ignore-existing &&
        mc mb local/data-lake --ignore-existing &&
        echo 'Buckets created'
      "

  # ─────────────────────────────────────────
  # Project Nessie: Iceberg catalog
  # ─────────────────────────────────────────
  nessie:
    image: ghcr.io/projectnessie/nessie:0.74.0
    container_name: nessie
    networks: [spark-net]
    ports: ["19120:19120"]
    environment:
      QUARKUS_DATASOURCE_DB_KIND: postgresql
      QUARKUS_DATASOURCE_USERNAME: spark
      QUARKUS_DATASOURCE_PASSWORD: spark
      QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://postgres:5432/nessie
      NESSIE_VERSION_STORE_TYPE: JDBC
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:19120/api/v2/config"]
      interval: 10s
      retries: 10

  # ─────────────────────────────────────────
  # Spark Master
  # ─────────────────────────────────────────
  spark-master:
    image: bitnami/spark:3.5
    container_name: spark-master
    networks: [spark-net]
    ports:
      - "7077:7077"   # Spark RPC
      - "8080:8080"   # Spark Web UI
      - "15002:15002" # Spark Connect gRPC
    environment:
      SPARK_MODE: master
      SPARK_RPC_AUTHENTICATION_ENABLED: "no"
      SPARK_RPC_ENCRYPTION_ENABLED: "no"
      SPARK_LOCAL_STORAGE_ENCRYPTION_ENABLED: "no"
      SPARK_SSL_ENABLED: "no"
    volumes:
      - ./spark-defaults.conf:/opt/bitnami/spark/conf/spark-defaults.conf
      - ./jars:/opt/bitnami/spark/jars/custom  # Iceberg + Nessie + AWS JARs
    depends_on:
      nessie:
        condition: service_healthy
      minio:
        condition: service_healthy

  # ─────────────────────────────────────────
  # Spark Worker
  # ─────────────────────────────────────────
  spark-worker:
    image: bitnami/spark:3.5
    container_name: spark-worker
    networks: [spark-net]
    ports: ["8081:8081"]
    environment:
      SPARK_MODE: worker
      SPARK_MASTER_URL: spark://spark-master:7077
      SPARK_WORKER_MEMORY: 2G
      SPARK_WORKER_CORES: "2"
    volumes:
      - ./spark-defaults.conf:/opt/bitnami/spark/conf/spark-defaults.conf
      - ./jars:/opt/bitnami/spark/jars/custom
    depends_on:
      - spark-master

Конфигурация Spark: spark-defaults.conf

# spark-defaults.conf

# ── Spark Connect ──────────────────────────────────────────────
spark.plugins=org.apache.spark.sql.connect.SparkConnectPlugin

# ── Iceberg Extensions ────────────────────────────────────────
spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions

# ── Nessie Catalog ────────────────────────────────────────────
spark.sql.catalog.nessie=org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.nessie.catalog-impl=org.apache.iceberg.nessie.NessieCatalog
spark.sql.catalog.nessie.uri=http://nessie:19120/api/v1
spark.sql.catalog.nessie.ref=main
spark.sql.catalog.nessie.warehouse=s3a://warehouse/

# ── MinIO / S3 ────────────────────────────────────────────────
spark.hadoop.fs.s3a.endpoint=http://minio:9000
spark.hadoop.fs.s3a.access.key=minioadmin
spark.hadoop.fs.s3a.secret.key=minioadmin
spark.hadoop.fs.s3a.path.style.access=true
spark.hadoop.fs.s3a.impl=org.apache.hadoop.fs.s3a.S3AFileSystem
spark.hadoop.fs.s3a.connection.ssl.enabled=false

# ── Производительность ────────────────────────────────────────
spark.sql.adaptive.enabled=true
spark.sql.adaptive.coalescePartitions.enabled=true
spark.executor.memory=1g
spark.driver.memory=1g
spark.sql.shuffle.partitions=10  # Мало для локальной среды

# ── Логи ──────────────────────────────────────────────────────
spark.eventLog.enabled=false  # Для локальной разработки, включить при необходимости

Нужные JAR-файлы

Spark не включает Iceberg и AWS-коннекторы по умолчанию. Скачиваем в ./jars/:

# Скрипт для загрузки JAR'ов
SPARK_VERSION=3.5.0
ICEBERG_VERSION=1.5.0
NESSIE_VERSION=0.74.0

mkdir -p jars

# Iceberg runtime для Spark 3.5
curl -Lo jars/iceberg-spark-runtime-3.5_2.12-${ICEBERG_VERSION}.jar \
  https://repo1.maven.org/maven2/org/apache/iceberg/iceberg-spark-runtime-3.5_2.12/${ICEBERG_VERSION}/iceberg-spark-runtime-3.5_2.12-${ICEBERG_VERSION}.jar

# Nessie Spark extensions
curl -Lo jars/nessie-spark-extensions-3.5_2.12-${NESSIE_VERSION}.jar \
  https://repo1.maven.org/maven2/org/projectnessie/nessie-integrations/nessie-spark-extensions-3.5_2.12/${NESSIE_VERSION}/nessie-spark-extensions-3.5_2.12-${NESSIE_VERSION}.jar

# AWS Bundle (включает S3A FileSystem)
curl -Lo jars/hadoop-aws-3.3.4.jar \
  https://repo1.maven.org/maven2/org/apache/hadoop/hadoop-aws/3.3.4/hadoop-aws-3.3.4.jar

curl -Lo jars/aws-java-sdk-bundle-1.12.262.jar \
  https://repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-bundle/1.12.262/aws-java-sdk-bundle-1.12.262.jar

Запуск и проверка стека

# Клонируем проект (или создаём структуру)
mkdir lakehouse-dev && cd lakehouse-dev

# Запускаем все сервисы
docker-compose up -d

# Следим за запуском
docker-compose logs -f

# Проверяем статус
docker-compose ps
# Скрипт проверки всех endpoints
./scripts/health-check.sh

# Или вручную:
curl -s http://localhost:19120/api/v2/config | python3 -m json.tool
# Ожидаем: {"defaultBranch":"main","minSupportedApiVersion":"1","maxSupportedApiVersion":"2"}

curl -s http://localhost:9000/minio/health/live
# Ожидаем: 200 OK

docker-compose exec spark-master curl -s http://localhost:8080/json/
# Ожидаем: статус Spark Master в JSON

Настройка VSCode

Extensions для DE

# Установка через CLI
code --install-extension ms-python.python
code --install-extension ms-python.vscode-pylance
code --install-extension ms-toolsai.jupyter
code --install-extension ms-vscode-remote.remote-containers
code --install-extension ms-azuretools.vscode-docker
code --install-extension bradlc.vscode-tailwindcss  # если нужен SQL highlighting
code --install-extension mtxr.sqltools
code --install-extension hashicorp.terraform

Workspace settings

// .vscode/settings.json
{
  "python.defaultInterpreterPath": "./venv/bin/python",
  "python.terminal.activateEnvironment": true,
  "editor.formatOnSave": true,
  "python.formatting.provider": "black",
  "files.exclude": {
    "**/__pycache__": true,
    "**/*.pyc": true
  },
  "terminal.integrated.env.linux": {
    "SPARK_CONNECT_SERVER": "sc://localhost:15002",
    "POSTGRES_URL": "postgresql://spark:spark@localhost:5432/nessie"
  }
}

Launch configuration для Spark-приложений

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run PySpark via Spark Connect",
      "type": "python",
      "request": "launch",
      "program": "${file}",
      "env": {
        "SPARK_REMOTE": "sc://localhost:15002"
      }
    }
  ]
}

Настройка OpenCode CLI

# Установка
pip install opencode-ai
# или
npm install -g @opencode/cli

# Инициализация в проекте
cd lakehouse-dev
opencode init

# Конфигурация модели
opencode config set model claude-sonnet-4-5
export ANTHROPIC_API_KEY="your-key"

# Или локальная модель (без интернета):
ollama pull codellama:34b
opencode config set model ollama/codellama:34b

Конфигурация OpenCode для DE-стенда

// .opencode/config.json
{
  "model": "claude-sonnet-4-5",
  "context": {
    "include": [
      "*.py",
      "*.sql",
      "*.yaml",
      "*.yml",
      "docker-compose.yml",
      "spark-defaults.conf"
    ],
    "exclude": [
      "jars/",
      ".venv/",
      "__pycache__/"
    ]
  },
  "mcp": {
    "postgres": {
      "type": "postgres",
      "connection": "postgresql://spark:spark@localhost:5432/nessie"
    }
  },
  "skills": [
    ".opencode/skills/spark-debug.yaml",
    ".opencode/skills/nessie-ops.yaml"
  ]
}

Skill для Spark-отладки

# .opencode/skills/spark-debug.yaml
name: spark-debug
description: Анализирует логи контейнера Spark и предлагает исправления
steps:
  - name: get_logs
    tool: bash
    command: "docker-compose logs spark-master spark-worker --tail=100 2>&1"
  - name: check_connectivity
    tool: bash
    command: "docker-compose exec spark-master curl -s http://nessie:19120/api/v2/config"
  - name: analyze
    llm: true
    prompt: |
      Проанализируй логи Spark и состояние подключения к Nessie.
      Определи причину проблемы и предложи конкретное исправление.

Структура проекта

lakehouse-dev/
├── docker-compose.yml
├── spark-defaults.conf
├── jars/                          # Iceberg + Nessie + AWS JARs
│   ├── iceberg-spark-runtime-3.5_2.12-1.5.0.jar
│   ├── nessie-spark-extensions-3.5_2.12-0.74.0.jar
│   ├── hadoop-aws-3.3.4.jar
│   └── aws-java-sdk-bundle-1.12.262.jar
├── init-scripts/                  # SQL-инициализация PostgreSQL
│   └── 01-create-tables.sql
├── notebooks/                     # Jupyter notebooks
│   └── 01-first-iceberg-table.ipynb
├── spark_jobs/                    # PySpark скрипты
│   ├── config.py                  # Общая конфигурация SparkSession
│   └── first_pipeline.py
├── .opencode/
│   ├── config.json
│   └── skills/
│       ├── spark-debug.yaml
│       └── nessie-ops.yaml
├── .vscode/
│   ├── settings.json
│   └── launch.json
└── scripts/
    ├── download-jars.sh
    ├── health-check.sh
    └── reset-lakehouse.sh         # Сброс данных для чистого старта

Практика: первый сквозной pipeline

Шаг 1: Конфигурация SparkSession

# spark_jobs/config.py
from pyspark.sql import SparkSession

def create_spark_session(app_name: str = "LocalLakehouse") -> SparkSession:
    """Создаёт SparkSession с подключением к локальному Lakehouse через Spark Connect."""
    return SparkSession.builder \
        .remote("sc://localhost:15002") \
        .appName(app_name) \
        .getOrCreate()

# Альтернатива: прямое подключение к Spark Standalone (без Connect)
def create_spark_session_direct(app_name: str = "LocalLakehouse") -> SparkSession:
    return SparkSession.builder \
        .master("spark://localhost:7077") \
        .appName(app_name) \
        .getOrCreate()

Шаг 2: Инициализация тестовых данных в PostgreSQL

-- init-scripts/01-create-tables.sql
CREATE TABLE IF NOT EXISTS orders (
    order_id    SERIAL PRIMARY KEY,
    user_id     INTEGER NOT NULL,
    product_id  INTEGER NOT NULL,
    amount      DECIMAL(10, 2) NOT NULL,
    status      VARCHAR(20) DEFAULT 'pending',
    created_at  TIMESTAMP DEFAULT NOW()
);

INSERT INTO orders (user_id, product_id, amount, status) VALUES
    (1, 101, 299.99, 'completed'),
    (2, 102, 149.50, 'completed'),
    (1, 103, 89.00, 'pending'),
    (3, 101, 299.99, 'cancelled'),
    (2, 104, 1299.00, 'completed');

Шаг 3: Чтение из PostgreSQL и запись в Iceberg

# spark_jobs/first_pipeline.py
from config import create_spark_session

spark = create_spark_session("FirstPipeline")

# ── Чтение из PostgreSQL ──────────────────────────────────────
orders_df = spark.read.format("jdbc") \
    .option("url", "jdbc:postgresql://postgres:5432/nessie") \
    .option("dbtable", "orders") \
    .option("user", "spark") \
    .option("password", "spark") \
    .option("driver", "org.postgresql.Driver") \
    .load()

orders_df.show()
print(f"Загружено строк: {orders_df.count()}")

# ── Трансформация ─────────────────────────────────────────────
from pyspark.sql.functions import col, to_date, year, month

enriched = orders_df \
    .filter(col("status") == "completed") \
    .withColumn("order_date", to_date(col("created_at"))) \
    .withColumn("year", year(col("order_date"))) \
    .withColumn("month", month(col("order_date"))) \
    .select("order_id", "user_id", "product_id", "amount",
            "order_date", "year", "month")

# ── Создание Iceberg таблицы через Nessie ─────────────────────
# Создать БД (namespace) в каталоге Nessie
spark.sql("CREATE NAMESPACE IF NOT EXISTS nessie.shop")

# Записать как Iceberg таблицу (CTAS)
enriched.writeTo("nessie.shop.completed_orders") \
    .partitionedBy("year", "month") \
    .createOrReplace()

print("Таблица записана в Iceberg!")

# ── Проверка ──────────────────────────────────────────────────
spark.sql("SELECT * FROM nessie.shop.completed_orders").show()

# Посмотреть историю таблицы
spark.sql("SELECT * FROM nessie.shop.completed_orders.history").show()

Шаг 4: Time Travel и ветки Nessie

# ── Time Travel (Iceberg) ─────────────────────────────────────
# Получить snapshot_id из истории
snapshots = spark.sql("""
    SELECT snapshot_id, committed_at, operation
    FROM nessie.shop.completed_orders.snapshots
""")
snapshots.show()

first_snapshot = snapshots.first()["snapshot_id"]

# Прочитать данные на момент первого снимка
spark.sql(f"""
    SELECT * FROM nessie.shop.completed_orders
    VERSION AS OF {first_snapshot}
""").show()

# ── Ветки Nessie ──────────────────────────────────────────────
# Создать ветку для разработки
spark.sql("CREATE BRANCH dev IN nessie")

# Переключиться на ветку dev
spark.conf.set("spark.sql.catalog.nessie.ref", "dev")

# Изменить данные в ветке dev (не затрагивает main)
spark.sql("""
    INSERT INTO nessie.shop.completed_orders
    VALUES (99, 100, 999, 9999.99, '2024-12-01', 2024, 12)
""")

# Проверить: в dev строк больше, в main - без изменений
spark.conf.set("spark.sql.catalog.nessie.ref", "dev")
dev_count = spark.sql("SELECT COUNT(*) FROM nessie.shop.completed_orders").first()[0]

spark.conf.set("spark.sql.catalog.nessie.ref", "main")
main_count = spark.sql("SELECT COUNT(*) FROM nessie.shop.completed_orders").first()[0]

print(f"Dev branch: {dev_count} rows, Main branch: {main_count} rows")

Шаг 5: Проверка данных в MinIO

# Посмотреть структуру файлов в MinIO через mc (MinIO Client)
docker-compose exec minio-init mc ls local/warehouse/ --recursive

# Ожидаемый вывод:
# [2024-01-15 10:00:00]  1.2 KiB warehouse/shop/completed_orders/metadata/v1.metadata.json
# [2024-01-15 10:00:01]  2.4 KiB warehouse/shop/completed_orders/metadata/snap-12345.avro
# [2024-01-15 10:00:01]  450 KiB warehouse/shop/completed_orders/data/year=2024/month=1/part-00000.parquet

OpenCode в действии: примеры запросов

1. Отладка проблем с подключением

opencode

# > Spark-задание не может подключиться к Nessie. Прочитай логи и найди причину.

# Агент выполнит:
# → bash: docker-compose logs spark-master --tail=50
# → bash: docker-compose exec spark-master curl -s http://nessie:19120/api/v2/config
# → bash: docker-compose ps nessie
# → Диагностирует проблему (например: неверный hostname, порт, или Nessie не успел стартовать)
# → Предложит исправление в spark-defaults.conf или docker-compose.yml

2. Генерация кода для нового pipeline

opencode

# > Напиши PySpark-скрипт, который читает все таблицы из PostgreSQL (orders, users, products),
#   джойнит их, считает выручку по пользователям за текущий месяц
#   и записывает результат в Iceberg nessie.shop.revenue_by_user с партиционированием по дате.

# Агент:
# → Читает spark_jobs/config.py для понимания существующей конфигурации
# → Подключается к PostgreSQL MCP и проверяет реальные схемы таблиц
# → Пишет корректный PySpark-код с реальными именами столбцов
# → Добавляет типизацию, обработку null, оптимальный partitioning

3. Исследование данных в Lakehouse

opencode

# > Покажи, сколько данных в каждой партиции таблицы nessie.shop.completed_orders,
#   и нет ли data skew.

# Агент:
# → bash: spark-sql -e "SELECT year, month, COUNT(*) as cnt FROM nessie.shop.completed_orders GROUP BY 1,2"
# → Анализирует распределение
# → Выявляет skew если есть
# → Предлагает решение (repartition, salting)

Troubleshooting: типичные проблемы

Лимиты памяти Docker

Платформа Рекомендуемые ресурсы Docker Минимум
Mac (Docker Desktop) 8 GB RAM, 4 CPU 4 GB RAM
Windows (WSL2) 8 GB RAM, 4 CPU 4 GB RAM
Linux По умолчанию = вся RAM 4 GB свободной RAM
# docker-compose.yml: лимиты для Spark Worker на слабых машинах
spark-worker:
  deploy:
    resources:
      limits:
        memory: 2G
  environment:
    SPARK_WORKER_MEMORY: 1G  # оставляем 1G системе
    SPARK_WORKER_CORES: "2"

Сброс состояния Lakehouse

# scripts/reset-lakehouse.sh
#!/bin/bash
echo "Сброс Lakehouse (удаление всех данных)..."

# Остановить сервисы
docker-compose down

# Удалить volumes (все данные MinIO, Postgres, Nessie)
docker volume rm lakehouse-dev_minio-data lakehouse-dev_postgres-data lakehouse-dev_nessie-data

# Пересоздать с нуля
docker-compose up -d

echo "Lakehouse сброшен и запущен заново"

Reproducible Environment: Environment-as-Code

Весь стенд версионируется в Git:

git init
git add docker-compose.yml spark-defaults.conf scripts/ .opencode/ .vscode/
git commit -m "feat: local lakehouse setup"

# .gitignore
cat > .gitignore << EOF
jars/         # бинарные файлы, качаем скриптом
.venv/
__pycache__/
*.pyc
.env          # секреты не в гит!
EOF

Новый разработчик в команде:

git clone https://github.com/team/lakehouse-dev
cd lakehouse-dev
bash scripts/download-jars.sh    # качает нужные JAR'ы
cp .env.example .env             # заполнить API ключи
docker-compose up -d
# → полностью рабочий стенд через 5 минут

Резюме

Собранный стенд воспроизводит ключевые компоненты production Data Platform:

Знания, полученные на этом стенде, переносятся напрямую в production - та же конфигурация Iceberg, те же JAR-зависимости, тот же Nessie API. Меняется только endpoint: minio:9000s3.amazonaws.com, nessie:19120production-nessie.internal.