Docker Compose: order-service + Postgres + Redis

4 câu hỏi~35 phútTừ khóa: compose.yaml, build:, image:, depends_on, healthcheck, service_healthy, .env, env_file, down -v, thực hành

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 đề

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:

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:

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ệnhContainerNetworkVolume
docker compose stopSTOPPED, vẫn cònCònCòn
docker compose downXóaXóaCòn
docker compose down -vXóaXóaXóa (mất data!)
Docker ComposeKubernetes
Phạm viMột máyCluster nhiều node
Self-healingrestart policyController tạo lại Pod
Service DNSTên serviceService DNS
HealthcheckhealthcheckLiveness/Readiness probe
Dùng choDev 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

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ùng image: 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 with image:.

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_isready và dùng condition: 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_isready and use condition: 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 .env khô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 .env file 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


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.