generated from ttipo/project-base
docs(rules): 13단계 부품 — 제한된 세션 · 운영 도구 · 백업 · 감시 · 스킬 표 · env
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m37s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 12m57s
CI / Frontend (lint · type-check · test · build) (pull_request) Failing after 7m54s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m5s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 12m3s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m37s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 12m57s
CI / Frontend (lint · type-check · test · build) (pull_request) Failing after 7m54s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m5s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 12m3s
- 스킬 표: nestjs(제한된 세션 · 2단계 필수 정책 · 받은편지함 알림 · 운영 도구 · 15초 동기화 · 만료 계정의 엔진 사실), vue3(제한된 세션 화면 · 로그인 틀 단계 · 유예 줄 · 메일 프로그램 연결 · 30일 줄), docker(이미지 표 — PostgreSQL · restic · shellcheck, 자동 설정 server, 호스트 systemd 백업 · 감시 절, setup 의 관리자 단단히 · 휴지통 30일, 로그 앵커 규칙, CI infra 잡). - 규칙: security(제한된 세션 · 운영 도구 비밀 · 관리자 계정 · 알림 메일 링크 없음 · 퇴사 마무리 · 백업 비밀 · restic 의 root 예외), logging(컨테이너 로그 보존 · 실행 기록 · 자동 설정 쿼리), env-structure(자동 설정 env · DB_IMAGE · ops.env 예외 · OPS_ALLOW_NO_TTY), error-codes(AUTH_006 ~ AUTH_010), checklist(13단계 운영 준비), release-ops 담당 범위(mail/ · ops/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,10 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
`CORS_ORIGIN` 의 호스트 이름들과 견준다(백엔드 `file-sharing.settings.ts` 와 같은 판정 — 포트는 보지 않는다).
|
||||
다르면 공개 server 가 화면 `/s/` · 화면 자산 · `/api/public-links/` **만** 열고(그 밖의 화면 404 · API 404 `RES_001` — 백엔드에 넘기지 않는다) 포털은 그 둘을 404 로 닫는다.
|
||||
비었거나 같으면(개발) 포털이 함께 열고 공개 server 이름은 어느 요청에도 맞지 않는 `share.invalid` 다.
|
||||
- **메일 프로그램 자동 설정 (13단계)**: server 가 하나 더 있다 — `autoconfig.<MAIL_DOMAIN>` · `autodiscover.<MAIL_DOMAIN>` 에 Thunderbird XML(두 경로) · Outlook POX 고정 응답(IMAP 993 · SMTP 465, `text/xml; charset=utf-8`).
|
||||
요청 값(주소 · 쿼리)을 되돌려 쓰지 않고 그 밖의 경로 · `/api` 는 404. 값은 `MAIL_DOMAIN` · `MAIL_HOSTNAME`(`nginx-env.envsh` — 비면 꺼짐, 모양이 틀리거나 포털 · 공개 호스트와 겹치면 기동 중단).
|
||||
엔진의 자동 설정은 공개하지 않은 POP3 를 알리고 입력을 XML 에 되돌려 써서 쓰지 않는다. 전 직원 전환 때 DNS(두 이름 A → 외부 프록시, SRV `_imaps._tcp` · `_submissions._tcp` · 쓰지 않는 것은 "."),
|
||||
외부 프록시 Proxy Host(같은 frontend:8080 · **접근 로그 끔** — 쿼리에 직원 주소)를 만든다. 엔진 인증서의 Additional Hostnames 에는 이 두 이름을 넣지 않는다(mail/README "메일 운영 설정").
|
||||
- 공개 경로 · 오류 location 은 템플릿이 아닌 스니펫이다(이미지의 `/etc/nginx/snippets/` — `${NGINX_*}` 를 쓰지 않는다):
|
||||
`nginx-share-open.conf` · `nginx-share-closed.conf`(server 단계 — 화면 `/s/` 만), `nginx-share-api-open.conf` · `nginx-share-api-closed.conf`(`/api/` 블록 안 — 공개 API, `~*`),
|
||||
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
|
||||
@@ -212,7 +216,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
|
||||
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
|
||||
백업은 메일 볼륨과 같은 방식: `sudo docker run --rm -v "${PROJECT_NAME}_uploads:/uploads:ro" -v "$PWD/backup:/backup" alpine tar czf "/backup/uploads-$(date +%Y%m%d).tar.gz" -C / uploads`.
|
||||
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
|
||||
- **대용량 첨부 파일은 named volume `<PROJECT_NAME>_files`**(`/app/files` — 경로는 compose 가 `FILE_STORAGE_DIR` 로 고정, 루트 env 로 바꾸지 않는다)에 쌓인다. **개발도 named volume** 이다 —
|
||||
바인드 마운트면 2GB 를 Windows 너머로 쓰고 Defender 가 EICAR 시험 파일을 지운다. 그래서 `backend/Dockerfile` 의 builder 단계가 `/app/files` 를 node 소유로 만들어
|
||||
개발 볼륨도 소유자를 물려받는다(2026-10-30 확인). **백업하지 않는다** — 30일짜리 전송물이고 기록은 DB 에 있다(서버를 되살리면 그 전의 링크는 파일이 없어 받지 못한다).
|
||||
@@ -330,6 +334,8 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
|
||||
"닫는 경로 시험"(12단계)은 같은 가짜 백엔드로 두 server × 닫는 경로 셋(`/api/internal` · `/api/health` · 공개 API)의 변형이 nginx 가 직접 낸 JSON 인지 본다 —
|
||||
대소문자 · 겹 `/` · `%2F` · `%69` · `%5C` · `..` · absolute-form · 끝 `/` 는 404, 날 `\` · `#` · 0xA0 는 400, 쿼리의 `\` 는 통과, `/api/internals` 같은 이웃 경로는 백엔드로 넘어간다
|
||||
(경계 확인은 wget — busybox `nc` 는 표준 입력이 끝나면 닫아 프록시를 거친 응답을 받지 못한다). 정적 검사는 include 와 `if ($request_target_rejected)` 를 지시어로만 센다(주석 처리한 줄은 세지 않는다).
|
||||
13단계부터: 세 조합(운영 · 개발 · remote-proxy)의 서비스 수와 `logging` 수 견주기, `DB_IMAGE` 판 · digest 정규식, 자동 설정 server 의 렌더링 · 값 거절(겹침 포함) · 실제 요청 시험,
|
||||
운영 스크립트(`ops/`) shellcheck(scratch 이미지 — 빌드 컨텍스트로 넘긴다).
|
||||
ClamAV 설정은 compose 의 이미지로 `clamconf` 를 돌려 한도 · 경보 값을 본다 — `clamconf` 는 틀린 항목에도 종료 코드 0 이라 출력의 `ERROR` 를 찾고,
|
||||
`-n`(기본값과 같은 항목을 숨김) 없이 한 번 더 돌려 기본값에 기대는 `SelfCheck = "600"` · `HeuristicScanPrecedence disabled` 를 줄 그대로 확인한다. 이미지 줄은 끝까지 읽는 awk 로 고른다(위 pipefail 규칙).
|
||||
|
||||
@@ -410,6 +416,11 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
키는 엔진 설정에 그대로 저장된다(메일 볼륨 백업에 있다 — Spamhaus 키와 같은 성격). 402 를 "비밀번호 맞음"으로 보는 것은 테스트 직원(웹메일에서 2단계를 켰을 수 있다)뿐이다 —
|
||||
게이트웨이가 402 면(OTP 를 켜면 백엔드의 대리 접근이 모두 막혀 웹메일 전체가 선다) 아무것도 바꾸기 전에 멈추고, 관리자도 그렇다.
|
||||
엔진 DNS 가 Custom(운영 — 리졸버)이어도 웹훅은 `backend` 를 푼다 — 메일 컨테이너의 resolv.conf 가 도커 DNS(127.0.0.11)다(2026-10-31 개발에서 Custom 으로 바꿔 확인) (mail/README.md "계정 보안 (12단계 — 인프라)").
|
||||
- setup 은 엔진 관리자를 단단히 한다(13단계): 관리자 역할(System Administrator)의 빼는 권한에 `impersonate` — 관리자 비밀번호로 `직원%관리자` 대리 로그인(바깥 993 · JMAP)이 막힌다(관리 API 는 그대로).
|
||||
영구 관리자 비밀번호의 허용 IP 를 `APP_NETWORK_SUBNET` 으로 — 관리 API 주소가 앱 네트워크 안일 때만 걸고, 건 뒤 다시 로그인해 보고, 막히면 복구 관리자로 되돌린다. **운영에서 처음 걸 때는 복구 관리자가 있어야 한다**(없으면 걸지 않고 종료 1).
|
||||
운영자가 대역 안으로 좁혀 둔 값은 넓히지 않는다. 관리 화면 SSH 터널은 mail 컨테이너의 **앱 네트워크 주소**로 연다(다른 망 주소로 열면 403 — 엔진이 보는 출발 주소가 그 망의 게이트웨이).
|
||||
휴지통 · 스팸메일함 30일(`x:DataRetention.expungeTrashAfter`)과 정리 시각 KST 03:00 · 04:00 · 04:30(컨테이너 UTC 차이를 읽어)도 맞춘다 — 그 메일함에 들어간 때부터 세므로 imapsync 로 옮긴 메일도 옮긴 때부터다.
|
||||
자동 비우기는 특수 메일함("Deleted Items" · "Junk Mail")에만 걸린다 — imapsync 는 `--f1f2` 로 폴더를 맞춘다(mail/README imapsync 절). 이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버".
|
||||
- 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다
|
||||
(파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포").
|
||||
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
|
||||
@@ -461,6 +472,20 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
|
||||
---
|
||||
|
||||
## 백업 · 감시 (호스트 systemd — compose 밖, 13단계)
|
||||
|
||||
서버가 스스로 지키고 되살리는 일은 compose 가 아니라 호스트의 systemd 가 돌린다. 설치 · 복원 차례 · 알림 대응은 [`mail/README.md`](../../../mail/README.md) "백업" · "감시"가 SSOT 다.
|
||||
|
||||
- **자리**: 저장소의 `ops/` 를 서버의 `/opt/arasystem-ops` 로 `install -m 0644` 해 root 소유 사본으로 돌린다(저장소 폴더를 그대로 쓰지 않는다). 설정 · 비밀은 `/etc/arasystem/ops.env` · `restic-password`(root 0600) — [[env-structure]] 의 예외.
|
||||
- **유닛 셋**: `arasystem-backup.timer`(매일 03:30 KST · 부팅 뒤 따라잡기), `arasystem-check.timer`(15분 점검), `arasystem-mail-recover.service`(부팅 때 — 백업 표시가 남았으면 엔진을 띄운다, `Before=arasystem-backup.service`). 모두 `Wants=` + `After=docker.service`.
|
||||
- **백업**(restic → Object Storage, 보존 일 7 · 주 4 · 월 6, 일요일 prune · check 5%): 메일 볼륨은 켜 둔 채 미리 올리고(warm — restic 종료 3 은 받아들임) → 엔진을 잠깐 멈춰(9초 안팎) 바뀐 것만(final — 상한 `BACKUP_FINAL_TIMEOUT` 15분) → 다시 띄운다.
|
||||
DB `pg_dump -Fc`(+ `pg_restore -l` 확인) · 업로드 볼륨 · env 둘. 어떻게 끝나도 엔진을 다시 띄운다(EXIT trap + `ExecStopPost`, 멈추는 중 신호면 멈춤을 끝낸 뒤). 비밀 사본(env · 덤프)은 끝날 때 지운다.
|
||||
**매일 03:30 에 웹메일이 10초 안팎 `MAIL_001`** 이다(엔진 멈춤). 백업과 복원 연습은 같은 잠금 — 잠긴 동안 손으로 부른 엔진 띄우기는 건너뛴다(final 도중 띄우면 일관되지 않은 스냅숏이 final 이 된다).
|
||||
- **복원**(`arasystem-restore.sh` — snapshots · check · drill · files · volumes · db): final 태그 스냅숏만, 쓰는 컨테이너가 있거나 볼륨이 비지 않았으면 멈춘다(운영 볼륨을 덮지 않게), 새 서버는 `.env.production` 없이 ops.env 의 `PROJECT_NAME` 으로 files 부터. 분기마다 `drill`.
|
||||
- **15분 점검**: 디스크 두 곳 · 컨테이너 health · 백엔드 health · 메일 대기열 · 인증서 셋 · 백업 나이(26시간 이상) · ClamAV 서명 · Spamhaus 키 → Healthchecks.io(정상 · 실패 + 까닭 한 줄). 바깥은 UptimeRobot(포털 HTTPS · mx1:993 · mx1:25) — 알림은 우리 메일 서버가 아닌 주소로.
|
||||
- **비-root 예외**: restic 보조 컨테이너는 root 로 돌되 `--cap-drop ALL` + 필요한 권한만(읽기 `DAC_READ_SEARCH`, 복원은 `DAC_OVERRIDE` · `CHOWN` · `FOWNER`) · `no-new-privileges` ([[security]]). 연습 엔진(Stalwart)을 `no-new-privileges` 로 띄우려면 `NET_BIND_SERVICE` 가 든다.
|
||||
- 비밀은 명령줄에 싣지 않는다 — `docker exec -e 이름` · 표준 입력 · 읽기 전용 마운트, Healthchecks 주소도 `curl -K -`.
|
||||
|
||||
## 규칙
|
||||
|
||||
- **이미지 버전 고정 — 떠 있는 태그(`stable-alpine`·`v0.16`·`24-alpine`)를 쓰지 않는다.** 그날의 최신이 들어오면 호스트마다 버전이 달라지고,
|
||||
@@ -474,6 +499,10 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
|
||||
| unbound (메일 리졸버) | `klutchell/unbound:1.26.2@sha256:50cbeb32…` | `docker-compose.yml` 의 `mail-resolver`, `mail/README.md` 「DNS 리졸버와 스팸 차단 목록」의 이미지 칸(판 · digest 앞부분), 이 문서. 커뮤니티 이미지라 **digest 까지** 묶는다 — 올릴 때는 `imagetools inspect` 의 manifest list digest 로 함께 바꾼다. CI 의 unbound 설정 검사는 compose 의 이미지를 읽어 쓴다 |
|
||||
| ClamAV (악성 검사 — 11단계) | `clamav/clamav:1.4.6_base@sha256:e7399106…` | `docker-compose.yml` 의 `clamav` · `clamav-updater`(같은 값 둘), `mail/README.md` "악성 검사 (ClamAV)"의 이미지 칸, 이 문서. LTS(1.4 — 2024-08 부터 3년)만 쓴다 — 같은 날 나온 1.5.4 대신 고른 까닭: 지원 기간, 1.5 의 새 ZIP 코드에서 나온 CVE-2026-20337. 공식 이미지지만 digest 까지 묶는다(CI 의 `clamconf` 검사는 compose 의 이미지를 읽어 쓴다) |
|
||||
| PostgreSQL (db — 13단계부터 고정) | `postgres:15.19-alpine3.24@sha256:54e0dd8e…` | 루트 예시 env 둘의 `DB_IMAGE`(각자의 로컬 env 도), CI 의 `DB_IMAGE` 정규식, 이 문서. `docker-compose.remote-proxy.yml` 의 `frontend-bind-check` 가 같은 이미지를 빌려 쓴다. 15.19 는 2026-08-13 보안 릴리스, 이 digest 는 2026-10-21 alpine 재빌드라 운영 반영은 11-04 뒤. **메이저(15)를 올리려면 덤프 · 복원** — 데이터 형식이 다르다. db · redis 의 watchtower 표시는 뺐다(판은 사람이 올린다) |
|
||||
| restic (백업 — 13단계) | `restic/restic:0.19.1@sha256:136600b6…` | `ops/backup/arasystem-backup.sh` · `arasystem-restore.sh` 의 `RESTIC_IMAGE` 두 곳, 이 문서 |
|
||||
| shellcheck (CI — 13단계) | `koalaman/shellcheck:v0.11.0@sha256:61862eba…` | `.github/workflows/ci.yml` |
|
||||
| Redis (선택 — 기본 꺼짐) | `redis:7-alpine`(아직 떠 있는 태그 — 기존 드리프트) | 켤 때 판 · digest 로 고정하고 이 표에 줄을 둔다 |
|
||||
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
|
||||
|
||||
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
|
||||
@@ -495,6 +524,8 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
24.22.1 은 2026-10-20 보안 릴리스라 보안 릴리스 규칙으로 바로 고정했다.
|
||||
- 올린 뒤에는 운영 이미지 두 개 빌드, CI 와 같은 명령(아래 "의존성은 컨테이너 안에서"처럼 임시 폴더 복사본에서 lint·type-check·test·build),
|
||||
dev 재빌드(`dc up -d --build -V`)로 확인한다.
|
||||
- **로그 보존 — 모든 서비스에 `logging` 앵커**(json-file · max-size · max-file · compress — 13단계). 기본 10m×5, mail · frontend · backend 는 20m×10. 앵커는 파일을 넘지 못해 dev · remote-proxy 는 자기 앵커를 둔다.
|
||||
새 서비스에도 붙인다 — CI 가 세 조합의 서비스 수와 logging 수를 견준다. 로그 한도는 컨테이너를 다시 만들어야 걸린다.
|
||||
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
|
||||
- **의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.**
|
||||
dev 는 `/app/node_modules` 를 익명 볼륨으로 마운트하는데, `--build` 만으로는 **예전 볼륨이 그대로 재사용된다.**
|
||||
|
||||
Reference in New Issue
Block a user