블로그 회사 시스템 구축기

GitHub Actions와 Watchtower로 집 서버에 접속하지 않고 배포하는 방법

코드를 main에 올리면 GitHub Actions가 도커 이미지를 ghcr.io에 올리고, 집 서버의 Watchtower가 5분마다 확인해 컨테이너를 바꿔 끼우는 구성입니다. 워크플로·compose 설정, 자동으로 바꿀 컨테이너만 고르는 라벨, 실제 로그와 되돌리는 방법을 적었습니다.

집에 있는 2018년 맥미니(램 24GB) 한 대에서 도커 컨테이너 18개를 돌리고 있습니다. 서버 전체로 봐도 메모리는 24GB 중 4GB 정도만 씁니다. 이 중 웹 3개와 백엔드 1개는 코드를 main에 올리면 서버에 접속하지 않아도 새 버전으로 바뀝니다. 서버에 ssh로 들어가는 건 배포할 때가 아니라 뭔가 깨졌을 때뿐입니다.

구성은 두 조각입니다. GitHub Actions가 도커 이미지를 만들어 GitHub 컨테이너 레지스트리(ghcr.io)에 올리고, 서버에서 도는 Watchtower가 5분마다 새 이미지가 있는지 확인해서 있으면 받아 컨테이너를 바꿔 끼웁니다. 아래에 그 설정과 실제로 바뀌는 데 걸린 시간, 되돌리는 방법을 적었습니다.

GitHub에 push하면 Actions가 이미지를 만들어 ghcr.io에 올리고, 집 맥미니의 Watchtower가 5분마다 ghcr.io를 확인해 라벨이 붙은 컨테이너만 새 이미지로 바꾸는 흐름. 서버로 들어오는 연결은 없다

밀어 넣는 배포와 가져가는 배포

GitHub Actions로 배포하는 흔한 방법은 워크플로 마지막에 서버로 ssh 접속해서 docker compose pull && docker compose up -d를 실행하는 것입니다. 이렇게 하려면 GitHub에 서버의 ssh 키를 넣어 두고, 밖에서 서버의 ssh 포트에 닿을 수 있어야 합니다.

Watchtower를 쓰면 GitHub은 이미지를 레지스트리에 올리는 데서 끝나고, 서버가 레지스트리에서 이미지를 가져갑니다.

ssh로 밀어 넣기Watchtower로 가져가기
GitHub에 두는 것서버 ssh 키없음
서버로 들어오는 연결ssh 포트 필요없음
반영까지워크플로 끝나면 바로최대 확인 주기(5분)만큼 늦음
배포 순서 제어스크립트로 자유롭게컨테이너 단위 교체만

집 서버는 공유기 포트를 하나도 열지 않고 운영하고 있어서 Watchtower로 가져가는 방식을 골랐습니다. 외부 공개는 Cloudflare Tunnel로 포트포워딩 없이 집 서버를 외부에 여는 방법으로 하고 있습니다.

1. GitHub Actions에서 이미지 만들어 ghcr.io에 올리기

웹 저장소의 워크플로는 main에 push가 들어오면 린트·테스트·빌드를 돌리고, 전부 통과하면 이미지를 올립니다. 테스트가 실패하면 이미지가 안 올라가므로 서버도 바뀌지 않습니다.

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # ... 설치·린트·테스트·빌드 ...

      - name: Set image tags
        run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> "$GITHUB_ENV"

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

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

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/내-조직/내-앱:latest
            ghcr.io/내-조직/내-앱:sha-${{ env.SHORT_SHA }}

태그는 두 개를 붙였습니다. 서버는 latest를 따라가고, sha- 태그는 문제가 생겼을 때 이전 버전으로 되돌리는 데 씁니다.

백엔드 저장소는 push마다 돌리지 않고, 수동 실행과 매일 새벽 4시 반(KST) 한 번으로 두었습니다. GitHub Actions 무료 시간을 아끼려는 설정이라 백엔드 변경은 다음 날 아침에 반영됩니다. 무료 시간을 다 쓴 뒤 러너를 집 컴퓨터로 옮긴 이야기는 깃허브 액션 무료 분을 다 쓰면 집 컴퓨터를 러너로 붙인다에 적었습니다.

2. 서버에서 ghcr.io 로그인

비공개 저장소의 이미지는 서버도 로그인해야 받을 수 있습니다. GitHub에서 read:packages 권한만 있는 토큰을 만들어 서버에서 한 번 로그인합니다.

echo "$GHCR_TOKEN" | docker login ghcr.io -u 내-깃허브-아이디 --password-stdin

로그인 정보는 ~/.docker/config.json에 저장됩니다. 맥에서 Docker 설정이 키체인에 인증을 저장하도록(credsStore) 되어 있으면 이 파일에 인증이 안 남아서 Watchtower가 읽지 못합니다. 파일의 auths 아래 ghcr.io 항목에 auth 값이 있는지 확인합니다.

3. Watchtower 컨테이너 띄우기

Watchtower도 같은 compose 파일에 컨테이너 하나로 넣습니다.

services:
  watchtower:
    image: containrrr/watchtower:latest
    restart: unless-stopped
    command: --interval 300 --cleanup --label-enable
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ${HOME}/.docker/config.json:/config/config.json:ro
    environment:
      DOCKER_CONFIG: /config
      DOCKER_API_VERSION: "1.41"

command에 넣은 옵션 세 개는 이렇습니다.

  • --interval 300 · 300초(5분)마다 레지스트리에 새 이미지가 있는지 확인합니다.
  • --cleanup · 바꿔 끼운 뒤 옛 이미지를 지웁니다. 안 켜면 배포할 때마다 디스크에 옛 이미지가 쌓입니다.
  • --label-enable · 라벨을 붙인 컨테이너만 바꿉니다. 다음 절에서 설명합니다.

DOCKER_API_VERSION은 맥에서 OrbStack을 쓸 때 넣었습니다. OrbStack의 도커 데몬이 Watchtower 기본 API 버전을 거부해서 버전을 맞춰 줘야 합니다. Docker Desktop이나 리눅스 서버라면 없어도 됩니다.

Watchtower에 넘기는 docker.sock은 서버의 도커 전체를 다룰 수 있는 통로입니다. 이걸 받은 컨테이너는 다른 컨테이너를 멈추거나 새로 띄울 수 있으므로, Watchtower는 공식 이미지(containrrr/watchtower)만 쓰고 다른 컨테이너에는 소켓을 넘기지 않습니다.

4. 자동으로 바꿀 컨테이너에만 라벨 붙이기

--label-enable 없이 띄우면 Watchtower는 서버의 컨테이너 전부를 확인합니다. 데이터베이스나 외부 터널처럼 버전이 저절로 바뀌면 곤란한 것까지 들어가므로 자동 배포할 컨테이너에만 라벨을 붙였습니다.

  web:
    image: ghcr.io/내-조직/내-앱:latest
    labels:
      com.centurylinklabs.watchtower.enable: "true"

지금 서버의 컨테이너 18개 중 이 라벨이 붙은 건 4개(백엔드 1, 웹 3)입니다. 데이터베이스·터널·모니터링 같은 나머지 14개는 자동으로 바뀌지 않습니다. Watchtower 로그의 Scanned=4가 이 4개를 확인했다는 뜻입니다.

5. 실제로 바뀌는 데 걸린 시간

일요일 아침 백엔드가 새 이미지로 바뀔 때의 Watchtower 로그입니다(시각은 UTC).

22:07:52 Found new ghcr.io/내-조직/내-앱:latest image
22:07:53 Stopping /내-앱 with SIGTERM
22:07:57 Session done Failed=0 Scanned=4 Updated=1

새 이미지를 찾고 컨테이너를 바꿔 끼워 끝나기까지 5초였습니다. 그 사이에는 해당 서비스가 잠깐 응답하지 않습니다. 끊기면 안 되는 서비스라면 이 방식보다 컨테이너를 두 개 띄워 번갈아 바꾸는 구성이 필요합니다.

새 이미지가 없을 때는 5분마다 Updated=0만 찍히고 아무것도 바뀌지 않습니다. 백엔드처럼 매일 새벽에 빌드하는 경우에도 코드가 안 바뀌었으면 같은 이미지가 올라가므로 재시작되지 않습니다.

6. 문제가 생기면 이전 버전으로 되돌리기

latest로 새 버전이 나갔는데 문제가 있으면, compose 파일에서 그 서비스의 태그를 직전 sha- 태그로 고정하고 다시 띄웁니다.

  web:
    image: ghcr.io/내-조직/내-앱:sha-1a2b3c4   # 직전 정상 버전
docker compose up -d web

태그를 고정해 두면 Watchtower가 확인해도 그 태그의 이미지는 바뀌지 않으므로 자동 배포가 멈춥니다. 코드를 고쳐 main에 다시 올린 뒤 태그를 latest로 돌려놓으면 다시 자동으로 따라갑니다.

이 방식이 안 맞는 경우

  • 배포 순서가 있는 경우 · 데이터베이스 마이그레이션을 먼저 하고 앱을 바꿔야 한다면, Watchtower는 컨테이너를 하나씩 바꿀 뿐이라 순서를 보장하지 않습니다.
  • 바로 반영돼야 하는 경우 · 최대 5분 늦습니다. 주기를 줄일 수는 있지만 레지스트리 확인 횟수가 그만큼 늘어납니다.
  • 테스트 없이 이미지를 올리는 경우 · latest에 올라간 이미지는 그대로 서버에 나갑니다. 그래서 워크플로에서 테스트가 통과해야 이미지가 올라가도록 순서를 두었습니다.
#GitHub Actions#Watchtower#Docker#ghcr.io#자가호스팅#CI/CD