# PI Continuum — 에어갭(폐쇄망) 배포 번들 인터넷이 없는 망에 PI Continuum 전체 스택을 올리기 위한 묶음이다. 레지스트리·패키지 저장소에 한 번도 나가지 않고, 이 디렉터리에 든 이미지 tar 만으로 배포가 끝난다. ``` nginx (:80/:443) ─┬─ /assets, /img, /static → 정적 파일 (collectstatic 산출물) └─ 나머지 → web (daphne, ASGI :8000) web ─────────────┐ worker-default │ 같은 이미지, 역할만 다름 (entrypoint 의 첫 인자) worker-high ├─→ postgres (pgvector/pg16) · redis (AOF) · minio (문서 원본·artifact) worker-log │ scheduler ───────┘ 업무 문서 분석 reconciler — DB manifest 기준 RQ 재배분·복구 (반드시 1개) bootstrap migrate + 기본 seeding 을 한 번 수행하고 종료 alpha-processing Redis blob 을 읽어 문서 텍스트를 gRPC 로 돌려주는 별도 컨테이너 ``` --- ## 1. 번들 내용 ``` __deploy/ ├── README.md 이 문서 ├── .env.example 환경값 견본 (실제 값이 든 .env 는 매체에만 있다) ├── compose.yml 스택 정의 — 빌드 단계가 없고 모든 이미지가 pull_policy: never ├── bundle-manifest.json (배포 ZIP) 버전·관리 파일·이미지·DB 호환 계약 ├── release-policy.json 릴리스 앱/환경/멱등 데이터 갱신 정책 ├── .bundle-state/ (현장 생성) 적용 버전·진행 상태·이전 파일/정적 파일 백업 ├── compose.patches.yml (생성물) bin/apply-patches.sh 가 만드는 현장 패치 override ├── compose.ports.yml (생성물) EXPOSE_*_PORT 로 up-*.sh 가 만드는 포트 override ├── conf/ nginx·postgres 설정의 **정본** │ ├── nginx/ app.conf · http.conf · https.conf │ ├── nginx/upstream/ docker.conf · podman.conf │ ├── nginx/certs/ TLS 인증서를 두는 자리 │ ├── postgres/init.sql 최초 기동 때 pgvector 확장 생성 │ └── litellm/config.yaml 선택형 Watsonx·Bedrock 게이트웨이 설정 ├── images/ │ └── *.tar 기본 이미지 7 개 + 선택형 LiteLLM ├── logs/ 컨테이너 로그가 쌓이는 호스트 디렉터리 (app · processing) ├── patches/ 현장에서 이미지의 특정 파일만 덮어쓸 때 (patches/README.md) └── bin/ ├── export-images.sh [폐쇄망 밖] 이미지 확보 → tar → 매니페스트 ├── apply-patches.sh patches/ 를 훑어 현장 패치를 적용·해제 ├── load-images.sh [폐쇄망 안] 체크섬 대조 → docker/podman load ├── up-compose.sh compose 로 기동 (docker compose) ├── up-podman-compose.sh compose 로 기동 (podman-compose) ├── up-docker-run.sh compose 없이 docker run 으로 기동 ├── up-podman-run.sh compose 없이 podman run 으로 기동 ├── up-run.sh 위 두 개의 공통 구현 (엔진만 인자로 다르다) ├── update-bundle.sh 일반 업데이트: register / plan / apply / status / recover ├── verify.sh 기동 후 점검 (alpha-processing 연동 포함) ├── backup-db.sh PostgreSQL 단일 DB 수동 백업 ├── restore-db.sh 앱 중지 확인·안전 백업 후 DB 복구 ├── auto-backup-db.sh 자동 백업 run/status/install/enable/disable/uninstall ├── auto_backup.py 호스트 보관·상태·systemd 설치 정책 (Python 표준 라이브러리) ├── systemd/*.in 배포별 service/timer 생성 템플릿 ├── down.sh 내리기 ├── lib.sh 모든 스크립트가 source 하는 공통 함수 ├── checks/*.py verify.sh 가 web 컨테이너 안에서 돌리는 점검 스크립트 ├── _compose_to_plan.py compose 파일 → run 명령 계획 (compose 없는 호스트용) └── _miniyaml.py PyYAML 이 없을 때 쓰는 최소 YAML 파서 ``` ### 기본 이미지 7 개 + 선택형 LiteLLM | tar | 태그 | 용도 | |---|---|---| | `pi-continuum-76d67d4.tar` | `pi-continuum:76d67d4` | 앱 (web·worker·scheduler·bootstrap) | | `alpha-processing-b11f26b.tar` | `alpha-processing:b11f26b` | 문서/URL 텍스트 추출 gRPC | | `pgvector-pgvector-pg16.tar` | `pgvector/pgvector:pg16` | PostgreSQL 16 + pgvector | | `redis-8-alpine.tar` | `redis:8-alpine` | 캐시 · RQ 큐 · 문서 blob | | `minio-minio-*.tar` | `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z.hotfix.7aa24e772` | 객체 저장소 | | `minio-mc-*.tar` | `quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z` | 최초 버킷 생성 (1회성) | | `nginx-1.30-alpine.tar` | `nginx:1.30-alpine` | 리버스 프록시 · 정적 파일 | | `berriai-litellm-v1.101.0.tar` | `ghcr.io/berriai/litellm:v1.101.0` | 선택형 Watsonx·AWS Bedrock 프록시 | LiteLLM은 2026-09-15 확인한 [PyPI 최신 정식 버전 1.101.0](https://pypi.org/project/litellm/1.101.0/)이며 GHCR의 같은 태그도 확인했다. `LITELLM_IMAGE`로 다른 반입 태그를 지정할 수 있다. `LITELLM_ENABLED=false`(기본)이면 기동·필수 이미지 검사에서 제외된다. 전부 `linux/amd64` 다. 배포 서버 아키텍처가 다르면 `PLATFORM=linux/arm64 ./bin/export-images.sh` 로 폐쇄망 밖에서 다시 만든다. --- ## 2. 반입 ### CI 산출물로 조립 (권장) `main` 에 push 될 때마다 `.woodpecker/deploy-bundles.yaml` 이 번들을 S3 의 고정된 두 prefix 에 올려 둔다. 날짜별로 쌓지 않고 **항상 최신 하나**다. ``` s3:///__deployment-files/ 커밋된 __deploy/ 트리 (compose·스크립트·conf·문서) s3:///__dependencies/ 외부 의존성 이미지 6종 tar (pgvector · redis · minio · mc · nginx · litellm) ``` 앱과 `alpha-processing` tar 은 여기에 없다. 그 둘은 **날짜별** build-image 산출물을 쓴다: ``` s3:///pi-continuum///pi-continuum-.tar s3:///alpha-processing///alpha-processing-.tar ``` 조립 순서: ```bash mkdir -p pi-continuum-deploy # 1. 배포 파일 (images/ · logs/ 는 빈 폴더로 함께 내려온다) rclone copy s3:/__deployment-files ./pi-continuum-deploy # 2. 외부 의존성 이미지 6종 (LiteLLM 포함) rclone copy s3:/__dependencies ./pi-continuum-deploy/images # 3. 앱·처리 이미지는 원하는 날짜 폴더에서 # (그 폴더에는 Trivy 리포트 .html/.pdf 도 있으므로 tar 만 가져온다) rclone copy s3:/pi-continuum/<날짜>/<시각> ./pi-continuum-deploy/images --include '*.tar' rclone copy s3:/alpha-processing/<날짜>/<시각> ./pi-continuum-deploy/images --include '*.tar' # 4. .env 를 만들고 값을 채운다 (S3 에 없다) cp pi-continuum-deploy/.env.example pi-continuum-deploy/.env ``` `.env` 의 `APP_IMAGE` · `PROCESSING_IMAGE` 를 내려받은 tar 의 태그와 맞춘다. tar 파일명은 `bin/export-images.sh` 가 만드는 것과 같은 규칙이라 두 방법을 섞어 써도 된다. `__deployment-files` 에는 **`.env` 와 실행 로그가 들어 있지 않다.** CI 는 Git 에 커밋된 것만 올리고, `images/` · `logs/` 밑은 통째로 제외한다(`__pycache__` 도 제외). 대신 `images/` · `logs/app/` · `logs/processing/` 은 **빈 폴더로 유지**되므로 위 순서대로 tar 을 부어 넣으면 그대로 완성된다. CI 는 바뀐 prefix 만 교체한다(`.ci-manifest.json` 의 fingerprint 비교). 첫 실행이거나 이전 업로드가 중간에 끊겼으면, 또는 CI 키에 `GetObject` 권한이 없어 이전 상태를 읽지 못하면 그 prefix 를 **전체 교체**한다. 그래서 두 prefix 는 항상 한 커밋에서 나온 온전한 세트다 — 반쯤 갱신된 상태로 남지 않는다. `.ci-manifest.json` 은 CI 내부용 표식이므로 배포에 쓰지 않는다. `main` push·수동 CI에서는 이미지 빌드와 번들 업로드가 끝나면 `.woodpecker/deploy-smoke-{docker-run,docker-compose,podman-run}.yaml`이 각각 별도 VM에서 이 디렉터리의 스크립트로 실제 배포한다. Docker run → Docker Compose → Podman run 순서로 실행한다. 각 방식은 `bin/verify.sh`와 nginx 경유 로그인·PI API·로그아웃 프로브까지 통과해야 한다. 처리 이미지는 이 파일의 `PROCESSING_IMAGE` 고정 태그에 맞는 S3 tar를 사용한다. 상세 설정·실패 조건은 [`__docker/README.md`](../__docker/README.md)의 CI `deploy-smoke` 절을 따른다. ### 직접 만들기 인터넷 되는 곳에 저장소 checkout 이 있으면 CI 없이도 만들 수 있다. ```bash # 앱과 alpha-processing 은 CI 산출물 tar 을 images/ 에 먼저 둔다. # 나머지 인프라 이미지는 이 스크립트가 받아서 저장한다. ./bin/export-images.sh ``` 직접 export할 때는 `.env`의 `LITELLM_ENABLED=true`인 경우에만 LiteLLM 이미지가 포함된다. CI 의존성 번들은 선택 여부와 관계없이 이 이미지도 포함해 현장에서 켤 수 있게 한다. ### 매체로 옮기기 `__deploy/` 전체(약 3.6 GB)를 매체에 복사해 폐쇄망으로 옮긴다. `.env` 는 git 에도 S3 에도 없으므로 **실제 값이 든 `.env` 를 매체에 같이 넣는다.** 특히 `VITE_ENCRYPTION_KEY` 는 5절 참고. --- ## 3. 사전 요구사항 배포 대상은 **KVM 가상 머신**(일반 리눅스 게스트)을 기준으로 한다. - Linux x86_64, 컨테이너 엔진 **docker** 또는 **podman** 중 하나 - vCPU 4 이상, 메모리 8 GB 이상 권장 — `alpha-processing` 이 ONNX 임베딩 모델을 올리고 Docling 이 문서를 변환한다 - 디스크 여유 약 25 GB (이미지 적재 후 약 8 GB + DB·문서·로그) - compose 는 **없어도 된다** — `up-docker-run.sh` / `up-podman-run.sh` 가 compose 파일을 읽어서 같은 스택을 `run` 명령으로 띄운다. 호스트에 `python3` 만 있으면 되고, PyYAML 이 없으면 번들에 든 최소 파서를 쓴다 - SELinux 를 enforcing 으로 쓰는 게스트(RHEL 계열)라면 4.5 절의 `SELINUX_LABEL` 참고 ## 4. 배포 ### 4.1 이미지 적재 ```bash cd __deploy ./bin/load-images.sh # 엔진 자동 감지 (docker 우선) ./bin/load-images.sh podman # 명시 ENGINE="sudo podman" ./bin/load-images.sh # rootful podman ./bin/load-images.sh --verify-only # 체크섬만 대조 ``` 각 tar 이 담고 있는 태그는 tar 안의 `manifest.json` 에서 읽고, 무엇이 있어야 하는지는 `compose.yml` 에서 읽는다. 별도 목록 파일을 두지 않으므로 목록이 낡을 일이 없다. 빠진 이미지가 있으면 적재 전에 멈추고, 적재 뒤 태그가 실제로 생겼는지까지 확인한다. `--verify-only` 는 적재 없이 이 대조만 한다. `nginx:1.30-alpine` 과 `pgvector/pgvector:pg16` 은 순정 이미지라 우리 설정이 들어 있지 않다. `conf/` 의 파일을 bind mount 로 넣어야 뜨며, 적재 단계에서 그 파일들이 다 있는지 확인한다. `conf/` 가 이 설정의 **정본**이다. 개발용 `__docker/compose.yml` 도 같은 파일을 (`../__deploy/conf/…`) 참조하므로 사본이 두 벌 생기지 않는다. | 파일 | 쓰임 | |---|---| | `conf/nginx/app.conf` | server 공통 — 업로드 크기, gzip, 보안 헤더, 프록시·SSE 타임아웃 | | `conf/nginx/http.conf` · `https.conf` | `NGINX_CONF` 로 고른다 | | `conf/nginx/upstream/docker.conf` · `podman.conf` | 엔진별 `web` 이름 해석 방식 | | `conf/postgres/init.sql` | 데이터 볼륨 최초 생성 때 `CREATE EXTENSION vector` | ### 4.2 환경값 ```bash cp .env.example .env # 매체에 .env 가 함께 왔으면 그것을 쓴다 vi .env ``` 반드시 확인할 값: | 키 | 설명 | |---|---| | `PUBLIC_URL` | 브라우저가 실제로 접속하는 주소. **scheme 이 실제 접속 방식과 같아야 한다** — HTTP 배포에 `https://` 를 적으면 Secure 쿠키가 발급돼 로그인 직후 401 이 난다 | | `VITE_ENCRYPTION_KEY` | 앱 이미지에 구워진 값과 **같아야** 한다 (5절) | | `DJANGO_SECRET_KEY`, `POSTGRESQL_PWD`, `MINIO_SECRET_KEY`, `DJANGO_SUPERUSER_PASSWORD` | 전부 바꾼다 | | `HTTP_PORT` / `HTTPS_PORT` | 기본 80 / 443 | | `LOG_DIR` | 로그가 쌓일 호스트 디렉터리 (기본 `./logs`) | | `EXPOSE_*_PORT` | 비워 두면 열지 않는다. 채우면 그 포트를 `0.0.0.0` 으로 연다 (4.3 절) | | `COMPOSE_PROJECT_NAME` | 컨테이너·볼륨·네트워크 이름 접두사 | ### ASGI · RQ 실행 수 `.env` 에 다음 값을 넣으면 `up-compose.sh` · `up-podman-compose.sh` · `up-docker-run.sh` · `up-podman-run.sh` 모두 같은 수로 실행한다. 값을 생략하면 ASGI web은 4개, RQ는 큐마다 1개다. `.env.example`은 오래 걸리는 PI 작업이 몰리는 RQ `default`를 2개로 둔다. ```bash WEB_REPLICAS=4 # Daphne(ASGI) 컨테이너 4개 RQ_DEFAULT_WORKERS=2 # 문서 분석·합성, 인터뷰 후처리 등 긴 작업 RQ_HIGH_WORKERS=1 # 별도 긴급 작업 큐 (현재 PI 작업은 대부분 default) RQ_LOG_WORKERS=1 # API 요청·오류 로그 저장 등 짧은 작업 ``` 모두 **1 이상의 정수**다. `web` 컨테이너 하나는 Daphne 프로세스 하나를 실행하고, RQ 컨테이너 하나는 한 번에 job 하나를 처리한다. 따라서 각 RQ 값은 해당 큐에서 동시에 처리할 수 있는 job 수다. `default=2`는 긴 문서 작업 하나가 실행 중이어도 다른 후처리 작업이 시작할 자리를 남기는 보수적인 시작값이다. `high`는 별도 큐라 `default`가 밀려도 그 작업을 대신 처리하지 않으며, `log`는 짧은 기록 작업을 전담한다. `scheduler`는 항상 1개로 둔다. 메모리·CPU·PostgreSQL 연결도 복제 수만큼 늘 수 있으므로 VM 용량과 실제 큐 대기 시간·워커 RSS를 보고 조정한다. `worker-default`/`worker-high` 의 `--max-jobs 100` 과 `worker-log` 의 `--max-jobs 500` 은 동시 실행 수가 아니라 **worker 한 프로세스의 처리 수명**이다. 예를 들어 default worker는 100번째 job을 끝낸 뒤 종료되고, `restart: unless-stopped` 가 새 worker를 띄운다. 대기 중인 job은 새 worker나 같은 큐의 다른 worker가 계속 처리한다. Compose 없는 `run` 경로도 이 정책을 `--restart unless-stopped` 로 전달하므로 별도로 `up-*.sh` 를 다시 실행할 필요가 없다. 단, Docker는 컨테이너가 최소 10초간 정상 실행된 뒤에야 재시작 정책을 적용한다. 아주 빠른 job만 처리해 그 전에 `--max-jobs` 에 도달하면 자동 재시작이 적용되지 않을 수 있으므로 워커 상태를 확인한다. 사용자가 명시적으로 중지한 컨테이너는 자동 재시작 대상이 아니다. 초기 설치 전에 복제 수를 정한다. 운영 중 변경은 새 릴리스 ZIP의 업데이트 계획에서 앱 실행 설정 변경으로 확인하고 작업 drain을 거쳐 반영한다(10절). 설치 시 저장한 실행 계획과 새 계획을 비교해 영향받는 복제만 교체·추가·제거한다. `./bin/verify.sh` 는 설정한 복제 컨테이너와 RQ 등록 수를 확인한다. ### LiteLLM · IBM watsonx.ai · AWS Bedrock (선택) 배포 `.env`와 `.env.example`의 기본값은 `LITELLM_ENABLED=false`다. 사용할 때는 `true`로 바꾸고 `LITELLM_MASTER_KEY`에 `sk-`로 시작하는 긴 무작위 인증키를 넣는다. 선택한 공급자의 환경값만 채우면 된다: | 공급자 | 환경값 | |---|---| | IBM watsonx.ai | `WATSONX_URL`, `WATSONX_PROJECT_ID`, `WATSONX_APIKEY` (또는 `WATSONX_TOKEN` / `WATSONX_ZENAPIKEY`) | | Watsonx 배포 공간 모델 | `WATSONX_DEPLOYMENT_SPACE_ID`, 모델 `watsonx/deployment/` | | AWS Bedrock | `AWS_REGION_NAME`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`; 임시 자격증명은 `AWS_SESSION_TOKEN`도 지정 | | Bedrock API key / IAM role | `AWS_BEARER_TOKEN_BEDROCK` 또는 컨테이너에서 접근 가능한 IAM role; role 사용 시 정적 키는 비움 | | 사설 Bedrock endpoint | `AWS_BEDROCK_RUNTIME_ENDPOINT`; 비우면 `https://bedrock-runtime..amazonaws.com` | | 사내 CA 사설 endpoint | `SSL_VERIFY=false` (기본 `true`); 검증 실패 시에만 임시로 내림 | [Watsonx 공식 환경 변수](https://docs.litellm.ai/docs/providers/watsonx)와 [Bedrock 인증 설정](https://docs.litellm.ai/docs/providers/bedrock)을 따른다. `WATSONX_*`는 기존 PI 직접 연결의 `WATSON_*` username/token 교환 설정과 별개다. 사설 Bedrock endpoint 를 받은 경우 `AWS_BEDROCK_RUNTIME_ENDPOINT`에 그 URL 을 넣는다. endpoint 만 바뀌고 SigV4 서명 region 은 `AWS_REGION_NAME` 을 그대로 쓰므로 실제 region 을 계속 채워 둔다. 이 값은 `bedrock-runtime` 만 덮으므로, IAM role 로 `AssumeRole` 을 하는 구성이면 STS 호출은 공개 endpoint 로 나간다 — 폐쇄망이면 정적 키나 `AWS_BEARER_TOKEN_BEDROCK` 을 쓴다. 사설 endpoint 가 사내 CA 인증서를 쓰면 TLS 검증이 실패하는데, 그때만 `SSL_VERIFY=false` 로 내린다. 이 스위치는 Bedrock 뿐 아니라 LiteLLM 의 모든 바깥 호출에 적용되므로 사내 CA 를 신뢰 저장소에 넣기 전까지의 임시 조치로만 쓴다. `up-*.sh` 는 `true`/`false` 외의 값을 거부한다. LiteLLM 이미지를 `load-images.sh`로 적재한 후 평소의 `up-*.sh`를 실행한다. 네 방식 모두 활성화된 게이트웨이의 health를 확인하고 앱을 시작한다. 호스트 포트는 열지 않으며 앱 컨테이너에서 `http://litellm:4000/v1`로 접근한다. PI 관리자 LLM 설정에서 provider=`openai_compatible`, URL=`http://litellm:4000/v1`, auth_type=`api_key`, API key 참조=`LITELLM_MASTER_KEY`로 지정한다. 모델명은 `watsonx/` 또는 `bedrock/` 전체를 입력한다. 공급자별 모델의 tool calling 지원은 사용하려는 PI 작업에 맞게 확인한다. 활성화 플래그는 게이트웨이 배포 여부를 정하며, 기존 관리자 모델 설정을 자동 변경하지 않는다. 설정 정본은 `conf/litellm/config.yaml`이다. 별도 DB 없이 동작하고, 외부 모델 비용표 다운로드와 telemetry는 끈다. 선택한 공급자 및 인증 endpoint에는 네트워크로 연결할 수 있어야 한다. `verify.sh`는 활성화된 LiteLLM 컨테이너의 health도 검사하며 유료 모델 요청은 하지 않는다. 운영 중 켜기·끄기·이미지/공급자 설정 변경은 인프라 변경이다. 앱이 해당 모델을 사용 중이면 먼저 작업을 마무리하고 기존 스택을 `down.sh`로 내린 뒤 설정과 이미지를 반영하여 다시 기동한다. `down.sh`는 Compose·run 모두 플래그를 이미 false로 바꿨어도 기존 LiteLLM 컨테이너를 함께 정리한다. 일반 ZIP 업데이트는 이미 켜진 LiteLLM의 설정을 유지한 앱 업데이트를 지원한다. 이 경우 manifest의 `infrastructure.litellm`에 현재 이미지 태그를 명시해야 한다. LiteLLM 활성화·비활성화·이미지·환경·config 변경은 일반 updater가 적용 전에 차단한다. 실제 컨테이너 검증은 저장소 루트에서 다음과 같이 재실행한다: ```bash docker pull ghcr.io/berriai/litellm:v1.101.0 . ./__set_env.sh prd RUN_LITELLM_INTEGRATION=1 python3 -m unittest discover \ -s .woodpecker/tests -p 'test_deploy_litellm_integration.py' -v ``` 이 검증은 Docker Compose와 배포 실행 계획의 Docker run으로 LiteLLM만 기동한다. 컨테이너는 `network=none`이고 포트를 공개하지 않는다. 컨테이너 내부의 테스트 HTTP 서버가 Watsonx·Bedrock 응답을 제공하며, 임시 인증값으로 chat·tool calling 변환을 확인한다. 운영 환경값을 컨테이너에 전달하거나 DB·실제 공급자 API에 접속하지 않는다. 테스트 종료 시 임시 컨테이너와 파일을 정리한다. 이미지 캐시는 재사용할 수 있게 남긴다. 검증(2026-09-15): 다음 명령으로 배포 범위 75개 테스트·59개 subtest가 통과했고, 선택형 S3 통합 1개를 건너뛰었다. Django 기본 검사, 프런트엔드 테스트 82개, 문서·i18n·디자인·Harness 검사도 통과했다. ```bash RUN_LITELLM_INTEGRATION=1 PYTEST_ADDOPTS='.woodpecker/tests --tb=short' ./__verify.sh ``` Docker Compose·Docker run의 실제 LiteLLM 기동/health, 외부 통신·포트 노출 없음, 인증키 누락·오류 차단, 정상키 모델 목록, 미설정 공급자 거부, Watsonx·Bedrock의 chat 및 tool calling HTTP 왕복을 확인했다. 설정 파일과 run 인자는 배포 정본을 사용했다. 이미지 digest는 `sha256:d295634e09c648dcdb72c4cc2dd226f5fb87823a73e88cbbed6f205e4deb044b`다. DB 없는 LiteLLM은 잘못된 인증키에 HTTP 400을 반환할 수 있고, `/v1/models`는 공급자 wildcard를 내장 모델 목록으로 펼쳐 반환한다. 테스트는 이 실제 동작을 반영한다. 테스트용 컨테이너와 임시 파일은 모두 정리했다. 현재 공급자 인증값은 비어 있어 실제 Watsonx·AWS 계정의 인증·모델 사용 권한과 추론은 검증하지 않았다. Podman은 일반 실행의 `newuidmap` 권한 오류와 관리자 실행 컨테이너의 소켓 생성 `PermissionError` 때문에 실기동 검증을 완료하지 못했다. 두 엔진의 실행 계획 검증은 통과했다. 위 명령의 pytest 범위는 `.woodpecker/tests`다. 앞선 기본 앱 전체 검증에서 확인한 WebSocket 테스트의 로컬 Redis(`127.0.0.1:6379`) 연결 거부 등 7개 실패를 해결하거나 앱 전체 테스트를 통과한 것으로 간주하지 않는다. ### 4.3 호스트 포트 노출 (선택) 기본은 **nginx 만** 호스트에 열린다(`HTTP_PORT`/`HTTPS_PORT`). PostgreSQL·Redis·MinIO 는 컨테이너 네트워크 안에만 있고 호스트에 나오지 않는다. 디버깅이나 외부 도구 연결이 필요하면 `.env` 에서 해당 값을 채운다. `up-*.sh` 가 `compose.ports.yml` 을 만들어 그 포트를 **`0.0.0.0`** 으로 붙인다 — VM 밖에서 바로 붙을 수 있다는 뜻이므로 방화벽을 함께 확인한다. 값을 비우거나 주석으로 되돌리면 다음 기동부터 다시 닫힌다. ```bash EXPOSE_POSTGRES_PORT=5432 EXPOSE_REDIS_PORT=6379 EXPOSE_MINIO_API_PORT=9000 EXPOSE_MINIO_CONSOLE_PORT=9001 ``` `compose.ports.yml` 은 생성물이라 직접 고치지 않는다. compose 경로와 compose 없는 경로가 같은 파일을 읽으므로 둘 다 동일하게 열린다. ### 4.4 로그 디렉터리 소유권 로그는 볼륨이 아니라 호스트 디렉터리(`LOG_DIR`, 기본 `./logs`)에 쌓인다. 쓰기 마운트라 컨테이너 사용자가 **쓸 수 있어야** 한다. bind mount 는 호스트 uid/gid 를 그대로 넘기므로 디렉터리 소유자를 실제 이미지의 uid/gid에 맞춘다. `up-*.sh`는 먼저 이미지 기본 사용자로 파일 생성·삭제를 검사하고, 실패하면 네트워크 없이 `id -u`·`id -g`를 실행해 숫자 계정을 확인한 후 `chown` → `sudo chown`으로 보정하고 다시 검사한다. 이미지 사용자 조회가 실패하거나 숫자 계정이 아니면 추측해서 권한을 바꾸지 않고 중단한다. ```bash mkdir -p logs/app logs/processing sudo chown 10001:10001 logs/app # web · worker-* · scheduler · bootstrap # 기본 배포 이미지 alpha-processing:b11f26b의 계정 sudo chown 10001:10001 logs/processing ``` `alpha-processing:4e8b5a2`는 `1000:1000`, `b11f26b`는 `10001:10001`이다. 태그를 유지한 채 신형 계정으로 하드코딩하면 구형 이미지의 쓰기 검사가 실패한다. rootless UID 매핑처럼 소유권 보정 후에도 쓰지 못하면 기존 `chmod 0777` fallback을 시도한다. 그래도 쓰지 못하면 `up-*.sh`가 **기동 전에 멈추고** 실제 이미지 계정에 맞는 `chown` 명령을 알려 준다. (그냥 두면 컨테이너는 뜬 직후 로깅 초기화에서 `PermissionError` 로 죽는데, 트레이스백만 보고는 원인을 알기 어렵다.) 2026-09-15 CI #41·#42의 Docker run은 이 계정 불일치로 앱 기동 전에 실패했다. DB 복구 healthcheck 변경 전부터 발생한 오류다. 격리된 임시 로그 폴더에서 기존 코드의 실패를 재현하고, 두 실제 처리 이미지와 앱 이미지로 보정 후 쓰기·삭제 및 0755 유지를 확인했다. `.woodpecker/tests/test_deploy_log_dirs.py`는 구형/신형 UID, UID와 다른 GID, sudo 보정, 사용자 조회 실패, 재검사 실패를 회귀 검사한다. CI와 같은 unittest 전체는 119개 중 116개 통과·opt-in 3개 건너뜀으로 완료했고 Harness 문서 검사도 통과했다. 세 배포 방식의 전체 CI smoke 재실행 결과는 별도로 확인한다. ### 4.5 기동 — 네 가지 방법 중 하나 ```bash ./bin/up-compose.sh # docker compose ./bin/up-podman-compose.sh # podman-compose ./bin/up-docker-run.sh # compose 없이 docker run ./bin/up-podman-run.sh # compose 없이 podman run ``` rootful podman 이 필요하면 앞에 `ENGINE="sudo podman"` 을 붙인다. 넷 다 같은 순서를 지킨다: **인프라(postgres·redis·minio) → 버킷 생성 → bootstrap(migrate + seeding) → 앱·처리 서비스 → nginx.** compose 구현마다 `depends_on` 의 `condition` 해석이 달라서, 스크립트가 각 단계의 healthy / exit 0 를 직접 기다린 뒤 다음으로 넘어간다. 어디서 멈췄는지 로그에 남는다. `up-*-run.sh` 는 토폴로지를 따로 들고 있지 않다. `compose.yml` 을 읽어 `_compose_to_plan.py` 로 `run` 인자로 옮긴 뒤 순서대로 실행한다. 아무것도 만들지 않고 실행될 명령만 보려면: ```bash PLAN_ONLY=1 ./bin/up-docker-run.sh ``` 호스트 사정으로 모든 컨테이너에 같은 옵션을 붙여야 할 때의 탈출구가 둘 있다. compose 파일을 고치지 않고 환경변수로만 준다. | 변수 | 쓰임 | |---|---| | `SELINUX_LABEL=Z` | SELinux enforcing 게스트에서 bind mount 에 라벨을 붙인다 (이름 있는 볼륨에는 붙이지 않는다) | | `EXTRA_RUN_OPTS` | 모든 컨테이너에 그대로 덧붙일 `run` 옵션. 예: `--security-opt seccomp=unconfined`, `--ulimit nofile=65536:65536` | #### podman 참고 - rootless podman 은 1024 미만 포트를 열지 못한다. `HTTP_PORT` 를 8080 등으로 올리거나 rootful 로 띄운다 (`ENGINE="sudo podman" ./bin/up-podman-run.sh`). - podman 의 컨테이너 이름 해석은 `aardvark-dns` 가 맡는다. 일반 VM 게스트에서는 기본 설정(netavark)으로 동작한다. `podman network create` 로 만든 네트워크에서 이름이 풀리지 않으면 `aardvark-dns` 가 떠 있는지 먼저 본다. - `conf/nginx/upstream/podman.conf` 는 `web` 을 기동 시 한 번만 해석한다. `web` 컨테이너를 다시 만들었다면 `podman exec <프로젝트>-nginx nginx -s reload`. ### 4.6 확인 ```bash ./bin/verify.sh # 엔진 자동 감지 ./bin/verify.sh podman ``` | # | 항목 | |---|---| | 1 | 필수 서비스와 설정한 web/RQ 복제 상태 (bootstrap 은 exit 0) | | 2 | `manage.py check` · 미적용 migration 없음 | | 3 | PostgreSQL 접속 · pgvector 확장 · migration 건수 | | 4 | Redis 연결(FILE_STORAGE db·web cache) · RQ 워커 default/high/log 등록 | | 5 | MinIO 버킷 읽기·쓰기 왕복 | | 6 | nginx 문법 · `GET /` 200 · `/assets/*.js` 200 · 호스트 포트 | | 7 | **alpha-processing 연동** — PDF 를 Redis blob 으로 올려 텍스트를 되받고, 임베딩도 왕복시킨다 | Podman은 healthcheck가 없는 실행 중 컨테이너의 health 값을 빈 문자열로 반환할 수 있다. `verify.sh`는 이를 Docker의 `-`와 동일하게 취급하며, `unhealthy`는 계속 실패로 판정한다. 7번은 앱이 실제로 쓰는 경로(`extract_text_from_file()`)를 그대로 왕복시킨다. 표준 라이브러리만으로 표식이 든 1쪽 PDF 를 만들어 보내고, 돌아온 텍스트에 그 표식이 있는지 본다. 이어서 임베딩 엔드포인트도 같은 계약(`apps.embedding.__api.embed`, task·dimension)으로 호출해 768차원 벡터가 돌아오는지 확인한다. ### 4.7 내리기 ```bash ./bin/down.sh # compose (docker) ./bin/down.sh compose podman ./bin/down.sh run # up-docker-run.sh 로 띄운 것 ./bin/down.sh run podman ./bin/down.sh run --volumes # 데이터 볼륨까지 (확인 입력을 받는다) ``` `--volumes` 는 DB·MinIO 객체·업로드 문서를 전부 지운다. 호스트 로그(`LOG_DIR`)는 남긴다. --- ## 5. VITE_ENCRYPTION_KEY — 가장 자주 걸리는 함정 로그인 비밀번호는 프런트엔드가 AES 로 암호화해 보내고 백엔드가 복호화한다. **프런트엔드 쪽 키는 Vite 빌드 시점에 번들 JS 안에 구워진다.** 폐쇄망에서는 프런트를 다시 빌드할 수 없으므로, `.env` 의 `VITE_ENCRYPTION_KEY` 는 반입한 앱 이미지를 만들 때 쓴 값과 반드시 같아야 한다. 다르면 화면도 뜨고 API 도 살아 있는데 **로그인만 조용히 실패한다.** 그래서 값은 번들 매체의 `.env` 에만 들어 있고 저장소에는 없다. **틀려도 오류가 나지 않으므로** 이미지를 만든 쪽에서 받은 `.env` 를 그대로 쓰고, 손으로 옮겨 적지 않는다. 키를 바꾸려면 폐쇄망 밖에서 그 값으로 이미지를 다시 빌드해 반입해야 한다. --- ## 6. 구성 세부 ### 네트워크 compose 가 만드는 기본 네트워크 하나(`<프로젝트>_default`)에 전부 올라간다. 서비스끼리는 컨테이너 이름이 아니라 **서비스 이름**(`postgres`, `redis`, `minio`, `web`, `alpha-processing`)으로 서로를 찾는다. `up-*-run.sh` 도 `--network-alias` 로 같은 이름을 붙이므로 compose 로 띄우든 `run` 으로 띄우든 설정이 같다. 호스트로 여는 포트는 기본적으로 nginx 의 `HTTP_PORT`/`HTTPS_PORT` 뿐이다. PostgreSQL·Redis·MinIO 는 `EXPOSE_*_PORT` 를 채웠을 때만 `0.0.0.0` 으로 열리고(4.3 절), gRPC(50051)는 어느 경우에도 열지 않는다. 외부 접근 차단은 스택 안이 아니라 **VM 의 방화벽에서** 한다. ### 볼륨과 로그 데이터는 이름 있는 볼륨(`postgres-data`, `redis-data`, `minio-data`, `static-data`, `media-data`)에 두고, **로그는 호스트 디렉터리**에 뺀다. ``` /app/ web · worker-* · scheduler · bootstrap (컨테이너 uid 10001) /processing/ alpha-processing (컨테이너 uid 10001) ``` 스택을 지웠다 다시 올려도 로그는 남는다. 두 이미지의 실행 uid 는 같지만 앱과 처리 로그를 섞지 않으려고 디렉터리를 나눠 두었다. 스크립트는 디렉터리를 만들고 **실제 이미지로 써지는지 확인만** 한다 — 소유권은 `up-*.sh` 가 자동으로 맞춘다(4.4 절). ### nginx upstream `conf/nginx/upstream/` 에 두 가지가 있고 엔진에 맞춰 자동으로 골라진다. - `docker.conf` — Docker 내장 DNS(`127.0.0.11`)를 resolver 로 써서 `web` 이 다시 떠도 따라간다. - `podman.conf` — podman 의 DNS 주소는 네트워크마다 달라 고정할 수 없어 기동 시 1회만 해석한다. `web` 컨테이너를 다시 만들면 `nginx -s reload` 가 필요하다. `NGINX_UPSTREAM` 으로 직접 지정할 수도 있다. ### HTTPS `NGINX_CERT_DIR`(기본 `conf/nginx/certs`)에 `fullchain.pem` · `privkey.pem` 을 두고 `.env` 를 바꾼 뒤 다시 띄운다. ``` NGINX_CONF=https PUBLIC_URL=https://pi.example.com ``` 인증서만 교체했다면 ` exec <프로젝트>-nginx nginx -s reload`. 앞단 로드밸런서가 TLS 를 종단하면 `NGINX_CONF=http` 그대로 두고 `PUBLIC_URL` 만 `https://…` 로 한다. --- ## 7. alpha-processing 환경변수 기존 검증 이미지(`4e8b5a2`)는 **DB 를 쓰지 않는다** (`settings.DATABASES = {}`). 이전 구성에서 넘기던 `POSTGRESQL_IP` / `POSTGRESQL_DB` / `POSTGRESQL_USER` / `POSTGRESQL_PWD` / `POSTGRESQL_PORT` 와 `OP_TYPE` 은 어디서도 읽지 않아 이 번들에서 뺐다. (`OP_TYPE` 은 이미지 자체의 baked env 에서도 빠졌고, Python 은 3.11 → 3.12 로 올라갔다.) 컨테이너 entrypoint 가 없으면 즉시 죽는 값: | 변수 | 값 | 왜 필요한가 | |---|---|---| | `REDIS_MAIN_IP` | `redis` | 문자열/임베딩 캐시(db0)와 파일 blob(db3)을 앱과 공유한다 | | `REDIS_MAIN_PORT` | `6379` | 같음 | | `VITE_OP_TYPE` | `PRD` | `settings.DEBUG` 와 로그 출력 위치를 정한다. PRD/STG 는 `__log` 파일로 쓴다 | 기본값이 있어 없어도 뜨지만 이 번들이 명시하는 값: | 변수 | 값 | 뜻 | |---|---|---| | `GRPC_PORT` | `50051` | gRPC 수신 포트 (호스트에 노출하지 않는다) | | `GRPC_MAX_WORKERS` | `4` | 이미지 기본값은 10. 한 호스트에 전체 스택을 올리므로 낮춘다 | | `EMBEDDING_ONNX_VARIANT` | `model` | 어떤 ONNX 파일을 올릴지 고른다 — 바꾸면 임베딩 결과가 달라진다 | | `EMBEDDING_ONNX_INTRA_THREADS` | `4` | ONNX 세션 스레드 | | `HF_HUB_OFFLINE`, `TRANSFORMERS_OFFLINE` | `1` | 폐쇄망에서 HuggingFace 로 나가지 않게 못 박는다 (이미지에도 같은 값이 있다) | 모델 파일은 이미지 안(`/models`)에 들어 있어 내려받지 않는다. 이 컨테이너가 맡는 일은 세 가지다. | gRPC API | 쓰는 곳 | |---|---| | `apps.docling.__api.extract` | 업무 문서(PDF·DOCX·XLSX·PPTX) 본문 추출 | | `apps.web.__api.extract` | URL 본문 추출 | | `apps.embedding.__api.embed` | 임베딩 (embeddinggemma-300m, 768차원, L2 정규화) | **임베딩도 이 컨테이너가 처리하므로 번들 밖 서비스가 필요 없다.** pgvector 검색에 쓰는 벡터는 여기서 나온다. rerank 는 제품에서 쓰지 않는다. `bin/verify.sh` 7번이 문서 추출과 임베딩을 각각 왕복시켜 확인한다. --- ## 8. 알려진 제약 - **LLM 은 외부 호출이다.** 폐쇄망에서는 설치 후 Settings 화면의 Agent LLM 설정에 사내 게이트웨이 URL·모델·토큰을 입력해야 한다. `.env` 의 일반 provider 키나 `OPENAI_BASE_URL` 은 이 설정을 대신하지 않는다. - **Agent LLM 설정(base model·토큰)은 기본 seeding 하지 않는다.** 수동으로 `seed_pi_continuum_config` 를 실행할 때만 `--api-key` 또는 `.env` 의 `OPENAI_API_KEY` 를 선택적으로 사용할 수 있다. - **scheduler 는 정확히 1개만** 떠야 한다. 스케일하지 않는다. - **URL 본문 추출**은 `alpha-processing` 컨테이너가 직접 나가서 수행한다. 폐쇄망에서 닿는 것은 사내 주소뿐이다. --- ## 9. 현장에서 코드 일부만 바꾸기 폐쇄망에서는 이미지를 다시 빌드할 수 없다. `patches/` 에 바꾼 파일을 두고 스크립트를 돌리면 컨테이너의 같은 경로 위에 **파일 단위로** 덮어쓴다. 나머지는 이미지 것을 그대로 쓰고, 컨테이너를 다시 만들어도 유지된다. ```bash # 1. 이미지에서 원본을 꺼낸다 mkdir -p patches/app/prompts docker run --rm --entrypoint sh pi-continuum:76d67d4 \ -c 'cat /app/prompts/interview_system.md' > patches/app/prompts/interview_system.md # 2. 고친다 vi patches/app/prompts/interview_system.md # 3. 초기 설치 준비 — patches/ 를 훑어 compose.patches.yml 을 만든다 ./bin/apply-patches.sh # --list 로 미리 확인, --clear 로 해제 ./bin/up-compose.sh # 초기 설치에만 사용; 운영 중에는 10절의 업데이트 절차 ``` | patches/ 경로 | 덮어쓰는 곳 | 적용 서비스 | |---|---|---| | `patches/app/<경로>` | `/app/<경로>` | bootstrap · web · worker-* · scheduler | | `patches/processing/<경로>` | `/www/alpha-processing/<경로>` | alpha-processing | `compose.patches.yml` 은 생성물이라 직접 고치지 않는다. `--clear` 로 지우면 다음 기동부터 이미지 원본으로 돌아간다. `--list`는 패치 파일이 하나도 없어도 기존 override와 파일 권한을 변경하지 않는다. `apply-patches.sh` 는 패치 파일 모드를 `a+r` 로 맞추고, 실제 이미지를 띄워 컨테이너 사용자(앱·처리 모두 uid 10001)로 **읽히는지 확인한 뒤에** override 를 만든다. `umask 077` 호스트에서 만든 `0600` 파일은 컨테이너가 열지 못하는데, 이 경우 컨테이너는 정상으로 뜨고 그 파일을 읽는 순간 실패해 원인을 찾기 어렵다. 현장 패치 파일과 override는 버전 ZIP이 소유하지 않으며 삭제하지 않는다. ### 급할 때: 컨테이너 안에서 직접 **컨테이너를 다시 만들면 사라진다.** 장애 대응용 임시 조치로만 쓴다. ```bash docker exec -i pi-continuum-web-1 sed -i 's/old/new/' /app/prompts/interview_system.md docker restart pi-continuum-web-1 # 파이썬 코드를 고쳤다면 반드시 재시작 ``` 확인한 제약: - `/app` 최상위는 root 소유라 새 파일을 만들 수 없다. 하위 디렉터리(`prompts/`, `alpha/`, `apps_pi_continuum/` …)는 실행 사용자(uid 10001) 소유라 수정된다. - 이미지에 `vi`·`nano`·`patch` 가 없다 (`sed`·`python` 은 있다). - 파이썬 코드 변경은 프로세스 **재시작** 후 반영된다. `.pyc` 는 만들지 않는다. - 디렉터리를 통째로 마운트하면 이미지의 나머지 파일이 가려진다. 파일 단위로 덮어쓴다. - **프런트엔드는 바꿀 수 없다.** Vite 가 빌드 시점에 번들로 구우므로 소스를 고쳐도 화면은 그대로다. 폐쇄망 밖에서 이미지를 다시 만들어 반입해야 한다. ## 10. 운영 ### 버전 ZIP 업데이트 `up-*.sh`는 **초기 설치·전체 기동** 진입점이다. 일반 업데이트는 `bin/update-bundle.sh`의 `plan` → `apply`를 사용한다. Docker Compose, Docker run, Podman run을 지원하며 podman-compose는 이 업데이트 계약의 대상이 아니다. 일반 업데이트에서 bootstrap, migration, 전체 seeding을 실행하지 않는다. 업데이트 입력은 전체 관리 파일과 manifest를 담은 ZIP이며 앱 이미지 `.tar`는 별도다. ZIP 생성·배포 자동화는 이 업데이트 도구의 CI에 연결하지 않는다. ZIP은 설치 디렉터리에 직접 덮어 풀지 않는다. 최초 설치 때만 새 빈 디렉터리에 풀고 `up-*.sh`를 사용한다. 과거 tar와 ZIP은 롤백을 위해 보관한다. `APP_IMAGE`를 수동으로 바꾸지 않는다. 적용된 이미지 선택은 `.bundle-state/images.env`에 기록하며 이후 전체 기동도 이를 읽는다. `bundle-manifest.json`에는 전체 커밋·고유 릴리스 ID, Git 조상 커밋, 파일별 SHA-256·크기· 권한(0644/0755), 적용할 앱 이미지 ref/ID와 tar 경로, 인프라 이미지 조건, 필요한 DB migration 이력·테이블/컬럼 타입·nullable 상태, 필수 환경 이름, 누적 데이터 작업을 기록한다. 동일 ID·동일 내용의 재적용은 변경 없이 종료한다. 중간 버전을 건너뛰어도 커밋 계보·이미지·실제 DB 계약과 누적 작업을 모두 검사한다. 같은 커밋의 재빌드도 별도 릴리스로 식별하고 같은 호환성 검사를 거친다. ZIP checksum은 무결성 검사이며 서명이 아니다. 승인된 전달 경로의 checksum과 대조한다. 릴리스 작성자는 `release-policy.json`을 검토한다. 앱 릴리스는 `application_action: replace`, 파일 전용 릴리스는 `keep`와 `compatible_app_ids`의 명시적 이미지 ID 목록을 사용한다. 새로운 멱등 데이터 작업은 `data_updates`에 새 ID로 추가하며 기존 ID/명령/롤백 조건을 바꾸거나 삭제하지 않는다. 현재 허용 명령은 `seed_pi_continuum_menu`뿐이다. 다른 작업은 검토 후 updater의 allowlist와 검증을 함께 확장한다. 일반 업데이트는 이 목록에서 미완료 작업만 실행한다. #### 최초 등록과 사전 설정 실행 호스트에 Python 3.10 이상과 기존 엔진이 필요하다. 설치 경로, 엔진 모드, `.env`의 명시적 `COMPOSE_PROJECT_NAME`은 최초 설치 때와 같아야 한다. PostgreSQL·Redis·MinIO는 이 스택 내부 서비스를 사용해야 한다. 새 필수 환경값이 누락되면 이름만 안내하고 적용을 중단한다. 운영자가 값을 추가한 뒤 다시 계획한다. 기존 현장값·인증서·패치·포트 override를 자동으로 덮어쓰지 않는다. 서비스 변경 검증에는 PI API 접근 권한이 있고 비밀번호 변경 대기 상태가 아닌 전용 계정이 필요하다. 로그인하면 기존 계정 세션에 영향을 줄 수 있으므로 사람이 사용하는 계정을 공유하지 않는다. 계정 정보를 설치 외부의 0600 JSON 파일로 준비한다: ```json {"username": "update-check", "password": "<현장 검증 계정 비밀번호>"} ``` manifest 없는 기존 설치는 **출처가 확인된 기존 배포본과 기존 이미지**의 baseline ZIP으로 등록한다. 새 버전 ZIP을 baseline으로 가장하거나 현장 디렉터리를 그대로 신뢰해 채택하지 않는다. 관리 파일의 내용·권한, 실행 앱 이미지 ID, 실제 DB 상태가 모두 baseline과 같아야 한다. 등록은 소유권 기록만 생성한다. 미등록 파일은 같은 내용이어도 임의로 채택하지 않는다. 현장 수정은 별도로 보관·검토하고 원본 출처를 확인한 후 다시 등록한다. 신규 설치도 최초 기동·검증 후 설치에 사용한 ZIP으로 한 번 등록한다: ```bash # 새 updater가 아직 없는 구형 설치에서는 승인된 새 ZIP을 별도 도구 디렉터리에 풀고 # 그곳의 update-bundle.sh에 --install-dir로 실제 기존 설치 경로를 지정한다. ./bin/update-bundle.sh --mode docker-compose register /반입/기존버전.zip --sha256 <승인된-SHA256> ./bin/update-bundle.sh --mode docker-compose register /반입/기존버전.zip --sha256 <승인된-SHA256> --apply ``` 과거에 ZIP을 발행하지 않았다면 `python3 bin/make-bundle.py --source <신뢰된-기존-배포소스> --metadata <검토된-기존-릴리스.json> --output `으로 전체 baseline을 만든다. metadata는 위 manifest 필드 중 `files/format/product/contract`를 제외한 항목을 제공한다. DB 요구 상태는 기존 이미지에 `bin/checks/schema_export.py`를 stdin으로 넣어 entrypoint를 Python으로 바꾸고 `--network none`에서 추출한다. 더미 환경값을 주입하며 운영 환경 파일을 사용하지 않는다. 현재 DB를 요구 상태로 복사해 계약을 만들어서는 안 된다. baseline 등록은 과거 데이터 작업 완료를 추정하지 않으므로 다음 적용에서 릴리스가 명시한 누적 멱등 작업을 한 번 재검증·실행할 수 있다. #### 계획·적용·진행 상태 ```bash # 새 이미지 tar만 먼저 적재 (기존 이미지·tar를 삭제하지 않는다). docker load -i /반입/새버전.tar ./bin/update-bundle.sh --mode docker-compose plan /반입/새버전.zip --sha256 <승인된-SHA256> ./bin/update-bundle.sh --mode docker-compose --credentials /안전한/위치/update-check.json \ --drain-timeout 300 --service-timeout 300 apply /반입/새버전.zip --sha256 <승인된-SHA256> ./bin/update-bundle.sh --mode docker-compose status # 같은 인자에서 모드만 docker-run 또는 podman-run으로 바꾼다. # rootful Podman 설치라면 최초 설치와 같은 권한으로 실행한다. ENGINE="sudo -n podman" ./bin/update-bundle.sh --mode podman-run plan /반입/새버전.zip ``` 순서는 임시 디렉터리 해제 → ZIP 경로/파일 형식/해시/권한 검사 → 기존 파일과 호환성 검사 → 계획 → 이전 관리 파일·복구 도구 백업 → 적용 → 서비스 검증이다. 백업·진행 단계·실패 사유는 `.bundle-state/transactions//`와 `pending.json`에 0600으로 기록한다. 이 디렉터리에는 이전 컨테이너 환경값도 있으므로 운영 비밀정보와 같은 권한으로 보관하며 실행 중 편집하지 않는다. 관리 범위는 `bin/`, `conf/nginx/`(certs 제외), `conf/postgres/`, `conf/litellm/`, `compose.yml`, `README.md`, `.env.example`, `release-policy.json`이다. ZIP은 이 범위의 전체 파일을 포함한다. `.env`, 인증서, 이미지 tar, 로그, `patches/`, DB 백업, 데이터 볼륨은 보존한다. 삭제는 이전 manifest가 소유한 파일에만 적용한다. 관리 파일의 현장 수정·삭제·권한 변경, 새 관리 경로의 출처 불명 파일·symlink가 있으면 적용 전에 중단한다. 설치 디렉터리 전체에 삭제 동기화를 하지 않는다. | 변경 | 반영 | |---|---| | 문서·관리 스크립트 | 파일만 교체 | | nginx 설정 | 격리 nginx로 사전 문법 검사, 정상 종료 후 nginx만 재생성, 실제 bind mount SHA 확인 | | 앱 이미지·앱 실행 설정·현장 .env | 필요한 앱 서비스만 재생성; .env 변경은 앱 전체에 반영 | | 선언된 기본 데이터 작업 | 미완료 멱등 명령만 실행·기록 | | PostgreSQL/Redis/MinIO/processing 및 nginx 이미지, 데이터 볼륨·네트워크 변경 | 일반 업데이트 사전 차단, 별도 인프라 절차 필요 | 서비스 반영 시 nginx를 QUIT으로 정상 종료하여 신규 요청을 차단하고 기존 요청이 끝나기를 기다린다. WebSocket/SSE 등 장기 연결도 종료 대기 대상이다. 앱 변경은 scheduler를 pause하고 default/high/log 큐를 suspend한 뒤, RQ 실행 레지스트리와 busy worker가 연속으로 비었는지 확인한다. 대기 시간 초과 시 파일·이미지를 교체하지 않고 큐와 scheduler, nginx를 복원한다. worker에 TERM은 실행 세대당 한 번만 보내며 강제 종료하지 않는다. 정상 종료 자체가 끝나지 않으면 pending 상태로 남겨 운영자 확인을 요구한다. 엔진의 `stop --time -1`을 사용하여 강제 종료 타이머를 두지 않는다 ([Docker](https://docs.docker.com/reference/cli/docker/container/stop/), [Podman 4.9](https://docs.podman.io/en/v4.9.3/markdown/podman-stop.1.html)). 이후 관련 앱을 정상 종료하고 영향받는 서비스만 교체한다. PostgreSQL·Redis·MinIO· processing과 데이터 볼륨은 유지한다. web 변경 전 공유 static 볼륨을 백업하고, collectstatic 충돌을 피하도록 web을 하나씩 기동해 health를 기다린다. 공개 포트 없는 임시 nginx에서 로그인·PI 문맥·언어·사업부 조회·로그아웃, MinIO 임시 객체 왕복, PDF 추출·임베딩을 확인한 뒤 큐·scheduler·공개 nginx를 연다. 외부 LB, 직접 앱 포트, 외부 RQ producer는 업데이트 전에 운영자가 별도로 차단해야 한다. 이 구현의 요청 차단 경계는 번들의 nginx와 scheduler/RQ다. #### 실패·재실행·롤백 경계 `status`가 pending이면 새 ZIP을 적용하지 말고 실패 단계와 복구 결과부터 확인한다: ```bash ./bin/update-bundle.sh --mode docker-compose --credentials /안전한/위치/update-check.json recover # bin 교체 중 프로세스가 끊겨 진입점이 손상된 경우: python3 .bundle-state/transactions//updater/update_bundle.py \ --install-dir "$PWD" --mode docker-compose --credentials /안전한/위치/update-check.json recover ``` 검증 전 실패는 이전 앱과 현재 DB 계약이 호환되고 실행한 데이터 작업이 모두 `backward_compatible: true`일 때만 이전 파일·이미지·실행 환경·정적 파일로 되돌린다. 확인할 수 없으면 서비스를 닫은 채 `recovery_required`로 남긴다. 검증 완료 후 개방 단계 실패는 검증된 새 버전 개방을 재시도한다. 신호 기록 직후 프로세스가 끊긴 경우 실제 신호 전달 여부가 불명확하므로 재전송하지 않는다. 정지 상태를 운영자가 확인하고 안전하게 마무리한 후 `recover`를 재실행한다. 현장 설정/override를 트랜잭션 중 변경하면 자동 복구도 중단한다. DB 백업 복원을 일반 실패 처리에 자동으로 연결하지 않는다. 필요한 DB 스키마 상태가 다르거나 확인할 수 없으면 업데이트는 파일·서비스 변경 전에 차단된다. 스키마 변경 릴리스의 별도 운영 순서는 다음과 같다: 1. 외부 요청·scheduler·외부 producer의 작업 투입 차단. 2. 실행 중 작업 종료 확인. 시간 초과 시 강제 종료하지 않고 보류. 3. DB 백업을 생성하고 일회용 DB에서 복원 검증. 4. DB 담당자가 승인된 스키마 변경 수행. 에이전트/updater는 migration 명령을 실행하지 않는다. 5. 실제 DB와 이전/새 앱 호환성을 재평가한 뒤 새 앱 기동·로그인/주요 기능 검증. 6. 큐·scheduler·서비스 개방. 스키마 변경 후 이전 앱이 호환되지 않으면 일반 롤백을 사용하지 않는다. DB 복원이 필요하면 DB만 되돌려 끝내지 말고 같은 시점의 MinIO 객체와 DB 참조, RQ 대기·실행·완료 작업의 중복/누락을 함께 대조하여 별도 복구 계획을 승인받는다. #### CI의 스크립트 검증 기존 `deploy-bundles`의 unittest 단계에서 `.woodpecker/tests`를 자동 탐색한다. 이미지 export/load의 정상·실패 경로, patch list/apply/clear, down의 확인·dry-run·소유 범위, 백업/복원 보호 조건, 복제 계획, ZIP 무결성·업데이트 충돌/drain/복구를 엔진 대역으로 검사한다. `bin/`의 모든 Shell/Python 파일은 환경을 로드하지 않고 구문 검사도 수행한다. `test_bundle_update.py`의 관리 파일 fixture는 생성 직후 권한을 명시한다. `Path.write_text()`만 사용하면 호스트 `umask`에 따라 `0664`/`0600` 등이 되어 `invalid managed file path/permission: .env.example`로 모든 테스트가 setup에서 실패한다. 실제 번들의 `0644/0755` 제한과 현장 권한 변경 감지는 그대로 유지하며, 테스트를 위해 검증기를 완화하거나 프로세스 전체의 `umask`를 고정하지 않는다. 새 관리 파일 fixture에도 `write_managed_fixture()`를 사용한다. `BundleUmaskTests`는 별도 프로세스에서 `0000/0002/0022/0027/0077`로 번들 테스트 전체를 반복해 이 조건을 검사한다. 2026-09-15 수정 후 CI와 같은 `python3 -m unittest discover -s .woodpecker/tests -v`는 78개 중 75개 통과, opt-in 통합 테스트 3개 건너뜀으로 완료했다. umask 회귀 검사에서는 각 조건의 번들 테스트 20개가 모두 통과했다. 세 배포 smoke workflow는 로그인 확인 후 `test-deploy-operations.py`를 실행한다. 이 검증은 CI 실행 번호와 `.ci/` 아래 설치 경로를 확인하고, 해당 스택 내부 서비스만 사용한다. 실제 dependency tar의 재사용·verify-only·skip·force-load, 앱/processing 사용자로 패치 읽기, DB sentinel 생성 → 백업 → 앱 종료 → 복원 → 로그인, 종료 후 데이터 볼륨·환경·백업 보존을 검사한다. 마지막 `down --volumes`는 기존 smoke 종료 처리에서 수행한다. 추가 secret이나 ZIP 자동 발행은 없다. 호스트 운영 DB를 사용하는 `__verify.sh` 또는 Django migration 명령으로 테스트하지 않는다. #### 구현 검증 기록 (2026-09-14) - DB/엔진 대역을 사용한 업데이트 검증 13개로 세 모드의 트랜잭션 경계를 확인했다. - 실제 Docker run / Docker Compose 일회용 nginx에서 정상 종료·선택 재생성·파일 bind 내용 교체·static 볼륨 백업/복원을 확인했다. 테스트가 만든 컨테이너/볼륨은 정리했다. - 실제 이미지에 `--network none`을 적용한 요구 스키마 추출(74개 테이블/98개 이력)과 임시 Git 저장소 → 전체 ZIP(39개 관리 파일) → checksum/해제 검증을 실행했다. - 로컬 Podman은 컨테이너의 소켓 생성이 `Permission denied`로 거부되어 nginx 검증을 완료하지 못했다. S3 발행 및 실제 이전/새 앱 전체 스택의 로그인·업그레이드는 이번 워크스페이스에서 실행하지 않았다. 이전 버전 ZIP을 받는 CI 경로와 baseline secret 요구는 없다. - 운영 DB에는 접속하지 않았다. 운영 DB를 대상으로 하는 기본 `__verify.sh`는 실행하지 않았다. `__check_agent_docs.sh`는 기존 인터뷰 문서의 중복 HTML ID `s80`, `s80-1`~`s80-4`로 실패했다. 업데이트 변경과 무관한 인터뷰 본문은 수정하지 않았다. #### 추가 검증 (2026-09-15) - Shell 계약·업데이트 명령 진입점 테스트를 기존 CI unittest 자동 탐색에 추가했다. 전체 61개 중 60개가 통과했고 기존 opt-in 검증 1개는 건너뛰었다. - 네트워크를 차단한 별도 Docker PostgreSQL에서 실제 dump/restore, 덮어쓰기 거부, DB 이름 확인, 복구 전 안전 백업, sentinel 데이터 복원을 확인하고 컨테이너·볼륨을 정리했다. - 세 배포 방식의 전체 smoke 실행은 CI에서 확인해야 한다. ZIP을 자동 발행하거나 추가 검증 계정/secret을 요구하지 않고, 기존 smoke가 생성한 일회용 환경을 사용한다. - 운영 DB에 접속하지 않았으며 문서 검사 `__check_agent_docs.sh`는 통과했다. ### 로그 ```bash tail -f logs/app/*.log logs/processing/*.log # 애플리케이션 로그 (호스트) logs -f <프로젝트>-web # 컨테이너 stdout ``` ### PostgreSQL 백업·복구 ```bash ./bin/backup-db.sh # Docker, backups/<프로젝트>__.dump ./bin/backup-db.sh podman /안전한/저장소/db.dump ``` 실행 중인 PostgreSQL 컨테이너를 자동 탐색해 `pg_dump -Fc` 형식으로 백업한다. 완료 전 임시 파일은 삭제하고, `pg_restore --list` 확인을 통과한 파일만 최종 이름으로 저장한다. `backups/`는 Git에서 제외되며 덤프에는 실제 운영 데이터가 들어 있으므로 별도 보관·접근 통제가 필요하다. 서버 장애에 대비하려면 다른 저장소에도 복사한다. 수동·자동 백업과 복구는 배포 루트의 `.db-operations.lock`에 `flock` 잠금을 공유한다. 경합하면 대기 없이 실패하며, 자동 백업은 아래 재시도 정책을 따른다. 복구 전 안전 백업은 잠금을 가진 내부 함수로 실행해 재잠금 교착을 피한다. **잠금 파일은 삭제·교체하지 않고, 수동 백업/복구도 설치한 백업 계정으로 실행한다.** 복구 전에는 신규 요청을 받지 않도록 nginx·web을 내리고, 실행 중인 RQ 작업이 끝난 뒤 worker·scheduler·processing을 정상 종료한다. 스크립트는 앱 컨테이너가 하나라도 실행 중이면 대상 목록을 출력하고 중단한다. 다른 DB 클라이언트의 접속도 끝내야 한다. DB인 postgres는 계속 실행해야 한다. 출처를 신뢰하는 백업만 복구한다. Compose와 compose 없는 run 방식 모두 같은 스크립트를 쓴다. ```bash # 아래 명령은 예시다. 복제된 web/worker 컨테이너도 모두 중지해야 한다. docker compose -f compose.yml stop nginx web worker-default worker-high worker-log scheduler processing ./bin/restore-db.sh ./backups/db.dump --confirm-db '' # Podman 또는 compose 없는 환경도 컨테이너를 모두 중지한 뒤: ./bin/restore-db.sh podman ./backups/db.dump --confirm-db '' ``` 복구는 먼저 현재 DB를 `backups/pre-restore_*.dump`로 자동 백업한다. 이 단계나 아카이브 검증이 실패하면 원본 DB를 건드리지 않는다. 성공하면 `dropdb --force`로 대상 DB에 남은 연결만 종료하고 DB를 새로 만들어 아카이브를 복원한다. prepared transaction·활성 logical replication slot/subscription처럼 PostgreSQL이 강제 종료할 수 없는 blocker는 계속 실패로 처리한다. PostgreSQL healthcheck는 복구 대상이 아니라 항상 유지되는 `postgres` maintenance DB를 확인하므로 Docker와 Podman의 주기 실행 차이가 drop/recreate와 경합하지 않는다. 실패하면 앱을 시작하지 말고 출력된 복구 전 백업으로 원인을 확인한다. 성공 후에는 중지했던 앱 컨테이너를 다시 시작하고 서비스 상태를 확인한다. 이 덤프는 PostgreSQL 단일 DB의 스키마·데이터·객체 권한을 포함하지만 클러스터 전체의 역할 생성은 포함하지 않는다. 별도 DB 역할을 사용한다면 복구 환경에 먼저 만들어야 한다. MinIO 객체는 `minio-data` 볼륨을 따로 백업·복구해야 하고 Redis의 RQ 대기 작업·캐시는 포함하지 않는다. 과거 DB로 되돌릴 때는 MinIO 객체와 Redis 대기 작업이 그 시점의 DB 상태에 맞는지도 앱 재시작 전에 확인한다. ### PostgreSQL 자동 백업 (호스트 systemd timer) Linux 호스트의 Bash·Python 3 표준 라이브러리·util-linux(`flock`, `mountpoint`)·systemd(`systemctl`, `systemd-analyze`, user timer는 `loginctl`)를 사용한다. Docker/Podman, Compose/run 네 가지 기동 방식을 동일한 기존 백업 스크립트로 처리한다. 실행 계정은 해당 엔진의 컨테이너와 배포 `.env`에 접근하고 배포 잠금·상태·백업 디렉터리에 쓸 수 있어야 한다. | 정책 | 기본값 / 동작 | |---|---| | 예약 | 매일 **03:00 Asia/Seoul**, `--calendar`로 systemd calendar 식 지정 | | 누락 실행 | `Persistent=true`: 호스트 정지 중 놓친 예약은 타이머 재활성화 후 한 번 보완 | | 재시도 | 최초 1회 + 실패 후 **15분 간격 최대 3회**, 모두 실패하면 exit 1. 다음 예약은 새 실행 주기 | | 저장 | 배포 루트 `backups/auto/<프로젝트+해시>//auto--.dump` | | 보관 | 기본 **30일**, `--retention-days`로 변경. 새 덤프 검증·메타데이터·성공 상태 저장 후에만 정리 | | 정리 범위 | 같은 프로젝트/DB의 자동 파일명과 메타데이터가 일치하는 보관기간 초과 파일만 삭제. 최신 성공본·수동·`pre-restore`·미확인 파일은 보존 | | 접근·증거 | 덤프/JSON은 0600. `.dump.json`에 SHA-256, DB·프로젝트, UTC 시작/완료 시각, 크기·소요 시간 기록 | | 상태 | `.auto-backup/last-success.json`, `last-failure.json`. 실패 후 성공해도 실패 이력은 마지막 1건 보존 | `pg_dump`는 서비스 읽기·쓰기 중에도 일관된 단일 DB 백업을 만든다([PostgreSQL 16 문서](https://www.postgresql.org/docs/16/app-pgdump.html)). 누락 예약 보완의 의미는 [systemd timer 문서](https://github.com/systemd/systemd/blob/main/man/systemd.timer.xml)를 따른다. 하루 간격의 복구 지점을 제공하지만, 실패·장시간 실행까지 포함한 최대 데이터 손실 24시간을 보장하지 않는다. MinIO 문서 원본·Redis·클러스터 역할 생성은 포함하지 않는다. #### 설치 → 첫 백업 → 격리 복원 → 활성화 실제 배포 루트에서 실행한다. 기존 `backup-db.sh`의 기본 디렉터리는 `./backups/`이며, 자동 백업은 그 아래 `./backups/auto/`를 첫 실행 시 생성해 사용한다. 저장 경로와 마운트 옵션 없이 설치하면 이 로컬 기본 경로가 적용된다. 아래 Docker 예시는 현재 로그인 계정에 엔진 접근 권한이 있는 경우다. | 설치 옵션 | 의미 | |---|---| | `--engine` | 생략 또는 `auto`이면 `--user` 계정으로 Docker/Podman의 실행 중 컨테이너를 조회해 이 프로젝트의 PostgreSQL이 있는 엔진을 선택한다. 후보가 없거나 여러 개면 명시가 필요하다. 선택 결과는 설정에 저장하며 매일 다시 탐색하지 않는다. `docker`/`podman` 직접 지정도 가능하다. | | `--scope system` | 호스트 전체 systemd에 타이머를 등록한다. 설치/관리는 root로 하고, 실제 백업은 `--user` 계정으로 실행한다. | | `--scope user` | 해당 계정의 systemd에 타이머를 등록한다. 해당 사용자 세션에서 설치하며 linger로 로그아웃 후 실행을 유지한다. Rootless Podman은 이 방식이 필요하다. | | `--user` | 실제 백업 실행 계정. Docker라면 해당 데몬 접근 권한(보통 docker 그룹), 배포 `.env` 읽기 권한, 배포 잠금·상태·`backups/auto/` 쓰기 권한이 필요하다. Rootless Podman은 컨테이너 소유 계정을 지정한다. | **일반적인 Docker 서버 백업은 `--scope system`을 사용한다.** 서버가 부팅되면 사용자 로그인 없이 예약을 관리하며, 백업은 `--user`에 지정한 계정으로 실행한다. 설치 명령에 `sudo`를 붙여도 실제 백업 계정은 이 설정을 따른다. `user` 방식의 systemd는 기본적으로 로그인 세션에 연결되지만, 이 설치기는 linger를 활성화해 부팅 시 사용자 systemd를 시작하고 로그아웃 후에도 유지한다. Rootless Podman은 컨테이너 소유 계정의 `--scope user`를 사용한다. system scope에서 설치자와 실행 계정이 다르면 `runuser`(util-linux)로 실행 계정을 전환해 엔진을 탐색한다. 엔진 조회는 각각 최대 10초이며, 실행 파일 없음·데몬 미연결·권한 오류는 후보에서 제외한다. ```bash # 기존 백업 디렉터리 아래 ./backups/auto/ 사용 sudo ./bin/auto-backup-db.sh install --scope system --user "$(id -un)" # 설치는 service/timer와 현장 설정만 생성하며 예약 실행을 활성화하지 않는다. # 백업 실행 계정으로 첫 백업과 체크섬 검증 ./bin/auto-backup-db.sh run ./bin/auto-backup-db.sh status --check --verify-checksum # status의 last_success.file을 격리 PostgreSQL 16 + pgvector의 별도 DB에 복원해 # 데이터·확장·권한을 확인한 뒤에만 다음 명령을 실행한다. sudo ./bin/auto-backup-db.sh enable --restore-verified ``` `--restore-verified`는 운영자가 실제 격리 복원을 확인했다는 선언이다. 활성화 명령은 최근 성공본·현재 저장 경로·메타데이터·체크섬을 재확인한다. `pg_restore --list`만으로 실제 복원 성공을 보장하지 않는다. 설치 과정에서 계산한 실제 배포 경로와 명시한 엔진·계정은 현장 설정/유닛에 저장되며 저장소에 checkout 절대 경로를 고정하지 않는다. 설치한 배포 루트를 이동하거나 DB/프로젝트·계정·예약·보관 설정을 바꾸려면 `uninstall` 후 새 위치/옵션으로 다시 설치한다. Rootless Podman은 **컨테이너를 실행한 사용자의 로그인 세션**에서 다음처럼 설치한다. 설치기가 `loginctl enable-linger <사용자>`를 실행해 로그아웃 후에도 user manager가 유지되도록 한다. 호스트 정책이 이를 거부하면 관리자가 해당 계정의 linger를 설정한 뒤 재실행한다. ```bash ./bin/auto-backup-db.sh install --scope user --user "$(id -un)" ./bin/auto-backup-db.sh run ./bin/auto-backup-db.sh status --check --verify-checksum # 격리 복원 검증 후 ./bin/auto-backup-db.sh enable --restore-verified ``` Rootful Podman은 `sudo ... install --engine podman --scope system --user root`를 사용하고 첫 실행도 root로 한다. 비루트 Podman 계정에 system timer를 지정하면 설치를 거부한다. user timer 설정·상태 명령은 해당 사용자로, system timer 설치/활성화/해제는 root로 실행한다. #### 조회·중지·보관 및 장애 ```bash ./bin/auto-backup-db.sh status --check # 위 JSON의 unit 값을 사용한다. user timer이면 systemctl/journalctl에 --user 추가. systemctl list-timers 'pi-db-backup-*' journalctl -u '.service' sudo ./bin/auto-backup-db.sh disable sudo ./bin/auto-backup-db.sh uninstall ``` `disable`은 타이머를 중지하며 진행 중 덤프를 강제 종료하지 않는다. 실행 중에는 상태 변경/해제 잠금이 실패하므로 작업 종료 후 재실행한다. `uninstall`은 유닛을 제거하고 설정을 `.auto-backup/uninstalled-config.json`에 남긴다. 백업·성공/실패 기록·공통 잠금·다른 user 서비스가 공유할 수 있는 linger는 보존한다. `run`은 systemd 호출 없이도 실행되므로 비활성으로 설치한 설정을 이용해 crontab에서도 호출할 수 있다. 그 경우 cron 실행 계정을 맞추고 별도 모니터링에서 `status --check`를 호출한다. systemd와 중복 예약하지 않는다. 전체 자동 실행 주기는 `.auto-backup/run.lock`, 실제 DB 백업/복구는 `.db-operations.lock`으로 중복을 차단한다. NAS 옵션을 지정하면 설치·각 시도·게시·정리 때 실제 마운트를 검사한다. 디렉터리 핸들에 작업 위치를 고정해 마운트 해제 시 같은 경로의 로컬 디스크에 대신 저장하지 않는다. NAS 미연결·덤프 실패·공간 부족 시 기존 백업을 정리하지 않고 실패 상태를 남긴다. 실패로 남은 미확인 덤프나 메타데이터 없는 파일은 자동 삭제하지 않으므로 운영자가 확인한다. 상태 디스크까지 꽉 차면 상태 기록도 실패할 수 있으므로 서비스 종료 코드와 journal을 함께 확인한다. 자동 백업을 `enable`한 배포는 `bin/verify.sh`에서 최초 성공 없음·덤프/메타데이터 누락·최근 미복구 실패·스냅샷 시작 기준 24시간 초과를 실패로 처리한다. 수동으로 systemctl만 조작하면 현장 설정의 활성화 의도와 달라질 수 있으므로 위 관리 명령을 사용한다. 성공 직후 재시도 시간이 새벽 예약보다 늦어지거나 오래 걸리는 덤프는 이 24시간 기준을 넘을 수 있다. `.auto-backup/`, `.db-operations.lock`, `backups/`는 Git/번들 관리 대상에서 제외한다. 버전 ZIP 업데이트는 `bin/systemd/*.in`과 실행 코드만 교체하며 백업·현장 설정·호스트 유닛 등록은 보존한다. 템플릿 변경을 설치된 유닛에 반영하려면 해제·재설치한다. 같은 디스크의 백업은 서버·디스크 장애로 함께 유실될 수 있으므로 독립된 NAS 등 별도 저장소를 권장한다. #### 자동 백업 검증 운영 DB에 연결하는 루트 `__verify.sh` 대신 이 변경의 검증은 DB 없는 `.woodpecker/tests`와 격리 컨테이너에서 수행한다. ```bash python3 -m unittest discover -s .woodpecker/tests -p 'test_deploy_auto_backup.py' -v python3 -m unittest discover -s .woodpecker/tests -p 'test_bundle_update.py' -v bash .woodpecker/scripts/test-auto-backup-restore.sh docker ./__check_agent_docs.sh ``` 합성 복원 스크립트는 임시 PostgreSQL 16 + pgvector 컨테이너를 `--network none`, 호스트 포트/데이터 볼륨 없이 만들고 자동 백업을 별도 DB에 복원한 뒤 행·한글·벡터·확장과 SHA-256을 확인한다. 종료 시 자신이 만든 컨테이너와 임시 파일만 제거한다. 프로젝트 환경을 사용하는 개발 워크스페이스의 Python 실행 전에는 `__set_env.sh` 로드 규칙을 따른다. 이 스크립트는 자체 합성 환경만 사용한다. `SEED_API_ENDPOINTS`는 기본 배포에 필요하지 않다. 생략하면 `false`로 처리되어 자동 API 등록을 건너뛴다. 레지스트리 기반 그룹 권한을 운영할 때만 배포 환경에서 `SEED_API_ENDPOINTS=true`를 지정하거나 `scan_api_endpoints --apply`를 수동 실행한다. 새 항목은 `require_authentication=False`로 생성되므로 등록만으로 접근이 제한되지는 않는다. 각 항목에서 인증 요구를 켠 뒤 그룹 권한을 부여해야 한다. PI Continuum API는 별도의 접근 정책도 적용한다. --- ## 11. 문제 해결 | 증상 | 확인 | |---|---| | `이미지 ... 가 없다` | `./bin/load-images.sh <엔진>` 을 먼저 돌렸는지. 엔진마다 저장소가 따로다 | | `번들에 설정 파일이 없다` | `conf/` 가 비었다. 매체를 온전히 복사했는지 확인한다 | | `컨테이너가 로그 디렉터리에 쓰지 못한다` | 자동 보정이 모두 실패한 경우. 메시지의 `chown` 을 그대로 실행한다 | | 패치가 반영되지 않는다 | `./bin/apply-patches.sh --list` 로 대상 확인 후 다시 기동했는지 | | `VITE_ENCRYPTION_KEY 가 ... 다르다` | 5절. 번들 매체의 `.env` 값을 그대로 쓴다 | | 화면은 뜨는데 로그인만 실패 | 같은 원인이거나 `PUBLIC_URL` 의 scheme 불일치 | | `bootstrap 이 exit 1` | ` logs <프로젝트>-bootstrap`. 대개 DB 접속값이나 pgvector 확장 | | `pgvector 확장 없음` | 데이터 볼륨이 이미 있으면 `init.sql` 이 다시 돌지 않는다. `CREATE EXTENSION vector;` 를 수동 실행 | | nginx 502 | `web` 이 healthy 인지. podman 에서 `web` 을 다시 만들었다면 `nginx -s reload` | | alpha-processing 추출 실패 | `logs/processing/`, 그리고 `verify.sh` 7번의 `[probe]` 줄 | | podman 에서 80 포트 바인딩 실패 | rootless 는 1024 미만을 못 연다. `HTTP_PORT` 를 8080 등으로 올리거나 `ENGINE="sudo podman"` | | podman 에서 컨테이너 이름이 안 풀린다 | `aardvark-dns` 가 떠 있는지 확인. 없으면 `podman network rm` 후 다시 만든다 | | SELinux 호스트에서 마운트 거부 | `SELINUX_LABEL=Z ./bin/up-podman-run.sh` (bind mount 에만 라벨을 붙인다) | | 컨테이너가 소켓/자원 제한에 걸린다 | `EXTRA_RUN_OPTS='--ulimit nofile=65536:65536'` 처럼 모든 컨테이너에 옵션을 덧붙인다 |