지난 글까지 점검 엔진, 알림, 메일 서버 PC 에이전트를 만들었다. 개발 PC에서는 다 돌아간다. 남은 일은 운영 서버에 올리고, 실제 대상을 등록하고, 바깥에서 봐도 괜찮은지 확인하는 것이다.
운영 배포부터 마지막 보안 검토까지, 배포 스크립트, 서버 설정, 화면, 보안 점검에서 문제가 하나씩 나왔다. 연재 마지막인 이 글은 그 기록이다.
배포 방식: 개발 PC에서 Web Deploy 원격 배포
설계 문서에는 "서버에서 dotnet publish 후 efbundle로 마이그레이션"이라고 적어 두었다. 실제로는 이 블로그를 배포할 때 쓰는 방식을 그대로 쓰기로 정했다. 개발 PC에서 게시하고, Web Deploy Remote Agent로 서버 폴더를 동기화하는 방식이다. 서버에 SDK나 소스를 둘 필요가 없고, 배포 계정도 이미 정리돼 있다.
배포 대상은 셋이다.
| 대상 | 위치 | 방법 |
|---|---|---|
| 웹 | 배포 서버 IIS 사이트 | deploy-web.ps1 — 게시 → 사이트 동기화 → 서버 스크립트로 앱풀 설정 |
| 메인 워커 | 배포 서버 Windows 서비스 | deploy-worker.ps1 — 서비스 중지 → 동기화 → 마이그레이션 → 시작 |
| 에이전트 | 메일 서버 PC Windows 서비스 | package-agent.ps1 로 zip → 메일 서버 PC에서 install-agent.ps1 |
운영 설정 파일(appsettings.Production.json)은 git에서 빼고 개발 PC에 대상별로 따로 둔다. setup-production.ps1 이 DB와 앱 계정을 만들고 이 세 파일을 작성한다. 비밀번호는 실행할 때 입력받는다. 배포 스크립트는 게시본에 운영 설정 파일이 섞여 있으면 배포를 멈추고, 동기화할 때는 서버에 있는 운영 설정을 지우지 않게 건너뛴다.
웹 동기화에는 -enableRule:AppOffline 을 켜고 실패하면 15초 뒤 최대 3번 다시 시도한다. 이 블로그를 배포하다 dll 잠금 때문에 동기화가 중간에 멈추고 app_offline.htm 이 남아 사이트가 503이 됐던 일이 있어서 처음부터 넣었다. 배포 비밀번호는 msdeploy 출력의 어디에도 남지 않게 Hide-Secret 으로 가린다. 이것도 같은 블로그 배포에서 겪은 일 때문이다.
마이그레이션은 서버에서 워커가
설계할 때 앱 시작 시 자동 마이그레이션은 하지 않기로 정했다. 웹과 워커가 동시에 Migrate() 하면 경합이 생기기 때문이다. 원래는 efbundle.exe 를 쓰려 했는데, 운영 DB의 앱 계정은 배포 서버 localhost에서만 접속된다. 개발 PC에서는 그 계정으로 붙을 수 없다.
그래서 워커 실행 파일에 --migrate 옵션을 넣었다. 배포된 워커가 자기 운영 설정의 연결 문자열로 미적용 마이그레이션을 적용하고 끝난다.
var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList();
if (pending.Count == 0)
{
Console.WriteLine("마이그레이션: 적용할 것이 없습니다 (최신).");
return 0;
}
Console.WriteLine($"마이그레이션 적용: {string.Join(", ", pending)}");
await db.Database.MigrateAsync(ct);
서버 스크립트 worker-setup.ps1 이 이 명령을 실행하고, 종료 코드가 0이 아니면 서비스를 시작하지 않는다. 그래서 배포 순서는 워커 → 웹이다. 새 마이그레이션이 있으면 워커를 먼저 배포해서 스키마를 올리고, 그다음 웹을 올린다. 별도 도구 없이 이미 서버에 있는 실행 파일 하나로 끝났다. 첫 배포에서 마이그레이션 3개가 적용됐다.
앱풀은 관리 코드 없음
서버의 IIS 사이트는 미리 만들어 두었는데, 앱풀이 .NET CLR v4.0으로 잡혀 있었다. ASP.NET Core는 IIS 안에서 자기 런타임을 쓰므로 앱풀은 관리 코드 없음이 맞다. 손으로 한 번 바꾸고 끝내지 않고, 웹 배포의 마지막 단계(web-setup.ps1)가 매번 appcmd set apppool /managedRuntimeVersion:"" 로 맞추게 했다. 로그 폴더와 Data Protection 키 폴더에 앱풀 계정의 쓰기 권한을 주는 것도 같은 스크립트가 한다.
PowerShell 5.1이 만든 두 가지 함정
msdeploy 인수의 따옴표
msdeploy 인수는 -source:dirPath="..." 처럼 콜론과 따옴표가 섞인다. 이걸 Windows PowerShell 5.1에서 그냥 부르면, 인수에 공백이나 따옴표가 있을 때 5.1이 인수 전체를 다시 따옴표로 감싸 버린다. msdeploy는 그 인수를 알아보지 못한다.
여러 방법으로 이스케이프를 맞춰 보는 대신, PowerShell의 인수 처리를 아예 거치지 않게 했다. 명령줄 문자열을 ProcessStartInfo 로 그대로 넘긴다.
# PowerShell 5.1 은 인수에 공백·따옴표가 있으면 인수 전체를 다시 따옴표로 감싸 msdeploy 가 인식하지 못한다
function Invoke-MsDeploy([string]$arguments, [string]$title, [switch]$WhatIf) {
$psi = New-Object Diagnostics.ProcessStartInfo $script:MsDeploy, $arguments
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.StandardOutputEncoding = [Text.Encoding]::GetEncoding(949)
# ... 출력은 Hide-Secret 으로 비밀번호를 가린 뒤 표시, 종료 코드가 0 이 아니면 throw
}
서버에서 명령을 실행하는 runCommand 는 더 까다롭다. 따옴표가 한 겹 더 들어가기 때문이다. 그래서 규칙을 하나 정했다. 원격 명령에는 따옴표를 쓰지 않는다. 복잡한 일은 서버 스크립트(deploy\server\*.ps1)로 만들어 먼저 동기화해 두고, 원격에서는 powershell -NoProfile -ExecutionPolicy Bypass -File <스크립트> -Action Install 처럼 따옴표 없는 한 줄만 실행한다. 함수 첫 줄에서 명령에 " 가 있으면 바로 예외를 던진다.
runCommand 는 원격 명령이 실패해도 msdeploy 자체는 성공으로 끝날 수 있다. 그래서 출력에 찍히는 원격 프로세스의 종료 코드(0x...)를 정규식으로 읽어서 0이 아니면 실패로 처리한다.
BOM 없는 UTF-8 .ps1은 CP949로 읽힌다
에이전트 E2E 스크립트를 돌리다 이상한 일이 있었다. 분명히 있는 줄이 실행되지 않았다. 원인은 인코딩이었다. Windows PowerShell 5.1은 BOM 없는 UTF-8 파일을 시스템 코드 페이지(CP949)로 읽는다. 한글 주석의 마지막 바이트가 CP949 2바이트 문자의 앞 바이트로 해석되면서 뒤의 줄바꿈을 삼키고, 다음 줄이 주석의 일부가 된다. 오류도 없이 그 줄만 조용히 사라진다.
배포 스크립트는 전부 한글 주석이 달려 있고, 5.1에서 실행된다. 그래서 deploy 폴더의 .ps1은 모두 BOM 포함 UTF-8로 저장하고, _common.ps1 첫머리에 이 이유를 적어 두었다.
서비스로 돌기 전에 고쳐 둔 것들
워커 콘텐츠 루트는 실행 파일 폴더
이건 Phase 5에서 먼저 겪은 일이다. 워커를 다른 폴더에서 실행했더니 동작은 하는데 로그 파일이 하나도 안 남았다. .NET 일반 호스트의 콘텐츠 루트 기본값은 현재 폴더다. 다른 폴더에서 실행하면 appsettings.json 을 못 읽고, 그 안의 Serilog 설정도 빠진다. 연결 문자열은 user-secrets에서 오니까 나머지는 멀쩡히 돌아서 알아채기 어렵다. Windows 서비스로 돌면 현재 폴더가 실행 파일 폴더가 아니니 운영에서 똑같이 터질 문제였다.
// 콘텐츠 루트를 실행 파일 폴더로 고정한다. 기본값(현재 폴더)이면 다른 폴더에서 실행할 때
// appsettings.json 을 못 읽어 로그 설정 등이 조용히 빠진다
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
Args = args,
ContentRootPath = AppContext.BaseDirectory,
});
에이전트 로그 경로
메일 서버 PC에 에이전트를 설치하고 로그 폴더를 열어 봤더니 agent 폴더가 비어 있었다. 에이전트는 워커와 같은 실행 파일이라 appsettings.json 의 Serilog 파일 경로도 같다. 로그는 worker 폴더에 쌓이고 있었다. 한 PC에 둘이 같이 있지는 않지만, 로그를 볼 때 헷갈린다.
package-agent.ps1 이 zip에 넣는 운영 설정에 Serilog 파일 싱크(WriteTo 의 두 번째 항목) 경로만 agent\agent-.log 로 재정의하게 고쳤다. 실행 파일과 기본 설정은 그대로 두고 패키지 설정에서만 바꾼 것이다. 이 zip에는 API 키 원문이 들어 있어서, 스크립트가 zip을 만든 뒤 작업 폴더의 설정 파일은 지우고 "설치 후 zip을 지우라"는 안내를 출력한다.
첫 운영: NAT 루프백
대상 25개, 그룹 4개를 등록하고 점검을 돌렸다. 대부분 정상인데, 배포 서버 자신에 올라가 있는 두 사이트의 SSL 점검만 접속 시간 초과였다. 브라우저로는 잘 열리는 사이트다.
두 도메인 모두 배포 서버 자신의 공인 주소를 가리킨다. 배포 서버는 NAT 안쪽에 있다. 서버가 자기 공인 주소로 접속하면 패킷이 네트워크 장비까지 나갔다가 다시 안으로 돌아와야 하는데(NAT 루프백, 헤어핀 NAT), 이 장비는 그걸 지원하지 않는다. 바깥에서는 되고 안에서만 안 되는 이유다.
해결은 간단하다. 배포 서버의 hosts 파일에 그 도메인들을 127.0.0.1 로 넣었다. SSL 점검은 SNI에 도메인 이름을 그대로 실어 보내므로 IIS는 같은 사이트·같은 인증서로 응답한다. 사무실 안에서 브라우저로 처음 열 때 시간 초과가 났던 것도 같은 원인으로 보인다.
RBL 점검도 하나 정리했다. 5편에서 다룬 대로 공용 DNS로는 Spamhaus를 믿을 수 없다. 배포 서버의 DNS도 통신사 리졸버라 차단됐다. Spamhaus 무료 DQS 계정을 만들어 키를 RBL 설정에 암호화 저장하고 나서 다섯 목록 모두 정상으로 나왔다. 10월 5일 기준 사용 중인 점검 44개가 전부 정상이었다.
운영 화면을 재 보니 가로로 넘쳤다
운영 데이터가 들어가자 화면이 개발 때와 달라 보였다. 점검 대상 필터의 "검색" 버튼 글자가 세로로 한 글자씩 줄바꿈되고, 그 버튼 높이만큼 검색창이 늘어나 있었다. 이런 건 눈으로 몇 화면 보는 것으로는 다 못 찾는다. 그래서 측정하기로 했다.
운영 화면 17개를 1280px(데스크톱)과 375px(모바일) 로 열고, 스크립트로 세 가지를 쟀다.
- 가로 넘침:
document.documentElement.scrollWidth가 창 너비보다 큰가 - 버튼 줄바꿈: 버튼 높이가 44px를 넘는가
- 같은 줄 컨트롤의 높이 차
처음에는 한 페이지 안에 iframe으로 화면들을 띄워 한 번에 재려 했는데 안 됐다. 웹에 X-Frame-Options: DENY 를 걸어 두었기 때문이다. 보안 헤더가 제 역할을 한 셈이라, 탭을 이동하며 화면마다 쟀다.
찾은 문제와 수정은 이렇다.
| 문제 | 원인 | 수정 |
|---|---|---|
| 버튼 글자 세로 줄바꿈 | 좁은 줄에서 버튼이 줄어듦 | 공통 버튼 클래스에 whitespace-nowrap shrink-0, 검색 줄 items-center |
| 페이지 전체 가로 스크롤 (데스크톱 대상 수정 +127px, 모바일 3개 화면) | 표 마지막 열 머리글의 sr-only 가 스크롤 카드 밖으로 빠짐 |
표를 감싼 overflow-x-auto 카드에 relative |
| 대상 수정 표가 카드보다 넓음 | 점검 요약의 max-w-md truncate 가 열 최소 폭을 448px로 고정 |
line-clamp-2 break-words |
두 번째가 재미있었다. 표의 마지막 열(수정·삭제 버튼 열)은 머리글 글자를 화면에서 숨기고 스크린 리더용으로만 남기는 sr-only 를 쓴다. sr-only 는 position: absolute 다. 표를 감싼 카드는 overflow-x-auto 로 가로 스크롤을 자기 안에 가두는데, 카드에 위치 기준(position)이 없으니 절대 위치 요소의 기준이 카드 밖, 더 위의 조상이 된다. 그러면 그 요소는 카드의 스크롤 영역이 아니라 페이지 전체를 넓힌다. 보이지 않는 글자 하나가 페이지 전체에 가로 스크롤을 만든 것이다.
<div class="iw-card relative overflow-x-auto">
표를 감싼 카드 6곳에 relative 를 붙였고, 설계 문서에 "표를 감싸는 스크롤 카드에는 항상 relative"를 규칙으로 남겼다. 고친 뒤 17개 화면을 두 폭으로 다시 재서 세 항목 모두 걸리는 것이 없는 것을 확인했다.
만료 현황 화면 추가
대상을 25개 넣고 보니 대시보드의 "만료 임박(60일)"만으로는 부족했다. 인증서와 도메인이 언제 끝나는지 한 화면에서 전부 보고 싶었다. 그래서 /Expiry 만료 현황 화면을 추가했다.
- SSL 인증서·도메인 만료를 남은 일수 순으로
- 종류(전체·인증서·도메인) 탭과 그룹 필터
- 단계별 건수: 만료됨·심각·주의·여유·확인 불가. 기준은 화면 공통이 아니라 점검별 WarnDays·CriticalDays
- 남은 일수 막대 (1년이면 가득)
사이드바 "점검 대상" 아래에 메뉴를 두고, 대시보드 만료 임박 목록에 "전체 만료 현황 →" 링크를 달았다. 운영에서 인증서 27개, 도메인 5개가 표시됐다.
바깥에서 실제 요청으로 한 노출 점검
마지막으로 보안 검토를 했다. 코드를 읽는 검토가 아니라 배포 서버 네트워크 바깥에서 운영 주소에 실제 요청을 보내 무엇이 보이는지 확인했다.
보안 헤더와 키 보호
배포하면서 먼저 넣어 둔 것들이다.
web.config로Server·X-Powered-By헤더 제거- HSTS 365일, HTTPS 리디렉션
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: strict-origin-when-cross-origin- Data Protection 키 파일을 머신 범위 DPAPI로 암호화
마지막 항목은 이유가 있다. 점검 설정의 비밀값(DQS 키, Rspamd 비밀번호 등)과 로그인 쿠키가 모두 이 키로 보호된다. 키 파일이 평문 XML로 디스크에 있으면 파일을 읽는 것만으로 비밀값을 풀 수 있다. 웹(앱풀 계정)과 워커(LocalSystem)가 같은 키를 써야 하므로 사용자 범위가 아니라 머신 범위로 했다.
실제 요청 결과
| 요청 | 결과 |
|---|---|
appsettings*.json, web.config, *.dll, deps.json, .env, .git, logs |
모두 404 |
| 로그인이 필요한 화면, 상태 조회 JSON | 302 → 로그인 |
| 에이전트 API, 키 없음·틀린 키 | 401 |
| Swagger, health 같은 진단 경로 | 없음 |
| 오류 화면 | 요청 ID만 표시 |
| 소스 저장소 | 비로그인 404 (비공개) |
git 이력 전체도 훑었다. 설정 파일에는 자리표시자(__SET_IN_SECRETS__)만 있고 비밀값은 한 번도 커밋되지 않았다. 운영 설정, 에이전트 패키지, 개발용 Docker .env 는 git에서 빠져 있다. 에이전트 API는 7편에서 설명한 대로 담당 종류의 설정만 복호화해 내보내므로, RBL의 DQS 키는 바깥으로 나가지 않는다.
위조 방지 쿠키에 Secure가 없었다
응답 쿠키를 하나씩 보다가 걸린 게 있었다. 위조 방지 쿠키(.AspNetCore.Antiforgery.*)에 Secure 플래그가 없었다. HSTS가 걸려 있어 실제로 http로 나갈 일은 거의 없지만, 플래그가 없으면 브라우저는 http 요청에도 이 쿠키를 실어 보낼 수 있다. 로그인 쿠키도 기본값에 기대고 있어서 명시하지 않은 상태였다.
운영은 항상 Secure, 개발은 요청에 따르게 했다. 개발 기본 실행 주소가 http라서 개발까지 Always 로 하면 로컬에서 로그인이 안 된다.
// 쿠키 Secure: 운영은 항상(https 전용), 개발은 요청에 따름
var cookieSecure = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
builder.Services.ConfigureApplicationCookie(o =>
{
o.Cookie.Name = "InfraWatch.Auth";
o.Cookie.HttpOnly = true;
o.Cookie.SecurePolicy = cookieSecure;
// ...
});
builder.Services.AddAntiforgery(o => o.Cookie.SecurePolicy = cookieSecure);
배포 후 위조 방지 쿠키가 secure; samesite=strict; httponly 로 나가는 것과, 기존 로그인 세션이 그대로 유지되는 것을 확인했다. 연재에서 다룬 작업은 여기까지다.
연재를 마치며
솔루션 골격을 세운 Phase 0부터 운영 배포 뒤 보안 검토까지의 기록을 8편에 나눠 정리했다.
- 인증서·도메인 만료를 메일로 알려 주는 「InfraWatch」 개발기
- SslStream으로 인증서를 직접 읽는다 — STARTTLS 메일 포트까지 SSL 점검
- .kr은 WHOIS, 나머지는 RDAP — 도메인 만료일 자동 조회
- SPF 조회 10회, DMARC는 상위 도메인까지 — 메일 DNS 레코드 점검과 변경 감지
- 공용 DNS로 RBL을 조회하면 "미등재"를 믿을 수 없다 — 블랙리스트 점검
- 같은 알림은 한 번만, 복구는 꼭 — 상태 전이 알림 규칙 엔진
- SSH 대신 API로 — 메일 서버 PC 에이전트와 Postfix 큐·Rspamd 감시
- 운영 배포와 첫날 점검 (이 글)
돌아보면 시간이 가장 많이 든 곳은 코드가 아니라 가정이 틀린 지점이었다. 메일 서버가 리눅스가 아니었고, 공용 DNS의 "미등재"는 믿을 수 없었고, 서버가 자기 주소로 접속하지 못했고, 보이지 않는 글자 하나가 화면을 넓혔다. 그때마다 실제로 요청을 보내고, 화면을 재고, 결과를 진행 기록에 남겼다. 이 연재도 그 기록을 다시 읽으며 썼다.
앞으로 할 것
설계 문서의 "범위 외(2차)" 목록이 그대로 다음 할 일이다.
- 알림 채널 추가 — 메일 말고 텔레그램·카카오·슬랙. 지금은 메일 하나라 메일 서버가 멈추면 알림도 같이 멈춘다
- HTTP 감시 — 응답 코드, 키워드, 응답 시간. 지금은 인증서만 보고 페이지가 실제로 뜨는지는 보지 않는다
- 공개 상태 페이지와 다중 사용자·권한 — 지금은 관리자 1인용이다
- Dovecot 내부 지표, 디스크·CPU 감시 — 에이전트 구조가 생겼으니 메일 서버 PC 쪽 지표는 붙이기 쉬워졌다
- 인증서 자동 갱신 연동 — 만료를 알려 주는 데서 한 걸음 더
멀티테넌트 메일 서버 쪽에서 이번에 일부러 뺀 것도 있다. 큐를 도메인별로 나눠 집계하는 것, 에이전트를 여러 대 두는 것, 테넌트 도메인과 공용 인증서 SAN을 대조하는 것이다. 에이전트 명령 실행의 따옴표 제약(공백 있는 경로)도 실행 방식을 바꿔 풀 수 있다.
지금 InfraWatch는 매일 아침 일일 요약 메일을 보내고, 무언가 바뀌면 한 번 알린다. 다음 개선은 그 메일이 처음으로 무언가를 잡아냈을 때 다시 정리해 보겠다.