본문 바로가기
C.W.K.
Lesson 01 of 04 · published

프로세스 하나가 다 쥔다

~13 min · launchd, keepalive, uvicorn, fastapi-lifespan, startup-order

Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

레이블 하나, 작업 하나

운영 중인 Sizzle은 등록된 서비스 호스트 위의 launchd 에이전트 하나야. 이름은 com.cwk.sizzle-serve. plist는 짧은데, 키 하나하나가 다 결정이야. RunAtLoad는 에이전트가 올라갈 때 작업을 켜. KeepAlive는 작업이 무슨 이유로 끝나든 다시 켜. ThrottleInterval을 10으로 둬서, 계속 죽는 상황에서도 10초에 한 번보다 자주 다시 띄우진 않아. ProgramArguments는 cwk-sizzle conda 환경의 파이썬으로 -m backend.serve를 돌려. WorkingDirectory가 저장소 루트라서 import 경로가 곧 저장소고, 셸 프로필은 끼어들 틈이 없어. 표준 출력과 표준 오류는 가족 데이터 루트 밑 sizzle-db/logs/로 가.

값을 잘 보면 경로가 있어야 할 자리에 @@HOME@@, @@DATA_ROOT@@가 있어. launchd는 plist 값을 셸처럼 펼치지 않아. 경로에 $HOME을 적으면 그 다섯 글자가 그대로 남아. plist 안의 EnvironmentVariables 블록도 띄운 프로세스가 볼 환경을 정할 뿐이고. 경로는 전부 끝까지 풀어 적어야 하니까, 저장소에 들어 있고 모든 Mac이 같이 쓰는 plist는 템플릿일 수밖에 없지. 설치 스크립트가 자기가 도는 Mac에 맞게 렌더링하는데, 그건 이어지는 레슨에서 볼 거야. 지금 기억할 건 하나야. 저장소의 파일엔 기계 이름도 사용자 이름도 없어.

프로세스의 정체

진입점은 backend/serve.py가 전부야. uvicorn에 backend.app:app이라는 import 문자열을 넘기고, 호스트와 포트는 backend/settings.py에서 가져와. 호스트 기본값은 127.0.0.1이야. 포트 기본값은 engine_port("cwkSizzle")인데, 가족 레지스트리의 포트 원장을 읽어서 7600을 돌려줘. 둘 다 SIZZLE_HOST, SIZZLE_PORT로 덮을 수 있어. 그러면 두 번째 인스턴스가 운영 포트에 올라앉진 않아. 그래도 운영 데이터에서 떼어 놓진 못해. 데이터 디렉터리는 따로 SIZZLE_DATA_DIR로 덮어야 하고, 그게 없으면 다른 포트에 뜬 두 번째 인스턴스도 첫 번째랑 같은 저널을 열어. 테스트 스위트는 이 변수들에 아예 기대지 않아. create_app에 임시 디렉터리를 직접 넘기거든(레슨 3). workers 인자가 없으니까 프로세스 하나, 이벤트 루프 하나야.

레지스트리에서 중요한 행은, 호스트를 역할 이름으로 적으면 {'host': <role>, 'engine': 7600, 'https': 7601}이야. 엔진은 루프백 7600에서만 들어. 7601은 그 앞에 선 Tailscale Serve HTTPS 쌍둥이고, 폰이랑 다른 Mac은 이쪽으로 Sizzle에 닿아. 엔진은 레지스트리 사본을 따로 안 들고 있어. backend/family_registry.py가 벤더링된 scripts/kit_family_endpoints.py를 파일 경로로 불러와. 설치 스크립트가 돌리는 바로 그 파일이야.

그 안에 사는 것들

부분사는 곳하는 일
HTTP APIbackend/app.py의 라우트웹 클라이언트와 아이폰 앱이 똑같이 부르는 라우트
웹 클라이언트mount_webui(app, frontend/dist), 맨 마지막에 마운트빌드된 React 앱을 내줘. API 라우트가 먼저 등록돼서 API가 이겨
읽기 워커ReadingWorker, asyncio 태스크대기 중인 접시를 읽어. 기본은 한 번에 두 개(SIZZLE_READING_CONCURRENCY)
대화 턴Threads아빠의 질문을 cwkPippa에 보내고 답을 기록해
스테이징 업로드데이터 디렉터리의 staged/접시가 이름을 부르기 전에, 따로 올라온 사진을 한 장씩 맡아 둬
헬스GET /api/healthok, app: "sizzle", 제품 버전으로 답해

Firelink가 Sizzle 선언에 따라 찔러 보는 게 이 헬스 라우트야. 기대하는 본문은 "app":"sizzle" 그대로라서, 같은 포트에서 다른 엔진이 200으로 답해도 이 검사는 실패해. 이런 선언을 왜 앱이 아니라 가족 레지스트리가 쥐는지는 firelink-quest의 one-owner-per-fact 트랙이 다뤄.

시작 순서는 계약이야

시작 코드는 FastAPI lifespan 하나뿐이고, 순서엔 다 이유가 있어.

  1. 미러 다시 짓기. sizzle.open이 워커 스레드에서(asyncio.to_thread) journal.jsonl을 SQLite 미러에 재생하고, 이벤트를 몇 개 읽었는지 로그에 남겨. 미러가 다 서기 전엔 아무것도 안 내줘.
  2. 두뇌 슬롯 선언. brain_control.adopt_once가 어떤 작업이 슬롯을 찾기 전에 Sizzle의 기능별 슬롯을 가족 권한자에 등록해. 그래서 시작하자마자 도는 다시 읽기가 자기 등록과 엇갈릴 일이 없어. 권한자가 안 닿으면 경고만 남기고, 나중에 슬롯을 찾을 때 늦게라도 선언해.
  3. 워커 연결. on_reading_requested가 worker.kick이 돼. 캡처, 정정, 다시 읽기 요청이 올 때마다 워커가 깨.
  4. 워커 시작. 지금 루브릭보다 오래된 읽기를 가진 접시마다 먼저 reading.requested를 기록하고, 루프를 켠 다음 자기를 한 번 깨워. 지난 프로세스가 멈출 때 대기 중이던 일이 이걸로 다 처리돼.
  5. 턴 이어받기. threads.resume이 끝나지 않은 대화 턴을 하나씩 봐. 그 턴의 요청 id로 cwkPippa가 이미 답을 들고 있으면 그 답을 기록해. 아니면 Interrupted by an engine restart. Ask again.을 달고 실패로 처리해. 엔진이 재시작돼서 끊겼으니 다시 물어보라는 뜻이야. 질문을 다시 보내는 일은 절대 없어.

끌 때는 거꾸로 가. 워커를 멈추고, 턴 태스크를 취소하고, 저장소를 닫아.

KeepAlive면 충분한 이유

Sizzle엔 예약 작업이 없어. 읽기 워커는 켜질 때마다 한 번, 캡처, 정정, 다시 읽기 요청이 올 때, 읽기 슬롯에 새 두뇌가 정해질 때, 그리고 읽기가 끝났는데 접시가 아직 pending일 때 깨어나. 시계를 보고 깨는 일은 없어. 그래서 엔진이 바깥에 바라는 보장은 딱 하나야. 죽으면 다시 켜 달라는 것. KeepAlive가 그 보장이고, 나머진 lifespan이 해. 기록은 저널이고 미러는 버려도 돼. 접시는 읽기가 기록되거나 실패할 때까지 미러에서 pending으로 남아 있어. 그러니 크래시로 끊긴 읽기는 다음 프로세스가 그냥 다시 읽어. 끝나지 않은 턴도 정직하게 정리돼. 크래시가 가져가는 건 날아가던 모델 호출 하나고, 기록된 건 하나도 안 잃어.

원칙: 복구 경로를 시작 경로로 만들어. 켜질 때마다 기록에서 파생 상태를 다시 짓고 밀린 일을 집어 들면, 재시작은 평범한 부팅이 돼. 그러면 감독자는 다시 켜 주는 일만 하면 돼.

LaunchAgent 일반론은 swift-quest의 macos-citizen 트랙에 있어. 여기서 볼 건 짝이야. 한 가지만 하는 감독자, 그리고 켜질 때마다 복구하는 프로세스.

Code

cwkSizzle, deploy/com.cwk.sizzle-serve.plist: launchd 작업, @@HOME@@과 @@DATA_ROOT@@ 토큰이 든 템플릿 (원문 그대로, 4-32행)·text
<dict>
	<key>EnvironmentVariables</key>
	<dict>
		<key>HOME</key>
		<string>@@HOME@@</string>
		<key>PATH</key>
		<string>@@HOME@@/miniconda3/envs/cwk-sizzle/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
	</dict>
	<key>KeepAlive</key>
	<true/>
	<key>Label</key>
	<string>com.cwk.sizzle-serve</string>
	<key>ProgramArguments</key>
	<array>
		<string>@@HOME@@/miniconda3/envs/cwk-sizzle/bin/python</string>
		<string>-m</string>
		<string>backend.serve</string>
	</array>
	<key>RunAtLoad</key>
	<true/>
	<key>StandardErrorPath</key>
	<string>@@DATA_ROOT@@/sizzle-db/logs/serve-stderr.log</string>
	<key>StandardOutPath</key>
	<string>@@DATA_ROOT@@/sizzle-db/logs/serve-stdout.log</string>
	<key>ThrottleInterval</key>
	<integer>10</integer>
	<key>WorkingDirectory</key>
	<string>@@HOME@@/CWK/cwkPersonalProjects/cwkSizzle</string>
</dict>
cwkSizzle, backend/serve.py: 진입점 전체 (원문 그대로, 1-15행)·python
"""Run the Sizzle engine: ``python -m backend.serve`` from the repo root."""

from __future__ import annotations

import uvicorn

from . import settings


def main() -> None:
    uvicorn.run("backend.app:app", host=settings.HOST, port=settings.PORT, log_level="info")


if __name__ == "__main__":
    main()
cwkSizzle, backend/app.py: 시작 순서대로 적힌 lifespan (원문 그대로, 119-138행)·python
    @asynccontextmanager
    async def lifespan(_app: FastAPI):
        count = await asyncio.to_thread(sizzle.open)
        logger.info("sizzle: mirror rebuilt from %d journal events", count)
        # Declare the per-function brain slots before any job resolves one,
        # so a startup re-read never races its own registration. An
        # unreachable authority only delays it: resolve() declares lazily.
        try:
            await asyncio.to_thread(brain_control.adopt_once)
        except Exception as exc:  # noqa: BLE001
            logger.warning("sizzle: brain slots not declared yet: %s", exc)
        sizzle.on_reading_requested = worker.kick
        worker.start()
        await threads.resume()
        try:
            yield
        finally:
            await worker.stop()
            await threads.close()
            sizzle.close()

External links

Exercise

네 Mac에서 돌리는 작은 웹 서비스 하나를 골라서 이 모양을 입혀 봐. RunAtLoad, KeepAlive, ThrottleInterval을 넣고, 로그 경로는 미리 만들어 둔 디렉터리로 잡은 LaunchAgent plist를 써. 서비스 쪽에선 파생 상태를 전부 시작 단계로 옮겨. 캐시든 인덱스든 검색 테이블이든, 원본 기록에서 다시 짓고 얼마나 읽었는지 로그에 남기게. 준비가 되면 kill -9로 프로세스를 죽이고, launchd가 새 프로세스를 띄웠는지, 로그에 새로 다시 지은 흔적이 있는지 확인해.
Hint
launchctl print gui/$(id -u)/your.label로 작업 상태, 마지막 종료 코드, PID를 볼 수 있어. 죽이기 전후 PID를 비교해 봐. 작업이 아예 안 뜨면 로그 디렉터리가 있는지부터 확인해. launchd는 그 디렉터리를 만들어 주지 않아.

Progress

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

댓글 0

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

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