ESC

결국에 셀프 호스팅 하기로 했다

결국에 셀프 호스팅 하기로 했다 티저 사진
d3h1 Profile Image

도훈

개발

프론트엔드 개발을 하면서 GitHub Pages, Netlify, Vercel, Cloudflare Workers 같은 여러 호스팅 서비스를 이용해 왔지만, 완전히 만족스러운 서비스를 찾지는 못했습니다.

사실 원래 이번 글은 Vercel에서 Cloudflare Workers로 Next.js 프로젝트를 이전하는 후기 글이였지만, Cloudflare Workers에서도 실망하여 결국 셀프 호스팅을 하게 되었습니다.

Vercel은 프로젝트가 커질수록 빌드 시간이 길어졌고, 간혹 명확한 이유를 찾기 어려운 빌드 실패도 발생했습니다.

Cloudflare Workers는 전반적으로 만족스러웠지만, 제 환경에서 배포 결과를 확인했을 때 요청이 미국 로스앤젤레스(LAX) 쪽으로 전달되어 국내 접속 속도가 기대보다 느렸습니다. Workers Paid 요금제를 사용하더라도 일본 도쿄(NRT)에서 서비스된다는 후기도 확인할 수 있었는데, CloudFront의 대한민국 서울(icn1) 엣지로 잡히는 Vercel보다는 느립니다.

결국 플랫폼의 배포 정책과 엣지 위치에 계속 의존하기보다, 이미 운영하고 있던 기기들에 호스팅하기로 했습니다.

어디에 호스팅할 것인가?


집에는 24/7 켜져 있는 N100 미니 PC 2대와 08시에 켜져 다음 날 00시에 켜지는 시놀로지 나스가 있습니다. 집 밖에는 Oracle Cloud Infrastructure(OCI) 춘천 리전에서 동작하고 있는 4 OCPU, 24GB RAM 사양의 ARM 인스턴스가 있습니다.

시놀로지 나스는 항상 켜져 있지 않기 때문에 호스팅 용도로 적합하지 않았습니다. 한두 시간 정도의 장애도 검색엔진 최적화(SEO)와 크롤링에 영향을 줄 수 있는데, 하루에 8시간씩 꺼져 있다면 블로그를 안정적으로 운영하기 어렵습니다.

N100 미니 PC는 계속 켜져 있지만, 집 전체가 정전되거나 통신사 장비에 갑작스럽게 문제가 생기면 인터넷 불능이 되는 문제가 생길 수도 있기 때문에 집에서 셀프 호스팅하는 것은 적절하지 않다고 판단하였습니다.

반면 OCI 춘천 리전의 ARM 인스턴스는 메모리도 충분했고, 이전부터 백엔드 배포에 사용하면서 안정성을 확인한 상태였습니다. CI/CD 도구인 Jenkins도 이미 설치되어 있어 새로 준비할 항목이 많지 않았습니다.

백엔드도 계속 같은 인스턴스에서 운영할 계획이었기 때문에 프론트엔드와 백엔드를 같은 Docker 네트워크에 연결할 수 있다는 장점도 있었습니다. 프론트엔드가 외부에 공개된 백엔드 주소로 우회하지 않고 컨테이너 이름으로 직접 통신하게 만들면, 백엔드 포트를 인터넷에 노출하지 않아도 됩니다.

어떻게 배포할 것인가?


이전에 Jekins를 사용해 왔고, OCI 춘천 리전의 ARM 인스턴스에서 백엔드를 배포했습니다. 하지만, 정석적인 사용 방법은 아니였습니다. Jenkins는 멀티 아키텍처 Docker 이미지를 Docker Hub에 올리는 용도로만 사용했고, 백엔드는 Dockerfile을 이용해 이미지를 만들고 컨테이너를 새 이미지에 맞게 갈아 끼워서 배포하고 있었으니까요.

이전의 Jenkins 사용 경험과 백엔드 배포 경험으로는 부족했습니다. 제 프로젝트들이 대부분 오픈 소스로 공개가 되어 있다고 하더라도 Docker Hub에 올려 누구나 쉽게 배포하도록 설계하지 않았고, Dockerfile로 매번 이미지를 만들어 갈아 끼우면 Docker 컨테이너가 정지되는 동안 프로젝트에 접근할 수 없어질 것이기 때문입니다.

실제로 단일 포트로 매번 새 Docker 이미지를 만들어 컨테이너를 갈아 끼울 때 짧게는 1초, 길게는 7초까지 서비스가 중단되었습니다.

뭐 얼마 안 되는 시간이라고 생각할 수 있지만, 그동안 검색엔진 크롤러나 진짜 사용자의 요청이 들어오지 않을 것이라는 보장은 없어서 너무 무책임한 배포 전략이라는 생각이 들었습니다.

그래서 두 개의 포트를 번갈아 사용하는 Blue/Green 방식을 적용했습니다.

Nginx Blue/Green 구조


블로그 프론트엔드 컨테이너는 호스트의 20062016 포트를 번갈아 사용합니다. 외부 요청은 항상 Nginx를 통해 처리되고, Nginx upstream만 현재 활성 컨테이너의 포트를 가리킵니다.

사용자

Nginx

현재 활성 포트
  ├─ Blue  : 127.0.0.1:2006
  └─ Green : 127.0.0.1:2016

예를 들어 Blue가 현재 운영 중이라면 upstream 설정은 다음과 같습니다.

upstream d3h1_blog {
    server 127.0.0.1:2006;
}

새 버전은 비활성 포트인 2016에서 먼저 실행합니다. 새 컨테이너가 준비되고 health check와 smoke test를 통과한 뒤에만 upstream을 2016으로 변경합니다. 전환이 끝난 후에도 기존 컨테이너를 즉시 제거하지 않고 잠시 기다렸다가 정리합니다.

배포 순서는 다음과 같습니다.

1. 현재 Nginx upstream에서 활성 포트를 확인한다.
2. 반대편 포트에 새 컨테이너를 실행한다.
3. 새 컨테이너의 health check와 smoke test를 수행한다.
4. Nginx upstream을 새 포트로 변경한다.
5. nginx -t로 전체 설정을 검증한다.
6. Nginx를 reload한다.
7. 기존 연결이 빠져나갈 때까지 drain 시간을 둔다.
8. 이전 컨테이너를 제거한다.

중요한 점은 Docker 컨테이너 목록이 아니라 실제 Nginx upstream 파일을 기준으로 활성 포트를 판단하는 것입니다. 배포 도중 프로세스가 강제로 종료되면 두 컨테이너가 모두 남을 수 있기 때문입니다. 단순히 실행 중인 컨테이너만 보고 판단하면 다음 배포에서 활성 포트를 반대로 인식할 수 있습니다.

왜 drain 시간이 필요할까?


처음에는 Nginx를 reload한 직후 이전 컨테이너를 바로 제거하는 것이 깔끔하다고 판단되어서 바로 제거했습니다. 하지만, 연속적으로 요청을 보내 테스트해 보니 HTTP 502가 한 번 발생했습니다.

Nginx를 reload해도 기존 워커가 즉시 사라지는 것은 아닙니다. 기존 연결을 처리하던 워커가 잠시 이전 upstream을 사용할 수 있는데, 이 시점에 이전 컨테이너를 제거하면 요청이 실패할 수 있습니다.

배포 스크립트에 10초의 drain 시간을 추가한 뒤 같은 방식으로 다시 2,016건의 연속 요청을 보냈고, 요청이 실패하지 않았습니다.

Nginx reload

새 요청은 새 upstream으로 전달

10초간 기존 연결 drain

이전 컨테이너 제거

Blue/Green 방식은 포트만 두 개 사용한다고 되는 것이 아니라, 프록시의 reload 방식과 기존 keep-alive 연결이 어떻게 종료되는지까지 고려해야 실제 무중단 배포가 됩니다.

배포가 실패하더라도 계속 유지되도록


새 이미지를 이용한 컨테이너를 실행한 이후에 바로 사용자에게 전달이 되면 안 됩니다. 빌드가 성공했더라도 실제로는 작동하지 않는 상황이 있을 수도 있기 때문입니다.

그래서 조건을 만들고, 이 조건을 모두 통과하면 Nginx upstream을 전환하도록 했습니다.

- 컨테이너 프로세스가 정상적으로 실행 중인지
- 지정한 HTTP 경로가 정상 상태 코드를 반환하는지
- 프론트엔드 페이지가 실제로 렌더링 되는지
- 서버 실행에 필요한 환경 변수가 주입되었는지

조건을 만족하지 않으면 기존 컨테이너를 유지하고 새 컨테이너를 제거합니다. 또한, Nginx upstream을 수정하지 않아서 여전히 Nginx는 기존 컨테이너의 포트를 사용합니다.

정상 이미지 배포에서도 2,013건의 연속 요청을 보냈고, 요청이 실패하지 않았습니다. 단순히 스크립트가 성공했다는 결과만 확인한 것이 아니라, 배포가 진행되는 동안 외부에서 계속 요청을 보내 실제 가용성을 측정했습니다.

Jenkins가 어떻게 Nginx 설정 파일을 수정할까?


Docker는 Jenkins 사용자가 소켓에 접근할 수 있도록 구성하면 되지만, /etc/nginx/ 경로에 있는 Nginx 설정 파일을 수정하거나 reload에는 root 권한이 필요합니다.

Jenkins 계정 그룹에 포함된 사용자가 Nginx 권한을 갖게 설정하는 것은 매우 위험합니다. 프로세스는 실행에 필요한 최소한의 권한을 가져야 하는 권한 분리 원칙에도 위배되지만, 더 큰 문제가 생길 수 있습니다.

Nginx가 배포 중인 서비스에 취약점이 있어 권한을 획득하면, Jenkins에 포함된 자격 증명에 접근이 가능해지고, Jenkins 내부의 Jenkinsfile을 수정하여 공급망 공격도 가능해집니다.

그래서, 아래 경로에 허용된 서비스명과 포트만 처리하는 전용 래퍼를 만들었습니다.

/usr/local/sbin/nginx-switch-upstream

이 래퍼는 다음 작업만 수행합니다.

- 허용된 upstream 이름인지 검사
- 허용된 포트인지 검사
- 임시 파일에 새 upstream 작성
- `nginx -t` 수행
- 검증 성공 시 reload
- 검증 또는 reload 실패 시 이전 설정 복원

Jenkins에는 이 래퍼 하나만 비밀번호 없이 실행할 수 있는 권한을 부여했습니다. 배포 자동화에 필요한 최소 권한만 허용한 것입니다.

래퍼의 원본은 저장소에서 버전 관리하고, 호스트의 /usr/local/sbin에는 root 소유의 설치 사본을 둡니다. Jenkins가 운영 중인 래퍼 자체를 덮어쓸 수 없게 소유자와 쓰기 권한도 분리했습니다.

포함할 파일 설정은 똑바로 합시다


Nginx upstream을 처리하는 전용 래퍼는 upstream을 변경하기 전에 기존 파일을 .bak으로 복사해 둡니다. 저는 이 점을 간과하고 아래와 같이 Nginx 설정을 해두었습니다.

include /etc/nginx/upstream/*;

전용 래퍼를 사용하도록 Jenkinsfile을 수정하는 PR을 작성하였는데, Jenkins Pipeline이 실패했습니다. 실패 원인을 찾으려고 로그를 확인했더니, 아래와 같이 나오더군요.

duplicate upstream "d3h1_blog"

Nginx 설정은 /etc/nginx/upstream/ 안의 모든 파일을 읽도록 설정되었기 때문에, .bak 파일을 읽어 동일한 upstream이 두 번 선언된 것이나 다름없게 되었기 때문입니다.

이 문제를 .conf 파일만 읽도록 nginx.conf를 수정하여 해결했습니다.

include /etc/nginx/upstream/*.conf;

Jenkins 파이프라인 구성


Jenkins 파이프라인은 크게 검사, 이미지 빌드, smoke test, 운영 배포 단계로 나눴습니다.

Checkout

Lint / Type Check / Test / Build

Docker 이미지 빌드

독립 컨테이너 Smoke Test

운영 브랜치인 경우에만 Blue/Green 배포

Telegram 결과 알림

일반 작업 브랜치에서는 이미지가 실제로 실행되는지 확인하는 smoke test까지만 수행합니다. 운영 브랜치에서만 실제 서버의 upstream을 전환합니다. 이렇게 하면 Dockerfile이나 런타임 설정이 깨진 변경을 병합하기 전에 발견할 수 있습니다.

이미지에는 latest를 사용하지 않고 commit SHA 태그를 함께 붙였습니다. 어떤 커밋으로 만들어진 이미지인지 확인할 수 있고, 문제가 생겼을 때 이전 이미지로 돌아가기도 쉽습니다.

동일 서비스의 배포가 겹치지 않도록 Jenkins lock도 적용했습니다. 두 빌드가 동시에 비활성 포트를 판단하고 서로 다른 upstream을 쓰면 Blue/Green 상태가 꼬일 수 있기 때문입니다.

Docker Registry, Telegram 등 인증 정보는 Jenkins Credentials로 주입했습니다. 비밀번호나 토큰을 저장소, Docker 이미지 레이어, 빌드 로그에 남기지 않는 것을 원칙으로 했습니다.

백엔드를 더 이상 외부에 노출하지 않기


원래는 백엔드는 OCI 춘천 리전의 ARM 인스턴스에서 운영되고 프론트엔드는 Vercel이나 Cloudflare Workers로 배포되어서 백엔드를 외부에 노출해야 했습니다.

셀프 호스팅을 하게 되면서 프론트엔드와 백엔드를 같은 인스턴스에서 운영하기 때문에 더 이상 백엔드를 외부에 공개할 필요가 없습니다. 하지만, 같은 인스턴스에서 프론트엔드와 백엔드가 동작하더라도 환경 변수를 통하여서 백엔드의 경로를 지정해 주어야 성공적으로 연동됩니다.

Docker의 기본 bridge 네트워크를 사용하여 컨테이너가 발급받은 IPv4 주소를 환경 변수를 통해 전달할 경우, 백엔드 컨테이너가 재배포 될 때 백엔드 컨테이너의 IPv4 주소가 변경될 수 있다는 문제점이 있습니다.

이 문제를 해결하기 위해 Docker DNS 이름을 사용하기로 하였습니다. 이를 사용하면 Docker 컨테이너의 IPv4 주소가 변경되어도 새 컨테이너를 찾을 수 있습니다. 하지만, 이 기능은 Docker의 기본 bridge 네트워크에서는 동작하지 않습니다.

그래서 두 컨테이너를 미리 생성한 사용자 정의 Docker 네트워크에 연결하고, 아래처럼 프론트엔드는 백엔드 컨테이너의 서비스 이름을 사용하도록 했습니다.

API_BASE_URL=http://backend-service:3000

다만 Docker DNS 별칭은 이미 맺어진 TCP 연결을 새 컨테이너로 옮겨주지는 않습니다. 실제 내부 keep-alive 연결을 측정했을 때 연속된 요청 1,817건 중 1건이 재연결 과정에서 실패하여 99.94%의 가용성을 보였습니다. 외부 Nginx 경로는 같은 테스트에서 실패가 없었습니다.

따라서 외부 사용자의 무중단 전환은 Nginx가 담당하고, 내부 통신은 클라이언트 재시도와 graceful shutdown을 함께 적용하는 것이 더 안전합니다. 단순히 Docker DNS 이름만 사용한다고 모든 기존 연결이 무중단으로 이전되는 것은 아닙니다.

큰 호스팅에는 큰 책임이 따른다


이렇게만 보면 Vercel이나 Cloudflare에 유료 결제를 해서 배포하는 것보다 OCI 프리티어 한도 내에서 ARM 인스턴스를 통한 셀프 호스팅이 더 나아 보일 수 있습니다만, 이것은 틀렸습니다.

운영체제나 패키지의 보안 취약점이 발생하지 않도록 수시로 업데이트해 주고, 배포하는 Docker 이미지의 의존성 취약점도 직접 관리해야 합니다.

제가 운영하고 있는 OCI 춘천 리전의 ARM 인스턴스에는 민감한 내용이나 중요한 정보가 없지만 만약에 운영하고 있는 환경에서 취약점이 생겨 공격을 당하면 이러한 정보들이 유출될 수 있고, 본인이 직접 해결해야 합니다.

이 모든 것은 Vercel이나 Cloudflare를 통해 무료로 배포해도 신경 쓰지 않아도 되는 문제이거나 문제가 생겼을 때 경고해 주고 조치 방법을 알려줍니다.

셀프 호스팅은 무조건 장점만 있는 것이 아니니 셀프 호스팅을 고민하고 계신다면 장단점을 잘 살펴보시어 신중하게 생각하시길 바랍니다.

마무리


이번 셀프 호스팅은 단순히 Vercel과 Cloudflare를 벗어나는 것만을 목표로 시작한 작업은 아닙니다. 기존 배포 플랫폼들이 제공하는 것처럼 배포 중에도 기존 버전이 요청을 처리하고, 새 버전에 문제가 생기면 아무 일도 없었던 것처럼 기존 버전을 유지하는 구조를 직접 만들어보는 것이 목표였습니다.

결과적으로 Jenkins가 코드 검사와 Docker 이미지 빌드를 진행하고, 비활성 포트에서 새 버전의 컨테이너를 검증한 뒤, 제한된 권한을 가진 래퍼를 통해 Nginx upstream을 전환하는 구조를 만들었습니다.

정상적으로 배포되어 사용자에게 전달되는지만 확인한 것이 아니라, 다음과 같은 상황도 직접 재현하며 배포 과정이 의도한 대로 동작하는지 확인했습니다.

- 새 컨테이너가 정상적으로 가동되는 배포
- health check에 실패하는 이미지 배포
- `nginx -t`가 실패하는 상황
- Nginx reload가 실패하는 상황
- upstream 전환 직후 배포 프로세스가 중단되는 상황
- 기존 컨테이너와 새 컨테이너가 함께 남아 있는 상황

이번 작업과 이 글을 작성하는 과정에는 생성형 AI를 적극적으로 활용했습니다.

예전 같았으면 직접 연속적으로 요청을 보내는 테스트 스크립트를 작성하고, 문제가 생길 때마다 관련 자료를 하나씩 찾아보느라 훨씬 많은 시간이 필요했을 것입니다. 무중단 배포에 대한 기초 지식이 충분하지 않은 상태에서도 구조를 설계하고 여러 실패 상황을 재현하며 결과를 검증할 수 있었던 것은 생성형 AI의 도움 덕분이었습니다.

이전에 제 블로그에 게시된 글을 생성형 AI가 참고한 것을 보고 신기하다고 생각한 적이 있습니다. 이후 생성형 AI와 함께 여러 프로젝트를 진행하면서도 비슷한 생각을 했지만, 이번에는 제가 거의 알지 못했던 영역의 구조를 직접 구현하고 검증하는 단계까지 도달할 수 있다는 점에서 생성형 AI의 가능성을 더 크게 체감했습니다.

물론 AI가 제안한 내용을 그대로 적용하지는 않았습니다. 실제 환경에서 테스트하면서 잘못된 가정이나 빠진 조건을 확인했고, 문제가 발견될 때마다 원인을 다시 분석해 수정했습니다. AI가 처음 제안한 방법 때문에 새로운 문제가 발생하기도 했지만, 그 문제까지 직접 재현하고 해결하는 과정도 이번 경험의 일부였습니다.

그 결과 단순히 배포 설정을 완성하는 데 그치지 않고, Blue/Green 배포가 어떤 방식으로 동작하는지, Nginx reload 이후 기존 연결을 왜 바로 종료하면 안 되는지, 실패한 배포에서 기존 서비스를 어떻게 보호해야 하는지까지 이해할 수 있었습니다.

처음에는 기존 호스팅 서비스의 아쉬운 점을 해결하기 위해 시작했지만, 결과적으로는 제가 운영하는 서비스의 배포 과정 전체를 직접 통제할 수 있는 환경을 만들었습니다. 앞으로 개선해야 할 부분은 여전히 남아 있겠지만, 적어도 이제는 배포할 때마다 서비스가 잠시 멈추지 않을까 걱정하지 않아도 됩니다.