← Kỹ sư dữ liệu← Data Engineer
Kỹ sư dữ liệuData Engineer19 Th7, 2026Jul 19, 202625 phút đọc20 min read

CI/CD cho Data EngineeringCI/CD for Data Engineering

Thuộc bộ kiến thức Data Engineer Roadmap.

Tổng quan

CI/CD cho một application thông thường trả lời một câu hỏi khá gọn: “code mới có chạy đúng không, và nếu không thì có rollback được không?” Một deploy application tệ thường chỉ có nghĩa là website bị down hoặc một nút bấm bị hỏng — khó chịu, dễ thấy, và gần như luôn có thể khắc phục bằng cách deploy lại phiên bản trước. CI/CD cho một data pipeline phải trả lời một câu hỏi khó hơn, vì artifact được deploy không chỉ phục vụ request — nó ghi dữ liệu. Một deploy dbt model tệ không chỉ đơn giản là chạy fail; nếu nó chạy thành công với logic sai, nó âm thầm ghi đè lên một bảng production với những con số sai, và bảng đó có thể được đọc bởi bốn mươi dashboard downstream, ba machine learning feature, và một báo cáo tài chính gửi lên ban lãnh đạo. Rollback code thì dễ. Rollback lại dữ liệu đã được ghi — cùng mọi bảng derived tính toán từ nó kể từ đó — thì thường không dễ chút nào.

Đây chính là chủ đề trọng tâm của bài viết này: cơ chế của CI/CD (build, test, deploy, verify) giống nhau bất kể bạn đang ship cái gì, và ../devops/en/11-ci-cd.md đã bao quát các cơ chế đó rất kỹ — các stage của pipeline, test pyramid, artifact promotion, so sánh công cụ. Điều thực sự khác biệt ở data engineering là cái gì cần được test trước khi merge (tính đúng đắn của một SQL transformation trên hình dạng dữ liệu thực, chứ không chỉ cú pháp), bao nhiêu phần của project cần được test lại (dbt/Airflow project lớn khiến việc “test toàn bộ trên mọi PR” trở nên quá chậm, đó là lý do có Slim CI), và “blast radius” nghĩa là gì (một deploy tệ làm hỏng một bảng warehouse dùng chung, có trạng thái, thay vì làm crash một service stateless mà chỉ cần redeploy là khắc phục ngay). Bài viết này đi qua CI cho dbt project (bao gồm Slim CI), CI cho Airflow DAG, các chiến lược deployment giới hạn blast radius cho thay đổi dữ liệu, và các quy ước version control đặc thù cho data project — dựa trên nền tảng của ./09-workflow-orchestration.md về những gì đang được orchestrate và ./16-testing-for-data-pipelines.md về các kỹ thuật test mà CI thực sự chạy.

Vì sao “cứ deploy lại là xong” không cứu bạn trong data engineering

Bản năng khiến CI/CD của application trở nên khoan dung — “nếu deploy tệ, rollback container là ổn trong vòng một phút” — sụp đổ với data pipeline vì ba lý do mang tính cấu trúc:

  1. State sống lâu hơn deploy. Một application server không giữ state giữa các lần deploy; một bảng warehouse thì có. Nếu một transformation tệ đã chạy và ghi đè dim_customers bằng logic dedupe thực ra là sai, việc revert code dbt về commit trước không hề xóa bỏ dữ liệu tệ đang nằm trong bảng. Bạn cần chạy lại model đã sửa — và đến lúc đó, mọi model downstream tiêu thụ bảng tệ cũng đã tính toán dựa trên nó.
  2. Consumer khuếch tán blast radius về phía trước. Một API endpoint bị hỏng fail từng request một, và lỗi dừng lại ngay khi bạn rollback. Một bảng warehouse bị hỏng được đọc bởi mọi dashboard, mọi export theo lịch, mọi ML feature pipeline query nó trong khoảng thời gian giữa lúc chạy tệ và lúc sửa — mỗi consumer đó lại phục vụ những con số sai cho chính khán giả downstream của nó, một số trong đó có thể hành động dựa trên đó (một báo cáo tài chính đã gửi email, một chiến dịch marketing kích hoạt dựa trên một segment sai) trước khi ai đó nhận ra.
  3. “Chạy thành công” không có nghĩa là “đúng”. Một SQL transformation với logic join sai một cách tinh vi vẫn chạy tốt, trả về exit code 0, và tạo ra một bảng trông hoàn toàn hợp lý — cho đến khi ai đó ở downstream nhận ra doanh thu bị đếm gấp đôi ba tuần sau đó. Không như một application crash, thường không hề có lỗi nào để alert; failure mode ở đây là dữ liệu sai một cách âm thầm, đó là lý do vì sao test tính đúng đắn của dữ liệu trước khi merge (chứ không chỉ test code compile được) là mối quan tâm trung tâm của CI ở đây.

Không điều nào trong số này có nghĩa là data pipeline cần một toolchain CI/CD hoàn toàn khác — cùng một GitHub Actions, GitLab CI, hay Jenkins build application code cũng build dbt project và Airflow DAG. Điều thay đổi là bạn test cái gì, test lại bao nhiêu phần của project cho mỗi thay đổi, và bạn stage việc rollout chính dữ liệu đó cẩn thận đến mức nào, chứ không chỉ rollout code.

Kiến thức nền tảng

Hình dạng chung của CI/CD, nhắc lại

Như đã nêu trong ../devops/en/11-ci-cd.md, một pipeline chạy build → test → scan → package → deploy → verify, promote cùng một artifact đã test qua dev → staging → production thay vì build lại cho từng environment. Hình dạng đó áp dụng cho data project mà không đổi: một dbt project hay một Airflow DAG repo là code được version-control, được lint, test, và promote qua các environment y hệt một application. Mọi thứ trong bài viết này giả định nền tảng đó và chồng thêm các mối quan tâm đặc thù cho data lên trên — nó không thay thế nền tảng đó. Nếu bạn chưa đọc phần nền tảng về CI vs. delivery vs. deployment, các stage của pipeline, và các chiến lược deployment (blue-green, canary, rolling), hãy đọc bài đó trước.

CI cho dbt project: test SQL trước khi nó chạm vào bảng dùng chung

Về bản chất, một dbt project là một directed graph gồm các câu lệnh SQL SELECT (model) cộng với các assertion về dữ liệu mà những model đó tạo ra (test). Câu hỏi CI tự nhiên là: trước khi merge một thay đổi vào model, SQL mới có thực sự build đúng và pass test không? Câu trả lời đơn giản nhất — chạy dbt builddbt test cho toàn bộ project với một schema staging, trên mỗi pull request — hoạt động, và với một project nhỏ thì đó chính xác là điều nên làm. dbt build compile và chạy từng model (tôn trọng dependency graph) rồi thực thi các test gắn với nó, nên một câu lệnh duy nhất cho bạn biết cả “có chạy được không” lẫn “output có đúng không”.

Vấn đề là cách này không scale được. Một dbt project với vài trăm model có thể mất hai mươi đến ba mươi phút để rebuild toàn bộ, và nếu một PR chỉ đụng vào một mart ở downstream, việc rebuild toàn bộ số bảng staging tương đương cả warehouse trên mỗi commit là lãng phí thời gian và chi phí. Đây chính xác là trade-off giữa tốc độ feedback và độ tin cậy đã nêu trong ../devops/en/11-ci-cd.md, áp dụng vào SQL: bạn muốn có độ tin cậy rằng thay đổi là đúng, nhưng không muốn trả giá cho việc test code đã được chứng minh là không hề thay đổi.

Slim CI: chỉ test những gì đã thay đổi

Câu trả lời của dbt cho vấn đề này là Slim CI (về mặt kỹ thuật, là selector state:modified kết hợp với deferral). Ý tưởng khá đơn giản khi đã hiểu: dbt có thể so sánh manifest (manifest.json) của project đã compile trong PR hiện tại với manifest của lần chạy production thành công gần nhất, và tính chính xác model nào đã được thêm, thay đổi, hoặc có upstream dependency bị thay đổi. Sau đó bạn yêu cầu dbt chỉ build tập modified đó — cộng với mọi thứ downstream của nó, vì một thay đổi trong một model có thể ảnh hưởng đến bất cứ thứ gì select từ nó:

dbt build --select "state:modified+" --state ./prod-manifest

Dấu + sau state:modified có nghĩa “và mọi thứ downstream của các model đã thay đổi”, điều này rất quan trọng — nếu bạn thay đổi stg_orders, bạn cần rebuild và test lại fct_ordersmart_revenue dù bạn không đụng vào SQL của chúng, vì tính đúng đắn của chúng phụ thuộc vào output của stg_orders. --state trỏ đến một thư mục chứa manifest production để diff; trên thực tế manifest này thường được lấy từ artifact của lần chạy production thành công gần nhất (nhiều team lưu nó trong cloud storage hoặc như một CI artifact sau mỗi lần deploy production).

Slim CI còn dùng thêm deferral (--defer) để các model upstream không thay đổi hoàn toàn không cần được rebuild trong schema CI — dbt resolve các reference đến chúng bằng cách trỏ thẳng vào các bảng production đã được build sẵn, thay vì rebuild một bản copy trong schema CI tạm thời. Điều này có nghĩa một PR chỉ đụng vào một mart có thể validate mart đó trong vài giây thay vì rebuild hàng trăm model upstream mà nó không cần verify lại.

Một workflow GitHub Actions thực tế cho dbt Slim CI

Workflow dưới đây chạy trên mỗi pull request vào một dbt project, cài dependency, lấy manifest production gần nhất làm state để so sánh, và build/test chỉ tập con đã thay đổi trong một schema cô lập, riêng cho từng PR:

# .github/workflows/dbt-ci.yml
name: dbt Slim CI

on:
  pull_request:
    branches: [main]
    paths:
      - "models/**"
      - "macros/**"
      - "tests/**"
      - "dbt_project.yml"

concurrency:
  group: dbt-ci-${{ github.event.pull_request.number }}
  cancel-in-progress: true

env:
  DBT_PROFILES_DIR: ./
  # Isolate each PR's builds into its own schema so concurrent PRs don't collide
  DBT_SCHEMA: ci_pr_${{ github.event.pull_request.number }}

jobs:
  slim-ci:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout PR branch
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dbt
        run: pip install dbt-snowflake==1.8.0   # swap adapter for your warehouse

      - name: Fetch last production manifest
        run: |
          mkdir -p ./prod-manifest
          # manifest.json from the last successful production run,
          # published as a CI artifact or stored in cloud storage
          aws s3 cp s3://our-dbt-artifacts/prod/manifest.json ./prod-manifest/manifest.json
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: dbt deps
        run: dbt deps

      - name: dbt build (Slim CI — modified models + downstream only)
        run: |
          dbt build \
            --select "state:modified+" \
            --state ./prod-manifest \
            --defer \
            --target ci
        env:
          DBT_TARGET_SCHEMA: ${{ env.DBT_SCHEMA }}
          SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
          SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_CI_USER }}
          SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }}

      - name: Post dbt run summary as PR comment
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: 'dbt Slim CI finished — see the job logs for the full build/test summary.'
            })

      - name: Drop CI schema
        if: always()
        run: dbt run-operation drop_schema --args "{schema_name: ${{ env.DBT_SCHEMA }}}"

Vài chi tiết đáng chú ý: filter paths giúp tránh chạy toàn bộ job cho các PR chỉ đụng vào documentation; group concurrency hủy các lần chạy cũ khi PR có commit mới, nhờ vậy CI không lãng phí compute để test một diff đã lỗi thời; schema riêng cho từng PR đảm bảo hai PR đang mở không bao giờ đè lên bảng test của nhau; và bước teardown (if: always()) drop schema tạm ngay cả khi build fail, để schema CI không tích tụ và làm tăng chi phí warehouse. Với những project quá lớn đến mức Slim CI vẫn cảm thấy chậm, một số team thêm bước rebuild toàn bộ theo lịch (chạy dbt build không selector vào ban đêm) như một lớp backstop, để phát hiện độ lệch giữa manifest production và thực tế dù CI ở cấp PR chỉ bao giờ test một subgraph.

CI cho Airflow DAG: bắt lỗi trước khi chúng đến được scheduler

Một Airflow DAG là một file Python mà khi được import phải tạo ra một graph task hợp lệ, không cycle, không lỗi cú pháp — và nó được import bởi DagFileProcessor của scheduler trên mỗi chu kỳ parse, ngay trong production, bất kể bạn có định test nó hay không. Điều này tạo ra một failure mode gần như không tồn tại với application thông thường: một file DAG có bug có thể bị hỏng âm thầm trong Airflow UI (hiện banner import error) trong khi mọi DAG khác trong cùng Airflow instance vẫn chạy bình thường, và không ai để ý cho đến khi có người đi tìm một run chưa bao giờ xuất hiện trong graph view. CI cho DAG tồn tại chính xác để bắt các vấn đề ở thời điểm import và thời điểm cấu trúc này trước khi chúng đến được scheduler.

Test DAG validation

Bài test giá trị cao nhất cho một Airflow codebase là một DAG validation test dạng tổng quát — không phải business logic riêng cho project, chỉ đơn giản là kiểm tra mọi file DAG trong repository import sạch sẽ và tạo ra một graph có cấu trúc hợp lệ:

# tests/test_dag_integrity.py
import os
from airflow.models import DagBag

def test_no_import_errors():
    """Every DAG file in dags/ must parse without raising an exception."""
    dag_bag = DagBag(dag_folder="dags/", include_examples=False)
    assert len(dag_bag.import_errors) == 0, (
        f"DAG import failures:\n{dag_bag.import_errors}"
    )

def test_dags_have_no_cycles():
    """DagBag itself raises during load if a cycle exists, but assert explicitly
    that every discovered DAG is a well-formed, non-empty graph."""
    dag_bag = DagBag(dag_folder="dags/", include_examples=False)
    assert len(dag_bag.dags) > 0, "No DAGs were discovered — check dag_folder path"
    for dag_id, dag in dag_bag.dags.items():
        assert len(dag.tasks) > 0, f"DAG '{dag_id}' has no tasks"

def test_dags_have_required_tags_and_owner():
    """Enforce team conventions: every DAG must declare an owner for on-call routing."""
    dag_bag = DagBag(dag_folder="dags/", include_examples=False)
    for dag_id, dag in dag_bag.dags.items():
        assert dag.default_args.get("owner"), f"DAG '{dag_id}' has no owner set"
        assert dag.tags, f"DAG '{dag_id}' has no tags for filtering in the UI"

DagBag chính là cơ chế mà scheduler của Airflow dùng nội bộ để parse dags/, nên chạy nó trong CI tái tạo chính xác lỗi mà một deploy tệ sẽ gây ra ở production — một import thiếu, một lỗi gõ trong argument của operator, hay một cycle vô tình giữa hai TaskGroup đều lộ ra ở đây, trong một PR check, thay vì trong Airflow UI lúc 2 giờ sáng. Bài test này rẻ (vài giây, không cần external service nào) và nên có mặt trong mọi Airflow repository bất kể quy mô.

Lint DAG

Ngoài tính đúng đắn khi import, static linting bắt các “mùi” cấu trúc trước khi chúng trở thành sự cố production: một task không có upstream hay downstream dependency nào mà lẽ ra phải được nối vào graph, một DAG không có schedule và không chọn catchup một cách tường minh (âm thầm mặc định thành thứ tác giả không hề định), hay một ngày hardcode được dùng thay vì biến template {{ ds }} của Airflow (điều này phá vỡ backfill). Các công cụ như ruff/flake8 bắt các vấn đề Python tổng quát (import không dùng, tên chưa định nghĩa); một lint pass tùy chỉnh hoặc một thư viện như black/isort để format giữ cho file DAG nhất quán trong cả team, điều này quan trọng hơn nghe có vẻ vì file DAG được đọc nhiều hơn hẳn so với module application thông thường — mọi buổi review sự cố đều bắt đầu bằng việc đọc một file DAG.

Test logic của task tách biệt khỏi cấu trúc DAG

Test DAG validation chứng minh graph có cấu trúc hợp lệ; nó không nói gì về việc logic của từng task riêng lẻ có đúng hay không. Những thứ đó cần được test độc lập, giống hệt cách bạn unit test một hàm bất kể ai gọi nó. Nếu một PythonOperator gọi một hàm transform_customer_batch(records), hàm đó nên đủ nhỏ, và đủ tách biệt khỏi runtime context của Airflow, để có thể import và unit test trực tiếp:

# tests/test_transform_logic.py
from dags.customer_pipeline.transforms import transform_customer_batch

def test_transform_customer_batch_deduplicates_by_email():
    raw = [
        {"email": "a@example.com", "name": "A"},
        {"email": "a@example.com", "name": "A (dup)"},
    ]
    result = transform_customer_batch(raw)
    assert len(result) == 1

Sự tách biệt này — cấu trúc DAG được test qua DagBag, business logic được test như những hàm Python thuần túy — là điều giữ cho test suite của Airflow nhanh. Một anti-pattern phổ biến là viết test khởi chạy toàn bộ một DagRun qua scheduler để verify business logic; những test đó chậm, không ổn định, và phần lớn là dư thừa so với những gì một unit test thuần túy của hàm bên dưới đã chứng minh. Hãy dành test ở cấp DAG-run cho hành vi thực sự thuộc về cấp DAG (thứ tự task, cấu hình retry, phân nhánh trigger_rule) và giữ việc test transformation logic ở cấp unit. Về phân loại rộng hơn các loại test đối với chính output của pipeline (schema test, freshness test, kiểm tra bất thường row-count), xem ./16-testing-for-data-pipelines.md.

Khái niệm chính

Chiến lược deployment thích ứng cho data: giảm blast radius của transformation sai

../devops/en/11-ci-cd.md bao quát blue-green và canary deployment cho traffic của application — định tuyến một phần request đến phiên bản mới trước khi commit hoàn toàn. Data pipeline không thể “định tuyến traffic” theo nghĩa tương tự (một bảng không phục vụ live request như một web server), nhưng cùng ý tưởng nền tảng — validate phiên bản mới ở một nơi an toàn, rồi chuyển consumer sang một cách atomic — có những phiên bản tương ứng trực tiếp trong hạ tầng data, và chúng quan trọng chính xác vì việc ghi đè tại chỗ (in-place) là kiểu deploy rủi ro nhất có thể có đối với một bảng dùng chung.

Ghi vào bảng mới rồi swap (phiên bản data của blue-green). Thay vì để một model chạy trực tiếp CREATE OR REPLACE TABLE fct_orders AS SELECT ... lên bảng mà mọi consumer đang đọc, pipeline build phiên bản mới dưới một tên có version hoặc staged — fct_orders__build_20260719 hoặc hậu tố _stg — chạy toàn bộ bộ validation với bảng đó, và chỉ sau khi validation pass mới hoặc rename bảng vào đúng vị trí, hoặc trỏ lại một view/alias ổn định (fct_orders là một view trên bản build đã validate mới nhất) sang phiên bản mới. Nếu validation fail, không có gì ở downstream từng thấy dữ liệu tệ, vì không có gì downstream từng trỏ vào nó. Các warehouse hỗ trợ zero-copy cloning (Snowflake) hay table versioning/time travel (BigQuery, Delta Lake, Iceberg) khiến pattern này rẻ: clone hoặc branch bảng, build trên bản clone, validate, rồi swap alias — phiên bản “cũ” vẫn sẵn sàng như một rollback target tức thì nếu có gì đó bị phát hiện là sai sau khi swap.

Canary theo partition hoặc theo phần trăm. Với các bảng rất lớn hoặc streaming pipeline, một số team rollout transformation mới cho một partition, một customer segment, hoặc một region duy nhất trước, so sánh output của nó với logic cũ chạy song song (một kiểu so sánh “shadow” hay “dual-write”), và chỉ mở rộng rollout khi diff trông hợp lý. Cách này chậm hơn để thiết lập so với cutover thẳng, nhưng cho bạn một sự so sánh trực tiếp với dữ liệu có hình dạng production thay vì hoàn toàn dựa vào fixture test trước khi merge.

Vì sao điều này quan trọng hơn với data so với application: một canary application hoạt động sai chỉ ảnh hưởng đến những user được định tuyến đến nó, trong khoảng thời gian canary còn nhận traffic — blast radius bị giới hạn bởi khối lượng request và thời gian. Một lần ghi đè bảng tại chỗ sai có blast radius không giới hạn theo một chiều khác: mọi job downstream, dashboard, và export đọc bảng đó ở bất kỳ thời điểm nào trước khi ai đó nhận ra và sửa đều thừa hưởng dữ liệu tệ, và mỗi thứ đó, đến lượt mình, có thể đã tạo ra artifact downstream riêng của nó (một snapshot dashboard được cache, một báo cáo đã gửi email, một model được retrain trên feature tệ) mà không tự động được sửa khi bảng nguồn được sửa. Việc stage việc ghi và chỉ swap sau khi validate biến một lỗi không giới hạn, âm thầm thành một lỗi có giới hạn, được bắt trước khi ai đó nhìn thấy — đó chính là toàn bộ mục đích.

Quy ước version control đặc thù cho data project

../backend/en/04-version-control-and-collaboration.md bao quát các nền tảng Git, branching, và PR review áp dụng không đổi cho data repository — dbt project và Airflow DAG repo là các Git repo thông thường, được review qua pull request thông thường. Tuy nhiên có vài quy ước đặc thù cho data project:

Bức tranh công cụ

Các công cụ CI đa dụng — GitHub Actions, GitLab CI, CircleCI, Jenkins — chính xác là những công cụ chạy pipeline CI cho dbt và Airflow trên thực tế; không có gì trong data engineering đòi hỏi một engine CI khác biệt, và bảng so sánh công cụ trong ../devops/en/11-ci-cd.md áp dụng trực tiếp. Điều đáng biết là CI tích hợp sẵn của dbt Cloud, được thiết kế chuyên biệt cho các team lấy dbt làm trung tâm không muốn tự tay dựng Slim CI trong một pipeline YAML tổng quát: dbt Cloud hỗ trợ sẵn “Slim CI jobs” được trigger trên mỗi PR, tự động diff so với state của lần chạy production thành công gần nhất, dựng lên một schema tạm cho từng PR, và teardown sau đó — về cơ bản chính là toàn bộ workflow GitHub Actions ở trên, nhưng được cấu hình qua giao diện thay vì viết tay YAML. Các team đã đầu tư vào dbt Cloud để schedule production run thường dùng luôn tính năng CI của nó thay vì tự xây tương đương; các team chạy dbt-core mã nguồn mở cùng Airflow hoặc một orchestrator khác thường tự xây tương đương bằng GitHub Actions/GitLab CI, vì không có một control plane dbt Cloud được host riêng để hook vào.

Best Practices

Duy trì các schema dev, staging, và production thực sự tách biệt, và promote giữa chúng một cách có chủ đích. Mỗi developer nên có thể chạy dbt build trên schema dev riêng của họ mà không có rủi ro chạm vào bảng mà ai khác phụ thuộc; CI nên build vào một schema tạm, giới hạn trong phạm vi PR; và production chỉ nên được ghi bởi job production theo lịch chạy từ branch main. Làm mờ ranh giới này — developer test “chỉ lần này thôi” trực tiếp lên schema production, hay CI chia sẻ một schema giữa các PR đang chạy song song — là cách những thay đổi chưa được test rò rỉ vào các bảng mà mọi người phụ thuộc.

Yêu cầu test pass như một merge gate, không phải một kiểm tra mang tính lịch sự. Một CI job chạy dbt test nhưng không chặn merge khi nó fail chỉ mang tính hình thức. Các branch protection rule yêu cầu status check của CI phải pass trước khi một PR được merge vào main biến “chúng ta có test” thành “SQL chưa được test không thể đến được production”, đó mới là sự đảm bảo thực sự bạn muốn có.

Xây dựng khả năng rollback tự động ngay trong bước deploy, không chỉ trong kế hoạch khôi phục. Vì state của bảng tồn tại lâu hơn một lần rollback code, “rollback” đối với một data pipeline có nghĩa nhiều hơn git revert — nó có nghĩa là khả năng trỏ một view/alias trở lại phiên bản bảng đã biết là tốt gần nhất (đó là lý do có pattern ghi-vào-bảng-mới-rồi-swap ở trên), hoặc chạy lại một historical DAG run cụ thể để tái tạo một bảng đã sửa từ nguồn. Hãy thiết kế cơ chế deploy sao cho đây là một lệnh hoặc một nút bấm duy nhất, không phải một quá trình khôi phục ứng biến dưới áp lực lúc 2 giờ sáng.

Không có “cowboy deploy”: không bao giờ sửa tay một file DAG, config của một job dbt đã được lên lịch, hay schema của một bảng production ngoài con đường CI/CD. Luôn có cám dỗ SSH vào một máy scheduler hoặc bấm “edit” trong console của warehouse để hotfix điều gì đó dưới áp lực — và đó chính xác là cách sự lệch pha giữa cái nằm trong Git và cái thực sự đang chạy ở production tích tụ, âm thầm làm mất hiệu lực của cam kết “main branch == cái đang được deploy”. Mọi thay đổi, kể cả fix khẩn cấp, nên đi qua cùng con đường PR → CI → deploy, dù con đường đó có thể cần một “làn hotfix” được đẩy nhanh với review nhẹ hơn (nhưng không bị bỏ qua) cho các sự cố thực sự.

Giữ manifest production dùng cho Slim CI luôn tươi mới. Tính hữu ích của Slim CI hoàn toàn phụ thuộc vào việc so sánh với một bức tranh chính xác về những gì thực sự đang chạy ở production; nếu manifest.json được lưu để dùng làm --state bị cũ (vì một bước deploy quên publish nó, hoặc ai đó merge mà không đi qua CI), Slim CI bắt đầu âm thầm bỏ qua những model thực sự đã thay đổi, điều này phá hỏng toàn bộ mục đích của nó. Publish manifest như một artifact của mỗi lần deploy production thành công — tự động, như một phần của cùng job deploy — là điều giữ cho cơ chế này đáng tin cậy.

Tài liệu tham khảo

Part of the Data Engineer Roadmap knowledge base.

Overview

CI/CD for a web application answers a fairly contained question: “does the new code work, and can we roll it back if it doesn’t?” A bad application deploy usually means the website is down or a button is broken — annoying, visible, and almost always reversible by redeploying the previous version. CI/CD for a data pipeline has to answer a harder question, because the artifact being deployed doesn’t just serve requests — it writes data. A bad dbt model deploy doesn’t just fail to run; if it runs successfully with wrong logic, it silently overwrites a production table with incorrect numbers, and that table might be read by forty downstream dashboards, three machine learning features, and a finance report that goes to the board. Rolling back the code is easy. Rolling back the data it already wrote — and every derived table computed from it since — is often not.

This is the core theme of this note: the mechanics of CI/CD (build, test, deploy, verify) are the same regardless of what you’re shipping, and ../devops/en/11-ci-cd.md covers those mechanics in depth — pipeline stages, the test pyramid, artifact promotion, tool comparisons. What’s genuinely different for data engineering is what has to be tested before merge (a SQL transformation’s correctness against real data shapes, not just its syntax), how much of the project needs re-testing (large dbt/Airflow repos make “test everything on every PR” too slow, hence Slim CI), and what “blast radius” means (a bad deploy corrupts a shared, stateful warehouse table rather than crashing a stateless service that a redeploy instantly fixes). This note covers CI for dbt projects (including Slim CI), CI for Airflow DAGs, deployment strategies that limit blast radius for data changes, and version-control conventions specific to data projects — building on ./09-workflow-orchestration.md for what’s being orchestrated and ./16-testing-for-data-pipelines.md for the test techniques CI actually runs.

Why “just redeploy” doesn’t save you in data engineering

The instinct that makes application CI/CD forgiving — “if the deploy is bad, roll back the container and you’re fine within a minute” — breaks down for data pipelines for three structural reasons:

  1. State outlives the deploy. An application server holds no state between deploys; a warehouse table does. If a bad transformation ran and overwrote dim_customers with de-duplicated logic that’s actually wrong, reverting the dbt code back to the previous commit does not undo the bad data already sitting in the table. You need to re-run the corrected model — and by then, every downstream model that consumed the bad table has also computed on top of it.
  2. Consumers cache the blast radius forward. A broken API endpoint fails one request at a time, and the failure stops the moment you roll back. A broken warehouse table gets read by every dashboard, every scheduled export, every ML feature pipeline that queries it between the bad run and the fix — each of which now serves wrong numbers to its own downstream audience, some of whom may act on them (a finance report emailed out, a marketing campaign triggered off a bad segment) before anyone notices.
  3. “It ran successfully” doesn’t mean “it’s correct.” A SQL transformation with subtly wrong join logic executes fine, returns a 0 exit code, and produces a table that looks entirely plausible — until someone downstream notices revenue is double-counted three weeks later. Unlike an application crash, there is often no error at all to alert on; the failure mode is silently wrong data, which is why testing data correctness before merge (not just testing that code compiles) is the central concern of CI here.

None of this means data pipelines need a fundamentally different CI/CD toolchain — the same GitHub Actions, GitLab CI, or Jenkins that build application code build dbt projects and Airflow DAGs. What changes is what you test, how much of the project you re-test on each change, and how carefully you stage the rollout of data itself, not just the rollout of code.

Fundamentals

The general CI/CD shape, recapped

As covered in ../devops/en/11-ci-cd.md, a pipeline runs build → test → scan → package → deploy → verify, promoting the same tested artifact through dev → staging → production rather than rebuilding per environment. That shape applies to data projects unchanged: a dbt project or an Airflow DAG repository is version-controlled code that gets linted, tested, and promoted through environments exactly like an application. Everything in this note assumes that baseline and layers data-specific concerns on top of it — it does not replace it. If you haven’t read the fundamentals of CI vs. delivery vs. deployment, pipeline stages, and deployment strategies (blue-green, canary, rolling), read that note first.

CI for dbt projects: testing SQL before it touches a shared table

A dbt project is, at its core, a directed graph of SQL SELECT statements (models) plus assertions about the data those models produce (tests). The natural CI question is: before merging a change to a model, does the new SQL actually build correctly and pass its tests? The naive answer — run dbt build and dbt test for the entire project against a staging schema, on every pull request — works, and for a small project it’s exactly right. dbt build compiles and runs each model (respecting the dependency graph) and then executes the tests attached to it, so a single command gives you both “does it run” and “is the output correct.”

The problem is that this doesn’t scale. A dbt project with a few hundred models can take twenty or thirty minutes to fully rebuild, and if a PR only touches one downstream mart, rebuilding the entire warehouse’s worth of staging tables on every commit is wasted time and cost. This is the exact same feedback-speed-vs-confidence trade-off from ../devops/en/11-ci-cd.md, applied to SQL: you want confidence that the change is correct, but you don’t want to pay the cost of testing code that provably didn’t change.

Slim CI: only test what changed

dbt’s answer to this is Slim CI (technically, the state:modified selector combined with deferral). The idea is straightforward once you see it: dbt can compare the manifest (manifest.json) of the current PR’s compiled project against the manifest of the last known-good production run, and compute exactly which models were added, changed, or have a changed upstream dependency. You then tell dbt to build only that modified set — plus everything downstream of it, since a change to a model can affect anything that selects from it:

dbt build --select "state:modified+" --state ./prod-manifest

The + after state:modified means “and everything downstream of the modified models,” which is essential — if you change stg_orders, you need to rebuild and retest fct_orders and mart_revenue even though you didn’t touch their SQL, because their correctness depends on stg_orders’s output. --state points at a directory holding the production manifest to diff against; in practice this manifest is usually fetched from the last successful production run’s artifacts (many teams store it in cloud storage or as a CI artifact after every production deploy).

Slim CI additionally uses deferral (--defer) so that unmodified upstream models don’t need to be rebuilt in the CI schema at all — dbt resolves references to them by pointing at the already-built production tables instead of rebuilding a copy in the ephemeral CI schema. This means a PR that touches one mart can validate that mart in seconds rather than rebuilding hundreds of upstream models it doesn’t need to re-verify.

A real GitHub Actions workflow for dbt Slim CI

The following workflow runs on every pull request against a dbt project, installs dependencies, fetches the last production manifest as the comparison state, and builds/tests only the modified subgraph in an isolated, PR-specific schema:

# .github/workflows/dbt-ci.yml
name: dbt Slim CI

on:
  pull_request:
    branches: [main]
    paths:
      - "models/**"
      - "macros/**"
      - "tests/**"
      - "dbt_project.yml"

concurrency:
  group: dbt-ci-${{ github.event.pull_request.number }}
  cancel-in-progress: true

env:
  DBT_PROFILES_DIR: ./
  # Isolate each PR's builds into its own schema so concurrent PRs don't collide
  DBT_SCHEMA: ci_pr_${{ github.event.pull_request.number }}

jobs:
  slim-ci:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout PR branch
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dbt
        run: pip install dbt-snowflake==1.8.0   # swap adapter for your warehouse

      - name: Fetch last production manifest
        run: |
          mkdir -p ./prod-manifest
          # manifest.json from the last successful production run,
          # published as a CI artifact or stored in cloud storage
          aws s3 cp s3://our-dbt-artifacts/prod/manifest.json ./prod-manifest/manifest.json
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: dbt deps
        run: dbt deps

      - name: dbt build (Slim CI — modified models + downstream only)
        run: |
          dbt build \
            --select "state:modified+" \
            --state ./prod-manifest \
            --defer \
            --target ci
        env:
          DBT_TARGET_SCHEMA: ${{ env.DBT_SCHEMA }}
          SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
          SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_CI_USER }}
          SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }}

      - name: Post dbt run summary as PR comment
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: 'dbt Slim CI finished — see the job logs for the full build/test summary.'
            })

      - name: Drop CI schema
        if: always()
        run: dbt run-operation drop_schema --args "{schema_name: ${{ env.DBT_SCHEMA }}}"

A few details worth calling out: the paths filter avoids running the whole job for PRs that only touch documentation; the concurrency group cancels stale runs when a PR gets new commits, so CI doesn’t waste compute testing an outdated diff; the isolated per-PR schema means two open PRs never clobber each other’s test tables; and the teardown step (if: always()) drops the temporary schema even if the build fails, so CI schemas don’t accumulate and rack up warehouse cost. On projects too large for even Slim CI to feel fast, teams add scheduled full rebuilds (nightly dbt build with no selector) as a backstop, so drift between the production manifest and reality gets caught even though PR-level CI only ever tests a subgraph.

CI for Airflow DAGs: catching failures before they reach the scheduler

An Airflow DAG is a Python file that, when imported, must produce a valid graph of tasks with no cycles and no syntax errors — and it gets imported by the scheduler’s DagFileProcessor on every parse cycle, in production, whether or not you meant to test it. This creates a failure mode that doesn’t really exist for typical applications: a DAG file with a bug can be silently broken in the Airflow UI (showing an import error banner) while every other DAG in the same Airflow instance keeps running fine, and nobody notices until someone goes looking for a run that never appeared in the graph view. CI for DAGs exists specifically to catch these import-time and structure-time problems before they ever reach the scheduler.

DAG validation tests

The single highest-value test for an Airflow codebase is a generic DAG validation test — not project-specific business logic, just a check that every DAG file in the repository imports cleanly and produces a well-formed graph:

# tests/test_dag_integrity.py
import os
from airflow.models import DagBag

def test_no_import_errors():
    """Every DAG file in dags/ must parse without raising an exception."""
    dag_bag = DagBag(dag_folder="dags/", include_examples=False)
    assert len(dag_bag.import_errors) == 0, (
        f"DAG import failures:\n{dag_bag.import_errors}"
    )

def test_dags_have_no_cycles():
    """DagBag itself raises during load if a cycle exists, but assert explicitly
    that every discovered DAG is a well-formed, non-empty graph."""
    dag_bag = DagBag(dag_folder="dags/", include_examples=False)
    assert len(dag_bag.dags) > 0, "No DAGs were discovered — check dag_folder path"
    for dag_id, dag in dag_bag.dags.items():
        assert len(dag.tasks) > 0, f"DAG '{dag_id}' has no tasks"

def test_dags_have_required_tags_and_owner():
    """Enforce team conventions: every DAG must declare an owner for on-call routing."""
    dag_bag = DagBag(dag_folder="dags/", include_examples=False)
    for dag_id, dag in dag_bag.dags.items():
        assert dag.default_args.get("owner"), f"DAG '{dag_id}' has no owner set"
        assert dag.tags, f"DAG '{dag_id}' has no tags for filtering in the UI"

DagBag is the same mechanism Airflow’s scheduler uses internally to parse dags/, so running it in CI reproduces exactly the failure a bad deploy would cause in production — a missing import, a typo in an operator argument, or an accidental cycle between two TaskGroups all surface here, in a PR check, instead of in the Airflow UI at 2 a.m. This test is cheap (seconds, no external services needed) and belongs in every Airflow repository regardless of size.

DAG linting

Beyond import correctness, static linting catches structural smells before they become production incidents: a task with no upstream or downstream dependency that was probably meant to be wired into the graph, a DAG with no schedule and no catchup setting explicitly chosen (silently defaulting to something the author didn’t intend), or a hardcoded date used instead of Airflow’s templated {{ ds }} (which breaks backfills). Tools like ruff/flake8 catch general Python problems (unused imports, undefined names); a custom lint pass or a library like black/isort for formatting keeps DAG files consistent across a team, which matters more than it sounds because DAG files are read far more often than typical application modules — every incident review starts by reading one.

Testing task logic separately from DAG structure

The DAG validation test proves the graph is well-formed; it says nothing about whether an individual task’s logic is correct. Those need to be tested independently, the same way you’d unit test a function regardless of what calls it. If a PythonOperator calls a function transform_customer_batch(records), that function should be small enough, and decoupled enough from Airflow’s runtime context, to import and unit test directly:

# tests/test_transform_logic.py
from dags.customer_pipeline.transforms import transform_customer_batch

def test_transform_customer_batch_deduplicates_by_email():
    raw = [
        {"email": "a@example.com", "name": "A"},
        {"email": "a@example.com", "name": "A (dup)"},
    ]
    result = transform_customer_batch(raw)
    assert len(result) == 1

This separation — DAG structure tested via DagBag, business logic tested as plain Python functions — is what keeps Airflow test suites fast. A common anti-pattern is writing tests that spin up a full DagRun through the scheduler to verify business logic; those tests are slow, flaky, and mostly redundant with what a plain unit test of the underlying function already proves. Reserve DAG-run-level tests for genuinely DAG-level behavior (task ordering, retry configuration, trigger_rule branching) and keep transformation logic testing at the unit level. For the broader taxonomy of test types against pipeline outputs themselves (schema tests, freshness tests, row-count anomaly checks), see ./16-testing-for-data-pipelines.md.

Key Concepts

Deployment strategies adapted for data: reducing the blast radius of bad transformations

../devops/en/11-ci-cd.md covers blue-green and canary deployment for application traffic — routing a slice of requests to the new version before committing fully. Data pipelines can’t route “traffic” in the same sense (a table doesn’t serve live requests the way a web server does), but the same underlying idea — validate the new version somewhere safe, then atomically switch consumers over — has direct analogues in data infrastructure, and they matter precisely because in-place overwrites are the riskiest possible deploy pattern for a shared table.

Write-to-new-table-then-swap (the data equivalent of blue-green). Instead of a model running CREATE OR REPLACE TABLE fct_orders AS SELECT ... directly against the table every consumer reads, the pipeline builds the new version under a versioned or staged name — fct_orders__build_20260719 or a _stg suffix — runs the full validation suite against that table, and only after validation passes does it either rename the table into place or repoint a stable view/alias (fct_orders as a view over the latest validated build) at the new version. If validation fails, nothing downstream ever saw the bad data, because nothing downstream was pointed at it yet. Warehouses that support zero-copy cloning (Snowflake) or table versioning/time travel (BigQuery, Delta Lake, Iceberg) make this pattern cheap: clone or branch the table, build against the clone, validate, then swap the alias — the “old” version stays available as an instant rollback target if something is discovered wrong after the swap.

Canary-by-partition or canary-by-percentage. For very large tables or streaming pipelines, some teams roll out a new transformation to a single partition, a single customer segment, or a single region first, compare its output against the old logic running in parallel (a “shadow” or “dual-write” comparison), and only widen the rollout once the diff looks sane. This is slower to set up than a straight cutover but gives you a live comparison against production-shaped data rather than relying entirely on pre-merge test fixtures.

Why this matters more for data than for applications: an application canary that misbehaves affects only the users routed to it, for as long as the canary receives traffic — the blast radius is bounded by request volume and time. A bad in-place table overwrite has an unbounded blast radius in a different dimension: every downstream job, dashboard, and export that reads the table at any point before someone notices and fixes it inherits the bad data, and each of those, in turn, may have already produced its own downstream artifacts (a cached dashboard snapshot, an emailed report, a model retrained on bad features) that don’t automatically get corrected when the source table is fixed. Staging the write and swapping only after validation converts an unbounded, silent failure into a bounded, caught-before-anyone-sees-it failure — which is the entire point.

Version control conventions specific to data projects

../backend/en/04-version-control-and-collaboration.md covers Git fundamentals, branching, and PR review that apply unchanged to data repositories — dbt projects and Airflow DAG repos are ordinary Git repos, reviewed through ordinary pull requests. A few conventions are specific to data projects, though:

Tools landscape

The general-purpose CI tools — GitHub Actions, GitLab CI, CircleCI, Jenkins — are exactly the tools that run dbt and Airflow CI pipelines in practice; nothing about data engineering requires a different CI engine, and the tool comparison in ../devops/en/11-ci-cd.md applies directly. What is worth knowing about is dbt Cloud’s built-in CI, which is purpose-built for dbt-centric teams that don’t want to hand-roll Slim CI in a generic YAML pipeline: dbt Cloud natively supports “Slim CI jobs” that are triggered on every PR, automatically diff against the last successful production run’s state, spin up an ephemeral schema per PR, and tear it down after — essentially the entire GitHub Actions workflow shown above, but configured through a UI rather than hand-written YAML. Teams already invested in dbt Cloud for scheduling production runs often use its CI feature instead of building the equivalent themselves; teams running open-source dbt-core alongside Airflow or another orchestrator typically build the GitHub Actions/GitLab CI equivalent, since there’s no separately-hosted dbt Cloud control plane to hook into.

Best Practices

Maintain genuinely separate dev, staging, and production schemas, and promote between them deliberately. Every developer should be able to run dbt build against their own sandboxed dev schema without any risk of touching a table anyone else depends on; CI should build into an ephemeral, PR-scoped schema; and production should only ever be written to by the scheduled production job running off the main branch. Blurring these — developers testing “just this once” directly against a production schema, or CI sharing a schema across concurrent PRs — is how untested changes leak into tables people depend on.

Require passing tests as a merge gate, not a courtesy check. A CI job that runs dbt test but doesn’t block the merge when it fails is decorative. Branch protection rules that require the CI status check to pass before a PR can merge into main turn “we have tests” into “untested SQL cannot reach production,” which is the actual guarantee you want.

Build automated rollback capability into the deploy step itself, not just into your recovery plan. Because table state persists past a code rollback, “rollback” for a data pipeline means more than git revert — it means the ability to point a view/alias back at the last known-good table version (hence the write-new-then-swap pattern above), or to re-run a specific historical DAG run to regenerate a corrected table from source. Design the deploy mechanism so this is a single command or button, not an improvised recovery under pressure at 2 a.m.

No cowboy deploys: never hand-edit a DAG file, a scheduled dbt job’s config, or a production table’s schema outside the CI/CD path. It is always tempting to SSH into a scheduler box or click “edit” in a warehouse console to hot-fix something under pressure — and it is exactly how drift between what’s in Git and what’s actually running in production accumulates, silently invalidating the guarantee that “main branch == what’s deployed.” Every change, including urgent fixes, should go through the same PR → CI → deploy path, even if that path needs a fast-tracked “hotfix” lane with lighter-weight (but not skipped) review for genuine incidents.

Keep the production manifest for Slim CI fresh. Slim CI’s usefulness depends entirely on comparing against an accurate picture of what’s actually running in production; if the stored manifest.json used as --state goes stale (because a deploy step forgot to publish it, or someone merged without going through CI), Slim CI starts silently skipping models that actually changed, which defeats its entire purpose. Publishing the manifest as an artifact of every successful production deploy — automatically, as part of the same deploy job — is what keeps this reliable.

References