배경
서버는 2H2G 경량 인스턴스라서 Next.js를 빌드하면 메모리가 바로 터진다. 그래서 빌드는 GitHub Actions에서 하고, 산출물을 zip으로 묶어서 서버로 푸시한다. 예전에는 Innei 님이 작성한 SSH + SCP 전송 방식을 썼는데, 배포할 때마다 22번 포트를 열어야 했다. 계속 열어두면 스캔 공격에 노출되고, 매번 수동으로 열고 닫기도 귀찮았다.
서버에서 Nginx는 이미 돌고 있고 443 포트도 열려 있다. 그럼 443에 산출물을 받는 라우트를 하나 추가하고, GitHub Actions에서 curl로 POST만 보내면 된다.
서버 측
Webhook 서비스
순수 Node.js HTTP 서비스로, 127.0.0.1:9000에서 대기하며 의존성은 전혀 설치하지 않는다. 핵심은 세 단계: secret 검증, zip 스트리밍 쓰기, 배포 실행.
스트리밍 쓰기가 핵심이다. 첫 번째 버전은 zip을 통째로 메모리에 읽어들였는데, 47MB만 돼도 PM2 메모리 제한을 초과해서 프로세스가 죽고 Nginx가 502를 반환했다. req.pipe(fs.createWriteStream())으로 바꾼 뒤로는 메모리 사용량이 10MB 미만이다.
const fileStream = fs.createWriteStream(zipPath);
req.pipe(fileStream);
fileStream.on("finish", () => {
deploy(zipPath, runNumber);
});
배포 로직은 기존 SSH 스크립트와 동일하다: 압축 해제 → monorepo/flat 구조 감지 → .env 및 server.js 심볼릭 링크 → pm2 reload. 배포할 때마다 ~/shiro/<run_number>/에 저장하므로 롤백 스크립트는 수정할 필요가 없다.
Nginx
API 도메인 설정에 location 하나를 추가한다:
location = /deploy {
client_max_body_size 200m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_pass http://127.0.0.1:9000/deploy;
proxy_set_header X-Deploy-Secret $http_x_deploy_secret;
proxy_set_header X-Run-Number $http_x_run_number;
proxy_request_buffering off;
}
proxy_request_buffering off는 필수다. 이게 없으면 Nginx가 zip 전체를 디스크에 버퍼링한 뒤에야 전달한다.
GitHub Actions 측
기존 appleboy/scp-action + appleboy/ssh-action을 한 줄의 curl로 완전히 대체했다:
- name: Deploy via webhook
run: |
response=$(curl -s -w "\n%{http_code}" \
--max-time 300 --retry 3 --retry-delay 10 \
-X POST "${{ vars.DEPLOY_URL }}" \
-H "X-Deploy-Secret: ${{ secrets.DEPLOY_SECRET }}" \
-H "X-Run-Number: ${{ github.run_number }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @release.zip)
http_code=$(echo "$response" | tail -1)
echo "$response" | sed '$d'
[ "$http_code" = "200" ] || exit 1
SSH 키도, ssh-keyscan도, known_hosts도 필요 없다. DEPLOY_URL은 Variables에, DEPLOY_SECRET은 Secrets에 넣으면 된다.
업스트림 자동 동기화
Shiro 업스트림이 계속 업데이트되고 있고, 내 저장소는 업스트림 기반에 몇 가지 커스터마이징이 추가된 상태다. 수동으로 정기 병합하다간 깜빡하기 쉬우니 sync.yml로 자동화했다:
on:
schedule:
- cron: '17 2 * * *'
workflow_dispatch:
매일 정해진 시간에 소스 저장소를 체크아웃하고, git fetch upstream main으로 새 커밋이 있으면 git merge --no-edit으로 병합한 뒤 푸시하고, repository_dispatch로 빌드-배포를 트리거한다.
병합 충돌이 나면 바로 에러로 종료하고 자동 해결은 하지 않는다. -X theirs를 쓰면 내 커스텀 코드가 조용히 덮어씌워지는데, 자체 수정 사항이 있는 fork 입장에서는 받아들일 수 없는 방식이다. 충돌이 나면 GitHub에서 이메일 알림이 오니 수동으로 처리하면 된다. 대부분의 경우 충돌이 나지 않는다.
겪었던 문제들
502의 진짜 원인은 Nginx 설정이 아니었다. PM2의 max_memory_restart 기본값이 64MB로 너무 작았던 것이다. Webhook 서비스가 zip을 받을 때 메모리가 70MB까지 올라가면서 PM2가 프로세스를 죽였고, Nginx는 연결이 끊긴 상태를 받은 것이다. 스트리밍 쓰기로 바꾸고 제한을 256MB로 올려서 해결했다. 추가로 pm2 restart는 ecosystem 설정을 다시 읽지 않으므로 pm2 delete + pm2 start를 해야 한다.
첫 번째 sync에서 알 수 없는 충돌이 발생했다. taze.config.js에서 add/add 충돌이 났다고 나왔는데, 방금 수동 병합을 마친 상태였고 업스트림 최신 커밋에서도 이 파일을 건드리지 않았다. 진단 로그를 추가하고 두 번째로 실행했을 때는 완전히 정상 작동했다. 원인은 아마 첫 번째 트리거 시점에 Actions가 예전 버전을 체크아웃하면서, git이 찾은 병합 베이스 포인트가 더 이전 시점이 되어 양쪽에 각각 존재하는 동일 파일을 독립적으로 새로 추가된 파일로 판단한 것 같다. 시차 문제이지 로직 문제는 아니다.
전체 흐름
업스트림에 새 커밋 발생
→ sync.yml이 정기적으로 병합 후 푸시
→ repository_dispatch가 deploy.yml 트리거
→ GitHub Actions 빌드 + curl POST로 zip 전송
→ 서버 Webhook 수신 → 압축 해제 → pm2 reload
22번 포트는 전혀 열 필요가 없다. 443은 Nginx가 원래 쓰고 있던 포트다.