SoloLakehouse Phase 1 作战计划 + 思维操作系统
北极星目标: 12 个月内拿到 Frankfurt FinTech €100k+ Platform Engineer/Architect offer
Phase 1 时间窗口: 8 周(每周投入约 5–7 小时)
Phase 1 核心任务: 把 SoloLakehouse 从"个人学习项目"升级为"工程师作品"
一、为什么 Phase 1 是最重要的 8 周
在你开始写第一行代码之前,先理解这件事的逻辑:
面试官打开你的 GitHub repo,在最初 15 秒内会扫描以下信号:
- README 顶部有没有 CI badge(绿色 = 这个人知道工程规范)
- 项目结构是
notebooks/还是src/+tests/(前者 = 学生项目,后者 = 工程项目) - commit history 是否有意义("add stuff" vs "feat(ingestion): extract raw fetch logic to src/slh/ingestion")
这 15 秒决定了他们是否继续往下看。Phase 1 就是让这 15 秒传递正确的信号。
二、Phase 1 详细任务清单
Sprint 1(Week 1–2):src/ 模块化
交付物: src/slh/ 包存在,pip install -e . 可运行,Dagster assets 调用 src/ 中的函数而不是内联逻辑。
为什么这件事最先做: 没有 src/,你就无法写有意义的单元测试;没有测试,CI 就只是空架子。所有事情都依赖这一步。
目标目录结构
slh/
├── src/
│ └── slh/
│ ├── __init__.py
│ ├── ingestion/
│ │ ├── __init__.py
│ │ └── raw_fetch.py # 从外部数据源拉数据的逻辑
│ ├── transform/
│ │ ├── __init__.py
│ │ └── bronze_to_silver.py # 数据清洗/转换逻辑
│ ├── storage/
│ │ ├── __init__.py
│ │ └── iceberg_writer.py # 写 Iceberg 表的逻辑
│ └── models/
│ ├── __init__.py
│ └── schemas.py # Pydantic v2 数据模型
├── tests/ # Sprint 2 建立
├── dagster_slh/
│ └── assets/
│ └── bronze.py # 只做 orchestration,业务逻辑来自 src/
├── pyproject.toml
└── README.md
重构前 vs 重构后对比
重构前(学生写法,逻辑内联在 asset 里):
# dagster_slh/assets/bronze.py — 重构前
import requests
import pandas as pd
from dagster import asset
@asset
def raw_adsb_data():
response = requests.get("https://opensky-network.org/api/states/all")
data = response.json()
df = pd.DataFrame(data["states"])
df.columns = ["icao24", "callsign", "origin_country", ...]
# 50 行内联清洗逻辑
df = df.dropna(subset=["icao24"])
df["timestamp"] = pd.to_datetime(df["time_position"], unit="s")
return df
重构后(工程师写法,asset 只做 orchestration):
# dagster_slh/assets/bronze.py — 重构后
from dagster import asset
from slh.ingestion.raw_fetch import fetch_adsb_states # 来自 src/
from slh.transform.bronze_to_silver import normalize_adsb_states
@asset
def raw_adsb_data():
"""Fetches raw ADS-B state vectors from OpenSky Network."""
raw = fetch_adsb_states() # 业务逻辑在 src/ 里
return normalize_adsb_states(raw) # 可独立测试的函数
# src/slh/ingestion/raw_fetch.py
import requests
from slh.models.schemas import AirspaceSnapshot
def fetch_adsb_states(bbox: tuple = (47.0, 48.5, 8.0, 10.0)) -> AirspaceSnapshot:
"""
Fetches ADS-B state vectors for EDDF bounding box.
Returns a validated Pydantic model — never raw dict.
"""
response = requests.get(
"https://opensky-network.org/api/states/all",
params={"lamin": bbox[0], "lamax": bbox[1], "lomin": bbox[2], "lomax": bbox[3]},
timeout=30,
)
response.raise_for_status()
return AirspaceSnapshot(**response.json())
关键原则:Dagster asset 只负责"什么时候跑、读什么、写什么",不负责"怎么处理数据"。这样你才能在不启动 Dagster 的情况下测试业务逻辑。
pyproject.toml 配置
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-dir]
"" = "src"
配置完后运行 pip install -e .,之后在任何地方都可以 from slh.ingestion import fetch_adsb_states。
Sprint 1 验收标准
python -c "from slh.ingestion.raw_fetch import fetch_adsb_states; print('ok')"输出 ok- 至少 3 个 Dagster asset 的核心逻辑已迁移至
src/ git log --oneline显示有意义的 commit 信息(如refactor(ingestion): extract fetch logic to src/slh/ingestion)
Sprint 2(Week 3–4):pytest 测试基础设施
交付物: pytest tests/unit/ 全部绿色,test_platform_contracts.py 存在并通过。
为什么测试是"让代码能证明自己": 没有测试,你只能说"我相信这段代码是对的"。有测试,你可以说"这段代码在 X 条件下保证返回 Y"。面试官的区别感知是巨大的。
tests/ 目录结构
tests/
├── conftest.py # 共享 fixtures
├── unit/
│ ├── test_ingestion.py # 测试 src/slh/ingestion/
│ ├── test_transform.py # 测试 src/slh/transform/
│ └── test_schemas.py # 测试 Pydantic 模型
├── integration/
│ └── test_dagster_assets.py # 使用 dagster.materialize() 测试
└── test_platform_contracts.py # 平台契约测试(最重要)
conftest.py 示例
# tests/conftest.py
import pytest
import pandas as pd
from unittest.mock import MagicMock, patch
@pytest.fixture
def sample_adsb_df():
"""A minimal valid ADS-B DataFrame for testing transforms."""
return pd.DataFrame({
"icao24": ["3c6781", "3c4b26", None],
"callsign": ["DLH123 ", "BAW456", "UFO999"],
"origin_country": ["Germany", "United Kingdom", "Unknown"],
"time_position": [1700000000, 1700000001, 1700000002],
"altitude": [10000.0, 8500.0, None],
})
@pytest.fixture
def mock_minio_client():
"""Mock MinIO client so tests don't need a running MinIO instance."""
with patch("slh.storage.iceberg_writer.get_minio_client") as mock:
mock.return_value = MagicMock()
yield mock
单元测试示例
# tests/unit/test_transform.py
import pytest
from slh.transform.bronze_to_silver import normalize_adsb_states, clean_callsign
class TestCleanCallsign:
def test_strips_whitespace(self):
assert clean_callsign("DLH123 ") == "DLH123"
def test_handles_none(self):
assert clean_callsign(None) is None
def test_uppercase(self):
assert clean_callsign("dlh123") == "DLH123"
class TestNormalizeADSBStates:
def test_drops_rows_with_null_icao24(self, sample_adsb_df):
result = normalize_adsb_states(sample_adsb_df)
assert result["icao24"].notna().all(), "icao24 must never be null after normalization"
assert len(result) == 2 # one null row dropped
def test_timestamp_column_created(self, sample_adsb_df):
result = normalize_adsb_states(sample_adsb_df)
assert "event_timestamp" in result.columns
assert result["event_timestamp"].dtype == "datetime64[ns]"
test_platform_contracts.py — 最重要的文件
这个文件是你 14 个 ADR 的"活文档"。它不测试业务逻辑,而是测试"平台承诺是否还有效"。
# tests/test_platform_contracts.py
"""
Platform Contracts Test Suite
==============================
这些测试验证 SoloLakehouse 对外的"承诺"是否仍然成立。
每当平台升级时,这些测试应该首先运行。
对应 PLATFORM_CONTRACTS.md 中的每一条契约。
"""
import os
import pytest
class TestEnvironmentContracts:
"""ADR #07: 环境隔离通过 APP_PREFIX 实现,不允许硬编码。"""
def test_app_prefix_env_var_exists(self):
"""APP_PREFIX 必须在环境中设置,防止跨环境污染。"""
assert os.getenv("APP_PREFIX") is not None, (
"APP_PREFIX environment variable must be set. "
"See ADR #07: Environment Isolation Strategy."
)
def test_app_prefix_not_hardcoded_in_source(self):
"""检查 src/ 中没有硬编码的 bucket/table 前缀。"""
import subprocess
result = subprocess.run(
["grep", "-r", "slh-prod", "src/"],
capture_output=True, text=True
)
assert result.returncode != 0, (
"Found hardcoded 'slh-prod' prefix in src/. "
"Use os.getenv('APP_PREFIX') instead. See ADR #07."
)
class TestStorageContracts:
"""ADR #03: Iceberg over Delta Lake for open format interoperability."""
def test_iceberg_catalog_type(self):
"""Trino catalog must use Iceberg connector, not Hive or Delta."""
from slh.storage.catalog_config import get_catalog_type
assert get_catalog_type() == "iceberg", (
"Storage catalog must be Iceberg. See ADR #03."
)
pyproject.toml 测试配置
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"unit: fast tests, no external dependencies",
"integration: requires running Dagster/MinIO/Trino",
"slow: tests that take > 10 seconds",
]
addopts = "-v --tb=short"
[tool.coverage.run]
source = ["src/slh"]
omit = ["tests/*"]
Sprint 3(Week 5–6):GitHub Actions CI/CD
交付物: README 顶部有绿色 CI badge。这是面试官打开你 repo 时的第一眼信号。
完整 .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint:
name: Lint (Ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install Ruff
run: pip install ruff
- name: Run Ruff
run: ruff check src/ tests/
type-check:
name: Type Check (mypy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Run mypy
run: mypy src/slh --ignore-missing-imports --no-strict-optional
test:
name: Unit Tests (pytest)
runs-on: ubuntu-latest
env:
APP_PREFIX: slh-ci # 为 CI 设置测试用的 prefix
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Run unit tests
run: pytest tests/unit/ tests/test_platform_contracts.py -v --tb=short
注意:integration tests 被排除在 CI 之外,因为它们需要运行中的 MinIO/Trino。这是一个有意的架构决策,应记录在 PLATFORM_CONTRACTS.md 中。
README badge 代码
在 README.md 的最顶部添加:
# SoloLakehouse (SLH)



> A self-hosted data lakehouse built with Dagster, Apache Iceberg, MinIO, Trino, and MLflow.
> 14 Architecture Decision Records document every major platform decision.
Sprint 4(Week 7–8):v3 Infrastructure as Code
交付物: k8s/ 目录存在,kubectl apply -k k8s/ 语法有效,ADR #15 解释迁移理由。
k8s/ 目录结构
k8s/
├── namespace.yaml
├── kustomization.yaml
├── minio/
│ ├── deployment.yaml
│ └── service.yaml
├── trino/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── configmap.yaml
└── hive-metastore/
├── deployment.yaml
└── service.yaml
k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: slh-system
labels:
app.kubernetes.io/managed-by: kustomize
platform: solo-lakehouse
k8s/minio/deployment.yaml(核心示例)
apiVersion: apps/v1
kind: Deployment
metadata:
name: minio
namespace: slh-system
labels:
app: minio
component: object-storage
spec:
replicas: 1
selector:
matchLabels:
app: minio
template:
metadata:
labels:
app: minio
spec:
containers:
- name: minio
image: quay.io/minio/minio:latest
command: ["server", "/data", "--console-address", ":9001"]
ports:
- containerPort: 9000
name: api
- containerPort: 9001
name: console
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: minio-credentials
key: access-key
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: minio-credentials
key: secret-key
volumeMounts:
- mountPath: /data
name: minio-storage
volumes:
- name: minio-storage
persistentVolumeClaim:
claimName: minio-pvc
k8s/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: slh-system
resources:
- namespace.yaml
- minio/deployment.yaml
- minio/service.yaml
- trino/deployment.yaml
- trino/service.yaml
- trino/configmap.yaml
- hive-metastore/deployment.yaml
- hive-metastore/service.yaml
ADR #15 模板:v3 部署策略
# ADR #15: v3 Deployment Strategy — Kubernetes vs Docker Compose
**Date:** 2025-Q1
**Status:** Accepted
**Deciders:** Bill (Solo Project)
## Context
SoloLakehouse v1/v2 used Docker Compose for local deployment. As we move toward
v3 (targeting production-like environments on Hetzner VPS), we need to decide
whether to stay with Docker Compose or migrate to Kubernetes.
The decision also impacts how the platform is positioned in job search contexts:
target companies (Deutsche Börse, ING Deutschland, Trade Republic) all run
Kubernetes in production.
## Decision
We will adopt Kubernetes manifests (via Kustomize) as the primary deployment
artifact for SLH v3, while keeping Docker Compose for local development only.
## Consequences
**Positive:**
- Platform manifests align with industry standard deployment tooling
- Demonstrates K8s fluency to potential employers reviewing the repository
- Enables future multi-node deployment on Hetzner without architectural change
- Kustomize overlays allow environment separation (dev/staging/prod) without
duplicating manifests
**Negative:**
- Increased complexity for first-time local setup
- Requires a running K8s cluster (Minikube/k3s for local, Hetzner for remote)
- Longer initial setup time vs `docker-compose up`
## Mitigation
Docker Compose is retained in `docker/` for local development. README clearly
documents both paths. CI validates K8s manifest syntax via `kubectl --dry-run`.
三、思维操作系统:从"学生模式"到"顶级工程师/创业者模式"
这是整份文档中最重要的部分。技术任务清单告诉你做什么;这一节告诉你怎么想。
3.1 两种模式的根本差异
学生和工程师/创业者之间的差异,不是知识的差异,而是目标函数的差异。
学生的目标函数是个人理解的最大化。他不断追求"完全搞懂",总觉得还差一点准备,总在寻找更好的教程,总在添加更多功能因为"很有趣"。成功的标准是:"我理解了这个。"
工程师/创业者的目标函数是外部价值的交付。他追求"能解决谁的问题",一旦东西够好就推出去,收集反馈,迭代。成功的标准是:"这个东西让某人(面试官、用户、同事)的状态变好了。"
这两个目标函数,在日常行为上会产生完全不同的结果。
3.2 五个具体的认知切换
切换一:把"输出"作为最终证据,而不是"时间投入"
学生会说:"我在这个项目上花了三个月。"
工程师会说:"三个月前是 Docker Compose + Notebooks。现在是 src/模块化 + pytest + CI badge + K8s manifests。这是那三个月发生的事。"
时间是投入,不是证明。面试官不在乎你花了多久,他在乎你的 GitHub 上有什么。在开始每一周之前,问自己:这一周结束时,GitHub 上会多出什么可以被别人看到的东西?
切换二:永远先问"观众是谁"
学生在项目里加一个功能,因为"这个技术我想学一下"。
工程师在项目里加一个功能,因为"Deutsche Börse 的 Principal Engineer 看到这个会对我的平台工程能力有更高评价"。
每次你要做一个决定——写一个新功能、写一篇 ADR、重构一段代码——先问:这件事的观众是谁?他们看到这个会想什么?
你的 SoloLakehouse repo 的观众,是未来会看你 GitHub 的面试官。以这个标准来决定什么值得做、什么不做。ADS-B 3D CesiumJS 可视化之所以可以暂停,就是因为它的观众价值(对面试官的差异化)低于它的时间成本。
切换三:用"最小可信证明"代替"完美主义"
学生:"我的测试覆盖率还不够高,再等等。"
工程师:"5 个单元测试全部通过,CI 绿色,这已经可以被信任了。后续迭代增加覆盖率。"
每件事都有一个"最小可信证明"(Minimum Credible Proof)的阈值。这个阈值是:一个理性的、资深的工程师看到这个,会说'合理'而不是'不专业'。一旦达到这个阈值,就推出去,开始下一件事。
等到"完美"再推出去,在竞争激烈的求职市场是一种奢侈。
切换四:把决策本身当成资产,而不是障碍
学生:"我不确定用 Kubernetes 还是 Docker Compose,让我先研究一下。"(研究了三周)
工程师/ADR 思维:"基于目前的信息,我选择 Kubernetes,理由是 A、B、C。我把这个决策写下来,包括 trade-off。如果将来证明错了,我修改 ADR。"
你已经有 14 个 ADR,这说明你本能上已经接近这个模式了,只是还没有完全内化。ADR 的核心价值不是"记录正确的决策",而是"让决策可以被推翻,所以不需要害怕做决策"。
下次你遇到技术选择时,不要研究到"确定",而是研究到"可以写 ADR"。这两个阈值差距很大,前者可能永远到不了。
切换五:把你的一切输出理解为销售材料
这一条是最难接受的,但也是最重要的。
学生:"我不想过度包装我的项目,实力就是实力。"
创始人:"我的 README 是 landing page,我的 ADR 是案例研究,我的 CI badge 是质量背书,我的 commit history 是工作日志。每一样都在向读者传递信号。"
"销售"不是欺骗,是沟通。把真实的工作清晰地呈现给正确的观众,让他们能快速理解你的价值。你的 SoloLakehouse 本质上是一份用代码写成的求职材料,它的每一个细节都在对面试官说话。
你已经有很好的内容,但你的包装还不够。Phase 1 的工作,有一半是技术的,有一半是沟通的。
3.3 每天的思维校准问题
把以下问题打印出来或贴在显示器旁边,每天开始工作前问自己:
关于今天的任务:
- 今天结束后,GitHub 上会多出什么可见的东西?
- 这件事的"最小可信证明"是什么?我距离那个阈值还有多远?
- 如果今天只能做一件事,哪件事对面试官看到我 repo 的第一眼影响最大?
关于当前的决策:
- 我是在"学习"这个决策,还是在"做"这个决策?
- 我现在有足够的信息写一篇 ADR 吗?(如果是,就写,并继续前进)
- 这个决策的观众是谁?他们关心我选 A 还是 B,还是只关心我有没有做到?
关于整体方向:
- 我今天的工作,是在服务"12个月内拿到offer"这个目标,还是在服务"让自己感觉在学习"这个目标?
- 如果一个 Deutsche Börse 的 Principal Engineer 今天看我的 repo,他会说什么?
3.4 学生模式思维的"翻译表"
以下是一些最常见的"学生模式"内心独白,以及它们对应的"工程师模式"翻译。每当你发现自己有左边的想法时,强迫自己翻译到右边。
| 学生模式(原文) | 工程师/创始人模式(翻译) |
|---|---|
| "我还没完全搞懂 Kubernetes,再学一下" | "我需要一个能通过 kubectl --dry-run 验证的 manifest,现在就写" |
| "这个项目还不够好,不想给别人看" | "哪个具体问题让我不好意思展示?修掉那个问题,其余的 push 上去" |
| "我想把 ADS-B 3D 可视化做出来,很酷" | "这对面试官的价值是什么?哪个优先级更高的任务因此被延迟了?" |
| "测试覆盖率还不够,先等等" | "现在有几个测试?0 个还是 5 个?5 个通过的测试比 0 个完美的测试有价值" |
| "ADR 写得不够全面" | "ADR 够不够让一个新人理解这个决策?够了就发,不够就补一段 Consequences" |
| "我需要把整个 Lakehouse 重构完才能开始找工作" | "我需要把哪 3 件事做完,才能撑过30分钟的技术面试?只做那3件事" |
| "应该把所有 Notebook 都迁移到 src/" | "迁移最核心的2个,其他的 Notebook 加一个 import 就好,保持项目前进" |
3.5 如何在 SoloLakehouse 项目中实践这套思维
这套思维不是抽象的,它对你的 SLH 项目有非常具体的影响:
对项目范围的影响: v4、v5 的规划文档可以存在,但只需要一个文件 ROADMAP.md,最多两页。把精力从"规划未来"转移到"交付现在"。一个可以 demo 的 v3,比一份精美的 v5 愿景文档更能拿到 offer。
对 README 的影响: 你的 README 不是技术文档,是 landing page。它需要在前 3 句话里回答:这是什么、它解决了什么问题、一个工程师为什么应该在乎这个。然后才是技术细节。现在的大多数 README 是反过来的。
对 commit message 的影响: 采用 Conventional Commits 格式(feat:, fix:, refactor:, docs:)。这不只是规范,它让你的 commit history 成为一份工作日志,让面试官能看出你是怎么工作的。
对 ADR 的影响: 每当你做一个技术决策时,在写代码之前先写 ADR。不是事后补,是事前写。这会强迫你在动手前想清楚目的和 trade-off,这才是 ADR 最大的价值所在。
四、Phase 1 完成后的状态
8 周后,你的 SoloLakehouse repo 应该传递以下信号:
一个面试官打开你的 GitHub,他会看到:绿色的 CI badge、清晰的 src/slh/ 模块结构、tests/ 目录里有通过的测试、k8s/ 目录里有 Kubernetes manifests、docs/adr/ 里有 15 个 ADR(包括一个关于 K8s 迁移的)、以及一个说"这不是 side project,这是一个平台工程师的工作样本"的 README。
这就是 Phase 1 的终点。
然后,Phase 2 才开始:把这个作品变成一个故事,讲给 Frankfurt FinTech 的人听。
文档版本:Phase 1 v1.0
下次更新时机:Sprint 2 完成后,回顾并标记已完成的任务