Back to blog

Docker Production: Xử Lý Lỗi Khi Khởi Động

dockerdevopscontainersproductionbackend
Docker Production: Xử Lý Lỗi Khi Khởi Động

Bạn đã containerize app xong. Compose file trông clean. Chạy ngon trên máy local.

Rồi bạn deploy lên production và một trong hai thứ sau xảy ra:

  1. Image mới pull thất bại. Container crash. Version cũ đã bị xóa. Service down.
  2. App start trước khi database kịp sẵn sàng. Crash với lỗi kết nối. Không tự recover.

Đây không phải edge case. Chúng xảy ra với mọi team deploy Docker production, thường vào thời điểm tệ nhất. Bài này cho bạn các pattern cụ thể để xử lý cả hai.


Vấn Đề 1: Image Pull Thất Bại Khi Deploy

Tại Sao Xảy Ra

Khi bạn chạy docker compose up hoặc docker pull, Docker liên hệ registry để download image. Việc này có thể thất bại vì nhiều lý do:

  • Registry down — Docker Hub có outages; private registry có vấn đề network
  • Authentication hết hạn — credentials xoay vòng, token timeout
  • Image tag không tồn tại — typo trong tag, image chưa được push trước khi deploy
  • Network timeout — kết nối chậm, image lớn, proxy công ty chặn
  • Rate limiting — Docker Hub free tier giới hạn unauthenticated pull

Scenario nguy hiểm là khi bạn đã stop container cũ trước khi image mới pull xong.

Cách Deploy Sai

# ❌ NGUY HIỂM — stop container cũ trước, rồi mới pull image mới
docker compose down
docker compose pull
docker compose up -d

Nếu docker compose pull thất bại ở đây, bạn không có service nào đang chạy. Bạn vừa biến một deploy failure thành outage.

Chiến Lược 1: Pull Trước Khi Stop

Luôn pull image mới trước, xác nhận nó tồn tại local, rồi mới swap:

# ✅ Pull image mới TRƯỚC khi stop container cũ
docker compose pull
 
# Chỉ stop và restart nếu pull thành công
if [ $? -eq 0 ]; then
  docker compose up -d
else
  echo "Pull thất bại — giữ nguyên containers đang chạy"
  exit 1
fi

Chiến Lược 2: Ghim Version Image, Không Dùng :latest

# docker-compose.yml
services:
  api:
    image: myregistry.io/myapp:${APP_VERSION}  # không bao giờ dùng :latest
# deploy.sh
PREVIOUS_VERSION=$(docker inspect myapp:current --format='{{.Config.Image}}' 2>/dev/null)
NEW_VERSION="myregistry.io/myapp:${APP_VERSION}"
 
echo "Pulling $NEW_VERSION..."
docker pull "$NEW_VERSION"
 
if [ $? -ne 0 ]; then
  echo "❌ Pull thất bại. Giữ nguyên $PREVIOUS_VERSION"
  exit 1
fi
 
echo "✅ Pull thành công. Đang deploy..."
APP_VERSION=$APP_VERSION docker compose up -d
 
echo "Version trước để rollback: $PREVIOUS_VERSION"

Chiến Lược 3: Retry với Exponential Backoff

# Retry pull tối đa 3 lần với exponential backoff
pull_with_retry() {
  local image=$1
  local max_attempts=3
  local attempt=1
 
  while [ $attempt -le $max_attempts ]; do
    echo "Lần thử $attempt/$max_attempts: pulling $image"
    docker pull "$image" && return 0
 
    wait_time=$((attempt * 15))
    echo "Pull thất bại. Chờ ${wait_time}s trước khi retry..."
    sleep $wait_time
    attempt=$((attempt + 1))
  done
 
  echo "❌ Tất cả lần thử đều thất bại cho $image"
  return 1
}
 
pull_with_retry "myregistry.io/myapp:${APP_VERSION}"

Chiến Lược 4: Local Registry Mirror

Với production server, chạy một local registry mirror để pull không phụ thuộc vào external network:

# registry-mirror/docker-compose.yml
services:
  registry:
    image: registry:2
    ports:
      - "5000:5000"
    environment:
      REGISTRY_PROXY_REMOTEURL: https://registry-1.docker.io
    volumes:
      - registry-data:/var/lib/registry
 
volumes:
  registry-data:

Cấu hình Docker daemon dùng mirror:

// /etc/docker/daemon.json
{
  "registry-mirrors": ["http://localhost:5000"]
}

Giờ docker pull nginx hit local mirror trước. Nếu mirror đã cache image, external registry outage không ảnh hưởng bạn.

Chiến Lược 5: Verify Image trong CI Trước Khi Deploy

Cách an toàn nhất là xác nhận image tồn tại và pull được trong CI pipeline, trước khi bước deploy bắt đầu:

# GitHub Actions
jobs:
  verify-image:
    runs-on: ubuntu-latest
    steps:
      - name: Pull và verify image
        run: |
          docker pull myregistry.io/myapp:${{ github.sha }}
          echo "Image đã xác nhận ✅"
 
  deploy:
    needs: verify-image
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        run: |
          ssh deploy@server "APP_VERSION=${{ github.sha }} ./deploy.sh"

Nếu image thiếu hoặc registry down, pipeline fail trước khi bất kỳ container production nào bị động đến.


Vấn Đề 2: Mất Kết Nối Database Khi Khởi Động

Tại Sao Xảy Ra

Container start nhanh. Database start chậm. Khi bạn chạy docker compose up, container app có thể sẵn sàng nhận connection trong 1-2 giây — nhưng PostgreSQL, MySQL, hay MongoDB có thể mất 10-30 giây để khởi tạo xong.

Không có coordination rõ ràng, đây là những gì xảy ra:

t=0s   → docker compose up
t=1s   → api container start, app code chạy
t=1s   → app thử kết nối db: "Connection refused"
t=1s   → app crash (exit code 1)
t=2s   → Docker restart api (restart: always)
t=3s   → app thử kết nối lại: "Connection refused"
t=3s   → app crash lại
...
t=15s  → db cuối cùng sẵn sàng
t=20s  → api cuối cùng kết nối được ở lần retry thứ N

App mất 20+ giây mới healthy, log đầy lỗi connection trong lúc startup. Trong production không có restart: always, app cứ nằm down.

Nguyên Nhân Gốc Rễ

depends_on trong Docker Compose chỉ chờ container start, không chờ service bên trong sẵn sàng:

# ❌ Cái này KHÔNG chờ postgres sẵn sàng
services:
  api:
    depends_on:
      - db
  db:
    image: postgres:16

Container api start ngay khi process db bắt đầu — dù Postgres chưa load xong.

Giải Pháp 1: Healthcheck + depends_on condition (Tốt nhất cho Compose)

Thêm healthcheck vào database service, rồi chờ nó với condition: service_healthy:

services:
  api:
    build: .
    depends_on:
      db:
        condition: service_healthy  # chờ cho đến khi db pass healthcheck
      cache:
        condition: service_healthy
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 5s      # kiểm tra mỗi 5 giây
      timeout: 5s       # fail nếu không response trong 5s
      retries: 10       # đánh dấu unhealthy sau 10 lần fail liên tiếp
      start_period: 10s # không tính fail trong 10s đầu startup
 
  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

Với config này, Docker Compose sẽ không start api cho đến khi cả db và cache pass healthcheck. Không cần retry logic trong app cho startup race condition.

Lệnh healthcheck cho các database phổ biến:

# PostgreSQL
pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}
 
# MySQL / MariaDB
mysqladmin ping -h localhost -u root -p${MYSQL_ROOT_PASSWORD}
 
# MongoDB
mongosh --eval "db.adminCommand('ping')"
 
# Redis
redis-cli ping
 
# Elasticsearch
curl -f http://localhost:9200/_cluster/health?wait_for_status=yellow

Giải Pháp 2: Retry Logic trong Application

Healthchecks xử lý startup race, nhưng bạn vẫn cần retry logic trong app vì hai lý do:

  1. Database có thể down trong khi app đang chạy (restart, failover, network blip)
  2. Healthchecks không phải lúc nào cũng available (Kubernetes, bare metal)

Node.js với Prisma:

import { PrismaClient } from '@prisma/client'
 
const prisma = new PrismaClient()
 
async function connectWithRetry(maxAttempts = 10, delayMs = 3000): Promise<void> {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      await prisma.$connect()
      console.log('✅ Database đã kết nối')
      return
    } catch (error) {
      console.error(`❌ Lần thử kết nối DB ${attempt}/${maxAttempts}:`, error.message)
 
      if (attempt === maxAttempts) {
        throw new Error(`Không thể kết nối database sau ${maxAttempts} lần thử`)
      }
 
      const backoff = Math.min(delayMs * attempt, 30000) // tối đa 30s
      console.log(`Thử lại sau ${backoff / 1000}s...`)
      await new Promise(resolve => setTimeout(resolve, backoff))
    }
  }
}
 
// Khi khởi động
await connectWithRetry()

Python với SQLAlchemy:

import time
import logging
from sqlalchemy import create_engine, text
from sqlalchemy.exc import OperationalError
 
logger = logging.getLogger(__name__)
 
def connect_with_retry(database_url: str, max_attempts: int = 10, delay: float = 3.0):
    engine = create_engine(database_url, pool_pre_ping=True)
 
    for attempt in range(1, max_attempts + 1):
        try:
            with engine.connect() as conn:
                conn.execute(text("SELECT 1"))
            logger.info("✅ Database đã kết nối")
            return engine
        except OperationalError as e:
            logger.error(f"❌ Lần thử DB {attempt}/{max_attempts}: {e}")
 
            if attempt == max_attempts:
                raise
 
            backoff = min(delay * attempt, 30)
            logger.info(f"Thử lại sau {backoff:.0f}s...")
            time.sleep(backoff)
 
engine = connect_with_retry(os.environ["DATABASE_URL"])

Go:

package db
 
import (
    "database/sql"
    "fmt"
    "log"
    "math"
    "time"
    _ "github.com/lib/pq"
)
 
func ConnectWithRetry(dsn string, maxAttempts int) (*sql.DB, error) {
    var db *sql.DB
    var err error
 
    for attempt := 1; attempt <= maxAttempts; attempt++ {
        db, err = sql.Open("postgres", dsn)
        if err == nil {
            err = db.Ping()
        }
 
        if err == nil {
            log.Println("✅ Database đã kết nối")
            return db, nil
        }
 
        log.Printf("❌ Lần thử DB %d/%d: %v", attempt, maxAttempts, err)
 
        if attempt == maxAttempts {
            break
        }
 
        backoff := math.Min(float64(attempt)*3, 30)
        log.Printf("Thử lại sau %.0fs...", backoff)
        time.Sleep(time.Duration(backoff) * time.Second)
    }
 
    return nil, fmt.Errorf("không thể kết nối sau %d lần thử: %w", maxAttempts, err)
}

Giải Pháp 3: Dùng pool_pre_ping / Connection Validation

Cấu hình connection pool để test connection trước khi sử dụng. Xử lý không chỉ startup mà còn stale connections sau khi DB restart:

# SQLAlchemy — validate connection trước mỗi lần dùng
engine = create_engine(
    DATABASE_URL,
    pool_pre_ping=True,       # test connection trước khi dùng
    pool_recycle=3600,        # recycle connections mỗi giờ
    pool_size=10,
    max_overflow=20,
)
// Prisma — tự reconnect
const prisma = new PrismaClient({
  datasources: {
    db: { url: process.env.DATABASE_URL }
  }
})
// Prisma xử lý reconnection nội bộ

Giải Pháp 4: Kubernetes Readiness Probes

Trong Kubernetes, bạn không dùng depends_on — bạn dùng readiness probes. Pod chỉ được thêm vào endpoint list của Service khi readiness probe pass:

spec:
  containers:
    - name: api
      image: myapp:v1.0.0
      readinessProbe:
        httpGet:
          path: /health/ready   # trả về 200 chỉ khi DB đã kết nối
          port: 8080
        initialDelaySeconds: 5
        periodSeconds: 5
        failureThreshold: 6
 
      livenessProbe:
        httpGet:
          path: /health/live    # trả về 200 nếu process còn sống
          port: 8080
        initialDelaySeconds: 15
        periodSeconds: 10

Endpoint /health/ready phải check kết nối database thực sự:

// Express health endpoints
app.get('/health/ready', async (req, res) => {
  try {
    await prisma.$queryRaw`SELECT 1`
    res.json({ status: 'ready', db: 'connected' })
  } catch (error) {
    res.status(503).json({ status: 'not ready', db: 'disconnected' })
  }
})
 
app.get('/health/live', (req, res) => {
  res.json({ status: 'alive' })
})

Liveness probe (/health/live) chỉ check process còn sống — luôn trả 200 khi process đang chạy. Readiness probe (/health/ready) check app có thể serve traffic không — trả 503 khi DB unavailable. Điều này ngăn Kubernetes route traffic đến pod khi DB down, mà không kill và restart pod không cần thiết.


Tổng Hợp Lại

Đây là docker-compose.yml production-grade xử lý cả hai failure mode:

services:
  api:
    image: myregistry.io/myapp:${APP_VERSION:?APP_VERSION là bắt buộc}
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/myapp
      REDIS_URL: redis://cache:6379
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s
    ports:
      - "127.0.0.1:8080:8080"
 
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD là bắt buộc}
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s
 
  cache:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --appendonly yes
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
 
volumes:
  pgdata:
  redisdata:

Và deploy script xử lý image pull failure:

#!/bin/bash
# deploy.sh
set -e
 
APP_VERSION=${1:?Cách dùng: ./deploy.sh <version>}
IMAGE="myregistry.io/myapp:${APP_VERSION}"
 
echo "🚀 Deploying $IMAGE"
 
# Bước 1: Pull image mới TRƯỚC khi động đến containers đang chạy
echo "📦 Đang pull image..."
MAX_RETRIES=3
for i in $(seq 1 $MAX_RETRIES); do
  docker pull "$IMAGE" && break
 
  if [ $i -eq $MAX_RETRIES ]; then
    echo "❌ Pull thất bại sau $MAX_RETRIES lần. Hủy deploy."
    exit 1
  fi
 
  echo "Retry $i/$MAX_RETRIES sau 15s..."
  sleep 15
done
 
echo "✅ Pull thành công"
 
# Bước 2: Deploy (containers cũ vẫn chạy cho đến đây)
echo "🔄 Đang update service..."
APP_VERSION=$APP_VERSION docker compose up -d --no-deps api
 
echo "⏳ Chờ api trở nên healthy..."
timeout 60 bash -c 'until docker compose ps api | grep -q "healthy"; do sleep 2; done'
 
echo "✅ Deploy hoàn tất"

Bảng Tóm Tắt Nhanh

Image pull failure:

ScenarioGiải pháp
Registry downPull trước khi stop; local mirror
Auth hết hạnXoay credentials trong CI/CD secrets; verify trước deploy
Tag không tồn tạiGhim version rõ ràng; verify tag trong CI trước deploy
Network timeoutRetry với backoff; local mirror; pre-pull trong CI
Rate limitingAuthenticate với Docker Hub; dùng private registry

DB connection failure:

ScenarioGiải pháp
DB chưa sẵn sàng khi startuphealthcheck + condition: service_healthy
DB restart khi app đang chạypool_pre_ping=True; retry logic trong app
Port DB chưa mởRetry với exponential backoff khi startup
Kubernetes deploymentReadiness probe check /health/ready
DB failoverConnection pool với reconnect; circuit breaker

Tóm Tắt và Điểm Chính

✅ Luôn pull image mới trước khi stop containers đang chạy — không bao giờ làm ngược lại
✅ Ghim version image rõ ràng trong production — :latest làm rollback bất khả thi
✅ Thêm retry với exponential backoff vào bước pull trong CI/CD pipeline
✅ Local registry mirror loại bỏ external registry outage như một deploy risk
✅ depends_on một mình KHÔNG chờ database sẵn sàng — phải thêm healthcheck
✅ condition: service_healthy trong Compose + pg_isready trong healthcheck là pattern đúng
✅ Thêm startup retry logic trong app như defense-in-depth — healthchecks không phải lúc nào cũng available
✅ Dùng pool_pre_ping=True (hoặc tương đương) để phát hiện stale connections trước khi dùng
✅ Trong Kubernetes: readiness probe trên /health/ready (check DB) + liveness trên /health/live (check process)
✅ Cú pháp APP_VERSION:? trong Compose biến missing env vars thành hard error trước khi deploy bắt đầu


Liên quan:
Docker Fundamentals — containers, images, concepts cốt lõi
Docker Compose & Multi-Container Apps — Compose deep dive
Docker Networking & Volumes — networking và storage
Lộ Trình Docker & Kubernetes — lộ trình học đầy đủ


Có câu chuyện Docker production failure? Đừng ngại liên hệ hoặc để lại comment!

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.