Skip to content

Latest commit

 

History

History
321 lines (265 loc) · 17.1 KB

SERVICES.md

File metadata and controls

321 lines (265 loc) · 17.1 KB

Patternflow Server Services & Worker Management

Patternflow 커뮤니티 웹 서버 및 C++ 펌웨어 웹플래시 컴파일 빌드 워커 서비스 구성과 운영 방법 가이드입니다.


1. Systemd 서비스 구성

라즈베리 파이(또는 린눅스 서버) 상에서 다음 2개의 systemd 서비스로 가동됩니다.

① patternflow-community.service

  • 역할: Next.js 기반 커뮤니티 웹 서버 (patternflow.work / community.patternflow.work)
  • 실행 명령: npm start (포트 3000)
  • 위치: /etc/systemd/system/patternflow-community.service

죽으면 스스로 다시 뜨게 — Restart=always 필수

systemd 기본값은 Restart=no입니다. 그대로 두면 Node 프로세스가 한 번이라도 죽는 순간(라즈베리 파이에서 현실적인 원인은 OOM 킬) 누가 systemctl restart를 칠 때까지 커뮤니티 전체가 내려간 채로 있습니다. 두 서비스 모두 [Service]에 아래 두 줄이 들어 있는지 확인하세요:

[Service]
Restart=always
RestartSec=3

확인: systemctl show patternflow-community.service -p Restart 가 Restart=always 를 돌려줘야 합니다. 죽은 적이 있는지는 sudo journalctl -u patternflow-community.service | grep -i "main process exited", OOM 킬은 dmesg -T | grep -i "killed process" 로 봅니다.

② patternflow-worker.service

  • 역할: C++ 펌웨어 빌드 워커 (scripts/build-worker.ts)
  • 설명: 웹플래시 요청 대기열(queued)을 수신하여 arduino-cli를 이용해 ESP32-S3 바이너리를 컴파일합니다.
  • 위치: /etc/systemd/system/patternflow-worker.service
  • 환경변수 중요 사항: Environment=PATH에 arduino-cli 설치 경로(~/.local/bin 또는 /usr/local/bin) 포함 필수

워커는 격리된 채로 돈다 — hardening.conf (2026-09-04)

워커는 사용자가 제출한 C++를 컴파일한다. 컴파일 시점의 임의 코드 실행이 가능한 구조라, 유닛에 systemd 샌드박스를 걸어 둔 상태로만 켠다. /etc/systemd/system/patternflow-worker.service.d/hardening.conf:

[Service]
NoNewPrivileges=yes
PrivateNetwork=yes            # 워커는 네트워크를 쓰지 않는다 (로컬 SQLite 폴링 → 파일 산출)
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/pi/patternflow-data /home/pi/Patternflow/.build-worker /home/pi/Patternflow/firmware/modules/.build
# 시크릿은 유닛 밖: 워커가 쓰는 변수만 root 전용 파일로, .env.local은 빈 파일로 보이게
EnvironmentFile=/etc/patternflow/worker.env
BindReadOnlyPaths=/dev/null:/home/pi/Patternflow/web/.env.local
InaccessiblePaths=-/home/pi/.ssh -/home/pi/backups -/home/pi/.config
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
# bwrap이 컴파일러를 가두려면 네임스페이스와 mount 계열이 필요하다 (아래 2단계)
RestrictNamespaces=user mnt pid ipc uts net cgroup
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service @mount
SystemCallFilter=~@resources
UMask=0077
MemoryMax=1G
CPUQuota=200%
TasksMax=128
Nice=10

MemoryDenyWriteExecute는 넣지 않는다 — Node(V8)가 죽는다. 쓰기 경로 세 개는 strace로 확인한 전부다: 데이터 디렉터리(DB·WAL·산출물), 워커 작업 디렉터리, 레포 안 모듈 빌드 캐시. systemd-analyze security patternflow-worker가 9.2(UNSAFE)에서 1.7(OK)로 내려갔고, 정상 빌드·자원 폭탄(120 s 타임아웃 뒤 워커 생존)·다음 빌드 정상 수주까지 확인했다.

이 격리가 막지 않는 것. 컴파일러는 워커와 같은 사용자(pi)로 돌기 때문에, pi가 읽을 수 있는 파일은 제출된 헤더의 #include로 읽힌다. 컴파일 오류는 문제의 줄을 그대로 인용하고, 그 오류 문자열은 빌드 상태 API로 제출자에게 돌아간다 — 즉 web/.env.local(인증 시크릿)이나 community.db(비밀번호 해시)를 #include하면 내용 일부가 새어 나갈 수 있다. /etc/shadow가 막힌 건 격리 덕이 아니라 원래 root 전용이라서다. 카나리 파일(/home/pi/canary.txt에 CANARY-7f3a)을 #include한 빌드의 오류 문자열에 그 문장이 그대로 나오는 것으로 재현했다. 두 단계 중 첫째는 끝났다:

  1. 시크릿은 유닛 밖 (완료, 2026-09-04). 워커가 필요로 하는 변수 (COMMUNITY_ENABLED, BUILD_ENABLED, BUILD_FQBN, COMMUNITY_DB_PATH)는 root 전용 /etc/patternflow/worker.env(0600)에서 EnvironmentFile=로 받는다. .env.local은 InaccessiblePaths로 막으면 워커가 못 뜬다 — scripts/loadEnv.ts의 existsSync는 접근 불가 파일에도 true를 돌려주고, 그다음 process.loadEnvFile이 EACCES에 예외를 던진다. 그래서 BindReadOnlyPaths=/dev/null:…/.env.local로 빈 파일로 보이게 한다: 워커는 빈 파일을 조용히 넘기고, #include는 0바이트를 읽는다. .ssh, backups, .config는 InaccessiblePaths. 재검증: 카나리는 Permission denied, .env.local은 0바이트, 백업 디렉터리는 Permission denied, 정상 빌드 0.5 s.
  2. 컴파일러만 따로 가둔다 (완료, 2026-09-04). DB는 워커에게 필요하니 유닛 단위로는 못 숨긴다. build_module.py는 PF_XTENSA_BIN이 가리키는 디렉터리에서 xtensa-esp32s3-elf-g++를 찾고 nm은 그 옆 것을 쓰므로, /etc/patternflow/xtensa-sandbox/(root 소유 0755)에 그 둘의 래퍼를 두고 worker.env에 PF_XTENSA_BIN=/etc/patternflow/xtensa-sandbox를 넣었다. 래퍼는 bwrap --unshare-all --die-with-parent --new-session으로 실제 툴체인(~/.arduino15/packages/esp32/tools/esp-x32/2601, g++가 cc1plus·as·ld를 찾도록 루트째)·/usr /lib /bin /etc/ld.so.cache·firmware/patternflow· firmware/toolchain을 읽기 전용으로, .build-worker·modules/.build·/tmp (유닛의 PrivateTmp라 호스트와 무관)를 쓰기로 바인드하고 --chdir /에서 실제 g++를 실행한다. 컴파일러의 눈에 그 밖의 파일은 없다. 재검증: 데이터 디렉터리의 카나리와 web/package.json 모두 No such file or directory, 정상 빌드 0.5 s(오버헤드 없음), 자원 폭탄은 120 s 타임아웃 뒤 워커 생존, seccomp 추가 허용 0개, systemd-analyze security 2.7(OK). ~@privileged는 뺐다 — bwrap이 사용자 네임스페이스 안에서 capset을 쓴다.

남은 노출은 작다. 컴파일 하나가 남기는 파일은 두 군데다. 제출된 헤더 소스는 .build-worker/modules/<job id>-<n>/에 들어가는데, 워커가 컴파일마다 새로 만들고 끝나는 즉시(성공이든 실패든) finally에서 지운다 (web/scripts/build-worker.ts). 컴파일러가 내놓는 오브젝트는 체크아웃 안 firmware/modules/.build/<slug>/pattern.o에 떨어지는데(build_module.py, 샌드박스에 읽기·쓰기로 바인드됨), 이건 runModuleBuild가 매 빌드 finally에서 firmware/modules/.build/ 디렉터리째 지운다(web/src/lib/firmware/moduleRunner.ts) — <slug>는 실패한 포팅에선 알 수 없고 체크아웃당 워커는 하나뿐이라, 특정 서브디렉터리를 짚는 대신 디렉터리 전체를 비운다. 워커는 잡을 하나씩만 처리하므로, 다른 제출자의 헤더든 그 오브젝트든 디스크에 있는 건 그 사람 자신의 컴파일이 도는 동안뿐이다 — rm 도중 워커가 죽은 경우의 잔여물을 빼면 컴파일러가 남의 것을 볼 틈은 없다. drop-in에 남은 -/home/pi/canary.txt는 파일이 없어 무해하다.

헤더 텍스트 검사는 경계가 아니다. validateCustomPattern (web/src/lib/firmware/assemble.ts)이 # include·#embed·asm(·.incbin 같은 평범한 표기는 거절하지만, 글자를 보는 검사라 표기를 바꾸면 지나간다 — #와 include 사이의 백슬래시 줄바꿈, 지시문 안의 주석, #의 이중문자 %:, asm으로 펼쳐지는 매크로, 두 문자열로 쪼갠 .incbin(2026-09-24 실제 툴체인으로 확인). 헤더가 읽을 수 있는 파일을 정하는 건 위 2단계의 bwrap 래퍼다: 툴체인, 시스템 디렉터리, firmware/patternflow·firmware/toolchain(읽기 전용)뿐. 그리고 그 안의 파일은 공개된 것으로 본다 — 공개 패턴의 컴파일 오류는 /api/community/patterns/<id>/zip의 422 detail로 누구에게나 보이고, .incbin은 파일 바이트를 공개 다운로드되는 .pfm에 그대로 넣는다. 그러니 Pi 체크아웃의 그 경로에 비밀을 두지 않는다(예: firmware/patternflow/patternflow_secrets.h를 Pi에 만들지 않는다). 래퍼 없이 도는 워커(개발 PC 등)에서는 헤더가 워커 사용자가 읽을 수 있는 모든 파일에 닿으므로, 남의 헤더를 굽는 워커는 반드시 래퍼 뒤에서 돌린다.

호스트 업데이트 절차

레포는 /home/pi/Patternflow, 데이터는 /home/pi/patternflow-data (DB community.db, attachments/, builds/), 백업은 매일 04:00 크론이 /home/pi/backups/에 .backup으로 뜬다. Node는 /home/pi/Desktop/nodejs/node-v21.7.2-linux-arm64, 터널은 cloudflared.service.

# 1. 백업 (DB는 온라인 .backup으로, WAL 일관성 유지)
sqlite3 /home/pi/patternflow-data/community.db ".backup '/home/pi/backups/community-$(date +%F)_pre_update.db'"
cp -r /home/pi/patternflow-data/attachments /home/pi/backups/attachments-$(date +%F)_pre_update
git -C /home/pi/Patternflow rev-parse HEAD   # 롤백 기준점을 적어 둔다

# 2. 코드
cd /home/pi/Patternflow && git fetch origin && git checkout main && git pull --ff-only
cd web && npm ci && npm run build

# 3. 재시작 (DB 마이그레이션은 서버가 첫 접속 때 자동 적용)
sudo systemctl restart patternflow-community patternflow-worker

# 4. 확인
curl -sI https://community.patternflow.work/editions | head -1      # 200
curl -sI https://community.patternflow.work/variants | head -1      # 308
curl -sI https://community.patternflow.work/api/community/patterns | head -1   # 200
journalctl -u patternflow-community -n 30 --no-pager                 # 마이그레이션 오류 없음

.env.local은 건드리지 않는다. 키 목록은 web/.env.example이 전부다.


2. 주요 관리 명령어

서비스 상태 및 상태 확인

# 커뮤니티 웹 서버 상태 확인
sudo systemctl status patternflow-community.service

# 펌웨어 빌드 워커 상태 확인
sudo systemctl status patternflow-worker.service

# 빌드 워커 실시간 로그 확인
sudo journalctl -u patternflow-worker.service -f

서비스 재시작

# 웹 서버 재시작
sudo systemctl restart patternflow-community.service

# 빌드 워커 재시작
sudo systemctl restart patternflow-worker.service

보관 기간 정리 (Retention)

/terms §9에 약속한 보관 기간을 실제로 이행하는 작업입니다.

대상 보관
세션 (IP·User-Agent 포함) 만료 시 삭제, 최대 90일
Better Auth 인증 토큰 만료 시 삭제
빌드 산출물 + 빌드 기록 (bake 잡 포함) 30일
참조되지 않는 산출물 파일 24시간 유예 후 삭제
컴파일된 패턴 모듈 (module_cache) 헤더가 사이트에 있는 동안 보관. 헤더가 지워지거나 바뀌면 마지막 사용 후 30일
이전 툴체인 리비전의 모듈 마지막 사용 후 7일 (롤백하면 그대로 다시 쓰인다)

빌드 워커가 하루에 한 번 자동으로 돌립니다 (patternflow-worker.service). 별도 systemd 타이머를 설치할 필요가 없습니다 — 대신 워커가 꺼져 있으면 정리도 멈춥니다.

cd web

# 뭐가 지워질지 먼저 확인 (아무것도 안 지움)
npm run sweep -- --dry-run

# 실제로 정리
npm run sweep

워커 로그에서 retention swept 줄로 마지막 실행 결과를 확인할 수 있습니다:

sudo journalctl -u patternflow-worker.service | grep "retention"

보관 기간을 바꾸려면 web/src/lib/community/server/retention.ts의 상수와 /terms §9를 함께 고쳐야 합니다. 한쪽만 고치면 약관이 거짓말이 됩니다.

컴파일된 모듈 캐시 (module_cache, 2026-09-24)

공개된 헤더는 한 번만 컴파일된다. 워커가 결과(.pfm 바이트 + 사이드카)를 DB의 module_cache에 (헤더 sha256, 툴체인 리비전) 키로 넣고, 웹은 설치·다운로드 요청마다 그 행으로 zip을 조립한다 — 큐도 컴파일도 없다. 패턴 페이지의 .zip (/api/community/patterns/<id>/zip)과 덱 팩(/api/community/decks/<id>/zip)이 둘 다 여기서 나온다. 컴파일하고 모듈 내용(.pfm·사이드카·상태·오류)을 쓰는 건 워커뿐이다. 웹은 bake 잡을 넣고, 보관 기간 계산용 used_at만 행당 하루 한 번 갱신한다.

  • 툴체인 리비전은 워커가 잡마다 계산해 build_meta.builder_rev에 적는다 (ABI 헤더·공유 수학 헤더·core_module_loader.h·module.ld·두 파이썬 스크립트·컴파일러 --version·PF_TARGET_ABI의 해시, web/src/lib/firmware/builderRev.ts). 펌웨어를 git pull해서 이 중 하나라도 바뀌면 리비전이 바뀌고, 워커가 한가할 때 공개 헤더를 1분에 5개씩 다시 굽는다. 손으로 할 일은 없다.
  • 컴파일 오류(코드 탓)는 캐시에 남아 같은 헤더를 다시 컴파일하지 않는다. 코드 탓으로 치는 건 증거가 있을 때만이다: 패턴 자신의 소스 줄을 가리키는 컴파일 오류(pattern.cpp:12:3: error: 또는 그 줄에서 이어진 required from), 기기 로더가 못 푸는 심볼. 그리고 캐시에 적기 전에 워커가 카나리(firmware/patternflow/presets/preset_origin.h)를 컴파일해 본다 — 그것도 실패하면 툴체인 쪽 문제로 보고 캐시하지 않는다(통과는 10분 기억). bwrap이 안 뜨거나 cc1plus·ld가 없어진 경우처럼 인프라 오류는 캐시하지 않고, 같은 헤더 재시도는 1분부터 두 배씩(최대 6시간) 미룬다. 헤더 안의 #error Permission denied 같은 글자는 판정에 쓰지 않는다.
  • 컴파일러가 없거나 --version에 답하지 않으면 워커는 리비전을 만들지 않는다 (잡은 인프라 오류로 실패, 워밍은 그 회차를 건너뜀). build_meta에는 마지막 정상 리비전이 남으므로 이미 구운 모듈은 계속 내려받힌다.
  • bake 잡은 헤더 주인(작성자, 포트면 포터)에게 달리지만 개인 빌드 한도·대기열 취소에는 안 잡힌다. 대신 주인당 동시 4개(대기+컴파일 중)까지만 쌓인다 — 헤더를 계속 고쳐 저장해도 컴파일이 줄줄이 쌓이지 않는다. 헤더를 저장하면 사이트에서 사라진 이전 텍스트의 대기 중 bake는 취소되고, 차례가 왔을 때 이미 사라진 텍스트는 컴파일하지 않고 넘긴다. 워커는 사람이 누른 send 잡을 항상 먼저 처리한다.
  • 워커는 체크아웃당 하나만. build_module.py는 오브젝트를 체크아웃의 firmware/modules/.build/<slug>/pattern.o에 만들고 <slug>는 패턴 NAME에서 나오므로, 워커 둘이 같은 NAME의 헤더를 동시에 컴파일하면 서로의 오브젝트를 링크할 수 있다. BUILD_WORK_DIR를 달리해도 막히지 않는다.
  • 툴체인 사고 뒤 잘못 남은 오류 행을 지우려면(다음 bake가 다시 판정한다):
sqlite3 /home/pi/patternflow-data/community.db \
  "DELETE FROM module_cache WHERE status='error'
     AND builder_rev=(SELECT value FROM build_meta WHERE key='builder_rev');"
  • 상태 보기:
sqlite3 /home/pi/patternflow-data/community.db \
  "SELECT builder_rev = (SELECT value FROM build_meta WHERE key='builder_rev') AS current,
          status, COUNT(*), SUM(bytes) FROM module_cache GROUP BY 1, 2;"
sqlite3 /home/pi/patternflow-data/community.db \
  "SELECT kind, status, COUNT(*) FROM builds GROUP BY 1, 2;"

로컬 확인: npm run check:modcache(가짜 컴파일러, CI에서도 돈다), npm run check:modcache-e2e(진짜 툴체인 — 한 번 굽고, 두 번째는 프로세스 0개인지).

펌웨어 빌드 큐 상태 점검 스크립트

cd web

# 현재 최신 큐 상태 확인
npm run build:status

# 특정 빌드 ID 상태 확인
npm run build:status -- <BUILD_ID>

# 테스트 빌드 인큐
npm run build:enqueue

3. 자주 발생하는 문제 & 조치법 (Troubleshooting)

  • 웹플래시 요청이 계속 queued에서 안 넘어감:
    • patternflow-worker.service가 켜져 있는지 확인: sudo systemctl status patternflow-worker.service
    • 꺼져 있다면 재시작: sudo systemctl restart patternflow-worker.service
  • spawn arduino-cli ENOENT 에러 발생:
    • patternflow-worker.service 파일 내 PATH 환경변수에 arduino-cli 설치 경로가 올바르게 지정되었는지 확인.