Tailscale을 활용한 비공개 서버 배포 자동화
들어가며
CI에서 서버로 배포하려면 어떤 식으로든 CI가 그 서버에 접근할 수 있어야 한다. 가장 흔한 방법은 서버의 SSH 포트를 열어두고 CI에 키를 넣어두는 것이다.
그런데 이 방식에는 두 가지 위험 요소가 있다. 바로 인터넷에 노출된 SSH 포트와 어딘가에 저장된 자격증명이다. 둘 다 당장 문제를 일으키지는 않지만, 그만큼 공격 표면이 계속 열려 있는 상태가 된다.
운영 중인 프로젝트에서는 개발 서버를 아예 공개하지 않기로 했다. 따라서 배포 경로도 다르게 만들어야 했고, 그 과정을 정리해보고자 한다.
서버를 인터넷에 두지 않기
먼저 운영 환경의 앱만 공개하고, 나머지는 전부 사설 네트워크 뒤에 두는 것을 원칙으로 정했다.
사설 네트워크는 Tailscale로 구성했다. 각 서버가 tailnet에 소속되어 있고, 여기에 참여한 기기끼리만 서로 접근할 수 있다. 그 결과 다음과 같이 나눌 수 있었다.
| 공개 | tailnet 전용 | |
|---|---|---|
| 운영 | 앱(웹, API) | SSH, 모니터링 UI |
| 개발 서버 | 없음 | 전부 |
개발 서버에는 공개할 것이 하나도 없으므로 리버스 프록시도 두지 않았다. 서비스는 호스트의 loopback에만 바인딩하고, Tailscale의 serve 기능을 통해 tailnet 안에서만 접근할 수 있도록 노출했다.
이 방식은 이상적으로 보이지만, 문제가 하나 있다. 바로 CI이다. CI는 GitHub Actions 러너 위에서 돌아가는데, 러너는 GitHub이 띄우는 일회성 가상 머신이기 때문에 당연히 tailnet 바깥에 있다.
러너를 tailnet에 참여시키기
따라서 서버를 밖으로 꺼내는 것이 아니라 러너를 안으로 들여보내기로 했다. 이를 위해 Tailscale이 제공하는 GitHub Actions용 액션을 활용했다.
- name: Connect to tailnet (ephemeral tag:ci)
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_CI_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_CI_OAUTH_SECRET }}
tags: tag:ci
이 단계를 지나면 러너가 tailnet의 노드가 된다. 그다음부터는 tailnet 안의 이름으로 서버에 바로 접근할 수 있다.
이렇게 되면 접근하기 위한 IP도, 공개 도메인도 필요가 없어진다. 가장 중요한 점은 이 노드가 Job이 끝나면 사라진다는 것이다. 따라서 배포가 끝난 뒤에도 남아 있는 접근 주체가 없다.
위 방식으로도 충분히 접근 제어가 가능하지만 ACL을 통해 접근 범위를 한 번 더 제한했다. 러너가 tailnet에 참여할 때 tag:ci 태그를 붙이고, 이 태그에는 서버의 22번 포트에 접근할 수 있는 권한만 부여했다.
{
"src": ["tag:ci"],
"dst": ["tag:staging:22", "tag:prod:22"]
}
호스트에 남아 있던 자격증명
여기까지 진행하면 SSH 접근 문제는 해결된다. 하지만 실제로 배포 과정에서 하는 일은 레지스트리에서 이미지를 받아오는 것이고, 이를 위해서는 호스트가 레지스트리에 인증되어 있어야 한다.
처음에는 호스트의 .env에 개인 액세스 토큰을 넣어두고 로그인해두는 방식을 사용했다. 동작에는 문제가 없었지만, 토큰이 개인 계정에 묶여 있어 계정을 바꾸거나 토큰을 회수하면 배포가 깨진다는 문제가 있었다. 또한 배포는 하루에 몇 번 이루어지는 반면, 자격증명은 항상 호스트에 남아 있었다.
따라서 배포할 때만 로그인하고, 배포가 끝나면 로그아웃하는 방식으로 변경했다. 이때 사용하는 토큰은 GitHub Actions가 Job마다 발급하는 GITHUB_TOKEN으로, Job이 끝나면 자동으로 만료된다. 구현한 코드는 다음과 같다.
- name: Deploy to staging over tailnet
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
printf '%s' "$GHCR_TOKEN" \
| ssh $SSHOPTS "$HOST" 'sudo docker login ghcr.io -u x-access-token --password-stdin'
ssh $SSHOPTS "$HOST" 'bash -s -- staging' < infra/deploy.sh
- name: Revoke host ghcr credential
if: always()
run: |
ssh $SSHOPTS "$HOST" 'sudo docker logout ghcr.io' || true
여기서 로그아웃 단계에 if: always()를 지정했는데, 이를 통해 배포가 실패하더라도 로그아웃은 항상 실행되도록 했다. 배포가 중간에 실패한 경우에도 호스트에 자격증명이 남지 않도록 하기 위함이다.
또한 배포 스크립트의 경우 호스트에 파일로 복사하지 않고 stdin으로 전달했다. bash -s는 표준 입력으로 받은 스크립트를 실행하기 때문에, 이 방식을 사용하면 호스트에 스크립트 파일이 남지 않는다.
런타임 이미지 pull 제거하기
자격증명을 배포 시점에만 두도록 바꾸면서 예상하지 못한 문제가 하나 생겼다.
이 시스템에는 백엔드가 필요할 때마다 띄우는 실행용 컨테이너가 있다. 해당 컨테이너의 이미지도 같은 레지스트리에 올라가 있는데, 백엔드는 그동안 필요한 시점에 직접 이미지를 pull하고 있었다. 호스트가 항상 로그인되어 있었기 때문에 가능했던 방식이다.
하지만 이제는 백엔드가 컨테이너를 띄우려는 시점에 호스트가 이미 로그아웃된 상태이기 때문에 이 방식을 더 이상 사용할 수 없다. 따라서 백엔드에서는 이미지를 pull하지 않고 로컬에 이미지가 있는지만 확인하도록 수정하고, 대신 배포 스크립트에서 배포 시점에 해당 이미지를 미리 받아두도록 했다.
if [ "$ENV" = "staging" ]; then RUNNER_TAG=staging-latest; else RUNNER_TAG=latest; fi
sudo docker pull "ghcr.io/.../runner:${RUNNER_TAG}"
해당 이미지의 경우 compose로 관리하는 서비스가 아니기 때문에 compose pull로는 받아지지 않는다. 그렇기에 위와 같이 별도로 pull하는 코드를 추가했다.
물론 이 방식에도 단점은 있다. 백엔드가 더 이상 이미지를 직접 받아오지 않기 때문에, 호스트에 이미지가 없으면 컨테이너를 띄우지 못하고 바로 실패한다. 이미지를 호스트에 받아두는 책임이 백엔드에서 배포 쪽으로 넘어온 셈이다. 다만 실행용 컨테이너의 이미지만 따로 배포하는 경우에도 해당 이미지의 배포 워크플로우에서 같은 방식으로 로그인, pull, 로그아웃을 거치면 되기 때문에 큰 문제는 아니라고 보았다.
오히려 이 방식으로 얻는 이점도 있다. 먼저 이미지를 받아오지 못하는 문제가 사용자가 실행을 요청한 시점이 아니라 배포 시점에 드러난다. 배포 단계에서 pull이 실패하면 배포 자체가 실패로 처리되기 때문에, 이미지가 없는 상태로 서비스가 운영되는 일을 미리 막을 수 있다. 그리고 무엇보다 처음 목표였던 호스트에 자격증명을 상시로 두지 않는 것을 지킬 수 있었기 때문에, 이 정도의 제약은 감수할 만하다고 판단했다.
헬스체크로 배포 완료 확인하기
컨테이너를 재시작했다고 해서 배포가 끝난 것은 아니다. 백엔드의 경우 부팅 과정에서 마이그레이션을 적용하기 때문에 응답하기까지 30초 정도가 걸린다. 따라서 백엔드가 정상적으로 응답할 때까지 기다린 뒤에 배포 성공을 보고하도록 구현했다.
for i in $(seq 1 20); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://localhost:3010/ || true)
echo "healthcheck $i: backend / -> ${code:-000}"
[ "$code" = "200" ] && break
[ "$i" = "20" ] && { echo "backend unhealthy after retries" >&2; exit 1; }
sleep 6
done
위 코드는 6초 간격으로 최대 20번까지 백엔드에 요청을 보내고, 200 응답을 받으면 반복을 종료한다. 20번 안에 정상 응답을 받지 못하면 배포를 실패로 처리한다.
배포 동시 실행 제어하기
배포 워크플로우는 직접 실행되지 않고 다른 워크플로우에서 호출하는 재사용 워크플로우(workflow_call)로 구성했다. 이미지가 새로 올라갔을 때와 인프라 설정이 바뀌었을 때, 두 곳에서 이 워크플로우를 호출한다.
문제는 이 두 호출이 같은 push에서 동시에 일어날 수 있다는 것이다. 이 경우 두 개의 배포가 같은 호스트에서 동시에 진행된다. 이를 막기 위해 concurrency 설정을 추가했다.
concurrency:
group: deploy-staging
cancel-in-progress: false
여기서 cancel-in-progress를 false로 설정한 이유는 진행 중인 배포가 중간에 끊기지 않도록 하기 위함이다. true로 설정할 경우 나중에 들어온 배포가 진행 중인 배포를 취소하는데, 컨테이너를 재시작하는 도중에 중단되면 서버가 어떤 상태로 남을지 알 수 없다. 따라서 취소하지 않고 순서대로 실행되도록 했다.
마무리
이번 글에서는 개발 서버를 인터넷에 공개하지 않은 상태에서 GitHub Actions로 배포하는 과정을 정리해보았다. 현재 개발 서버의 경우 main에 push하면 이미지 빌드부터 호스트 배포, 헬스체크까지 자동으로 진행된다.
Tailscale로 구성하면서 얻은 점을 정리하면 다음과 같다. 먼저 서버의 SSH 포트를 인터넷에 열지 않고도 CI에서 배포할 수 있게 되었다. 러너는 Job이 실행되는 동안에만 tailnet에 참여하고, ACL을 통해 서버의 22번 포트에만 접근할 수 있다. 레지스트리 자격증명 역시 배포하는 동안에만 호스트에 존재한다. 즉 배포가 끝나면 CI가 서버에 접근할 수 있는 경로와 권한이 모두 사라진다.
또 하나의 장점은 비용이다. 비슷한 구성을 위해서는 VPN 서버를 직접 두거나, 별도의 Bastion 서버를 운영하거나, 클라우드에서 제공하는 관리형 접근 서비스를 사용하는 등 더 정교한 방법들도 존재한다. 하지만 이 방법들은 추가로 서버를 운영하거나 특정 클라우드에 종속되어야 하는 부담이 있다. 반면 Tailscale은 각 서버와 러너에 클라이언트만 설치하면 되고, 지금 규모에서는 무료 플랜으로도 충분했다. 현재 프로젝트의 규모를 고려했을 때, 추가 비용 없이 접근 경로를 닫을 수 있는 가장 현실적인 선택이었다.