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/Render | Kamal | Kubernetes |
|---|---|---|---|
| 초기 학습 비용 | 낮음 | 낮음 | 높음 |
| 월 비용 (중간 규모) | $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 checkregistry: 이미지 레지스트리 (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 접근만 되어 있으면 됩니다.
무중단 배포가 일어나는 순서
- 로컬(또는 builder 호스트)에서 Docker 이미지 빌드, 태그 = git SHA
- registry로 push
- 모든 서버에서
docker pull병렬 실행 - 서버별로 새 컨테이너 기동,
/uphealth check 대기 - kamal-proxy가 healthy한 새 컨테이너로 트래픽 스위칭
- 구 컨테이너 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
실전 운영 팁
- staging을 먼저 동일 설정으로 운영해 배포 이슈를 프로덕션 전에 잡습니다.
deploy_timeout은 애플리케이션 최대 요청 시간 + 여유로 설정 (기본 30초는 짧을 수 있음).- 배포 알림을 Slack 웹훅으로 —
post-deployhook 활용. - 이미지 태그에 git SHA를 사용해 어떤 커밋이 돌아가는지 즉시 확인 가능하게.
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 완벽 가이드