Настройка среды: VSCode + OpenCode + Dockerized Spark + Local Lakehouse
Собираем локальный production-like стенд: Spark + Iceberg + Nessie + MinIO + PostgreSQL через docker-compose, подключаем OpenCode CLI и настраиваем DE-workflow.
Зачем локальный 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:9000 → s3.amazonaws.com, nessie:19120 → production-nessie.internal.