디웹시 Blog 검색

Cloudflare Workers 자동 배포로 공장 서버를 닫아둔 이유, Hetzner 세팅기

사이트를 찍어내는 공장 서버를 Hetzner에 따로 만들고, 결과물은 Cloudflare Workers 자동 배포로 내보내는 구조를 잡았어요. 서버 분리, 초기 보안, API 토큰 권한, 텔레그램 승인까지 실제 세팅 과정을 정리했습니다.

수정

공유

“이것도 어차피 공개될 거 아냐? 내가 승인하면 그 서버에 도메인 붙여서 여는 방식 아닌가?” 사이트 공장 서버를 만들다가 제가 실제로 던진 질문이에요. 답은 아니었고, 그 답 덕분에 공장 구조가 Cloudflare Workers 자동 배포 쪽으로 정리됐어요.

이 글은 사이트 공장 시리즈 2편이에요. 1편에서 공공데이터 조회 사이트를 찍어내는 공장을 짓기로 했다면, 이번엔 그 공장을 어디에 어떻게 세웠는지에 대한 이야기예요. 혼자 서버 하나 더 띄우는 거니까 별것 아닐 줄 알았는데, 막상 해보니 결정할 게 꽤 많더라고요. 서버 사양, 프로젝트 분리, SSH 키, 방화벽, API 토큰 권한까지. 저처럼 자동화 서버를 처음 따로 세워보는 분이라면 같은 데서 멈칫할 거예요.

공장 서버는 Hetzner CX23 하나, 프로젝트부터 따로

처음엔 기존에 쓰던 서버랑 같은 사양으로 하나 더 만들면 되겠지 싶었어요. Hetzner 생성 화면을 열어보니 고민되는 선택지가 두 개였어요. 2코어 4GB에 40GB SSD인 CX23이 월 5.49유로, 같은 2코어 4GB인데 CPU 세대가 새로운 CPX22가 월 19.49유로였거든요. 둘 다 IPv4 주소 비용 0.5유로가 따로 붙고요(제가 만들 때 화면 기준).

코어 수랑 메모리가 똑같은데 가격은 3.5배 차이예요. 공장은 사이트를 빌드할 때만 바쁘고, 그것도 승인된 사이트 하나씩 순서대로 돌아가니까 CPU 세대 차이를 체감할 일이 별로 없다고 봤어요. 빌드가 몇 분 더 걸리는 정도는 상관없고요. 그래서 CX23으로 갔습니다. 아키텍처도 고민했는데, Arm이 조금 저렴하긴 해도 Docker 이미지나 Node 네이티브 모듈 중에 Arm용 빌드가 없는 경우가 있다고 해서 x86 그대로 뒀어요. 공장은 안정적으로 도는 게 먼저니까요. IPv4도 그대로 켜뒀어요. 들어오는 연결이 없으니 IPv6만 써서 월 0.5유로를 아낄 수도 있었는데, 공장이 호출할 국내 공공 API 중에 IPv6를 지원하지 않는 곳이 있으면 호출 자체가 막힐 수 있다는 조언을 듣고 0.5유로로 그 위험을 없애기로 했어요.

사양보다 먼저 결정해야 했던 게 있어요. Hetzner 프로젝트를 아예 분리할지였죠. 결론은 분리였고, 이유는 사고 격리예요. Hetzner 공식 문서를 보면 Hetzner Cloud API 토큰은 프로젝트를 연 상태에서 그 프로젝트 안에서 발급해요. 공장 프로젝트를 따로 두면 나중에 공장이 Hetzner API를 쓰게 되더라도, 공장 프로젝트에서 받은 토큰으로는 운영 중인 버림값 서버에 손을 못 대요. 실수로 서버를 지우거나 설정을 바꾸는 사고도 공장 안에서 끝나고요.

SSH 키도 새로 만들었어요. 운영 서버에 쓰던 키를 재사용하면, 공장 서버가 뚫렸을 때 그 키로 운영 서버까지 들어갈 수 있으니까요. 맥 터미널에서 이렇게 만들었습니다.

ssh-keygen -t ed25519 -C "site-factory" -f ~/.ssh/site_factory
pbcopy < ~/.ssh/site_factory.pub

Hetzner에 공개키를 붙여넣으니 기본 키로 등록할 거냐고 묻더라고요. 공장 전용 프로젝트 안이라 체크했어요. 기본 키는 그 프로젝트에서 서버를 만들 때 자동으로 선택되는 키라, 운영 프로젝트에서 체크하면 키를 나눈 의미가 사라져요. 그리고 하나 웃긴 일이 있었어요. 독일 리전으로 만들려다 헬싱키에 만들어져 버렸거든요. 지우고 다시 할까 했는데, 공장은 방문자를 받지 않고 빌드랑 배포만 하니까 위치가 성능에 주는 영향이 거의 없어서 그대로 썼어요.

생성 직후 10분, 서버 초기 보안 세팅

서버가 뜨자마자 한 일은 보안 세팅이에요. AI한테 스크립트를 하나 짜달라고 해서 루트로 한 번 돌렸어요. 하는 일은 작업용 계정 만들기, 루트 로그인과 비밀번호 로그인 막기, 방화벽에서 SSH만 열기, fail2ban 설치, 자동 보안 업데이트, 시간대를 한국 시간으로 바꾸기, 스왑 4GB 추가, Docker 설치였어요.

스왑을 넣은 건 페이지 수천 개짜리 정적 사이트를 빌드하면 메모리가 순간적으로 튈 수 있어서예요. 스왑이 있으면 빌드가 죽는 대신 조금 느려지는 걸로 끝나요. 4GB 서버에서 이 차이는 꽤 커요.

실행할 때 순서 하나는 꼭 지켰어요. 스크립트 마지막에 루트 로그인이 막히니까, 실행한 터미널을 닫지 않고 새 터미널을 열어서 작업용 계정으로 접속되는지 먼저 확인했어요. 이걸 안 하면 설정이 하나만 꼬여도 서버에서 쫓겨나요. 최악이면 Hetzner 웹 콘솔로 들어가면 되지만, 그 상황까지 가면 시간이 많이 깨져요.

# ~/.ssh/config
Host factory
  HostName 서버IP
  User factory
  IdentityFile ~/.ssh/site_factory

공장 코드는 처음엔 맥에서 서버로 rsync로 올렸는데, 나중에 GitHub 비공개 저장소로 옮겼어요. 서버에는 쓰기 권한 없는 읽기 전용 deploy key만 등록했고요. 서버는 코드를 받아오기만 하면 되니까, 서버가 뚫려도 저장소 코드를 바꿀 수 없게 하려는 거예요. 지금 배포는 맥에서 이 한 줄로 끝나요.

git push && ssh factory ~/site-factory/scripts/deploy.sh

여기에도 함정이 하나 있었어요. 텔레그램 봇 토큰을 새로 바꾸고 deploy.sh를 돌렸는데, 봇이 계속 옛 토큰으로 돌고 있었어요. 스크립트가 이미지가 그대로면 컨테이너를 다시 만들지 않아서, 바뀐 .env가 컨테이너에 들어가지 않았던 거예요. 그 사이 봇은 텔레그램 버튼을 하나도 못 받았고요. 그래서 deploy.sh가 .env 해시를 기억해뒀다가, 바뀌었으면 관련 컨테이너를 강제로 다시 만들고 봇 연결까지 확인하게 고쳤어요. 환경변수를 바꿨으면 컨테이너가 정말 새 값으로 떴는지까지 확인해야 한다는 걸 이때 배웠어요. 컨테이너는 전부 재시작 정책을 걸어둬서 서버가 재부팅돼도 공장이 다시 올라와요.

이렇게 해두면 ssh factory 한 줄로 접속돼요. 이 세팅이 끝난 시점에 서버에서 외부로 열려 있는 문은 SSH 하나뿐이었어요. 그리고 지금도 그대로예요. 공장 서버는 끝까지 SSH 하나만 열어둔다. 이게 가능한 이유가 다음 섹션이에요.

승인하면 공장 서버에서 사이트를 여는 줄 알았다

글 첫머리의 그 질문으로 돌아가 볼게요. 저는 당연히 이렇게 생각했어요. 공장이 사이트를 만들면, 그 서버에 nginx 설정을 하나 추가하고 도메인을 붙여서 여는 거라고요. 버림값도 제 서버의 nginx에서 서빙하고 있었으니까요. 그러니 공장 서버도 결국 80번, 443번 포트를 열어야 하는 줄 알았어요.

그런데 실제 구조는 달랐어요. 공장 서버는 사이트를 빌드만 하고, 결과물은 Cloudflare Workers로 내보내요. 도메인도 공장 서버가 아니라 Workers에 붙고요. 방문자는 Cloudflare로 접속하니까 공장 서버의 존재 자체를 몰라요. 공장 서버에는 배포, API 호출, 텔레그램 폴링 같은 나가는 연결만 있고 들어오는 연결이 필요 없어요. 그래서 SSH만 열어두면 되는 거죠.

이 Cloudflare Workers 자동 배포 구조가 좋은 이유는 세 가지였어요. 사이트가 10개, 20개로 늘어도 방문자 트래픽은 Cloudflare가 받으니 공장 서버 사양을 올릴 필요가 없어요. 공장 서버가 다운돼도 이미 배포된 사이트들은 그대로 살아 있어요. 공장을 고치는 동안에도 Cloudflare Workers 자동 배포로 이미 나가 있는 사이트는 아무 영향을 받지 않는다는 뜻이에요. 그리고 공격받을 수 있는 입구가 SSH 하나뿐이에요.

미리보기도 같은 원리로 해결했어요. 처음 설계에는 미리보기 주소를 제 도메인의 하위 주소로 잡아뒀는데, 그러면 공장이 제 메인 도메인의 DNS를 건드려야 해요. 그래서 Workers가 기본으로 주는 workers.dev 주소를 쓰게 바꿨어요. 미리보기에는 색인 금지 헤더를 걸어서 검색엔진이 미완성본을 가져가지 못하게 했고, 도메인을 입력하면 같은 Worker에 커스텀 도메인만 붙이고 색인 금지를 푸는 방식이에요. 확인할 때 본 화면과 실제로 열리는 화면이 같은 Worker 위에 있다는 게 핵심이에요. 확인은 공장에서 하고 오픈은 다른 데서 하면, “볼 땐 됐는데 열고 나니 깨지는” 일이 생기거든요.

Cloudflare Workers 목록에 공장이 배포한 스테이징과 프로덕션 Worker가 보이는 화면

나중에 걱정되는 게 하나 생겼어요. 프로덕션 Worker도 workers.dev 주소로 열리니까, 같은 사이트가 두 주소로 색인될 여지가 있었거든요. 확인해보니 접속 주소가 workers.dev면 미리보기든 프로덕션이든 이미 색인 금지로 응답하고 있었고, 옛 주소에서 넘어가는 리디렉션 응답에만 그 표시가 빠져 있어서 그 부분만 보완했어요. 페이지마다 원본 주소를 가리키는 canonical도 있지만, 막을 수 있는 건 처음부터 막아두는 게 편해요.

Cloudflare Workers 자동 배포를 위한 API 토큰은 좁게

Cloudflare Workers 자동 배포를 하려면 공장이 Cloudflare API를 써야 하고, 그러려면 토큰이 필요해요. 여기서 제일 신경 쓴 건 권한 범위예요. 공장이 할 일에만 딱 맞게 커스텀 토큰을 만들었어요.

범위항목권한공장이 쓰는 곳
계정Workers 스크립트편집미리보기·프로덕션 배포
계정계정 설정편집Web Analytics 사이트 자동 생성
영역영역읽기구매한 도메인 찾기
영역DNS편집서치콘솔 소유권 확인 TXT 등록
영역Workers 경로편집도메인을 Worker에 연결
영역캐시 제거제거배포 후 사이트맵 캐시 비우기

계정 설정은 처음에 읽기로 줬다가, 방문자 통계용 Cloudflare Web Analytics를 사이트마다 자동으로 만드는 단계에서 편집 권한이 필요하다고 해서 나중에 올렸어요. 넓은 권한이라 고민했는데, 아래 IP 제한이 있어서 감수할 만하다고 판단했어요. 캐시 제거 권한은 버림값에서 한 번 크게 데이고 넣었어요. 고친 사이트맵 대신 예전 404 응답이 캐시에 남아 있었거든요. 이 얘기는 4편에서 할게요.

토큰을 만들 때 두 가지를 더 걸었어요. 첫 번째는 영역 범위예요. 계정의 모든 영역을 포함하되, 제 메인 도메인과 버림값 같은 운영 중인 도메인은 제외 항목으로 뺐어요. 공장이 새로 산 도메인에는 쓸 수 있고, 기존 운영 도메인은 절대 못 건드리게 하려고요. 두 번째는 클라이언트 IP 필터링과 TTL이에요. Cloudflare 공식 문서에서도 토큰이 유출됐을 때 악용 가능성을 줄이려면 클라이언트 IP 필터링이나 TTL로 사용 범위를 제한하라고 권장해요. 그래서 공장 서버 IP에서만 쓸 수 있게 묶고, 기한도 1년으로 걸었어요. 이렇게 하면 토큰이 새어 나가도 공장 서버 밖에서는 쓸 수가 없어요.

같은 이유로 토큰은 채팅창이나 AI 코딩 도구 프롬프트에 붙여넣지 않기로 규칙을 정했어요. 대화 기록에 남는 순간 IP 제한 말고는 막을 게 없어지니까요. 공장 서버 .env 파일을 제가 직접 열어서 넣고, AI한테는 “넣었어, 실행해봐”라고만 말하는 식이에요. 그런데 이렇게 조심했는데도 텔레그램 봇 토큰이 한 번 AI 대화창에 그대로 찍힌 적이 있어요. 범인은 제가 아니라 HTTP 라이브러리 로그였는데, 이 얘기는 5편에서 자세히 할게요.

솔직히 이 화면에서 제일 헷갈렸던 건 언어였어요. 대시보드가 한국어로 되어 있으니 권한 이름이 영어 문서랑 달라서, 제대로 고른 건지 한참 들여다봤거든요. 저처럼 한국어 화면에서 막히는 분은 토큰 만들 때만 잠깐 영어로 바꾸는 것도 방법이에요. 나중에 권한 오류가 나면 오류 메시지가 영어 권한 이름으로 나와서 대조하기도 편해요.

승인은 텔레그램 버튼 하나로

공장에서 사람이 하는 일은 주제 승인, 도메인 구매, 수동 등록 세 가지뿐이라고 1편에서 말했어요. 그 승인을 받는 창구가 텔레그램 봇이에요. BotFather에서 봇을 만들면 토큰을 주고, 봇한테 먼저 말을 건 다음 getUpdates로 제 chat_id를 확인했어요. 이 순서가 은근히 중요해요. 봇 컨테이너가 메시지를 먼저 받아가는 상태면 getUpdates 결과가 비어 보이거든요. 봇은 제 chat_id 외의 메시지는 전부 무시하게 해뒀어요. 누가 봇을 찾아내도 공장을 조작할 수 없게요.

텔레그램 공장 봇에서 프로덕션 반영과 보류 유지 버튼이 달린 알림을 받은 화면

운영하면서 텔레그램 알림도 몇 번 손봤어요. 어느 날 아침에 주차장 사이트 “가동 실패” 알림이 왔는데, 폰으로 들어가 보니 사이트는 멀쩡했어요. 확인해보니 공장 서버에서 그 순간만 연결이 튄 일시적 실패였어요. 서버에서 같은 점검을 40번 다시 돌렸는데 전부 정상이었거든요. 그래서 실패하면 1분 간격으로 세 번 더 확인하고, 네 번 모두 실패해야 알리도록 바꿨어요. 그래도 실패하면 workers.dev 주소로 한 번 더 확인해서 “사이트 자체 문제”인지 “Cloudflare 경로 문제”인지까지 알림에 적게 했고요. 알림이 자주 틀리면 결국 아무도 안 보게 된다는 걸 이때 실감했어요.

스테이징에 올릴 때마다 프로덕션 반영이 보류되는데, 그걸 깜빡하면 월요일 데이터 갱신이 조용히 멈추는 문제도 있었어요. 그래서 일요일 밤 9시에 보류 중인 사이트가 있으면 버튼 두 개짜리 알림이 오게 했어요. 누르면 반영되고, 보류를 유지하면 그 주는 데이터만 받아두는 식이에요.

한 달 6유로 서버가 하는 일, 그리고 3편

정리하면 공장 서버는 월 5.49유로짜리 CX23에 IPv4 0.5유로를 더한, 한 달 6유로가 안 되는 서버 하나예요. 열려 있는 건 SSH 하나고, 사이트는 전부 Cloudflare Workers 자동 배포로 바깥에서 서빙돼요. 서버가 죽어도 사이트는 살아 있고, 사이트가 늘어도 서버는 그대로예요. 처음에 제가 상상했던 “공장 서버에 도메인을 붙여서 연다”는 구조였다면 지금쯤 포트 관리, 인증서 관리, 트래픽 걱정까지 떠안고 있었을 거예요.

다만 이 구조가 처음부터 매끄럽게 돌아간 건 아니에요. 서버를 세우는 것보다 그 서버가 바깥 API들과 말을 트게 만드는 게 훨씬 오래 걸렸어요. 구글 서비스 계정 키는 조직 정책에 막혔고, 공공데이터포털은 401을 뱉었고, 네이버 검색 API는 어느새 다른 플랫폼으로 이사 가 있었어요. 다음 3편은 그 인증 삽질 모음이에요. 같은 오류 메시지를 보고 이 글에 들어오신 분이라면 3편이 더 바로 쓸모 있을 거예요.

이어서