저도 처음에는 코드 수정할 때마다 서버에 직접 접속해서 git pull 하고 빌드하고 컨테이너 재시작하는 걸 당연하게 여겼습니다. 그런데 이번에 GitHub Actions와 Docker를 연동해서 자동 배포 파이프라인을 직접 구성해보고 나서, 그 생각이 완전히 바뀌었습니다. 한 번 세팅해두면 push 한 번으로 배포까지 끝난다는 게 이렇게 편한 일인지 몰랐습니다.

도커 데몬을 원격으로 여는 것, 생각보다 간단하지 않았다
일반적으로 Docker는 로컬에서만 쓰는 도구라고 생각하는 분들도 있는데, 저는 이번에 dockerd를 원격에서도 제어할 수 있다는 걸 실습하면서 그게 얼마나 강력한 기능인지 직접 느꼈습니다.
dockerd란 Docker Daemon의 줄임말로, 컨테이너 생성, 이미지 관리, 네트워크 설정 등 도커의 모든 핵심 기능을 처리하는 백그라운드 프로세스입니다. 평소에 docker run이나 docker ps 같은 명령어를 칠 때 실제로 일을 처리하는 주체가 바로 이 데몬입니다.
기본 설정에서 dockerd는 소켓 파일을 통해 명령을 받습니다. 여기서 소켓 파일이란 IP 주소 없이 같은 서버 안의 프로세스끼리 통신하는 데 쓰이는 특수한 파일로, 경로는 보통 /var/run/docker.sock입니다. 이 파일의 권한이 잘못 설정되어 있으면 docker 명령어 자체가 실행되지 않는 권한 오류가 발생하는데, sudo usermod -aG docker $USER 명령으로 현재 사용자를 docker 그룹에 추가하면 해결됩니다.
원격 접속을 허용하려면 docker.service 파일을 수정해서 ExecStart 항목에 -H tcp://0.0.0.0:2375 옵션을 추가해야 합니다. 이 옵션은 TCP 네트워크 통신을 통해서도 명령을 받겠다는 의미입니다. 설정 후 systemctl daemon-reload와 systemctl restart docker를 순서대로 실행하면 적용됩니다. 저는 이 상태에서 Postman으로 http://서버IP:2375/containers/json에 GET 요청을 보내서 컨테이너 목록이 JSON으로 돌아오는 걸 직접 확인했는데, 도커가 단순한 CLI 도구가 아니라 RESTful API 서버라는 사실을 그때 처음 실감했습니다.
한 가지 주의할 점은, 2375 포트는 TLS 암호화 없이 열리는 포트입니다. 실무 환경에서는 반드시 2376 포트와 TLS 인증서를 함께 사용해야 하며, 2375를 외부에 그냥 열어두는 건 보안상 위험합니다(출처: Docker 공식 문서).
GitHub Actions 워크플로우, 처음 버전과 개선 버전의 차이
솔직히 처음 짠 워크플로우 yaml 파일은 좀 부끄러운 수준이었습니다. 배포 과정을 전부 EC2 서버 안에서 처리하도록 짰는데, 이게 실제로 돌아가긴 하지만 문제가 꽤 있었습니다.
처음 방식은 GitHub Actions가 SSH로 서버에 접속한 뒤, 서버 안에서 git clone으로 코드를 가져오고 gradlew bootJar로 빌드하고 docker build까지 전부 실행하는 구조였습니다. 이 방식의 문제는 빌드에 필요한 Java와 Gradle이 배포 서버에도 설치되어 있어야 한다는 점입니다. 배포 서버는 애플리케이션을 실행하는 데만 집중해야 하는데, 빌드 환경까지 구축해두면 서버가 무거워지고 유지보수도 복잡해집니다.
개선된 워크플로우는 역할을 두 단계로 나눴습니다.
- build 단계: GitHub Actions Runner(가상 서버) 위에서 코드 체크아웃, JDK 17 설치, gradlew bootJar 빌드, 도커 이미지 생성, Docker Hub에 push까지 처리합니다.
- deploy 단계: SSH로 실제 배포 서버에 접속해서 Docker Hub에서 최신 이미지를 pull 하고 기존 컨테이너를 교체하는 역할만 합니다.
여기서 GitHub Actions Runner란 GitHub이 제공하는 가상 머신으로, runs-on: ubuntu-latest로 지정한 환경에서 워크플로우의 각 단계를 실행합니다. 즉 무거운 빌드 작업은 GitHub 서버가 대신 처리해주고, 내 서버는 완성된 이미지를 받아서 실행만 하면 됩니다.
Docker Hub에 이미지를 push하려면 로그인이 필요한데, yaml 파일에 비밀번호를 직접 쓰는 건 당연히 안 됩니다. 이때 사용하는 게 Personal Access Token(PAT)입니다. PAT란 실제 계정 비밀번호 대신 특정 권한만 부여된 별도의 인증 토큰으로, Docker Hub의 Account Settings → Security → Personal Access Tokens 메뉴에서 발급받을 수 있습니다. 이 토큰을 GitHub 레포지토리의 Secrets에 DOCKERHUB_TOKEN이라는 이름으로 등록해두면, yaml 파일 안에서 ${{ secrets.DOCKERHUB_TOKEN }} 형태로 안전하게 불러올 수 있습니다. 실제 비밀 값은 로그에도 노출되지 않습니다.
needs: build 옵션도 제가 처음 쓸 때 놓쳤던 부분입니다. 이 옵션이 없으면 build와 deploy가 동시에 실행되어서, 이미지가 Docker Hub에 올라가기도 전에 서버에서 pull을 시도하는 타이밍 문제가 생길 수 있습니다.
무중단 배포 전략, 실제로 어떤 방식을 선택해야 할까
배포 자동화를 구성하다 보면 결국 맞닥뜨리는 문제가 있습니다. 새 버전을 올릴 때 서비스가 잠깐이라도 끊기면 안 되는 상황입니다. 무중단 배포(Zero-downtime Deployment)란 서비스를 중단하지 않은 채로 새 버전으로 교체하는 배포 방식을 말합니다.
대표적인 전략은 세 가지로 나뉩니다.
- 블루-그린 배포(Blue-Green Deployment): 구버전 환경(블루)과 동일한 신버전 환경(그린)을 미리 준비해두고, 로드밸런서의 트래픽을 한 번에 전환하는 방식입니다. 롤백이 쉽다는 장점이 있지만 서버 자원이 두 배로 필요합니다.
- 롤링 배포(Rolling Deployment): 서버를 한 대씩 순차적으로 교체하는 방식으로 자원 효율이 좋지만, 구버전과 신버전이 동시에 서비스되는 구간이 생겨 API 호환성 문제가 발생할 수 있습니다.
- 카나리 배포(Canary Deployment): 전체 트래픽의 일부만 신버전으로 먼저 보내서 문제를 관찰한 뒤 점진적으로 전환하는 방식입니다. 위험을 최소화할 수 있지만 모니터링 체계가 갖춰져 있어야 합니다.
제가 이번 실습에서 구성한 방식은 단일 서버 환경이라 docker stop → docker rm → docker run 순서로 교체하는 단순 재시작에 가까웠습니다. 짧은 다운타임이 발생하지만, 이 과정에서 || true나 2>/dev/null || true 같은 옵션을 붙여두면 이전 컨테이너가 없는 상황에서도 에러 없이 다음 단계로 넘어갈 수 있다는 걸 직접 확인했습니다. 실무 수준의 무중단 배포를 위해서는 Nginx를 리버스 프록시(Reverse Proxy)로 앞단에 두고, nginx -s reload로 설정을 재적용하는 방식이 많이 쓰입니다. 리버스 프록시란 클라이언트 요청을 받아서 내부 서버로 전달하는 중간 서버로, 포트 80에서 요청을 받아 Spring Boot가 실행 중인 8080 포트로 넘겨주는 역할을 합니다. Nginx를 중간에 두면 컨테이너를 교체하는 동안에도 트래픽을 유연하게 제어할 수 있다는 점에서 확장 가능성이 훨씬 높아집니다(출처: Nginx 공식 문서).
CI/CD 파이프라인을 처음부터 완벽하게 만들려고 할 필요는 없다는 게 이번 실습에서 얻은 가장 큰 교훈입니다. 처음엔 단순히 push하면 서버에 파일이 생기는 것만 확인했고, 그다음엔 서버에서 직접 빌드하는 방식으로 넘어갔다가, 지금은 GitHub Runner에서 빌드하고 Docker Hub를 중간 저장소로 쓰는 구조까지 왔습니다. 각 단계의 불편함을 직접 겪으면서 개선 이유가 몸에 밟혔기 때문에, 다음에 더 나은 구조로 나아가는 것도 어렵지 않을 것 같습니다.
참고: https://dltldnr2563.tistory.com/entry/%EC%BD%94%EB%94%A9%EA%B3%B5%EB%B6%8020250910-Dockerd-CICD-github-actions-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0
https://docs.docker.com/engine/security/protect-access/
https://nginx.org/en/docs/http/ngx_http_proxy_module.html