블로그 구축기 다섯 번째 글이다. 지난 글에 이어 이번에도 배포 이야기다. 이번에는 배포가 실패한 뒤 남은 파일 하나 때문에 사이트가 내려갔다.
증상: 디자인 배포 직후 503
블로그 디자인을 Tailwind CSS로 새로 만들고 운영 서버에 배포했다. 배포 스크립트가 오류를 내며 멈췄고, 사이트에 접속하자 모든 페이지가 503 Service Unavailable을 돌려줬다. 배포를 한 번 더 실행하자 이번에는 끝까지 성공했고 사이트도 돌아왔다. 사이트가 내려가 있던 시간은 2~3분 정도였다.
배포를 다시 하면 복구된다는 건 다행이었지만, 실패한 배포가 사이트를 내린 채로 두고 끝났다는 건 그냥 넘길 수 없었다.
원리: AppOffline 규칙과 app_offline.htm
배포는 Web Deploy(msdeploy)의 sync 명령으로 한다. 이때 AppOffline 규칙을 켜 두었다.
'-verb:sync',
"-source:contentPath=$publishDir",
"-dest:contentPath=$SiteName,$remote",
# 배포 중에는 app_offline.htm 으로 앱을 내려 dll 잠금을 푼다
'-enableRule:AppOffline',
'-retryAttempts:3'
이 규칙이 필요한 이유는 dll 잠금이다. ASP.NET Core 앱을 IIS에서 in-process로 돌리면, 앱이 실행 중인 동안 앱의 dll 파일이 잠겨 있다. 그 상태로는 새 dll로 덮어쓸 수 없다.
ASP.NET Core 모듈(ANCM)은 사이트 폴더에 app_offline.htm이라는 파일이 생기면 앱을 종료하고, 들어오는 요청에 503과 함께 그 파일 내용을 돌려준다. AppOffline 규칙은 이걸 이용한다.
- 동기화를 시작하면서 대상 사이트에
app_offline.htm을 올린다. 앱이 내려가고 dll 잠금이 풀린다. - 파일을 동기화한다.
- 동기화가 끝나면
app_offline.htm을 지운다. 앱이 새 버전으로 다시 뜬다.
문제는 2단계에서 실패할 때다. 3단계까지 가지 못하니 app_offline.htm이 서버에 그대로 남는다. 앱은 계속 내려가 있고, 모든 요청이 503을 받는다. 우리가 본 장면이 정확히 이것이었다.
원인 추정: 잠금이 늦게 풀렸다
실패한 정확한 원인은 로그만으로 확정하지 못했다. 다만 다시 실행했을 때 바로 성공한 점이 단서가 됐다. 앱을 내리는 데는 시간이 조금 걸린다. 그 사이 dll 잠금이 아직 풀리지 않은 파일을 덮어쓰려다 실패한 것으로 판단했다. msdeploy에도 재시도 옵션(-retryAttempts:3)을 주고 있었지만, 재시도 간격이 짧으면 잠금이 풀리기 전에 기회를 다 써 버린다.
해결: 기다렸다가 다시 시도한다
두 겹으로 재시도를 넣었다.
- msdeploy 안의 재시도 간격을 늘렸다.
-retryInterval:3000을 추가해 파일 단위 재시도 사이에 3초씩 기다린다. - 동기화 전체를 다시 시도한다. 그래도 실패하면 15초 기다렸다가 동기화를 처음부터 다시 실행한다. 최대 3번까지 시도한다.
# 앱이 내려가는 동안 dll 잠금이 늦게 풀리면 동기화가 실패하고 app_offline.htm 이 남아 사이트가 503 이 된다
# → 잠시 기다렸다가 다시 시도한다. 다시 실행하면 app_offline 도 정리된다
$maxAttempts = 3
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
Invoke-MsDeploy $syncArgs "[2/4] 사이트 동기화$(if ($attempt -gt 1) { " (재시도 $attempt/$maxAttempts)" })"
break
}
catch {
if ($attempt -eq $maxAttempts) {
Write-Warning '동기화에 계속 실패했습니다. 서버에 app_offline.htm 이 남아 사이트가 503 일 수 있습니다. 원인을 확인한 뒤 deploy.ps1 -SkipBuild 로 다시 실행하세요.'
throw
}
Write-Warning "동기화 실패 — 15초 후 다시 시도합니다. ($($_.Exception.Message))"
Start-Sleep -Seconds 15
}
}
동기화 전체를 다시 실행하는 방식이 좋은 이유가 있다. 재시도가 성공하면 3단계에서 app_offline.htm도 함께 지워진다. 남은 흔적을 따로 청소할 필요가 없다.
세 번 모두 실패하면 스크립트는 사이트가 503일 수 있다는 경고와 함께 멈춘다. 이때는 원인을 확인한 뒤 빌드를 건너뛰는 옵션(-SkipBuild)으로 다시 실행하면 된다. 최악의 경우에도 다음에 할 일을 스크립트가 알려 주게 한 것이다.
정리하며
- 배포가 실패했을 때 서버가 어떤 상태로 남는지 알아 둔다. 성공 경로만 생각하면
app_offline.htm은 잠깐 생겼다 사라지는 파일이다. 실패 경로에서는 사이트를 내리는 스위치가 된다. - 배포 스크립트는 다시 실행해도 안전하게 만든다. 이번에 2~3분 만에 복구할 수 있었던 것은 같은 스크립트를 한 번 더 실행하면 되는 구조였기 때문이다. 재시도도 이 성질 덕분에 단순하게 넣을 수 있었다.
- 멈출 때는 다음 행동을 알려 준다. 자동으로 해결하지 못한 경우를 위해 경고 메시지에 현재 상태와 복구 방법을 함께 적었다.
이 일 역시 배포 문서의 장애 대응 표에 증상, 원인, 조치로 기록해 두었다.
다음 글에서는 .NET Framework 4.8의 MVC 5로 만든 블로그를 같은 날 ASP.NET Core 10으로 옮긴 이야기를 다룬다.