Kamal 2 완벽 가이드 — Docker 기반 무중단 배포, PaaS 없이 셀프 호스팅하기

Kamal 2 완벽 가이드 — Docker 기반 무중단 배포, PaaS 없이 셀프 호스팅하기

이 글의 핵심

Kamal 2는 Docker 이미지를 단일 커맨드로 VPS에 배포하는 Rails 팀의 도구입니다. Kubernetes의 복잡도·PaaS의 비용·Capistrano의 한계를 동시에 해결하며, Rails 8의 공식 배포 도구로 채택되었습니다. 이 글은 설치·kamal-proxy·accessories·SSL·멀티 호스트·롤백·보안을 실전 중심으로 다룹니다.

이 글의 핵심

Kamal 2는 Basecamp(DHH)가 만든 Docker 기반 배포 도구로, Heroku 같은 PaaS 편의성을 자체 VPS에서 누리게 해주는 오픈소스입니다. 2024년 말 2.0 GA, 2025-2026에 걸쳐 Rails 8 기본 배포 도구로 채택되면서 주목받았습니다.

핵심 가치:

  • kamal deploy 한 줄로 무중단 배포
  • Kubernetes 없이 멀티 호스트 지원
  • 내장 리버스 프록시(kamal-proxy)로 HTTPS + 롤링 업데이트
  • MySQL·Redis·Postgres 같은 accessories를 함께 관리
  • Rails/Node/Python/Go 등 Docker 가능한 모든 스택 대응

이 글은 설치부터 프로덕션 운영 패턴까지 실제 배포해본 경험을 바탕으로 정리합니다.

왜 Kamal인가: 중간 지대의 도구

항목Heroku/RenderKamalKubernetes
초기 학습 비용낮음낮음높음
월 비용 (중간 규모)$100~500+VPS 비용만 ($10~100)컨트롤 플레인 + 노드
벤더 락인강함없음중립
무중단 배포자동자동수동 설정 필요
다중 서비스제한적accessories로 관리네이티브 지원
스케일 상한플랜에 종속수십 대 VPS까지 자연스러움수천 노드까지

“Hetzner VPS에서 Docker 컨테이너를 굴리되 Heroku처럼 배포하고 싶다” — 이것이 Kamal이 풀려는 문제입니다.

설치

# Ruby Gem (Ruby 3.1+ 필요)
gem install kamal

# 또는 Docker 이미지로 실행 (Ruby 없이)
alias kamal='docker run --rm -it \
  -v "${PWD}:/workdir" \
  -v "${SSH_AUTH_SOCK}:/ssh-agent" \
  -e "SSH_AUTH_SOCK=/ssh-agent" \
  -v /var/run/docker.sock:/var/run/docker.sock \
  ghcr.io/basecamp/kamal:latest'

kamal version

프로젝트 디렉터리에서 초기화:

kamal init
# config/deploy.yml, .env, .kamal/secrets 생성

deploy.yml: 한 파일로 보는 전체 그림

# config/deploy.yml
service: pkglog-web
image: myteam/pkglog-web

servers:
  web:
    hosts:
      - 203.0.113.10
      - 203.0.113.11
    labels:
      app: pkglog

  job:
    hosts:
      - 203.0.113.12
    cmd: bin/jobs

proxy:
  ssl: true
  host: pkglog.com
  app_port: 3000
  healthcheck:
    path: /up
    interval: 3
    timeout: 30

registry:
  server: ghcr.io
  username: myteam
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64
  secrets:
    - RAILS_MASTER_KEY

env:
  clear:
    RAILS_ENV: production
    RAILS_LOG_TO_STDOUT: "true"
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL
    - REDIS_URL

accessories:
  db:
    image: postgres:16
    host: 203.0.113.20
    port: 5432
    env:
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data

  redis:
    image: redis:7
    host: 203.0.113.20
    port: 6379
    directories:
      - data:/data

aliases:
  console: app exec --interactive --reuse "bin/rails console"
  logs: app logs -f
  shell: app exec --interactive --reuse "bash"

핵심 블록 해설

  • servers: 역할(roles)별 호스트 IP. web은 HTTP 트래픽, job은 백그라운드 워커
  • proxy: kamal-proxy 설정. SSL 자동, /up health check
  • registry: 이미지 레지스트리 (GHCR, Docker Hub, ECR 모두 가능)
  • builder: 로컬에서 빌드해 push. remote: true로 원격 Docker 빌더 지정도 가능
  • accessories: 같은 lifecycle로 관리할 DB/Redis. kamal accessory boot <name>
  • aliases: 자주 쓰는 명령을 짧게 정의

시크릿 관리

# .kamal/secrets
RAILS_MASTER_KEY=$(cat config/master.key)
DATABASE_URL=$(op read op://Vault/prod-db/url)
REDIS_URL=$(op read op://Vault/prod-redis/url)
KAMAL_REGISTRY_PASSWORD=$(gh auth token)
POSTGRES_PASSWORD=$(op read op://Vault/prod-db/password)

.kamal/secrets쉘 스크립트로 실행되어 결과를 환경변수로 주입합니다. 1Password CLI(op), Bitwarden CLI, AWS CLI, sops 등 원하는 시크릿 저장소와 연동할 수 있습니다. .kamal/secrets 자체는 절대 커밋하지 마세요.

Dockerfile: Rails 예시 (다른 스택도 원리 동일)

# Rails 8 기본 Dockerfile 구조
FROM ruby:3.3-slim AS base
WORKDIR /rails
RUN apt-get update -qq && apt-get install -y curl libpq5 libvips

FROM base AS build
RUN apt-get install -y build-essential libpq-dev nodejs npm git
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs 4
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile

FROM base
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails

USER 1000:1000
ENTRYPOINT ["bin/docker-entrypoint"]
EXPOSE 3000
CMD ["bin/rails", "server"]
# bin/docker-entrypoint
#!/bin/bash -e

if [ "${1}" = "bin/rails" ] && [ "${2}" = "server" ]; then
  bin/rails db:prepare  # 마이그레이션
fi

exec "${@}"

첫 배포

# 원격 서버에 Docker 설치 + 방화벽 열기
kamal setup

# 이미지 빌드·푸시·서버 pull·새 컨테이너 기동·프록시 스위칭
kamal deploy

# 로그 tail
kamal app logs -f

# Rails 콘솔 (앞서 aliases 정의)
kamal console

kamal setup은 호스트에 Docker·kamal-proxy를 설치하고 registry 로그인까지 자동으로 수행합니다. SSH 접근만 되어 있으면 됩니다.

무중단 배포가 일어나는 순서

  1. 로컬(또는 builder 호스트)에서 Docker 이미지 빌드, 태그 = git SHA
  2. registry로 push
  3. 모든 서버에서 docker pull 병렬 실행
  4. 서버별로 새 컨테이너 기동, /up health check 대기
  5. kamal-proxy가 healthy한 새 컨테이너로 트래픽 스위칭
  6. 구 컨테이너 SIGTERM, deploy_timeout(기본 30초) 대기 후 종료

요청 처리 중이던 연결은 drain되며, 사용자 측엔 끊김이 없습니다.

kamal-proxy: 내장 리버스 프록시의 가치

Kamal 2의 큰 변화는 Traefik을 버리고 자체 프록시(kamal-proxy)를 쓴다는 점입니다. Go로 작성되어 가볍고, 설정 surface가 작아 안정적입니다.

proxy:
  ssl: true
  host:
    - pkglog.com
    - www.pkglog.com
  app_port: 3000
  healthcheck:
    path: /up
    interval: 3
    timeout: 30
  buffering:
    requests: true
    responses: false
    max_request_body: 10485760   # 10MB
  • SSL 자동 발급/갱신: Let’s Encrypt
  • health check: 통과 전에는 트래픽 라우팅 안 함
  • 요청 버퍼링: 큰 업로드 요청 처리 안정화
  • 여러 도메인: host 배열로 지원

이미 Cloudflare Tunnel이나 다른 프록시를 쓰면 proxy: false로 끌 수 있습니다.

멀티 호스트 + 역할 분리

servers:
  web:
    hosts:
      - web1.pkglog.com
      - web2.pkglog.com
      - web3.pkglog.com
    labels:
      service: web

  worker:
    hosts:
      - worker1.pkglog.com
      - worker2.pkglog.com
    cmd: bundle exec sidekiq

  cron:
    hosts:
      - cron.pkglog.com
    cmd: bundle exec rake cron:run

역할별로 다른 CMD·다른 replicas를 지정합니다. 스테이트리스 web은 수평 확장, cron은 단일 인스턴스로 유지하는 식.

Accessories: DB/Redis도 함께 관리

accessories:
  db:
    image: postgres:16
    host: db.pkglog.com
    port: 5432
    env:
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data
    files:
      - config/postgresql.conf:/etc/postgresql/postgresql.conf
    cmd: postgres -c config_file=/etc/postgresql/postgresql.conf
kamal accessory boot db       # DB 컨테이너 생성·기동
kamal accessory logs db -f    # 로그 tail
kamal accessory exec db "psql -U postgres"

주의: 프로덕션 DB는 가능하면 managed service(RDS·Neon·Supabase)를 권장합니다. accessories는 staging/내부 서비스/스타트업 초기에 실용적이지만, 장기적으로 백업·복구·HA를 직접 관리하는 부담이 큽니다.

CI/CD: GitHub Actions 연동

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - uses: actions/checkout@v4

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.3'
          bundler-cache: true

      - uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Install Kamal
        run: gem install kamal

      - name: Deploy
        env:
          RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
          KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
          SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_KEY }}
        run: |
          mkdir -p ~/.ssh
          echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H ${{ vars.KAMAL_HOSTS }} >> ~/.ssh/known_hosts
          kamal deploy

롤백: 실전 시나리오

# 현재 버전과 이전 버전들 확인
kamal app version

# 한 버전 전으로 되돌리기
kamal rollback

# 특정 버전(이미지 태그)으로 롤백
kamal rollback abc1234

롤백은 이미지만 이전 것으로 교체합니다. DB 마이그레이션·외부 API 상태·캐시는 별도로 고려해야 합니다.

DB 마이그레이션의 backward-compatible 전략

롤백이 안전하려면 매 배포의 마이그레이션이 이전 코드와 호환되어야 합니다.

  • 컬럼 삭제: “더 이상 안 쓰기” 코드 배포 → 다음 배포에서 컬럼 drop
  • 컬럼 변경: 새 컬럼 추가 → 병행 쓰기 → 읽기 전환 → 구 컬럼 제거
  • Enum 값: 값 추가만 배포 → 쓰기 코드 배포
  • 인덱스: CONCURRENTLY 옵션으로 잠금 없이 생성

마이그레이션 두 단계(add/remove) 규칙만 지켜도 롤백 범위가 극적으로 안전해집니다.

보안 체크리스트

  • SSH는 키 인증만 (비밀번호 로그인 비활성화)
  • .kamal/secrets.gitignore
  • 방화벽: kamal-proxy의 80/443 외 포트는 외부 차단
  • accessories의 DB 포트는 VPC 내부망에서만 접근
  • kamal env push 주기적 실행으로 서버 env 동기화
  • registry 자격증명 로테이션 (GHCR PAT 만료)
  • 호스트 OS 자동 보안 업데이트(unattended-upgrades)
  • fail2ban으로 SSH 무차별 공격 차단
  • Cloudflare Tunnel 또는 Tailscale로 관리 접근 숨기기

모니터링·관측

Kamal 자체는 관측 도구가 아닙니다. 다음을 결합하세요.

  • 로그 수집: Loki + Promtail, 또는 Docker --log-driver=fluentd
  • 메트릭: Prometheus + node_exporter + cAdvisor
  • APM: Sentry, Datadog, Honeycomb, AppSignal
  • 업타임: Healthchecks.io, BetterStack Uptime
# docker-compose.monitoring.yml (관리 호스트에 별도 배포)
services:
  prometheus:
    image: prom/prometheus:v2.54.0
    volumes: ['./prometheus.yml:/etc/prometheus/prometheus.yml']
    ports: ['9090:9090']

  grafana:
    image: grafana/grafana:11
    ports: ['3000:3000']

트러블슈팅

No such container — 배포 중 컨테이너가 사라짐

서버 disk full, OOM, Docker 데몬 재시작 등이 원인. kamal app exec "df -h", dmesg | tail, docker info로 확인.

프록시 SSL 에러

  • 도메인 A 레코드가 서버 IP를 정확히 가리키는지
  • 80/443 포트가 방화벽/보안그룹에서 열려 있는지
  • Let’s Encrypt rate limit (주당 50개) 초과 여부

배포는 성공하는데 500 에러

health check URL이 단순 OK 반환이 아니라 실제 DB 커넥션까지 검증하는지 확인. Rails라면 Rails.application.routes.default_url_options 설정 누락일 수도 있음.

SSH 연결 지연으로 배포가 느림

~/.ssh/config에 ControlMaster 설정:

Host *.pkglog.com
  ControlMaster auto
  ControlPath ~/.ssh/sockets/%r@%h:%p
  ControlPersist 10m

실전 운영 팁

  1. staging을 먼저 동일 설정으로 운영해 배포 이슈를 프로덕션 전에 잡습니다.
  2. deploy_timeout은 애플리케이션 최대 요청 시간 + 여유로 설정 (기본 30초는 짧을 수 있음).
  3. 배포 알림을 Slack 웹훅으로 — post-deploy hook 활용.
  4. 이미지 태그에 git SHA를 사용해 어떤 커밋이 돌아가는지 즉시 확인 가능하게.
  5. kamal config 로 현재 설정을 출력해 리뷰어에게 공유하면 infra PR 리뷰가 쉬워집니다.

마무리

Kamal 2는 “K8s는 과하고 PaaS는 비싸다” 는 구간을 정확히 공략합니다. 월 $20짜리 Hetzner VPS 몇 대로 Heroku급 배포 경험을 얻을 수 있고, 이미 Rails 8·37signals 내부 수백만 사용자 서비스가 이 스택으로 돌아갑니다. Docker로 빌드 가능한 모든 스택에 적용 가능하니, 개인 프로젝트·스타트업·내부 도구 배포를 단순화하고 싶다면 오늘 저녁 VPS 하나 구해서 1시간 안에 첫 배포를 경험해보세요.

관련 글

  • Docker 완벽 가이드
  • Docker Compose 튜토리얼
  • GitHub Actions CI/CD 가이드
  • Nginx 완벽 가이드