Docker Compose: order-service + Postgres + Redis
Docker Compose có thể thêm các step build như Dockerfile không? Hay nó chỉ dùng image có sẵn?
Có. Compose build được image từ Dockerfile, không chỉ dùng image có sẵn. Nhưng phân biệt rõ trách nhiệm:
Dockerfile → "Build MỘT image như thế nào?"
Compose → "Hệ thống gồm những service nào, chạy và nối với nhau ra sao?"
1. Bài toán
Bài trước, để chạy order-service phải gõ:
docker network create order-net
docker volume create pg_data
docker run -d --name postgres-db --network order-net -e ... -v pg_data:... postgres:17
docker run -d --name redis --network order-net redis:7
docker run -d --name order-service --network order-net -p 8080:8080 -e ... order-service:1.0
Thêm RabbitMQ, payment-service, gateway... là thêm một đống docker run.
2. Vấn đề
- Lệnh dài, dễ gõ sai, không version control được.
- Thứ tự start phải tự nhớ: Postgres trước, app sau.
- Postgres "đã start" chưa chắc "đã sẵn sàng nhận connection": app start sớm sẽ fail.
- Người mới vào team không biết cần chạy những gì.
3. Giải pháp: compose.yaml
Mô tả cả hệ thống trong một file. Mỗi service dùng một trong hai:
build:→ Compose gọi Dockerfile của mình để build image.image:→ dùng image có sẵn (postgres, redis).
Compose không viết trực tiếp các step FROM/COPY/RUN; các step đó vẫn nằm trong Dockerfile. Compose tự tạo network mặc định cho project, tên service chính là hostname (db, redis).
Docker Compose
│
┌──────────────┼───────────────┐
▼ ▼ ▼
order-service db redis
│ │ │
build: . postgres:17 redis:7
Dockerfile
4. Code từng bước
Bước 1: build: dạng ngắn và dạng đầy đủ
services:
order-service:
build: . # context = ., tìm ./Dockerfile
services:
order-service:
build:
context: .
dockerfile: docker/Dockerfile.dev
args:
APP_VERSION: 1.0.0 # truyền vào ARG trong Dockerfile
image: order-service:1.0 # tên/tag cho image vừa build
Bước 2: .env cho giá trị nhạy cảm. Compose tự đọc file .env cạnh compose.yaml để thay ${...}. File .env không commit; commit .env.example:
# .env.example (copy thành .env rồi điền giá trị local)
POSTGRES_DB=orders
POSTGRES_USER=order_app
POSTGRES_PASSWORD=
Bước 3: compose.yaml hoàn chỉnh
services:
order-service:
build:
context: .
dockerfile: Dockerfile
image: order-service:1.0
ports:
- "8080:8080"
environment:
DB_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
DB_USERNAME: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
REDIS_HOST: redis
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432" # chỉ để DBeaver trên máy connect
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
postgres_data:
${POSTGRES_PASSWORD:?...}: thiếu biến thì Compose báo lỗi ngay, không chạy với password rỗng.depends_on+condition: service_healthy: đợi healthcheck pass rồi mới start app. Chỉdepends_on: [db]thì chỉ đợi container start, không đợi DB ready.
Bước 4: Chạy và test
docker compose up --build -d # build image rồi start, chạy nền
docker compose ps
docker compose logs -f order-service
curl -X POST localhost:8080/orders -H "Content-Type: application/json" \
-d '{"productId":"P-1","quantity":2}'
curl localhost:8080/orders
docker compose exec db psql -U order_app -d orders -c "SELECT * FROM orders;"
5. Cách hoạt động
docker compose up --build -d
│
├──────────────────────────┬────────────────────┐
▼ ▼ ▼
order-service db redis
│ build: . │ image: postgres │ image: redis
▼ ▼ ▼
Dockerfile → Maven → jar pull image pull image
│ │ │
│ healthcheck healthcheck
│ pg_isready ✅ redis-cli ping ✅
▼ │ │
start (sau khi db, redis healthy) │
│ ▼ │
├──── db:5432 ──────► Postgres ──► postgres_data (volume)
└──── redis:6379 ───────────────────────────────┘
Network mặc định: <project>_default (Compose tự tạo)
6. So sánh
| Lệnh | Container | Network | Volume |
|---|---|---|---|
docker compose stop | STOPPED, vẫn còn | Còn | Còn |
docker compose down | Xóa | Xóa | Còn |
docker compose down -v | Xóa | Xóa | Xóa (mất data!) |
| Docker Compose | Kubernetes | |
|---|---|---|
| Phạm vi | Một máy | Cluster nhiều node |
| Self-healing | restart policy | Controller tạo lại Pod |
| Service DNS | Tên service | Service DNS |
| Healthcheck | healthcheck | Liveness/Readiness probe |
| Dùng cho | Dev local, test tích hợp, server nhỏ | Production nhiều instance |
7. Khi nào nên dùng / không nên dùng
- Nên: môi trường dev local, CI integration test, demo; mọi người clone repo và
docker compose uplà chạy. - Nên: luôn có healthcheck cho DB/cache và
service_healthy. - Không nên: production cần scale nhiều máy, rolling update, auto-heal → Kubernetes.
- Không nên: commit
.env; chạydown -vkhi đang giữ data local quan trọng.
OK, làm bài thực hành đi, cho source code luôn nhé, mình sẽ xem.
Được. Project nhỏ nhưng đủ để thấy Dockerfile + Image + Container + Network + Volume + Compose cùng hoạt động. API:
POST /orders body: {"productId":"P-1","quantity":2}
GET /orders
order-service/
├── src/main/java/com/example/order/
│ ├── OrderApplication.java
│ ├── Order.java
│ ├── OrderRepository.java
│ └── OrderController.java
├── src/main/resources/application.properties
├── pom.xml
├── Dockerfile
├── .dockerignore
├── .env.example
└── compose.yaml
pom.xml (phần dependency):
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.6</version>
</parent>
<artifactId>order-service</artifactId>
<version>1.0.0</version>
<properties><java.version>17</java.version></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
</dependencies>
Order.java
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productId;
private int quantity;
public Order() {}
public Order(String productId, int quantity) {
this.productId = productId;
this.quantity = quantity;
}
public Long getId() { return id; }
public String getProductId() { return productId; }
public int getQuantity() { return quantity; }
}
OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {
}
OrderController.java
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderRepository repository;
public OrderController(OrderRepository repository) {
this.repository = repository;
}
@PostMapping
public Order create(@RequestBody Order order) {
return repository.save(new Order(order.getProductId(), order.getQuantity()));
}
@GetMapping
public List<Order> getAll() {
return repository.findAll();
}
}
application.properties
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/orders}
spring.datasource.username=${DB_USERNAME:order_app}
spring.datasource.password=${DB_PASSWORD}
spring.data.redis.host=${REDIS_HOST:localhost}
spring.jpa.hibernate.ddl-auto=update
management.endpoints.web.exposure.include=health
Dockerfile là bản multi-stage ở bài 16.2, compose.yaml là bản ở Bước 3 phía trên. Không cần mvn clean package trên máy vì Dockerfile đã có RUN mvn ....
Bài test quan trọng nhất: tự tay thấy volume khác container.
cp .env.example .env # điền POSTGRES_PASSWORD local
docker compose up --build -d
curl -X POST localhost:8080/orders -H "Content-Type: application/json" -d '{"productId":"P-1","quantity":2}'
curl -X POST localhost:8080/orders -H "Content-Type: application/json" -d '{"productId":"P-2","quantity":1}'
docker compose down # xóa container + network
docker compose up -d
curl localhost:8080/orders # 2 order vẫn còn → nhờ volume
docker compose down -v # xóa cả volume
docker compose up -d
curl localhost:8080/orders # [] → database mới tinh
Tự kiểm tra thêm: docker images, docker ps, docker network ls, docker volume ls. Controller gọi thẳng Repository cho gọn vì mục tiêu bài là Docker.
OK, rất thú vị. Nhưng nên viết Service class, vì mình sẽ bị đánh giá nếu viết code như thế.
Đúng. Demo Docker thì Controller → Repository cho ít code, nhưng nếu source được review như một backend project thì nên có Service layer:
Request → Controller → Service → Repository → PostgreSQL
src/main/java/com/example/order/
├── OrderApplication.java
├── controller/OrderController.java
├── service/OrderService.java
├── repository/OrderRepository.java
└── entity/Order.java
OrderController: chỉ lo HTTP.
package com.example.order.controller;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public Order create(@RequestBody Order request) {
return orderService.create(request.getProductId(), request.getQuantity());
}
@GetMapping
public List<Order> getAll() {
return orderService.getAll();
}
}
OrderService: business logic và transaction ở đây.
package com.example.order.service;
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public Order create(String productId, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
return orderRepository.save(new Order(productId, quantity));
}
@Transactional(readOnly = true)
public List<Order> getAll() {
return orderRepository.findAll();
}
}
Order: đổi constructor rỗng thành protected. JPA cần no-args constructor, nhưng code ứng dụng không nên tạo new Order() rỗng.
package com.example.order.entity;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String productId;
@Column(nullable = false)
private int quantity;
protected Order() {}
public Order(String productId, int quantity) {
this.productId = productId;
this.quantity = quantity;
}
public Long getId() { return id; }
public String getProductId() { return productId; }
public int getQuantity() { return quantity; }
}
Muốn chuẩn production hơn nữa thì không trả Entity thẳng ra Controller, mà dùng CreateOrderRequest / OrderResponse (DTO) và @Valid. Bài Docker này dừng ở mức Service layer để Docker không bị chìm trong Spring Boot; phần DTO và validation đã có ở chương Spring.
Phỏng vấn hỏi Docker Compose, Dockerfile khác Compose chỗ nào thì trả lời sao?
Q1. Dockerfile khác docker-compose thế nào?
🇻🇳 Tiếng Việt:
Dockerfile mô tả cách build một image. Compose mô tả nhiều service chạy cùng nhau: image hoặc build context nào, port, biến môi trường, volume, network, thứ tự phụ thuộc. Với service của mình em dùng
build:để Compose gọi Dockerfile; với Postgres, Redis em dùngimage:có sẵn.
🇬🇧 English:
A Dockerfile describes how to build one image. Compose describes multiple services running together: which image or build context, ports, environment variables, volumes, networks and dependencies. For our own service I use
build:so Compose calls the Dockerfile, and for Postgres or Redis I use existing images withimage:.
Q2. depends_on có đảm bảo database sẵn sàng không?
🇻🇳 Tiếng Việt:
Dạng ngắn chỉ đảm bảo container DB start trước, không đảm bảo DB nhận connection được. Em thêm healthcheck như
pg_isreadyvà dùngcondition: service_healthy. App vẫn nên có retry khi kết nối, vì DB có thể restart lúc đang chạy.
🇬🇧 English:
The short form only ensures the database container starts first, not that it accepts connections. I add a healthcheck such as
pg_isreadyand usecondition: service_healthy. The application should still retry connections, because the database can restart at runtime.
Q3. Bạn quản lý cấu hình và secret trong Compose thế nào?
🇻🇳 Tiếng Việt:
Spring Boot đọc config từ biến môi trường như
${DB_URL:default}, nên cùng một image chạy được ở mọi môi trường. Giá trị nhạy cảm để trong file.envkhông commit, repo chỉ có.env.example. Production thì dùng secret manager hoặc Kubernetes Secret.
🇬🇧 English:
Spring Boot reads configuration from environment variables such as
${DB_URL:default}, so the same image runs in every environment. Sensitive values live in a.envfile that is not committed; the repo only has a.env.example. In production I use a secret manager or Kubernetes Secrets.
Cách nhớ ngắn gọn
- Dockerfile = một image; Compose = cả hệ thống.
build:cho code mình,image:cho Postgres/Redis.- Tên service = hostname trong network mặc định.
depends_on+ healthcheck +service_healthy.downgiữ volume;down -vxóa data.
Tự kiểm tra
Compose có viết được RUN mvn package trực tiếp trong compose.yaml không?
Không. Các step build nằm trong Dockerfile; Compose chỉ trỏ tới nó bằng build: (context, dockerfile, args).
Trong Compose, order-service gọi Postgres bằng host nào?
Bằng tên service, ví dụ db:5432, nhờ DNS của network mặc định Compose tạo.
docker compose down và down -v khác nhau?
down xóa container và network, giữ volume. down -v xóa cả volume, mất data.
Vì sao dùng ${POSTGRES_PASSWORD:?...}?
Thiếu biến thì Compose báo lỗi ngay thay vì chạy với giá trị rỗng; password không bị ghi cứng vào file.
Vì sao Controller nên gọi Service thay vì Repository?
Tách trách nhiệm: Controller lo HTTP, Service giữ business logic và transaction, dễ test và tái sử dụng.