别再手动装环境了!Docker Compose 一键搭建 Airflow + Postgres + Spark 完整数据栈

你的本地数据环境可能是个烂摊子

大多数数据工程师的本地环境能跑起来,但只在他们的机器上,而且得靠运气。你手动装 PostgreSQL,卡住 Python 版本,折腾 Airflow 又怕搞坏别的东西。新同事加入后花三天才能复现你的环境。

这本质不是工程问题,而是工具问题。Docker Compose 就是来解决它的。

手动安装 vs Docker Compose 工作流

Docker Compose 允许你把整个本地数据栈写成代码——服务、网络、卷、环境变量全在一个 compose.yaml 文件里,一条命令就能启动或销毁。不再有“在我机器上能跑”,不再有三天才能入门的噩梦。

本指南覆盖完整流程:Docker Compose 究竟是什么(以及不是什么)、你需要理解的四个核心概念、一个生产级的真实示例栈(包含 Airflow、PostgreSQL、Redis 和 Spark),以及大部分工程师容易踩的坑。


Docker Compose 是什么(以及不是什么)

Docker Compose 是一个编排工具,用于在单台机器上定义和运行多容器 Docker 应用。你写一个 compose.yaml 文件,描述每个服务——用什么镜像、暴露哪些端口、如何连接其他服务、数据存在哪里。

先注意一个关键点:Docker Compose v1 已在 2023 年 7 月结束生命周期。 旧的 docker-compose 命令(带连字符)已经没了。你应该使用 Docker Compose v2,它作为内置 CLI 插件随 Docker 一同提供。如果你在任何脚本或教程里看到 docker-compose,请把它替换成 docker compose(空格,没有连字符)。

另外,version: 字段在 Compose 文件顶部已经正式弃用,不需要写它,直接删掉。

Docker Compose 不是:

  • 生产环境里 Kubernetes 的替代品
  • 管理分布式多机器部署的工具
  • 生产环境下妥善管理机密信息的方案

但用于本地开发、CI 流水线和单机测试环境?它几乎是无敌的。


前置条件

在写 YAML 之前,确保你已具备:

  • Docker Desktop (v4.0+) 或 Linux 上的 Docker Engine + docker-compose-plugin
  • 至少 8GB 可用内存(数据栈很吃内存)
  • 最低 4 个 CPU 核心(Spark 尤其需要)
  • 基本的命令行操作能力

运行以下命令确认环境正常:

docker compose version
# 应该显示 v2.24 或更高版本(2026 年)

如果命令失败或显示 v1.x,请先更新 Docker。


四个核心概念

在搭建完整栈之前,你需要理解 Docker Compose 实际管理的四个东西。

Docker Compose 核心概念示意图

服务(Services)

一个服务就是一个运行中的容器。services: 下的每个条目会变成一个或多个容器。在数据工程栈里,服务就是你的数据库、调度器、消息代理、转换工具等。

网络(Networks)

默认情况下,一个 Compose 文件里的每个服务都可以用服务名作为主机名互相通信——不需要 IP 地址,不需要手动 DNS。这是最被低估的特性之一:你的 Airflow scheduler 连接 Postgres 时,主机名直接写 postgres 就行了。

卷(Volumes)

卷让你的数据在容器重启后依然存活。有两种类型:命名卷(由 Docker 管理,推荐用于数据库)和绑定挂载(宿主机的一个文件夹挂到容器里,适合 DAG、脚本和正在编辑的代码)。

环境变量(Environment Variables)

永远不要硬编码凭据到 compose 文件里。始终使用 .env 文件,并通过 ${VARIABLE_NAME} 语法引用变量。.env 文件不放进版本控制,compose.yaml 里就没有敏感信息。


搭建真实数据工程栈

我们来搭建一个实际可用的栈。这套工具覆盖了大部分数据工程工作流所需的内容:

  • PostgreSQL — 充当元数据库和数据仓库(端口 5432)
  • Redis — 作为 Celery 任务的消息代理(端口 6379)
  • Apache Airflow — 工作流调度器(端口 8080)
  • Apache Spark — 分布式数据处理(端口 4040 和 7077)
  • Adminer — 轻量级数据库管理界面(端口 8085)

数据工程栈及关系图

第一步:项目结构

先建一个干净的文件夹结构。文件夹混乱会导致挂载点和构建上下文一团糟。

data-eng-local/
├── compose.yaml
├── .env
├── .env.example
├── airflow/
│   ├── dags/
│   ├── logs/
│   ├── plugins/
│   └── config/
├── spark/
│   └── jobs/
├── postgres/
│   └── init/
│       └── 01_create_schemas.sql
└── README.md

两条规则:compose.yaml 放在根目录;永远不要把 .env 提交到 Git——现在就去添加到 .gitignore,免得忘记。

第二步:.env 文件

# .env —— 不要提交到版本控制
POSTGRES_USER=dataeng
POSTGRES_PASSWORD=changeme_local
POSTGRES_DB=warehouse

AIRFLOW_UID=50000
AIRFLOW__CORE__FERNET_KEY=your_fernet_key_here
AIRFLOW__WEBSERVER__SECRET_KEY=your_secret_key_here

REDIS_PASSWORD=redis_local_pass

生成 Fernet 密钥:

python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

第三步:compose.yaml 文件

下面是完整的配置。请仔细阅读行内注释——它们解释了设计决策,而不仅仅是语法。

# compose.yaml —— 不需要 version 字段(Compose v2 已弃用)

x-airflow-common: &airflow-common
  image: apache/airflow:3.0.4
  environment: &airflow-common-env
    AIRFLOW__CORE__EXECUTOR: CeleryExecutor
    # 连接到 PostgreSQL 元数据库
    AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres/${POSTGRES_DB}
    # Celery 结果后端也存到 PostgreSQL
    AIRFLOW__CELERY__RESULT_BACKEND: db+postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres/${POSTGRES_DB}
    # Celery 消息代理用 Redis
    AIRFLOW__CELERY__BROKER_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
    AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW__CORE__FERNET_KEY}
    AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW__WEBSERVER__SECRET_KEY}
    AIRFLOW_UID: ${AIRFLOW_UID}
  env_file:
    - .env
  volumes:
    - ./airflow/dags:/opt/airflow/dags        # 绑定挂载 —— 修改 DAG 不用重建容器
    - ./airflow/logs:/opt/airflow/logs
    - ./airflow/plugins:/opt/airflow/plugins
    - ./airflow/config:/opt/airflow/config
  depends_on:
    postgres:
      condition: service_healthy              # 等 PostgreSQL 健康检查通过后再启动
    redis:
      condition: service_healthy

services:
  # ─── 数据库 ──────────────────────────────────────────────────────────────
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data          # 命名卷 —— 容器重启后数据还在
      - ./postgres/init:/docker-entrypoint-initdb.d     # 首次启动时自动执行 SQL 脚本
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  # ─── 消息代理 ────────────────────────────────────────────────────────────
  redis:
    image: redis:7-alpine                    # Alpine 版本,镜像更小功能完整
    command: redis-server --requirepass ${REDIS_PASSWORD}
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  # ─── Airflow ──────────────────────────────────────────────────────────────
  airflow-init:
    <<: *airflow-common
    entrypoint: /bin/bash
    command:
      - -c
      - |
        airflow db migrate &&
        airflow users create \
          --username admin \
          --password admin \
          --firstname Admin \
          --lastname User \
          --role Admin \
          --email admin@example.com
    restart: "no"                            # 只运行一次就退出,不是长服务

  airflow-webserver:
    <<: *airflow-common
    command: webserver
    ports:
      - "8080:8080"
    healthcheck:
      test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 5
    restart: unless-stopped

  airflow-scheduler:
    <<: *airflow-common
    command: scheduler
    restart: unless-stopped

  airflow-worker:
    <<: *airflow-common
    command: celery worker
    restart: unless-stopped

  # ─── Spark ────────────────────────────────────────────────────────────────
  spark-master:
    image: bitnami/spark:3.5
    environment:
      - SPARK_MODE=master
    ports:
      - "4040:4040"                          # Spark UI
      - "7077:7077"                          # Spark 主节点端口
    volumes:
      - ./spark/jobs:/opt/spark-jobs
    restart: unless-stopped

  spark-worker:
    image: bitnami/spark:3.5
    environment:
      - SPARK_MODE=worker
      - SPARK_MASTER_URL=spark://spark-master:7077
      - SPARK_WORKER_MEMORY=2G
      - SPARK_WORKER_CORES=2
    depends_on:
      - spark-master
    restart: unless-stopped

  # ─── 数据库管理界面 ────────────────────────────────────────────────────────
  adminer:
    image: adminer:latest
    ports:
      - "8085:8080"
    depends_on:
      - postgres
    restart: unless-stopped

# ─── 命名卷 ────────────────────────────────────────────────────────────────
volumes:
  postgres_data:        # 由 Docker 管理,down/up 后数据依然存在
  redis_data:

第四步:启动栈

# 第一次运行,初始化 Airflow 数据库并创建管理员用户
docker compose up airflow-init

# 然后启动所有服务(后台模式)
docker compose up -d

# 查看所有容器状态
docker compose ps

# 查看某个服务的实时日志(调试很有用)
docker compose logs -f airflow-scheduler

就是这样。所有服务都变绿后,你可以访问:


健康检查:大多数人忽略的重要功能

这是大部分教程跳过的地方——但它真的很关键。

没有健康检查,depends_on 几乎没用。默认情况下,Docker 认为容器在进程启动的那一刻就算“已启动”,而不是里面的服务真正就绪。PostgreSQL 需要几秒初始化,Redis 也需要一点时间。如果 Airflow 在它们就绪之前就尝试连接,就会崩溃——然后你会看到一堆混乱的重启循环。

depends_on 里的 condition: service_healthy 解决了这个问题。它告诉 Docker:等那个服务的健康检查通过之后,再启动本服务。配合依赖服务上的 healthcheck 块,你的栈每次都能按正确顺序启动。

# 正确做法
depends_on:
  postgres:
    condition: service_healthy  # ← 关键

没有这个,你就是在靠运气。运气不是策略。


常见错误及如何避免

  • 把凭据硬写在 compose.yaml 里——别这么做。用 .env 文件,花 30 秒就能设置好,避免不小心把密码提交到公开仓库。
  • 使用 docker-compose(v1)而不是 docker compose(v2)——老命令已死。从 2023 年中之前的教程复制配置时,要检查这个。
  • 在 compose 文件里写 version:——这个字段在 Docker Compose v2 里已经过时,而且会在 Docker Desktop 里触发弃用警告。删掉它。
  • 没有固定镜像版本——postgres:latest 是个陷阱。一次升级可能让你的初始化 SQL 失败、连接字符串变化、扩展不存在。始终固定到具体版本,比如 postgres:16
  • 忘记把 .env 加到 .gitignore——真的,先做这个。
  • 运行 docker compose down -v 时以为只是 docker compose down——-v 会删除命名卷,意味着你的数据库数据全没了,无法撤销。用这个参数时一定要明确知道后果。

日常操作命令

栈跑起来后,你会经常用到这些命令:

# 查看运行状态和健康检查结果
docker compose ps

# 查看所有服务的实时日志
docker compose logs -f

# 只查看某个服务的日志
docker compose logs -f airflow-worker

# 重启某个服务,不碰其他服务
docker compose restart airflow-scheduler

# 在运行中的容器里执行一次性命令
docker compose exec postgres psql -U dataeng -d warehouse

# 进入容器的 shell 做调试
docker compose exec airflow-webserver bash

# 停止所有服务(保留卷,安全)
docker compose down

# 核武器——停止所有服务并删除所有卷
docker compose down -v

Docker Compose 开发者工作流示意图


用 Profiles 管理多环境

当你的栈变得复杂时,有个功能值得了解:Docker Compose Profiles 允许你定义只在特定上下文里启动的服务。给服务加上 profiles: [dev]profiles: [monitoring],它只在你显式请求该 profile 时才会运行。

services:
  # 这个服务只在运行 docker compose --profile monitoring up 时启动
  prometheus:
    image: prom/prometheus:latest
    profiles:
      - monitoring
    ports:
      - "9090:9090"

这样,你用一个 compose.yaml 就能适应所有环境——本地开发、CI、测试——而不需要维护多个文件。这是让 Compose 比它传统形象更接近生产级能力的特性之一。


工具版本与参考

本指南中使用的工具版本及官方文档:

进一步阅读:


总结

设置一个可靠的本地数据工程环境曾经是一整天的痛苦。有了 Docker Compose,这是一次性投资:写好 compose.yaml,提交到仓库,团队里每个人都能通过 docker compose up 获得完全一致的环境。

记住几点:

  • 删掉 version: 字段——它已弃用
  • 始终在 depends_on 里用 condition: service_healthy
  • 固定镜像版本,别用 latest
  • 凭据放在 .env,永远别放 compose 文件里
  • 命名卷要起名——不可恢复的数据没什么价值

本指南中的配置只是一个起点。随着栈增长,你还会用到 override 文件(compose.override.yaml)来做环境特定调整,以及 Compose Profiles 来开关监控工具、调试容器或测试数据库。

核心目标是可复现:当团队成员克隆你的仓库并运行 docker compose up 时,他们应该得到和你一模一样的环境。这不是锦上添花,而是严肃数据工程工作流的基础。


直达网址:https://docs.docker.com/compose/

类似文章