Series dbt · Bài 2/10. Bài này dựng một phòng lab riêng để chạy PostgreSQL trong Docker và dbt Core trên máy host. Kết thúc bài, bạn có database retail, bốn bảng raw của cửa hàng, project retail_analytics và view stg_orders đầu tiên. Các bài 3–5 sẽ mở rộng đúng project này, không tạo lại môi trường từ đầu.
Lab dùng Python 3.11, dbt-core 1.9.8, dbt-postgres 1.9.1 và PostgreSQL 16.6 để cố định cách thực hành. Đây là bộ phiên bản phục vụ học có chủ đích, không phải khẳng định phiên bản mới nhất hay lựa chọn bản vá cho production. Khi triển khai thực tế, chọn phiên bản còn được hỗ trợ và kiểm thử tương thích trong môi trường riêng.
1. Hiểu môi trường trước khi cài
PostgreSQL chạy trong container tên dịch vụ postgres, lắng nghe cổng 5432 nội bộ. Host mở cổng 55432 trên loopback 127.0.0.1 để dbt chạy ngoài Docker truy cập. Ta dùng 55432 để tránh đụng PostgreSQL 5432 đã có trên máy. Nếu cổng này vẫn bị chiếm, đổi cả Compose và profile ở các bước sau về cùng một cổng trống.
Các lệnh shell dành cho Bash trên Linux, macOS hoặc WSL. Cần có Docker cùng Compose v2 và Python 3.11 với module venv. Máy có thể dùng Python khác trong phạm vi adapter hỗ trợ, nhưng tạo môi trường riêng giúp tránh trộn thư viện với Airflow hoặc dự án Python khác. Không cài vào Python hệ thống chỉ để vượt qua lỗi externally-managed-environment.
| Thành phần | Chạy ở đâu | Cách kết nối |
|---|---|---|
| dbt CLI | Virtual environment trên host | 127.0.0.1:55432 |
| PostgreSQL | Container postgres | 5432 bên trong container |
| File project | Thư mục dbt-retail trên host | dbt đọc SQL và YAML tại đây |
| Dữ liệu database | Named volume pgdata | Không nằm trong thư mục models |

2. Dựng PostgreSQL bằng Docker Compose
Tạo thư mục và chọn mật khẩu lab bằng lệnh read. Mật khẩu được nhập kín trong terminal, dùng chung cho lần bootstrap PostgreSQL và profile dbt. Mỗi terminal mới phải export lại cùng giá trị. Không dùng thông tin đăng nhập của database thật và không đưa mật khẩu vào Git.
mkdir -p dbt-retail/init dbt-retail/profiles
cd dbt-retail
read -rsp "Lab database password: " DBT_PASSWORD
export DBT_PASSWORD
printf "\n"
File compose.yaml
name: dbt-retail
services:
postgres:
image: postgres:16.6
environment:
POSTGRES_USER: dbt_lab
POSTGRES_PASSWORD: ${DBT_PASSWORD:?Export DBT_PASSWORD first}
POSTGRES_DB: retail
ports:
- "127.0.0.1:55432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dbt_lab -d retail"]
interval: 5s
timeout: 5s
retries: 12
volumes:
pgdata:
POSTGRES_USER của image chính thức tạo tài khoản bootstrap có quyền cao trong lab. Production nên tách tài khoản quản trị, ingestion và dbt, chỉ cấp quyền đọc raw cùng quyền xây schema đích cần thiết. Bind loopback giới hạn truy cập từ máy local; cấu hình này không dành để mở database trực tiếp lên Internet.
docker compose config --quiet
docker compose up -d
docker compose ps
docker compose logs --tail=40 postgres
docker compose exec postgres pg_isready -U dbt_lab -d retail
Kỳ vọng healthcheck chuyển healthy và pg_isready báo accepting connections. Chạy config chỉ kiểm tra cấu trúc Compose, chưa xác nhận database đã sẵn sàng. Nếu restart container rồi đổi DBT_PASSWORD nhưng login thất bại, nhớ rằng POSTGRES_PASSWORD khởi tạo lúc volume còn trống; biến môi trường không tự đổi mật khẩu của role đã lưu.
3. Tạo bốn bảng nguồn và dữ liệu mẫu
Tạo file init/01_raw.sql với toàn bộ nội dung dưới đây. Ta chủ động nạp bằng psql qua stdin, nên không phụ thuộc cơ chế init chỉ chạy lần đầu của Docker image. File này tạo bảng nếu chưa có và chèn các khóa chưa tồn tại; chạy lại không tạo thêm bản trùng, nhưng cũng không ghi đè dòng đã sửa trong bài tập.
create schema if not exists raw;
create table if not exists raw.customers (
customer_id integer primary key,
customer_name text,
city text
);
create table if not exists raw.products (
product_id integer primary key,
product_name text,
category text
);
create table if not exists raw.orders (
order_id integer primary key,
customer_id integer,
ordered_at text,
status text
);
create table if not exists raw.order_items (
order_item_id integer primary key,
order_id integer,
product_id integer,
quantity integer,
unit_price_vnd numeric(14,0)
);
insert into raw.customers values
(1, ' An ', 'Ho Chi Minh'),
(2, 'Binh', 'Ha Noi'),
(3, 'Chi', 'Da Nang'),
(4, 'Dung', 'Ho Chi Minh')
on conflict (customer_id) do nothing;
insert into raw.products values
(10, 'Notebook', 'Stationery'),
(20, 'Pen', 'Stationery'),
(30, 'Bag', 'Accessories')
on conflict (product_id) do nothing;
insert into raw.orders values
(1001, 1, '2026-01-01', ' PAID '),
(1002, 2, '2026-01-01', 'cancelled'),
(1003, 1, '2026-01-02', 'paid'),
(1004, 3, '2026-01-02', 'pending'),
(1005, 2, '2026-01-03', 'paid')
on conflict (order_id) do nothing;
insert into raw.order_items values
(1, 1001, 10, 2, 50000),
(2, 1001, 20, 3, 10000),
(3, 1002, 30, 1, 200000),
(4, 1003, 30, 1, 200000),
(5, 1003, 20, 2, 10000),
(6, 1004, 10, 1, 50000),
(7, 1005, 10, 1, 50000)
on conflict (order_item_id) do nothing;
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 \
-U dbt_lab -d retail < init/01_raw.sql
docker compose exec -T postgres psql -U dbt_lab -d retail \
-c "select count(*) as orders from raw.orders;"
Kết quả mong đợi có 5 orders. Dữ liệu còn có 4 customers, 3 products và 7 order_items. ordered_at để dạng text nhằm mô phỏng một nguồn cần chuẩn hóa. Ta không khai báo foreign key giữa các bảng raw trong lab để còn thực hành kiểm tra quan hệ ở tầng phân tích, nhưng mỗi bảng có khóa chính để bộ dữ liệu ban đầu có grain rõ ràng.
| Bảng | Một dòng đại diện cho | Số dòng ban đầu |
|---|---|---|
| raw.customers | Một khách hàng | 4 |
| raw.products | Một sản phẩm | 3 |
| raw.orders | Một đơn hàng | 5 |
| raw.order_items | Một dòng hàng của đơn | 7 |
Giá bán nằm trong order_items vì đây là giá tại thời điểm giao dịch. Không lấy giá hiện tại từ danh mục sản phẩm rồi nhân với đơn cũ; như vậy lịch sử doanh thu có thể đổi khi bảng sản phẩm cập nhật. Bộ dữ liệu ban đầu dùng VND nguyên và numeric để phép tính tiền có độ chính xác thập phân phù hợp.
4. Cài dbt Core và adapter trong venv
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install "dbt-core==1.9.8" "dbt-postgres==1.9.1"
python -m pip check
dbt --version
Nếu máy chỉ có python3 trỏ đúng bản 3.11, thay python3.11 bằng python3. Sau khi tạo venv, python -m pip bảo đảm pip thuộc đúng interpreter đang dùng. pip check giúp phát hiện dependency không tương thích; nếu resolver không giải được bộ phiên bản, xem log trong venv sạch, không dùng --no-deps để ép cài.
dbt-core và adapter được phát hành độc lập; chúng không bắt buộc có cùng số patch. --version cần hiển thị Core và plugin postgres đúng bản đã chọn. Nếu hiện bản khác, kiểm tra command -v dbt và môi trường đang activate. Đây là bài dùng Core 1.x, không chạy thêm pip install dbt vào cùng venv theo một trang hướng dẫn cho runtime khác.
python -m pip freeze > requirements.lock.txt
command -v python
command -v dbt
File lock ghi lại các dependency thực tế đã cài để tái tạo môi trường lab trên cùng nền tảng. Nó không làm mọi hệ điều hành tự có wheel tương thích. Với nhóm làm việc, nên lưu cả phiên bản Python và hệ điều hành/container build cùng quy trình cập nhật dependency.
5. Tạo project tối thiểu, nhìn rõ từng file
Thay vì dùng dbt init rồi phụ thuộc vào câu trả lời tương tác, ta tạo project tối thiểu thủ công. Chạy lệnh sau tại dbt-retail; tất cả đường dẫn SQL/YAML bên dưới cũng tính từ thư mục này.
mkdir -p models/staging models/intermediate models/marts tests macros
File dbt_project.yml
name: retail_analytics
version: '1.0.0'
config-version: 2
profile: retail_analytics
model-paths: ['models']
test-paths: ['tests']
macro-paths: ['macros']
clean-targets: ['target', 'dbt_packages']
models:
retail_analytics:
staging:
+schema: staging
+materialized: view
intermediate:
+schema: intermediate
+materialized: view
marts:
+schema: marts
+materialized: table
name là tên project và cũng là khóa lồng trong models. profile phải trùng tên khóa trong profiles.yml. +schema là custom schema chứ không thay hoàn toàn schema trong profile: với target schema dev, staging sẽ được xây vào dev_staging. Giữ nguyên cơ chế mặc định này để tránh vô tình ghi đè môi trường của người khác.
File profiles/profiles.yml
retail_analytics:
target: dev
outputs:
dev:
type: postgres
host: 127.0.0.1
port: 55432
user: dbt_lab
password: "{{ env_var('DBT_PASSWORD') }}"
dbname: retail
schema: dev
threads: 2
connect_timeout: 10
threads=2 cho phép dbt thực thi một số tài nguyên độc lập song song, không có nghĩa mọi câu SQL được chia thành hai phần. host dùng loopback vì dbt chạy trên host. Nếu sau này đưa dbt vào container cùng mạng, cần đổi host thành tên dịch vụ postgres và port thành 5432. Không dùng localhost theo thói quen mà bỏ qua vị trí của client.
dbt debug --profiles-dir profiles
Kỳ vọng kiểm tra cấu hình và connection đều thành công. --profiles-dir chỉ rõ thư mục, không phải đường dẫn file. Ta dùng tham số này trong toàn bộ series để tránh dbt vô tình đọc một profile khác trong thư mục mặc định của người dùng.

6. Khai báo source và model đầu tiên
File models/staging/_sources.yml
version: 2
sources:
- name: shop
schema: raw
tables:
- name: customers
- name: products
- name: orders
- name: order_items
shop là tên logic của source trong project; raw là schema thật ở PostgreSQL. Khai báo này không tạo bốn bảng nguồn. Chúng phải tồn tại sau bước nạp SQL. dbt model mới là tài nguyên được xây; source ghi nhận nơi đọc và tạo nền cho lineage, tài liệu, test hoặc freshness khi cấu hình thêm.
File models/staging/stg_orders.sql
select
order_id::integer as order_id,
customer_id::integer as customer_id,
ordered_at::date as order_date,
lower(trim(status)) as order_status
from {{ source('shop', 'orders') }}
dbt parse --profiles-dir profiles
dbt run --profiles-dir profiles --select stg_orders
docker compose exec -T postgres psql -U dbt_lab -d retail \
-c "select * from dev_staging.stg_orders order by order_id;"
| order_id | customer_id | order_date | order_status |
|---|---|---|---|
| 1001 | 1 | 2026-01-01 | paid |
| 1002 | 2 | 2026-01-01 | cancelled |
| 1003 | 1 | 2026-01-02 | paid |
| 1004 | 3 | 2026-01-02 | pending |
| 1005 | 2 | 2026-01-03 | paid |
File SQL model chỉ có SELECT, không thêm CREATE VIEW thủ công. Materialization view trong dbt_project.yml bảo dbt xây relation phù hợp. Cast ordered_at::date sẽ thất bại nếu chuỗi nguồn không parse được; lab cố ý dùng ISO date hợp lệ. Với dữ liệu thật, phải có chiến lược kiểm tra hoặc quarantine thay vì giả định mọi chuỗi đều tốt.

7. Kiểm tra SQL đã compile và lỗi thường gặp
dbt compile --profiles-dir profiles --select stg_orders
cat target/compiled/retail_analytics/models/staging/stg_orders.sql
Trong file compiled, source được thay bằng relation PostgreSQL tương ứng. Điều này giúp hiểu lỗi đến từ Jinja, tên source hay SQL. Compile không xây view như dbt run và không phải một chế độ sandbox tuyệt đối cho macro tùy ý; khi sau này dùng macro có truy vấn database, cần đọc hành vi của macro. Với project hiện tại, mục đích là xem SQL được render.
| Lỗi | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Connection refused | Database chưa sẵn sàng hoặc sai cổng | Xem compose ps/logs, khớp cổng host 55432 |
| Password authentication failed | Biến mật khẩu khác credential đã tạo trong volume | Kiểm tra môi trường và credential đúng; không xóa volume để thử vận may |
| Profile not found | Sai profile hoặc quên --profiles-dir profiles | Đối chiếu khóa retail_analytics ở hai file |
| Source not found | Tên shop/orders không khớp YAML | Kiểm tra spelling, thụt lề YAML và model-paths |
| Relation raw.orders does not exist | Chưa nạp SQL hoặc đang kết nối database khác | Kiểm tra dbname retail và bước psql nạp raw |
| Model nằm ở dev_staging | Hành vi custom schema mặc định | Truy vấn đúng schema; đây không phải lỗi |
8. Bài tập: thấy khác biệt giữa view và table
Sửa trạng thái order 1004 sang paid bằng SQL bên dưới rồi đọc lại view. View phản ánh nguồn mới khi được truy vấn. Sau quan sát, hoàn nguyên về pending để giữ dữ liệu gốc cho bài 3. Chỉ chạy trên database lab.
docker compose exec -T postgres psql -U dbt_lab -d retail <<'SQL'
update raw.orders set status = ' PAID ' where order_id = 1004;
select order_id, order_status from dev_staging.stg_orders where order_id = 1004;
update raw.orders set status = 'pending' where order_id = 1004;
SQL
Đừng suy ra table model cũng tự cập nhật như view. Bảng vật lý hóa giữ kết quả tại lần xây; muốn phản ánh thay đổi cần chạy lại model theo chiến lược phù hợp. Bài 3 sẽ dùng staging view và mart table để bạn kiểm tra sự khác nhau đó bằng một báo cáo doanh thu.
docker compose down
Khi kết thúc buổi học, down giữ named volume. Lần sau activate venv, export lại DBT_PASSWORD rồi docker compose up -d. Không thêm -v nếu muốn giữ database. Bài 3 yêu cầu stack đang chạy và stg_orders đã xây thành công.
