Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.
# 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]`에
아래 두 줄이 들어 있는지 확인하세요:
```ini
[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`:
```ini
[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`.
```bash
# 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. 주요 관리 명령어
### 서비스 상태 및 상태 확인
```bash
# 커뮤니티 웹 서버 상태 확인
sudo systemctl status patternflow-community.service
# 펌웨어 빌드 워커 상태 확인
sudo systemctl status patternflow-worker.service
# 빌드 워커 실시간 로그 확인
sudo journalctl -u patternflow-worker.service -f
```
### 서비스 재시작
```bash
# 웹 서버 재시작
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 타이머를 설치할 필요가 없습니다 — 대신 **워커가 꺼져 있으면
정리도 멈춥니다.**
```bash
cd web
# 뭐가 지워질지 먼저 확인 (아무것도 안 지움)
npm run sweep -- --dry-run
# 실제로 정리
npm run sweep
```
워커 로그에서 `retention swept` 줄로 마지막 실행 결과를 확인할 수 있습니다:
```bash
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가 다시 판정한다):
```bash
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');"
```
- 상태 보기:
```bash
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개인지).
### 펌웨어 빌드 큐 상태 점검 스크립트
```bash
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` 설치 경로가 올바르게 지정되었는지 확인.