+1

#2 Bước 1 — Khởi tạo dự án & Hạ tầng Docker

Tài liệu thực hành. Làm theo từ trên xuống, mỗi mục đều có lệnh kiểm chứng để bạn biết chắc mình đang đi đúng trước khi sang mục tiếp theo.

Thời gian: 15–20 phút (lần đầu tải image có thể lâu hơn) Kiến trúc tổng thể: xem ARCHITECTURE.md

Mục tiêu

Kết thúc bước này bạn sẽ có:

  • Một Go module trống nhưng đúng chuẩn, sẵn sàng nhận code
  • PostgreSQL 16 chạy trong Docker, có volume nên không mất dữ liệu khi restart
  • Kafka 3.9 chạy chế độ KRaft (không cần Zookeeper), đã tạo sẵn các topic
  • Kafka UI tại localhost:8080 để nhìn thấy event bằng mắt
  • Một chương trình Go nhỏ chứng minh code của bạn kết nối được cả hai

Chưa có ở bước này: bảng dữ liệu, migration, HTTP server. Đó là bước 2.


Mục lục

  1. Chuẩn bị
  2. Khởi tạo Go module
  3. Tạo .gitignore.env
  4. Viết docker-compose.yml
  5. Khởi động và kiểm chứng
  6. Tạo Kafka topic
  7. Smoke test bằng Go
  8. Lệnh tắt (Makefile)
  9. Dọn dẹp và reset
  10. Xử lý sự cố
  11. Checklist hoàn thành

1. Chuẩn bị

Cần cài sẵn

Công cụ Phiên bản tối thiểu Kiểm tra
Go 1.22 go version
Docker Desktop 4.x docker version
Docker Compose v2 docker compose version
Git bất kỳ git --version

Chạy cả bốn lệnh kiểm tra một lượt:

go version; docker version --format '{{.Server.Version}}'; docker compose version; git --version

Nếu lệnh nào báo "not recognized", cài công cụ đó trước rồi mở lại terminal (PATH chỉ được nạp khi terminal khởi động).

Riêng cho Windows

  • Docker Desktop phải bật WSL 2 backend (Settings → General → Use the WSL 2 based engine). Hyper-V backend chạy được nhưng chậm hơn nhiều.
  • Cấp cho Docker tối thiểu 4 GB RAM (Settings → Resources). Kafka một mình đã ăn ~1 GB.
  • Tài liệu này dùng PowerShell. Nếu bạn dùng Git Bash hay WSL thì lệnh docker/go giống hệt, chỉ khác cách đặt biến môi trường — chỗ nào khác tôi sẽ ghi rõ.

Xác nhận Docker thực sự đang chạy

docker run --rm hello-world

Phải thấy dòng "Hello from Docker!". Nếu báo error during connect → Docker Desktop chưa khởi động xong, đợi biểu tượng cá voi ở khay hệ thống chuyển sang trạng thái Running.


2. Khởi tạo Go module

cd D:\project
go mod init github.com/yourname/community

Thay yourname bằng tên GitHub của bạn.

Chọn module path cho đúng ngay từ đầu

Module path là tiền tố của mọi import trong dự án. Với đường dẫn trên, package event bus sẽ được import là:

import "github.com/yourname/community/internal/platform/eventbus"

Đổi module path về sau nghĩa là sửa dòng import trong mọi file — làm được nhưng phiền. Ba quy tắc:

  1. Dùng đúng đường dẫn repo thật (github.com/<user>/<repo>), kể cả khi chưa push lên GitHub. Ngày đăng ký go.mod khớp sẵn thì không phải sửa gì.
  2. Không đặt tên chung chung như myapp hay backend — khi dự án khác import vào sẽ đụng tên.
  3. Chữ thường, dùng dấu gạch ngang, không dấu tiếng Việt.

Từ đây trở đi tài liệu dùng github.com/yourname/community. Khi copy code, nhớ thay bằng path của bạn.

Kiểm chứng:

Get-Content go.mod
module github.com/yourname/community

go 1.22

Khởi tạo git luôn (bước 3 cần tới .gitignore):

git init

3. Tạo .gitignore.env

.gitignore

# Binary
/bin/
*.exe
*.exe~

# Môi trường — KHÔNG BAO GIỜ commit
.env
.env.local

# Test
*.out
coverage.html

# IDE
.idea/
.vscode/
*.swp

# OS
.DS_Store
Thumbs.db

.env.example — commit file này

# PostgreSQL
DATABASE_URL=postgres://app:secret@localhost:5432/community?sslmode=disable

# Kafka
KAFKA_BROKERS=localhost:9092

# HTTP
HTTP_PORT=8000

# Log: debug | info | warn | error
LOG_LEVEL=debug

.env — file thật, không commit

Copy-Item .env.example .env

Vì sao tách hai file? .env.exampletài liệu: nó cho người mới clone repo biết cần khai báo những biến gì. .envbí mật: chứa giá trị thật và không bao giờ vào git. Ở môi trường dev hai file giống hệt nhau, nhưng thói quen này là thứ ngăn bạn đẩy mật khẩu production lên GitHub sau này.

Vì sao HTTP_PORT=8000 mà không phải 8080? Vì Kafka UI đã chiếm 8080. Đây là loại xung đột cổng gây mất 20 phút debug một cách vô duyên — tránh sẵn từ đầu.


4. Viết docker-compose.yml

New-Item -ItemType Directory -Force deployments

Tạo deployments/docker-compose.yml:

name: community

services:
  # ─────────────────────────────────────────────────────────────
  # PostgreSQL — DB nghiệp vụ + bảng outbox + bảng consumed_events
  # ─────────────────────────────────────────────────────────────
  postgres:
    image: postgres:16-alpine
    container_name: community-postgres
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: community
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d community"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

  # ─────────────────────────────────────────────────────────────
  # Kafka — chế độ KRaft, một node, không cần Zookeeper
  # ─────────────────────────────────────────────────────────────
  kafka:
    image: apache/kafka:3.9.0
    container_name: community-kafka
    ports:
      - "9092:29092"          # host 9092 → listener PLAINTEXT_HOST trong container
    environment:
      # --- KRaft: node này vừa là broker vừa là controller ---
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER

      # --- Listener: xem giải thích bên dưới, đây là chỗ hay sai nhất ---
      KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,PLAINTEXT_HOST://:29092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT

      # --- Cụm 1 node thì mọi replication factor đều phải là 1 ---
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1

      # --- Thiết lập cho môi trường dev ---
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
      KAFKA_NUM_PARTITIONS: 3
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
    healthcheck:
      test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1"]
      interval: 10s
      timeout: 10s
      retries: 10
      start_period: 20s
    restart: unless-stopped

  # ─────────────────────────────────────────────────────────────
  # Kafka UI — xem topic, message, consumer lag bằng trình duyệt
  # ─────────────────────────────────────────────────────────────
  kafka-ui:
    image: kafbat/kafka-ui:latest
    container_name: community-kafka-ui
    ports:
      - "8080:8080"
    environment:
      KAFKA_CLUSTERS_0_NAME: local
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092   # ← tên service, KHÔNG phải localhost
      DYNAMIC_CONFIG_ENABLED: "true"
    depends_on:
      kafka:
        condition: service_healthy
    restart: unless-stopped

volumes:
  pgdata:

4.1 Vì sao Kafka cần hai listener

Đây là điểm gây nhầm nhiều nhất khi chạy Kafka trong Docker, và nó không báo lỗi rõ ràng — chỉ im lặng không kết nối được.

Kafka không hoạt động như một HTTP server thông thường. Khi client kết nối lần đầu, broker trả về địa chỉ mà client nên dùng cho các lần sau (đó là advertised.listeners). Client sẽ ngắt và kết nối lại theo đúng địa chỉ đó.

Vấn đề: "địa chỉ đúng" khác nhau tuỳ ai đang hỏi.

┌─ Máy bạn (host) ────────────────┐   ┌─ Docker network ──────────────┐
│                                 │   │                               │
│  go run ./cmd/api               │   │   kafka-ui                    │
│  → cần: localhost:9092          │   │   → cần: kafka:9092           │
│                                 │   │                               │
└─────────────────────────────────┘   └───────────────────────────────┘
              │                                       │
              │  PLAINTEXT_HOST                       │  PLAINTEXT
              │  advertise "localhost:9092"           │  advertise "kafka:9092"
              └───────────────┬───────────────────────┘
                              ▼
                       ┌─────────────┐
                       │    kafka    │
                       └─────────────┘
  • Từ máy bạn, kafka không phải là hostname phân giải được → phải dùng localhost:9092
  • Từ container khác, localhost trỏ về chính container đó → phải dùng kafka:9092

Nên ta khai báo hai listener riêng, mỗi cái advertise một địa chỉ phù hợp với đối tượng của nó.

Nếu chỉ có một listener advertise localhost:9092: Kafka UI khởi động bình thường, giao diện mở được, nhưng hiện "Cluster offline" mãi mãi — vì nó nhận địa chỉ localhost:9092 rồi kết nối vào chính bản thân nó. Không có dòng log lỗi nào giải thích chuyện đó.

Còn dòng ports: "9092:29092" thì đọc như sau: cổng 9092 trên máy bạn được nối tới cổng 29092 trong container (nơi listener PLAINTEXT_HOST đang lắng nghe). Nhờ vậy ứng dụng Go của bạn dùng localhost:9092 — một con số quen thuộc — dù bên trong container nó là cổng khác.

4.2 Vì sao KRaft mà không phải Zookeeper

KRaft là chế độ Kafka tự quản lý metadata, không cần cụm Zookeeper đi kèm. Cụ thể với bạn:

  • Một container thay vì hai — khởi động nhanh hơn, tốn ít RAM hơn
  • Zookeeper đã deprecated từ Kafka 3.5bị gỡ hoàn toàn ở Kafka 4.0

Nếu bạn đọc một hướng dẫn nào đó vẫn còn service zookeeper, đó là tài liệu viết trước 2023.

4.3 Vì sao tắt auto.create.topics.enable

Mặc định Kafka tự tạo topic khi có ai đó publish vào một topic chưa tồn tại. Nghe thì tiện, nhưng topic tạo tự động dùng số partition mặc định — và đó là cái bẫy.

ARCHITECTURE.md §5.2 dựa vào việc mọi event của cùng một entity rơi vào cùng partition để đảm bảo thứ tự xử lý. Nếu một topic bị tạo nhầm với số partition sai, thứ tự sẽ hỏng theo cách chỉ lộ ra dưới tải cao và gần như không tái hiện được trên máy dev.

Bắt buộc tạo topic tường minh nghĩa là số partition luôn là một quyết định có ý thức, không phải giá trị tình cờ.

4.4 Hai chi tiết nhỏ trong file compose

  • Không có version: ở đầu file. Khoá này đã lỗi thời; Docker Compose v2 sẽ in cảnh báo nếu bạn để nó.
  • Kafka không gắn volume. Có chủ đích: image apache/kafka chạy dưới user không phải root, gắn named volume vào thư mục chưa tồn tại sẽ tạo ra thư mục thuộc quyền root và container crash vì không ghi được. Với môi trường dev điều này chấp nhận được — nguồn sự thật là bảng outbox trong PostgreSQL (đã có volume), nên nếu Kafka mất dữ liệu bạn chỉ cần cho relay phát lại. docker compose stop / start vẫn giữ nguyên dữ liệu Kafka; chỉ down mới xoá.

5. Khởi động và kiểm chứng

docker compose -f deployments/docker-compose.yml up -d

Lần đầu sẽ tải khoảng 500 MB image, mất vài phút.

5.1 Kiểm tra trạng thái container

docker compose -f deployments/docker-compose.yml ps

Đợi tới khi cả ba service đều running và hai service có healthcheck đều (healthy):

NAME                  STATUS                    PORTS
community-kafka       Up 45 seconds (healthy)   0.0.0.0:9092->29092/tcp
community-kafka-ui    Up 20 seconds             0.0.0.0:8080->8080/tcp
community-postgres    Up 45 seconds (healthy)   0.0.0.0:5432->5432/tcp

Kafka mất khoảng 20–30 giây để chuyển sang healthy. Trạng thái starting là bình thường, cứ đợi. Nếu sau 2 phút vẫn chưa healthy → xem mục 10.

5.2 Kiểm chứng PostgreSQL

docker compose -f deployments/docker-compose.yml exec postgres psql -U app -d community -c "SELECT version();"
                                    version
--------------------------------------------------------------------------------
 PostgreSQL 16.x on x86_64-pc-linux-musl, compiled by gcc ...
(1 row)

Kiểm tra luôn từ phía máy host (đây mới là đường mà app Go sẽ đi):

Test-NetConnection localhost -Port 5432 | Select-Object TcpTestSucceeded

Phải trả True.

5.3 Kiểm chứng Kafka

docker compose -f deployments/docker-compose.yml exec kafka /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092

Ra một danh sách API version dài — nghĩa là broker đã sẵn sàng.

5.4 Kiểm chứng Kafka UI

Mở trình duyệt: http://localhost:8080

Bạn phải thấy cluster tên local ở trạng thái online, 1 broker.

Nếu hiện "Cluster offline": gần như chắc chắn KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS đang là localhost:9092 thay vì kafka:9092. Đọc lại mục 4.1.


6. Tạo Kafka topic

Vì đã tắt auto-create, ta tạo tường minh các topic mà ARCHITECTURE.md cần:

$kt = "/opt/kafka/bin/kafka-topics.sh"
$dc = "docker compose -f deployments/docker-compose.yml"

foreach ($t in @("post", "social", "identity")) {
    docker compose -f deployments/docker-compose.yml exec kafka $kt `
        --bootstrap-server localhost:9092 --create --if-not-exists `
        --topic $t --partitions 3 --replication-factor 1
}

foreach ($t in @("post.DLQ", "social.DLQ", "identity.DLQ")) {
    docker compose -f deployments/docker-compose.yml exec kafka $kt `
        --bootstrap-server localhost:9092 --create --if-not-exists `
        --topic $t --partitions 1 --replication-factor 1
}
<details> <summary>Bản cho Git Bash / WSL / macOS</summary>
DC="docker compose -f deployments/docker-compose.yml"
KT="/opt/kafka/bin/kafka-topics.sh"

for t in post social identity; do
  $DC exec kafka $KT --bootstrap-server localhost:9092 \
    --create --if-not-exists --topic "$t" --partitions 3 --replication-factor 1
done

for t in post.DLQ social.DLQ identity.DLQ; do
  $DC exec kafka $KT --bootstrap-server localhost:9092 \
    --create --if-not-exists --topic "$t" --partitions 1 --replication-factor 1
done
</details>

Kiểm chứng:

docker compose -f deployments/docker-compose.yml exec kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list
identity
identity.DLQ
post
post.DLQ
social
social.DLQ

Vì sao topic chính có 3 partition còn DLQ chỉ 1?

Partition là đơn vị song song hoá: 3 partition cho phép 3 consumer trong cùng group xử lý đồng thời. Với topic chính, đó là thứ bạn cần khi scale.

DLQ thì ngược lại — nó nên rỗng. Message vào đó là ngoại lệ cần người xem, không phải luồng cần thông lượng. Một partition giữ mọi message hỏng theo đúng thứ tự thời gian, tiện cho việc điều tra.

Lưu ý: số partition tăng được nhưng không giảm được, và tăng partition sẽ thay đổi cách phân bổ key — làm hỏng đảm bảo thứ tự với dữ liệu cũ. Cứ 3 cho môi trường dev là hợp lý.

Thử gửi/nhận một message

Mở terminal thứ nhất làm consumer:

docker compose -f deployments/docker-compose.yml exec kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic post --from-beginning

Mở terminal thứ hai làm producer:

docker compose -f deployments/docker-compose.yml exec kafka /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic post

Gõ vào producer rồi Enter:

{"event_type":"post.post.created.v1","payload":{"title":"Xin chao"}}

Dòng đó phải hiện ngay ở terminal consumer. Thoát cả hai bằng Ctrl+C.

Vào Kafka UI → Topicspost → tab Messages, bạn cũng sẽ thấy chính message đó. Đây là công cụ bạn sẽ dùng liên tục ở các bước sau để kiểm tra event có thực sự được phát hay không.


7. Smoke test bằng Go

Hai mục trên chứng minh hạ tầng chạy. Mục này chứng minh code Go của bạn nối được vào hạ tầng đó — hai việc khác nhau, và cái sau mới là cái quan trọng.

Cài driver:

go get github.com/jackc/pgx/v5
go get github.com/twmb/franz-go

Tạo cmd/smoketest/main.go:

package main

import (
	"context"
	"log"
	"os"
	"strings"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/twmb/franz-go/pkg/kgo"
)

func env(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	// ── PostgreSQL ────────────────────────────────────────────
	dsn := env("DATABASE_URL", "postgres://app:secret@localhost:5432/community?sslmode=disable")
	pool, err := pgxpool.New(ctx, dsn)
	if err != nil {
		log.Fatalf("❌ postgres: cấu hình sai: %v", err)
	}
	defer pool.Close()

	if err := pool.Ping(ctx); err != nil {
		log.Fatalf("❌ postgres: không kết nối được: %v", err)
	}

	var version string
	if err := pool.QueryRow(ctx, "SELECT version()").Scan(&version); err != nil {
		log.Fatalf("❌ postgres: query lỗi: %v", err)
	}
	log.Printf("✅ PostgreSQL: %s", strings.SplitN(version, " on ", 2)[0])

	// ── Kafka ─────────────────────────────────────────────────
	brokers := strings.Split(env("KAFKA_BROKERS", "localhost:9092"), ",")
	cl, err := kgo.NewClient(kgo.SeedBrokers(brokers...))
	if err != nil {
		log.Fatalf("❌ kafka: cấu hình sai: %v", err)
	}
	defer cl.Close()

	if err := cl.Ping(ctx); err != nil {
		log.Fatalf("❌ kafka: không kết nối được: %v", err)
	}
	log.Printf("✅ Kafka: %s", strings.Join(brokers, ","))

	log.Println("🎉 Hạ tầng sẵn sàng.")
}

Chạy:

go mod tidy
go run ./cmd/smoketest

Kết quả mong đợi:

2026/07/29 22:10:03 ✅ PostgreSQL: PostgreSQL 16.x
2026/07/29 22:10:03 ✅ Kafka: localhost:9092
2026/07/29 22:10:03 🎉 Hạ tầng sẵn sàng.

Vì sao có hàm env() với giá trị mặc định? Để go run chạy được ngay mà không cần bạn set biến môi trường trước. Giá trị mặc định trùng với .env.example, nên cả hai cách đều cho kết quả giống nhau. Ở code thật (bước 2 trở đi), internal/platform/config sẽ bắt buộc phải có biến môi trường và báo lỗi nếu thiếu — dev thì tiện tay được, production thì không.

Giữ lại cmd/smoketest — nó rất hữu ích mỗi khi bạn nghi ngờ "lỗi này do code hay do hạ tầng?".


8. Lệnh tắt (Makefile)

docker compose -f deployments/docker-compose.yml ... hàng chục lần một ngày sẽ rất mệt.

Nếu bạn có make

winget install GnuWin32.Make

Sau đó mở lại terminal và tạo Makefile ở thư mục gốc:

COMPOSE := docker compose -f deployments/docker-compose.yml
KT      := /opt/kafka/bin/kafka-topics.sh
BOOT    := localhost:9092

.PHONY: up down stop logs ps topics smoke reset

up:                       ## Khởi động hạ tầng
	$(COMPOSE) up -d

down:                     ## Dừng và xoá container (giữ dữ liệu)
	$(COMPOSE) down

stop:                     ## Tạm dừng, giữ nguyên mọi thứ
	$(COMPOSE) stop

logs:                     ## Xem log realtime
	$(COMPOSE) logs -f

ps:                       ## Trạng thái container
	$(COMPOSE) ps

topics:                   ## Tạo topic (chạy được nhiều lần)
	@for t in post social identity; do \
		$(COMPOSE) exec -T kafka $(KT) --bootstrap-server $(BOOT) \
			--create --if-not-exists --topic $$t --partitions 3 --replication-factor 1; \
	done
	@for t in post.DLQ social.DLQ identity.DLQ; do \
		$(COMPOSE) exec -T kafka $(KT) --bootstrap-server $(BOOT) \
			--create --if-not-exists --topic $$t --partitions 1 --replication-factor 1; \
	done
	@$(COMPOSE) exec -T kafka $(KT) --bootstrap-server $(BOOT) --list

smoke:                    ## Kiểm tra kết nối từ Go
	go run ./cmd/smoketest

reset:                    ## ⚠️ XOÁ SẠCH dữ liệu và dựng lại
	$(COMPOSE) down -v
	$(COMPOSE) up -d

Lưu ý: make cần sh để chạy vòng lặp trong target topics. Git for Windows đã cài sẵn sh, nên nếu bạn có Git thì việc này chạy được. Nếu không, dùng cách bên dưới.

Nếu bạn không muốn cài make

Tạo dev.ps1 ở thư mục gốc:

param([Parameter(Position=0)][string]$cmd = "help")

$compose = "docker", "compose", "-f", "deployments/docker-compose.yml"
$kt = "/opt/kafka/bin/kafka-topics.sh"

function Invoke-Compose { & $compose[0] $compose[1..3] @args }

switch ($cmd) {
    "up"     { Invoke-Compose up -d }
    "down"   { Invoke-Compose down }
    "stop"   { Invoke-Compose stop }
    "logs"   { Invoke-Compose logs -f }
    "ps"     { Invoke-Compose ps }
    "smoke"  { go run ./cmd/smoketest }
    "reset"  { Invoke-Compose down -v; Invoke-Compose up -d }
    "topics" {
        foreach ($t in @("post","social","identity")) {
            Invoke-Compose exec -T kafka $kt --bootstrap-server localhost:9092 `
                --create --if-not-exists --topic $t --partitions 3 --replication-factor 1
        }
        foreach ($t in @("post.DLQ","social.DLQ","identity.DLQ")) {
            Invoke-Compose exec -T kafka $kt --bootstrap-server localhost:9092 `
                --create --if-not-exists --topic $t --partitions 1 --replication-factor 1
        }
        Invoke-Compose exec -T kafka $kt --bootstrap-server localhost:9092 --list
    }
    default  { Write-Host "Dùng: .\dev.ps1 [up|down|stop|logs|ps|topics|smoke|reset]" }
}

Dùng:

.\dev.ps1 up
.\dev.ps1 topics
.\dev.ps1 smoke

Nếu PowerShell chặn script:

Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

9. Dọn dẹp và reset

Lệnh Tác dụng Dữ liệu Postgres
docker compose ... stop Tạm dừng container Giữ
docker compose ... down Dừng và xoá container Giữ (nằm trong volume)
docker compose ... down -v Xoá container và volume MẤT SẠCH

⚠️ Cẩn thận với down -v

Cờ -v xoá volume pgdata — toàn bộ dữ liệu PostgreSQL biến mất, không hoàn tác được. Không có bước xác nhận nào.

Chỉ dùng khi bạn cố ý muốn làm lại từ đầu (ví dụ migration hỏng cần dựng lại schema sạch).

Muốn dừng máy đi ngủ thì dùng stop.

Kiểm tra volume còn hay mất:

docker volume ls --filter name=community

10. Xử lý sự cố

Bảng tra nhanh

Triệu chứng Nguyên nhân thường gặp Cách xử lý
port is already allocated (5432) Máy đã cài PostgreSQL sẵn Đổi sang "5433:5432" và sửa DATABASE_URL thành cổng 5433
port is already allocated (8080) Ứng dụng khác đang chiếm Đổi Kafka UI sang "8081:8080"
Kafka mãi không healthy Docker thiếu RAM Settings → Resources → tăng lên ≥ 4 GB
Kafka UI báo "Cluster offline" BOOTSTRAPSERVERS sai Phải là kafka:9092, không phải localhost:9092
Go báo connection refused (Kafka) Dùng sai cổng Từ host phải là localhost:9092
Go báo password authentication failed Volume cũ giữ mật khẩu cũ down -v rồi up -d
unknown topic or partition Chưa tạo topic Chạy lại mục 6
docker: command not found Docker Desktop chưa chạy Khởi động Docker Desktop, đợi biểu tượng Running

Tìm cổng nào đang bị chiếm

Get-NetTCPConnection -LocalPort 5432 -State Listen |
    Select-Object LocalPort, OwningProcess |
    ForEach-Object { [PSCustomObject]@{ Port = $_.LocalPort; Process = (Get-Process -Id $_.OwningProcess).ProcessName } }

Xem log khi container crash

docker compose -f deployments/docker-compose.yml logs kafka --tail 100
docker compose -f deployments/docker-compose.yml logs postgres --tail 50

Với Kafka, phần lớn lỗi cấu hình thật sự nằm ở những dòng đầu tiên của log, không phải dòng cuối:

docker compose -f deployments/docker-compose.yml logs kafka | Select-String -Pattern "ERROR|Exception" | Select-Object -First 10

password authentication failed — vì sao đổi mật khẩu không ăn

PostgreSQL chỉ đọc POSTGRES_PASSWORD đúng một lần, ở lần khởi tạo data directory đầu tiên. Sau đó biến này bị bỏ qua hoàn toàn.

Nghĩa là sửa mật khẩu trong docker-compose.yml rồi up -d lại sẽ không có tác dụng — container mới vẫn dùng mật khẩu cũ nằm trong volume. Muốn đổi thật thì phải xoá volume:

docker compose -f deployments/docker-compose.yml down -v
docker compose -f deployments/docker-compose.yml up -d

(Đọc lại cảnh báo ở mục 9 trước khi chạy.)

Làm lại hoàn toàn từ đầu

docker compose -f deployments/docker-compose.yml down -v
docker system prune -f
docker compose -f deployments/docker-compose.yml up -d

11. Checklist hoàn thành

Đánh dấu từng dòng — mỗi dòng đều kiểm chứng được bằng một lệnh cụ thể:

  • [ ] go.mod tồn tại, module path là đường dẫn repo thật của bạn
  • [ ] .gitignore có dòng .env; git status không liệt kê file .env
  • [ ] .env.example được commit, .env thì không
  • [ ] docker compose ps — cả 3 service running, 2 service (healthy)
  • [ ] psql ... -c "SELECT version()" trả về PostgreSQL 16
  • [ ] kafka-topics.sh --list liệt kê đủ 6 topic
  • [ ] http://localhost:8080 hiện cluster local online
  • [ ] Gửi được message qua console producer và thấy nó ở consumer
  • [ ] go run ./cmd/smoketest in ra cả hai dấu ✅

Cấu trúc thư mục lúc này:

project/
├── cmd/
│   └── smoketest/
│       └── main.go
├── deployments/
│   └── docker-compose.yml
├── docs/
│   ├── ARCHITECTURE.md
│   └── 01-khoi-tao-du-an-va-ha-tang.md
├── .env                  ← không commit
├── .env.example
├── .gitignore
├── go.mod
├── go.sum
└── Makefile              (hoặc dev.ps1)

Commit lại:

git add .
git commit -m "chore: khởi tạo go module và hạ tầng docker"

Bước tiếp theo

Bước 2 — Nền tảng dữ liệu: viết migration cho users, user_stats, posts, cộng thêm hai bảng hạ tầng outboxconsumed_events; cấu hình sqlc để sinh code truy vấn type-safe.

Trước khi sang bước 2, nên đọc lại hai mục này trong ARCHITECTURE.md:

Hai bảng này trông có vẻ thừa khi mới nhìn. Chúng là thứ giữ cho hệ thống không âm thầm sai số liệu, nên hiểu lý do tồn tại của chúng trước khi gõ migration sẽ đỡ cho bạn rất nhiều thời gian về sau.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.