포트가 열렸다는 사실에서 서비스가 정상이라는 판단까지

포트 감시 OPS: 서비스 상태 확인과 안전한 자동 복구의 핵심 흐름도
그림. OPS는 여러 상태 신호를 결합하고 제한된 복구만 수행한다.

포트 감시가 필요한 이유

여러 웹서비스와 AI 서버를 한 맥에서 운영하면 각 서비스는 서로 다른 로컬 포트를 사용한다. 프로세스 하나가 종료되거나, 포트는 열렸지만 응답이 멈추거나, SSH 터널만 끊겨 외부에서 접근할 수 없는 장애가 생길 수 있다.

터미널에 접속해 `ps`, `lsof`, `curl`, 로그를 매번 확인할 수는 있다. 그러나 외부에 있을 때 휴대전화에서 전체 상태를 한눈에 보고, 허용된 서비스만 재시작할 수 있으면 복구 시간이 짧아진다.

이 역할을 하는 작은 운영 시스템이 OPS다.

휴대전화
  ↓ HTTPS + 클라이언트 인증서
IDC Nginx
  ├─ 정적 OPS 화면
  └─ /api → IDC 로컬 포트
              ↓ 리버스 SSH 터널
            맥 OPS 에이전트
              ├─ 로컬 포트 점검
              ├─ 외부 HTTPS 점검
              ├─ 하트비트와 로그 읽기
              └─ 허용된 서비스 재시작

왜 화면은 IDC에 두는가

관제 대상인 맥의 웹 프로세스가 모두 멈추면 맥에서 제공하는 관제 화면도 같이 사라질 수 있다. 그래서 OPS 화면은 IDC에 정적 파일로 배포한다. 맥의 Next 개발 서버가 꺼져도 화면 자체는 열린다.

IDC의 Nginx는 정적 화면을 직접 제공하고 `/api` 요청만 맥 에이전트로 전달한다. 맥 에이전트가 응답하지 않으면 화면은 남아 있으므로 "관제 화면 자체 장애"와 "맥 에이전트 연결 장애"를 구분할 수 있다.

관제 UI와 관제 대상의 장애 영역을 분리하는 것이 이 설계의 첫 번째 이유다.

한 가지 검사만으로는 부족하다

서비스 정상 여부에는 여러 층이 있다.

검사알 수 있는 것알 수 없는 것
프로세스 검색프로세스 이름이 존재한다.요청을 처리할 수 있는지는 모른다.
TCP 포트 연결해당 주소와 포트가 연결을 받는다.올바른 HTTP 응답과 데이터인지는 모른다.
HTTP health애플리케이션이 요청에 응답한다.외부 DNS·TLS·Nginx 경로는 모른다.
외부 HTTPS사용자가 보는 전체 경로가 동작한다.내부 어느 계층이 원인인지는 모른다.
하트비트워치독이 최근 상태를 기록했다.기록 직후 발생한 장애는 놓칠 수 있다.

OPS는 이 신호를 겹쳐 본다. 로컬 포트가 DOWN이면 프로세스 또는 바인딩 문제일 가능성이 높다. 로컬 포트는 UP인데 외부 HTTPS가 실패하면 SSH 터널, IDC Nginx, TLS, DNS 경로를 먼저 의심한다.

로컬 포트 점검

에이전트는 짧은 timeout으로 `127.0.0.1:port`에 TCP 연결을 시도한다. IPv4가 실패하면 IPv6 루프백도 검사할 수 있다.

import socket


def port_open(port: int, host: str = "127.0.0.1") -> bool:
    try:
        with socket.create_connection((host, port), timeout=1.2):
            return True
    except OSError:
        return False

연결에 성공하면 해당 순간에 어떤 프로세스가 포트를 받고 있다는 뜻이다. 포트가 열렸다고 서비스의 기능 전체가 정상인 것은 아니다. 잘못된 프로세스가 같은 포트를 차지했을 수도 있고, HTTP 처리 스레드는 멈췄을 수도 있다.

따라서 중요한 서비스에는 `/health/live`와 `/health/ready` 같은 HTTP 상태 확인을 추가하는 것이 좋다. live는 프로세스 생존, ready는 DB·모델 등 요청 처리 준비 상태를 나타낸다.

감시 대상 목록

OPS의 상태 스냅샷은 서비스 ID, 표시 이름, 로컬 포트, 상태, 외부 주소, 하트비트 경로를 하나의 목록으로 만든다.

{
    "id": "game-web",
    "name": "게임 웹",
    "port": 3000,
    "up": port_open(3000),
    "public": "https://game.example.com/",
}

웹서비스뿐 아니라 WebSocket 브리지, VLM 모델 서버, SSH 터널, OPS 에이전트 자체도 같은 형식으로 본다. 포트가 없는 항목은 프로세스나 별도 HTTP 검사로 상태를 만든다.

이 목록을 코드 여러 곳에 반복하기보다 설정 데이터로 분리하면 새 서비스를 추가할 때 화면, 상태 API, 재시작 별칭을 한곳에서 관리할 수 있다. 그러나 재시작 명령까지 외부 설정에서 자유롭게 읽어 실행하면 명령 주입 위험이 생긴다. 상태 메타데이터와 실행 허용 목록은 분리하는 편이 안전하다.

외부 HTTPS 점검

사용자가 실제로 여는 HTTPS 주소에 요청을 보내 상태 코드와 응답 시간을 기록한다.

from time import monotonic
from urllib.request import Request, urlopen


def probe(url: str) -> dict:
    started = monotonic()
    request = Request(url, headers={"User-Agent": "ops-probe/1.0"})
    with urlopen(request, timeout=4) as response:
        return {
            "ok": 200 <= response.status < 500,
            "status": response.status,
            "ms": round((monotonic() - started) * 1000),
        }

위 코드는 구조 예시다. 실제 정상 기준은 서비스마다 정한다. 로그인 페이지가 401을 돌려주는 것이 정상일 수 있고, 리다이렉트가 잘못된 호스트로 가면 3xx라도 장애다. 가장 확실한 방식은 인증이 필요 없는 작은 전용 health endpoint가 기대한 본문과 상태 코드를 반환하게 하는 것이다.

외부 점검을 UI의 짧은 폴링마다 모든 도메인에 실행하면 요청이 폭증하고 화면 응답도 느려진다. OPS는 가벼운 화면 폴링에서는 로컬 포트 중심으로 확인하고, 사용자가 강제 상태 점검을 요청할 때 외부 HTTPS와 하트비트를 더 읽는 방식으로 비용을 나눈다.

상태 캐시와 동시 요청

관제 화면이 몇 초마다 새로고침되고 여러 탭이 열리면 같은 포트와 HTTPS를 반복 점검한다. 에이전트는 짧은 TTL의 상태 캐시를 두어 동시에 들어온 요청이 같은 결과를 재사용하게 한다.

첫 요청     실제 상태 수집 → 캐시 저장
8초 안 요청 캐시 반환
TTL 이후    다시 수집

상태 캐시에는 락이 필요하지만 느린 네트워크 I/O를 락 안에서 실행하면 다음 요청이 모두 기다린다. 캐시 확인과 저장만 락으로 보호하고, 포트·HTTP 점검은 락 밖에서 수행한다.

캐시는 화면 부하를 줄이는 장치다. 오래된 상태를 정상이라고 오해하지 않도록 응답에 측정 시각을 포함하고 UI에서 표시한다.

하트비트

각 서비스의 워치독은 최근 점검 결과를 하트비트 파일에 기록한다. OPS도 전체 상태를 주기적으로 수집해 자신의 하트비트를 남긴다.

# OPS agent — 측정 시각
- overall: OK 또는 DOWN
- 게임 웹: up
- 모델 서버: DOWN
- SSH 터널: up

하트비트에는 세 가지 시간이 구분되어야 한다.

  • 파일이 마지막으로 갱신된 시각
  • 하트비트 안에 기록된 실제 측정 시각
  • OPS가 그 파일을 읽은 시각

파일이 존재한다는 이유만으로 정상이라 판단하지 않는다. 현재 시각과의 차이가 허용 범위를 넘으면 stale 상태로 표시한다.

SSH 터널 감시

맥 서비스는 리버스 SSH 터널을 통해 IDC 로컬 포트와 연결된다. 터널 상태는 SSH 프로세스 존재, 원격 포워딩 포트, 실제 HTTP 요청을 각각 확인할 수 있다.

SSH 프로세스 있음
  ↓
IDC 로컬 포트 연결 가능
  ↓
IDC Nginx를 통한 외부 HTTPS 성공

프로세스만 남고 연결이 죽은 반쪽 상태가 있을 수 있다. `ServerAliveInterval`, `ServerAliveCountMax`, `ExitOnForwardFailure`를 사용해 실패한 SSH가 종료되게 하고 워치독이 다시 연결하게 한다.

터널 재시작 요청은 OPS가 임의 SSH 명령을 조합하는 방식이 아니라, 정해진 플래그 파일을 만들거나 전용 워치독에 신호를 전달하는 방식이 안전하다.

자동 복구는 허용 목록으로

운영 API가 사용자의 문자열을 그대로 `subprocess(..., shell=True)`에 넘기면 원격 명령 실행 취약점이 된다. OPS는 인식 가능한 대상과 동작을 코드의 허용 목록으로 제한한다.

RESTARTERS = {
    "game-web": restart_game_web,
    "model": restart_model,
    "tunnel": request_tunnel_restart,
}


def restart_target(target: str):
    handler = RESTARTERS.get(target)
    if handler is None:
        return {"ok": False, "error": "허용되지 않은 대상"}
    return handler()

한국어 명령을 받더라도 최종 결과는 정해진 서비스 ID 하나여야 한다. `바둑 재시작`, `game restart` 같은 별칭을 매핑할 수는 있지만 사용자가 보낸 나머지 문자열을 셸 명령에 붙이지 않는다.

재시작 전후에는 다음을 기록한다.

  • 누가 요청했는가
  • 어느 서비스를 요청했는가
  • 요청 시각과 이유
  • 재시작 방식
  • 포트가 다시 열린 시각
  • 최종 성공 또는 실패

워치독에 재시작을 맡기기

각 서비스가 자체 워치독을 가지고 있다면 OPS가 프로세스를 직접 죽이고 새 명령을 추측하는 것보다 `RESTART_REQUESTED` 같은 플래그를 남기는 편이 역할이 명확하다.

OPS → 재시작 플래그 작성
워치독 → 플래그 발견
워치독 → 기존 프로세스 정리
워치독 → 올바른 환경·작업 경로로 실행
워치독 → 플래그 제거와 하트비트 기록
OPS → 포트 재확인

워치독은 그 서비스의 Python 환경, Node 버전, 작업 디렉터리, 로그 위치를 이미 안다. OPS는 정책과 요청만 담당한다.

워치독 자체가 죽었을 때를 대비해 제한된 fallback 실행을 둘 수 있다. 이 경우에도 고정된 인자 배열을 사용하고, 경로 존재와 포트 상태를 확인한 뒤 실행한다. 같은 서비스를 두 번 띄우는 경쟁을 막기 위한 락이나 단일 서비스 관리자가 필요하다.

재시작 성공 판정

프로세스를 시작한 사실이 성공은 아니다. 정해진 시간 동안 포트를 기다리고, 가능하면 readiness까지 확인한다.

import time


def wait_port(port: int, seconds: float) -> bool:
    deadline = time.time() + seconds
    while time.time() < deadline:
        if port_open(port):
            return True
        time.sleep(1)
    return False

모델 서버는 모델 로딩에 시간이 오래 걸릴 수 있고 웹 서버는 더 짧을 수 있다. 대상별 timeout을 둔다. 실패 시 마지막 로그와 상태를 반환하되 비밀번호와 토큰은 마스킹한다.

죽은 서비스만 재시작하기

"죽은 것 재시작"은 편리하지만 무조건 모든 DOWN 항목을 동시에 올리면 CPU와 메모리가 몰릴 수 있다. 먼저 상태를 다시 강제 수집해 일시적인 오판을 줄이고, OPS 자체와 IDC 전용 서비스를 제외한 뒤 순서대로 처리한다.

AI 모델과 웹 서버의 의존 관계가 있다면 모델을 먼저 올리고 readiness 뒤 웹을 올린다. 반대로 웹이 모델 없이도 대기 화면을 제공한다면 웹을 먼저 복구할 수 있다. 복구 순서를 데이터로 명시한다.

자동 복구 횟수에는 제한을 둔다. 설정 오류로 계속 종료되는 프로세스를 빠르게 재시작하면 로그와 CPU를 소모한다. 일정 횟수 이후에는 failed 상태로 두고 사람에게 알린다.

OPS 웹 화면

UI는 상태 API를 주기적으로 읽어 각 항목을 UP 또는 DOWN 칩으로 보여 준다.

{
  "ts": "측정 시각",
  "ok": false,
  "note": "로컬 DOWN: model",
  "services": [
    {"id": "web", "name": "웹", "port": 3000, "up": true},
    {"id": "model", "name": "모델", "port": 9000, "up": false}
  ],
  "public": {
    "web": {"ok": true, "status": 200, "ms": 84}
  }
}

화면은 마지막 측정 시각, 전체 정상 여부, 로컬 서비스, 외부 HTTPS를 분리해 보여 준다. API 연결이 끊기면 이전 값을 계속 정상처럼 표시하지 않고 "에이전트 끊김"으로 바꾼다.

재시작 버튼은 클릭 즉시 성공으로 바꾸지 않는다. 요청 중 상태를 표시하고, API 결과와 재수집된 상태가 모두 성공일 때 복구로 표시한다. 중복 클릭도 막는다.

자연어 명령의 한계

OPS는 "상태 확인해줘", "터널 재시작해줘" 같은 짧은 문장을 정해진 의도로 분류할 수 있다. 이 기능은 LLM이 셸을 자유롭게 사용하는 에이전트가 아니다.

문장 → status | logs | restart
대상 → 허용된 서비스 ID
동작 → 미리 작성된 함수

모호한 요청은 대상을 다시 묻는다. "다 고쳐"처럼 범위가 큰 문장은 사전에 정의된 "현재 DOWN 항목만"으로 제한한다. 자연어가 명령 실행 범위를 넓혀서는 안 된다.

로그와 감사 기록

일반 실행 로그와 감사 로그를 분리한다.

로그내용
런타임 로그포트 점검 오류, HTTP 실패, 프로세스 시작 결과
감사 로그상태·로그·재시작 요청의 시각, 대상, 결과
서비스 로그각 웹·모델·터널 워치독의 상세 출력

감사 로그는 한 줄 JSON으로 기록하면 검색과 수집이 쉽다. 채팅 메시지는 길이를 제한하고 비밀값이 들어올 가능성을 고려해 전체 보관을 피하거나 마스킹한다.

로그 tail API는 임의 경로를 받지 않고 서비스 ID를 미리 정한 파일에만 매핑한다. `../../secret` 같은 경로 입력을 파일 시스템에 전달하지 않는다. 응답 크기와 줄 수에도 제한을 둔다.

휴대전화 접근 보안

관제 화면은 서비스 재시작 권한을 가진 관리자 화면이다. 일반 웹사이트와 같은 공개 인증만으로 두지 않는다.

이 구현에서는 IDC Nginx가 클라이언트 인증서를 확인하는 상호 TLS를 사용할 수 있다.

휴대전화가 서버 인증서를 확인한다.
IDC Nginx도 휴대전화의 클라이언트 인증서를 확인한다.
검증 성공 뒤에만 정적 화면과 /api를 연다.

클라이언트 인증서 개인키와 PIN은 소스 저장소에 넣지 않는다. 분실한 휴대전화의 인증서는 폐기하고 새 인증서를 발급한다. 인증서만으로 끝내지 않고 관리 URL 비공개화, 요청 속도 제한, 감사 로그와 방화벽을 함께 적용한다.

IDC Nginx가 인증을 끝낸 뒤 `/api`를 리버스 터널로 보내더라도, 맥 에이전트 포트가 LAN 전체에 열려 있으면 같은 LAN의 다른 장치가 인증을 우회할 수 있다. 에이전트는 가능하면 루프백이나 전용 VPN 주소에 바인딩하고, 필요한 피어만 방화벽으로 허용한다.

OPS가 임의 셸을 제공하면 안 되는 이유

웹에서 명령을 입력해 그대로 셸에서 실행하는 기능은 편리해 보이지만, 관제 시스템 탈취가 서버 전체 탈취로 이어진다. 따옴표와 필터로 막으려 해도 우회가 생긴다.

OPS의 동작은 다음 정도로 제한한다.

  • 상태 조회
  • 허용된 로그의 마지막 부분 조회
  • 허용된 서비스 재시작
  • 터널 워치독에 재연결 요청
  • 필요하면 읽기 전용 진단 결과 조회

파일 편집, 임의 프로세스 종료, 패키지 설치, 임의 URL 요청은 관제 API에서 제공하지 않는다. 필요한 새 동작은 코드 리뷰와 배포를 거쳐 허용 목록에 추가한다.

오탐과 자동 복구 폭주 막기

한 번의 timeout으로 바로 재시작하면 네트워크 순간 지연에도 정상 서비스를 죽인다. 상태를 다음처럼 나눌 수 있다.

UP       최근 검사 성공
DEGRADED 일부 검사 실패, 기능은 살아 있음
DOWN     연속 실패가 임계값을 넘음
UNKNOWN  에이전트 또는 측정 자체가 실패
RECOVERING 재시작 후 준비를 기다리는 중

연속 실패 횟수와 시간 창을 둔다. 로컬 포트와 외부 HTTPS가 서로 다른 결과이면 원인 계층을 먼저 표시한다. 자동 재시작은 로컬 DOWN처럼 복구 효과가 명확한 경우에만 적용하고, DNS·인증서 장애에는 재시작 대신 알림을 보낸다.

에이전트 자체의 안전성

에이전트 HTTP 서버가 느린 파일 시스템과 네트워크 점검 때문에 멈추지 않도록 요청을 스레드로 처리하고 I/O timeout을 둔다. 무한히 큰 요청 본문을 읽지 않도록 Content-Length 상한도 필요하다.

상태 수집에서 한 서비스의 예외가 전체 응답을 깨뜨리지 않게 항목별 오류를 격리한다. 에이전트 자체 health endpoint는 상태 수집과 분리해 프로세스 생존을 빠르게 응답한다.

파일 설명자 한도, 스레드 수, HTTP 요청 큐에도 상한이 필요하다. 관제 요청이 몰려 운영 대상보다 에이전트가 먼저 자원을 소모하면 안 된다.

개선할 수 있는 구조

작은 개인 운영 환경에서는 단일 Python 에이전트와 정적 UI가 충분하다. 서비스가 늘어나면 다음 방향을 검토한다.

  • 서비스 목록을 타입이 있는 설정으로 분리한다.
  • 포트 검사와 HTTP readiness를 서비스별로 선택한다.
  • 상태를 시계열 DB에 보내 가용성과 지연 추세를 본다.
  • 알림에 중복 억제와 복구 메시지를 추가한다.
  • 재시작 동작을 launchd 또는 systemd API에 위임한다.
  • 인증서 폐기와 관리자 권한 변경 절차를 자동화한다.
  • 단일 맥 장애에 대비한 예비 워커를 둔다.

Prometheus, Alertmanager, Grafana 같은 표준 관제 도구로 옮길 수도 있다. 다만 로컬 launchd 서비스의 제한된 재시작과 휴대전화 전용 UI 같은 요구에는 작은 전용 OPS가 단순할 수 있다.

운영 점검 목록

  • 포트, HTTP readiness, 외부 HTTPS를 서로 다른 신호로 본다.
  • 상태 응답에 측정 시각과 오류 원인을 포함한다.
  • UI 폴링의 외부 점검 빈도와 timeout을 제한한다.
  • 재시작은 서비스 ID 허용 목록으로만 실행한다.
  • 사용자 문자열을 셸에 전달하지 않는다.
  • 워치독 플래그와 readiness로 재시작 성공을 확인한다.
  • 감사 로그와 서비스 로그를 분리한다.
  • 관리 UI에 상호 TLS 또는 강한 관리자 인증을 적용한다.
  • 에이전트 API는 루프백·VPN·방화벽으로 접근 범위를 제한한다.
  • 연속 실패와 backoff로 재시작 폭주를 막는다.
  • 인증서, 개인키, 실제 서버 주소를 게시물과 저장소에서 제외한다.

포트 감시 OPS의 목적은 모든 장애를 자동으로 고치는 것이 아니다. 사용자가 보는 외부 경로와 맥 내부 서비스를 빠르게 구분하고, 위험하지 않은 복구만 제한적으로 수행하는 것이다. 상태 확인과 명령 실행의 경계를 지킬 때 작은 관제 도구가 운영 안정성을 높일 수 있다.