죽은 채로 48만 번 재시작한 서비스: 유령 systemd 유닛 복구기

기능 하나를 고치러 들어갔다가, 재시작 카운터가 483,142를 가리키는 systemd 서비스를 만났습니다. 바이너리도, 실행 유저도, 데이터도 전부 없는데 유닛만 남아 3초마다 살아나려다 죽기를 반복하고 있었죠. 이 글은 "연결은 되는데 검색만 안 되는" 문제를 파고들다 발견한 유령 서비스의 정체와, 그걸 정식으로 되살린 기록입니다.

1. 발단: 검색만 안 된다

과거 대화를 임베딩해 검색해 주는 지식베이스(로컬 RAG)를 AI 봇에 연결했습니다. 연결 자체는 완벽했습니다 — 도구 목록도 뜨고, 초기화도 성공. 그런데 실제로 검색을 시키면 이 에러가 났습니다.

Error: Failed to connect to Ollama.
Please check that Ollama is downloaded, running and accessible.

이 RAG는 임베딩(문장을 벡터로 바꾸는 일)을 로컬 추론 엔진에 의존합니다. 그 엔진이 응답을 안 하고 있었습니다.

2. 진단: 유닛만 살아있는 좀비

서비스 상태를 봤더니 계속 activating에서 맴돌았습니다. 로그가 범인을 정확히 지목했습니다.

service: Failed to determine user credentials: No such process
service: Main process exited, code=exited, status=217/USER
service: Scheduled restart job, restart counter is at 483142.

status=217/USER는 "유닛에 지정된 실행 유저가 존재하지 않는다"는 뜻입니다. 하나씩 확인해 보니:

  • 실행 유저 → 없음 (no such user)
  • 실행 바이너리 → 없음 (경로에 파일이 없음)
  • 모델·데이터 디렉토리 → 없음

즉, 이 서비스는 실체가 통째로 사라진 채 유닛 파일만 남아 있었습니다. Restart=always, RestartSec=3 설정 때문에, 시작 → 217 실패 → 3초 후 재시작을 수십만 번 반복하고 있었던 겁니다. 아무도 눈치채지 못한 채로.

왜 이렇게 됐는지는 명확하지 않았습니다 — 과거에 설치했다가 정리 중 바이너리·유저만 지워지고 유닛이 남았거나, 마이그레이션 잔재이거나. 중요한 건 "지금 이 유닛은 되살릴 대상이 아니라 정리 대상"이라는 점이었습니다.

3. 복구: 깨끗이 지우고 정식 설치

실체가 없으니 고칠 게 아니라 새로 까는 게 맞습니다. 순서는 이렇게 잡았습니다.

  1. 백업 후 유령 유닛 제거: 크래시 루프부터 멈춥니다. 죽은 유닛을 백업해 두고 삭제 → daemon-reload.
  2. 공식 스크립트로 설치: 바이너리·실행 유저·systemd 유닛을 정석대로 생성.
  3. 바인딩 확인: 추론 엔진이 127.0.0.1로만 듣는지 확인. 외부에 열리면 안 됩니다.
  4. ss -tlnp | grep 11434
    LISTEN 127.0.0.1:11434   # 로컬 전용 ✅ (외부 노출 없음)
  5. 모델 pull: RAG가 실제로 참조하는 임베딩 모델명을 설정에서 먼저 확인하고, 정확히 그 모델을 받습니다. (엉뚱한 모델을 받으면 벡터 차원이 안 맞아 검색이 깨집니다.)

바인딩은 기본값이 로컬이더라도, drop-in 설정으로 127.0.0.1을 명시적으로 못 박아 뒀습니다. 나중에 기본값이 바뀌어도 외부로 새지 않도록.

4. 검증: 진짜 되는지, 그리고 안 폭주하는지

  • 임베딩 API: 벡터 차원이 기대값(768)으로 나오는지 확인.
  • 실제 검색: RAG로 과거 대화를 검색해 결과가 나오는지 — 봇 경로와 직접 호출 양쪽에서.
  • 재발 방지: 유저가 생겼으니 217/USER는 안 납니다. 그래도 5분간 재시작 카운터를 관찰해 크래시 루프가 재발하지 않는지 확인.
  • 자원: idle 시 메모리 점유는 작고(수십 MB), 추론 때만 모델이 로드됐다가 유휴 후 언로드. 서버 여유 안에서 무해한지 수치로 확인.

5. 교훈

  • Restart=always는 조용한 좀비를 만든다. 실체가 사라진 유닛이 남으면, 아무 알림 없이 무한 재시작을 돕니다. 재시작 카운터가 비정상적으로 크면 그 자체가 경보입니다.
  • "연결됨"과 "동작함"은 다르다. 도구가 붙었다고 기능이 되는 게 아닙니다. 백엔드 의존성(여기선 추론 엔진)이 죽어 있으면, 연결은 초록불인데 실제 호출만 빨간불입니다.
  • 죽은 서비스는 되살리기 전에 실체부터 확인하라. 유저·바이너리·데이터가 다 없으면 "복구"가 아니라 "재설치"입니다. 진단이 조치의 방향을 바꿉니다.
  • 로컬 추론 엔진은 반드시 로컬 바인딩을 확인하라. 임베딩·LLM 엔진이 외부 인터페이스에 열리면 그대로 무인증 API 노출입니다.

FAQ

Q. 재시작 카운터가 48만인 걸 왜 진작 몰랐나요?

바로 그게 핵심입니다. 로그를 뒤지지 않는 한, "실패하지만 곧바로 재시작되는" 서비스는 대시보드상 조용합니다. 이런 좀비는 정기적으로 systemctl --failed나 재시작 카운터를 훑는 습관으로만 잡힙니다.

Q. 로컬 추론 엔진, 메모리 많이 먹지 않나요?

모델 나름입니다. 임베딩 전용 소형 모델은 idle 수십 MB, 추론 시 수백 MB 수준이고 유휴 후 언로드됩니다. 큰 생성형 LLM을 상주시키면 얘기가 달라지니, "무엇을 로드하느냐"로 자원 계획을 세우세요.

Q. 그냥 외부 임베딩 API를 쓰면 되지 않나요?

과거 대화 같은 민감 데이터를 다루는 RAG라면, 로컬 임베딩이 데이터가 서버를 안 떠난다는 점에서 유리합니다. 프라이버시와 비용·운영 부담의 트레이드오프이고, 이 경우엔 로컬을 택했습니다.

이 글은 실제 운영 중인 개인 서버의 서비스 복구 작업을 일반화해 정리한 기록입니다. 특정 호스트·경로·자격증명 등 구체적 인프라 정보는 의도적으로 제외했습니다.