Kamal: Triển khai ứng dụng Docker lên VPS không cần Kubernetes — Hướng dẫn thực chiến

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

Bối cảnh: Tại sao không phải Kubernetes, và tại sao lại là Kamal?

Kubernetes mạnh, nhưng setup một cluster K8s cho một app Rails hay Node.js chạy trên VPS $5/tháng là overkill hoàn toàn. Mình đã trải qua giai đoạn đó — viết YAML cho Deployment, Service, Ingress, cert-manager… chỉ để deploy một app có vài trăm users. Không đáng.

Còn cách cũ hơn? SSH vào server, kéo image mới, restart container thủ công. Đơn giản, nhưng không có zero-downtime, không rollback tự động, và khi cần deploy lên nhiều server thì script Bash bắt đầu trở thành nỗi ác mộng bảo trì.

Kamal (trước đây gọi là MRSK, do team Basecamp/37signals phát triển) lấp vừa khoảng giữa đó. Những gì nó mang lại:

  • Deploy qua SSH trực tiếp — không cần cài agent hay daemon trên server
  • Zero-downtime deployment bằng container swap, dùng Traefik làm reverse proxy
  • Tự động quản lý SSL qua Let’s Encrypt (thông qua Traefik)
  • Rollback về version trước chỉ với một lệnh: kamal rollback
  • Multi-server support — deploy lên 5 server cùng lúc cũng được

Mình đã chuyển từ docker-compose v1 sang v2 cho toàn bộ stack khi rebuild lại hạ tầng, và quá trình đó khá smooth. Nhưng vẫn còn pain point: mỗi lần deploy vẫn phải SSH tay, copy-paste lệnh, và cầu mong không có downtime. Kamal giải quyết đúng cái đó.

Cài đặt Kamal

Kamal là Ruby gem, nhưng bạn không cần biết Ruby để dùng. Ruby 3.1+ cần có trên máy local — không phải server.

# macOS — dùng rbenv
brew install rbenv
rbenv install 3.2.2
rbenv global 3.2.2

# Ubuntu/Debian (máy local)
sudo apt install ruby ruby-dev build-essential

# Cài Kamal
gem install kamal

# Kiểm tra
kamal version

Yêu cầu phía VPS (server):

  • Ubuntu 20.04+ hoặc Debian 11+
  • Docker chưa cần cài sẵn — Kamal tự cài khi setup
  • SSH key đã cấu hình (không dùng password)
  • User có quyền sudo

Kiểm tra SSH access trước khi làm gì cả:

ssh user@your-vps-ip "echo OK"

Cấu hình chi tiết với deploy.yml

Vào thư mục app, init project Kamal:

kamal init

Lệnh này tạo file config/deploy.yml — file quan trọng nhất. Dưới đây là ví dụ cấu hình thực tế cho một app web thông thường:

# config/deploy.yml
service: myapp
image: ghcr.io/your-username/myapp

servers:
  web:
    hosts:
      - 103.x.x.x
    labels:
      traefik.http.routers.myapp-secure.rule: Host(`myapp.com`)
      traefik.http.routers.myapp-secure.tls: true
      traefik.http.routers.myapp-secure.tls.certresolver: letsencrypt

registry:
  server: ghcr.io
  username: your-github-username
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  clear:
    APP_ENV: production
    PORT: 3000
  secret:
    - DATABASE_URL
    - SECRET_KEY_BASE

healthcheck:
  path: /up
  port: 3000
  interval: 3s
  timeout: 10s
  retries: 5

traefik:
  options:
    publish:
      - "443:443"
    volume:
      - "/letsencrypt/acme.json:/letsencrypt/acme.json"
  args:
    entryPoints.web.address: ":80"
    entryPoints.websecure.address: ":443"
    certificatesResolvers.letsencrypt.acme.email: "[email protected]"
    certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
    certificatesResolvers.letsencrypt.acme.tlschallenge: true

accessories:
  db:
    image: postgres:16
    host: 103.x.x.x
    port: 5432
    env:
      clear:
        POSTGRES_DB: myapp_production
      secret:
        - POSTGRES_PASSWORD
    volumes:
      - /data/postgres:/var/lib/postgresql/data

  redis:
    image: redis:7
    host: 103.x.x.x
    port: 6379
    volumes:
      - /data/redis:/data

Tạo file .env ở thư mục gốc project (không commit vào git):

# .env
KAMAL_REGISTRY_PASSWORD=ghp_your_github_token
DATABASE_URL=postgresql://user:pass@localhost/myapp_production
SECRET_KEY_BASE=your_long_random_secret
POSTGRES_PASSWORD=strong_db_password
# Thêm vào .gitignore ngay
echo ".env" >> .gitignore

Mẹo hay khi cấu hình

Healthcheck là bắt buộc nếu muốn zero-downtime thật sự. Kamal giữ container cũ chạy cho đến khi container mới pass healthcheck. Không có endpoint /up? Tự tạo một route trả về HTTP 200 là đủ.

Dùng volumes cho persistent data — uploaded files, storage, cache. Ví dụ với Rails:

volumes:
  - /data/myapp/storage:/rails/storage

Multi-server deploy — Kamal xử lý song song, rolling update mặc định:

servers:
  web:
    hosts:
      - 103.x.x.1
      - 103.x.x.2
  workers:
    hosts:
      - 103.x.x.3
    cmd: bundle exec sidekiq

Deploy lần đầu

# Setup server lần đầu — cài Docker, Traefik, accessories
kamal setup

# Mỗi lần deploy sau đó
kamal deploy

Lần đầu chạy kamal setup, Kamal SSH vào server và tự lo phần còn lại: cài Docker, khởi Traefik, start Postgres/Redis, pull image rồi chạy app. Mất khoảng 2-3 phút. Từ lần thứ hai trở đi, kamal deploy thường xong trong 30-60 giây.

Kiểm tra & Monitoring sau khi deploy

App đã chạy trên production — nhưng công việc không dừng ở đó. Mấy lệnh sau sẽ cần dùng thường xuyên:

# Xem logs realtime
kamal app logs -f

# Trạng thái containers trên từng server
kamal app details

# SSH vào container đang chạy để debug
kamal app exec --interactive "bash"

# Chạy lệnh một lần trong container (ví dụ: migrate DB)
kamal app exec "rails db:migrate"

# Xem logs Traefik
kamal traefik logs

# Rollback về version trước (rất nhanh, dùng image đã có sẵn)
kamal rollback

Kiểm tra SSL và routing

Muốn xác nhận SSL hoạt động đúng, chạy nhanh:

curl -I https://myapp.com

Thấy HTTP/2 200 kèm certificate từ Let’s Encrypt là ngon lành. Lần đầu Let’s Encrypt cấp cert có thể mất từ vài giây đến 2-3 phút — trong lúc đó Traefik dùng self-signed cert tạm thời, không cần lo.

Lỗi thường gặp và cách xử lý

Port 80/443 bị chiếm: Nếu Nginx hoặc Apache đang chạy trên server, Traefik sẽ không bind được port. Dừng service cũ trước:

ssh user@vps "sudo systemctl stop nginx && sudo systemctl disable nginx"

acme.json permission lỗi: File lưu Let’s Encrypt certificate cần permission 600, không thì Traefik từ chối đọc:

ssh user@vps "sudo touch /letsencrypt/acme.json && sudo chmod 600 /letsencrypt/acme.json"

Image pull chậm: Dùng GitHub Container Registry (ghcr.io) thay Docker Hub. Pull image ~500MB từ ghcr.io về server ở châu Á thường mất 20-40 giây; cùng image đó qua Docker Hub có thể lên 2-3 phút.

Rõ ràng Kamal không dành cho bài toán 100 microservices hay cần horizontal autoscaling. Nhưng nếu bạn có 1-5 server, app dưới vài nghìn concurrent users, và không muốn mất một tuần setup K8s — đây là lựa chọn đúng. Mình dùng cho một SaaS khoảng 800 users, chạy ổn 6 tháng nay, chưa cần đụng vào infrastructure lần nào.

Share: