Первые наброски airflow-gp
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# ==== Compose Environment (.env) ====
|
||||
# Airflow metadata DB (Postgres)
|
||||
PG_USER=airflow
|
||||
PG_PASSWORD=airflow
|
||||
PG_DB=airflow
|
||||
|
||||
# Greenplum access from Airflow
|
||||
GP_HOST=greenplum
|
||||
GP_PORT=5432
|
||||
GP_DB=gpadmin
|
||||
GP_USER=gpadmin
|
||||
# В большинстве демо-образов TCP доступ настроен без пароля (trust).
|
||||
# Если потребуется пароль, задай его здесь:
|
||||
GP_PASSWORD=
|
||||
|
||||
# Kafka
|
||||
KAFKA_BOOTSTRAP=kafka:9092
|
||||
KAFKA_TOPIC=orders
|
||||
|
||||
# Airflow admin
|
||||
AIRFLOW_USER=admin
|
||||
AIRFLOW_PASSWORD=admin
|
||||
@@ -0,0 +1,19 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
up:
|
||||
docker compose -f docker-compose.yml up -d
|
||||
|
||||
down:
|
||||
docker compose -f docker-compose.yml down -v
|
||||
|
||||
airflow-init:
|
||||
docker compose -f docker-compose.yml run --rm airflow-init
|
||||
|
||||
logs:
|
||||
docker compose -f docker-compose.yml logs -f airflow-webserver airflow-scheduler
|
||||
|
||||
gp-psql:
|
||||
docker compose -f docker-compose.yml exec greenplum bash -lc "su - gpadmin -c 'psql -p 5432 -d gpadmin'"
|
||||
|
||||
ddl-gp:
|
||||
docker compose -f docker-compose.yml exec greenplum bash -lc "su - gpadmin -c \"psql -d gpadmin -f /sql/ddl_gp.sql\""
|
||||
@@ -0,0 +1,53 @@
|
||||
# DE Starter Kit — Greenplum + Kafka + Airflow
|
||||
|
||||
Обновлено: 2025-09-15 11:12
|
||||
|
||||
Этот вариант повторяет логику Postgres-стенда, но в роли DWH — **Greenplum (single node в Docker)**.
|
||||
Airflow по‑прежнему использует **Postgres** только как metadata DB (это стандартная и простая схема).
|
||||
|
||||
## Что внутри
|
||||
- **Greenplum** (single node) — `datagrip/greenplum:6.8` (локальный стенд для разработки).
|
||||
- **Kafka (KRaft, без Zookeeper)** — генерация событий.
|
||||
- **Airflow (2.9)** — оркестрация пайплайна, metadata в Postgres.
|
||||
- **DAG** `kafka_to_greenplum.py` — генерирует данные → пишет в Kafka → читает и грузит в Greenplum.
|
||||
- **DDL** `sql/ddl_gp.sql` — создаёт таблицу `orders` с распределением по `order_id`.
|
||||
|
||||
> Примечание по версиям и надёжности: образы Greenplum в публичном Docker Hub — комьюнити/сторонние. Я выбрал `datagrip/greenplum:6.8` как популярный и поддерживаемый для локальной разработки. Для продакшен‑подобных тестов зафиксируй digest (SHA256) конкретного тега на Docker Hub и/или собери образ самостоятельно. См. раздел «Пинning и альтернативы» ниже.
|
||||
|
||||
## Быстрый старт
|
||||
Установим make под Вашу операционную систему
|
||||
```bash
|
||||
sudo apt install -y make
|
||||
```
|
||||
Запустим приложение
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make up && make airflow-init
|
||||
make logs # ждем "Listening at: http://0.0.0.0:8080"
|
||||
```
|
||||
Открой Airflow: http://localhost:8080 (логин/пароль см. `.env`, по умолчанию admin/admin).
|
||||
Включи DAG **kafka_to_greenplum** и нажми **Trigger** — он создаст таблицу и загрузит ~1000 записей в `gpadmin.public.orders`.
|
||||
|
||||
### Проверка загрузки
|
||||
```bash
|
||||
make gp-psql # войти в psql к Greenplum
|
||||
-- внутри psql:
|
||||
\dt
|
||||
SELECT count(*) FROM public.orders;
|
||||
```
|
||||
|
||||
## Файлы
|
||||
- `docker-compose.gp.yml` — сервисы Greenplum + Kafka + Airflow + Postgres (metadata).
|
||||
- `.env.example` — переменные окружения.
|
||||
- `airflow/dags/kafka_to_greenplum.py` — сам DAG.
|
||||
- `sql/ddl_gp.sql` — DDL таблицы в Greenplum.
|
||||
- `Makefile` — обёртки команд (`up`, `down`, `airflow-init`, `gp-psql`, `ddl-gp`).
|
||||
|
||||
## Пинning и альтернативы
|
||||
- Зафиксируй digest образа `datagrip/greenplum:6.8` (Docker Hub → Tag → «Copy digest») и замени тег на `@sha256:...` в `docker-compose.gp.yml`.
|
||||
- Альтернативы: репозиторий `woblerr/docker-greenplum` позволяет **собрать свой образ** для GPDB 6/7 (подойдёт, если нужен полный контроль и повторяемость сборки).
|
||||
- Особенность GPDB 6: в нём **нет** `INSERT ... ON CONFLICT`. В DAG используется безопасная для GP6 конструкция `INSERT ... WHERE NOT EXISTS` внутри транзакции.
|
||||
|
||||
## Ограничения и заметки
|
||||
- Этот стенд — учебный. Для высокой надёжности и производительности Greenplum обычно разворачивают кластерами на нескольких узлах, на裸‑железе/VM с отдельными дисками под сегменты.
|
||||
- Для GP7 (основан на новее PostgreSQL) можно упростить загрузку, включая `ON CONFLICT`. В учебных целях мы остались на широко доступном GP6 образе.
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
import os, json, random
|
||||
from datetime import datetime, timedelta
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
import psycopg2
|
||||
from confluent_kafka import Producer, Consumer, KafkaException
|
||||
|
||||
# Greenplum (Postgres wire protocol)
|
||||
GP_DSN = {
|
||||
"dbname": os.getenv("GP_DB", "gpadmin"),
|
||||
"user": os.getenv("GP_USER", "gpadmin"),
|
||||
"password": os.getenv("GP_PASSWORD", ""),
|
||||
"host": os.getenv("GP_HOST", "greenplum"),
|
||||
"port": int(os.getenv("GP_PORT", "5432")),
|
||||
}
|
||||
|
||||
KAFKA_BOOTSTRAP = os.getenv("KAFKA_BOOTSTRAP", "kafka:9092")
|
||||
TOPIC = os.getenv("KAFKA_TOPIC", "orders")
|
||||
|
||||
def _create_table():
|
||||
ddl = """
|
||||
CREATE TABLE IF NOT EXISTS public.orders (
|
||||
order_id BIGINT PRIMARY KEY,
|
||||
order_ts TIMESTAMP NOT NULL,
|
||||
customer_id BIGINT NOT NULL,
|
||||
amount NUMERIC(12,2) NOT NULL
|
||||
)
|
||||
WITH (appendonly=true, orientation=column, compresstype=zlib)
|
||||
DISTRIBUTED BY (order_id);
|
||||
"""
|
||||
with psycopg2.connect(**GP_DSN) as conn, conn.cursor() as cur:
|
||||
cur.execute(ddl)
|
||||
conn.commit()
|
||||
|
||||
def _produce(n=1000):
|
||||
p = Producer({"bootstrap.servers": KAFKA_BOOTSTRAP})
|
||||
for i in range(n):
|
||||
payload = {
|
||||
"order_id": i + 1,
|
||||
"order_ts": datetime.utcnow().isoformat(),
|
||||
"customer_id": random.randint(1, 100),
|
||||
"amount": round(random.uniform(10, 500), 2),
|
||||
}
|
||||
p.produce(TOPIC, json.dumps(payload).encode("utf-8"))
|
||||
p.flush()
|
||||
|
||||
def _consume_and_load(max_messages=1000, timeout_s=10):
|
||||
consumer = Consumer({
|
||||
"bootstrap.servers": KAFKA_BOOTSTRAP,
|
||||
"group.id": "airflow-loader-gp",
|
||||
"auto.offset.reset": "earliest",
|
||||
"enable.auto.commit": False,
|
||||
})
|
||||
consumer.subscribe([TOPIC])
|
||||
|
||||
with psycopg2.connect(**GP_DSN) as conn, conn.cursor() as cur:
|
||||
inserted = 0
|
||||
while inserted < max_messages:
|
||||
msg = consumer.poll(timeout_s)
|
||||
if msg is None:
|
||||
break
|
||||
if msg.error():
|
||||
raise KafkaException(msg.error())
|
||||
d = json.loads(msg.value().decode("utf-8"))
|
||||
# GPDB6 не поддерживает ON CONFLICT — используем WHERE NOT EXISTS
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO public.orders(order_id, order_ts, customer_id, amount)
|
||||
SELECT %s, %s, %s, %s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.orders WHERE order_id = %s
|
||||
);
|
||||
""",
|
||||
(d["order_id"], d["order_ts"], d["customer_id"], d["amount"], d["order_id"]),
|
||||
)
|
||||
inserted += 1
|
||||
conn.commit()
|
||||
|
||||
consumer.commit(); consumer.close()
|
||||
|
||||
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
|
||||
|
||||
with DAG(
|
||||
dag_id="kafka_to_greenplum",
|
||||
start_date=datetime(2024, 1, 1),
|
||||
schedule_interval=None,
|
||||
catchup=False,
|
||||
default_args=default_args,
|
||||
tags=["demo", "kafka", "greenplum"],
|
||||
) as dag:
|
||||
|
||||
create_table = PythonOperator(task_id="create_table", python_callable=_create_table)
|
||||
produce = PythonOperator(task_id="produce_messages", python_callable=_produce, op_kwargs={"n": 1000})
|
||||
consume_and_load = PythonOperator(task_id="consume_and_load", python_callable=_consume_and_load, op_kwargs={"max_messages": 1000})
|
||||
|
||||
create_table >> produce >> consume_and_load
|
||||
@@ -0,0 +1,3 @@
|
||||
confluent-kafka==2.3.0
|
||||
psycopg2-binary==2.9.9
|
||||
pandas==2.2.2
|
||||
@@ -0,0 +1,140 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
# Postgres только для Airflow метаданных
|
||||
pgmeta:
|
||||
image: postgres:16
|
||||
# container_name: gp_pgmeta
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_USER: ${PG_USER}
|
||||
POSTGRES_PASSWORD: ${PG_PASSWORD}
|
||||
POSTGRES_DB: ${PG_DB}
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- pgmeta:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
greenplum:
|
||||
image: datagrip/greenplum:6.8
|
||||
# container_name: gp_single
|
||||
hostname: gpdbsne
|
||||
# Порты: внешний 5432
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ./sql:/sql:ro
|
||||
# Простая проверка доступности: psql откликается
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 || echo 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
|
||||
kafka:
|
||||
image: apache/kafka:3.8.0
|
||||
environment:
|
||||
KAFKA_BROKER_ID: 1
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT
|
||||
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
|
||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
|
||||
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
||||
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
||||
KAFKA_PROCESS_ROLES: broker,controller
|
||||
KAFKA_NODE_ID: 1
|
||||
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093
|
||||
KAFKA_LISTENERS: PLAINTEXT://kafka:29092,CONTROLLER://kafka:29093,PLAINTEXT_HOST://0.0.0.0:9092
|
||||
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
||||
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
||||
KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
|
||||
# 22-символьный base64 идентификатор для KRaft-кластера
|
||||
CLUSTER_ID: aGVsbG93b3JsZGtpdGNoZW4
|
||||
ports:
|
||||
- "9092:9092"
|
||||
|
||||
kafka-ui:
|
||||
image: provectuslabs/kafka-ui:v0.7.2
|
||||
ports:
|
||||
- 8082:8080
|
||||
environment:
|
||||
- DYNAMIC_CONFIG_ENABLED=true
|
||||
- KAFKA_CLUSTERS_0_NAME=KafkaCluster
|
||||
- KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS=kafka:29092
|
||||
volumes:
|
||||
- kafka-ui-data:/app/data
|
||||
depends_on:
|
||||
- kafka
|
||||
|
||||
airflow-webserver:
|
||||
image: apache/airflow:2.9.2
|
||||
container_name: gp_airflow_web
|
||||
env_file: .env
|
||||
environment:
|
||||
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
|
||||
command: >
|
||||
bash -lc "pip install --no-cache-dir -r /opt/airflow/requirements.txt &&
|
||||
airflow webserver"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./airflow/dags:/opt/airflow/dags
|
||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
||||
depends_on:
|
||||
pgmeta:
|
||||
condition: service_healthy
|
||||
kafka:
|
||||
condition: service_started
|
||||
greenplum:
|
||||
condition: service_healthy
|
||||
|
||||
airflow-scheduler:
|
||||
image: apache/airflow:2.9.2
|
||||
container_name: gp_airflow_sch
|
||||
env_file: .env
|
||||
environment:
|
||||
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
|
||||
command: >
|
||||
bash -lc "pip install --no-cache-dir -r /opt/airflow/requirements.txt &&
|
||||
airflow scheduler"
|
||||
volumes:
|
||||
- ./airflow/dags:/opt/airflow/dags
|
||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
||||
depends_on:
|
||||
pgmeta:
|
||||
condition: service_healthy
|
||||
kafka:
|
||||
condition: service_started
|
||||
greenplum:
|
||||
condition: service_healthy
|
||||
|
||||
airflow-init:
|
||||
image: apache/airflow:2.9.2
|
||||
container_name: gp_airflow_init
|
||||
env_file: .env
|
||||
environment:
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
|
||||
volumes:
|
||||
- ./airflow/dags:/opt/airflow/dags
|
||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
||||
entrypoint: ["/bin/bash", "-lc"]
|
||||
command: |
|
||||
set -e
|
||||
until pg_isready -h pgmeta -U ${PG_USER} -d ${PG_DB}; do echo 'waiting for pgmeta'; sleep 2; done
|
||||
pip install --no-cache-dir -r /opt/airflow/requirements.txt
|
||||
airflow db migrate
|
||||
airflow users create --username ${AIRFLOW_USER} --password ${AIRFLOW_PASSWORD} --firstname Admin --lastname User --role Admin --email admin@example.org
|
||||
depends_on:
|
||||
pgmeta:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgmeta:
|
||||
kafka-ui-data:
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Greenplum DDL (GPDB 6 совместимо)
|
||||
-- Колонночная таблица (append-optimized) и распределение по ключу
|
||||
CREATE TABLE IF NOT EXISTS public.orders (
|
||||
order_id BIGINT PRIMARY KEY,
|
||||
order_ts TIMESTAMP NOT NULL,
|
||||
customer_id BIGINT NOT NULL,
|
||||
amount NUMERIC(12,2) NOT NULL
|
||||
)
|
||||
WITH (appendonly=true, orientation=column, compresstype=zlib)
|
||||
DISTRIBUTED BY (order_id);
|
||||
Reference in New Issue
Block a user