feat(course): add SparkSession setup practice to module 3
This commit is contained in:
1 parent
00942c255f
commit
7c5e976405
10 files changed
+523
-22
No files matched your search
@@ -9,13 +9,14 @@
|
||||
"\n",
|
||||
"В этом модуле мы разбираем полный первый цикл работы с учебным датасетом в Lakehouse:\n",
|
||||
"\n",
|
||||
"- создаём Spark-сессию для кластера и проверяем её настройки;\n",
|
||||
"- проверяем заранее скачанный локальный data bundle;\n",
|
||||
"- загружаем исходные файлы в raw-зону `MinIO`;\n",
|
||||
"- читаем raw-данные через `Spark` по `s3a://` пути;\n",
|
||||
"- смотрим схему, типы, базовые метрики и простые аномалии;\n",
|
||||
"- фиксируем, почему raw-слой нужен как воспроизводимая неизменяемая точка входа.\n",
|
||||
"\n",
|
||||
"После этого ноутбука ты должен уметь объяснить, почему raw-данные не надо «чинить на месте» и почему их не стоит скачивать прямо из ноутбука.\n"
|
||||
"После этого ноутбука ты должен уметь объяснить, почему raw-данные не надо «чинить на месте» и почему их не стоит скачивать прямо из ноутбука."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -29,7 +30,8 @@
|
||||
"\n",
|
||||
"- подними стенд по `START_HERE.md`;\n",
|
||||
"- пройди `notebooks/01_environment_and_smoke_test.ipynb`;\n",
|
||||
"- желательно пройти `notebooks/02_lakehouse_mental_model.ipynb`;\n",
|
||||
"- пройди `notebooks/02_lakehouse_mental_model.ipynb`;\n",
|
||||
"- начни с нового ядра, чтобы настройки не зависели от предыдущих опытов;\n",
|
||||
"- заранее скачай data bundle в `./data/nyc_taxi` по инструкции из `START_HERE.md`.\n",
|
||||
"\n",
|
||||
"В классическом `PostgreSQL / Greenplum` данные часто уже лежат внутри системы. В Lakehouse путь более явный: сначала исходные файлы должны попасть в object storage, и только потом мы начинаем поверх них читать, профилировать и строить таблицы.\n",
|
||||
@@ -38,7 +40,334 @@
|
||||
"| --- | --- | --- |\n",
|
||||
"| Где стартует работа | Данные уже внутри СУБД | Исходные файлы сначала попадают в storage |\n",
|
||||
"| Первая практическая задача | `SELECT` из готовой таблицы | Загрузить raw и прочитать raw |\n",
|
||||
"| Что важно не сломать | Таблицу / схему БД | Неизменяемый raw-слой |\n"
|
||||
"| Что важно не сломать | Таблицу / схему БД | Неизменяемый raw-слой |"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "session-intro",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 0.1. Как настроен Spark в нашем стенде\n",
|
||||
"\n",
|
||||
"Отведи на эту практику 20–30 минут. Сначала разберём готовый пример, затем ты заполнишь шаблон и проверишь подключение. Файлы Taxi понадобятся после практики.\n",
|
||||
"\n",
|
||||
"`SparkSession` — объект `spark`, через который мы читаем DataFrame и выполняем SQL. Внутри него `SparkContext` связывает приложение с вычислительными ресурсами. В обычном ядре нашего ноутбука используется один работающий контекст.\n",
|
||||
"\n",
|
||||
"| Участник | Что делает в этом стенде |\n",
|
||||
"| --- | --- |\n",
|
||||
"| Driver | Работает в контейнере `jupyter`: выполняет Python-код ноутбука, строит план и раздаёт задания |\n",
|
||||
"| Master | Принимает подключение по `spark://spark-master:7077` и выделяет ресурсы worker-ов |\n",
|
||||
"| Executors | Запускаются на `spark-worker-1` и `spark-worker-2`, читают данные и выполняют задания |\n",
|
||||
"\n",
|
||||
"В режиме `local[*]` Spark выполняет задания локально, используя потоки на машине driver-а. В нашем курсе нужен кластер: успешный `count()` сам по себе ещё не доказывает, что подключение правильное.\n",
|
||||
"\n",
|
||||
"### Откуда берутся настройки\n",
|
||||
"\n",
|
||||
"Для новой сессии стенд читает `spark/spark-defaults.conf`, смонтированный в Jupyter как `/opt/spark/conf/spark-defaults.conf`. В нём уже заданы master, каталог и доступ к MinIO.\n",
|
||||
"\n",
|
||||
"При запуске приложения через `spark-submit` можно передать `--master`, `--name` и `--conf`. В коде похожие настройки задают методы builder-а: `.master(...)`, `.appName(...)`, `.config(...)`. Обычно приоритет такой: код → параметры запуска → файл defaults. Но уже запущенный контекст не перестраивается от нового вызова builder-а, а параметры запуска driver-а надо передавать до его старта.\n",
|
||||
"\n",
|
||||
"В нашем Jupyter дополнительные `--master`/`--conf` при запуске не передаются. Готовый пример ниже берёт master из файла, а имя задаёт в коде. Вне стенда понадобятся также библиотеки Iceberg/S3A/JDBC и конфигурация каталога: одного адреса master недостаточно. Пути и состав образа описаны в [справочнике стенда](../docs/stack_reference.md#spark)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "session-demo",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"from pyspark.sql import SparkSession\n",
|
||||
"\n",
|
||||
"spark = (\n",
|
||||
" SparkSession.builder\n",
|
||||
" .appName(\"module-03-session-demo\")\n",
|
||||
" .getOrCreate()\n",
|
||||
")\n",
|
||||
"spark.sparkContext.setLogLevel(\"ERROR\")\n",
|
||||
"\n",
|
||||
"print(\"Spark:\", spark.version)\n",
|
||||
"print(\"Master:\", spark.sparkContext.master)\n",
|
||||
"print(\"Приложение:\", spark.sparkContext.appName)\n",
|
||||
"assert spark.sparkContext.master == \"spark://spark-master:7077\", \"Нужен Spark-кластер стенда\"\n",
|
||||
"print(\"Сумма 0…9:\", spark.range(10).groupBy().sum(\"id\").first()[0])"
|
||||
],
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "session-config-explanation",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"`appName` — имя приложения, видимое в Spark UI. `getOrCreate()` создаёт сессию, если её ещё нет, или возвращает существующую. `.master(...)` выбирает вычислитель; в демонстрации он не указан, поэтому используется общий файл.\n",
|
||||
"\n",
|
||||
"### Что уже подготовлено для Iceberg и MinIO\n",
|
||||
"\n",
|
||||
"| Настройка в `spark-defaults.conf` | Назначение |\n",
|
||||
"| --- | --- |\n",
|
||||
"| `spark.sql.extensions` | Добавляет команды Iceberg к Spark SQL |\n",
|
||||
"| `spark.sql.catalog.lakehouse` | Подключает Spark-каталог с именем `lakehouse` |\n",
|
||||
"| `spark.sql.catalog.lakehouse.catalog-impl` и `.uri` | Выбирает JDBC-каталог в PostgreSQL, где зарегистрированы таблицы |\n",
|
||||
"| `spark.sql.catalog.lakehouse.warehouse` | Задаёт место для файлов таблиц в MinIO: `s3a://lakehouse/warehouse` |\n",
|
||||
"| `spark.hadoop.fs.s3a.endpoint` и `.path.style.access` | Направляют S3A-запросы в MinIO внутри Docker-сети |\n",
|
||||
"\n",
|
||||
"В том же файле находятся настройки доступа к PostgreSQL и MinIO. Выведем только выбранные несекретные значения. SQL-настройки читаем через `spark.conf`, master и имя — через `spark.sparkContext`. При передаче настроек в Hadoop префикс `spark.hadoop.` снимается."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "session-config",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"for key in (\n",
|
||||
" \"spark.sql.extensions\",\n",
|
||||
" \"spark.sql.catalog.lakehouse\",\n",
|
||||
" \"spark.sql.catalog.lakehouse.catalog-impl\",\n",
|
||||
" \"spark.sql.catalog.lakehouse.uri\",\n",
|
||||
" \"spark.sql.catalog.lakehouse.warehouse\",\n",
|
||||
"):\n",
|
||||
" print(f\"{key} = {spark.conf.get(key)}\")\n",
|
||||
"\n",
|
||||
"hadoop_conf = spark.sparkContext._jsc.hadoopConfiguration()\n",
|
||||
"for key in (\"fs.s3a.endpoint\", \"fs.s3a.path.style.access\"):\n",
|
||||
" print(f\"{key} = {hadoop_conf.get(key)}\")"
|
||||
],
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "session-live-explanation",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Что можно изменить во время работы\n",
|
||||
"\n",
|
||||
"`spark.sql.session.timeZone` задаёт часовой пояс SQL-сессии. У одной и той же секунды Unix-времени будет разное отображение в UTC и Москве. Это настройка интерпретации времени, без изменения файлов данных.\n",
|
||||
"\n",
|
||||
"Сохраним исходное значение и восстановим его в `finally`: оно вернётся даже при ошибке внутри опыта. Для Spark 3.5.1 эта настройка изменяется в работающей сессии."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "session-live-config",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"timezone_key = \"spark.sql.session.timeZone\"\n",
|
||||
"original_timezone = spark.conf.get(timezone_key)\n",
|
||||
"assert spark.conf.isModifiable(timezone_key)\n",
|
||||
"try:\n",
|
||||
" for timezone in (\"UTC\", \"Europe/Moscow\"):\n",
|
||||
" spark.conf.set(timezone_key, timezone)\n",
|
||||
" value = spark.sql(\"SELECT from_unixtime(0) AS displayed_time\").first()[0]\n",
|
||||
" print(timezone, value)\n",
|
||||
"finally:\n",
|
||||
" spark.conf.set(timezone_key, original_timezone)\n",
|
||||
"\n",
|
||||
"assert spark.conf.get(timezone_key) == original_timezone\n",
|
||||
"print(\"Исходный часовой пояс восстановлен:\", original_timezone)"
|
||||
],
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "session-reuse-explanation",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Для чего нужен новый запуск\n",
|
||||
"\n",
|
||||
"Проверим `appName`: это имя приложения, которое запущено через `SparkContext`. Попробуем задать другое имя через builder и посмотрим на **фактическое** `spark.sparkContext.appName`.\n",
|
||||
"\n",
|
||||
"Повторное выполнение ячейки не останавливает приложение. `getOrCreate()` использует ту же сессию и тот же контекст; это не способ переключиться на другой master или переименовать уже запущенное приложение."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "session-reuse",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"demo_context = spark.sparkContext\n",
|
||||
"demo_name = demo_context.appName\n",
|
||||
"same_spark = (\n",
|
||||
" SparkSession.builder\n",
|
||||
" .appName(\"module-03-raw-ingest-and-first-read\")\n",
|
||||
" .getOrCreate()\n",
|
||||
")\n",
|
||||
"assert same_spark is spark\n",
|
||||
"assert same_spark.sparkContext is demo_context\n",
|
||||
"assert same_spark.sparkContext.appName == demo_name\n",
|
||||
"print(\"То же приложение:\", same_spark.sparkContext.appName)"
|
||||
],
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "session-stop-explanation",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Теперь остановим демонстрационную сессию. В следующем задании новый контекст получит новое имя.\n",
|
||||
"\n",
|
||||
"- **Повторный запуск ячейки** сохраняет переменные Python и может использовать уже работающий контекст.\n",
|
||||
"- **`spark.stop()`** останавливает сессию и её контекст. Переменные Python остаются, но старые Spark DataFrame больше нельзя использовать. Файлы MinIO и записи каталога сохраняются.\n",
|
||||
"- **Перезапуск ядра** заново запускает Python и связанный с ним Spark driver. Переменные исчезают; нужно повторить импорты, создание сессии и чтение DataFrame. Данные в MinIO и каталоге от этого не удаляются.\n",
|
||||
"\n",
|
||||
"Для смены master или конфигурации файла начинай с нового ядра. Остановка сессии без перезапуска ядра достаточна для нашего опыта с именем, но не заменяет новый процесс для всех стартовых настроек."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "session-stop",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"spark.stop()"
|
||||
],
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "session-exercise-explanation",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Самостоятельно: создай сессию и проверь подключение\n",
|
||||
"\n",
|
||||
"Заполни три пропуска в следующей ячейке:\n",
|
||||
"\n",
|
||||
"1. Адрес кластера внутри Docker-сети.\n",
|
||||
"2. Имя приложения `module-03-raw-ingest-and-first-read`.\n",
|
||||
"3. Вызов builder-а с `.master(...)`, `.appName(...)` и `.getOrCreate()`.\n",
|
||||
"\n",
|
||||
"Используй подготовленный каталог из `spark-defaults.conf`: его параметры не нужно копировать в код. Явный `.master(...)` здесь нужен, чтобы ты самостоятельно указал подключение; остальные ноутбуки берут тот же адрес из файла.\n",
|
||||
"\n",
|
||||
"Шаблон остановится на `assert`, пока ты его не заполнишь. После заполнения выполни шаблон и ячейку проверки подключения дважды: имя и `applicationId` должны остаться теми же."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "session-exercise",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"from pyspark.sql import SparkSession\n",
|
||||
"\n",
|
||||
"CLUSTER_MASTER = ...\n",
|
||||
"APP_NAME = ...\n",
|
||||
"assert CLUSTER_MASTER == \"spark://spark-master:7077\", \"Впиши адрес кластера\"\n",
|
||||
"assert APP_NAME == \"module-03-raw-ingest-and-first-read\", \"Впиши имя приложения\"\n",
|
||||
"\n",
|
||||
"# Замени ... цепочкой вызовов SparkSession.builder.\n",
|
||||
"spark = ...\n",
|
||||
"assert isinstance(spark, SparkSession), \"Создай SparkSession через builder\"\n",
|
||||
"spark.sparkContext.setLogLevel(\"ERROR\")"
|
||||
],
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "session-solution",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<details>\n",
|
||||
"<summary>Контрольное решение — открой после своей попытки</summary>\n",
|
||||
"\n",
|
||||
"```python\n",
|
||||
"from pyspark.sql import SparkSession\n",
|
||||
"\n",
|
||||
"CLUSTER_MASTER = \"spark://spark-master:7077\"\n",
|
||||
"APP_NAME = \"module-03-raw-ingest-and-first-read\"\n",
|
||||
"spark = (\n",
|
||||
" SparkSession.builder\n",
|
||||
" .master(CLUSTER_MASTER)\n",
|
||||
" .appName(APP_NAME)\n",
|
||||
" .getOrCreate()\n",
|
||||
")\n",
|
||||
"spark.sparkContext.setLogLevel(\"ERROR\")\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"</details>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "session-connection-check",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"assert spark.sparkContext.master == CLUSTER_MASTER\n",
|
||||
"assert spark.sparkContext.appName == APP_NAME\n",
|
||||
"print(\"Master:\", spark.sparkContext.master)\n",
|
||||
"print(\"Приложение:\", spark.sparkContext.appName)\n",
|
||||
"print(\"ID приложения:\", spark.sparkContext.applicationId)"
|
||||
],
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "session-table-explanation",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Проверь весь путь: Spark → каталог → MinIO\n",
|
||||
"\n",
|
||||
"Одного правильного master недостаточно: проверим запись и чтение через каталог. Ячейка сама создаёт маленькую Iceberg-таблицу с числами `0` и `1`, читает её и удаляет в `finally`. Таблицы модуля 2 не требуются.\n",
|
||||
"\n",
|
||||
"У таблицы уникальное имя. `DROP TABLE … PURGE` удаляет именно её запись в каталоге, файлы данных и метаданных. В MinIO могут остаться пустые маркеры каталогов `data/` и `metadata/`; это не данные таблицы. Пока ячейка работает, не перезапускай ядро: при принудительном завершении процесса `finally` не выполнится. Если это произошло, сохрани напечатанное имя для удаления этой таблицы после нового подключения.\n",
|
||||
"\n",
|
||||
"Это только проверка подключения; создание рабочей таблицы поездок изучим в модуле 4."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "session-table-check",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"from uuid import uuid4\n",
|
||||
"\n",
|
||||
"session_table = f\"lakehouse.default.session_practice_{uuid4().hex}\"\n",
|
||||
"print(\"Временная таблица:\", session_table)\n",
|
||||
"try:\n",
|
||||
" spark.range(2).writeTo(session_table).using(\"iceberg\").create()\n",
|
||||
" session_rows = [row.id for row in spark.table(session_table).orderBy(\"id\").collect()]\n",
|
||||
" assert session_rows == [0, 1]\n",
|
||||
" print(\"Прочитано через Iceberg:\", session_rows)\n",
|
||||
"finally:\n",
|
||||
" spark.sql(f\"DROP TABLE IF EXISTS {session_table} PURGE\")\n",
|
||||
"\n",
|
||||
"assert not spark.catalog.tableExists(session_table)\n",
|
||||
"print(\"Временная таблица удалена\")"
|
||||
],
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "session-checkpoint",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Checkpoint настройки\n",
|
||||
"\n",
|
||||
"Проверки выше должны подтвердить master `spark://spark-master:7077`, заданное имя приложения, строки `[0, 1]` и удаление временной таблицы. В опыте с часовым поясом исходное значение должно восстановиться.\n",
|
||||
"\n",
|
||||
"Ответь своими словами:\n",
|
||||
"\n",
|
||||
"1. Какие настройки ты задал в коде, а какие предоставил стенд? Где их найти?\n",
|
||||
"2. Чем SparkSession отличается от SparkContext? Где работают driver и executors?\n",
|
||||
"3. Почему повторный `getOrCreate()` не сменил имя демонстрационного приложения, а после `stop()` оно изменилось?\n",
|
||||
"4. Что останется после перезапуска ядра и что потребуется выполнить заново?\n",
|
||||
"\n",
|
||||
"Теперь, когда временная таблица удалена, **перезапусти ядро**. Выполни заполненный шаблон, проверку подключения и ячейку с временной таблицей. Они должны работать без демонстрации и без состояния прошлого ядра; `applicationId` будет новым. Импорт `SparkSession` уже есть в шаблоне.\n",
|
||||
"\n",
|
||||
"После этой проверки продолжи ниже: подготовь переменные и MinIO-клиент, затем переходи к raw-данным. Сессию пока не останавливай."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "raw-preparation",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 0.2. Подготовка к raw-ingest\n",
|
||||
"\n",
|
||||
"Сессия из самостоятельного задания продолжает работать. Создадим MinIO-клиент и переменные для исходного сценария модуля."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -63,10 +392,6 @@
|
||||
"RAW_URI = f\"s3a://{BUCKET_NAME}/{RAW_PREFIX}\"\n",
|
||||
"ZONE_LOOKUP_RAW_URI = f\"{RAW_URI}taxi_zone_lookup.csv\"\n",
|
||||
"\n",
|
||||
"spark = SparkSession.builder .appName(\"module-03-raw-ingest-and-first-read\") .getOrCreate()\n",
|
||||
"\n",
|
||||
"spark.sparkContext.setLogLevel(\"ERROR\")\n",
|
||||
"\n",
|
||||
"s3 = boto3.client(\n",
|
||||
" \"s3\",\n",
|
||||
" endpoint_url=\"http://minio:9000\",\n",
|
||||
@@ -127,7 +452,7 @@
|
||||
"\n",
|
||||
"\n",
|
||||
"print(\"Spark и MinIO-клиент готовы\")\n",
|
||||
"print(f\"RAW URI: {RAW_URI}\")\n"
|
||||
"print(f\"RAW URI: {RAW_URI}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -305,7 +630,7 @@
|
||||
"- `spark.read.parquet(\"s3a://...\")` читает raw-файлы напрямую по физическому пути;\n",
|
||||
"- `spark.table(\"lakehouse....\")` читает уже зарегистрированную управляемую таблицу через каталог.\n",
|
||||
"\n",
|
||||
"Сейчас мы ещё не создавали `Iceberg`-таблицу, поэтому работаем именно через `spark.read.parquet(...)`. К `spark.table(...)` вернёмся в Модуле 4.\n"
|
||||
"Проверочную `Iceberg`-таблицу из практики настройки мы уже удалили. Поездки пока лежат отдельными raw-файлами, поэтому читаем их через `spark.read.parquet(...)`. Рабочую таблицу поездок создадим в Модуле 4."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user