Kamal 2로 Kubernetes 없이 무중단 배포하기: deploy.yml, kamal-proxy, accessories, 롤백
이 글의 핵심
Kamal 2는 Docker 이미지를 단일 커맨드로 VPS에 배포하는 Rails 팀의 도구입니다. Kubernetes의 복잡도·PaaS의 비용·Capistrano의 한계를 동시에 해결하며, Rails 8의 공식 배포 도구로 채택되었습니다. 이 글은 설치·kamal-proxy·accessories·SSL·멀티 호스트·롤백·보안을 실전 중심으로 다룹니다.
이 글의 핵심
Kamal 2는 37signals(Basecamp)가 만든 Docker 기반 배포 도구로, Heroku 같은 PaaS 편의성을 자체 VPS에서 누리게 해주는 오픈소스입니다. 2024년 9월 2.0이 나왔고, 같은 해 11월 출시된 Rails 8부터 새 앱의 기본 배포 도구로 포함되었습니다.
핵심 가치:
kamal deploy한 줄로 무중단 배포- Kubernetes 없이 멀티 호스트 지원
- 내장 리버스 프록시(kamal-proxy)로 HTTPS + 롤링 업데이트
- MySQL·Redis·Postgres 같은 accessories를 함께 관리
- Rails/Node/Python/Go 등 Docker 가능한 모든 스택 대응
이 글은 설치부터 프로덕션 운영 패턴까지 실제 배포해본 경험을 바탕으로 정리합니다.
Kamal이 하는 일을 한 문장으로 줄이면 “SSH로 서버에 접속해 docker run을 대신 실행해 주는 도구”입니다. 서버에 에이전트를 설치하지 않고, 클러스터 상태를 저장하는 컨트롤 플레인도 없습니다. 이 단순함이 장점이자 한계입니다. 서버가 죽으면 Kubernetes처럼 다른 노드에서 컨테이너를 다시 띄워 주지 않고, 트래픽에 따라 컨테이너 수를 자동으로 늘리지도 않습니다. 대신 문제가 생겼을 때 서버에 SSH로 들어가 docker ps와 docker logs로 바로 확인할 수 있고, 배워야 할 추상화가 거의 없습니다. 이 트레이드오프를 받아들일 수 있는 규모인지가 도입 판단의 핵심입니다.
왜 Kamal인가: 중간 지대의 도구
| 항목 | Heroku/Render | Kamal | Kubernetes |
|---|---|---|---|
| 초기 학습 비용 | 낮음 | 낮음 | 높음 |
| 월 비용 | 플랜·dyno 단위 과금 | VPS 비용만 | 컨트롤 플레인 + 노드 + 운영 인력 |
| 벤더 락인 | 강함 | 없음 | 중립 |
| 무중단 배포 | 자동 | 자동 | 수동 설정 필요 |
| 다중 서비스 | 제한적 | accessories로 관리 | 네이티브 지원 |
| 스케일 방식 | 플랜에 종속 | 서버를 deploy.yml에 직접 추가 | 오토스케일링 |
| 장애 복구 | 플랫폼이 처리 | 수동 (서버 재기동·재배포) | 자동 재스케줄링 |
“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, .kamal/secrets, .kamal/hooks/ 샘플 생성
Kamal 1을 쓰던 글에서는 .env 파일과 kamal env push가 등장하는데, Kamal 2에서는 이 방식이 .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: 자주 쓰는 명령을 짧게 정의
env의 clear와 secret 구분도 중요합니다. clear에 적은 값은 deploy.yml에 평문으로 남아 저장소에 커밋되므로 RAILS_ENV처럼 비밀이 아닌 값만 두고, 비밀번호나 API 키는 secret에 이름만 적어 실제 값은 .kamal/secrets에서 가져오게 합니다. secret에 이름을 적었는데 .kamal/secrets에 그 변수가 없으면 배포 전에 시크릿을 찾지 못했다는 오류로 멈추므로, 새 시크릿을 추가할 때는 두 파일을 함께 고쳐야 합니다.
시크릿 관리
# .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는 dotenv 형식으로 읽히며, $(...) 명령 치환이 실행되어 그 결과가 값으로 들어갑니다. 그래서 1Password CLI(op), Bitwarden CLI, AWS CLI, sops 등 원하는 시크릿 저장소와 연동할 수 있습니다. Kamal 2는 kamal secrets fetch/extract 헬퍼로 1Password, Bitwarden, LastPass, AWS Secrets Manager 등을 한 번의 호출로 읽는 방식도 제공합니다. 위 예처럼 op read를 변수마다 호출하면 배포할 때마다 여러 번 인증 창이 뜰 수 있어서, 시크릿이 많다면 헬퍼로 한 번에 가져오는 편이 편합니다. 실제 값이 들어 있는 .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-proxy 기동 + accessories 부팅 + 첫 배포
kamal setup
# 이미지 빌드·푸시·서버 pull·새 컨테이너 기동·프록시 스위칭
kamal deploy
# 로그 tail
kamal app logs -f
# Rails 콘솔 (앞서 aliases 정의)
kamal console
kamal setup은 호스트에 Docker·kamal-proxy를 설치하고 registry 로그인까지 자동으로 수행합니다. SSH 접근만 되어 있으면 됩니다. 방화벽이나 SSH 보안 설정은 해 주지 않으므로 서버 준비 단계에서 따로 해야 합니다.
첫 배포에서 가장 흔히 막히는 곳은 SSH와 health check입니다. 기본값으로 root 사용자로 접속하므로, root 로그인을 막아 둔 서버라면 ssh: user: 설정과 그 사용자의 Docker 권한이 필요합니다. 그다음은 새 컨테이너가 deploy_timeout 안에 health check를 통과하지 못해 target failed to become healthy 같은 메시지와 함께 배포가 중단되는 경우입니다. 앱이 /up에 200을 돌려주는지, 컨테이너 안에서 듣는 포트가 proxy의 app_port(기본 80)와 같은지, 앱이 127.0.0.1이 아니라 0.0.0.0에 바인딩되어 있는지를 순서대로 확인하면 대부분 해결됩니다. 이때 kamal app logs로 새 컨테이너의 부팅 로그를 보면 마이그레이션 실패나 시크릿 누락 같은 원인이 바로 드러납니다.
무중단 배포가 일어나는 순서
- 로컬(또는 builder 호스트)에서 Docker 이미지 빌드, 태그 = git SHA
- registry로 push
- 모든 서버에서
docker pull병렬 실행 - 서버별로 새 컨테이너 기동,
/uphealth check 대기 - kamal-proxy가 healthy한 새 컨테이너로 트래픽 스위칭
- 구 컨테이너로 가던 요청을 drain(
drain_timeout, 기본 30초)한 뒤 정지
요청 처리 중이던 연결은 drain되며, 애플리케이션이 SIGTERM을 받았을 때 진행 중인 요청을 마무리하도록 되어 있다면 사용자 측엔 끊김이 없습니다.
여기서 두 개의 타임아웃을 구분해야 합니다. deploy_timeout은 새 컨테이너가 healthy해질 때까지 기다리는 시간이고, 이 안에 health check를 통과하지 못하면 배포가 실패로 끝나며 트래픽은 기존 컨테이너에 그대로 남습니다. 부팅이 느린 앱(에셋 컴파일, 큰 캐시 워밍업)이라면 이 값을 늘려야 합니다. drain_timeout은 구 컨테이너가 남은 요청을 끝낼 시간이라서, 파일 업로드나 긴 보고서 생성처럼 오래 걸리는 요청이 있다면 그 최대 시간보다 길게 잡습니다. 이 순서 덕분에 새 버전이 뜨지 않는 배포는 서비스에 영향을 주지 않는다는 점이 Kamal 무중단 배포의 실제 보장입니다. 반대로 health check는 통과하지만 특정 요청에서만 실패하는 버전은 그대로 트래픽을 받게 되므로, 아래 트러블슈팅의 “배포는 성공하는데 500 에러” 항목처럼 health check를 얼마나 깊게 만들지가 중요합니다.
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로 끌 수 있습니다.
ssl: true의 자동 인증서 발급은 web 역할 서버가 한 대일 때 쓰는 기능이라는 점에 주의해야 합니다. 서버마다 따로 Let’s Encrypt 인증서를 받으면 인증 요청이 어느 서버로 갈지 보장할 수 없기 때문입니다. web 서버를 여러 대로 늘리는 시점에는 앞단에 로드 밸런서나 Cloudflare를 두고 TLS를 거기서 종료하거나, 직접 준비한 인증서를 쓰는 구성으로 바꿔야 합니다. Cloudflare 프록시를 켠 도메인이라면 Cloudflare의 SSL 모드를 “Full”로 두어야 리다이렉트 루프 없이 동작합니다.
멀티 호스트 + 역할 분리
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 app containers
# 특정 버전(이미지 태그)으로 롤백
kamal rollback abc1234
롤백은 이미지만 이전 것으로 교체합니다. DB 마이그레이션·외부 API 상태·캐시는 별도로 고려해야 합니다. 롤백할 수 있는 버전은 서버에 아직 남아 있는 이전 컨테이너들이며(retain_containers, 기본 5개), 오래된 버전으로 돌아가야 한다면 kamal deploy --version=태그로 해당 이미지를 다시 배포하는 방식이 됩니다.
DB 마이그레이션의 backward-compatible 전략
롤백이 안전하려면 매 배포의 마이그레이션이 이전 코드와 호환되어야 합니다.
- 컬럼 삭제: “더 이상 안 쓰기” 코드 배포 → 다음 배포에서 컬럼 drop
- 컬럼 변경: 새 컬럼 추가 → 병행 쓰기 → 읽기 전환 → 구 컬럼 제거
- Enum 값: 값 추가만 배포 → 쓰기 코드 배포
- 인덱스:
CONCURRENTLY옵션으로 잠금 없이 생성
마이그레이션 두 단계(add/remove) 규칙만 지켜도 롤백 범위가 극적으로 안전해집니다.
보안 체크리스트
- SSH는 키 인증만 (비밀번호 로그인 비활성화)
-
.kamal/secrets는.gitignore - 방화벽: kamal-proxy의 80/443 외 포트는 외부 차단
- accessories의 DB 포트는 VPC 내부망에서만 접근
- 시크릿 변경 후에는 재배포로 컨테이너에 반영 (Kamal 2는 배포 시 환경 변수를 함께 전달)
- 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 커넥션까지 검증하는지 확인. 다만 health check를 너무 깊게 만들면 DB가 잠깐 느려질 때 모든 컨테이너가 unhealthy로 판정되는 반대 문제가 생기므로, “이 컨테이너가 요청을 처리할 준비가 됐는가”까지만 확인하는 선이 적당합니다. kamal app logs로 실제 요청의 스택 트레이스를 먼저 보는 것이 가장 빠릅니다.
SSH 연결 지연으로 배포가 느림
~/.ssh/config에 ControlMaster 설정:
Host *.pkglog.com
ControlMaster auto
ControlPath ~/.ssh/sockets/%r@%h:%p
ControlPersist 10m
실전 운영 팁
- staging을 먼저 동일 설정으로 운영해 배포 이슈를 프로덕션 전에 잡습니다.
deploy_timeout은 부팅 시간 + 여유,drain_timeout은 최대 요청 시간 + 여유로 설정 (둘 다 기본 30초).- 배포 알림을 Slack 웹훅으로 —
post-deployhook 활용. - 이미지 태그에 git SHA를 사용해 어떤 커밋이 돌아가는지 즉시 확인 가능하게.
kamal config로 현재 설정을 출력해 리뷰어에게 공유하면 infra PR 리뷰가 쉬워집니다.
마무리
Kamal 2는 “K8s는 과하고 PaaS는 비싸다” 는 구간을 정확히 공략합니다. 저렴한 VPS 몇 대로 Heroku와 비슷한 배포 흐름을 얻을 수 있고, 37signals가 클라우드에서 자체 서버로 옮기면서 자사 서비스 배포에 쓰는 도구이기도 합니다. Docker로 빌드 가능한 모든 스택에 적용 가능하니, 개인 프로젝트·스타트업·내부 도구 배포를 단순화하고 싶다면 오늘 저녁 VPS 하나 구해서 1시간 안에 첫 배포를 경험해보세요.
자주 묻는 질문 (FAQ)
Q. Kamal 배포는 성공했는데 실제 요청에서 500 에러가 나는 이유는 무엇인가요?
A. 헬스 체크 엔드포인트가 단순히 OK만 반환하면, 앱이 DB에 연결하지 못하는 상태여도 kamal-proxy는 새 컨테이너를 정상으로 보고 트래픽을 넘깁니다. 헬스 체크가 DB 커넥션처럼 요청 처리에 꼭 필요한 의존성까지 확인하도록 만들어야 배포 단계에서 문제가 걸러집니다. 환경 변수나 시크릿 누락도 같은 증상을 내므로, staging을 프로덕션과 같은 설정으로 먼저 배포해 보는 것이 좋습니다.