연재 1편에서 정한 InfraWatch의 점검 중 두 가지는 바깥에서 볼 수 없다. Postfix 메일 큐가 얼마나 쌓였는지, Rspamd가 오늘 몇 통을 검사했는지다. SMTP 포트가 응답한다고 메일이 잘 나가고 있다는 뜻은 아니다. 받는 쪽 서버가 연결을 끊어도 Postfix는 "나중에 다시"라며 큐에 쌓아 두고, 그동안 SMTP 점검은 계속 정상이다. 그래서 큐를 직접 들여다봐야 했다.
지난 글에서 만든 알림 규칙 엔진에 이 두 점검을 얹는 것이 Phase 8의 일이었다. 그런데 처음 설계한 방법을 그대로 쓸 수 없었다. 이 글은 그 계획을 바꾼 이야기다.
처음 계획은 SSH였다
처음 설계 문서에 정리한 Postfix 큐 점검 방식은 이랬다.
- 메인 워커가 SSH.NET으로 메일 서버에 접속해
docker exec <Postfix 컨테이너> postqueue -j를 실행한다 - 전용 계정, 키 인증만 허용,
authorized_keys의command=로 이 명령 하나만 허용한다 - Rspamd는 컨트롤러 API(11334)의
/stat을 배포 서버에서 HTTP로 읽는다
메일 서버가 리눅스 서버이고 배포 서버에서 바로 닿는다는 가정이었다. Phase 8을 시작하기 전에 메일 서버 환경을 다시 확인했더니 가정이 셋 다 틀렸다.
| 가정 | 실제 |
|---|---|
| 리눅스 서버 | 배포 서버와 다른 Windows PC, 메일 서버 4종(Postfix·Dovecot·Rspamd·Redis)은 Docker Desktop의 Linux 컨테이너 |
| SSH 접속 가능 | OpenSSH Server 미설치 |
| Rspamd 11334 접근 가능 | 컨트롤러가 그 PC의 127.0.0.1 에만 바인딩 |
SSH를 쓰려면 메일 서버 PC에 OpenSSH Server를 깔고, 방화벽을 열고, 명령 제한을 Windows 방식으로 다시 짜야 한다. Rspamd는 더 곤란하다. 루프백에만 열려 있고 비밀번호도 없으니 지금은 안전하다. 바깥에서 읽으려고 이걸 외부에 열면 비밀번호와 방화벽 규칙을 새로 챙겨야 한다. 감시 도구 하나 때문에 운영 메일 서버의 공격 면을 넓히는 셈이다.
그래서 방향을 바꿨다. 메일 서버 PC 안에서 점검을 실행하고, 결과만 밖으로 보낸다. 큐는 그 PC에서 docker exec 로 바로 읽고, Rspamd는 그 PC의 127.0.0.1 로 읽는다. 메일 서버 PC에는 새로 여는 포트가 없다. 필요한 건 배포 서버 443으로 나가는 연결 하나다.
같은 워커를 에이전트 모드로
메일 서버 PC에 둘 프로그램을 새로 만들지 않았다. 점검 코드는 이미 워커 안에 있다. 그래서 같은 워커 실행 파일에 설정 하나로 켜는 에이전트 모드를 넣었다.
if (agentOptions.Enabled)
{
// 에이전트 모드 (메일 서버 PC): DB 없이 웹 API 로 할 일을 받고 결과를 올린다
builder.Services.AddSingleton<ISecretProtector, PassThroughSecretProtector>();
builder.Services.AddInfraWatchChecks();
builder.Services.AddHttpClient(AgentService.HttpClientName, c => AgentService.ConfigureClient(c, agentOptions));
builder.Services.AddWindowsService(o => o.ServiceName = agentOptions.ServiceName);
builder.Services.AddHostedService<AgentService>();
}
else
{
// 메인 워커 (배포 서버)
builder.Services.AddInfraWatchData(builder.Configuration);
// ...
}
Agent:Enabled=true 면 DB 등록도 Data Protection 등록도 하지 않는다. 점검 실행기(AddInfraWatchChecks)와 HTTP 클라이언트만 있다. Windows 서비스 이름도 InfraWatchAgent 로 따로 쓴다.
에이전트가 DB에 직접 붙지 않게 한 이유는 두 가지다. 운영 DB의 앱 계정은 배포 서버 localhost에서만 접속되게 만들어 두었다. 에이전트를 위해 DB를 바깥에 열고 싶지 않았다. 또 점검 설정의 비밀값은 Data Protection 키로 암호화돼 있는데, 그 키를 메일 서버 PC로 복사하면 키가 두 곳에 놓인다. 그래서 에이전트는 웹 API로만 할 일을 받고 결과를 올린다.
웹 /api/agent ◀── HTTPS + API 키 ── InfraWatchAgent (메일 서버 PC)
├ GET due 맡은 점검 내주기 (10분 예약)
├ POST results 결과 저장 + 알림 판단
└ POST heartbeat 대시보드 워커 목록에 "<머신명>-agent" 로 표시
에이전트 루프는 15초마다 돈다. 60초마다 하트비트를 보내고, due 로 받은 점검을 동시에 4개까지 실행해 results 로 올린다. 웹에 닿지 않으면 로그를 한 번만 남기고 다음 주기에 다시 시도한다. 같은 오류를 15초마다 반복해서 쓰지 않게 failing 플래그를 둬서, 복구될 때 "웹 API 연결 복구"를 한 줄 남긴다.
어떤 종류를 에이전트가 맡을지는 설정 화면에서 고른다(Agent.CheckTypes, 기본 Postfix 큐·Rspamd). 메인 워커 스케줄러는 이 종류를 빼고 실행 대상을 고른다. 같은 점검을 두 곳에서 돌리지 않기 위해서다.
if (excludeTypes is { Count: > 0 })
{
var excluded = excludeTypes.ToList();
query = query.Where(c => !excluded.Contains(c.CheckType));
}
"에이전트가 맡은 종류는 메인 스케줄러가 실행하지 않는다"는 DB 통합 테스트로 확인했다.
API 키는 해시만, 내준 점검은 10분 예약
에이전트 API는 로그인 쿠키를 쓸 수 없다. 그래서 컨트롤러에 [AllowAnonymous] 와 [IgnoreAntiforgeryToken] 을 붙이고 별도 인증 필터를 달았다. 규칙은 이렇다.
- 키는 32바이트 난수. 워커의
--new-agent-key명령이 원문과 해시를 출력하고, 어디에도 저장하지 않는다 - 웹 설정에는 SHA-256 해시만 넣는다. 원문은 에이전트 설정에만 있다
- 요청의
Authorization: Bearer <키>를 해시해서 비교한다. 비교는FixedTimeEquals - 해시가 설정돼 있지 않으면 API 자체를 끈다(404). 실패는 접속 IP와 함께 로그에 남긴다
var header = context.HttpContext.Request.Headers.Authorization.ToString();
var key = header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header[7..].Trim() : "";
var actual = key.Length == 0 ? "" : AgentApi.HashKey(key);
if (actual.Length == 0 || !CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(actual), Encoding.ASCII.GetBytes(expected)))
{
// 접속 IP·경로를 로그에 남기고 401
context.Result = new UnauthorizedResult();
}
웹 서버가 털려도 해시만으로는 에이전트 행세를 할 수 없다. 키가 새면 새 키를 만들어 웹 해시와 에이전트 원문을 함께 바꾸면 이전 키는 바로 무효가 된다.
due 에는 신경 쓸 부분이 하나 더 있다. 에이전트가 점검을 받아 가서 결과를 올리기 전에 다음 due 요청이 오면 같은 점검을 또 내줄 수 있다. 그래서 내주는 순간 그 점검들의 NextRunAt 을 10분 뒤로 미뤄 둔다.
var due = await checks.GetDueAsync(now, Math.Clamp(max, 1, 50), ct, onlyTypes: types);
await checks.LeaseAsync(due.Select(c => c.Id).ToList(), now + Lease, ct); // Lease = 10분
결과가 오면 저장하면서 다음 실행 시각이 정상 주기로 다시 정해진다. 에이전트가 죽어서 결과가 안 오면 10분 뒤 다시 내준다. 별도의 "예약 테이블" 없이 기존 컬럼 하나로 끝낸 셈이다.
due 응답의 설정은 비밀값을 웹이 복호화한 평문이다. 에이전트는 Data Protection 키가 없으니 그렇게 넘길 수밖에 없다. 그래서 운영은 반드시 https 주소를 쓰고, due 는 에이전트가 맡은 종류의 설정만 복호화해 보낸다. 예를 들어 RBL 점검의 Spamhaus DQS 키는 메인 워커 전용이라 이 API로 나가지 않는다.
results 는 거꾸로 받는 쪽에서 막는다. 에이전트 담당 종류가 아니거나 이미 삭제된 점검의 결과는 거부하고, 한 번에 100건까지만 처리하며, 상세 JSON이 64KB를 넘으면 버린다. 에이전트 쪽은 올리지 못한 결과를 메모리에 들고 있다가 다음 주기에 다시 올린다. 웹 재시작이나 잠깐의 장애에 대비한 것이고, 200건을 넘으면 오래된 것부터 버린다.
결과 처리를 한 곳으로 — Processing 프로젝트
에이전트가 올린 결과도 메인 워커가 직접 실행한 결과와 똑같이 처리돼야 한다. 연속 실패 수, 만료일 유지, 상태 JSON, 그리고 6편의 알림 판단까지. 그런데 이 코드는 워커 프로젝트 안에 있었고, 웹이 워커를 참조하는 건 구조상 맞지 않았다.
그래서 InfraWatch.Processing 프로젝트를 새로 만들었다. 결과 반영 규칙(CheckRunRecorder), 후처리 확장 지점(ICheckRunObserver), 알림 후처리(AlertObserver)를 워커에서 옮기고, 이것들을 묶는 CheckRunProcessor 를 두었다.
/// 메인 워커 스케줄러와 에이전트 결과 API 가 같은 경로를 쓴다.
public async Task<bool> ProcessAsync(Check before, CheckRunResult run, CancellationToken ct)
{
var update = CheckRunRecorder.ToUpdate(before, run, timeProvider.GetUtcNow().UtcDateTime);
if (!await checks.SaveRunAsync(before.Id, update, CancellationToken.None))
return false; // 실행 중에 점검이 삭제됨
foreach (var observer in observers)
await observer.OnCheckCompletedAsync(before, run, update, ct); // 실제 코드는 관찰자별 예외를 로그만 남기고 계속
return true;
}
참조 관계는 Web → Processing, Worker → Processing, Processing → Data·Checks·Notifications·Core가 됐다. 설계 문서 4장의 프로젝트 구조에도 이 줄을 추가했다. 결과 경로가 하나라서, 에이전트가 올린 큐 적체도 메인 워커의 SSL 만료와 같은 규칙으로 알림이 나간다.
postqueue -j와 Rspamd 읽기
큐: 지연 사유는 IP·포트를 지우고 묶는다
postqueue -j 는 큐에 있는 메시지마다 JSON 한 줄을 낸다. queue_name(active·deferred·hold 등), arrival_time(유닉스 시각), 받는 사람별 delay_reason 이 들어 있다. PostqueueJsonParser 는 줄마다 파싱해서 전체 건수, 큐별 건수, 가장 오래 기다린 메시지의 대기 시간, deferred 지연 사유 상위 5개를 만든다. JSON이 아닌 줄은 버리지 않고 따로 모은다. postqueue: warning: ... 같은 경고가 섞여 나오는 경우가 있어서다.
지연 사유는 그대로 묶으면 쓸모가 없다. 같은 이유로 막혀도 상대 서버 IP나 포트가 달라 전부 다른 문자열이 된다. 그래서 묶기 전에 가변 부분을 지운다.
/// "connect to mx.example.com[1.2.3.4]:25: Connection timed out" → "connect to mx.example.com: Connection timed out"
public static string NormalizeReason(string reason)
{
var r = IpInBrackets().Replace(reason, ""); // \[[0-9a-fA-F:.]+\]
r = PortSuffix().Replace(r, ":"); // :\d+:
r = MultiSpace().Replace(r, " ").Trim();
return r.Length > 160 ? r[..160] + "…" : r;
}
테스트에서는 IP가 다른 두 건이 "connect to mx.example.com: Connection timed out 2건"으로 묶이는지 확인했다. 판정은 건수와 최장 대기 시간 중 더 심각한 쪽이다. 기본값은 50건·30분이면 주의, 200건·120분이면 심각이다. 이 경계값들은 [Theory] 하나로 다섯 경우를 확인했다.
Rspamd: 누적값 대신 증가량
Rspamd /stat 의 scanned 는 Rspamd가 켜진 뒤의 누적값이다. 그래프에 누적값을 그리면 계속 올라가는 선 하나라 볼 것이 없다. 그래서 측정값은 직전 점검 대비 증가량으로 했다. 직전 값은 점검의 상태 JSON에 넣어 둔다.
var previous = ReadPreviousScanned(context.PreviousStateJson);
var delta = previous is { } p && stats.Scanned >= p ? stats.Scanned - p : stats.Scanned;
Rspamd가 재시작하면 누적값이 0부터 다시 시작해 직전 값보다 작아진다. 이때 음수를 쓰지 않고 현재 값을 증가량으로 본다. Rspamd 점검은 통계 표시가 목적이라 접속 실패만 오류, 나머지는 정상으로 둔다.
에이전트 모드에서는 로컬 실행
Postfix 큐 점검에는 실행 방식 설정을 두었다. 기본은 Local 이다. 에이전트가 Windows면 cmd.exe /d /c, 아니면 /bin/sh -c 로 명령을 실행하고, 취소되면 프로세스 트리를 통째로 종료한다. Ssh 방식도 지우지 않고 남겼다. 키 인증과 호스트 키 지문 고정까지 넣었고, SSH일 때만 호스트·사용자를 필수로 검증한다.
운영에서 만난 함정 세 가지
열거형이 숫자로 가서 400
E2E를 돌리자 에이전트 로그에 웹 API 호출 실패 ... 400 (Bad Request) 가 찍혔다. 에이전트는 상태(CheckStatus)와 종류(CheckType)를 JsonStringEnumConverter 로 문자열로 보내는데, 웹의 MVC JSON 설정에는 이 변환기가 없어서 숫자만 받으려 했다. 웹에 한 줄을 넣어 해결했다.
// 열거형은 이름 문자열로 주고받는다 (에이전트 API·상태 조회 JSON)
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
양쪽이 같은 계약 타입(AgentCheck, AgentResult)을 Core에서 공유하더라도, 직렬화 설정까지 같다는 보장은 없다는 걸 확인한 일이다.
서비스 계정의 PATH에는 docker가 없다
메일 서버 PC에 에이전트를 설치하자 큐 점검이 실패했다. 에이전트 서비스는 LocalSystem으로 돈다. Docker Desktop은 관리자 사용자 프로필 아래에 설치돼 있어서, 그 사용자의 PATH에만 docker 가 있었다. 서비스 계정에서는 docker 를 찾지 못한다. 실행 명령을 docker.exe 전체 경로로 바꿔서 해결했다. Docker Desktop을 다시 설치하거나 옮기면 이 경로도 갱신해야 해서 운영 메모에 남겼다.
cmd 따옴표 이스케이프
전체 경로에 공백이 있으면 따옴표로 감싸면 될 것 같지만 그렇지 않다. CommandRunner 는 ProcessStartInfo 의 ArgumentList 로 cmd.exe 에 /d, /c, 명령을 넘긴다. ArgumentList 는 각 인수를 안전하게 이스케이프하는데, 그 과정에서 명령 안의 " 가 \" 로 바뀐다. cmd는 \" 를 이스케이프로 해석하지 않는다. 그래서 지금 구조에서는 공백 있는 경로를 따옴표로 감쌀 수 없다. 공백 없는 경로나 8.3 짧은 이름을 쓰는 것으로 정리하고 알려진 제약으로 적어 두었다.
멀티테넌트 메일 서버의 테넌트 도메인 자동 등록
이 메일 서버는 한 인스턴스가 여러 회사 도메인을 함께 처리하는 멀티테넌트 구조다. 호스트명과 인증서는 공용이고, DKIM 키는 도메인마다 따로 있다. 새 고객사 도메인이 메일 서버에 추가될 때마다 InfraWatch에 대상을 손으로 등록하는 건 잊기 쉬운 일이다. 그래서 에이전트가 이 일을 하게 했다.
여러 가지를 할 수 있었지만 범위는 테넌트 도메인 자동 등록 하나로 정했다. 큐를 도메인별로 집계하거나, 에이전트를 여러 대 두거나, 인증서 SAN을 대조하는 건 하지 않았다.
흐름은 이렇다.
- 에이전트 설정에 메일 서버 도메인 DB의 읽기 전용 연결 문자열이 있을 때만 동작한다
- 에이전트가 테넌트 메일 도메인 목록(도메인, 활성 여부)을 읽어
POST /api/agent/domains로 보낸다 - 웹이 순수 함수
TenantDomainSyncPlanner.Plan으로 계획을 세우고, 서비스가 그대로 적용한다
계획 규칙은 보수적으로 잡았다.
| 경우 | 처리 |
|---|---|
| 활성 도메인, 자동 대상 없음 | 대상 생성 + DNS(DKIM 셀렉터 포함)·도메인 만료 점검 |
| 활성 도메인, 자동 대상이 꺼져 있음 | 켠다 |
| 활성 도메인, 같은 호스트의 수동 대상이 있음 | 건드리지 않음 (건너뜀으로 보고) |
| 자동 대상인데 비활성이거나 목록에서 사라짐 | 끄기만 한다 (삭제 안 함) |
| 수동 대상 | 어떤 경우에도 바꾸지 않음 |
자동 대상은 Targets.Source 컬럼으로 구분한다. 값이 메일 서버 식별자면 자동 대상이고, 비어 있으면 내가 손으로 등록한 대상이다. 끄기만 하고 지우지 않는 이유는 이력과 알림 기록을 남기기 위해서다. 도메인이 다시 활성화되면 그 대상을 다시 켠다.
가장 조심한 건 빈 목록이다. DB 조회가 잘못돼 빈 목록이 오면 "모든 도메인이 사라졌다"로 해석돼 자동 대상이 전부 꺼진다. 그래서 에이전트는 빈 결과를 보내지 않고, 웹도 빈 목록이 오면 400으로 거부한다. 두 겹으로 막았다.
if (report.Domains.Count == 0)
{
logger.LogWarning("에이전트 {Source} 가 빈 테넌트 도메인 목록을 보냈습니다 — 무시합니다.", report.Source);
return BadRequest("빈 도메인 목록은 받지 않습니다.");
}
플래너가 DB를 모르는 순수 함수라서 규칙은 단위 테스트로 바로 확인할 수 있었다. 대소문자·끝 점 정리, 수동 대상 건너뛰기, 사라진 자동 대상 끄기, 다시 활성화되면 켜기, 다른 출처의 자동 대상은 건드리지 않기를 각각 테스트로 두었다. 마지막 동기화 결과("도메인 n개(활성) · 추가 · 켬 · 끔")는 설정 화면에 표시된다.
E2E는 가짜 postqueue와 파이썬 http.server로
메일 서버 PC에 설치하기 전에 개발 PC에서 전체 흐름을 먼저 확인했다. 진짜 메일 큐와 Rspamd는 없으니 둘 다 가짜로 만들었다.
- 가짜 postqueue:
postqueue -j형식의 JSON 줄을 담은 파일을 만들고, 점검의 실행 명령을type <파일 경로>로 바꿨다. 에이전트 입장에서는 명령을 실행해 표준 출력을 읽는 것이라 진짜와 구별이 없다 - 가짜 Rspamd:
/stat이라는 이름의 JSON 파일을 둔 폴더를python -m http.server로127.0.0.1에 띄웠다
이 상태로 웹·메인 워커·에이전트를 띄우고 웹에서 "지금 점검"을 눌렀다. 확인한 것은 이렇다.
- 에이전트가 점검을 받아 실행하고 결과를 업로드한다
- 웹이 저장하고 알림 판단까지 거친다
- 대시보드의 메일 큐·Rspamd 위젯에 값이 채워지고, 워커 목록에 두 개(메인 워커,
-agent)가 보인다 - 메인 워커는 해당 점검을 실행하지 않는다
- 키 없이, 틀린 키로 API를 부르면 401
앞의 400 오류도 이 E2E에서 잡았다. 실제 메일 서버 PC 확인은 운영 배포 때 했고, 위의 docker PATH 문제가 그때 나왔다. 설치 후 대시보드 워커 목록에 에이전트가 "실행 중"으로 보이고 큐·Rspamd 위젯이 채워지는 것으로 마무리했다.
SSH 방식은 README에 대안으로만 남겼다. 쓰려면 메일 서버 PC에 OpenSSH Server를 설치하고, sshd_config 의 Match User + ForceCommand 로 명령 하나만 허용하고, 관리자가 아닌 전용 계정을 쓰고, 22번은 배포 서버에서만 열고, 호스트 키 지문을 고정하라는 정도의 보안 권고다. Rspamd는 SSH로 볼 수 없어서 이 방식은 큐 전용이다.
정리
- 메일 서버가 SSH 없는 다른 Windows PC이고 Rspamd가 루프백에만 열려 있어서, SSH 원격 실행 대신 메일 서버 PC 안에서 실행하고 결과만 보내는 구조로 바꿨다. 새로 여는 포트는 없다.
- 에이전트는 같은 워커 실행 파일의
Agent:Enabled=true모드. DB·Data Protection 없이 웹 API(due·results·heartbeat)로만 통신한다. - 인증은 Bearer API 키. 웹은 SHA-256 해시만 갖고
FixedTimeEquals로 비교한다. 내준 점검은NextRunAt을 10분 미뤄 예약하고, 올리지 못한 결과는 200건까지 들고 있다. - 결과 처리를 Processing 프로젝트로 빼서 메인 워커와 에이전트가 같은 저장·알림 경로를 쓴다.
- 큐 지연 사유는 IP·포트를 지우고 묶고, Rspamd는 누적값 대신 증가량을 본다.
- 함정은 열거형 문자열 직렬화(400), 서비스 계정 PATH의 docker, cmd 따옴표 이스케이프.
- 테넌트 도메인 자동 등록은 순수 플래너, 수동 대상 보호, 끄기만, 빈 목록 거부.
다음 8편 (10월 10일 공개)은 연재 마지막 글이다. 운영 서버에 배포하고 첫날 화면과 외부 노출을 점검한 이야기를 한다.