본문 바로가기
C.W.K.
Stream
Lesson 02 of 06 · published

launchd 에이전트: 싸우지 않고 유틸리티 살려두기

~17 min · macos-citizen, launchd, launchagent, keepalive, deployment

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"너무 짧은 대기는 실패를 보고하는 게 아니라 실패를 지어내."

로그인할 때 켜지는 방법 두 가지

사용자가 직접 켜는 Mac 앱(설정의 "로그인 시 실행")은 SMAppService.mainApp.register()를 써. 시스템이 로그인 항목에 올려주고, 사용자가 거기서 끌 수 있어. 가족의 상시 유틸리티(텍스트 변환 도구, 받아쓰기 도구, 음성 런처)는 달라. 스크립트로 모든 Mac에 배포하는 데다, 로그인 항목만으로는 모자라. 죽으면 다시 켜줘야 하고, 로그 파일이 있어야 하고, 배포 스크립트가 이름으로 재시작할 수 있는 작업이어야 해. 그래서 이런 유틸리티는 LaunchAgent로 배포해. ~/Library/LaunchAgents에 두는 속성 목록 파일이고, launchd가 사용자 GUI 세션에서 돌려.

중요한 키들

RunAtLoad는 에이전트가 올라올 때 작업을 시작해. GUI로 로그인할 때마다 그래. LimitLoadToSessionType = Aqua는 에이전트를 그래픽 세션에 묶어. 메뉴 막대 앱엔 이게 필요해. ProcessType = Interactive는 이 프로세스가 사용자를 직접 상대한다고 launchd에 알려. 그러면 분류 안 된 작업이 받는 CPU, I/O 조절 대신 앱 수준의 자원 한도로 돌아. StandardOutPathStandardErrorPath는 로그 코드가 돌기 전부터 쓸 수 있는 로그 파일을 만들어줘. 두 번 읽어야 할 키는 KeepAlive = { SuccessfulExit = false }야. launchd는 작업이 실패로 끝났을 때만 다시 켜. 연습용 에이전트로 재보니 exit 3은 다시 떴고(state = spawn scheduled였다가 다시 running), exit 0은 not running으로 남았어. 사용자가 종료를 누르면 exit 0이니까 앱은 꺼진 채로 있고, 죽으면 다시 살아나. 그냥 KeepAlive = true면 사용자가 끌 때마다 앱이 되살아나.

한 가족에서 나온 배포 함정 셋

먼저 죽이면 옛 바이너리가 다시 떠. 설치 스크립트가 swift build 전에 pkill을 불렀어. 앱을 쥐고 있는 건 launchd라서, 빌드가 도는 동안 아직 남아 있던 옛 번들로 바로 다시 띄웠어. 나중에 번들을 바꿔도 디스크만 바뀌고 돌고 있는 이미지는 안 바뀌었지. 제대로 된 순서는 이래. 빌드하고 서명하는 동안엔 지금 프로세스를 그대로 살려둬. 서명까지 끝난 번들을 이름 바꾸기로 설치하고 검증해. 그제야 올라가 있는 작업을 launchctl kickstart -k로 다시 켜.

짧은 대기가 실패를 지어내. 번들을 바꿔 끼운 직후엔 launchd가 작업을 띄웠다가 코드 서명 평가에서 한 번 실패하고(last exit reason = OS_REASON_CODESIGNING) 다시 시도할 수 있어. 5초만 확인한 스크립트가 멀쩡한 설치에 HARD_FAIL을 찍었어. 그걸 보면 누구나 설치를 다시 돌리고 싶어지는데, 그러면 이미 멀쩡한 시스템을 괜히 흔들게 돼. 지금은 20초를 기다리고, 실패하더라도 메시지에서 번들은 설치됐다고 알려줘.

KeepAlive는 실패하는 전제 조건을 무한 반복으로 바꿔. 어떤 서비스의 실행 스크립트가 set -e를 켠 채로 서비스 바깥 경로가 있는지 확인했어. 며칠 뒤 그 경로가 없어졌어. 돌고 있던 프로세스는 검사가 깨지기 전에 시작됐으니까 아무도 몰랐지. 다음 재시작부터는 검사에서 실패해서 끝나고, 다시 켜지고, 또 실패하고, 끝이 없어. 실행 스크립트는 인터프리터나 자기 데이터 폴더처럼 서비스 자신에게 필요한 것만 확인해야 해. 그리고 고쳤다고 말할 수 있는 건 지금 서비스 중인 pid가 수정 뒤에 시작됐을 때뿐이야.

Code

메뉴 막대 유틸리티용 Aqua LaunchAgent(~/Library/LaunchAgents/com.example.spark.plist)·xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.example.spark</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Applications/Spark.app/Contents/MacOS/Spark</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <dict>
    <key>SuccessfulExit</key>
    <false/>
  </dict>
  <key>LimitLoadToSessionType</key>
  <string>Aqua</string>
  <key>ProcessType</key>
  <string>Interactive</string>
  <key>StandardOutPath</key>
  <string>/tmp/spark.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/spark-error.log</string>
</dict>
</plist>
먼저 설치하고, 작업을 다시 켜고, launchd의 재시도를 기다릴 만큼 기다리기·bash
#!/bin/bash
set -euo pipefail
label=com.example.spark
plist="$HOME/Library/LaunchAgents/$label.plist"
domain="gui/$(id -u)"

./scripts/build-app.sh            # builds, signs, verifies, installs by rename; old process keeps running
cp "deploy/$label.plist" "$plist" # staged: RunAtLoad starts it at the next GUI login regardless

if launchctl print "$domain/$label" >/dev/null 2>&1; then
  launchctl kickstart -k "$domain/$label"     # replace the running image with the installed bundle
else
  launchctl bootstrap "$domain" "$plist"
fi

for _ in {1..80}; do                           # 20 s: a first spawn may fail code signing once and retry
  launchctl print "$domain/$label" 2>/dev/null | grep -q 'state = running' && break
  sleep 0.25
done
launchctl print "$domain/$label" | grep -E $'^\t(state|pid|last exit reason) ='

External links

Exercise

프로그램이 /bin/sh -c 'sleep 1 && exit 3'이고 KeepAlive = { SuccessfulExit = false }인 연습용 에이전트 com.example.sparkprobe를 만들어. GUI 도메인에 bootstrap하고, 15초 기다리고, launchctl print에서 state, runs, last exit code를 적어. 종료 상태를 0으로 바꾸고, bootout했다가 다시 올리고, 같은 항목을 적어. 차이를 한 문장으로 설명하고, 에이전트를 bootout하고 plist를 지워.
Hint
launchctl bootout gui/$(id -u)/com.example.sparkprobe가 올라간 작업을 내려. 손으로 쓴 plist는 bootstrap하기 전에 plutil -lint로 확인해. 형식이 틀린 파일은 뭉뚱그린 에러로 실패하거든.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.