지식

[systemd] ClamAV 데몬이 첫 부팅마다 죽어 있던 이유 — Condition 경합과 path 유닛의 trigger-limit 자멸

클라우드용 Ubuntu 골든 이미지를 만들면서 ClamAV를 넣었다가, "데몬이 첫 부팅에서 영원히 뜨지 않는" 문제를 두 번의 오진 끝에 해결한 기록이다. 원인은 ClamAV 자체가 아니라 systemd 유닛의 조건 평가와 path 유닛의 재트리거 동작이었다. ClamAV가 아니어도 "다른 서비스가 만들어 주는 파일이 생겨야 뜰 수 있는 데몬"이라면 똑같이 밟을 수 있는 함정이라 정리해 둔다.

배경

가상머신용 베이스 이미지를 오프라인(virt-customize)으로 굽는 파이프라인에서, 보안 요건으로 clamav-daemon(실시간 스캔 데몬 clamd)과 clamav-freshclam(시그니처 갱신 데몬)을 설치하고 둘 다 systemctl enable 해 두었다. 기대한 동작은 단순했다: 새 VM이 첫 부팅하면 freshclam이 시그니처 DB를 내려받고, clamd가 떠서 상시 스캔이 시작된다.

증상

첫 부팅한 게스트에서 freshclam은 정상인데 clamd만 죽어 있었다. is-enabledenabled인데 is-active는 계속 inactive. 10분을 기다려도 그대로였다. 저널에 결정적인 한 줄이 있었다.

clamav-daemon.service - Clam AntiVirus userspace daemon was skipped because of an
unmet condition check (ConditionPathExistsGlob=/var/lib/clamav/daily.{c[vl]d,inc}).

그런데 정작 /var/lib/clamav/를 보면 daily.cvd(23MB), main.cvd(89MB), bytecode.cvd가 전부 있었다. 파일이 다 있는데 "파일이 없어서 스킵했다"는 상태로 고정된 것이다.

1차 원인 — Condition은 한 번만 평가된다

배포판의 clamav-daemon.service에는 시그니처가 없으면 기동을 건너뛰라는 조건이 붙어 있다.

# /usr/lib/systemd/system/clamav-daemon.service (발췌)
ConditionPathExistsGlob=/var/lib/clamav/main.{c[vl]d,inc}
ConditionPathExistsGlob=/var/lib/clamav/daily.{c[vl]d,inc}

문제는 타이밍이다. 첫 부팅에서 systemd가 이 조건을 평가하는 시각과 freshclam이 파일을 내려놓는 시각이 몇 초 차이로 경합한다. 실측에서는 조건 평가와 시그니처 낙착이 같은 초에 일어났고, 평가가 근소하게 빨라 유닛은 skipped로 끝났다. 그리고 systemd의 Condition은 재평가 트리거가 없다 — 한 번 스킵된 유닛을 나중에 깨워 줄 주체가 없으면 그대로 영원히 죽어 있다. freshclam의 NotifyClamd 설정은 이미 떠 있는 clamd에 리로드를 알리는 기능이라, 죽어 있는 유닛에는 아무것도 못 한다.

1차 수정과 2차 증상 — path 유닛이 7.5초 만에 자멸했다

"파일이 생기면 서비스를 깨운다"는 요구니 systemd path 유닛이 정석으로 보였다. 시그니처 파일을 감시해 데몬을 기동시키는 유닛을 추가했다.

# 1차 수정 — 실패한 버전
[Path]
PathExistsGlob=/var/lib/clamav/daily.cvd
PathExistsGlob=/var/lib/clamav/daily.cld

[Install]
WantedBy=multi-user.target

재빌드한 이미지의 첫 부팅에서 결과는 더 흥미로운 실패였다.

x clamav-daemon.path - Start clamav-daemon once virus signatures exist
     Active: failed (Result: trigger-limit-hit)
   Duration: 7.466s
systemd[1]: clamav-daemon.path: Trigger limit hit, refusing further activation.

freshclam 저널과 파일 mtime을 대조하니 메커니즘이 정확히 보였다. freshclam은 daily → main → bytecode 순으로 내려받는데(문서상 보장이 아니라 관측 결과다), daily가 도착하고 main이 도착하기까지 6초의 틈이 있었다.

14:00:47  daily.cvd 도착           ← path 조건 충족, 서비스 트리거 시작
14:00:52  path 유닛 trigger-limit-hit 으로 사망
14:00:53  main.cvd 도착            ← 1초 늦었다

그 6초 동안 무슨 일이 있었나: path가 서비스를 깨운다 → 서비스는 main 조건 미충족으로 즉시 스킵된다 → 스킵된 유닛은 곧바로 비활성이 된다 → path는 "조건이 여전히 참인데 대상이 비활성"이므로 다시 트리거한다. 이 되먹임이 초당 수십 회로 돌다가 systemd의 기본 트리거 한도에 걸려 path 유닛 자체가 failed로 사망했다 — main이 도착하기 정확히 1초 전에. 감시하는 파일(daily)과 서비스의 기동 조건(daily+main)이 어긋나면, path 유닛은 도와주기는커녕 자기 자신을 죽인다.

트리거 한도를 없애는 우회(TriggerLimitIntervalSec=0)도 검토했지만 기각했다. 네트워크 장애로 main이 영영 안 오는 경우 이 되먹임이 무한 tight-loop로 남기 때문이다.

해법 — path는 방아쇠만, 대기는 oneshot이

구조를 둘로 쪼갰다. path 유닛은 "첫 파일이 생겼다"는 방아쇠 역할만 하고, 실제 대기와 기동은 중간의 oneshot 서비스가 맡는다.

# clamav-daemon-starter.service
[Unit]
Description=Wait for ClamAV signatures then start clamav-daemon

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'for i in $(seq 1 360); do \
  if ls /var/lib/clamav/daily.c?d >/dev/null 2>&1 && \
     ls /var/lib/clamav/main.c?d  >/dev/null 2>&1; then \
    exec systemctl start clamav-daemon.service; fi; sleep 5; done'
TimeoutStartSec=2000
# clamav-daemon.path (교체판)
[Path]
PathExistsGlob=/var/lib/clamav/daily.cvd
PathExistsGlob=/var/lib/clamav/daily.cld
Unit=clamav-daemon-starter.service

[Install]
WantedBy=multi-user.target

설계의 요점 세 가지다.

  1. 다운로드 순서 가정을 없앴다 — starter가 daily와 main 양쪽을 AND로 폴링하므로 어느 쪽이 먼저 오든 무관하다.
  2. RemainAfterExit=yes가 되먹임을 원천 차단한다 — starter는 한 번 완료되면 active(exited)로 남고, path는 대상 유닛이 비활성으로 떨어져야 재트리거하므로 두 번 다시 발화하지 않는다. 2차 실패의 사망 경로가 구조적으로 닫힌다.
  3. 실패 모드가 온순하다 — 시그니처가 영영 안 오면 starter는 5초 간격 폴링만 하다가 30분 뒤 조용히 끝난다. tight-loop도, 유닛 사망도 없다.

재빌드 후 첫 부팅 실측: path 상주 시작 → daily 도착에 트리거 → starter가 main까지 확인하고 기동 — 부팅 후 72초에 clamd active, trigger-limit 재발 없음. 재부팅 시나리오도 확인했는데, 이때는 시그니처가 디스크에 이미 있어 데몬의 자체 Condition이 부팅 시점에 충족되므로 starter를 거치지 않고 29초 만에 직행으로 뜬다. 첫 부팅과 재부팅 두 경로가 모두 성립한다.

교훈

  • systemd Condition은 게이트지 대기가 아니다. 한 번 스킵되면 끝이고, 재평가를 시켜 줄 별도 장치가 필요하다.
  • path 유닛의 감시 대상과 대상 서비스의 기동 조건은 정확히 일치해야 한다. 부분집합만 감시하면 그 차집합이 생기는 시간 창에서 스킵-재트리거 되먹임이 돌고, trigger-limit이 path를 죽인다.
  • path → 즉시 완료되는 oneshot(RemainAfterExit=yes) → 실제 서비스의 2단 구조는 이런 "선행 산출물 대기" 문제의 범용 패턴이다. 조건이 여러 개거나 도착 순서가 보장되지 않을 때 특히 그렇다.
  • 관측된 순서(daily가 먼저)는 계약이 아니다. 순서에 기대는 수정은 다음 릴리스에서 다시 깨진다.
조회 2댓글 0

댓글

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.