Thực hành: Deploy Laravel bằng GitLab CI/CD + Docker

Hướng dẫn thực hành, áp dụng chiến lược blue-green deployment (đã phân tích ở chien-luoc-deployment.md) cho ứng dụng Laravel, container hoá bằng Docker, deploy qua GitLab CI/CD lên 1 VPS.

Cách dùng branch/tag để đưa code qua các môi trường (test → staging → production) xem git-workflow.md.

Vì sao blue-green mà không phải rolling? Rolling deployment (thay từng instance một) cần orchestrator biết thay lần lượt. Docker Compose không làm được: nó đọc deploy.replicas nhưng bỏ qua deploy.update_config (parallelism, order: start-first) — mấy field đó chỉ Docker Swarm mới hiểu. docker compose up -d dừng hết container cũ rồi mới dựng container mới → vẫn có khoảng 502. Trên 1 VPS + Compose, blue-green là cách duy nhất đạt zero-downtime thật mà không phải kéo cả Swarm/K8s vào. Chi tiết ở mục 3.4.

Kiến trúc tổng quan

flowchart TD
  REPO["GitLab repo<br/>(push code)"] --> PIPE
  subgraph PIPE["GitLab CI/CD pipeline"]
    direction LR
    B["build<br/>composer install<br/>npm build (Vite)"] --> TE["test<br/>phpunit/pest, lint"] --> PK["package<br/>build image → Registry"] --> DP["deploy<br/>SSH vào VPS → deploy.sh"]
  end
  DP --> PROXY
  subgraph VPS["VPS (1 máy)"]
    PROXY["proxy — Nginx :80/:443<br/>serve asset tĩnh + fastcgi_pass tới màu active"]
    PROXY -->|"active"| BLUE["app_blue — php-fpm<br/>v1.4"]
    PROXY -.->|"idle, chờ rollback"| GREEN["app_green — php-fpm<br/>v1.5"]
    BLUE --> SHARED
    GREEN --> SHARED
    SHARED["DÙNG CHUNG, không nhân đôi:<br/>PostgreSQL · Redis<br/>volume storage · volume assets"]
    WK["worker + scheduler<br/>chạy image của màu active"] --> SHARED
  end

Chỉ container app nhân đôi. Database, Redis, file upload dùng chung → đây là lý do migration vẫn phải backward-compatible (mục 3.5).

1. Dockerfile — multi-stage build (build nhiều giai đoạn)

Multi-stage giúp image production không mang theo composer, node, dev dependencies — image nhỏ, ít bề mặt tấn công (attack surface) hơn.

# ---- Stage 1: build asset frontend (Vite) ----
FROM node:20-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources ./resources
COPY vite.config.js ./
RUN npm run build

# ---- Stage 2: cài dependency PHP ----
FROM composer:2 AS vendor
WORKDIR /app
COPY database ./database
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --optimize-autoloader

# ---- Stage 3: runtime image ----
FROM php:8.3-fpm-alpine
RUN apk add --no-cache postgresql-dev \
    && docker-php-ext-install pdo pdo_pgsql opcache

WORKDIR /var/www/html
COPY . .
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build

RUN php artisan config:cache \
    && mkdir -p /srv/assets/public \
    && chown -R www-data:www-data storage bootstrap/cache /srv/assets

USER www-data
EXPOSE 9000
# Chép asset của bản này vào volume dùng chung rồi mới chạy php-fpm.
# Nginx serve tĩnh từ volume đó, nên asset của các bản cũ vẫn còn (xem 6.5).
CMD ["sh", "-c", "cp -r public/. /srv/assets/public/ && exec php-fpm"]

2. .gitlab-ci.yml

stages:
  - test
  - build
  - deploy

variables:
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

test:
  stage: test
  image: php:8.3-cli
  script:
    - composer install --no-interaction
    - php artisan test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind   # dind = docker-in-docker, để build image trong runner
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $IMAGE_TAG .
    - docker push $IMAGE_TAG
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy:
  stage: deploy
  image: alpine:3.20
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh && ssh-keyscan -H $SERVER_HOST >> ~/.ssh/known_hosts
  script:
    - ssh $SERVER_USER@$SERVER_HOST "/srv/app/deploy.sh $IMAGE_TAG"
  environment:
    name: production
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual   # deploy production cần bấm tay, tránh push nhầm là live ngay

Toàn bộ logic switch nằm trong deploy.sh trên server (mục 3.3), không nhét vào chuỗi SSH — dễ đọc, dễ chạy tay lúc sự cố, và set -e hoạt động đúng.

Biến bí mật (secrets) — khai báo ở GitLab: Settings → CI/CD → Variables, tick Protected + Masked, không hardcode trong file:

3. Blue-green trên 1 VPS — cấu hình cụ thể

Ý tưởng: 2 service app chạy song song, Nginx chỉ trỏ tới một màu. Deploy = dựng màu đang rảnh (idle) bằng image mới, test nó, rồi đổi 1 dòng config Nginx và reload. Rollback = đổi ngược lại dòng đó.

3.1 docker-compose.yml

services:
  proxy:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "127.0.0.1:8081:8081"   # cổng test riêng app_green — chỉ nghe localhost
      - "127.0.0.1:8082:8082"   # cổng test riêng app_blue
    volumes:
      - ./nginx/app.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/active-upstream.conf:/etc/nginx/active-upstream.conf:ro
      - assets:/srv/assets:ro

  app_blue:
    image: ${BLUE_TAG}
    restart: unless-stopped
    env_file: .env
    volumes:
      - assets:/srv/assets
      - storage:/var/www/html/storage

  app_green:
    image: ${GREEN_TAG}
    restart: unless-stopped
    env_file: .env
    volumes:
      - assets:/srv/assets
      - storage:/var/www/html/storage

  worker:
    image: ${ACTIVE_TAG}          # luôn chạy code của màu đang active
    restart: unless-stopped
    env_file: .env
    volumes: [storage:/var/www/html/storage]
    command: php artisan queue:work --tries=3
    stop_grace_period: 60s        # đủ thời gian job đang chạy kết thúc êm

  scheduler:
    image: ${ACTIVE_TAG}
    restart: unless-stopped
    env_file: .env
    volumes: [storage:/var/www/html/storage]
    command: php artisan schedule:work   # 1 instance duy nhất, tránh cron chạy trùng

volumes:
  assets:
  storage:

BLUE_TAG, GREEN_TAG, ACTIVE_TAG để luôn trong /srv/app/.env cùng chỗ với secret — Compose tự đọc .env cho việc thay biến, không cần --env-file. Mấy biến thừa này lọt vào container cũng vô hại.

Không service nào publish port ra ngoài trừ proxy — app chỉ nói chuyện qua mạng nội bộ của Compose, gọi nhau bằng tên service (app_blue:9000).

3.2 Nginx

nginx/active-upstream.conffile quyết định màu nào đang live, chỉ 1 dòng:

server app_blue:9000;

nginx/app.conf:

upstream app_fpm {
    include /etc/nginx/active-upstream.conf;   # deploy.sh ghi đè file này
}

server {
    listen 80;
    root /srv/assets/public;      # asset từ volume dùng chung, có cả bản cũ
    index index.php;

    location /build/ {
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control immutable;
    }

    location / {
        try_files $uri /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app_fpm;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public$fastcgi_script_name;
        include fastcgi_params;
    }
}

# 2 cổng test cố định cho từng màu — dùng để smoke test màu idle
# TRƯỚC khi switch, đi đúng đường thật: nginx → php-fpm → DB.
server {
    listen 8081;   # app_green
    location / {
        fastcgi_pass app_green:9000;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
        include fastcgi_params;
    }
}

server {
    listen 8082;   # app_blue
    location / {
        fastcgi_pass app_blue:9000;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
        include fastcgi_params;
    }
}

Điểm quan trọng: proxy chỉ serve file tĩnh từ volume assets và chuyển PHP sang php-fpm. Blue và green mỗi bản có public/build/app-<hash>.js khác nhau, cả hai cùng nằm trong volume nên không đá nhau (hash khác tên file).

3.3 /srv/app/deploy.sh

#!/bin/sh
set -eu
cd /srv/app
NEW_TAG="$1"

ACTIVE=$(cat active-color)                       # "blue" hoặc "green"
if [ "$ACTIVE" = blue ]; then IDLE=green; PORT=8081; else IDLE=blue; PORT=8082; fi
IDLE_VAR=$(echo "$IDLE" | tr a-z A-Z)_TAG        # BLUE_TAG / GREEN_TAG

echo "==> active=$ACTIVE, deploy $NEW_TAG lên $IDLE"

# 1. Dựng màu idle bằng image mới. Màu active không bị đụng tới.
sed -i "s|^$IDLE_VAR=.*|$IDLE_VAR=$NEW_TAG|" .env
docker compose pull "app_$IDLE"
docker compose up -d --no-deps "app_$IDLE"

# 2. Migration expand-only, chạy TRƯỚC khi switch.
#    Lúc này cả 2 version code đều đang sống → schema phải cả 2 đọc được (3.5).
docker compose exec -T "app_$IDLE" php artisan migrate --force

# 3. Smoke test màu idle qua đúng đường thật. Không đạt thì dừng, không switch.
i=0
until curl -fs "http://127.0.0.1:$PORT/up" >/dev/null; do
    i=$((i + 1))
    [ "$i" -ge 15 ] && { echo "!! smoke test FAIL — giữ nguyên $ACTIVE"; exit 1; }
    sleep 2
done

# 4. SWITCH — đổi 1 dòng rồi reload. Nginx reload là graceful:
#    request đang chạy dở vẫn xong trên worker process cũ.
echo "server app_$IDLE:9000;" > nginx/active-upstream.conf
docker compose kill -s HUP proxy
echo "$IDLE" > active-color

# 5. Worker + scheduler chuyển sang code mới.
sed -i "s|^ACTIVE_TAG=.*|ACTIVE_TAG=$NEW_TAG|" .env
docker compose up -d --no-deps worker scheduler

echo "==> xong, đang chạy $IDLE ($NEW_TAG). Màu $ACTIVE vẫn sống để rollback."

Màu cũ cố ý không stop. Giữ nó ít nhất 30 phút; rollback trong thời gian đó chỉ mất một lệnh (mục 4). Dọn sau: docker compose stop app_$ACTIVE.

Lần đầu setup: tạo active-color chứa blue, và trong .env thêm 3 dòng BLUE_TAG=, GREEN_TAG=, ACTIVE_TAG= (cho tạm cùng 1 tag).

3.4 Tại sao cách này mới là zero-downtime thật

Compose "rolling" (replicas: 2)        Blue-green
 [v1][v1]                               [v1 active][v2 đang dựng]
 [  ][  ]  ← stop cả 2 → 502            [v1 active][v2 đã test xong]
 [v2][v2]                               [v1 idle  ][v2 active]  ← đổi 1 dòng nginx

Ba điểm khác nhau, đều quan trọng:

  1. Không có khoảnh khắc nào không container nào phục vụ. Container mới dựng xong và test xong rồi mới nhận traffic.
  2. Test được trước khi mở cửa. Cổng 8081/8082 cho phép gọi thẳng vào màu idle qua đúng đường nginx → php-fpm → DB. --wait của Compose chỉ chờ healthcheck của container, không chứng minh đường đi thật chạy được.
  3. Rollback tính bằng giây, không cần pull/build lại image. Container cũ vẫn đang chạy sẵn.

Đổi lại: tốn RAM cho 2 bản app cùng lúc. Ước lượng RAM app × 2 + DB + Redis + dư 20%; VPS đang chạy sát RAM thì lúc switch sẽ dính OOM killer (kernel giết process khi hết RAM).

3.5 Migration an toàn (2 version code cùng sống)

Blue-green vẫn không thoát khỏi ràng buộc này, vì database dùng chung. Có 2 quãng thời gian cả v1 và v2 cùng nói chuyện với 1 schema: giữa bước 2 và 4 của deploy.sh, và toàn bộ thời gian giữ màu cũ để rollback.

Áp dụng expand-contract pattern:

  1. Expand (deploy 1): thêm cột mới, KHÔNG xoá/đổi tên cột cũ. Code mới đọc/ghi được cả cột cũ và cột mới.
  2. Chạy migrate --force trên màu idle, trước khi switch.
  3. Contract (deploy sau, khi chắc chắn không rollback về bản cũ nữa): xoá cột/bảng cũ trong 1 migration riêng.

Chi tiết từng loại thay đổi schema (đổi tên cột, đổi kiểu, thêm constraint, thêm index không khoá bảng) ở migration-postgresql-zero-downtime.md.

Chạy migrate an toàn khi có nhiều runner/replica (--isolated, exit code, lock kẹt) và chiến lược rollback ở migration-ci-cd.md.

3.6 Queue worker & scheduler

4. Rollback

Màu cũ vẫn đang chạy sẵn, nên rollback là đổi ngược upstream — không pull, không build, không chờ container khởi động:

/srv/app/rollback.sh:

#!/bin/sh
set -eu
cd /srv/app

ACTIVE=$(cat active-color)
if [ "$ACTIVE" = blue ]; then PREV=green; else PREV=blue; fi
PREV_VAR=$(echo "$PREV" | tr a-z A-Z)_TAG

docker compose ps --status running --services | grep -qx "app_$PREV" \
    || { echo "!! app_$PREV đã bị stop, không rollback nhanh được"; exit 1; }

echo "server app_$PREV:9000;" > nginx/active-upstream.conf
docker compose kill -s HUP proxy
echo "$PREV" > active-color

# worker/scheduler quay về code cũ
sed -i "s|^ACTIVE_TAG=.*|ACTIVE_TAG=$(grep "^$PREV_VAR=" .env | cut -d= -f2-)|" .env
docker compose up -d --no-deps worker scheduler

echo "==> đã rollback về $PREV"

Cái rollback này KHÔNG lùi được: migration đã chạy. Đây chính là lý do migration phải expand-only (3.5) — code cũ vẫn chạy được trên schema mới.

Nếu màu cũ đã bị stop (deploy quá lâu rồi), rollback chậm hơn: chạy lại deploy.sh với tag image cũ. Image được tag theo $CI_COMMIT_SHORT_SHA (commit cụ thể) nên vẫn còn trong registry, không cần build lại — đây là lý do nên tag theo commit SHA thay vì latest.

5. Những chỗ hay bị bỏ sót trong thực tế

6. Nếu app dùng Livewire

Livewire là stateful (có trạng thái giữ qua nhiều request): mỗi component được render kèm một snapshot (JSON chứa toàn bộ public property), trình duyệt gửi snapshot đó ngược lên /livewire/update mỗi lần user bấm nút. Server hydrate (dựng lại object PHP từ snapshot) rồi chạy method.

Nghĩa là: request sau phải hiểu được snapshot do request trước sinh ra. Blue-green có 2 quãng 2 version cùng sống — lúc switch (request đang mở trên trình duyệt vẫn giữ snapshot của màu cũ) và suốt thời gian giữ màu cũ để rollback. Đúng chỗ vỡ.

Chi tiết vận hành (sticky routing, tách metrics theo version) nằm ở canary-laravel-livewire.md. Mục này chỉ liệt kê những gì cấu hình ở trên phải thoả.

6.1 APP_KEY — đừng đổi lúc deploy

Snapshot kèm một checksum ký bằng HMAC dựa trên APP_KEY. Sai checksum → Livewire ném CorruptComponentPayloadException và chặn request.

Với kiến trúc ở trên (1 VPS, 1 file .env, blue và green cùng env_file) thì APP_KEY giống nhau → checksum luôn hợp lệ, không cần làm gì. Chỉ vỡ khi rotate (đổi) APP_KEY — và lúc đó blue-green không cứu được, vì mọi snapshot đang mở trên trình duyệt user đều chết.

6.2 Expand-contract cho public property, không chỉ cho DB

Đây mới là lỗi hay gặp — và checksum không bắt được, vì snapshot không hề bị sửa, chỉ là code đọc nó đã đổi:

10:00  blue (v1) render → snapshot { filters: [], page: 1 }   ✔ checksum đúng
10:05  switch sang green (v2): component bỏ $filters, đổi $page → $currentPage
10:07  user bấm nút → POST snapshot v1 → green hydrate → 500, trang chết

Áp đúng pattern expand-contract ở mục 3.5, nhưng cho property: deploy N thêm property mới và giữ property cũ; deploy N+2 mới xoá.

6.3 Session + cache bắt buộc lên Redis

SESSION_DRIVER=file → blue và green mỗi container một kho session riêng (volume storage dùng chung có đỡ, nhưng cache config/route thì không). Lúc switch, mỗi request Livewire đều kèm CSRF token — user không tìm thấy session của mình → 419 Page Expired hàng loạt. Đặt SESSION_DRIVER=redis, CACHE_STORE=redis trong .env trên server. Redis dùng chung nên switch màu không ai bị đăng xuất.

6.4 File upload tạm (livewire-tmp)

Livewire upload 2 nhịp: nhịp 1 lưu file tạm vào storage/app/livewire-tmp/; nhịp 2 (submit form) là request khác — nếu rơi đúng lúc switch màu thì sang container khác. Volume storage dùng chung ở mục 3.1 xử lý được việc này; production thật nên đặt LIVEWIRE_DISK=s3.

6.5 Asset build cũ bị 404

Trang Livewire thường mở rất lâu (dashboard, form dài). Trình duyệt vẫn giữ HTML cũ trỏ tới /build/assets/app-abc123.js; nếu asset đó biến mất → 404 → mọi tương tác chết im, không báo lỗi.

Cách ở mục 1 + 3.2 đã xử lý: mỗi container chép asset vào volume assets lúc khởi động thay vì thay thế, Nginx serve từ volume đó → asset của các bản cũ vẫn còn. Nhớ dọn định kỳ (vài ngày một lần, không phải mỗi deploy), hoặc đẩy asset lên CDN/S3.

Checklist trước khi deploy app Livewire


7. Case thực tế có thể gặp và cách xử lý

Case 1 — Switch xong nhưng traffic vẫn vào màu cũ. nginx -s reload chạy nhưng file upstream sửa không phải file Nginx đang include, hoặc container proxy mount đường dẫn khác. Xử lý ngay: docker compose exec proxy nginx -T | grep upstream để xem cấu hình Nginx đang thật sự chạy, không tin file trên disk. Ngăn lần sau: deploy.sh sau khi switch phải gọi /health qua đường public và kiểm tra header X-Release trả về đúng màu mới; không đúng thì fail job.

Case 2 — Màu idle dựng xong, smoke test pass, switch xong thì 502. Smoke test gọi thẳng container (localhost:9001) nên bỏ qua đúng chỗ hỏng: proxy không resolve được tên service mới, hoặc PHP-FPM listen sai socket. Xử lý ngay: switch ngược lại, xem docker compose logs proxy. Ngăn lần sau: smoke test phải đi đúng đường thật proxy → php-fpm → DB (mục 3.2 để sẵn 2 cổng test cho việc này), không gọi tắt.

Case 3 — Migration fail giữa chừng, DB ở trạng thái nửa vời. PostgreSQL cho DDL trong transaction, nhưng một file migration Laravel có nhiều lệnh và CREATE INDEX CONCURRENTLY không chạy trong transaction được — fail là index còn lại ở trạng thái INVALID. Xử lý ngay: không switch. \d+ bảng tìm index INVALIDDROP INDEX rồi tạo lại. Màu active vẫn đang chạy schema cũ nên dịch vụ chưa sao. Ngăn lần sau: mỗi migration một việc; CONCURRENTLY tách ra file riêng; SET lock_timeout để nó fail nhanh thay vì treo. Chi tiết: migration-postgresql-zero-downtime.md.

Case 4 — Deploy fail vì VPS hết disk. Mỗi release để lại một image; volume assets giữ asset của các bản cũ. Xử lý ngay: docker image prune -af --filter "until=168h", xoá thư mục asset cũ hơn vài ngày. Ngăn lần sau: cho cron dọn hàng tuần + cảnh báo khi disk > 80%. Đừng dọn theo mỗi lần deploy — asset bản cũ còn cần cho tab đang mở (mục 6.5).

Case 5 — Web đã sang bản mới nhưng job vẫn chạy code cũ. Quên restart worker, hoặc worker vẫn gắn với màu cũ. Xử lý ngay: restart worker của màu mới, php artisan queue:restart để worker đang chạy tự thoát sau job hiện tại. Ngăn lần sau: worker/scheduler nằm trong cùng bước switch của deploy.sh (mục 3.3 bước 4), không phải việc làm tay sau đó.

Case 6 — Đang deploy màu green thì màu blue (đang chạy) bị restart. docker compose up -d mà không giới hạn service sẽ recreate mọi service có cấu hình thay đổi — kể cả proxy và màu đang active. Xử lý ngay: dịch vụ tự lên lại sau vài giây, nhưng đó là downtime thật. Ngăn lần sau: luôn docker compose up -d --no-deps <service-màu-idle>. Chỉ gọi tên service cần dựng.

Case 7 — Sau switch, user đang mở tab bị lỗi "checksum không khớp" (Livewire). Snapshot render bởi màu cũ bị gửi tới màu mới có APP_KEY khác, hoặc public property đã bị đổi/xoá trong release này. Xử lý ngay: switch ngược về màu cũ; user tải lại trang là hết. Ngăn lần sau: APP_KEY không đổi khi deploy; đổi public property theo expand-contract như đổi schema (mục 6.2).

Case 8 — Cần rollback nhưng màu cũ đã bị tắt để tiết kiệm RAM. Rollback lẽ ra 1 giây thành 3 phút chờ container dựng lại. Xử lý: giữ màu cũ chạy ít nhất 30–60 phút sau switch. Đó chính là thứ mình trả tiền khi chọn blue-green — tắt sớm là vứt bỏ lợi ích duy nhất của nó.

Case 9 — VPS hết RAM lúc 2 màu cùng chạy, OOM killer giết container đang phục vụ. Xử lý ngay: tắt màu idle, giảm pm.max_children của PHP-FPM. Ngăn lần sau: tính RAM theo đỉnh lúc chồng lấn (2 màu + DB + Redis + build), đặt mem_limit cho từng service để container idle không giết được container active.

8. Câu hỏi phụ interviewer hay hỏi tiếp

"Blue-green trên 1 VPS có phải blue-green thật không?" Là blue-green ở tầng ứng dụng: 2 bộ container app, đổi upstream để switch, rollback tức thì. Không phải blue-green hạ tầng: DB, Redis, VPS dùng chung — VPS chết là chết cả hai, và migration vẫn phải backward-compatible. Nói rõ giới hạn này là điểm cộng khi phỏng vấn.

"Nginx reload có làm rớt request đang chạy không?" Không. reload là graceful: Nginx dựng worker process mới đọc config mới, worker cũ phục vụ nốt request đang mở rồi tự thoát. Cái làm rớt request là restart, không phải reload.

"Sao không dùng docker compose up --scale cho rolling?" Compose bỏ qua deploy.update_config — nó dừng hết container cũ rồi mới dựng bản mới, tức là recreate chứ không phải rolling. Muốn rolling thật phải có orchestrator (Swarm/K8s/ECS).

"Migration chạy ở bước nào, và vì sao?" Trước khi switch, khi màu idle đã dựng xong và màu active vẫn đang phục vụ. Lúc đó hai version code cùng sống, nên migration chỉ được ADD, không DROP/RENAME. Chạy đúng một lần, từ một container, không chạy trong entrypoint (nhiều container = chạy song song = race condition).

"Sao build image trong CI mà không build trên server?" Ba lý do: build ăn CPU/RAM sẽ ảnh hưởng chính app đang phục vụ; build trên server nghĩa là mỗi môi trường có một artifact khác nhau (thứ test không phải thứ chạy); và cần image sẵn trong registry thì rollback mới nhanh được — docker pull bản cũ nhanh hơn build lại rất nhiều.

"Secret quản lý thế nào?" .env nằm trên server, ngoài repo, quyền 600. Giá trị nhạy cảm cho CI để ở GitLab CI variables (Masked + Protected). Đổi .env là một lần deploy riêng, không kèm release code — để khi hỏng còn biết nguyên nhân là gì.

"Zero-downtime cho queue worker thì sao?" Worker không nhận HTTP nên không cần switch. Cách an toàn là queue:restart (worker chạy nốt job hiện tại rồi thoát) + stop_grace_period dài hơn job lâu nhất. Job phải idempotent, vì bị giết giữa chừng là nó sẽ chạy lại.

"Cần VPS bao nhiêu RAM?" Tính theo lúc chồng lấn: RAM 1 màu × 2 + DB + Redis + Nginx + chỗ trống ~20%. Ứng dụng Laravel cỡ vừa thường 4 GB là chật, 8 GB thoải mái. Nếu đang sát trần, mem_limit cho từng service là bắt buộc.

"Khi nào thì nên bỏ cách này để lên Kubernetes?" Khi cần nhiều hơn 1 máy (HA thật, VPS chết vẫn sống), khi cần autoscale, hoặc khi có nhiều service phải deploy độc lập. Với 1 app + 1 VPS + team 3–4 người, K8s thêm rất nhiều việc vận hành mà không giải quyết vấn đề nào đang có.

Tóm tắt để trả lời phỏng vấn

"Trên 1 VPS chạy Docker Compose thì không có rolling thật, nên tôi làm blue-green ở tầng app: hai bộ container app_blue/app_green, Nginx trỏ upstream vào màu đang active, deploy là dựng màu idle bằng image mới → migration expand-only → smoke test đi đúng đường thật → đổi một dòng upstream rồi nginx -s reload (graceful nên không rớt request). Rollback là đổi ngược dòng đó, dưới một giây — với điều kiện màu cũ còn chạy, nên tôi giữ nó thêm 30–60 phút. Image build một lần trong CI và promote qua các môi trường, cấu hình đi bằng biến env lúc runtime. Ba chỗ tôi để ý nhất: migration phải backward-compatible vì có lúc hai version cùng dùng một DB, worker/scheduler phải chuyển màu cùng lúc và chỉ chạy một bản, và với Livewire thì APP_KEY không được đổi còn public property phải theo expand-contract như schema."