# Khách không có Kubernetes, Docker Compose vẫn tự rollback bản lỗi

> Một health check, cờ --wait và một nhánh else trong deploy.sh đủ để đưa image cũ lên lại khi bản mới không khỏe kịp hạn.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-rollback-ban-trien-khai-health-check/

Rất nhiều khách hàng không có Kubernetes. Họ có một con VM, một file `compose.yaml`, và một người vận hành chỉ biết gõ `docker compose up`. Khi bản mới hỏng lúc 6 giờ chiều, thứ cứu bạn không phải là một platform, mà là một lệnh deploy biết tự nói "không" rồi tự quay lại bản cũ.

Hướng dẫn này dựng đúng thứ đó. Bạn sẽ có một service `app` có health check, một job migration chạy trước, và một script `deploy.sh` nhận hai phiên bản: bản mới và bản cũ. Nếu bản mới không khỏe trong thời hạn, script tự đưa bản cũ lên lại.

Bạn cần Docker có plugin Compose, một ứng dụng web nhỏ có endpoint `/health`, và hai tag image khác nhau của nó, ví dụ `v1` và `v2`. Mọi lệnh bên dưới đều chạy được trên laptop trước khi bạn mang tới máy khách hàng.

## Health check phải nói đúng ngôn ngữ của Docker

Docker đọc mã thoát của lệnh kiểm tra: 0 nghĩa là container khỏe và sẵn sàng, 1 nghĩa là không khỏe, còn 2 được dành riêng. Theo mặc định, `--retries` bằng 3, tức container chỉ bị đánh dấu unhealthy sau ba lần thất bại liên tiếp.

**Bước 1.** Thêm vào Dockerfile của ứng dụng:

```dockerfile
# Giả định image có curl và app lắng nghe cổng 8080
HEALTHCHECK --retries=3 CMD curl -f http://localhost:8080/health || exit 1
```

Đoạn `|| exit 1` không phải trang trí. Nhiều công cụ trả về mã lỗi tùy ý khi thất bại; ép về 1 giúp bạn không bao giờ vô tình trả mã 2, mã mà Docker không coi là trạng thái hợp lệ.

**Kiểm tra:** build image, chạy container, rồi xem cột STATUS trong `docker ps`. Sau đó thử tắt endpoint `/health` và chờ: container phải chuyển sang unhealthy sau ba lần kiểm tra hỏng.

Một điểm hay bị bỏ qua: endpoint `/health` nên kiểm tra thứ mà ứng dụng thực sự cần, như kết nối database. Một `/health` lúc nào cũng trả 200 sẽ để bản lỗi lọt qua cổng mà không ai hay.

## Thứ tự khởi động là một phần của deploy

Compose có thể giữ một service lại cho tới khi health check của service phụ thuộc vượt qua, bằng `depends_on` với `condition: service_healthy`. Với job chạy một lần như migration, điều kiện `service_completed_successfully` yêu cầu job đó chạy xong thành công rồi service sau mới được khởi động.

**Bước 2.** Một `compose.yaml` rút gọn:

```yaml
services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD", "pg_isready"]   # rút gọn, chỉnh theo DB của bạn
  migrate:
    image: myapp:${APP_VERSION}
    command: ["./migrate"]
    depends_on:
      db:
        condition: service_healthy
  app:
    image: myapp:${APP_VERSION}
    depends_on:
      migrate:
        condition: service_completed_successfully
```

Phần `${APP_VERSION}` là giả định đơn giản hóa: tag image được truyền qua biến môi trường. Lệnh `./migrate` cũng là tên minh họa, bạn thay bằng lệnh migration thật.

**Kiểm tra:** chạy `APP_VERSION=v1 docker compose up` và quan sát log. `migrate` chỉ chạy sau khi `db` khỏe, còn `app` chỉ lên sau khi `migrate` thoát thành công. Nếu bạn sửa `migrate` cho thoát lỗi, `app` sẽ không được khởi động.

## Cổng deploy nằm trong hai cờ

Cờ `--wait` khiến `docker compose up` chặn lại cho tới khi các service ở trạng thái running hoặc healthy, và nó ngầm bật chế độ detached, nên bạn không cần thêm `-d`. Cờ `--wait-timeout` đặt số giây tối đa phải chờ, nhờ vậy một bản lỗi làm lệnh thất bại thay vì treo vô hạn.

Hai cờ này biến một lệnh khởi động thành câu hỏi có/không mà shell hiểu được qua mã thoát. Thêm `--no-deps` để chỉ cập nhật `app` mà không động tới các service liên kết như `db`.

**Bước 3.** Tạo `deploy.sh`:

```bash
#!/usr/bin/env bash
set -u
NEW="$1"   # ví dụ v2
OLD="$2"   # ví dụ v1

if APP_VERSION="$NEW" docker compose up --wait --wait-timeout 90 --no-deps app; then
  echo "Deployed $NEW"
else
  echo "$NEW unhealthy, rolling back to $OLD"
  APP_VERSION="$OLD" docker compose up --wait --wait-timeout 90 --no-deps app
fi
```

Rollback giờ đúng là một lệnh: `./deploy.sh v2 v1`. Bạn không cần nhớ gì lúc nửa đêm, chỉ cần biết tag nào đang chạy ổn.

**Kiểm tra:** build một tag `v2-broken` có `/health` luôn hỏng, rồi chạy `./deploy.sh v2-broken v1`. Lệnh đầu phải thất bại sau tối đa 90 giây, dòng rollback phải in ra, và `docker ps` phải cho thấy `app` chạy lại image `v1` ở trạng thái healthy.

**Điểm mấu chốt:** Một lệnh deploy đáng tin là lệnh biết thất bại nhanh và biết tự quay về bản cũ.

## Con số 90 giây không nên chọn bừa

Thử làm một phép tính. Giả sử ứng dụng mất khoảng 20 giây để khởi động và mỗi lần kiểm tra cách nhau 10 giây. Với ba lần thử mặc định, bản hỏng cần khoảng 20 + 3 × 10 = 50 giây để bị đánh dấu unhealthy.

Nếu bạn đặt `--wait-timeout 30`, cả bản tốt khởi động chậm cũng có thể bị coi là lỗi và bị rollback oan. Nếu đặt 600, mỗi lần deploy hỏng khách hàng phải chờ mười phút mới thấy bản cũ quay lại. Hãy đo thời gian khởi động thật trên máy của khách, rồi đặt timeout cao hơn tổng ở trên một khoảng an toàn.

Còn một bẫy nữa ở `--no-deps`: vì nó không động tới service liên kết, `migrate` sẽ không chạy trong script này. Hãy chạy migration như một bước riêng trước khi deploy, và viết migration tương thích ngược, vì rollback image không tự rollback schema. Bản `v1` phải chạy được trên database mà `v2` đã migrate.

## Muốn không downtime thì phải có hai bản cùng chạy

Script trên chỉ trả lời câu hỏi bản mới có khỏe hay không; nó không hứa rằng bản cũ vẫn phục vụ trong lúc bản mới khởi động.

Đặc tả deploy của Compose phân biệt hai thứ tự cập nhật: `stop-first` dừng bản cũ trước, còn `start-first` cho bản mới lên trước để hai bản chạy chồng nhau một lúc.

Muốn người dùng không thấy khoảng trống nào, bạn cần kiểu thứ hai: hai container cùng sống trong lúc chuyển giao.

Cờ `--scale` ghi đè thiết lập `scale` trong file Compose, cho phép chạy nhiều instance của một service cạnh nhau. Công cụ cộng đồng `docker-rollout`, một plugin CLI cho Docker, tự động hóa đúng ý tưởng đó: nó tăng gấp đôi số instance, chờ bản mới lên, rồi gỡ các bản cũ.

Nhưng nó đi kèm ràng buộc: service không được khai báo `container_name` hay `ports` trong file Compose, và bạn cần một reverse proxy để chia traffic giữa container cũ và mới.

Ràng buộc này có lý do: hai container không thể cùng giữ một cổng cố định trên host, cũng không thể cùng mang một tên. Vì thế proxy giữ cổng công khai, còn container phía sau được tạo mới hay gỡ đi mà người dùng không hề hay biết.

## Đặc tả Compose còn có rollback dạng khai báo

Đặc tả `deploy` của Compose mô tả sẵn hành vi này theo kiểu khai báo. Khối `rollback_config` định nghĩa cách service được rollback khi một lần cập nhật thất bại. Trong `update_config`, `failure_action` nhận một trong ba giá trị `continue`, `rollback` hoặc `pause`, còn `order` chọn giữa `stop-first` và `start-first` như đã nói ở trên.

```yaml
services:
  app:
    deploy:
      update_config:
        failure_action: rollback
        order: start-first
```

Đây là cấu hình rút gọn. Trước khi dựa vào nó, hãy kiểm tra công cụ đang chạy file Compose ở máy khách có thực thi các khối `deploy` này hay không. Nếu không chắc, script `deploy.sh` ở trên là phương án minh bạch hơn, vì bạn nhìn thấy từng bước nó làm.

## Mang quy trình này tới máy của khách hàng

Tình huống điển hình của một FDE là một server on-prem hoặc một VM trong cloud của khách, nơi đội IT không cho cài thêm orchestrator. Bạn giao một repo có `compose.yaml`, một `deploy.sh`, và một trang README ghi rõ: tag nào đang chạy, cách deploy, và lệnh rollback. Người vận hành của khách phải tự làm được mà không cần gọi bạn.

Việc đầu tiên nên làm ở khách hàng là hỏi xem `/health` của ứng dụng kiểm tra những gì, rồi đo thời gian khởi động thật trên máy của họ. Hai thông tin đó quyết định bản lỗi có bị chặn hay lọt qua cổng.

Nếu bạn đang ứng tuyển vị trí FDE, đừng ghi "biết Docker" trong CV. Hãy viết một dòng cụ thể hơn, chẳng hạn: dựng quy trình deploy bằng Compose với health check, timeout và tự rollback cho môi trường không có Kubernetes. Kèm một repo nhỏ có kịch bản `v2-broken` để nhà tuyển dụng tự chạy và thấy rollback hoạt động.

Khách hàng hiếm khi nhớ bản deploy suôn sẻ. Họ sẽ nhớ cái lần bản lỗi lên production và ba phút sau hệ thống đã tự về lại bản cũ.

**Thử ngay tuần này:**

- Thêm HEALTHCHECK vào Dockerfile của một service bạn đang chạy, rồi cố tình làm endpoint /health trả lỗi để xem container chuyển sang unhealthy
- Viết script deploy.sh theo hướng dẫn và thử deploy một image cố tình hỏng để thấy rollback tự chạy
- Chuyển job migration trong dự án sang service riêng, đặt điều kiện service_completed_successfully

## Nguồn

- [Control startup and shutdown order in Compose](https://docs.docker.com/compose/how-tos/startup-order.md)

- [docker compose up](https://docs.docker.com/reference/cli/docker/compose/up/)

- [Compose Deploy Specification](https://docs.docker.com/reference/compose-file/deploy.md)

- [Dockerfile reference](https://docs.docker.com/reference/dockerfile/)

- [wowu/docker-rollout: Zero Downtime Deployment for Docker Compose](https://github.com/wowu/docker-rollout)
