입력 계약
- 열:
order_id,order_date,amount순서. - 주문 번호: 빈 값은 오류. 양끝 공백은 제거.
- 날짜: ISO 날짜를 읽어
YYYY-MM-DD로 저장. - 금액: 음수가 아닌 정수 원 단위.
BIGDATA · 1~3장 보충 실습
주문 CSV를 정리하고 Spark로 합계와 단어 수를 구합니다. Worker 수를 바꾸며 실행 시간을 비교합니다. 코드·입력·예상 결과 파일은 아래에서 받습니다.
1~3장 개념을 연습하는 보충 예제로, 강의에서 제공한 실습자료/와 별도로 작성했습니다. 주문과 문장은 개인정보가 없는 합성 데이터입니다.
이 페이지의 파일은 practice/에 있습니다. 제공 실습과 서비스 이름·포트가 다르므로 아래 순서대로 실행합니다.
파일·환경 준비 → 전체 코드 확인 → 주문 CSV → Spark 합계 → 단어 집계 → 성능 비교 · 오류 확인·종료
| 순서 | 준비물 | 완료 기준 |
|---|---|---|
| 1장 · 주문 CSV | Python 3.10 이상, 아래 파일 | 5행 → 주문 3개 · 35,000원, 예상 CSV와 일치 |
| 2장 · 첫 Spark 계산 | Docker Desktop, Compose, Spark 이미지 | Worker 등록 2개, 합계 5050, 정상 종료 |
| 3장 · 단어 집계 | 같은 Docker 환경, words.txt | 단어 3종의 횟수와 Parquet 재읽기 일치 |
| 3장 · 자원 비교 | 같은 Docker 환경, 여유 CPU·메모리 | 같은 정답·조건별 시간·실제 Executor 기록 |
PC에 bigdata-practice 폴더를 만들고 아래 구조에 맞춰 파일을 저장합니다. 링크의 파일 받기로 내려받거나 전체 코드를 복사하여 UTF-8로 저장하세요. 파일 이름 뒤에 .txt가 추가되지 않았는지 확인합니다.
GitHub 저장소를 내려받았다면 site/BigData/practice에 파일이 있습니다. 로컬 학습 폴더에서는 BigData/practice에 있습니다. 명령은 compose.yaml이 있는 폴더에서 실행합니다.
bigdata-practice/
compose.yaml
apps/
order_pipeline.py
check_cluster.py
sum_app.py
wordcount_app.py
benchmark_app.py
submit.sh
data/
orders.csv
words.txt
expected/
orders-clean.csv
wordcounts.csv
results-template.csv
output/ ← 실행 결과를 저장할 빈 폴더
실행 위치: 위 폴더의 Windows PowerShell. 버전이 Python 3.10 이상인지 확인합니다. Windows의 python 명령이 Store를 열면 설치된 Python 실행 경로를 확인하거나 Python 공식 배포로 설치합니다.
python --version
New-Item -ItemType Directory -Force output
python3 --version과 mkdir -p output을 사용합니다.pip install이 필요 없습니다.Windows에서는 Docker Desktop 공식 설치 안내에 따라 WSL 2 기반 Linux 컨테이너 환경을 준비합니다. 이미 설치되어 있다면 Docker Desktop을 열고 Engine이 실행될 때까지 기다립니다.
wsl --status
docker version
docker compose version
docker info --format '{{.OSType}}'
docker compose config
docker version에 Client와 Server가 모두 나오고, 운영체제 값은 linux, Compose 설정 검사는 오류 없이 끝납니다.보충 예제는 1~3장의 Spark 이미지와 자원 개념을 사용합니다. 서비스 시작 순서: Docker 공식 문서.
같은 주문이 여러 번 수집될 수 있습니다. 모든 행의 금액을 더하면 중복 주문도 매출에 포함됩니다. 이 예제에서는 A002와 A003의 중복 행으로 중복 제거 규칙을 확인합니다.
같은 영수증이 두 장 있어도 판매는 한 번입니다. 번호는 같은데 금액이 다르면 한 장을 임의로 버리지 않고 확인해야 합니다.
order_id,order_date,amount 순서.YYYY-MM-DD로 저장.실행 위치: bigdata-practice 폴더의 PowerShell.
python apps/order_pipeline.py
Get-Content output/orders-clean.csv
Compare-Object (Get-Content expected/orders-clean.csv) (Get-Content output/orders-clean.csv)
$LASTEXITCODE
Compare-Object에 차이 행이 없으면 텍스트가 같습니다.$LASTEXITCODE는 마지막 Python 프로세스의 종료 코드이며 정상은 0입니다. 차이 비교 자체의 성공 여부를 이 변수로 판단하지 않습니다.INPUT_ROWS=5
UNIQUE_ORDERS=3
DUPLICATE_ROWS=2
TOTAL_AMOUNT=35000
위 값은 제공 입력의 예상 출력입니다. 실제 출력에는 결과 파일 위치도 나옵니다. WSL·macOS·Linux에서는 python3 apps/order_pipeline.py 뒤 diff expected/orders-clean.csv output/orders-clean.csv로 비교할 수 있습니다.
csv.DictReader가 열 이름을 키로 가진 행을 읽습니다.원본 입력을 보존하고 실험용 사본을 만들어 편집합니다.
Copy-Item data/orders.csv output/orders-test.csv
notepad output/orders-test.csv
python apps/order_pipeline.py --input output/orders-test.csv --output output/orders-test-clean.csv
$LASTEXITCODE
conflicting order_id A002 오류와 종료 코드 1. 결과를 갱신하지 않습니다.입력이 100배가 되어도 이 코드로 처리할 수 있을까요? 고유 주문을 Python 사전에 모두 보관하므로 고유 주문 수가 늘면 메모리 사용량도 늘어납니다. 큰 입력을 분산 처리하려면 데이터 분할 방법과 중복 제거 비용을 고려해야 합니다.
이미지를 받은 뒤 한 컨테이너에서 계산하고, Worker 등록과 클러스터 계산을 차례로 확인합니다.
| 항목 | 설정 | 의미 |
|---|---|---|
| Master | spark-master · 내부 7077 | Worker 자원을 등록하고 Application에 배정 |
| Worker | spark-worker · 복제 가능 | 각 1 Core·1g, 실행 시 2개로 복제 |
| Client / Driver | spark-client | submit.sh가 여기에서 Driver를 시작 |
| 브라우저 UI | Master 28080 · Driver 24041 | 호스트에서 localhost로 접속 |
| 앱·입력·정답 | apps · data · expected | Client에 읽기 전용 연결 |
| 출력 | output → /opt/spark/lab-output | Client에서 쓰면 PC의 output에 보존 |
이 보충 실습의 프로젝트 이름은 knut-public-spark입니다. 2장 제공 실습의 Worker 1·2 이름과 8080/4040 포트는 사용하지 않습니다. 성능 측정 중에는 다른 학습 환경을 종료해 CPU·메모리 경합을 줄입니다.
실행 위치: compose.yaml이 있는 폴더의 PowerShell.
docker compose pull
docker compose run --rm --no-deps --entrypoint /opt/spark/bin/spark-submit spark-client --master "local[2]" /opt/spark/apps/sum_app.py
$LASTEXITCODE
예상 결과는 MASTER=local[2], SUM_1_TO_100=5050, CHECK=PASS, APP_STOPPED, 종료 코드 0입니다.
local[2]는 하나의 Java 실행 환경(JVM) 안에서 작업 스레드 두 개를 쓰는 모드입니다. 클러스터 Worker 두 개의 등록 여부는 다음 단계에서 검사합니다.
이 실습은 spark:4.1.3-scala2.13-java21-python3-ubuntu 이미지를 사용합니다. 태그는 Docker 공식 이미지 목록에 등록되어 있습니다. manifest unknown 오류가 나면 태그·레지스트리 주소를 확인합니다. 다른 버전을 쓰면 버전을 기록하고 각 실행 단계를 다시 확인합니다.
docker compose up -d --scale spark-worker=2
docker compose ps
docker compose exec spark-client python3 /opt/spark/apps/check_cluster.py --workers 2
$LASTEXITCODE
ALIVE_WORKERS=2 TOTAL_CORES=2와 REGISTRATION_CHECK=PASS를 확인합니다. 검사기는 Master의 상태 JSON을 읽어 등록을 기다립니다. Master UI · localhost:28080의 ALIVE Worker 수도 2개여야 합니다.
healthy는 Master HTTP 응답 여부입니다. 등록 검사가 통과해도 Driver로 되돌아오는 연결과 실제 계산은 다음 단계에서 확인해야 합니다.
docker compose exec spark-client bash /opt/spark/apps/submit.sh sum_app.py --wait-seconds 120
$LASTEXITCODE
MASTER=spark://spark-master:7077와 합계 5050을 확인합니다. UI_WAIT_SECONDS=120이 나오면 별도 브라우저에서 Driver UI · localhost:24041를 엽니다. 터미널은 관찰 대기 중이므로 다음 앱을 겹쳐 실행하지 않습니다.
range의 입력 Partition 4개와 부분 합계·최종 합계 구조를 확인합니다.APP_STOPPED와 종료 코드 0을 확인합니다. 종료 후에는 이 Driver UI가 닫힙니다.입력 Partition이 4개인데 Core가 2개라면 네 작업을 모두 동시에 실행할까요? 동시에 실행할 수 있는 작업 수는 배정된 Core에 제한됩니다. 입력 분할 수와 동시에 사용할 자원을 구분해 기록하세요.
docker compose up -d --scale spark-worker=1
docker compose exec spark-client python3 /opt/spark/apps/check_cluster.py --workers 1
docker compose exec -e TOTAL_EXECUTOR_CORES=1 spark-client bash /opt/spark/apps/submit.sh sum_app.py --wait-seconds 120
같은 입력과 합계를 유지하면서 가용 자원을 줄입니다. 숫자 100개의 실행 시간만으로 성능 차이를 판단하기는 어렵습니다. 다음 절을 진행하거나 종료 절차를 따릅니다.
Compose 전체 설정 · 계산 앱 전체 코드 · 제출 스크립트 전체 코드 · 2장 해설
client 모드와 local[N]의 의미: Spark 4.1.3 공식 제출 안내. Standalone의 Worker·Core 구성: Spark 4.1.3 Standalone 문서.
3장에서 배운 단어 집계 연산을 보충 입력 파일로 실행합니다. 입력은 Client 컨테이너에만 연결하므로 local[2]로 실행합니다. 클러스터의 Worker에는 이 파일이 연결되어 있지 않습니다.
문장을 단어 카드로 자르고 같은 단어끼리 모아 셉니다. 실제 코드는 문자열 열을 배열로 나눈 뒤, 배열 원소를 여러 행으로 펼치고 집계합니다.
실행 위치: 같은 PowerShell. 앞 앱의 APP_STOPPED 이후. Client가 실행 중인 상태에서 아래를 실행합니다.
docker compose exec spark-client /opt/spark/bin/spark-submit --master "local[2]" /opt/spark/apps/wordcount_app.py
$LASTEXITCODE
Get-ChildItem output/wordcounts
예상 결과는 WORDCOUNT_CHECK=PASS, APP_STOPPED, 종료 코드 0입니다. 출력의 단어 표는 아래와 같습니다.
| word | count |
|---|---|
| spark | 3 |
| data | 2 |
| pipeline | 1 |
Parquet는 같은 열의 값을 모아 저장하는 파일 형식입니다. Spark는 지정 경로에 데이터를 나누어 담은 part 파일과 성공 표시를 만듭니다. Get-ChildItem으로 이 출력 폴더의 파일 목록을 확인합니다.
value 한 행으로 읽습니다. 빈 줄도 살펴봅니다.word 행으로 펼칩니다.기존 결과를 보존하도록 기본 저장 모드는 errorifexists입니다. 같은 실습을 다시 실행하려면 새 출력 경로를 지정합니다.
docker compose exec spark-client /opt/spark/bin/spark-submit --master "local[2]" /opt/spark/apps/wordcount_app.py --output /opt/spark/lab-output/wordcounts-run2
입력을 바꾸면 expected/wordcounts.csv의 예상값도 직접 계산해 바꿉니다. 예를 들어 Spark와 spark는 서로 다른 단어로 집계됩니다. 표기를 통일하려면 먼저 어떤 표기를 같은 단어로 볼지 정합니다.
이 예제에서 collect로 결과를 검사할 수 있는 이유는 무엇일까요? 합성 입력의 단어는 3종뿐입니다. 코드도 최대 101행만 가져와 100종을 넘으면 중단하므로 Driver에 모으는 데이터가 제한됩니다. 큰 결과 전체를 검사할 때는 다른 방법이 필요합니다.
텍스트의 한 줄·value 열·UTF-8 읽기: DataFrameReader.text 공식 API.
입력·연산·정답 확인 규칙을 고정하고 성능을 비교합니다. 작은 입력으로 코드와 설정을 확인한 뒤 PC 자원에 맞춰 입력을 늘립니다. 숫자는 spark.range로 생성하므로 큰 입력 파일을 따로 준비하지 않습니다.
실행 위치: compose.yaml이 있는 폴더의 PowerShell. 앞 앱의 APP_STOPPED를 확인한 뒤 실행합니다.
docker compose up -d --scale spark-worker=1
docker compose exec spark-client python3 /opt/spark/apps/check_cluster.py --workers 1
docker compose exec -e TOTAL_EXECUTOR_CORES=1 spark-client bash /opt/spark/apps/submit.sh benchmark_app.py --end 1000001 --partitions 128 --repetitions 3
$LASTEXITCODE
1부터 1,000,000까지의 예상 합은 500000500000입니다. end는 포함하지 않는 끝값입니다. 각 실행 시간과 CHECK=PASS를 확인한 뒤 종료까지 기다립니다.
--end 1000000001이면 1~10억, 예상 합은 500000000500000000입니다.PowerShell에서 아래 블록을 조건마다 실행합니다. 먼저 $workers = 1, 다음은 2, 4, 8로 바꿉니다. 앞 앱의 APP_STOPPED를 확인한 뒤 다음 조건을 실행하세요. 실행하지 못한 조건은 기록표에서 미측정으로 남깁니다.
$workers = 1
docker compose up -d --scale "spark-worker=$workers"
docker compose exec spark-client python3 /opt/spark/apps/check_cluster.py --workers $workers
docker compose exec -e "TOTAL_EXECUTOR_CORES=$workers" spark-client bash /opt/spark/apps/submit.sh benchmark_app.py --end 1000000001 --partitions 128 --repetitions 3
$LASTEXITCODE
등록 검사에 실패하면 제출하지 말고 원인을 먼저 해결합니다. Master UI의 ALIVE Worker와 Total Cores, 앱 출력의 REQUESTED_CORES를 같이 확인하세요. 요청 Core 수는 상한이며 실제 할당량과 항상 같지는 않습니다.
docker compose exec spark-client python3 /opt/spark/apps/check_cluster.py --workers 8
docker compose exec -e TOTAL_EXECUTOR_CORES=2 spark-client bash /opt/spark/apps/submit.sh benchmark_app.py --end 1000000001 --partitions 128 --repetitions 3 --wait-seconds 120
동일 시점의 Master 사용 Core와 Driver UI의 실제 Executor를 기록합니다. Worker 8개를 준비해도 이 앱의 Executor Core 합은 최대 2입니다. 앞의 2 Worker·2 Core 조건과 비교하면 준비된 Worker 수와 앱이 쓰는 자원을 구분할 수 있습니다.
2/2 조건의 화면을 놓쳤다면 그 조건으로 되돌려 동일 명령에 --wait-seconds 120을 추가해 관찰합니다. 관찰용 재실행의 시간은 앞 측정값과 섞지 않고 따로 표시합니다.
빈 CSV 기록표에 직접 실행한 결과를 적습니다. 코드가 출력한 시간만 실제 측정값입니다.
| 항목 | 계산 | 해석 |
|---|---|---|
| 중앙값 | 세 시간을 정렬한 가운데 값 | 단일 이상값의 영향을 줄이되 원자료도 보존 |
| Speedup | 1/1 조건 중앙값 ÷ 비교 조건 중앙값 | 같은 입력에서 얼마나 빨라졌는지 |
| 효율 | Speedup ÷ 사용 Core 수 × 100 | 1 Core 기준 자원 증가와 비교; 실제 할당도 확인 |
8 Worker가 4 Worker보다 느리면 코드가 반드시 틀린 걸까요? 같은 PC의 CPU·메모리 경합, 통신·스케줄링 비용, 초기 실행 비용을 확인합니다. 정답이 같다는 증거와 성능 이유를 나누어 설명하세요. 큰 차이가 반복되면 조건별 Application을 여러 번 다시 실행하여 변동도 기록합니다.
| 증상 | 확인할 것 |
|---|---|
| docker 명령 없음 / Server 오류 | Desktop 설치, 실행 상태, Linux 컨테이너 모드. 설치 성공과 Engine 시작을 구분합니다. |
| 이미지 pull 실패 | 태그 존재, 인터넷·프록시·로그인 제한. 오류를 기록하고 이미지가 없으면 다음 단계로 진행하지 않습니다. |
| 파일 없음 | Compose 현재 폴더, apps/data/expected 이름과 확장자, 내려받은 실제 파일 내용. |
| Worker 등록 실패 | Master/Worker 로그와 상태, 메모리 부족·종료 여부. healthy만으로 성공 판단하지 않습니다. |
| Initial job has not accepted any resources | ALIVE Worker, 가용 Core/메모리, 이전 앱 종료 여부, submit.sh의 Driver 호스트 설정. |
| 포트 사용 중 | 28080/24041을 쓰는 기존 프로세스 확인. Compose의 호스트 포트를 바꾸면 안내 URL도 맞춥니다. |
| Driver UI가 안 열림 | 앱이 이미 끝났는지 확인. wait-seconds를 주고 대기 메시지가 나온 동안 접속합니다. |
| submit.sh의 $'\r' 오류 | 편집기에서 Bash 파일 줄바꿈을 LF로 저장. 제공 파일의 LF를 유지합니다. |
| Parquet 경로 이미 존재 | 기존 결과를 남기고 새 --output 경로를 지정합니다. |
| 출력 Permission denied | 호스트 output의 쓰기 권한과 공유 설정. 컨테이너 사용자와 Linux 파일 소유자가 다를 수 있습니다. |
실행 위치: 보충 실습 폴더의 PowerShell. 로그를 확인한 뒤 실습 환경을 종료합니다.
docker compose ps -a
docker compose logs --tail 100 spark-master spark-worker
# 실행 중인 앱의 APP_STOPPED를 확인한 뒤 종료
docker compose down
docker compose ps -a
이 프로젝트의 컨테이너와 기본 네트워크가 제거됩니다. 바인드 마운트한 PC의 output과 원본 입력은 남습니다. 다음에는 같은 폴더에서 up으로 시작한 뒤 등록부터 다시 확인합니다.
합계·단어 수는 합성 입력에서 계산한 예상값입니다. 실행 결과를 예상값과 비교하고, 빈 측정 CSV에는 자신의 환경에서 측정한 시간과 자원 사용을 기록합니다.
파일 이름을 누르면 다운로드 파일과 같은 전체 내용이 펼쳐집니다. 코드 블록의 복사 버튼으로 복사해 UTF-8로 저장할 수 있습니다.
.py 파일로 저장하고 앞 절의 실행 명령을 사용합니다. PowerShell에 코드 한 줄씩 붙여 넣지 않습니다.apps/submit.sh로 저장합니다. 앞 절의 docker compose exec 명령이 컨테이너 안에서 실행합니다.# Independently authored public lab. Run commands from this directory.
name: knut-public-spark
x-runtime: &runtime
image: spark:4.1.3-scala2.13-java21-python3-ubuntu
init: true
environment:
PYSPARK_PYTHON: python3
services:
spark-master:
<<: *runtime
hostname: spark-master
entrypoint: ["/opt/spark/bin/spark-class"]
command: ["org.apache.spark.deploy.master.Master", "--host", "spark-master", "--port", "7077", "--webui-port", "8080"]
ports:
- "127.0.0.1:28080:8080"
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080', timeout=3)"]
interval: 5s
timeout: 5s
retries: 20
start_period: 15s
spark-worker:
<<: *runtime
entrypoint: ["/bin/bash", "-lc"]
command:
- >-
exec /opt/spark/bin/spark-class org.apache.spark.deploy.worker.Worker
--host "$$HOSTNAME" --cores 1 --memory 1g
--work-dir /tmp/spark-work spark://spark-master:7077
depends_on:
spark-master:
condition: service_healthy
spark-client:
<<: *runtime
hostname: spark-client
entrypoint: ["/bin/bash", "-lc"]
command: ["exec sleep infinity"]
ports:
- "127.0.0.1:24041:4040"
volumes:
- ./apps:/opt/spark/apps:ro
- ./data:/opt/spark/lab-data:ro
- ./expected:/opt/spark/lab-expected:ro
- ./output:/opt/spark/lab-output
depends_on:
spark-master:
condition: service_healthy
"""Small, synthetic CSV pipeline. Python standard library only."""
import argparse
import csv
from datetime import date
from pathlib import Path
import tempfile
ROOT = Path(__file__).resolve().parents[1]
FIELDS = ["order_id", "order_date", "amount"]
def clean_orders(source):
unique = {}
input_rows = 0
with source.open(encoding="utf-8-sig", newline="") as stream:
reader = csv.DictReader(stream)
if reader.fieldnames != FIELDS:
raise ValueError("CSV header must be order_id,order_date,amount")
for line, row in enumerate(reader, start=2):
input_rows += 1
if None in row or any(row[key] is None for key in FIELDS):
raise ValueError(f"line {line}: expected three columns")
order_id = row["order_id"].strip()
if not order_id:
raise ValueError(f"line {line}: empty order_id")
order_date = date.fromisoformat(row["order_date"].strip()).isoformat()
amount = int(row["amount"])
if amount < 0:
raise ValueError(f"line {line}: amount must be nonnegative")
record = (order_date, amount)
if order_id in unique and unique[order_id] != record:
raise ValueError(f"line {line}: conflicting order_id {order_id}")
unique[order_id] = record
return unique, input_rows
def run(source, output):
if source.resolve() == output.resolve():
raise ValueError("input and output must be different files")
orders, input_rows = clean_orders(source)
output.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", newline="", dir=output.parent,
prefix="orders-", suffix=".tmp", delete=False,
) as stream:
temporary = Path(stream.name)
writer = csv.writer(stream, lineterminator="\n")
writer.writerow(FIELDS)
for order_id, (order_date, amount) in sorted(orders.items()):
writer.writerow([order_id, order_date, amount])
temporary.replace(output)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
print(f"INPUT_ROWS={input_rows}")
print(f"UNIQUE_ORDERS={len(orders)}")
print(f"DUPLICATE_ROWS={input_rows - len(orders)}")
print(f"TOTAL_AMOUNT={sum(amount for _, amount in orders.values())}")
print(f"OUTPUT={output}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, default=ROOT / "data/orders.csv")
parser.add_argument("--output", type=Path, default=ROOT / "output/orders-clean.csv")
args = parser.parse_args()
try:
run(args.input, args.output)
except (ValueError, OSError) as error:
parser.exit(1, f"ERROR: {error}\n")
if __name__ == "__main__":
main()
"""Wait for the requested number of live one-core workers; no PySpark needed."""
import argparse
import json
import time
from urllib.error import URLError
from urllib.request import urlopen
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--workers", type=int, required=True)
parser.add_argument("--timeout", type=int, default=90)
args = parser.parse_args()
if args.workers < 1 or args.timeout < 1:
parser.error("workers and timeout must be positive")
deadline = time.monotonic() + args.timeout
observed = "master not reached"
while time.monotonic() < deadline:
try:
with urlopen("http://spark-master:8080/json/", timeout=3) as response:
status = json.load(response)
live = [worker for worker in status["workers"] if worker["state"] == "ALIVE"]
cores = sum(int(worker["cores"]) for worker in live)
observed = f"ALIVE_WORKERS={len(live)} TOTAL_CORES={cores}"
if len(live) == args.workers and cores == args.workers:
print(observed)
print("REGISTRATION_CHECK=PASS")
return
except (URLError, TimeoutError, OSError, ValueError, KeyError) as error:
observed = str(error)
time.sleep(1)
parser.exit(1, f"REGISTRATION_CHECK=FAIL: {observed}\n")
if __name__ == "__main__":
main()
"""Verify a known answer and leave time to inspect the Spark UI."""
import argparse
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--wait-seconds", type=int, default=0)
args = parser.parse_args()
if args.wait_seconds < 0:
parser.error("wait-seconds must be nonnegative")
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("Public-First-Sum").getOrCreate()
try:
spark.sparkContext.setLogLevel("WARN")
print(f"MASTER={spark.sparkContext.master}", flush=True)
print(f"APP_ID={spark.sparkContext.applicationId}", flush=True)
numbers = spark.range(1, 101, 1, 4)
numbers.printSchema()
result = numbers.agg(F.sum("id").alias("total"))
result.explain()
total = result.first()["total"]
if total != 5050:
raise ValueError(f"expected 5050, received {total}")
print(f"SUM_1_TO_100={total}", flush=True)
print("CHECK=PASS", flush=True)
print(f"UI_WAIT_SECONDS={args.wait_seconds}", flush=True)
time.sleep(args.wait_seconds)
finally:
spark.stop()
print("APP_STOPPED", flush=True)
if __name__ == "__main__":
main()
#!/usr/bin/env bash
set -euo pipefail
app="${1:-sum_app.py}"
if (( $# > 0 )); then shift; fi
case "$app" in
sum_app.py|benchmark_app.py) ;;
*) echo "Choose sum_app.py or benchmark_app.py" >&2; exit 2 ;;
esac
cores="${TOTAL_EXECUTOR_CORES:-2}"
if [[ ! "$cores" =~ ^[1-9][0-9]*$ ]]; then
echo "TOTAL_EXECUTOR_CORES must be a positive integer" >&2
exit 2
fi
exec /opt/spark/bin/spark-submit \
--master spark://spark-master:7077 \
--deploy-mode client \
--driver-memory 512m \
--executor-memory 512m \
--executor-cores 1 \
--total-executor-cores "$cores" \
--conf spark.dynamicAllocation.enabled=false \
--conf spark.sql.adaptive.enabled=false \
--conf spark.driver.host=spark-client \
--conf spark.driver.bindAddress=0.0.0.0 \
--conf spark.driver.port=39000 \
--conf spark.blockManager.port=39001 \
"/opt/spark/apps/$app" "$@"
"""Count the supplied small text fixture, save Parquet, then read it back."""
import argparse
import csv
from pathlib import Path
def small_counts(frame):
rows = frame.limit(101).collect()
if len(rows) > 100:
raise ValueError("this teaching check supports at most 100 distinct words")
return {row["word"]: row["count"] for row in rows}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", default="/opt/spark/lab-data/words.txt")
parser.add_argument("--expected", default="/opt/spark/lab-expected/wordcounts.csv")
parser.add_argument("--output", default="/opt/spark/lab-output/wordcounts")
args = parser.parse_args()
with Path(args.expected).open(encoding="utf-8-sig", newline="") as stream:
expected = {row["word"]: int(row["count"]) for row in csv.DictReader(stream)}
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("Public-Wordcount").getOrCreate()
try:
spark.sparkContext.setLogLevel("WARN")
if not spark.sparkContext.master.startswith("local["):
raise ValueError("use --master local[2]: files are mounted only in the client")
lines = spark.read.text(args.input)
words = lines.select(
F.explode(F.split(F.col("value"), r"\s+")).alias("word")
).where(F.col("word") != "")
counts = words.groupBy("word").count()
ordered = counts.orderBy(F.desc("count"), F.asc("word"))
print("INPUT_LINES")
lines.show(truncate=False)
print("WORD_ROWS")
words.show(truncate=False)
print("WORD_COUNTS")
ordered.show(truncate=False)
ordered.explain()
if small_counts(ordered) != expected:
raise ValueError("word counts differ from the expected CSV")
ordered.write.mode("errorifexists").parquet(args.output)
restored = spark.read.parquet(args.output)
if small_counts(restored) != expected:
raise ValueError("Parquet round-trip check failed")
print("WORDCOUNT_CHECK=PASS", flush=True)
print(f"PARQUET_OUTPUT={args.output}", flush=True)
finally:
spark.stop()
print("APP_STOPPED", flush=True)
if __name__ == "__main__":
main()
"""Measure repeated aggregation actions; timings are produced only by real runs."""
import argparse
import statistics
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--end", type=int, default=1_000_001)
parser.add_argument("--partitions", type=int, default=128)
parser.add_argument("--repetitions", type=int, default=3)
parser.add_argument("--wait-seconds", type=int, default=0)
args = parser.parse_args()
if not 2 <= args.end <= 4_294_967_296:
parser.error("end must be 2..4294967296 (keeps the sum within signed 64-bit)")
if args.partitions < 1 or args.repetitions < 1 or args.wait_seconds < 0:
parser.error("partitions/repetitions must be positive; wait must be nonnegative")
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("Public-Worker-Benchmark").getOrCreate()
try:
spark.sparkContext.setLogLevel("WARN")
context = spark.sparkContext
print(f"MASTER={context.master}", flush=True)
print(f"APP_ID={context.applicationId}", flush=True)
print(f"REQUESTED_CORES={context.getConf().get('spark.cores.max', 'unset')}", flush=True)
print(f"END_EXCLUSIVE={args.end}", flush=True)
print(f"PARTITIONS={args.partitions}", flush=True)
print(f"REPETITIONS={args.repetitions}", flush=True)
numbers = spark.range(1, args.end, 1, args.partitions)
expected = (args.end - 1) * args.end // 2
durations = []
for repeat in range(1, args.repetitions + 1):
started = time.perf_counter()
total = numbers.agg(F.sum("id").alias("total")).first()["total"]
elapsed = time.perf_counter() - started
if total != expected:
raise ValueError(f"expected {expected}, received {total}")
durations.append(elapsed)
print(f"RUN_{repeat}_SECONDS={elapsed:.6f}", flush=True)
print(f"SUM_RESULT={total}", flush=True)
print(f"MEDIAN_SECONDS={statistics.median(durations):.6f}", flush=True)
print("CHECK=PASS", flush=True)
print(f"UI_WAIT_SECONDS={args.wait_seconds}", flush=True)
time.sleep(args.wait_seconds)
finally:
spark.stop()
print("APP_STOPPED", flush=True)
if __name__ == "__main__":
main()
order_id,order_date,amount
A001,2026-09-01,10000
A002,2026-09-01,20000
A002,2026-09-01,20000
A003,2026-09-02,5000
A003,2026-09-02,5000
spark spark
data spark
data pipeline
order_id,order_date,amount
A001,2026-09-01,10000
A002,2026-09-01,20000
A003,2026-09-02,5000
word,count
spark,3
data,2
pipeline,1
condition,workers,requested_cores,end_exclusive,partitions,repetitions,run1_seconds,run2_seconds,run3_seconds,median_seconds,observed_executors,check,notes
A1,1,1,1000000001,128,3,,,,,,,not_measured
A2,2,2,1000000001,128,3,,,,,,,not_measured
A3,4,4,1000000001,128,3,,,,,,,not_measured
A4,8,8,1000000001,128,3,,,,,,,not_measured
B,8,2,1000000001,128,3,,,,,,,not_measured