From e664217485f953a77563940a43d54e6be38a3e63 Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 2 Nov 2026 23:41:22 +0900 Subject: [PATCH 01/14] =?UTF-8?q?docs:=20mailcow=20=ED=98=B8=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=8A=94=20=EC=82=AC=EB=AC=B4=EC=8B=A4=20PC=20?= =?UTF-8?q?=EC=9D=98=20WSL=20=E2=80=94=20=ED=99=95=EC=9D=B8=20=C2=B7=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=EA=B3=84=EC=A0=95=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=9D=84=20sudo=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mailcow.conf 와 mailcow 저장소가 root 소유라 sudo 없이는 grep 이 거절되고 git 이 dubious ownership 으로 멈췄다(2026-11-02 사용자 실행). 이전하는 동안 그 PC 가 절전 · 재부팅되면 imapsync 가 끊긴다는 줄도 더했다. Co-Authored-By: Claude Opus 5.5 --- mail/README.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/mail/README.md b/mail/README.md index 6126399..a5998c8 100644 --- a/mail/README.md +++ b/mail/README.md @@ -1520,23 +1520,26 @@ mailcow 는 dovecot-mailcow 를 다시 띄울 때마다 **무작위** 마스터 mailcow 문서 "Static master user"(, 몇 해 된 글): ```bash -# mailcow 호스트 — 설치 폴더(/opt/mailcow-dockerized)의 mailcow.conf 에 두 줄. 둘 다 값이 있어야 한다(하나라도 비면 무작위로 만든다) +# mailcow 호스트(사무실 PC 의 WSL 셸) — mailcow.conf 는 root 소유라 sudo 로 연다: sudo nano /opt/mailcow-dockerized/mailcow.conf +# 두 줄을 넣는다. 둘 다 값이 있어야 한다(하나라도 비면 무작위로 만든다) DOVECOT_MASTER_USER=<이름> # 로그인 이름에는 @mailcow.local 이 저절로 붙는다 DOVECOT_MASTER_PASS=<비밀번호> # 영문 · 숫자로 길게 -docker compose up -d # dovecot-mailcow 가 다시 만들어진다 +# 그리고 설치 폴더에서 다시 띄운다 — dovecot-mailcow 가 다시 만들어진다 +cd /opt/mailcow-dockerized && sudo docker compose up -d ``` - IMAP 로그인 이름은 `<직원 주소>*<이름>@mailcow.local`(예 `hong@arasystem.it.kr*migrator@mailcow.local` — `@` 가 둘), 비밀번호는 `DOVECOT_MASTER_PASS` 다. **IMAP 전용** — SOGo(mailcow 웹메일) 로그인에는 쓰지 못한다. - 끝나면(단계 8) 두 줄을 지우고 `docker compose up -d` — 무작위로 돌아간다. 마스터 비밀번호는 모든 메일함을 연다. -- `mailcow.conf` 는 **mailcow 호스트** — mailcow 컨테이너가 도는 사무실 PC(바깥 `mail.arasystem.it.kr` 은 Contabo 앞단을 거쳐 이 PC 로 온다)에 있다. 운영자가 그 PC 에 들어가 고친다 - (2026-11-02 사용자 확인 — 파일럿 때 적은 "관리 화면만"([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 "SSH 가 없을 때")은 원격으로 들어갈 길 이야기다). - 설치 폴더는 compose 가 기록한 작업 폴더로 찾는다 — 값(비밀번호)은 출력하지 않고 이름만 본다: +- `mailcow.conf` 는 **mailcow 호스트** — mailcow 컨테이너가 도는 사무실 PC(Windows 의 WSL — 설치 폴더 `/opt/mailcow-dockerized`, 바깥 `mail.arasystem.it.kr` 은 Contabo 앞단을 거쳐 이 PC 로 온다)에 있다. + 운영자가 그 PC 의 WSL 셸에서 고친다(2026-11-02 사용자 확인 — 파일럿 때 적은 "관리 화면만"([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 "SSH 가 없을 때")은 원격으로 들어갈 길 이야기다). + 이전하는 동안 그 PC 가 절전 · 재부팅되면 mailcow 가 멈춰 imapsync 가 끊긴다 — 전원 연결 시 절전 안 함으로 둔다. + `mailcow.conf` 는 root 만 읽고 저장소도 root 소유라 확인 명령은 sudo 로 한다 — 값(비밀번호)은 출력하지 않고 이름만 본다(`git config safe.directory` 는 건드리지 않는다): ```bash - # mailcow PC 에서 (리눅스 셸 · docker 를 쓸 수 있는 사용자) - sudo docker inspect "$(sudo docker ps -qf name=dovecot-mailcow)" --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' - cd <위 폴더> && grep -o '^#\?DOVECOT_MASTER_[A-Z]*' mailcow.conf # 줄이 있는지 · 주석인지만 - git log -1 --format='%cd %h' --date=short # mailcow 판(커밋 날짜) + # mailcow PC 의 WSL 셸에서 (sudo 를 쓸 수 있는 사용자) + sudo docker inspect "$(sudo docker ps -qf name=dovecot-mailcow)" --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' # → /opt/mailcow-dockerized + sudo grep -o '^#\?DOVECOT_MASTER_[A-Z]*' /opt/mailcow-dockerized/mailcow.conf # 줄이 있는지 · 주석인지만 + sudo git -C /opt/mailcow-dockerized log -1 --format='%cd %h' --date=short # mailcow 판(커밋 날짜) ``` - **확인하지 않은 것** — imapsync 가 붙는 `mail.arasystem.it.kr:993` 이 Contabo 앞단을 어떻게 지나 사무실 PC 의 mailcow 에 닿는지 — mailcow 가 보는 접속 주소가 서버 B 의 IP 가 아니라 앞단의 주소라면 Fail2ban 예외도 그 주소여야 하고, 그 주소가 막히면 모든 직원의 메일 프로그램이 함께 막힌다(첫 사람의 `--justlogin` 뒤 mailcow 관리 화면의 로그인 기록에서 그 주소를 본다). -- 2.52.0 From 34d63cf74005f5594d691293c5f7250b4c0a3e4e Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 00:59:03 +0900 Subject: [PATCH 02/14] =?UTF-8?q?docs:=20mailcow=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EB=91=90=20=EC=A4=84=EC=9D=80=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=88=20=EA=B0=92=20=E2=80=94=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=85=EB=A0=B9=20=C2=B7=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EB=A7=8C=20=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=A0=91=EC=86=8D=20=EC=A3=BC=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 mailcow.conf 에 DOVECOT_MASTER_USER · _PASS 가 주석 없이 있었는데, mailcow 의 generate_config.sh 가 두 줄을 빈 값으로 넣어 두므로 "주석이 아니면 정해 둔 것"으로 읽으면 틀린다. 값 대신 비었는지만 보는 확인으로 바꾸고, 끝낼 때는 줄을 지우지 않고 값을 비우게 했다. mailcow 의 Fail2ban(netfilter)은 사설 · 루프백 주소를 막지 않는다(원본 main.py) — 예외가 필요한지는 mailcow 가 Contabo 앞단 · WSL 을 지난 서버 B 를 어떤 주소로 보느냐에 달렸다. 준비에 계정 없이 원본 로그인만 보는 --justlogin(두 쪽 모두 mailcow)과 mailcow 로그에서 그 주소 · 지난 사흘의 접속 주소를 보는 명령을 넣었다(개발 Dovecot 2.3.21.1 · mawk 1.3.4 로 시험). 운영 사실: 설치 폴더 /opt/mailcow-dockerized, 판 2026-05-12(32156a33). Co-Authored-By: Claude Opus 5.5 --- mail/README.md | 57 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/mail/README.md b/mail/README.md index a5998c8..d9bd14c 100644 --- a/mail/README.md +++ b/mail/README.md @@ -1318,6 +1318,7 @@ alias dcx='docker compose exec postfix-mailcow' 1. **postscreen 예외** — `data/conf/postfix/custom_postscreen_whitelist.cidr` 에 `/32 permit` (재시작 없이 다시 읽는다). 새 IP 가 차단 목록 하나에만 있어도 postscreen 에서 끊길 수 있다. Forwarding Hosts 에는 넣지 않는다 — 넣으면 그 IP 의 메일은 스팸 검사 · 위조 판정 없이 들어온다. 2. **Fail2ban 예외** — System → Configuration → Options → Fail2ban parameters → "Allowlisted networks/hosts" 에 `` (imapsync 로그인 실패가 쌓여 mx1 IP 가 막히면 SMTP 도 끊긴다). + mailcow 가 mx1 을 사설 주소로 보면(앞단 · WSL 을 지나며 바뀌면) 막히지 않아 필요 없다 — 넣어도 해는 없다([imapsync › 원본](#원본--mailcow-마스터-계정)의 접속 주소). 3. **새 서버 setup** — 위 "env 와 실행". 이때부터 새 서버의 owner@ 가 동료에게 보낼 수 있다(mailcow 는 아직 owner 앞 메일을 자기 사서함에 넣는다). 4. **반대 방향부터 시험** — 새 서버의 owner@(웹메일)로 동료에게 한 통. 실사용자에게 영향이 없는 방향이라 먼저 한다: ```bash @@ -1455,7 +1456,7 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul | 5. 메일 이전 1차 | [imapsync](#imapsync) — 한 사람으로 먼저(`--justlogin` · 폴더 맞춤), 그다음 주소 목록 전부 | 사람마다 `Detected 0 errors` · 폴더별 수 · 「이전 현황」의 메일함 사용량 | | 6. MX 전환 | `arasystem.it.kr MX 10 mx1.arasystem.it.kr` 로 교체. mailcow 는 **계속 켜 둔다** (전파 중에는 mailcow 로도 들어온다). **이때부터 9 까지 직원은 새 서버에 들어오지 못한다** — 짧게(예: 금요일 저녁 ~ 월요일 아침) | 외부에서 보낸 메일이 새 엔진 로그에 수신 | | 7. 마지막 imapsync | TTL 이 지나 mailcow 에 새 수신이 없어진 뒤 같은 명령을 한 번 더(차이분 — 이미 옮긴 메일은 건너뛴다). 그 전에 직원에게 mailcow 를 그만 쓰게 알린다 | mailcow 로그에 새 수신 없음 · 사람마다 `Detected 0 errors` | -| 8. 이전 마치기 | 마지막 imapsync 뒤 5분쯤 지나 운영 도구 「메일 이전 › 이전 마치기」(주소 빈 값 — 이전 중인 계정 모두) → 주소 · 임시 비밀번호 표가 **한 번** 보인다. mailcow 메일함은 `Disallow login`, 마스터 계정 두 줄 · 서버 B 의 비밀번호 파일 지우기([imapsync](#imapsync) 단계 8) | 「이전 현황」이 비었다 | +| 8. 이전 마치기 | 마지막 imapsync 뒤 5분쯤 지나 운영 도구 「메일 이전 › 이전 마치기」(주소 빈 값 — 이전 중인 계정 모두) → 주소 · 임시 비밀번호 표가 **한 번** 보인다. mailcow 메일함은 `Disallow login`, 마스터 계정 두 값 비우기 · 서버 B 의 비밀번호 파일 지우기([imapsync](#imapsync) 단계 8) | 「이전 현황」이 비었다 | | 9. 임시 비밀번호 나눠 주기 | 직원에게 직접(말 · 쪽지). 직원은 첫 로그인 때 새 비밀번호를 정하고, 회사 정책이면 2단계 인증을 켠다 | 직원이 웹메일에 들어온다 | | 10. 병행 1~2주 후 정리 | SPF 에서 `ip4:46.250.248.43` 제거, `dkim._domainkey` 삭제, mailcow 중지. 필요하면 `mail.arasystem.it.kr` 을 새 호스트로 옮기고(인증서 포함) `MAIL_PUBLIC_URL` 변경 | — | @@ -1493,18 +1494,18 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul 이전 중인 계정은 운영 도구 메뉴 `11. 메일 이전` 이 다룬다 — `1. 이전 현황` · `2. 이전용 비밀번호 다시 만들기` · `3. 이전 마치기`(2 · 3 은 `주소 (빈 값이면 이전 중인 계정 모두): `). "이전 중"은 엔진 비밀번호 만료가 미래(30일 안)이면서 운영 도구의 실행 기록에서 마지막으로 건 비밀번호가 이전용인 계정이다(계약 6절 "메일 이전"). 엔진의 **비밀번호 기본 만료**(`x:Authentication` 의 `passwordDefaultExpiry` — 관리 화면의 인증 설정)는 켜지 않는다 — 켜면 직원 비밀번호가 주기적으로 만료되고, 끈 뒤에도 그동안 바꾼 비밀번호의 만료가 남는다. 운영 도구는 켜져 있으면 첫 화면에 알린다. -- 근거는 아래 [개발에서 확인한 것](#개발에서-확인한-것-imapsync--2026-11-02) — mailcow 를 흉내 낸 Dovecot → 개발 엔진. **운영 mailcow · 서버 B 에서는 아직 돌려 보지 않았다** — 단계 5 의 첫 사람으로 확인한다. +- 근거는 아래 [개발에서 확인한 것](#개발에서-확인한-것-imapsync--2026-11-02) — mailcow 를 흉내 낸 Dovecot → 개발 엔진. **운영 mailcow · 서버 B 에서는 아직 돌려 보지 않았다** — 준비의 "원본 로그인만"과 단계 5 의 첫 사람으로 확인한다. #### 차례 (단계 번호는 [전환 절차](#arasystemitkr-전환-절차-mailcow--stalwart)의 것) | 단계 | 할 일 | 비고 | | --- | --- | --- | -| 준비 | mailcow 마스터 계정 고정(아래 "원본") · mailcow Fail2ban 예외에 새 서버 IP · 서버 B 에 마스터 비밀번호 파일 · 주소 목록(아래 "돌리는 곳") | Fail2ban 예외는 [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 mailcow 절차 2 와 같다 — imapsync 로그인 실패가 쌓여 그 IP 가 막히면 새 서버 → mailcow 의 SMTP 도 끊긴다 | +| 준비 | 단계 2 며칠 전 — mailcow 마스터 계정 고정(아래 "원본") · 서버 B 에 마스터 비밀번호 파일 · **원본 로그인만 미리**(아래 "돌리는 곳"의 준비 줄 — 대상 계정 없이) · mailcow 가 서버 B 를 본 주소가 공인이면 Fail2ban 예외에 · 주소 목록 | 원본 쪽 문제(마스터 · 인증서 · 앞단 길)를 계정을 만들기 전에 고친다 — 단계 2 부터는 짧게 이어야 한다(아래). Fail2ban 예외는 [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 mailcow 절차 2 와 같다 — imapsync 로그인 실패가 쌓여 그 IP 가 막히면 새 서버 → mailcow 의 SMTP 도 끊긴다 | | 2 | 계정 만들기 — 운영 도구 「입사」를 한 사람씩(모두를 한 실행에서), 옮겨 올 메일 y. 처음 보이는 이전용 비밀번호를 **그 자리에서** 서버 B 의 비밀번호 파일로(아래 — 다시 볼 수 없다) | 「메일 이전 › 이전 현황」에 이전 중인 계정 · 끝나는 때 · 메일함 사용량이 보인다 | | 5 | imapsync 1차 — **한 사람으로 먼저**(`--justlogin` · 엔진이 본 접속 주소 · 1차 · 아래 "사람마다 확인할 것"), 그다음 주소 목록 전부 | 메일이 많으면 MX 전날에 — 단계 7 의 차이분이 짧아진다 | | 6 | MX 바꾸기 | 이때부터 단계 9 까지 직원은 새 서버에 들어오지 못한다(비밀번호를 아직 받지 않았다) | | 7 | **마지막 imapsync** — TTL 이 지나 mailcow 에 새 수신이 없어진 뒤 같은 명령 | 그사이 mailcow 에 온 메일 · 바뀐 읽음 표시만 옮긴다. 직원에게는 그 전에 mailcow 를 그만 쓰게 알린다 — 이 뒤에 mailcow 에서 한 일은 옮겨지지 않는다 | -| 8 | **마지막 imapsync 가 끝나고 5분쯤 지난 뒤** 운영 도구 「메일 이전 › 이전 마치기」 — 주소 빈 값(이전 중인 계정 모두). 그다음 mailcow 정리 — 메일함을 `Disallow login (incoming mail is still accepted)` 로, 마스터 계정 두 줄 지우기, 서버 B 의 비밀번호 파일 지우기 | 사람마다 직원용 임시 비밀번호(만료 — 첫 로그인 때 바꾸기), imapsync 의 로그인 기록 지우기. 끝에 주소 · 임시 비밀번호 표가 **한 번** 보인다. 5분을 기다리는 까닭 — 엔진 웹훅은 1초씩 묶어 보내고 실패하면 5분까지 다시 보낸다, 그 전에 하면 늦게 온 IMAP 로그인 몇 줄이 직원의 로그인 기록에 남는다. mailcow 메일함은 지우거나 Inactive 로 두지 않는다(파일럿 mailcow 절차 9 와 같다) | +| 8 | **마지막 imapsync 가 끝나고 5분쯤 지난 뒤** 운영 도구 「메일 이전 › 이전 마치기」 — 주소 빈 값(이전 중인 계정 모두). 그다음 mailcow 정리 — 메일함을 `Disallow login (incoming mail is still accepted)` 로, 마스터 계정 두 값 비우기(아래 "원본"), 서버 B 의 비밀번호 파일 지우기 | 사람마다 직원용 임시 비밀번호(만료 — 첫 로그인 때 바꾸기), imapsync 의 로그인 기록 지우기. 끝에 주소 · 임시 비밀번호 표가 **한 번** 보인다. 5분을 기다리는 까닭 — 엔진 웹훅은 1초씩 묶어 보내고 실패하면 5분까지 다시 보낸다, 그 전에 하면 늦게 온 IMAP 로그인 몇 줄이 직원의 로그인 기록에 남는다. mailcow 메일함은 지우거나 Inactive 로 두지 않는다(파일럿 mailcow 절차 9 와 같다) | | 9 | 임시 비밀번호 나눠 주기 — 직원에게 직접(말 · 쪽지) | 직원은 첫 로그인 때 새 비밀번호를 정하고, 회사 정책이면 2단계 인증을 켠다(계약 6절 "제한된 세션") | - **단계 6 ~ 9 는 짧게 잡는다** — 예: 금요일 저녁 MX → 토요일 마지막 imapsync · 이전 마치기 → 월요일 아침 나눠 주기. 그동안 바깥 메일은 새 서버에 쌓이고, 직원은 단계 7 전까지 mailcow 를 그대로 쓸 수 있다. @@ -1521,29 +1522,45 @@ mailcow 문서 "Static master user"( # 로그인 이름에는 @mailcow.local 이 저절로 붙는다 -DOVECOT_MASTER_PASS=<비밀번호> # 영문 · 숫자로 길게 -# 그리고 설치 폴더에서 다시 띄운다 — dovecot-mailcow 가 다시 만들어진다 +DOVECOT_MASTER_PASS=<비밀번호> # 영문 · 숫자만(특수 문자 금지 — mailcow 설정의 주석) · 길게 — 그 PC 에서 openssl rand -hex 24 로 만들어 비밀번호 관리자에 둔다 +# 그리고 설치 폴더에서 다시 띄운다 — 두 값을 받는 dovecot-mailcow 만 다시 만들어진다 cd /opt/mailcow-dockerized && sudo docker compose up -d ``` - IMAP 로그인 이름은 `<직원 주소>*<이름>@mailcow.local`(예 `hong@arasystem.it.kr*migrator@mailcow.local` — `@` 가 둘), 비밀번호는 `DOVECOT_MASTER_PASS` 다. **IMAP 전용** — SOGo(mailcow 웹메일) 로그인에는 쓰지 못한다. -- 끝나면(단계 8) 두 줄을 지우고 `docker compose up -d` — 무작위로 돌아간다. 마스터 비밀번호는 모든 메일함을 연다. +- 다시 띄우는 동안 직원의 IMAP · 웹메일(SOGo)이 잠깐 끊긴다 — 메일 프로그램은 다시 잇고, 들어오는 메일은 postfix 가 붙잡았다가 넘긴다. 한가한 때에 한다. +- 마스터 비밀번호는 모든 메일함을 연다 — 고정은 단계 2 며칠 전(원본 로그인을 미리 볼 수 있게)부터 단계 8 까지만 둔다. + 끝나면(단계 8) 두 값을 비우고(줄은 둔다) 같은 명령으로 다시 띄운다 — 무작위로 돌아간다. - `mailcow.conf` 는 **mailcow 호스트** — mailcow 컨테이너가 도는 사무실 PC(Windows 의 WSL — 설치 폴더 `/opt/mailcow-dockerized`, 바깥 `mail.arasystem.it.kr` 은 Contabo 앞단을 거쳐 이 PC 로 온다)에 있다. 운영자가 그 PC 의 WSL 셸에서 고친다(2026-11-02 사용자 확인 — 파일럿 때 적은 "관리 화면만"([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 "SSH 가 없을 때")은 원격으로 들어갈 길 이야기다). 이전하는 동안 그 PC 가 절전 · 재부팅되면 mailcow 가 멈춰 imapsync 가 끊긴다 — 전원 연결 시 절전 안 함으로 둔다. - `mailcow.conf` 는 root 만 읽고 저장소도 root 소유라 확인 명령은 sudo 로 한다 — 값(비밀번호)은 출력하지 않고 이름만 본다(`git config safe.directory` 는 건드리지 않는다): + `mailcow.conf` 는 root 만 읽고 저장소도 root 소유라 확인 명령은 sudo 로 한다 — 값(비밀번호)은 출력하지 않는다(`git config safe.directory` 는 건드리지 않는다): ```bash # mailcow PC 의 WSL 셸에서 (sudo 를 쓸 수 있는 사용자) sudo docker inspect "$(sudo docker ps -qf name=dovecot-mailcow)" --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' # → /opt/mailcow-dockerized - sudo grep -o '^#\?DOVECOT_MASTER_[A-Z]*' /opt/mailcow-dockerized/mailcow.conf # 줄이 있는지 · 주석인지만 + sudo awk -F= '/^DOVECOT_MASTER_(USER|PASS)=/ { print $1, ($2 == "" ? "비어 있음" : "값 있음") }' /opt/mailcow-dockerized/mailcow.conf # 값 대신 비었는지만. 아무것도 안 나오면 줄이 없거나 주석 sudo git -C /opt/mailcow-dockerized log -1 --format='%cd %h' --date=short # mailcow 판(커밋 날짜) ``` -- **확인하지 않은 것** — imapsync 가 붙는 `mail.arasystem.it.kr:993` 이 Contabo 앞단을 어떻게 지나 사무실 PC 의 mailcow 에 닿는지 — - mailcow 가 보는 접속 주소가 서버 B 의 IP 가 아니라 앞단의 주소라면 Fail2ban 예외도 그 주소여야 하고, 그 주소가 막히면 모든 직원의 메일 프로그램이 함께 막힌다(첫 사람의 `--justlogin` 뒤 mailcow 관리 화면의 로그인 기록에서 그 주소를 본다). - 운영 mailcow 판에서 이 설정이 문서대로인지(같은 꼴로 인증에 실패했다는 커뮤니티 글이 있다 — 해결 기록 없음. 그래서 한 사람의 `--justlogin` 으로 먼저 본다), + 운영(2026-11-03 확인): 설치 폴더 `/opt/mailcow-dockerized`, 판 2026-05-12(`32156a33`), 두 줄은 주석 없이 있다. + "값 있음"이면 누군가 이미 고정해 둔 것이다 — 바꾸기 전에 그 값을 쓰는 곳(백업 스크립트 등)이 있는지 알아본다. +- **mailcow 가 보는 접속 주소** — `mail.arasystem.it.kr:993` 은 Contabo 앞단을 지나 이 PC(WSL)의 mailcow 에 닿는다 — 가는 길에 접속 주소가 바뀔 수 있다. + mailcow 의 Fail2ban(netfilter-mailcow — 기본 10분에 실패 10번이면 30분)은 **사설 · 루프백 주소는 막지 않는다**(Python `ipaddress` 의 `is_private` · `is_loopback` — mailcow 원본 `data/Dockerfiles/netfilter/main.py`, 2026-11-03 확인). + 그래서 mailcow 가 서버 B 를 사설 주소(10 · 172.16–31 · 192.168 · 127)로 보면 예외가 필요 없고, 공인 주소로 보면 그 주소를 예외에 넣는다. + 100.64–100.127.x(Tailscale 같은 공유 주소)는 사설로 치지 않아 막힐 수 있다 — 바깥 접속이 모두 그 주소 하나(또는 앞단 `46.250.248.43` 하나)로 보이면 그 주소가 예외에 있는지 본다. 막히면 모든 직원의 메일 프로그램이 함께 막힌다. + ```bash + # 아무 때나 — 지난 사흘의 IMAP · POP3 접속이 어떤 주소로 보였는지(공인 주소는 세기만 하고 보이지 않는다) + sudo docker logs --since 72h "$(sudo docker ps -qf name=dovecot-mailcow)" 2>&1 | grep -E '(imap|pop3)-login:' | grep -oE 'rip=[0-9A-Fa-f:.]+' | cut -d= -f2 \ + | awk -v front=46.250.248.43 '$0 == front { f++; next } /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|::1$|[Ff][CcDd]|[Ff][Ee]80:)/ { p[$0]++; next } { if (!($0 in g)) d++; if (++g[$0] > m) m = g[$0]; n++ } END { for (ip in p) print "내부", ip, p[ip] "줄"; print "앞단", front, f + 0, "줄"; print "그 밖의 공인", d + 0, "곳", n + 0, "줄 · 가장 많은 한 곳", m + 0, "줄" }' + # 준비의 "원본 로그인만"(서버 B — 아래 "돌리는 곳") 뒤 15분 안에 — mailcow 가 서버 B 를 본 주소. 마스터 로그인만 고른다(직원의 평소 로그인은 나오지 않는다) + sudo docker logs --since 15m "$(sudo docker ps -qf name=dovecot-mailcow)" 2>&1 \ + | awk '/Master user logging in as / { m = $NF; next } m != "" && index($0, "Login: user=<" m ">") { if (match($0, /rip=[0-9A-Fa-f:.]+/)) print m, substr($0, RSTART + 4, RLENGTH - 4); m = "" }' + ``` + `내부 172.22.1.248` 은 mailcow 안의 SOGo(웹메일)다. 두 명령은 Dovecot 2.3 의 로그 꼴(`imap-login: Login: user=<…>, …, rip=…` · `Master user logging in as …`)로 맞췄다 — + 개발 흉내(Dovecot 2.3.21.1)의 실제 로그와 mailcow 꼴 표본으로, 우분투 · 데비안의 기본 awk 인 mawk 1.3.4 에서 시험했다(2026-11-03). mailcow 는 지금 원본 compose 에서도 `dovecot:2.3.21.1-2` 다. +- **확인하지 않은 것** — 운영 mailcow 판(2026-05-12)에서 이 설정이 문서대로인지(같은 꼴로 인증에 실패했다는 커뮤니티 글이 있다 — 해결 기록 없음. 그래서 준비의 "원본 로그인만"으로 먼저 본다), 메일함 편집의 **Allowed protocols** 에서 IMAP 이 꺼진 메일함이 마스터로도 막히는지(그래서 mailcow 접근 줄이기는 마지막 imapsync 뒤 — 단계 8). - 개발 흉내(Dovecot 2.3 에 같은 꼴 — 구분자 `*` · 마스터 이름 `migrator@mailcow.local`)에서는 imapsync 가 그 이름을 그대로 넘겨 Dovecot 이 `Master user logging in as kim@legacy.test` 로 받았다. mailcow 에 없는 메일함은 마스터로도 `NO [AUTHORIZATIONFAILED] Authorization failed` 다. @@ -1578,6 +1595,17 @@ imapsync_one() { # $1 = 직원 주소, 그 뒤는 덧붙일 옵션 --automap --syncinternaldates --addheader --nolog --noreleasecheck "${@:2}" &1 | grep auth.success | grep imaps | tail -1 @@ -1637,6 +1665,7 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a | 무엇 | 결과 | | --- | --- | | 원본 로그인 | `<주소>*migrator@mailcow.local` 을 imapsync 가 그대로 넘겼다. 원본에 없는 메일함은 마스터로도 `NO [AUTHORIZATIONFAILED]` | +| 원본 로그인만(준비) | 두 쪽을 같은 원본 · 같은 마스터 로그인으로 준 `--justlogin` 을 imapsync 가 받는다 — `Host1: success login` · `Host2: success login` · `Exiting with return value 0`(2026-11-03). Dovecot 로그는 로그인마다 `Master user logging in as <주소>` 다음 `Login: user=<주소>, …, rip=` | | 폴더 맞춤 | INBOX → Inbox(`inbox`) · Sent → Sent Items(`sent`) · Drafts → Drafts(`drafts`) · Trash → Deleted Items(`trash`) · Junk → Junk Mail(`junk`) — **따로 생긴 폴더 없음, `--f1f2` 가 필요 없었다**. Archive 는 보통 폴더 "Archive"(role 없음) | | 폴더마다 메일 수 | 원본과 같다 (메일 14 · 5통, 폴더 8 · 7개) | | 받은 때 | 모두 같다 — 2025년 내부 날짜가 초까지 그대로(JMAP `receivedAt`) | @@ -1654,7 +1683,7 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a - **개발과 운영이 다른 점** — 대상 인증서가 자체 서명이라 `--sslargs2 SSL_verify_mode=0` 으로 돌렸고, imapsync 를 잠시 앱 네트워크에 붙여 `mail:993` 으로 갔다(엔진은 신뢰 대역 주소로 본다 — 운영에서는 하지 않는다). 원본은 시험 CA 로 `SSL_verify_mode=1` 이었다. -- **확인하지 않은 것** — 운영 mailcow 의 마스터 계정 · Allowed protocols, 서버 B 의 길(`--network host` + `--add-host` 와 엔진이 보는 주소), 운영의 양 · 시간, 직원이 만든 실제 폴더 이름. +- **확인하지 않은 것** — 운영 mailcow(2026-05-12 판)의 마스터 계정과 mailcow 가 서버 B 를 보는 주소(준비의 "원본 로그인만"으로 본다) · Allowed protocols, 서버 B 의 길(`--network host` + `--add-host` 와 엔진이 보는 주소), 운영의 양 · 시간, 직원이 만든 실제 폴더 이름. --- -- 2.52.0 From b43b7471071bb6870682bdd2b500add017ee9515 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 01:20:20 +0900 Subject: [PATCH 03/14] =?UTF-8?q?docs:=20=EC=9A=B4=EC=98=81=20mailcow=20?= =?UTF-8?q?=EB=8A=94=20=EB=B0=94=EA=B9=A5=20=EC=A0=91=EC=86=8D=EC=9D=84=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90=2010.9.0.1=20=EB=A1=9C=20=EB=B3=B8=EB=8B=A4?= =?UTF-8?q?=20=E2=80=94=20Fail2ban=20=EC=98=88=EC=99=B8=20=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=86=EC=9D=8C=20=C2=B7=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=8A=94=20--tail=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 mailcow 에서 확인(2026-11-03): 마스터 두 값은 빈 값(아직 아무도 고정하지 않음). 최근 7일 Dovecot 로그인 줄과 지금 연결이 모두 앞단 터널 주소 10.9.0.1(사설) — 공인 주소 0. mailcow 의 Fail2ban 은 사설 주소를 막지 않으므로 서버 B 를 예외에 넣지 않는다. 남는 것은 Dovecot 의 늦추기(같은 주소의 바깥 직원 모두)라 실패를 되풀이하지 않는다. 이 mailcow 의 docker 로그(json-file · 회전 없음 · 428MB)는 --since 만 주면 0줄이고 --tail 은 된다 — 처음부터 읽다 중간의 읽지 못하는 줄에서 말없이 멈추는 것으로 보인다. 로그 명령을 --tail 로 바꾸고, 로그 없이 지금 연결을 보는 doveadm who 를 더했다. Co-Authored-By: Claude Opus 5.5 --- mail/README.md | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/mail/README.md b/mail/README.md index d9bd14c..d8678fb 100644 --- a/mail/README.md +++ b/mail/README.md @@ -1318,7 +1318,7 @@ alias dcx='docker compose exec postfix-mailcow' 1. **postscreen 예외** — `data/conf/postfix/custom_postscreen_whitelist.cidr` 에 `/32 permit` (재시작 없이 다시 읽는다). 새 IP 가 차단 목록 하나에만 있어도 postscreen 에서 끊길 수 있다. Forwarding Hosts 에는 넣지 않는다 — 넣으면 그 IP 의 메일은 스팸 검사 · 위조 판정 없이 들어온다. 2. **Fail2ban 예외** — System → Configuration → Options → Fail2ban parameters → "Allowlisted networks/hosts" 에 `` (imapsync 로그인 실패가 쌓여 mx1 IP 가 막히면 SMTP 도 끊긴다). - mailcow 가 mx1 을 사설 주소로 보면(앞단 · WSL 을 지나며 바뀌면) 막히지 않아 필요 없다 — 넣어도 해는 없다([imapsync › 원본](#원본--mailcow-마스터-계정)의 접속 주소). + mailcow 가 mx1 을 사설 주소로 보면 막히지 않아 필요 없다 — IMAP(993)은 앞단 터널 주소 `10.9.0.1` 로 보였고(2026-11-03), 25 는 보지 않았다. 넣어도 해는 없다([imapsync › 원본](#원본--mailcow-마스터-계정)의 접속 주소). 3. **새 서버 setup** — 위 "env 와 실행". 이때부터 새 서버의 owner@ 가 동료에게 보낼 수 있다(mailcow 는 아직 owner 앞 메일을 자기 사서함에 넣는다). 4. **반대 방향부터 시험** — 새 서버의 owner@(웹메일)로 동료에게 한 통. 실사용자에게 영향이 없는 방향이라 먼저 한다: ```bash @@ -1500,7 +1500,7 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul | 단계 | 할 일 | 비고 | | --- | --- | --- | -| 준비 | 단계 2 며칠 전 — mailcow 마스터 계정 고정(아래 "원본") · 서버 B 에 마스터 비밀번호 파일 · **원본 로그인만 미리**(아래 "돌리는 곳"의 준비 줄 — 대상 계정 없이) · mailcow 가 서버 B 를 본 주소가 공인이면 Fail2ban 예외에 · 주소 목록 | 원본 쪽 문제(마스터 · 인증서 · 앞단 길)를 계정을 만들기 전에 고친다 — 단계 2 부터는 짧게 이어야 한다(아래). Fail2ban 예외는 [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)의 mailcow 절차 2 와 같다 — imapsync 로그인 실패가 쌓여 그 IP 가 막히면 새 서버 → mailcow 의 SMTP 도 끊긴다 | +| 준비 | 단계 2 며칠 전 — mailcow 마스터 계정 고정(아래 "원본") · 서버 B 에 마스터 비밀번호 파일 · **원본 로그인만 미리**(아래 "돌리는 곳"의 준비 줄 — 대상 계정 없이) · 주소 목록 | 원본 쪽 문제(마스터 · 인증서 · 앞단 길)를 계정을 만들기 전에 고친다 — 단계 2 부터는 짧게 이어야 한다(아래). mailcow 의 Fail2ban 예외는 필요 없다 — mailcow 는 바깥 접속을 모두 앞단 터널 주소(사설 `10.9.0.1`)로 보고, 사설 주소는 막지 않는다(아래 "원본"의 접속 주소) | | 2 | 계정 만들기 — 운영 도구 「입사」를 한 사람씩(모두를 한 실행에서), 옮겨 올 메일 y. 처음 보이는 이전용 비밀번호를 **그 자리에서** 서버 B 의 비밀번호 파일로(아래 — 다시 볼 수 없다) | 「메일 이전 › 이전 현황」에 이전 중인 계정 · 끝나는 때 · 메일함 사용량이 보인다 | | 5 | imapsync 1차 — **한 사람으로 먼저**(`--justlogin` · 엔진이 본 접속 주소 · 1차 · 아래 "사람마다 확인할 것"), 그다음 주소 목록 전부 | 메일이 많으면 MX 전날에 — 단계 7 의 차이분이 짧아진다 | | 6 | MX 바꾸기 | 이때부터 단계 9 까지 직원은 새 서버에 들어오지 못한다(비밀번호를 아직 받지 않았다) | @@ -1544,22 +1544,26 @@ cd /opt/mailcow-dockerized && sudo docker compose up -d sudo awk -F= '/^DOVECOT_MASTER_(USER|PASS)=/ { print $1, ($2 == "" ? "비어 있음" : "값 있음") }' /opt/mailcow-dockerized/mailcow.conf # 값 대신 비었는지만. 아무것도 안 나오면 줄이 없거나 주석 sudo git -C /opt/mailcow-dockerized log -1 --format='%cd %h' --date=short # mailcow 판(커밋 날짜) ``` - 운영(2026-11-03 확인): 설치 폴더 `/opt/mailcow-dockerized`, 판 2026-05-12(`32156a33`), 두 줄은 주석 없이 있다. + 운영(2026-11-03 확인): 설치 폴더 `/opt/mailcow-dockerized`, 판 2026-05-12(`32156a33`), 두 줄은 주석 없이 **빈 값**이다(아직 아무도 고정하지 않았다). "값 있음"이면 누군가 이미 고정해 둔 것이다 — 바꾸기 전에 그 값을 쓰는 곳(백업 스크립트 등)이 있는지 알아본다. -- **mailcow 가 보는 접속 주소** — `mail.arasystem.it.kr:993` 은 Contabo 앞단을 지나 이 PC(WSL)의 mailcow 에 닿는다 — 가는 길에 접속 주소가 바뀔 수 있다. - mailcow 의 Fail2ban(netfilter-mailcow — 기본 10분에 실패 10번이면 30분)은 **사설 · 루프백 주소는 막지 않는다**(Python `ipaddress` 의 `is_private` · `is_loopback` — mailcow 원본 `data/Dockerfiles/netfilter/main.py`, 2026-11-03 확인). - 그래서 mailcow 가 서버 B 를 사설 주소(10 · 172.16–31 · 192.168 · 127)로 보면 예외가 필요 없고, 공인 주소로 보면 그 주소를 예외에 넣는다. - 100.64–100.127.x(Tailscale 같은 공유 주소)는 사설로 치지 않아 막힐 수 있다 — 바깥 접속이 모두 그 주소 하나(또는 앞단 `46.250.248.43` 하나)로 보이면 그 주소가 예외에 있는지 본다. 막히면 모든 직원의 메일 프로그램이 함께 막힌다. +- **mailcow 가 보는 접속 주소 — 바깥 접속은 모두 `10.9.0.1`**(2026-11-03 운영 — 최근 7일 로그인 줄 · 지금 연결 모두): `mail.arasystem.it.kr:993` 은 Contabo 앞단에서 터널을 지나 + 이 PC 의 mailcow 에 닿고, mailcow 는 그 터널 주소 하나로 본다(공인 주소는 0 — 서버 B 도 같은 길이다). 안쪽 `172.22.1.248` 은 SOGo(웹메일), `172.22.1.8` 은 mailcow 안의 다른 컨테이너(7일에 3만 8천 줄 — 상태 점검으로 보인다)다. + - mailcow 의 Fail2ban(netfilter-mailcow — 기본 10분에 실패 10번이면 30분)은 **사설 · 루프백 주소는 막지 않는다**(Python `ipaddress` 의 `is_private` · `is_loopback` — mailcow 원본 `data/Dockerfiles/netfilter/main.py`). + 그래서 **서버 B 를 Fail2ban 예외에 넣을 필요가 없다**. 거꾸로 지금 mailcow 는 바깥에서 오는 IMAP 무차별 대입을 IP 로 막지 못한다(전환 뒤 새 엔진은 실제 주소로 막는다). + - 남는 것은 Dovecot 의 늦추기다 — 실패가 있었던 주소의 다음 로그인을 늦춘다(최대 15초). 바깥 접속이 모두 한 주소라 imapsync 의 실패가 쌓이면 바깥 직원의 로그인도 함께 늦어진다 — 실패를 되풀이하지 않는다. + - 이 mailcow 의 docker 로그(json-file · 회전 없음 · Dovecot 만 428MB)는 `--since` 만 주면 **0줄**이다 — 처음부터 읽다가 중간의 읽지 못하는 줄(10-24 재시작 무렵으로 보인다)에서 docker 가 말없이 멈춘다. + `--tail` 은 끝에서부터 읽어 된다 — 아래처럼 늘 `--tail` 을 준다. 앞단을 바꾸는 등 다시 볼 때: ```bash - # 아무 때나 — 지난 사흘의 IMAP · POP3 접속이 어떤 주소로 보였는지(공인 주소는 세기만 하고 보이지 않는다) - sudo docker logs --since 72h "$(sudo docker ps -qf name=dovecot-mailcow)" 2>&1 | grep -E '(imap|pop3)-login:' | grep -oE 'rip=[0-9A-Fa-f:.]+' | cut -d= -f2 \ - | awk -v front=46.250.248.43 '$0 == front { f++; next } /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|::1$|[Ff][CcDd]|[Ff][Ee]80:)/ { p[$0]++; next } { if (!($0 in g)) d++; if (++g[$0] > m) m = g[$0]; n++ } END { for (ip in p) print "내부", ip, p[ip] "줄"; print "앞단", front, f + 0, "줄"; print "그 밖의 공인", d + 0, "곳", n + 0, "줄 · 가장 많은 한 곳", m + 0, "줄" }' - # 준비의 "원본 로그인만"(서버 B — 아래 "돌리는 곳") 뒤 15분 안에 — mailcow 가 서버 B 를 본 주소. 마스터 로그인만 고른다(직원의 평소 로그인은 나오지 않는다) - sudo docker logs --since 15m "$(sudo docker ps -qf name=dovecot-mailcow)" 2>&1 \ - | awk '/Master user logging in as / { m = $NF; next } m != "" && index($0, "Login: user=<" m ">") { if (match($0, /rip=[0-9A-Fa-f:.]+/)) print m, substr($0, RSTART + 4, RLENGTH - 4); m = "" }' + # 최근 10만 줄(끝에서부터 — 본 범위의 처음 · 끝 시각도 나온다)의 로그인 줄이 어떤 주소였는지. 공인 주소는 세기만 하고 보이지 않는다 + sudo docker logs -t --tail 100000 "$(sudo docker ps -qf name=dovecot-mailcow)" 2>&1 | awk -v front=46.250.248.43 'NR == 1 { a = substr($1, 1, 19) } { z = substr($1, 1, 19) } /-login: / && match($0, /rip=[0-9A-Fa-f:.]+/) { ip = substr($0, RSTART + 4, RLENGTH - 4); if (ip == front) f++; else if (ip ~ /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|::1$|[Ff][CcDd]|[Ff][Ee]80:)/) p[ip]++; else { if (!(ip in g)) d++; if (++g[ip] > m) m = g[ip]; n++ } } END { print "본 줄", NR, "· 처음", a, "· 끝", z; for (ip in p) print "내부", ip, p[ip] "줄"; print "앞단", front, f + 0, "줄"; print "그 밖의 공인", d + 0, "곳", n + 0, "줄 · 가장 많은 한 곳", m + 0, "줄" }' + # 지금 붙어 있는 IMAP · POP3 연결의 주소(로그 없이 — Dovecot 이 아는 연결) + sudo docker exec "$(sudo docker ps -qf name=dovecot-mailcow)" doveadm -f flow who -1 2>&1 | grep -oE 'ip=[0-9A-Fa-f:.]+' | cut -d= -f2 | awk -v front=46.250.248.43 '$0 == front { f++; next } /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|::1$|[Ff][CcDd]|[Ff][Ee]80:)/ { p[$0]++; next } { if (!($0 in g)) d++; if (++g[$0] > m) m = g[$0]; n++ } END { for (ip in p) print "내부", ip, p[ip] "개"; print "앞단", front, f + 0, "개"; print "그 밖의 공인", d + 0, "곳", n + 0, "개 · 가장 많은 한 곳", m + 0, "개" }' + # 준비의 "원본 로그인만"(서버 B — 아래 "돌리는 곳") 뒤 15분 안에 — 마스터 로그인만 골라 그 주소(10.9.0.1 이면 예상대로). 직원의 평소 로그인은 나오지 않는다 + sudo docker logs --since 15m --tail 20000 "$(sudo docker ps -qf name=dovecot-mailcow)" 2>&1 | awk '/Master user logging in as / { m = $NF; next } m != "" && index($0, "Login: user=<" m ">") { if (match($0, /rip=[0-9A-Fa-f:.]+/)) print m, substr($0, RSTART + 4, RLENGTH - 4); m = "" }' ``` - `내부 172.22.1.248` 은 mailcow 안의 SOGo(웹메일)다. 두 명령은 Dovecot 2.3 의 로그 꼴(`imap-login: Login: user=<…>, …, rip=…` · `Master user logging in as …`)로 맞췄다 — - 개발 흉내(Dovecot 2.3.21.1)의 실제 로그와 mailcow 꼴 표본으로, 우분투 · 데비안의 기본 awk 인 mawk 1.3.4 에서 시험했다(2026-11-03). mailcow 는 지금 원본 compose 에서도 `dovecot:2.3.21.1-2` 다. + 100.64–100.127.x(Tailscale 같은 공유 주소)는 사설로 치지 않아 Fail2ban 이 막을 수 있다 — 앞단을 바꿔 바깥 접속이 그런 주소나 공인 주소 하나로 보이게 되면 그 주소가 예외에 있는지 본다(막히면 바깥 직원 모두가 함께 막힌다). + 세 명령은 Dovecot 2.3 의 로그 · doveadm 꼴(`imap-login: Login: user=<…>, …, rip=…` · `Master user logging in as …` · `username=… proto=… pid=… ip=…`)로 맞췄다 — 개발 흉내(Dovecot 2.3.21.1)의 실제 출력과 mailcow 꼴 표본으로, + 우분투 · 데비안의 기본 awk 인 mawk 1.3.4 에서 시험했다(2026-11-03). 앞의 둘은 운영 mailcow 에서도 돌렸다. mailcow 는 지금 원본 compose 에서도 `dovecot:2.3.21.1-2` 다. - **확인하지 않은 것** — 운영 mailcow 판(2026-05-12)에서 이 설정이 문서대로인지(같은 꼴로 인증에 실패했다는 커뮤니티 글이 있다 — 해결 기록 없음. 그래서 준비의 "원본 로그인만"으로 먼저 본다), 메일함 편집의 **Allowed protocols** 에서 IMAP 이 꺼진 메일함이 마스터로도 막히는지(그래서 mailcow 접근 줄이기는 마지막 imapsync 뒤 — 단계 8). - 개발 흉내(Dovecot 2.3 에 같은 꼴 — 구분자 `*` · 마스터 이름 `migrator@mailcow.local`)에서는 imapsync 가 그 이름을 그대로 넘겨 Dovecot 이 `Master user logging in as kim@legacy.test` 로 받았다. @@ -1596,8 +1600,8 @@ imapsync_one() { # $1 = 직원 주소, 그 뒤는 덧붙일 옵션 } # 준비 — 원본 로그인만 미리: 대상 계정 없이 두 쪽 모두 mailcow 로 --justlogin(이전용 비밀번호 파일이 아직 없어도 된다). -# 끝 줄이 `Exiting with return value 0` 이면 된다 — 아니면 grep 없이 다시 보고 아래 "실패할 때"로(되풀이하지 않는다 — 한 번에 실패 둘이 mailcow 의 Fail2ban 에 센다). -# 된 뒤 15분 안에 mailcow PC 에서 서버 B 가 어떤 주소로 보였는지 본다(위 "원본"의 접속 주소) +# 끝 줄이 `Exiting with return value 0` 이면 된다 — 아니면 grep 없이 다시 보고 아래 "실패할 때"로(되풀이하지 않는다 — 한 번에 실패가 둘이고, Dovecot 이 바깥 직원 모두와 같은 주소의 로그인을 늦춘다). +# 된 뒤 15분 안에 mailcow PC 에서 서버 B 가 어떤 주소로 보였는지 본다(예상 10.9.0.1 — 위 "원본"의 접속 주소) A=hong@arasystem.it.kr sudo docker run --rm --network host --user 65534:65534 --cap-drop ALL --security-opt no-new-privileges --read-only --tmpfs /tmp --tmpfs /var/tmp \ --mount type=bind,src=/root/imapsync/mailcow-master.pass,dst=/run/secrets/master,readonly \ @@ -1653,7 +1657,7 @@ sudo rm -f /root/imapsync/mailcow-master.pass /root/imapsync/migration.pass | `… SSL connect attempt failed … certificate verify failed` · `hostname verification failed` (10) | 인증서 — 자체 서명이거나 이름이 다르다(`--add-host` 없이 주소로 붙음) | 위 `--host2` · 인증서 | - 같은 실패를 모두에게 되풀이하지 않는다 — 엔진은 IP 마다 인증 실패 하루 100회면 그 IP 를 막는다(자동 차단 — 위 "setup 스크립트가 하는 일" 4. 서버 B 의 사설 IP 는 신뢰 대역 밖이다). - 한 사람에서 실패하면 멈추고 고친다. mailcow 쪽은 Fail2ban 이다(준비의 예외). + 한 사람에서 실패하면 멈추고 고친다. mailcow 쪽은 Fail2ban 이 막지 않지만(서버 B 도 사설 `10.9.0.1` 로 보인다) Dovecot 이 그 주소 — 바깥 직원 모두 — 의 로그인을 늦춘다(위 "원본"의 접속 주소). - 한쪽 로그인이 실패해도 imapsync 는 다른 쪽 로그인을 해 본 뒤 끝난다(`--justlogin` 에서 봤다) — 출력의 `Err 1/2` 처럼 실패가 둘로 보이면 먼저 나온 것부터 본다. #### 개발에서 확인한 것 (imapsync — 2026-11-02) @@ -1683,7 +1687,7 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a - **개발과 운영이 다른 점** — 대상 인증서가 자체 서명이라 `--sslargs2 SSL_verify_mode=0` 으로 돌렸고, imapsync 를 잠시 앱 네트워크에 붙여 `mail:993` 으로 갔다(엔진은 신뢰 대역 주소로 본다 — 운영에서는 하지 않는다). 원본은 시험 CA 로 `SSL_verify_mode=1` 이었다. -- **확인하지 않은 것** — 운영 mailcow(2026-05-12 판)의 마스터 계정과 mailcow 가 서버 B 를 보는 주소(준비의 "원본 로그인만"으로 본다) · Allowed protocols, 서버 B 의 길(`--network host` + `--add-host` 와 엔진이 보는 주소), 운영의 양 · 시간, 직원이 만든 실제 폴더 이름. +- **확인하지 않은 것** — 운영 mailcow(2026-05-12 판)의 마스터 계정과 서버 B 도 `10.9.0.1` 로 보이는지(준비의 "원본 로그인만"으로 본다) · Allowed protocols, 서버 B 의 길(`--network host` + `--add-host` 와 엔진이 보는 주소), 운영의 양 · 시간, 직원이 만든 실제 폴더 이름. --- -- 2.52.0 From 07b7fcca0f808e0f50d6dbc1d26770edca6f4caa Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 01:53:30 +0900 Subject: [PATCH 04/14] =?UTF-8?q?docs:=20=EC=84=9C=EB=B2=84=20B=2012=20?= =?UTF-8?q?=C2=B7=2013=EB=8B=A8=EA=B3=84=20=EB=B0=B0=ED=8F=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B2=AA=EC=9D=80=20=EA=B2=83=20=E2=80=94=20DB=20d?= =?UTF-8?q?igest=20=ED=99=95=EC=9D=B8=20=C2=B7=20=EC=9B=B9=ED=9B=85=20?= =?UTF-8?q?=ED=82=A4=20=EB=A8=BC=EC=A0=80=20=C2=B7=20ZEN=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=B4=88=EA=B3=BC=20=C2=B7=20=EC=86=90=20=EB=B0=B1?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-03 서버 B(11단계)를 main 으로 올리며 확인한 것을 차례에 남긴다. - DB 가 이미 고정할 digest 로 돌고 있으면 새 이미지가 없어 날짜를 기다리지 않아도 된다 — 0) 확인 명령 - 12단계 전 서버는 웹훅 키를 먼저 넣는다(키 없이 띄우면 백엔드가 env 검사에서 멈춘다) - --recovery 가 Spamhaus ZEN 시간 초과로 종료 1 이어도 관리자 허용 IP 는 걸렸으니 보통 setup 으로 간다(ZEN 을 다시 켠다) - 운영은 마이그레이션 로그가 없어 migrations 표로 본다 - 손 백업에 DB 덤프를 더하고, 떠 있는 alpine 대신 지금 도는 DB 이미지 · 읽기 권한만 · 0600 으로 Co-Authored-By: Claude Opus 5.5 --- mail/README.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/mail/README.md b/mail/README.md index d8678fb..408d098 100644 --- a/mail/README.md +++ b/mail/README.md @@ -959,13 +959,17 @@ unset OTP_ADMIN_PW OTP_TARGET ### 13단계 전부터 운영 중인 서버 이미 떠 있는 운영 서버(서버 B)에 13단계를 거는 차례다. **2026-11-04 뒤에** 한다 — 고정한 DB 이미지(digest)가 그날 2주가 찬다(`.env.production.example` 의 `DB_IMAGE` 주석). +서버의 DB 가 이미 그 digest 로 돌고 있으면(아래 0) 새로 들이는 이미지가 없어 날짜를 기다리지 않아도 된다 — 서버 B 는 10-31 에 받은 `postgres:15-alpine` 이 같은 digest 라 2026-11-03 에 올렸다. +**12단계 전부터 운영 중인 서버**(웹훅 키가 없다)는 3) 전에 [위 12단계 차례](#운영-배포)의 1) — `MAIL_WEBHOOK_SECRET` 넣기 — 를 먼저 한다(키 없이 3)을 하면 백엔드가 env 검사에서 멈춰 웹메일 전체가 선다). 웹훅은 5)의 setup 이 만든다 — 서버 B 는 11단계에서 이렇게 12 · 13단계를 한 번에 올렸다. 모든 컨테이너가 다시 만들어진다(로그 한도 · DB 이미지) — 메일 · 웹메일이 1~2분 끊기므로 사용이 적은 시간에 한다. 그 전에 손으로 백업을 받아 두기를 권한다([손으로 받을 때](#손으로-받을-때-타이머-없이--예전-방법)). ```bash cd ~/arasystem && git pull # 13단계 코드 (저장소 사용자로) export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)" -# 1) 로그 남기기 — 컨테이너를 다시 만들면 그때까지의 로그가 지워진다 -mkdir -p ~/logs-before-13 && for c in mail backend frontend db; do sudo docker logs "${PROJECT_NAME}-$c" > ~/logs-before-13/$c.log 2>&1; done +# 0) 지금 DB 가 쓰는 이미지 — 예시의 DB_IMAGE 와 같은 sha256 이면 날짜를 기다리지 않아도 된다(위) +sudo docker image inspect "$(sudo docker inspect -f '{{.Image}}' "${PROJECT_NAME}-db")" --format '{{json .RepoDigests}}' +# 1) 로그 남기기 — 컨테이너를 다시 만들면 그때까지의 로그가 지워진다(메일 주소 · IP 가 들어 있다 — 이 사용자만 읽게) +umask 077 && mkdir -p ~/logs-before-13 && for c in mail backend frontend db; do sudo docker logs "${PROJECT_NAME}-$c" > ~/logs-before-13/$c.log 2>&1; done # 2) .env.production 의 DB_IMAGE 를 예시와 같은 고정값으로 (옛 값 postgres:15-alpine) — 편집기로 그 한 줄만 grep -n '^DB_IMAGE=' .env.production .env.production.example # 3) 복구 관리자를 일회성으로 준 채 전부 다시 만든다 — 관리자 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 setup 이 건다(막히면 되돌릴 길) @@ -984,6 +988,10 @@ node --env-file=.env.production mail/scripts/setup.mjs - 5)의 출력에 `[변경] 관리자 역할("System Administrator") — 대리 접근(impersonate) 뺌`, `[변경] 관리자 … — 허용 IP `, 휴지통 · 정리 시각 줄, 그리고 `[확인] 관리자 … — 대리 접근 권한 없음 (권한 N개에 impersonate 없음) · 허용 IP … 안에서 로그인됨` 이 있어야 한다. 6)은 모두 `[유지]` 에 같은 `[확인]` 줄. - 복구 관리자 없이 처음 돌리면 setup 이 허용 IP 를 걸지 않고 종료 1 로 이 절을 가리킨다(아무것도 막히지 않는다 — 다시 3)부터). +- 5)가 종료 1 이고 `[실패]` 가 **Spamhaus ZEN 시험 항목의 시간 초과**뿐이면 — 리졸버를 막 다시 띄운 직후에 난다(2026-10-31 · 11-03 두 번, 같은 키의 DBL 은 통과). + setup 이 ZEN 을 꺼 두지만 6)의 보통 setup 이 다시 시험해 켠다(`[변경] … ZEN(IP) — DQS 로 묻는다`). 관리자 허용 IP 는 이미 걸리고 확인됐으니(`[확인] 관리자 … 안에서 로그인됨`) 그대로 6)으로 간다 — 이때는 "복구 관리자 끄기" 안내가 나오지 않는다. +- 6)의 끝에 `[확인] 복구 관리자 꺼짐 (mail 컨테이너에 STALWART_RECOVERY_ADMIN 없음)` · `[완료]` · 종료 0 이면 끝이다(2026-11-03 서버 B — 백엔드는 마이그레이션 `AddAccountSecurity` · `AddCutoverPrep` 를 적용하며 떴다. 운영은 마이그레이션 로그를 남기지 않으니 `migrations` 표로 본다: + `sudo docker exec "${PROJECT_NAME}-db" sh -c 'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "select name from migrations order by id desc limit 3"'`). - 7) **백업 · 감시 설치** — [백업](#백업) › 설치 1~4(부팅 때 엔진 되살리기 유닛 포함), 첫 백업 뒤 `restore.sh drill` 한 번, [감시](#감시-13단계)의 Healthchecks · UptimeRobot. - 8) 바깥에서 관리자 비밀번호가 막혔는지 — 다른 네트워크의 PC 에서 `openssl s_client -quiet -connect mx1.arasystem.it.kr:993` 뒤 `a LOGIN <관리자 주소> <비밀번호>` → `NO`(또는 끊김). 관리 화면은 이제 [SSH 터널](#관리-화면--ssh-터널)로만 들어간다. @@ -1807,14 +1815,20 @@ systemctl list-timers 'arasystem-*' ### 손으로 받을 때 (타이머 없이 — 예전 방법) +배포 전 등에 한 번 받을 때 — DB 는 멈추지 않고, 메일 엔진만 30초쯤 멈춘다(2026-11-03 서버 B 13단계 배포 전에 이대로 받았다 — DB 표 20개 · 메일 1MB). + ```bash -sudo docker compose --env-file .env.production stop mail -sudo docker run --rm -v "${PROJECT_NAME}_mail-config:/config:ro" -v "${PROJECT_NAME}_mail-data:/data:ro" -v "$PWD/backup:/backup" \ - alpine tar czf "/backup/mail-$(date +%Y%m%d-%H%M).tar.gz" -C / config data -sudo docker compose --env-file .env.production start mail +cd ~/arasystem && export PROJECT_NAME=arasystem && umask 077 && D=~/backup-$(date +%Y%m%d-%H%M) && mkdir -p "$D" +# DB — 백업 스크립트와 같은 방법(pg_dump -Fc 뒤 pg_restore -l 로 읽히는지 — 표 수가 나온다) +sudo docker exec "${PROJECT_NAME}-db" sh -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$D/db.dump" && sudo docker exec -i "${PROJECT_NAME}-db" pg_restore -l < "$D/db.dump" | grep -c ' TABLE ' +# 메일 볼륨 — 엔진을 멈추고 묶는다. 이미지는 지금 도는 DB 이미지(새로 받지 않는다) · 네트워크 없음 · 읽기 권한만, 파일은 표준 출력으로 받아 이 사용자 것(0600) +IMG="$(sudo docker inspect -f '{{.Image}}' "${PROJECT_NAME}-db")" && sudo docker compose --env-file .env.production stop mail && sudo docker run --rm --network none --cap-drop ALL --cap-add DAC_READ_SEARCH --security-opt no-new-privileges -v "${PROJECT_NAME}_mail-config:/config:ro" -v "${PROJECT_NAME}_mail-data:/data:ro" --entrypoint tar "$IMG" czf - -C / config data > "$D/mail.tar.gz"; echo "tar 종료 코드 $?"; sudo docker compose --env-file .env.production start mail +ls -lh "$D" ``` -- 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /`, 소유자 UID 2000 유지) 컨테이너를 띄운다. 백업 파일에는 메일 전체가 들어 있다 — 접근을 제한하고 서버 밖에도 둔다. +- 메일 볼륨 줄은 한 줄이다 — 앞 단계가 실패하면 엔진을 멈추지 않게 `&&` 로 묶었다(나눠 붙이다 `IMG=` 를 빠뜨리면 `invalid reference format` 에 빈 파일이다 — 2026-11-03 한 번 겪음). `start mail` 은 어떻게 끝나도 돈다. +- 이미지를 따로 받지 않는 까닭 — 떠 있는 태그(`alpine` 등)를 쓰면 그날의 새 이미지가 들어온다([docker 스킬](../.claude/skills/docker/SKILL.md) "이미지 버전 고정"). +- 복원은 DB 는 빈 DB 에 `pg_restore`, 메일은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /`, 소유자 UID 2000 유지) 컨테이너를 띄운다. 백업 파일에는 메일 전체 · DKIM 키 · DB 가 들어 있다 — 접근을 제한하고 서버 밖에도 두며, 쓸모가 끝나면 지운다. --- -- 2.52.0 From 0388cf609a1b251c1799dbd716a95d9a4f36237f Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 01:59:44 +0900 Subject: [PATCH 05/14] =?UTF-8?q?docs:=20restic=20=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=EB=8A=94=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B4=80=EB=A6=AC=EC=9E=90=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A8=BC=EC=A0=80=20=E2=80=94=20=ED=99=94=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B0=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=97=90=20=EB=84=A3=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설치 2)는 서버에서 openssl 로 만들고 "비밀번호 관리자에도 둔다"고만 적어, 서버 밖에 두는 길이 빠져 있었다. 생성기로 먼저 만들어 저장한 뒤 read -rsp 로 붙여 넣게 바꿨다 — 서버 밖 사본이 백업보다 먼저 생긴다. 서버에서 만드는 길은 sudo cat 으로 한 번 옮기는 줄과 함께 남겼다. 회사가 꺼낼 수 있는 곳에 두라는 것(운영자 한 사람만이 아니라)과 재설정이 없다는 것을 더했다. Co-Authored-By: Claude Opus 5.5 --- mail/README.md | 7 +++++-- ops/ops.env.example | 8 +++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/mail/README.md b/mail/README.md index 408d098..8b0ce9f 100644 --- a/mail/README.md +++ b/mail/README.md @@ -1753,8 +1753,11 @@ sudo install -m 0644 ops/systemd/arasystem-*.service ops/systemd/arasystem-*.tim # 2) 설정 · 비밀 — 저장소 밖, root 만 (값은 ops/ops.env.example 의 설명대로) sudo install -d -m 0700 -o root -g root /etc/arasystem sudo install -m 0600 -o root -g root ops/ops.env.example /etc/arasystem/ops.env && sudoedit /etc/arasystem/ops.env -sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password' -# ⚠️ restic 비밀번호와 NCP 키를 회사의 비밀번호 관리자에도 둔다 — 서버를 잃으면 이것 없이는 백업을 풀 수 없다 +# restic 비밀번호 — 백업 저장소를 암호화하는 열쇠. 비밀번호 관리자의 생성기로 먼저 만들어 저장하고(40자 이상 · 영문 · 숫자) 화면 · 셸 기록에 남기지 않게 붙여 넣는다 +read -rsp 'restic 비밀번호: ' P; echo; printf '%s\n' "$P" | sudo install -m 0600 -o root -g root /dev/stdin /etc/arasystem/restic-password; unset P +# (서버에서 만들려면 sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password' — 그때는 sudo cat 으로 한 번 보고 비밀번호 관리자로 옮긴다) +# ⚠️ restic 비밀번호와 NCP 키를 회사의 비밀번호 관리자에도 둔다 — 서버를 잃으면 이것 없이는 백업을 풀 수 없다(재설정이 없다). +# 운영자 한 사람만 아는 곳이 아니라 회사가 꺼낼 수 있는 곳에. 저장소를 만든(3 의 init) 뒤에는 같은 값을 계속 쓴다 # 3) 저장소를 만들고 첫 백업을 손으로 (메일 엔진이 몇 초 멈춘다 — 사용이 적은 시간에) sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh init diff --git a/ops/ops.env.example b/ops/ops.env.example index 2c08b43..17f0061 100644 --- a/ops/ops.env.example +++ b/ops/ops.env.example @@ -4,9 +4,11 @@ # 이 파일은 예시다. 서버에서는 저장소 밖에 둔다 — root 만 읽게: # sudo install -d -m 0700 -o root -g root /etc/arasystem # sudo install -m 0600 -o root -g root ops/ops.env.example /etc/arasystem/ops.env # 그다음 값을 채운다 (sudoedit /etc/arasystem/ops.env) -# restic 저장소 비밀번호는 따로 /etc/arasystem/restic-password (0600) — 한 줄, 무작위로: -# sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password' -# ⚠️ restic 비밀번호 · S3 키는 서버 밖(회사의 비밀번호 관리자)에도 둔다 — 서버를 잃었는데 비밀번호가 서버에만 있으면 백업을 풀 수 없다. +# restic 저장소 비밀번호는 따로 /etc/arasystem/restic-password (0600) — 한 줄, 무작위로. 비밀번호 관리자의 생성기로 먼저 만들어 저장한 뒤(40자 이상 · 영문 · 숫자) +# 화면 · 셸 기록에 남기지 않고 붙여 넣는다(restic 은 끝 줄바꿈을 떼고 읽는다): +# read -rsp 'restic 비밀번호: ' P; echo; printf '%s\n' "$P" | sudo install -m 0600 -o root -g root /dev/stdin /etc/arasystem/restic-password; unset P +# (서버에서 만들려면 sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password' — 그때는 sudo cat 으로 한 번 보고 비밀번호 관리자로 옮긴다) +# ⚠️ restic 비밀번호 · S3 키는 서버 밖(회사의 비밀번호 관리자 — 운영자 한 사람만이 아니라 회사가 꺼낼 수 있는 곳)에도 둔다 — 서버를 잃었는데 비밀번호가 서버에만 있으면 백업을 풀 수 없다. # # 형식: KEY=값 한 줄씩. 스크립트는 이 파일을 셸로 실행(source)하지 않고 줄마다 읽는다 — 값에 공백이 있으면 작은따옴표로 감싼다. # ========================================== -- 2.52.0 From feabe0d8e16160f2a3cefbd9dedfce832a8e4262 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 02:04:20 +0900 Subject: [PATCH 06/14] =?UTF-8?q?docs:=20=EB=B0=B1=EC=97=85=20=EB=B2=84?= =?UTF-8?q?=ED=82=B7=EC=9D=80=20=EA=B3=B5=EA=B0=9C=20=EC=95=88=20=ED=95=A8?= =?UTF-8?q?=20=C2=B7=20=EC=9E=A0=EA=B8=88=20=ED=95=B4=EC=A0=9C=20=C2=B7=20?= =?UTF-8?q?=EC=95=94=ED=98=B8=ED=99=94(KMS)=20=ED=95=B4=EC=A0=9C=20?= =?UTF-8?q?=E2=80=94=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NCP 버킷 생성 화면의 칸마다 무엇을 고르는지 적는다. 공개는 목록을 누구에게나 열고, 잠금(보존 기간)은 restic 의 prune 을 막고, KMS 암호화는 되돌릴 수 없고 restic 이 이미 암호화한다. Co-Authored-By: Claude Opus 5.5 --- mail/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mail/README.md b/mail/README.md index 8b0ce9f..a802f0f 100644 --- a/mail/README.md +++ b/mail/README.md @@ -1769,7 +1769,9 @@ sudo systemctl enable arasystem-mail-recover.service systemctl list-timers 'arasystem-*' ``` -- **NCP Object Storage** — 콘솔에서 버킷을 만든다(공개 안 함 · 수명 주기 삭제 규칙 없음 — 지우기는 restic 이 한다). API 키는 이 버킷만 쓰는 서브 계정으로. +- **NCP Object Storage** — 콘솔에서 한국 리전에 버킷을 만든다(공개 안 함 · 수명 주기 삭제 규칙 없음 — 지우기는 restic 이 한다). API 키는 이 버킷만 쓰는 서브 계정으로. + 만들 때의 다른 칸은 기본값 그대로 — **잠금 해제**(보존 기간을 걸면 restic 이 지난 백업을 지우지 못해 prune 이 실패한다) · **암호화(KMS) 해제**(restic 이 이미 암호화한다. 한 번 켜면 끌 수 없다) · 타 계정 권한 없음. + "공개"는 버킷의 폴더 · 파일 목록을 누구에게나 연다 — restic 은 키로 서명해 들어가므로 필요 없다(NCP 가이드 "버킷 관리", 2026-11-03 확인). `RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/<버킷>/arasystem`, `RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path'` — 지역 `kr-standard`(서명 V4) · 경로 방식(NCP 는 경로 · 가상 호스트 방식 둘 다 받는다 — [NCP API 가이드 Object Storage](https://api.ncloud-docs.com/docs/storage-objectstorage)). ⚠️ **NCP 로는 아직 돌려 보지 않았다**(MinIO 로만) — 3)의 `init` · 첫 백업 · 아래 `drill` 로 처음 확인한다. -- 2.52.0 From 9b530da6e10c10e138e1cdec603028646067bcb0 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 02:11:14 +0900 Subject: [PATCH 07/14] =?UTF-8?q?docs:=20=EB=B0=B1=EC=97=85=20=ED=82=A4?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=ED=82=B7=EC=9D=B4=20=EC=95=84=EB=8B=88?= =?UTF-8?q?=EB=9D=BC=20=EC=84=9C=EB=B8=8C=20=EA=B3=84=EC=A0=95=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=E2=80=94=20Object=20Storage=20=EB=A7=8C=20=C2=B7?= =?UTF-8?q?=20=EB=B2=84=ED=82=B7=20=ED=95=98=EB=82=98=EB=A1=9C=EB=8A=94=20?= =?UTF-8?q?=EB=AA=BB=20=EC=A2=81=ED=9E=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NCP 버킷에는 따로 키가 없다(사용자가 버킷을 만든 뒤 키를 찾지 못했다). 키는 서브 계정의 Access Key 탭에서 만들고 Secret Key 는 그때만 보인다 — 차례를 README 에 적었다. "이 버킷만 쓰는 하위 계정"은 NCP 문서로 뒷받침되지 않는다(관리형 정책은 Object Storage 전체, 사용자 정의 정책에도 버킷을 고르는 칸이 문서에 없다) — README · ops.env 예시 · 체크리스트를 "Object Storage 만 쓰는 서브 계정 · 같은 계정에 다른 버킷을 두지 않는다"로 바로잡았다. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/checklist.md | 2 +- mail/README.md | 8 ++++++-- ops/ops.env.example | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.claude/rules/checklist.md b/.claude/rules/checklist.md index 55a8d7d..8885e49 100644 --- a/.claude/rules/checklist.md +++ b/.claude/rules/checklist.md @@ -17,7 +17,7 @@ 12단계부터 운영은 `MAIL_WEBHOOK_SECRET`(엔진 웹훅 서명 키 — 영문 · 숫자 32자 이상, `openssl rand -hex 24`)도 필수다 — setup 과 backend 가 같은 값을 읽는다 13단계부터 운영은 `DB_IMAGE` 를 예시의 고정값(판 · digest)으로 두고, 첫 setup 은 복구 관리자와 함께(`--recovery` — 관리자 허용 IP 를 처음 걸 때 되돌릴 길) 돌린다. 이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버" 차례를 따른다 -- [ ] 운영 백업 · 감시(13단계 — 호스트 systemd, mail/README "백업 › 설치" · "감시"): Object Storage 비공개 버킷 + 그 버킷만 쓰는 하위 계정 키, restic 비밀번호(**서버 밖에도 보관** — 잃으면 백업을 풀 수 없다), +- [ ] 운영 백업 · 감시(13단계 — 호스트 systemd, mail/README "백업 › 설치" · "감시"): Object Storage 비공개 버킷 + Object Storage 만 쓰는 하위 계정 키(버킷에는 따로 키가 없고 NCP 는 키를 버킷 하나로 좁히지 못한다 — 같은 계정에 다른 버킷을 두지 않는다), restic 비밀번호(**서버 밖에도 보관** — 잃으면 백업을 풀 수 없다), `/etc/arasystem/ops.env`, 타이머 셋(백업 03:30 · 점검 15분 · 부팅 되살리기) 켜기, 첫 백업 뒤 복원 연습(`drill`) 한 번. Healthchecks 체크 둘 · UptimeRobot 감시 셋 — 알림은 우리 메일 서버가 아닌 주소로 - [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패) - [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`) diff --git a/mail/README.md b/mail/README.md index a802f0f..29ef43a 100644 --- a/mail/README.md +++ b/mail/README.md @@ -288,7 +288,7 @@ node --env-file=.env.development mail/scripts/setup.mjs - **계정 보안(12단계)** — `.env.production` 의 `MAIL_WEBHOOK_SECRET`(엔진 웹훅의 서명 키 — `openssl rand -hex 24` 로 만든 값, 사람이 지은 값은 쓰지 않는다). 운영 필수다 — 아래 2)의 `up` **전에** 넣는다(없으면 백엔드가 env 검사에서 멈춰 웹메일 전체가 서고, setup 도 멈춘다). 직원에게 미리 알린다 — 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 앱 비밀번호로 다시 연결해야 한다 ([계정 보안](#계정-보안-12단계--인프라)) -- **전환 준비(13단계)** — 이미 떠 있는 서버는 [13단계 전부터 운영 중인 서버](#13단계-전부터-운영-중인-서버)의 차례대로(첫 setup 은 `--recovery`). 첫 직원을 옮기기 **전에** 백업과 15분 점검을 켠다: NCP Object Storage 버킷 · 그 버킷만 쓰는 하위 계정 키 · restic 비밀번호(비밀번호 관리자에 따로 보관), +- **전환 준비(13단계)** — 이미 떠 있는 서버는 [13단계 전부터 운영 중인 서버](#13단계-전부터-운영-중인-서버)의 차례대로(첫 setup 은 `--recovery`). 첫 직원을 옮기기 **전에** 백업과 15분 점검을 켠다: NCP Object Storage 비공개 버킷 · Object Storage 만 쓰는 하위 계정 키 · restic 비밀번호(비밀번호 관리자에 따로 보관), Healthchecks.io 점검 둘 · UptimeRobot 감시 셋 ([백업](#백업) · [감시](#감시-13단계)). `.env.production` 의 `DB_IMAGE` 를 예시와 같은 값(판 · digest 고정 — 옛 파일은 `postgres:15-alpine`)으로 바꾼다 — 다음 `up -d` 에서 db 가 다시 만들어진다(몇 초 · 데이터는 볼륨 그대로, 같은 메이저 15 라 덤프 · 복원은 필요 없다). frontend 는 `MAIL_DOMAIN` · `MAIL_HOSTNAME` 으로 [메일 프로그램 자동 설정](#메일-프로그램-자동-설정) 응답을 만든다 — 이미 있는 값이라 새로 넣을 것은 없다 @@ -1769,7 +1769,11 @@ sudo systemctl enable arasystem-mail-recover.service systemctl list-timers 'arasystem-*' ``` -- **NCP Object Storage** — 콘솔에서 한국 리전에 버킷을 만든다(공개 안 함 · 수명 주기 삭제 규칙 없음 — 지우기는 restic 이 한다). API 키는 이 버킷만 쓰는 서브 계정으로. +- **NCP Object Storage** — 콘솔에서 한국 리전에 버킷을 만든다(공개 안 함 · 수명 주기 삭제 규칙 없음 — 지우기는 restic 이 한다). + **버킷에는 따로 키가 없다** — API 인증키(Access Key ID · Secret Key)는 Object Storage 만 쓰는 **서브 계정**의 것을 쓴다(메인 계정 키는 서버 · 결제까지 연다): + 콘솔 › Sub Account › Sub Accounts › 서브 계정 생성(접근 유형은 **API Gateway 접근만** — 콘솔 로그인 없음) → 그 계정의 **정책** 탭에 관리형 `NCP_OBJECT_STORAGE_MANAGER` 하나 + (`…_VIEWER` 는 읽기만이라 백업이 안 된다) → **Access Key** 탭에서 추가 — **Secret Key 는 만들 때만 보인다**, 그 자리에서 비밀번호 관리자(restic 비밀번호와 같은 항목)에 둔다 → ops.env 의 `AWS_ACCESS_KEY_ID` · `AWS_SECRET_ACCESS_KEY`. + NCP 문서에는 키를 버킷 하나로 좁히는 방법이 없다(2026-11-03 — 사용자 정의 정책도 버킷을 고르는 칸이 문서에 없다) — 이 키로 같은 계정의 버킷이 모두 열리므로 다른 버킷을 두지 않는다(시험 버킷은 쓸모가 끝나면 지운다). 만들 때의 다른 칸은 기본값 그대로 — **잠금 해제**(보존 기간을 걸면 restic 이 지난 백업을 지우지 못해 prune 이 실패한다) · **암호화(KMS) 해제**(restic 이 이미 암호화한다. 한 번 켜면 끌 수 없다) · 타 계정 권한 없음. "공개"는 버킷의 폴더 · 파일 목록을 누구에게나 연다 — restic 은 키로 서명해 들어가므로 필요 없다(NCP 가이드 "버킷 관리", 2026-11-03 확인). `RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/<버킷>/arasystem`, `RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path'` — diff --git a/ops/ops.env.example b/ops/ops.env.example index 17f0061..7713173 100644 --- a/ops/ops.env.example +++ b/ops/ops.env.example @@ -31,7 +31,8 @@ RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/change-me-bucket/arasys # NCP 의 S3 옵션 — 지역 kr-standard(서명 V4 에 쓴다) · 경로 방식(https://<엔드포인트>/<버킷> — NCP 는 경로 · 가상 호스트 방식 둘 다 받는다) # (근거: NCP API 가이드 Object Storage — https://api.ncloud-docs.com/docs/storage-objectstorage · …-listobjects) RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path' -# NCP 서브 계정의 API 인증키 — 이 버킷만 쓰게 좁힌 계정으로 (콘솔 › Sub Account) +# NCP 서브 계정의 API 인증키 — Object Storage 만 쓰는 서브 계정으로 (콘솔 › Sub Account — 접근 유형 API Gateway 만 · 정책 NCP_OBJECT_STORAGE_MANAGER 하나 · Access Key 탭). +# 버킷에는 따로 키가 없고, NCP 는 키를 버킷 하나로 좁히지 못한다 — 같은 계정에 다른 버킷을 두지 않는다. Secret Key 는 만들 때만 보인다(바로 비밀번호 관리자에) AWS_ACCESS_KEY_ID=change-me AWS_SECRET_ACCESS_KEY=change-me # 보존 — 매일 7 · 매주 4 · 매달 6 (그보다 오래된 백업은 지운다 — 지운 메일도 이 기간까지만 백업에 남는다) -- 2.52.0 From 3f8ba0081a0bf026071d8e8208caebac84b11ecc Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 02:16:39 +0900 Subject: [PATCH 08/14] =?UTF-8?q?docs:=20=EB=B0=B1=EC=97=85=20=ED=82=A4?= =?UTF-8?q?=EB=8A=94=20=EB=A9=94=EC=9D=B8=20=EA=B3=84=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=EB=B0=B1=EC=97=85=20=EC=A0=84=EC=9A=A9=20API=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=ED=82=A4=EB=A1=9C=20=E2=80=94=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B2=B0=EC=A0=95=20=C2=B7=20=EC=9C=84=ED=97=98=20?= =?UTF-8?q?=C2=B7=20=EC=84=9C=EB=B8=8C=20=EA=B3=84=EC=A0=95=EC=9D=80=20?= =?UTF-8?q?=EC=A2=81=ED=9E=88=EB=8A=94=20=EA=B8=B8=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 서브 계정을 만들지 않고 메인 계정의 API 인증키로 가기로 했다(2026-11-03). README · ops.env 예시에 만드는 곳(My Account › 계정 및 보안 관리 › 보안 관리 › 접근 관리 — 계정마다 2개, 기본 키와 섞지 않게 새로)과, 그 키가 계정의 모든 API 를 연다는 위험을 함께 적었다. 서브 계정 차례는 범위를 줄이는 길로 남겼다. 체크리스트(템플릿 공용)의 권장은 그대로 둔다. Co-Authored-By: Claude Opus 5.5 --- mail/README.md | 13 ++++++++----- ops/ops.env.example | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/mail/README.md b/mail/README.md index 29ef43a..27c5c69 100644 --- a/mail/README.md +++ b/mail/README.md @@ -288,7 +288,7 @@ node --env-file=.env.development mail/scripts/setup.mjs - **계정 보안(12단계)** — `.env.production` 의 `MAIL_WEBHOOK_SECRET`(엔진 웹훅의 서명 키 — `openssl rand -hex 24` 로 만든 값, 사람이 지은 값은 쓰지 않는다). 운영 필수다 — 아래 2)의 `up` **전에** 넣는다(없으면 백엔드가 env 검사에서 멈춰 웹메일 전체가 서고, setup 도 멈춘다). 직원에게 미리 알린다 — 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 앱 비밀번호로 다시 연결해야 한다 ([계정 보안](#계정-보안-12단계--인프라)) -- **전환 준비(13단계)** — 이미 떠 있는 서버는 [13단계 전부터 운영 중인 서버](#13단계-전부터-운영-중인-서버)의 차례대로(첫 setup 은 `--recovery`). 첫 직원을 옮기기 **전에** 백업과 15분 점검을 켠다: NCP Object Storage 비공개 버킷 · Object Storage 만 쓰는 하위 계정 키 · restic 비밀번호(비밀번호 관리자에 따로 보관), +- **전환 준비(13단계)** — 이미 떠 있는 서버는 [13단계 전부터 운영 중인 서버](#13단계-전부터-운영-중인-서버)의 차례대로(첫 setup 은 `--recovery`). 첫 직원을 옮기기 **전에** 백업과 15분 점검을 켠다: NCP Object Storage 비공개 버킷 · 백업 전용 API 인증키(아래 "백업 › 설치" — 메인 계정 키를 고른 까닭과 위험) · restic 비밀번호(비밀번호 관리자에 따로 보관), Healthchecks.io 점검 둘 · UptimeRobot 감시 셋 ([백업](#백업) · [감시](#감시-13단계)). `.env.production` 의 `DB_IMAGE` 를 예시와 같은 값(판 · digest 고정 — 옛 파일은 `postgres:15-alpine`)으로 바꾼다 — 다음 `up -d` 에서 db 가 다시 만들어진다(몇 초 · 데이터는 볼륨 그대로, 같은 메이저 15 라 덤프 · 복원은 필요 없다). frontend 는 `MAIL_DOMAIN` · `MAIL_HOSTNAME` 으로 [메일 프로그램 자동 설정](#메일-프로그램-자동-설정) 응답을 만든다 — 이미 있는 값이라 새로 넣을 것은 없다 @@ -1770,10 +1770,13 @@ systemctl list-timers 'arasystem-*' ``` - **NCP Object Storage** — 콘솔에서 한국 리전에 버킷을 만든다(공개 안 함 · 수명 주기 삭제 규칙 없음 — 지우기는 restic 이 한다). - **버킷에는 따로 키가 없다** — API 인증키(Access Key ID · Secret Key)는 Object Storage 만 쓰는 **서브 계정**의 것을 쓴다(메인 계정 키는 서버 · 결제까지 연다): - 콘솔 › Sub Account › Sub Accounts › 서브 계정 생성(접근 유형은 **API Gateway 접근만** — 콘솔 로그인 없음) → 그 계정의 **정책** 탭에 관리형 `NCP_OBJECT_STORAGE_MANAGER` 하나 - (`…_VIEWER` 는 읽기만이라 백업이 안 된다) → **Access Key** 탭에서 추가 — **Secret Key 는 만들 때만 보인다**, 그 자리에서 비밀번호 관리자(restic 비밀번호와 같은 항목)에 둔다 → ops.env 의 `AWS_ACCESS_KEY_ID` · `AWS_SECRET_ACCESS_KEY`. - NCP 문서에는 키를 버킷 하나로 좁히는 방법이 없다(2026-11-03 — 사용자 정의 정책도 버킷을 고르는 칸이 문서에 없다) — 이 키로 같은 계정의 버킷이 모두 열리므로 다른 버킷을 두지 않는다(시험 버킷은 쓸모가 끝나면 지운다). + **버킷에는 따로 키가 없다** — 계정의 API 인증키(Access Key ID · Secret Key)를 쓴다. **서버 B 는 메인 계정의 API 인증키를 백업 전용으로 새로 만들어 쓴다**(2026-11-03 사용자 결정 — 서브 계정은 만들지 않는다): + 콘솔 오른쪽 위 My Account › 계정 및 보안 관리(다시 로그인) › 보안 관리 › 접근 관리 › API 인증키 관리 › [신규 API 인증키 생성] — 계정마다 2개까지라, 처음부터 있던 기본 키는 쓰지 않는다 + (그 키를 쓰는 다른 곳과 섞이지 않게 — 문제가 생기면 이 키만 사용 중지할 수 있다) → 두 값을 비밀번호 관리자(restic 비밀번호와 같은 항목)에 → ops.env 의 `AWS_ACCESS_KEY_ID` · `AWS_SECRET_ACCESS_KEY`. + ⚠️ **메인 계정 키는 Object Storage 만이 아니라 그 NCP 계정의 모든 API 를 연다**(서버 A · B, 네트워크 · ACG, 스토리지 …) — 이 키가 놓이는 서버 B 의 root 가 뚫리면 계정 전체가 넘어간다. 받아들이고 고른 길이다. + 범위를 줄이려면 서브 계정으로 바꾼다 — Sub Account › Sub Accounts › 서브 계정 생성(접근 유형 **API Gateway 접근만**) → 정책 탭에 관리형 `NCP_OBJECT_STORAGE_MANAGER` 하나(`…_VIEWER` 는 읽기만이라 백업이 안 된다) + → Access Key 탭에서 추가(**Secret Key 는 만들 때만 보인다**). 그래도 버킷 하나로는 좁히지 못한다(NCP 문서에 그 방법이 없다 — 2026-11-03) — 같은 계정에 다른 버킷을 두지 않는다(시험 버킷은 쓸모가 끝나면 지운다). + 키를 바꿀 때는 새 키를 ops.env 에 넣고 백업이 한 번 된 것을 본 뒤 옛 키를 사용 중지 → 삭제(사용 중지한 키만 지울 수 있다). 만들 때의 다른 칸은 기본값 그대로 — **잠금 해제**(보존 기간을 걸면 restic 이 지난 백업을 지우지 못해 prune 이 실패한다) · **암호화(KMS) 해제**(restic 이 이미 암호화한다. 한 번 켜면 끌 수 없다) · 타 계정 권한 없음. "공개"는 버킷의 폴더 · 파일 목록을 누구에게나 연다 — restic 은 키로 서명해 들어가므로 필요 없다(NCP 가이드 "버킷 관리", 2026-11-03 확인). `RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/<버킷>/arasystem`, `RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path'` — diff --git a/ops/ops.env.example b/ops/ops.env.example index 7713173..cf75381 100644 --- a/ops/ops.env.example +++ b/ops/ops.env.example @@ -31,8 +31,8 @@ RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/change-me-bucket/arasys # NCP 의 S3 옵션 — 지역 kr-standard(서명 V4 에 쓴다) · 경로 방식(https://<엔드포인트>/<버킷> — NCP 는 경로 · 가상 호스트 방식 둘 다 받는다) # (근거: NCP API 가이드 Object Storage — https://api.ncloud-docs.com/docs/storage-objectstorage · …-listobjects) RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path' -# NCP 서브 계정의 API 인증키 — Object Storage 만 쓰는 서브 계정으로 (콘솔 › Sub Account — 접근 유형 API Gateway 만 · 정책 NCP_OBJECT_STORAGE_MANAGER 하나 · Access Key 탭). -# 버킷에는 따로 키가 없고, NCP 는 키를 버킷 하나로 좁히지 못한다 — 같은 계정에 다른 버킷을 두지 않는다. Secret Key 는 만들 때만 보인다(바로 비밀번호 관리자에) +# NCP API 인증키 — 버킷에는 따로 키가 없다. 서버 B 는 메인 계정의 백업 전용 키(My Account › 계정 및 보안 관리 › 보안 관리 › 접근 관리 › API 인증키 관리 — 2026-11-03 결정). +# ⚠️ 메인 계정 키는 그 계정의 모든 API 를 연다 — 범위를 줄이려면 Object Storage 만 쓰는 서브 계정의 키로(mail/README.md "백업 › 설치"). 어느 쪽이든 버킷 하나로는 좁히지 못한다 AWS_ACCESS_KEY_ID=change-me AWS_SECRET_ACCESS_KEY=change-me # 보존 — 매일 7 · 매주 4 · 매달 6 (그보다 오래된 백업은 지운다 — 지운 메일도 이 기간까지만 백업에 남는다) -- 2.52.0 From 5829814dce0e7d323446eb24943972f86a78ccfe Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 02:50:45 +0900 Subject: [PATCH 09/14] =?UTF-8?q?docs:=20=EB=B0=B1=EC=97=85=EC=9D=84=20NCP?= =?UTF-8?q?=20=EB=A1=9C=20=EC=B2=98=EC=9D=8C=20=EB=8F=8C=EB=A0=A4=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=E2=80=94=20init=20=C2=B7=20=EC=B2=AB=20?= =?UTF-8?q?=EB=B0=B1=EC=97=85=20=C2=B7=20drill=20=ED=86=B5=EA=B3=BC,=20Acc?= =?UTF-8?q?ess=20Denied=20=EC=A7=84=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-03 서버 B 에서 restic 저장소를 NCP Object Storage 에 만들고 첫 백업(14초 · 메일 엔진 멈춤 6초) · 복원 연습(계정 · 메일 · DB 행 수 · env 가 운영과 같음)을 마쳤다 — "NCP 로는 아직"을 거둔다. init 의 "Stat: Access Denied" 는 HEAD 라 까닭이 안 보여, 같은 설정으로 목록을 조회해 S3 오류 코드를 보는 명령을 더했다(겪은 것은 SignatureDoesNotMatch — 콘솔 복사 버튼으로 다시 넣어 풀림). journalctl 은 sudo 로, ops.env 의 ARASYSTEM_DIR 예시 값을 그대로 두지 않는다는 주석도 더했다. Co-Authored-By: Claude Opus 5.5 --- mail/README.md | 11 +++++++++-- ops/ops.env.example | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/mail/README.md b/mail/README.md index 27c5c69..a237ab0 100644 --- a/mail/README.md +++ b/mail/README.md @@ -1761,7 +1761,8 @@ read -rsp 'restic 비밀번호: ' P; echo; printf '%s\n' "$P" | sudo install -m # 3) 저장소를 만들고 첫 백업을 손으로 (메일 엔진이 몇 초 멈춘다 — 사용이 적은 시간에) sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh init -sudo systemctl start arasystem-backup.service; journalctl -u arasystem-backup -n 30 --no-pager +sudo systemctl start arasystem-backup.service; sudo journalctl -u arasystem-backup -n 30 --no-pager # 기록은 sudo 로 — 일반 사용자는 "-- No entries --" +sudo bash /opt/arasystem-ops/backup/arasystem-restore.sh snapshots # final 스냅숏 하나 # 4) 타이머 켜기 — 백업 03:30 · 15분 점검, 그리고 부팅 때 백업이 멈춘 엔진 되살리기(표시 파일이 있을 때만 — 평소에는 아무 일도 하지 않는다) sudo systemctl enable --now arasystem-backup.timer arasystem-check.timer @@ -1781,7 +1782,13 @@ systemctl list-timers 'arasystem-*' "공개"는 버킷의 폴더 · 파일 목록을 누구에게나 연다 — restic 은 키로 서명해 들어가므로 필요 없다(NCP 가이드 "버킷 관리", 2026-11-03 확인). `RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/<버킷>/arasystem`, `RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path'` — 지역 `kr-standard`(서명 V4) · 경로 방식(NCP 는 경로 · 가상 호스트 방식 둘 다 받는다 — [NCP API 가이드 Object Storage](https://api.ncloud-docs.com/docs/storage-objectstorage)). - ⚠️ **NCP 로는 아직 돌려 보지 않았다**(MinIO 로만) — 3)의 `init` · 첫 백업 · 아래 `drill` 로 처음 확인한다. + **NCP 로 확인했다**(2026-11-03 서버 B — `init` · 첫 백업 14초(메일 엔진 멈춤 6초 · 새로 올린 양 363KB · DB 덤프 30KB) · 아래 `drill` 이 계정 · 메일 · DB 표마다 행 수 · env 를 운영과 같게 되살렸다). +- **`init` 이 `Stat: Access Denied` 로 멈추면** — restic 의 첫 요청은 본문 없는 HEAD 라 S3 가 거절한 까닭이 보이지 않는다. 같은 설정으로 목록 조회를 해 오류 코드를 본다(키는 출력하지 않는다): + ```bash + sudo bash -c '. /opt/arasystem-ops/lib/common.sh && load_config && for u in "/" "/<버킷>?list-type=2&max-keys=1"; do echo "== $u"; printf "user = \"%s:%s\"\n" "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" | curl -sS --max-time 20 -K - --aws-sigv4 "aws:amz:kr-standard:s3" "https://kr.object.ncloudstorage.com$u" -w "\nHTTP %{http_code}\n" | grep -oE "<(Code|Message|Name|KeyCount)>[^<]*|HTTP [0-9]+"; done' + ``` + `SignatureDoesNotMatch` — Secret Key 가 그 Access Key ID 와 짝이 아니다(2026-11-03 서버 B 에서 겪음 — 콘솔의 복사 버튼으로 같은 줄의 두 값을 다시 넣어 풀렸다. 화면에서 끌어 복사하면 줄여 보인 값 · 다른 줄의 값이 들어간다). + `InvalidAccessKeyId` — Access Key ID 가 틀렸다. `AccessDenied` — 키는 맞고 권한 · 버킷의 접근 제어(VPC) 문제다. 두 번 모두 `HTTP 200`(`` 에 버킷 · `0`)이면 `init` 을 다시 한다. - 설정 파일 `/etc/arasystem/ops.env` 는 셸로 실행하지 않고 줄마다 읽는다. `ARASYSTEM_DIR`(저장소 폴더)의 `.env.production` 에서 `PROJECT_NAME` 등을 읽는다 — 값을 따로 복사해 두지 않는다. `PROJECT_NAME` 은 ops.env 에도 적어 두기를 권한다(새 서버 복원의 첫 단계에는 `.env.production` 이 없다 — 아래 "서버를 통째로 잃었을 때"). 둘 다 있고 다르면 스크립트가 멈춘다. - 백업은 root 의 systemd 가 돈다(`ProtectSystem=full` · `ProtectHome=read-only` · `PrivateTmp` · 최대 6시간 · 멈출 때 엔진을 되살릴 시간 5분). 로그는 `journalctl -u arasystem-backup`. diff --git a/ops/ops.env.example b/ops/ops.env.example index cf75381..b5b3e7e 100644 --- a/ops/ops.env.example +++ b/ops/ops.env.example @@ -15,6 +15,7 @@ # 저장소 폴더 (git clone 한 곳) — 스크립트가 여기의 .env.production · backend/.env.production 을 읽는다 # (PROJECT_NAME · 메일 도메인 · 관리자 비밀번호(대기열 확인) · Spamhaus 키 — 따로 복사해 두지 않는다) +# 아래 예시 값(/home/ubuntu/…)을 그대로 두지 않는다 — 클론한 사용자의 홈으로(서버 B 는 /home/ara/arasystem). 틀리면 .env.production 을 찾지 못해 멈춘다 ARASYSTEM_DIR=/home/ubuntu/arasystem # 프로젝트 이름 — 볼륨 · 컨테이너 이름(_mail-data · -mail)과 백업 스냅숏의 host 이름이 이 값이다. # 비우면 위 저장소의 .env.production 에서 읽는다. ⚠️ 새 서버를 되살릴 때는 적는다(옛 서버의 값 — 예: arasystem) — 그 단계에는 .env.production 이 -- 2.52.0 From 3b59497d5955a8ee6d70662973adfa947bc95116 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 18:54:35 +0900 Subject: [PATCH 10/14] =?UTF-8?q?docs:=20=EC=9A=B4=EC=98=81=20imapsync=20?= =?UTF-8?q?=EC=9D=98=20=ED=81=B0=20=EB=A9=94=EC=9D=BC=20=E2=80=94=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=ED=81=AC=EA=B8=B0=20=ED=95=9C=EB=8F=84=20?= =?UTF-8?q?110=20MiB=20=C2=B7=201g=20=EC=97=90=EC=84=9C=20OOM=20=C2=B7=20?= =?UTF-8?q?=EC=98=AE=EA=B8=B0=EC=A7=80=20=EB=AA=BB=ED=95=9C=20=EC=B4=88?= =?UTF-8?q?=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-03 운영 1 · 2차에서 겪은 것을 imapsync 절에 남긴다. 한도보다 큰 메일은 APPEND 에서 빠지고(114), 100MB 남짓 한 통은 엔진 1g 를 넘겨 OOM 으로 다시 떴다 (그동안 다른 계정은 Connection refused). docker update 2g 는 컨테이너를 다시 만들면 사라지고, 한도를 올린 채 두면 메모리 예산의 합이 바뀐다 — 이전 뒤 어떻게 둘지는 아직 정하지 않았다. Co-Authored-By: Claude Opus 5.5 --- mail/README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/mail/README.md b/mail/README.md index a237ab0..86ab36e 100644 --- a/mail/README.md +++ b/mail/README.md @@ -787,7 +787,7 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne | --- | --- | --- | --- | | clamav (clamd) | 2g | 0.95GB — 서명 363만 개를 읽은 뒤 | 서명을 다시 읽을 때 1.0GB(두 벌을 들지 않는다), 2GB 검사 중 1.0~1.2GB + 받은 파일의 페이지 캐시(한도 안에서 줄여 쓴다) | | clamav-updater (freshclam) | 1g | 수십 MB | 처음 기동(서명 전체를 받아 읽어 봄) 0.7GB, 평소 갱신은 daily 만 | -| mail | 1g | 0.1~0.3GB | | +| mail | 1g | 0.1~0.3GB | 100MB 남짓 메일 한 통을 넣을 때 1g 를 넘었다(2026-11-03 — 서버 B 는 이전하는 동안 2g, [큰 메일](#큰-메일-운영-2026-11-03)) | | backend | 1g | 0.3~0.5GB | 조각 · 받기는 스트림이라 파일 크기와 상관없다 | | db · frontend · mail-resolver | 512m · 512m · 128m | 작다 | | | **합** | **6.15GB** | 2.5~3GB | 나머지 1.8GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 | @@ -1502,7 +1502,7 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul 이전 중인 계정은 운영 도구 메뉴 `11. 메일 이전` 이 다룬다 — `1. 이전 현황` · `2. 이전용 비밀번호 다시 만들기` · `3. 이전 마치기`(2 · 3 은 `주소 (빈 값이면 이전 중인 계정 모두): `). "이전 중"은 엔진 비밀번호 만료가 미래(30일 안)이면서 운영 도구의 실행 기록에서 마지막으로 건 비밀번호가 이전용인 계정이다(계약 6절 "메일 이전"). 엔진의 **비밀번호 기본 만료**(`x:Authentication` 의 `passwordDefaultExpiry` — 관리 화면의 인증 설정)는 켜지 않는다 — 켜면 직원 비밀번호가 주기적으로 만료되고, 끈 뒤에도 그동안 바꾼 비밀번호의 만료가 남는다. 운영 도구는 켜져 있으면 첫 화면에 알린다. -- 근거는 아래 [개발에서 확인한 것](#개발에서-확인한-것-imapsync--2026-11-02) — mailcow 를 흉내 낸 Dovecot → 개발 엔진. **운영 mailcow · 서버 B 에서는 아직 돌려 보지 않았다** — 준비의 "원본 로그인만"과 단계 5 의 첫 사람으로 확인한다. +- 근거는 아래 [개발에서 확인한 것](#개발에서-확인한-것-imapsync--2026-11-02) — mailcow 를 흉내 낸 Dovecot → 개발 엔진. 운영(mailcow → 서버 B)은 2026-11-03 에 원본 로그인만 · 1차 · 2차를 15계정으로 돌렸다 — 운영에서만 나온 것은 아래 [큰 메일](#큰-메일-운영-2026-11-03)(엔진의 크기 한도 · 메모리). #### 차례 (단계 번호는 [전환 절차](#arasystemitkr-전환-절차-mailcow--stalwart)의 것) @@ -1668,6 +1668,19 @@ sudo rm -f /root/imapsync/mailcow-master.pass /root/imapsync/migration.pass 한 사람에서 실패하면 멈추고 고친다. mailcow 쪽은 Fail2ban 이 막지 않지만(서버 B 도 사설 `10.9.0.1` 로 보인다) Dovecot 이 그 주소 — 바깥 직원 모두 — 의 로그인을 늦춘다(위 "원본"의 접속 주소). - 한쪽 로그인이 실패해도 imapsync 는 다른 쪽 로그인을 해 본 뒤 끝난다(`--justlogin` 에서 봤다) — 출력의 `Err 1/2` 처럼 실패가 둘로 보이면 먼저 나온 것부터 본다. +#### 큰 메일 (운영 2026-11-03) + +엔진의 크기 한도보다 큰 메일은 APPEND 에서 거절되고, 한도 안이어도 100MB 남짓이면 엔진이 메모리 한도 1g 를 넘는다 — 운영 1 · 2차에서 둘 다 겪었다. + +| imapsync 출력 (종료 코드) | 까닭 | 한 일 | +| --- | --- | --- | +| 그 메일에서 `literal exceeds …`, 끝에 `Detected N errors` (114 `EXIT_ERR_APPEND`) | 엔진 한도(IMAP 요청 · 메일 · 첨부 크기)보다 크다 — 그 메일만 빠지고 나머지는 옮긴다 | 관리 화면에서 세 값을 110 MiB(`115343360`)로 — Email 의 E-mail Size · Attachment Size, IMAP 의 `maxRequestSize`. setup 은 이 값을 맞추지 않는다(올린 값이 그대로 남는다) | +| 100MB 남짓 한 통에서 `socket closed`, 그 뒤 몇 사람이 `Connection refused` (`EXIT_CONNECTION_FAILURE_HOST2`) | 엔진이 1g 를 넘어 커널에 죽고(OOM — `dmesg` 에 stalwart) 다시 뜨는 동안이다 | `sudo docker update --memory 2g --memory-swap 2g -mail` 뒤 그 사람들만 다시 — 모두 통과 | + +- `docker update` 는 재시작 · 멈춤에도 남지만 **컨테이너를 다시 만들면 compose 의 1g 로 돌아간다** — 다시 만들었으면 다시 건다. 확인 `sudo docker inspect -f '{{.HostConfig.Memory}} {{.RestartCount}}' -mail`(2147483648 이면 2g). +- 110 MiB 보다 큰 메일은 옮기지 못한다 — 운영에서는 보내지 못한 초안 두 통(244 · 135 MiB)이었고, 사용자가 따로 보관하기로 했다(그 사람만 끝까지 `Detected 2 errors` · 114). +- 이전이 끝난 뒤 한도 · 메모리를 어떻게 둘지는 아직 정하지 않았다 — 한도를 올린 채 두면 엔진에 2g 가 들고, [메모리 예산](#메모리-예산)의 합이 7.15GB(백업이 도는 동안 8.15GB)가 된다. + #### 개발에서 확인한 것 (imapsync — 2026-11-02) mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a1d2f6dc164880cd7bfa74009106dfa46b36a6a2ee636d921` — mailcow 와 같은 2.3 줄기, 비-root 로 띄움, 시험 CA 의 인증서)에 @@ -1695,7 +1708,8 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a - **개발과 운영이 다른 점** — 대상 인증서가 자체 서명이라 `--sslargs2 SSL_verify_mode=0` 으로 돌렸고, imapsync 를 잠시 앱 네트워크에 붙여 `mail:993` 으로 갔다(엔진은 신뢰 대역 주소로 본다 — 운영에서는 하지 않는다). 원본은 시험 CA 로 `SSL_verify_mode=1` 이었다. -- **확인하지 않은 것** — 운영 mailcow(2026-05-12 판)의 마스터 계정과 서버 B 도 `10.9.0.1` 로 보이는지(준비의 "원본 로그인만"으로 본다) · Allowed protocols, 서버 B 의 길(`--network host` + `--add-host` 와 엔진이 보는 주소), 운영의 양 · 시간, 직원이 만든 실제 폴더 이름. +- **개발에서 확인하지 않은 것** — 운영 mailcow(2026-05-12 판)의 마스터 계정 · Allowed protocols · 서버 B 의 길(`--network host` + `--add-host`)은 2026-11-03 운영에서 됐다(원본 로그인만 → 1차 · 2차 15계정). 아직 보지 않은 것은 엔진이 보는 서버 B 의 주소다. + 운영의 양 · 시간 · 직원이 만든 폴더 이름은 사람마다의 imapsync 로그(서버 B `/root/imapsync/log/` — 주소 · 폴더 이름이 들어 있어 한 주쯤 뒤 지운다)에 있다. --- -- 2.52.0 From 6dc27778348e8688e81fe1aa849d5718c009336f Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 20:10:18 +0900 Subject: [PATCH 11/14] =?UTF-8?q?fix(docker):=20mail=20setup=20=E2=80=94?= =?UTF-8?q?=20SMTP=20=EB=B0=9B=EB=8A=94=20=EB=A9=94=EC=9D=BC=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=ED=95=9C=EB=8F=84=2050MiB=20=C2=B7=20=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=92=A4=20EHLO=20=EB=A1=9C=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=C2=B7=20IMAP/=EB=A9=94=EC=9D=BC=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=ED=95=9C=EB=8F=84=EB=8A=94=20[=EC=A3=BC=EC=9D=98]?= =?UTF-8?q?=20=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개발 엔진 실측(2026-11-03, 0.16.27): 큰 메일 한 통을 받으면 엔진 메모리가 그 크기의 5.5배쯤 는다(IMAP APPEND · SMTP 받기 같다). SMTP 받는 한도(x:MtaStageData 의 maxMessageSize, 기본 100MiB)는 x:Email 한도와 따로라, 기본값 · 1g 에서 바깥 104MB 한 통이 984/1024MiB 였다(운영은 106.4MB IMAP APPEND 로 OOM). 사용자 결정 — 메일 컨테이너 메모리는 1g 그대로 두고, setup 이 받는 한도를 50MiB 로 맞춘 뒤 리로드 뒤 EHLO 의 SIZE 로 실제로 걸렸는지 본다(저장 때 줄만으로는 리로드 실패를 못 거른다 — 리뷰). IMAP · 메일 크기 한도는 imapsync 이전 동안 운영자가 잠시 올리므로 setup 이 바꾸지 않고 기본보다 크면 [주의] 만 낸다. README 에 잰 값 · 정책 · 이전 뒤 되돌리는 차례(세 값 → setup [확인] → 1g)와 문제 해결 줄, docker 스킬에 한 항목. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/docker/SKILL.md | 3 + mail/README.md | 55 ++++++++- mail/scripts/setup.mjs | 203 ++++++++++++++++++++++++++++++++- 3 files changed, 255 insertions(+), 6 deletions(-) diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index e869100..3684632 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -391,6 +391,9 @@ node --env-file=.env.development mail/scripts/setup.mjs - setup 은 엔진의 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 계정마다 1시간 동안 올린 양, 기본 50MB)를 첨부 합계 한도(`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 백엔드와 같은 계산)의 20배(기본 524,288,000바이트) 이상으로 올린다 — 기본값이면 20MB 첨부 세 개로 그 직원의 첨부 올리기 · 필터 · 부재중 저장이 1시간까지 429 `REQ_001` 로 막혔다(2026-10-29). 더 크게 둔 값은 내리지 않고, 개수(1,000) · 기간 · 한 번 크기는 그대로다. 첨부 한도 env 를 바꾸면 setup 도 다시 돌린다 (mail/README.md "엔진 한도"). +- setup 은 SMTP 로 받는 메일 한 통의 크기(`x:MtaStageData` 의 `maxMessageSize`, 기본 100MiB)를 50MiB 로 낮춘다 — 큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 들어 + 메일 컨테이너 1g 로는 바깥 100MB 한 통이 한도에 닿았다(2026-11-03 개발 984/1024MiB · 운영은 106MB IMAP APPEND 로 OOM). IMAP 요청 · 메일 크기 한도(`x:Imap` · `x:Email`)는 + 이전(imapsync) 때 잠시 올릴 수 있어 바꾸지 않고, 기본보다 크면 `[주의]` 만 낸다 (mail/README.md "엔진 한도" · "큰 메일"). - setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소와 리졸버 네트워크(서브넷 · 구성원 · 리졸버 주소 · mail 의 `dns`)를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다. **운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다. - **메일 전용 DNS 리졸버** `mail-resolver`(unbound — 설정 `mail/resolver/unbound.conf`): 엔진의 DNS 조회(스팸 차단 목록 · SPF · DKIM · DMARC · MX · PTR · TLSA)를 diff --git a/mail/README.md b/mail/README.md index 86ab36e..f6b241d 100644 --- a/mail/README.md +++ b/mail/README.md @@ -138,6 +138,9 @@ node --env-file=.env.development mail/scripts/setup.mjs 로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고 `[주의]` ([엔진 한도](#엔진-한도)). 계정마다 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 1시간 동안 올린 양, 기본 50,000,000바이트)를 첨부 합계 한도의 20배(기본 524,288,000바이트 = 25MB × 20) 이상으로 올린다 — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도"). + SMTP 로 받는 메일 한 통의 크기(`x:MtaStageData` 의 `maxMessageSize`, 기본 100MiB)를 50MiB(52,428,800바이트)로 낮춘다 — 큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 들어 1g 로는 100MB 한 통이 한도에 닿는다. + 더 작게 둔 값은 올리지 않고, 갈래가 있거나 else 가 숫자 하나가 아닌 식 · 0 이면 바꾸지 않고 `[주의]`. 리로드 뒤 메일 컨테이너 안에서 EHLO 의 `SIZE` 로 걸렸는지 본다(`[확인] 받는 메일 크기 한도 — EHLO SIZE …`). + IMAP 요청 · 메일 크기 한도(`x:Imap` · `x:Email`)는 이전 때 잠시 올릴 수 있어 바꾸지 않고, 엔진 기본보다 크면 `[주의]` 만 낸다 ([엔진 한도](#엔진-한도) "받는 메일 크기"). **엔진 DNS · Spamhaus** — 엔진의 DNS(`x:DnsResolver`)를 메일 전용 리졸버로(운영은 리졸버에 바로 `Custom`, 개발은 도커 DNS 를 거쳐 `System` — Mailpit 이름 때문), 스팸 필터의 Spamhaus 두 항목은 키(`MAIL_SPAMHAUS_DQS_KEY`)가 있고 시험을 통과하면 DQS 로 켜고 아니면 끈다(공개 미러는 쓰지 않는다 — [DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)). **엔진 웹훅**(12단계) — 인증 이벤트 셋을 백엔드의 받는 곳(`http://backend:/api/internal/mail-engine-events`)으로 `MAIL_WEBHOOK_SECRET` 로 서명해 보내는 @@ -192,7 +195,7 @@ node --env-file=.env.development mail/scripts/setup.mjs | `MAIL_DOMAIN` | `arasystem.test` / 운영 `arasystem.it.kr` | | `MAIL_GATEWAY_USER` · `MAIL_GATEWAY_PASSWORD` | 게이트웨이 주소 · 비밀번호 | | `MAIL_ATTACHMENT_MAX_SIZE_MB` | 첨부 1개 최대 크기 (기본 20). 프론트 nginx 본문 한도는 이 값 + 1MB 로 자동, 외부 프록시는 직접 — [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" | -| `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` | 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상). setup 이 엔진의 임시 업로드 크기 합계를 이 값의 20배 이상으로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 실행한다 ([엔진 한도](#엔진-한도)) | +| `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` | 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상). setup 이 엔진의 임시 업로드 크기 합계를 이 값의 20배 이상으로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 실행한다 ([엔진 한도](#엔진-한도)). 30 넘게 올리려면 받는 메일 크기 한도(50MiB — 같은 절 "받는 메일 크기")도 본다 | | `MAIL_DIRECTORY_LOCAL_PART` | 사내 목록 계정 아이디 (기본 `mail-directory`) — setup 과 같은 값을 읽는다 ([사내 목록 계정](#사내-목록-계정)) | | `MAIL_WEBHOOK_SECRET` | 엔진 웹훅의 서명 키(12단계 — 영문 · 숫자 32자 이상, 운영 필수) — setup 이 같은 값으로 엔진 웹훅을 만든다. 바꾸면 setup 과 backend 를 함께 ([엔진 웹훅](#엔진-웹훅--메일-프로그램-로그인-기록)) | | `APP_NETWORK_SUBNET` | 앱 네트워크 대역 (기본 `172.16.71.0/24`) — 웹훅 받는 쪽이 접속 주소(소켓)가 이 대역인지 본다 | @@ -629,6 +632,40 @@ sudo docker logs "${PROJECT_NAME}-mail" 2>&1 | grep 'cannot validate DNSSEC' - 확인 (2026-10-29, 0.16.27): setup 을 이어서 두 번 돌리면 첫 번째는 `[변경] … 524,288,000바이트 … 전 50,000,000`, 두 번째는 `[유지]`. setup 뒤에는 이미 60MiB 를 올린 계정이 20MiB 를 더 올릴 수 있었다(바꾸기 전에는 429). `maxUploadCount` · `uploadTtl` · `maxUploadSize` 는 그대로였다. +**받는 메일 크기** (`x:MtaStageData` · `x:Imap` · `x:Email` 싱글톤) — 큰 메일 한 통을 받으면 엔진 메모리가 그 크기의 5.5배쯤 는다. +그래서 바깥에서 받는 한 통의 크기를 메일 컨테이너의 메모리 한도(1g — [메모리 예산](#메모리-예산))에 맞춰 둔다. + +| 설정 | 걸리는 곳 | 기본값 | setup | +| --- | --- | --- | --- | +| `x:MtaStageData` 의 `maxMessageSize` | SMTP 로 받는 메일 한 통(25 · 465 — EHLO 의 `SIZE`. 465 는 시험하지 않았다) | `{"match":{},"else":"104857600"}` (100MiB) | 50MiB(52,428,800)보다 크면 50MiB 로 낮춘다. 더 작게 둔 값은 올리지 않고, 갈래가 있거나 else 가 숫자 하나가 아닌 식 · 0 이면 바꾸지 않고 `[주의]`. 리로드 뒤 EHLO 의 `SIZE` 로 확인한다(`[확인]`) | +| `x:Imap` 의 `maxRequestSize` | IMAP 요청 하나(APPEND 의 리터럴) | 52,428,800 (50MiB) | 바꾸지 않는다 — 기본보다 크면 `[주의]` | +| `x:Email` 의 `maxMessageSize` · `maxAttachmentSize` | JMAP · IMAP 으로 넣는 메일 · 첨부(엔진 설명 기준 — 따로 시험하지 않았다). SMTP 배달에는 걸리지 않는다 | 75,000,000 · 50,000,000 | 바꾸지 않는다 — 기본보다 크면 `[주의]` | + +- **넘으면** — SMTP 는 `552 5.3.4 Message too big for system.` 이다(보낸 쪽이 `SIZE` 를 알리면 MAIL FROM 에서, 알리지 않아도 넘는 순간에 — 받아 두지 않는다). 바깥 보낸 사람은 반송을 받는다. + IMAP 은 리터럴을 받기 전에 `BAD [PARSE] Literal exceeds the maximum request size of 52428800 bytes.` 다. 웹메일은 큰 파일을 [대용량 첨부](#대용량-첨부-11단계--인프라)(링크)로 보내므로 걸리지 않는다. +- **IMAP · 메일 크기 한도를 바꾸지 않는 까닭** — imapsync 로 옮기는 동안 큰 메일 때문에 잠시 올린다([큰 메일](#큰-메일-운영-2026-11-03)). 그 도중에 setup 을 돌려도 이전이 깨지지 않게 알리기만 한다 — 이전을 마치면 관리 화면에서 기본으로 되돌린다. +- 잰 값 (2026-11-03 개발 엔진 0.16.27 — 메일 컨테이너 cgroup 의 anon 을 0.1초마다, 무작위 첨부 하나인 메일, 스왑 없음): + + | 넣은 길 | 25MB | 50MB | 75MB | 100MB 남짓 | 115MB | + | --- | --- | --- | --- | --- | --- | + | IMAP APPEND | +115MiB | +256MiB | +396MiB | +540MiB (100MB) | +614MiB | + | SMTP 받기 (인증 없음 · 스팸 필터) | +100MiB | +224~270MiB | +409MiB | +557MiB (104MB) | — | + + - 큰 메일을 넣은 뒤 엔진의 평소 사용량(기준선)이 127MiB 에서 408MiB 까지 쌓였고 몇 분 안에는 내려오지 않았다 — 가장 클 때 = 그때의 기준선 + 위 값. + - 기본값 그대로 · 1g 에서 바깥 104MB 한 통이 **984 / 1024MiB** 였다(겨우 버팀). 운영은 1g 에서 106.4MB IMAP APPEND 로 OOM 이 났다([큰 메일](#큰-메일-운영-2026-11-03)). + - 받는 한도 50MiB 에서는 50MB 한 통이 가장 클 때 581MiB, 60MB 는 거절되고 메모리가 그대로였다(`SIZE` 없이 보내도 +31MiB). + - `x:Email` 의 `maxMessageSize`(75MB)는 SMTP 배달에 걸리지 않았다 — 104MB 가 그대로 배달됐다. +- 확인 (2026-11-03, 0.16.27 개발): setup 을 이어서 두 번 돌리면 첫 번째는 `[변경] 받는 메일 크기 한도(SMTP — x:MtaStageData 의 maxMessageSize) 52,428,800바이트 (50MiB) · 전 104,857,600`, + 두 번째는 `[유지]` 이고, 둘 다 `[확인] IMAP 요청 · 메일 크기 한도 — 엔진 기본 이하 (…)` 가 이어진다. + 리로드 뒤의 확인은 두 번 다 `[확인] 받는 메일 크기 한도 — EHLO SIZE 52,428,800 (메일 컨테이너 <앱 네트워크 주소>:25 · 리로드 뒤 엔진이 알리는 값)` 이다. + setup 이 맞췄는데 엔진이 104,857,600 을 알리면 다섯 번 다시 본 뒤 종료 1, SIZE 줄이 없으면 `[주의]` 다(가짜 클라이언트로 본 갈래 — 실제 엔진에서 리로드 실패를 만들지는 않았다). + `x:Imap` 의 `maxRequestSize` 를 115,343,360 으로 올려 두고 돌리면 `[주의] IMAP 요청 · 메일 크기 한도가 엔진 기본보다 큽니다 — …` 이고 값은 그대로 남았다. +- **확인하지 않은 것** + - 메일 프로그램이 465 로 보내는 메일도 같은 식이라 50MiB 를 넘으면 552 일 것이다. + - 웹메일 발송(JMAP 제출)은 엔진 안의 SMTP 세션이라 같은 한도에 걸릴 수 있다. 메일 한 통 ≈ 첨부 합계 × 1.37(base64 · 줄바꿈) + 본문 4MB(인코딩까지 5.5MiB 남짓) + 서명 그림이라 + 첨부 합계 기본 25MB 면 41MiB 남짓으로 아래지만 32MB 남짓부터 넘는다 — `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` 를 30 넘게 올리려면 이 한도와 [메모리 예산](#메모리-예산)을 함께 본다. + - 큰 메일 두 통 이상을 함께 받는 경우는 재지 않았다 — 쌓인 기준선 408MiB 에 50MB 두 통(270MiB 씩)이면 950MiB 남짓이다. 메일 프로그램은 465 제출과 보낸편지함 APPEND 를 거의 함께 한다. + 운영 전에 정할 것: - **메일을 많이 받는 주소** — 같은 바깥 도메인에서 한 사람이 시간당 25통 넘게 받으면 그 뒤는 바깥 서버가 미뤘다가 다시 보낸다(늦게 받는다). @@ -787,7 +824,7 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne | --- | --- | --- | --- | | clamav (clamd) | 2g | 0.95GB — 서명 363만 개를 읽은 뒤 | 서명을 다시 읽을 때 1.0GB(두 벌을 들지 않는다), 2GB 검사 중 1.0~1.2GB + 받은 파일의 페이지 캐시(한도 안에서 줄여 쓴다) | | clamav-updater (freshclam) | 1g | 수십 MB | 처음 기동(서명 전체를 받아 읽어 봄) 0.7GB, 평소 갱신은 daily 만 | -| mail | 1g | 0.1~0.3GB | 100MB 남짓 메일 한 통을 넣을 때 1g 를 넘었다(2026-11-03 — 서버 B 는 이전하는 동안 2g, [큰 메일](#큰-메일-운영-2026-11-03)) | +| mail | 1g | 0.1~0.3GB (큰 메일 뒤 0.4GB 남짓까지 쌓인다 — 2026-11-03) | 메일 한 통을 받을 때 그 크기 × 5.5 쯤 더 — 받는 한도 50MiB(setup)면 +0.3GB 남짓. 운영에서 106.4MB 한 통이 1g 를 넘었다(2026-11-03 — 개발 104MB 는 984/1024MiB · 서버 B 는 이전하는 동안만 2g, [큰 메일](#큰-메일-운영-2026-11-03) · [받는 메일 크기](#엔진-한도)) | | backend | 1g | 0.3~0.5GB | 조각 · 받기는 스트림이라 파일 크기와 상관없다 | | db · frontend · mail-resolver | 512m · 512m · 128m | 작다 | | | **합** | **6.15GB** | 2.5~3GB | 나머지 1.8GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 | @@ -1674,12 +1711,18 @@ sudo rm -f /root/imapsync/mailcow-master.pass /root/imapsync/migration.pass | imapsync 출력 (종료 코드) | 까닭 | 한 일 | | --- | --- | --- | -| 그 메일에서 `literal exceeds …`, 끝에 `Detected N errors` (114 `EXIT_ERR_APPEND`) | 엔진 한도(IMAP 요청 · 메일 · 첨부 크기)보다 크다 — 그 메일만 빠지고 나머지는 옮긴다 | 관리 화면에서 세 값을 110 MiB(`115343360`)로 — Email 의 E-mail Size · Attachment Size, IMAP 의 `maxRequestSize`. setup 은 이 값을 맞추지 않는다(올린 값이 그대로 남는다) | +| 그 메일에서 `literal exceeds …`, 끝에 `Detected N errors` (114 `EXIT_ERR_APPEND`) | 엔진 한도(IMAP 요청 · 메일 · 첨부 크기)보다 크다 — 그 메일만 빠지고 나머지는 옮긴다 | 관리 화면에서 세 값을 110 MiB(`115343360`)로 — Email 의 E-mail Size · Attachment Size, IMAP 의 `maxRequestSize`. setup 은 이 셋을 바꾸지 않고 기본보다 크면 `[주의]` 만 낸다 — 이전을 마치면 되돌린다(아래) | | 100MB 남짓 한 통에서 `socket closed`, 그 뒤 몇 사람이 `Connection refused` (`EXIT_CONNECTION_FAILURE_HOST2`) | 엔진이 1g 를 넘어 커널에 죽고(OOM — `dmesg` 에 stalwart) 다시 뜨는 동안이다 | `sudo docker update --memory 2g --memory-swap 2g -mail` 뒤 그 사람들만 다시 — 모두 통과 | - `docker update` 는 재시작 · 멈춤에도 남지만 **컨테이너를 다시 만들면 compose 의 1g 로 돌아간다** — 다시 만들었으면 다시 건다. 확인 `sudo docker inspect -f '{{.HostConfig.Memory}} {{.RestartCount}}' -mail`(2147483648 이면 2g). - 110 MiB 보다 큰 메일은 옮기지 못한다 — 운영에서는 보내지 못한 초안 두 통(244 · 135 MiB)이었고, 사용자가 따로 보관하기로 했다(그 사람만 끝까지 `Detected 2 errors` · 114). -- 이전이 끝난 뒤 한도 · 메모리를 어떻게 둘지는 아직 정하지 않았다 — 한도를 올린 채 두면 엔진에 2g 가 들고, [메모리 예산](#메모리-예산)의 합이 7.15GB(백업이 도는 동안 8.15GB)가 된다. +- **이전을 마치면 되돌린다**(2026-11-03 사용자 결정) — 「이전 마치기」 뒤 차례대로: + 1. 관리 화면에서 세 값을 기본으로 — E-mail Size `75000000` · Attachment Size `50000000` · IMAP `maxRequestSize` `52428800`. + 2. setup 을 돌려 `[완료]`(종료 0)로 끝나고 `[확인] 받는 메일 크기 한도 — EHLO SIZE 52,428,800` 줄이 있는지, 크기 한도 `[주의]` 가 없는지 본다 — + `[변경]` · `[유지]` 줄은 저장한 값이고, 엔진에 걸린 값은 리로드 뒤의 이 `[확인]` 줄이다(리로드가 실패하면 엔진은 옛 한도로 받는다). + 3. 메모리를 compose 의 값으로 — `sudo docker update --memory 1g --memory-swap 2g -mail`. + + 받는 한도를 낮추기 전에 1g 로 돌리면 바깥 큰 메일 한 통에 엔진이 죽을 수 있다([엔진 한도](#엔진-한도) "받는 메일 크기"). 올린 채 두면 엔진에 2g 가 들고, [메모리 예산](#메모리-예산)의 합이 7.15GB, 백업이 도는 동안은 8.15GB 로 8GB 를 넘는다. #### 개발에서 확인한 것 (imapsync — 2026-11-02) @@ -2250,6 +2293,10 @@ sudo docker logs --tail 3 "${PROJECT_NAME}-frontend" | 웹메일 발송의 받는 사람이 `452 4.4.5 Rate limit exceeded` 로 거절된다 | setup 출력에 `받는 쪽 한도 … 인증 없는 세션에만` 이 있는지 본다. 받는 쪽 한도를 새로 만들었다면 그 `match` 도 — 엔진 로그 `smtp.rate-limit-exceeded` 의 `id` 가 어느 한도인지 알려 준다 ([엔진 한도](#엔진-한도)) | | 한 직원의 첨부 올리기 · 필터 · 부재중 저장이 429 `REQ_001` 로 막힌다 (백엔드 로그 `"reason":"mail-engine-rate-limit","call":"upload"`) | 엔진의 임시 업로드 한도(계정마다 1시간 — 크기 합계 · 개수)를 넘었다. 올린 것이 만료되면(최대 1시간) 풀린다. setup 출력에 `임시 업로드 한도 — 계정마다 크기 합계 …` 가 있는지 보고, 개수를 넘는 직원이 잦으면 `maxUploadCount` 를 올린다 ([엔진 한도](#엔진-한도) "임시 업로드 한도") | | `임시 업로드 한도(x:Jmap 의 uploadQuota = …)를 읽지 못해 바꾸지 않았습니다` · `[주의] 임시 업로드 한도(x:Jmap 의 uploadQuota)가 0 입니다` | 엔진이 숫자가 아닌 값(판을 올려 속성이 바뀌었는지 본다)이나 0 을 돌려줬다 — 관리 API `x:Jmap/get` 으로 `uploadQuota` 를 보고 첨부 합계 한도 × 20 이상으로 둔 뒤 `ReloadSettings` (0 이 제한 없음인지는 확인하지 않았다) | +| `[주의] IMAP 요청 · 메일 크기 한도가 엔진 기본보다 큽니다 — …` | imapsync 이전 때 올린 값이 남았다 — 이전을 마쳤으면 관리 화면에서 기본(`maxRequestSize` 52,428,800 · E-mail Size 75,000,000 · Attachment Size 50,000,000)으로 되돌린다([큰 메일](#큰-메일-운영-2026-11-03)). 이전 중이면 그대로 둔다 — setup 은 바꾸지 않는다 | +| `받는 메일 크기 한도(SMTP — x:MtaStageData 의 maxMessageSize)를 읽지 못해 맞추지 않았습니다` · `[주의] 받는 메일 크기 한도(…)가 운영자의 식이라 …` · `…가 0 입니다` | 엔진이 식이 아닌 꼴(판을 올려 속성이 바뀌었는지 본다) · 갈래가 있는 식 · 0 을 돌려줬다 — 관리 API `x:MtaStageData/get` 으로 `maxMessageSize` 를 보고 갈래와 else 를 모두 1 이상 52,428,800 이하로 둔 뒤 `ReloadSettings`(EHLO 의 `SIZE` 로 확인). setup 은 갈래가 없는 숫자 식만 고친다 | +| `받는 메일 크기 한도가 엔진에 걸리지 않았습니다 — 리로드 뒤 EHLO SIZE … 가 52,428,800바이트 (50MiB) 보다 크다` (종료 1) | 저장은 했는데 리로드가 걸리지 않았다 — 같은 실행의 리로드 `[실패]`(바깥 DNS 가 안 되면 엔진이 리로드를 통째로 거절한다 — [리졸버 장애 때](#리졸버-장애-때)) · `[주의]` 를 먼저 고치고 setup 을 다시 돌린다. 걸리기 전에는 엔진이 옛 한도로 받으니 메일 메모리를 1g 로 내리지 않는다([큰 메일](#큰-메일-운영-2026-11-03)) | +| `[주의] 받는 메일 크기 한도가 엔진에 걸렸는지 확인하지 못했습니다 — …` | 메일 컨테이너 안의 curl 로 25 에 EHLO 를 보내지 못했거나, 응답에 SIZE 가 없거나 숫자가 아니다 — 관리 API `x:MtaStageData/get` 으로 값을 보고 리로드한 뒤 다시 실행한다. `[확인]` 줄을 보기 전에는 메모리를 1g 로 내리지 않는다 | | `[주의] 사내 목록 계정이 없습니다 (…)` | 복구 관리자 없이 돌렸다 — 실패가 아니다. 운영: "운영 배포" 1)·`up -d mail` → 3) `--recovery` → 4)·5) 를 한 번. 개발: `MAIL_RECOVERY_ADMIN` 을 채우고 `up -d mail` 후 setup. 그동안 백엔드는 로그인한 직원 기록으로 사내 목록을 대신한다 ([사내 목록 계정](#사내-목록-계정)) | | `[주의] 사내 목록 계정이 setup 기준과 다릅니다 (역할의 권한이 다름 … · 계정의 역할이 다름)` | 누가 역할·계정을 고쳤다. 관리자로는 되돌릴 수 없다(엔진이 거절) — 위와 같이 복구 관리자로 setup. 이어서 나오는 `[확인]`·`[주의]` 줄이 지금 백엔드가 읽을 수 있는지 알려 준다 | | `…(MAIL_DIRECTORY_LOCAL_PART)는 setup 이 만들지 않은 계정의 주소라 …` | 그 주소를 실제 계정이 쓴다(그 계정은 건드리지 않았다). `MAIL_DIRECTORY_LOCAL_PART` 를 아무도 쓰지 않는 아이디로 바꾸고 `up -d backend` → setup 재실행 | diff --git a/mail/scripts/setup.mjs b/mail/scripts/setup.mjs index 22f4239..ae04702 100644 --- a/mail/scripts/setup.mjs +++ b/mail/scripts/setup.mjs @@ -51,6 +51,9 @@ * — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도") * 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영) * — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 + * SMTP 로 받는 메일 한 통의 크기(x:MtaStageData 의 maxMessageSize)를 50MiB 이하로 낮춘다 (설정 리로드 뒤 반영 · SMTP_MESSAGE_SIZE_LIMIT 머리말) + * — 더 작게 둔 값은 올리지 않고, 갈래가 있거나 else 가 숫자 하나가 아닌 식 · 0 이면 [주의]. 리로드 뒤 메일 컨테이너 안에서 EHLO 의 SIZE 로 + * 걸렸는지 본다. IMAP 요청 · 메일 크기 한도(x:Imap · x:Email)는 바꾸지 않고 엔진 기본보다 크면 [주의] 만 * 휴지통 · 스팸메일함 자동 비우기 30일(들어온 때부터)과 엔진 정리 시각 — 계정 정리 KST 03:00 · 데이터 04:00 · 원문 04:30 * (13단계 — 백업 03:30 의 앞뒤, 설정 리로드 뒤 반영 · TRASH_RETENTION_MS 머리말) * 엔진 DNS(x:DnsResolver)를 메일 전용 리졸버로 — 운영은 리졸버에 바로(Custom), 개발은 도커 DNS 를 거쳐(System — Mailpit 이름 때문). @@ -206,6 +209,30 @@ const DEFAULT_ATTACHMENT_MAX_SIZE_MB = 20; const DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB = 25; const MEGABYTE = 1024 * 1024; const POSITIVE_INT_PATTERN = /^[1-9]\d*$/; +/** + * SMTP 로 받는 메일 한 통의 상한 — x:MtaStageData 싱글톤의 maxMessageSize(25 · 465 — 465 는 시험하지 않았다 · EHLO 의 SIZE 로 알린다 · + * 엔진 기본 {"match":{},"else":"104857600"} = 100MiB)를 이 값 이하로 둔다 (mail/README.md "엔진 한도" 「받는 메일 크기」 · ensureMessageSizeLimits · + * 리로드 뒤 확인 checkMessageSizeLimit). 2026-11-03 개발 엔진(0.16.27)에서 잰 것: + * - 큰 메일 한 통을 받으면 엔진 메모리(cgroup anon)가 그 크기의 5.5배쯤 는다 — IMAP APPEND 와 SMTP 받기가 같다(100MB IMAP +540MiB · 104MB SMTP +557MiB · 50MB SMTP +224~270MiB). + * 받은 뒤 기준선이 127MiB 에서 408MiB 까지 쌓이고 몇 분 안에는 내려오지 않았다. 기본값 · 메모리 1g(스왑 없음)에서 바깥 104MB 한 통이 984/1024MiB 였고, + * 운영은 1g 에서 106.4MB IMAP APPEND 로 OOM 이 났다 — 메일 컨테이너 메모리 1g 는 그대로 두고 받는 한 통을 줄인다(2026-11-03 사용자 결정). + * - 넘는 메일은 552 5.3.4 "Message too big for system." — 보낸 쪽이 SIZE 를 알리면 MAIL FROM 에서, 알리지 않으면 넘는 순간에(받아 두지 않고) 거절한다. + * 값은 저장한 뒤 ReloadSettings 로 걸린다(EHLO 의 SIZE 가 52428800 으로 바뀐다). + * - x:Email 의 maxMessageSize · maxAttachmentSize 와 x:Imap 의 maxRequestSize 는 SMTP 배달에 걸리지 않는다(Email.maxMessageSize 75MB 에서 104MB SMTP 메일이 배달됐다) — + * SMTP 로 받는 크기는 이 값 하나로만 막는다 + */ +const SMTP_MESSAGE_SIZE_LIMIT = 50 * MEGABYTE; +/** + * IMAP 요청 · 메일 · 첨부 크기의 엔진 기본값 (0.16.27 — 2026-11-03 개발 엔진에서 관리 API 로 읽은 값). setup 은 이 셋을 바꾸지 않는다 — + * imapsync 이전 동안 운영자가 큰 메일 때문에 잠시 올리므로(mail/README.md "큰 메일") 이전 도중에 setup 을 돌려도 이전이 깨지지 않게. + * 기본보다 크면 [주의] 만 낸다 — 큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 든다(SMTP_MESSAGE_SIZE_LIMIT 머리말). + * IMAP 은 maxRequestSize 를 넘는 APPEND 를 리터럴을 받기 전에 BAD [PARSE] "Literal exceeds the maximum request size of …" 로 거절한다. + * 0.16.27 의 값이다 — 엔진 판을 올리면 다시 읽어 맞춘다 + */ +const ENGINE_DEFAULT_SIZE_LIMITS = { + Imap: { maxRequestSize: 52_428_800 }, + Email: { maxMessageSize: 75_000_000, maxAttachmentSize: 50_000_000 }, +}; /** * 계정 아이디(주소의 @ 앞) 형식 — 영문·숫자·. _ -, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시 (소문자로 맞춰 검사). * ⚠️ 백엔드 backend/src/modules/auth/employee-address.ts 의 LOCAL_PART_PATTERN(isLocalPart)과 같은 규칙이다 — @@ -3143,6 +3170,95 @@ async function ensureUploadQuota(client, cfg) { log(`[변경] ${label} ${formatCount(cfg.uploadQuotaFloor)}바이트 (${floor}) · 전 ${formatCount(quota)} · ${untouched}`); } +/** x: 싱글톤의 칸들 — 읽지 못하면(메서드 오류 · 요청 실패 · 싱글톤 없음) 멈추지 않고 { error: 까닭 } */ +async function readSingleton(client, type, properties) { + try { + const item = (await call(client, `x:${type}/get`, { ids: ['singleton'], properties })).list?.[0]; + return item ? { value: item } : { error: `x:${type}/get 에 singleton 이 없다` }; + } catch (error) { + if (!(error instanceof SetupError)) throw error; + return { error: error.message }; + } +} + +/** + * 받는 메일 크기 한도 (SMTP_MESSAGE_SIZE_LIMIT · ENGINE_DEFAULT_SIZE_LIMITS 머리말 — mail/README.md "엔진 한도" 「받는 메일 크기」). + * 1. SMTP 로 받는 한 통(x:MtaStageData 의 maxMessageSize)을 SMTP_MESSAGE_SIZE_LIMIT 이하로 — 저장만 한다(main 의 리로드로 반영). + * 더 작게 둔 값은 올리지 않는다. 0 은 뜻(제한 없음인지)을 확인하지 않았고, 갈래가 있거나 else 가 숫자 하나가 아닌 식은 운영자의 것이라 바꾸지 않고 [주의]. + * 2. IMAP 요청 · 메일 · 첨부 크기(x:Imap · x:Email)는 읽기만 한다 — 엔진 기본보다 크면 [주의], 읽지 못해도 멈추지 않는다. + * 돌려주는 값: 1 의 상태 — 리로드 뒤 확인(checkMessageSizeLimit)에 넘긴다. 'saved' 이번에 낮춰 저장 · 'kept' 단순한 꼴(갈래 없음 · 숫자 else)로 이미 한도 이하 · + * 'operator' 운영자의 식 · 'zero' 0 · 'unread' 읽지 못함(problems) + * ⚠️ 출력의 "받는 메일 크기 한도" 는 운영 차례 문서(mail/README.md "큰 메일")가 이 글자로 확인한다 — 바꾸지 않는다 + */ +async function ensureMessageSizeLimits(client) { + const label = '받는 메일 크기 한도(SMTP — x:MtaStageData 의 maxMessageSize)'; + const mib = `${SMTP_MESSAGE_SIZE_LIMIT / MEGABYTE}MiB`; + const limit = `${formatCount(SMTP_MESSAGE_SIZE_LIMIT)}바이트 (${mib})`; + const memory = '큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 든다'; + const smtp = await readSingleton(client, 'MtaStageData', ['maxMessageSize']); + const expression = smtp.value?.maxMessageSize; + let state; + if (smtp.error || !isExpression(expression)) { + state = 'unread'; + problems.push( + `${label}를 읽지 못해 맞추지 않았습니다 (${smtp.error ?? JSON.stringify(expression) ?? '칸 없음'}) — ${limit} 이하여야 합니다 (mail/README.md "엔진 한도").`, + ); + } else if (branchesOf(expression).length > 0 || !/^\d+$/.test(String(expression.else))) { + state = 'operator'; + warn( + `${label}가 운영자의 식이라(갈래가 있거나 else 가 숫자 하나가 아니다) 바꾸지 않았습니다 (${JSON.stringify(expression)}) — ` + + `모든 갈래와 else 가 ${limit} 이하인지 관리 화면에서 확인하세요. ${memory} (mail/README.md "엔진 한도").`, + ); + } else { + const size = Number(expression.else); + if (size === 0) { + state = 'zero'; + warn(`${label}가 0 입니다 — 0 의 뜻(제한 없음인지)을 확인하지 않아 바꾸지 않았습니다. 관리 화면에서 1 이상 ${limit} 이하로 두세요 — ${memory} (mail/README.md "엔진 한도").`); + } else if (size > SMTP_MESSAGE_SIZE_LIMIT) { + state = 'saved'; + await setObjects(client, 'MtaStageData', { update: { singleton: { maxMessageSize: { match: {}, else: String(SMTP_MESSAGE_SIZE_LIMIT) } } } }); + log(`[변경] ${label} ${limit} · 전 ${formatCount(size)}`); + } else { + state = 'kept'; + log(`[유지] ${label} ${formatCount(size)}바이트 (${mib} 이하)`); + } + } + + // IMAP 요청 · 메일 · 첨부 크기 — 이전(imapsync) 동안 운영자가 올리므로 바꾸지 않는다. 0 · null 은 뜻을 확인하지 않은 값이라 "확인하지 못함" + const scope = 'IMAP 요청 · 메일 크기 한도'; + const large = []; + const unread = []; + const within = []; + for (const [type, defaults] of Object.entries(ENGINE_DEFAULT_SIZE_LIMITS)) { + const read = await readSingleton(client, type, Object.keys(defaults)); + if (read.error) { + unread.push(`x:${type} — ${read.error}`); + continue; + } + for (const [field, engineDefault] of Object.entries(defaults)) { + const value = read.value[field]; + const name = `x:${type} 의 ${field}`; + if (!Number.isSafeInteger(value) || value <= 0) unread.push(`${name} = ${JSON.stringify(value) ?? '칸 없음'}`); + else if (value > engineDefault) large.push(`${name} ${formatCount(value)}(기본 ${formatCount(engineDefault)})`); + else within.push(`${name} ${formatCount(value)}`); + } + } + if (large.length > 0) { + warn( + `${scope}가 엔진 기본보다 큽니다 — ${large.join(' · ')}. ${memory} — 메모리 1g 로는 100MB 남짓(운영 106.4MB) 한 통에서 죽을 수 있다. ` + + '이전(imapsync)하는 동안 올렸다면 마친 뒤 관리 화면에서 기본으로 되돌린다 — setup 은 바꾸지 않는다 (mail/README.md "큰 메일").', + ); + } + if (unread.length > 0) { + warn( + `${scope}를 읽지 못했거나 뜻을 확인하지 않은 값(0 · null 등)이라 엔진 기본보다 큰지 확인하지 못했습니다 (${unread.join(' · ')}) — ` + + 'setup 은 바꾸지 않는다, 관리 화면에서 본다 (mail/README.md "엔진 한도").', + ); + } + if (large.length === 0 && unread.length === 0) log(`[확인] ${scope} — 엔진 기본 이하 (${within.join(' · ')})`); + return state; +} + /** 시 · 분 → "HH:MM" */ function formatClock(hour, minute) { return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; @@ -3426,8 +3542,10 @@ async function checkHttpEndpointPolicy(cfg) { } /** - * 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — { mailFrom: MAIL FROM 응답 줄, rcpt: 받는 주소(소문자)마다 응답 줄 }. curl 을 부르지 못하면 null. + * 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — { mailFrom: MAIL FROM 응답 줄, rcpt: 받는 주소(소문자)마다 응답 줄, + * ehlo: { sent: EHLO 를 보냈는가, size: EHLO 응답의 SIZE 값 — 숫자 글 · 숫자가 없으면 '' · SIZE 줄이 없으면 null }, status: curl 종료 코드 }. curl 을 부르지 못하면 null. * MAIL FROM 이 거절되거나 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다. + * 받는 주소를 하나도 주지 않으면 curl 은 EHLO 뒤 HELP 만 하고 끝낸다(MAIL FROM · RCPT · DATA 없음 — curl 8.14.1, 2026-11-03 개발 엔진) — EHLO 만 볼 때 쓴다. * sender 가 '<>' 면 빈 봉투(MAIL FROM:<>)다 (RCPT_CHECK_SENDER). */ function smtpRepliesInsideMail(cfg, mailIp, sender, recipients) { @@ -3454,9 +3572,26 @@ function smtpRepliesInsideMail(cfg, mailIp, sender, recipients) { { input: '', encoding: 'utf8' }, ); if (result.error) return null; - const replies = { mailFrom: null, rcpt: new Map() }; + const replies = { mailFrom: null, rcpt: new Map(), ehlo: { sent: false, size: null }, status: result.status }; let asked = null; + let inEhlo = false; for (const line of (result.stderr ?? '').split(/\r?\n/)) { + if (/^> EHLO /.test(line)) { + replies.ehlo.sent = true; + inEhlo = true; + continue; + } + if (inEhlo) { + // EHLO 응답은 여러 줄이다 — "250-…" 이 이어지고 "250 …" 이 끝이다 (SIZE 는 RFC 1870 — 숫자가 없을 수 있다) + const extension = /^< 250([ -])(.*)$/.exec(line); + if (extension) { + const size = /^SIZE(?:\s+(\d+))?\s*$/i.exec(extension[2].trim()); + if (size) replies.ehlo.size = size[1] ?? ''; + if (extension[1] === ' ') inEhlo = false; + continue; + } + if (/^[<>] /.test(line)) inEhlo = false; + } if (/^> MAIL FROM:/.test(line)) { asked = { mailFrom: true }; continue; @@ -3516,6 +3651,66 @@ async function checkSystemRecipientsRejected(cfg, addresses) { ); } +/** + * 받는 메일 크기 한도가 리로드 뒤 엔진에 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션 — 바깥 메일이 들어오는 길)로 + * EHLO 를 보내 SIZE 를 읽는다(smtpRepliesInsideMail 에 받는 주소 없이 — EHLO · HELP 뿐, 메일은 보내지 않는다). 루프백이 아니라 네트워크 주소인 까닭은 + * 시스템 계정 RCPT 확인과 같다. ensureMessageSizeLimits 의 [변경] · [유지] 는 저장한 값이라, 리로드가 걸리지 않았으면 엔진은 옛 한도(기본 100MiB)로 받는다 — + * 운영 차례(mail/README.md "큰 메일")는 이 [확인] 줄을 보고 메일 컨테이너 메모리를 1g 로 되돌린다. + * - SIZE 가 1 이상 SMTP_MESSAGE_SIZE_LIMIT 이하 → [확인] + * - 더 큰데 setup 이 단순한 꼴로 저장 · 유지했다(state 'saved' · 'kept') → problems(종료 1). 운영자의 식 · 0 · 읽지 못함이면 [주의] + * - SIZE 줄이 없거나 숫자가 아니거나 0, 확인 자체를 못 함 → [주의] + * ⚠️ "[확인] 받는 메일 크기 한도 — EHLO SIZE" 는 운영 차례 문서가 이 글자로 확인한다 — 바꾸지 않는다 + */ +async function checkMessageSizeLimit(cfg, state) { + const label = '받는 메일 크기 한도'; + const limit = `${formatCount(SMTP_MESSAGE_SIZE_LIMIT)}바이트 (${SMTP_MESSAGE_SIZE_LIMIT / MEGABYTE}MiB)`; + const ours = state === 'saved' || state === 'kept'; + const mailIp = mailContainerIp(cfg); + if (!mailIp) { + warn(`docker 를 쓸 수 없거나 mail 컨테이너 주소를 몰라 ${label}가 엔진에 걸렸는지(EHLO 의 SIZE) 확인하지 못했습니다.`); + return; + } + const where = `메일 컨테이너 ${mailIp}:25`; + const sizeOf = (replies) => (/^\d+$/.test(replies?.ehlo.size ?? '') ? Number(replies.ehlo.size) : null); + const within = (size) => size !== null && size >= 1 && size <= SMTP_MESSAGE_SIZE_LIMIT; + let replies = null; + // setup 이 맞춘 값이면 리로드가 퍼질 때까지 몇 번 더 본다 — 운영자의 식 · 0 은 기다려도 바뀌지 않는다 + for (let attempt = 0; attempt < (ours ? SETTLE_ATTEMPTS : 1); attempt += 1) { + if (attempt > 0) await sleep(POLL_INTERVAL_MS / 2); + replies = smtpRepliesInsideMail(cfg, mailIp, RCPT_CHECK_SENDER, []); + if (within(sizeOf(replies))) break; + } + const size = sizeOf(replies); + if (within(size)) { + const note = state === 'operator' ? ' · 운영자의 식이라 다른 세션(465 · 인증한 세션 등)은 다를 수 있다 — 위 [주의]' : ''; + log(`[확인] ${label} — EHLO SIZE ${formatCount(size)} (${where} · 리로드 뒤 엔진이 알리는 값${note})`); + return; + } + if (size !== null && size > SMTP_MESSAGE_SIZE_LIMIT) { + const head = `${label}가 엔진에 걸리지 않았습니다 — 리로드 뒤 EHLO SIZE ${formatCount(size)} (${where}) 가 ${limit} 보다 크다`; + if (ours) { + problems.push( + `${head} — 엔진에 저장된 값은 ${limit} 이하다(setup 이 이번에 저장했거나 그대로 두었다). 리로드가 실패했거나 반영되지 않았으니 위 [실패] · [주의] 를 보고 ` + + 'setup 을 다시 돌린다 — 걸리기 전에는 엔진이 옛 한도로 받아 큰 메일 한 통에 메모리 1g 를 넘을 수 있다 (mail/README.md "큰 메일").', + ); + } else { + const untouched = { + operator: '운영자의 식이라 setup 이 바꾸지 않았다 — 위 [주의]', + zero: '0 이라 setup 이 바꾸지 않았다 — 위 [주의]', + unread: 'setup 이 값을 읽지 못해 맞추지 않았다 — 끝의 [실패] 항목', + }; + warn(`${head} — ${untouched[state] ?? 'setup 이 맞추지 않은 값이다'}. 관리 화면에서 ${limit} 이하로 두고 다시 실행하세요 (mail/README.md "엔진 한도").`); + } + return; + } + let why; + if (!replies) why = 'docker exec 로 curl 을 부르지 못했다'; + else if (!replies.ehlo.sent) why = `EHLO 를 보내지 못했다 (curl 종료 코드 ${replies.status})`; + else if (replies.ehlo.size === null) why = 'EHLO 응답에 SIZE 줄이 없다'; + else why = `EHLO 의 SIZE 가 한도를 알리지 않는다 (SIZE ${replies.ehlo.size || '숫자 없음'})`; + warn(`${label}가 엔진에 걸렸는지 확인하지 못했습니다 — ${why} (${where}). 관리 API 로 x:MtaStageData 의 maxMessageSize 를 보고, 리로드한 뒤 다시 실행하세요 (mail/README.md "엔진 한도").`); +} + /** * 운영 — 바깥에서 쓰는 주소(MAIL_PUBLIC_URL) 그대로 /jmap/session 이 403 인지 (이 스크립트를 돌리는 호스트에서). * 컨테이너 안 확인과 달리 실제 공개 경로를 본다 — 앞단 프록시가 TLS 를 끝내고 8080(http 리스너)으로 넘기면 @@ -5270,10 +5465,12 @@ async function main() { } // 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다 · 시범 공존이면 기존 서버 IP 도 뺀다), // 계정마다 임시 업로드 크기 합계는 첨부 합계 한도의 20배 이상으로 (둘 다 아래 리로드로 반영) + // SMTP 로 받는 메일 한 통은 50MiB 이하로 (아래 리로드로 반영) — IMAP 요청 · 메일 크기 한도는 이전 동안 운영자가 올리므로 바꾸지 않고, 엔진 기본보다 크면 [주의] 만 await ensureRecipientThrottle(admin, pilot); // 시범 공존 A 의 나머지 — 기존 서버 IP 의 MAIL FROM · 검사 예외 · 연결 한도 예외 · 자동 차단 예외 (꺼져 있으면 setup 이 넣은 것만 뺀다) await ensurePilotInboundTrust(admin, pilot); await ensureUploadQuota(admin, cfg); + const messageSize = await ensureMessageSizeLimits(admin); // 휴지통 · 스팸메일함 30일 자동 비우기와 엔진 정리 시각(새벽 KST — 13단계, 아래 리로드로 반영) const retention = await ensureDataRetention(admin, cfg); // 엔진 DNS — 메일 전용 리졸버(운영은 바로 · 개발은 도커 DNS 를 거쳐), Spamhaus 두 항목은 키가 있으면 DQS 로 · 없으면 끈다 (아래 리로드로 반영) @@ -5297,6 +5494,8 @@ async function main() { } await checkHttpEndpointPolicy(cfg); if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients); + // 받는 메일 크기 한도가 위 리로드로 실제로 걸렸는지 — 메일 컨테이너 안에서 EHLO 의 SIZE (저장 때의 [변경] · [유지] 만으로는 모른다) + await checkMessageSizeLimit(cfg, messageSize); if (pilot) await checkPilotSenderGuard(cfg); checkEngineDnssec(cfg, engineDnsChanged); await checkEngineWebhook(admin, cfg, webhook); -- 2.52.0 From 2cd2764da87d2244d7979ed8b1af2d4072279ea5 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 23:10:44 +0900 Subject: [PATCH 12/14] =?UTF-8?q?test(backend):=20jmap.client=20spec=20?= =?UTF-8?q?=E2=80=94=20=EA=B0=80=EC=A7=9C=20=EC=9D=91=EB=8B=B5=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20fetch=20=EB=8A=94=20=EC=97=B0=EA=B2=B0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A1=9C=20=EB=A7=89=EA=B3=A0=20=EB=B9=A0=EC=A7=84=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=9D=84=20=EC=9E=A1=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 spec 은 fetch 를 spyOn 으로 감싸기만 해, 가짜 응답을 넣지 않은 호출이 진짜 http://mail:8080 으로 나갔다. 13단계부터 메일 계정이 없는 세션은 계정 정보를 한 번 더 묻는데, 그 요청이 CI 컨테이너에서 이름 풀기를 기다리다 테스트 5초를 넘겼다 (2026-11-03 CI — 개발 컨테이너에서는 개발 엔진이 실제로 받았다). - 가짜 응답이 없는 fetch 는 곧바로 연결 실패로 거절하고 주소를 남긴다 — afterEach 가 남은 주소가 있으면 그 테스트를 실패시킨다. jest-mock 30 의 mockReset 은 구현까지 지우므로 resetFetchMock 이 기본을 다시 건다. - 메일 계정이 없는 세션 · 실시간 연결 403 두 테스트에 계정 정보 응답을 넣고 요청을 단언. Co-Authored-By: Claude Opus 5.5 --- .../src/modules/mail/jmap/jmap.client.spec.ts | 92 +++++++++++++++---- 1 file changed, 74 insertions(+), 18 deletions(-) diff --git a/backend/src/modules/mail/jmap/jmap.client.spec.ts b/backend/src/modules/mail/jmap/jmap.client.spec.ts index f475bee..23fe4fb 100644 --- a/backend/src/modules/mail/jmap/jmap.client.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.client.spec.ts @@ -80,10 +80,40 @@ function decodeBasic(header: string): string { return Buffer.from(header.replace(/^Basic /, ''), 'base64').toString('utf8'); } +/** Node fetch 의 연결 실패와 같은 모양 — 엔진에 닿지 못했다 */ +function connectionRefused(): TypeError { + return new TypeError('fetch failed', { + cause: Object.assign(new Error('connect ECONNREFUSED'), { + code: 'ECONNREFUSED', + }), + }); +} + describe('JmapClient', () => { let fetchMock: jest.SpyInstance; let cache: Cache; let client: JmapClient; + /** 가짜 응답 없이 나간 fetch 의 주소 — 테스트가 끝날 때 비어 있어야 한다 (afterEach) */ + let unansweredFetches: string[]; + + /** + * 가짜 응답을 넣지 않은 fetch 의 기본 — 진짜 네트워크로 나가지 않고 곧바로 연결 실패로 거절하며 주소를 남긴다. + * spy 의 기본(원래 fetch)이면 spec 의 엔진 주소 http://mail:8080 으로 진짜 요청이 나간다 — 개발 컨테이너에서는 개발 엔진이 받고, + * CI 에서는 그 이름을 늦게 풀면 테스트 제한 시간(5초)을 넘긴다 (2026-11-03 CI — 메일 계정이 없는 세션의 계정 정보 조회) + */ + const refuseUnanswered = (url: string): Promise => { + unansweredFetches.push(url); + return Promise.reject(connectionRefused()); + }; + + /** + * 넣어 둔 가짜 응답 · 기록을 비우고 기본(연결 실패)을 다시 건다 — `fetchMock.mockReset()` 을 바로 부르지 않는다. + * jest-mock 30 의 mockReset 은 spy 의 구현까지 지워, 그 뒤로는 기본도 원래 fetch 도 아닌 undefined 를 돌려준다 + */ + const resetFetchMock = (): void => { + fetchMock.mockReset(); + fetchMock.mockImplementation(refuseUnanswered); + }; /** n 번째 fetch 호출의 URL·헤더 */ const requestAt = (index: number) => { @@ -96,7 +126,9 @@ describe('JmapClient', () => { }; beforeEach(() => { + unansweredFetches = []; fetchMock = jest.spyOn(global, 'fetch'); + fetchMock.mockImplementation(refuseUnanswered); cache = fakeCache(); client = new JmapClient(settings, cache); }); @@ -104,6 +136,8 @@ describe('JmapClient', () => { afterEach(() => { fetchMock.mockRestore(); jest.useRealTimers(); + // 엔진에 묻는 일마다 응답을 직접 넣는다 — 빠진 응답이 있으면 연결 실패로 지나가 버리지 않게 여기서 그 테스트를 실패시킨다 + expect(unansweredFetches).toEqual([]); }); describe('직원 자격 확인 (verifyCredentials)', () => { @@ -157,13 +191,7 @@ describe('JmapClient', () => { 'MAIL_001', ); - fetchMock.mockRejectedValueOnce( - new TypeError('fetch failed', { - cause: Object.assign(new Error('connect ECONNREFUSED'), { - code: 'ECONNREFUSED', - }), - }), - ); + fetchMock.mockRejectedValueOnce(connectionRefused()); expect(await codeOf(client.verifyCredentials(ARA, 'pw'))).toBe( 'MAIL_001', ); @@ -288,7 +316,7 @@ describe('JmapClient', () => { it('지금 비밀번호가 틀리면 세션 요청이 401 — 바꾸기를 보내지 않고 current-password-rejected (403 도 로그인 확인처럼 같다)', async () => { for (const status of [401, 403]) { - fetchMock.mockReset(); + resetFetchMock(); fetchMock.mockResolvedValueOnce(json({}, status)); await expect(client.changePassword(ARA, 'wrong', NEXT)).resolves.toBe( @@ -465,7 +493,7 @@ describe('JmapClient', () => { 'REQ_001', ); - fetchMock.mockReset(); + resetFetchMock(); fetchMock.mockResolvedValueOnce(json(sessionBody)); expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe( 'MAIL_001', @@ -1038,12 +1066,32 @@ describe('JmapClient', () => { expect(cache.set).not.toHaveBeenCalled(); }); - it('메일 계정이 없는 세션은 MAIL_001', async () => { - fetchMock.mockResolvedValueOnce( - json({ ...sessionBody, primaryAccounts: {} }), - ); + it('메일 계정이 없는 세션은 MAIL_001 — 먼저 대리 접근으로 계정 정보(권한)를 한 번 더 묻고, 비밀번호 만료의 모양이 아닐 때 (13단계)', async () => { + fetchMock + .mockResolvedValueOnce(json({ ...sessionBody, primaryAccounts: {} })) + // 보통 직원의 권한 — 메일 권한이 있어 비밀번호 만료가 아니다 + .mockResolvedValueOnce( + json({ + permissions: ['authenticate', 'emailReceive', 'jmapEmailGet'], + }), + ); - expect(await codeOf(client.getSession(ARA))).toBe('MAIL_001'); + const failure = await client + .getSession(ARA) + .catch((error: unknown) => error); + + expect(failure).not.toBeInstanceOf(PasswordExpiredException); + expect((failure as BusinessException).code).toBe('MAIL_001'); + // 만료가 아니니 세션이 모자란 것으로 — 계정 정보를 읽지 못해서가 아니다 + expect((failure as BusinessException).context).toMatchObject({ + reason: 'session-incomplete', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(requestAt(1).url).toBe('http://mail:8080/api/account'); + expect(decodeBasic(requestAt(1).authorization)).toBe( + `${ARA}%mail-gateway@arasystem.test:gateway-secret`, + ); + expect(cache.set).not.toHaveBeenCalled(); }); }); @@ -1291,7 +1339,7 @@ describe('JmapClient', () => { it('제한 시간을 받으면 세션 조회(캐시에 없을 때)까지 그 안에서 한다 — 세션 조회에 쓴 시간만큼 호출이 짧아진다', async () => { jest.useFakeTimers(); // 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게 - fetchMock.mockReset(); + resetFetchMock(); fetchMock.mockImplementation( (_url: string, init: RequestInit) => new Promise((resolve, reject) => { @@ -1326,7 +1374,7 @@ describe('JmapClient', () => { it('세션 조회가 받은 제한 시간을 넘기면 거기서 MAIL_001 (timeout) — 호출은 보내지 않는다', async () => { jest.useFakeTimers(); // 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게 - fetchMock.mockReset(); + resetFetchMock(); fetchMock.mockImplementation( (_url: string, init: RequestInit) => new Promise((_resolve, reject) => { @@ -1672,7 +1720,7 @@ describe('JmapClient', () => { expect(requestAt(0).url).toBe('http://mail:8080/jmap/session'); expect(requestAt(1).url).toContain('/jmap/eventsource/'); - fetchMock.mockReset(); + resetFetchMock(); // 부를 때마다 새 응답 — Response 본문은 한 번만 읽힌다 fetchMock.mockImplementation(() => Promise.resolve(json(withoutEventSource)), @@ -1705,13 +1753,21 @@ describe('JmapClient', () => { fetchMock .mockResolvedValueOnce(json(sessionBody)) - .mockResolvedValueOnce(json({}, 403)); + .mockResolvedValueOnce(json({}, 403)) + // 게이트웨이 자신의 권한에 impersonate 가 없다 — 설정 문제 + .mockResolvedValueOnce(json({ permissions: ['authenticate'] })); const code = await runWithRequestContext({ requestId: 'trace-es' }, () => codeOf( client.openEventSource(ARA, OPTIONS, new AbortController().signal), ), ); expect(code).toBe('MAIL_001'); + // 게이트웨이 자신의 권한을 읽었다(본인 Basic — 대리 접근이 아니다) + expect(fetchMock).toHaveBeenCalledTimes(6); + expect(requestAt(5).url).toBe('http://mail:8080/api/account'); + expect(decodeBasic(requestAt(5).authorization)).toBe( + 'mail-gateway@arasystem.test:gateway-secret', + ); expect(errorLog).toHaveBeenCalledWith( expect.stringMatching( /^\[MAIL_001\]\[trace-es\] .*대리 접근 권한이 없습니다/, -- 2.52.0 From 349c312989e591f9332f2386f72f24ce9c4aa476 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 23:26:28 +0900 Subject: [PATCH 13/14] =?UTF-8?q?test(frontend):=20=EB=8C=80=EC=9A=A9?= =?UTF-8?q?=EB=9F=89=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EB=8B=A4=20=EC=98=AC?= =?UTF-8?q?=EB=A6=B0=20=EB=92=A4=20=EB=B3=B4=EB=82=B8=EB=8B=A4=20=E2=80=94?= =?UTF-8?q?=20=EC=A1=B0=EA=B0=81=20SHA-256=20=EC=9D=B4=20=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=ED=92=80=EC=9D=B4=EB=9D=BC=20=EB=B6=80?= =?UTF-8?q?=ED=95=98=EC=97=90=EC=84=9C=20=EB=8A=A6=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 쓰기 화면의 두 테스트는 대용량 파일을 고른 뒤 양보(flushPromises) 두 번 만에 다 올렸다고 보았다. 조각의 SHA-256 은 jsdom 이 아니라 Node 의 Web Crypto(libuv 스레드 풀)라, CI 부하에서 그 안에 끝나지 않았다. 다 올리기 전에 누른 [보내기]는 막혀 보내기 요청이 끝내 나가지 않아 payload 가 undefined 였다(2026-11-03 CI — ComposePage.spec.ts 첨부 합계 테스트). - 두 테스트(첨부 합계 · 링크로 보내기)는 대용량 줄이 ready 가 될 때까지 vi.waitFor 로 본다. 첨부 합계 테스트는 보내기 전에 막는 까닭이 없음도 단언한다. - test-utils/timeouts.ts 에 WAIT_FOR_TIMEOUT_MS(15초 — 테스트 한도 20초보다 짧게)를 둔다. - digest 를 늦춘 실험으로 고치기 전에는 깨지고 고친 뒤에는 통과함을 확인했다(실험은 되돌림). Co-Authored-By: Claude Opus 5.5 --- .../pages/mail/ComposePage.large-files.spec.ts | 10 +++++++++- frontend/src/pages/mail/ComposePage.spec.ts | 16 ++++++++++++++-- frontend/src/test-utils/timeouts.ts | 8 ++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/mail/ComposePage.large-files.spec.ts b/frontend/src/pages/mail/ComposePage.large-files.spec.ts index f77ca84..cdced1f 100644 --- a/frontend/src/pages/mail/ComposePage.large-files.spec.ts +++ b/frontend/src/pages/mail/ComposePage.large-files.spec.ts @@ -21,6 +21,7 @@ import { } from '@/test-utils/mock-api' import { installProseMirrorDom } from '@/test-utils/prosemirror-dom' import { createTestRouter, routeViewProvide } from '@/test-utils/router' +import { WAIT_FOR_TIMEOUT_MS } from '@/test-utils/timeouts' import ComposePage from './ComposePage.vue' // 쓰기 화면의 대용량 첨부 (11단계 — 디자인 원본 ComposeLargeFiles · 계약 4절 "발송 · 임시 저장과 함께" · "인용 속 링크의 상태") @@ -367,7 +368,14 @@ describe('ComposePage — 대용량 첨부', () => { expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.uploading('1')) releaseChunk() - await flushPromises() + // 조각을 받으면 끝내기까지 가 ready 가 된다 — 조각을 보내기 전의 SHA-256 은 Node 의 Web Crypto(스레드 풀)라 + // 부하가 크면 양보 두 번으로는 끝나지 않는다 (ComposePage.spec.ts 의 첨부 합계 테스트와 같은 까닭) — ready 가 될 때까지 본다 + await vi.waitFor( + () => { + if (phases(wrapper)[0] !== 'ready') throw new Error('대용량 파일을 올리는 중') + }, + { timeout: WAIT_FOR_TIMEOUT_MS }, + ) await flushPromises() expect(phases(wrapper)).toEqual(['ready']) diff --git a/frontend/src/pages/mail/ComposePage.spec.ts b/frontend/src/pages/mail/ComposePage.spec.ts index ce23e75..273840b 100644 --- a/frontend/src/pages/mail/ComposePage.spec.ts +++ b/frontend/src/pages/mail/ComposePage.spec.ts @@ -36,7 +36,7 @@ import { } from '@/test-utils/mock-api' import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom' import { createTestRouter, routeViewProvide } from '@/test-utils/router' -import { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts' +import { LONG_DRAFT_TIMEOUT_MS, WAIT_FOR_TIMEOUT_MS } from '@/test-utils/timeouts' import ComposePage from './ComposePage.vue' const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' } @@ -599,7 +599,17 @@ describe('ComposePage', () => { configurable: true, }) await picker.trigger('change') - await flushPromises() + // 도면은 다 올려야(끝내기 응답 뒤 ready) [보내기]가 풀린다 — 그 전에 누른 보내기는 막혀 보내기 요청이 끝내 나가지 않는다. + // 조각의 SHA-256 은 Node 의 Web Crypto(스레드 풀)라 부하가 크면 양보 두 번으로는 끝나지 않았다 (2026-11-03 CI) — ready 가 될 때까지 본다 + await vi.waitFor( + () => { + const row = wrapper.find('[data-large-upload]') + if (!row.exists() || row.attributes('data-phase') !== 'ready') { + throw new Error('대용량 파일을 올리는 중') + } + }, + { timeout: WAIT_FOR_TIMEOUT_MS }, + ) await flushPromises() expect(wrapper.findAll('.upload-item').map((item) => item.classes())).toEqual([ @@ -612,6 +622,8 @@ describe('ComposePage', () => { ) expect(requests.filter((request) => request.url === '/mail-attachments')).toHaveLength(1) expect(useToastStore().toasts).toEqual([]) + // 다 올렸다 — [보내기]를 막는 까닭이 없다 (아래 보내기는 마이크로태스크로만 이어져 양보 한 번이면 요청이 나간다) + expect(wrapper.find('#compose-send-why').exists()).toBe(false) const input = wrapper.get('#compose-to') await input.setValue('junho.park@arasystem.it.kr') diff --git a/frontend/src/test-utils/timeouts.ts b/frontend/src/test-utils/timeouts.ts index 8777b25..d471b99 100644 --- a/frontend/src/test-utils/timeouts.ts +++ b/frontend/src/test-utils/timeouts.ts @@ -9,3 +9,11 @@ * 전체 한도에 가까워 이 테스트들에만 30초를 준다. */ export const LONG_DRAFT_TIMEOUT_MS = 30_000 + +/** + * 조건이 될 때까지 다시 보는 기다림(vi.waitFor)의 기한 — 몇 번 양보(flushPromises)해서는 끝난다고 믿을 수 없는 일을 기다린다. + * 예: 대용량 파일 조각의 SHA-256 — jsdom 이 아니라 Node 의 Web Crypto 라 스레드 풀에서 돌아, 부하가 크면 양보 두 번 안에 끝나지 않았다 + * (2026-11-03 CI — 다 올리기 전에 누른 [보내기]는 막혀 보내기 요청이 끝내 나가지 않는다). + * 테스트 한도(vitest.config.ts, 20초)보다 짧게 둬, 끝내 되지 않으면 테스트 한도보다 이 기다림이 먼저 까닭(던진 글)을 알린다. + */ +export const WAIT_FOR_TIMEOUT_MS = 15_000 -- 2.52.0 From bc22f5f3ba3a175a56bf1d1faa76dea27cb70fbe Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 11:30:18 +0900 Subject: [PATCH 14/14] =?UTF-8?q?test(backend):=20CPU=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20=EB=8B=A8=EC=96=B8=EC=9D=84=20=EC=A7=80=EA=B8=88=20=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=EC=9D=98=20user=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=E2=80=94=20CI=20=ED=9D=94=EB=93=A4?= =?UTF-8?q?=EB=A6=BC=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문서만 바꾼 PR(#8, run 138)의 CI 에서 할당이 많은 성능 단언 셋이 넘쳤다(받는 사람 맞추기 3.29초 · JSON 모양 1.35초 · DTO 2.94초). 프로세스 user + system 시간에는 V8 의 GC · JIT 도우미 스레드와 페이지 폴트 몫이 들어가 기계 사정을 따라 크게 흔들린다. 같은 Linux 컨테이너에서 전체 테스트 16회: 프로세스 user + system 은 4회 넘침, 스레드 user 는 0회. 막으려는 옛 경로(제곱)는 거의 user 시간이라 그대로 잡힌다(받는 사람 맞추기 옛 방법 51~70초). 받는 사람 맞추기 한도는 CI 쪽 값(0.17~0.25초)의 10배쯤인 2.5초로 올렸다. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/testing.md | 4 +- .claude/skills/nestjs/SKILL.md | 2 +- .../src/modules/mail/recipient-match.spec.ts | 6 ++- backend/test/cpu-time.ts | 54 +++++++++++++++---- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 8cb8308..5e8a33f 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -13,7 +13,9 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍, - **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다. - **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조. - **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다. - - 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 프로세스 CPU 시간이나 비용 수치. + - 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 지금 스레드의 user CPU 시간(`backend/test/cpu-time.ts`)이나 비용 수치. + 프로세스 전체 · system 시간은 쓰지 않는다 — V8 의 GC · JIT 도우미 스레드와 페이지 폴트가 기계 사정을 따라 흔들려, 2026-10-31 문서만 바꾼 PR 의 CI 에서 할당이 많은 단언 셋이 넘쳤다 + (같은 컨테이너 16회: 프로세스 user + system 으로 4회 넘침 · 스레드 user 로 0회). CPU 시간 한도는 **지금 값의 10배쯤** 둔다 — CPU 시간도 부하에서 는다(2026-10-28: CI 와 같은 Linux 컨테이너에서 메모리가 모자라고 테스트가 함께 돌 때 평소의 4~7배 — 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다). 막으려는 옛 경로(제곱 등)는 그 한도를 확실히 넘어야 한다 — 둘 사이가 좁으면 입력을 키운다. 벽시계는 다른 작업이 CPU 를 나눠 쓰면 몇 배로 늘어 CI 에서 흔들린다 (2026-10-27: 혼자 0.8초인 정화가 부하에서 9초). diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index 4c24b60..0bf49de 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -384,7 +384,7 @@ dc exec backend npm uninstall <패키지> - [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`. - [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지. - [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`. - 성능 단언은 `backend/test/cpu-time.ts` 의 `startCpuTimer()`(프로세스 CPU 시간)로 잰다 — 벽시계 한도는 부하에서 흔들린다 ([[testing]]). + 성능 단언은 `backend/test/cpu-time.ts` 의 `startCpuTimer()`(지금 스레드의 user CPU 시간 — GC · JIT 도우미 스레드와 system 시간은 뺀다)로 잰다 — 벽시계 한도는 부하에서 흔들린다 ([[testing]]). - [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인. - [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용. - [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]). diff --git a/backend/src/modules/mail/recipient-match.spec.ts b/backend/src/modules/mail/recipient-match.spec.ts index 0ca2c7f..503a16f 100644 --- a/backend/src/modules/mail/recipient-match.spec.ts +++ b/backend/src/modules/mail/recipient-match.spec.ts @@ -140,8 +140,10 @@ describe('받는 사람 맞추기 (recipient-match — 쓰기 화면과 같은 expect(recipientNameMatcher(`${'가'.repeat(99)}나`)(name)).toBe(true); const matches = recipientNameMatcher(`${'가'.repeat(99)}다`); expect(names.filter(matches)).toEqual([]); - // 지금 0.06~0.14초 — 한도는 10배쯤 (testing 규칙). 옛 방법(1억 번 비교)은 확실히 넘는다 - expect(cpuMs()).toBeLessThan(1_500); + // 지금 스레드 user 0.13초 안팎(호스트 Windows · Node 22), CI 와 같은 Linux 컨테이너(Node 24)에서는 혼자 0.17~0.25초 — + // 한도는 그 10배쯤 (testing 규칙 — 1.5초였을 때 2026-10-31 CI 에서 프로세스 시간 3.29초로 넘쳤다, test/cpu-time.ts). + // 옛 방법(1억 번 비교)은 그 컨테이너에서 51~70초로 확실히 넘는다 (2026-10-31 되살려 잼) + expect(cpuMs()).toBeLessThan(2_500); }); it('추천 줄 거르기 — 같은 이름은 한 번만 견준다 (연락처 하나의 주소 여러 줄)', () => { diff --git a/backend/test/cpu-time.ts b/backend/test/cpu-time.ts index d2ccaaf..3af898e 100644 --- a/backend/test/cpu-time.ts +++ b/backend/test/cpu-time.ts @@ -3,14 +3,28 @@ * * 왜 벽시계(performance.now)가 아닌가: 다른 프로세스가 CPU 를 나눠 쓰면 벽시계 시간은 그만큼 늘어난다. * 2026-10-27 바쁜 PC(병렬 테스트·개발 서버와 함께)에서 혼자 0.7~0.8초인 정화가 5.9초·9.1초로 재져 - * 전체 테스트 4번 중 2번이 실패했다. CPU 시간은 이 프로세스가 실제로 계산한 시간만 세므로 다른 작업이 많아도 거의 늘지 않는다 — + * 전체 테스트 4번 중 2번이 실패했다. CPU 시간은 실제로 계산한 시간만 세므로 다른 작업이 많아도 덜 는다 — * "계산량이 입력 길이에 비례하는가(예전의 제곱 경로가 돌아오지 않았는가)"를 보는 단언에 쓴다. * - * - 프로세스 전체(GC·JIT 스레드 포함)의 user + system 시간이다 — 한도는 지금 값의 10배쯤 넉넉하게, 예전의 제곱 경로는 확실히 넘는 값으로 둔다. - * CPU 시간도 늘지 않는 것은 아니다 — 2026-10-28 CI 와 같은 Linux 컨테이너(Docker Desktop)에서 메모리가 모자라고 테스트가 함께 돌 때 - * 평소의 4~7배로 재져, 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다(이미지 많은 정화 1.6초 → 5.75초, DTO 검사 0.08초 → 0.59초). + * - **지금 스레드**(테스트 코드가 도는 JS 스레드)의 **user 시간만** 센다 — `process.threadCpuUsage`. + * 함수가 없거나 플랫폼이 스레드 시간을 주지 못하면(부르면 던진다) 프로세스 전체(`process.cpuUsage`)의 user 시간으로 물러선다. + * 빼는 둘은 잰 코드가 아니라 그때 기계가 얼마나 바쁜지를 따라 흔들린다: + * - 다른 스레드 — V8 은 GC(병렬 스캐빈지 · 동시 마킹)와 JIT 컴파일을 도우미 스레드에 나눠 맡긴다. + * 할당이 많은 단언에서 프로세스 시간이 이 스레드 시간의 많게는 2~3배였다. + * - system 시간 — 새 메모리를 처음 만질 때의 페이지 폴트 등. CI 와 같은 Linux 컨테이너(Docker Desktop VM)에서 JSON.parse 를 포함한 모양 확인이 + * 혼자는 user 17~45ms · system 16~28ms 인데, 전체 테스트와 함께 돌 때는 같은 폴트 수(4~8천)에 system 이 366ms 까지 갔다. + * 2026-10-31 CI(문서만 바꾼 PR)에서 할당이 많은 단언 세 곳이 그때의 프로세스 user + system 시간으로 넘쳤다 — 받는 사람 맞추기 3.29초(한도 1.5초) · + * JSON.parse 를 포함한 모양 확인 1.35초(0.5초) · DTO 검사 2.94초(2초). 같은 컨테이너에서 전체 테스트를 16번 돌리니 한도를 넘은 회가 + * 프로세스 user + system 으로 4번 · 스레드 user + system 으로 2번 · 스레드 user 로 0번이었다. + * 막으려는 옛 경로(제곱 등)는 거의 다 user 시간이라 그대로 넘는다 — 받는 사람 맞추기의 옛 방법은 스레드 51.2초 가운데 user 51.0초. + * - user 시간도 부하에서 는다 — 이 스레드가 직접 하는 GC 몫, 코어를 나눠 쓰는 느려짐(하이퍼스레딩 · 효율 코어), VM 이 vCPU 를 쉬게 한 시간 + * (Docker Desktop VM 은 steal 로 따로 세지 않는다)이 그대로 들어간다 — 위 16번에서 혼자일 때의 3~8배, 많게는 16배까지 재졌다. + * 2026-10-28 에도 (그때는 프로세스 user + system 으로) 메모리가 모자라고 테스트가 함께 돌 때 평소의 4~7배로 재져, 여유가 3~6배뿐이던 한도 두 곳이 + * CI 를 실패시켰다(이미지 많은 정화 1.6초 → 5.75초, DTO 검사 0.08초 → 0.59초). 그래서 한도는 지금 값의 10배쯤 넉넉하게, 예전의 제곱 경로는 확실히 넘는 값으로 둔다. + * 지금 값은 CI 와 같은 Linux 컨테이너(Node 24)에서도 잰다 — 호스트(Windows · Node 22)보다 2배쯤 크게 재지는 단언이 있다. * - 기다리는 시간(I/O·타이머)은 CPU 시간에 들어가지 않는다 — 그런 단언(예: 파일이 지워질 때까지 기다리기)에는 쓰지 않는다. - * - Windows 에서는 CPU 시간이 스케줄러 틱(약 15.6ms) 단위로 는다 — 수십 ms 아래를 비교하는 데 쓰지 않는다. + * system 시간을 세지 않으므로 시스템 호출이 많은 코드의 비용을 재는 데도 쓰지 않는다. + * - Windows 에서는 CPU 시간이 스케줄러 틱(약 15.6ms) 단위로 는다(스레드 시간도) — 수십 ms 아래를 비교하는 데 쓰지 않는다. * * 위치: src 밖(test/)이라 빌드(nest build 는 sourceRoot 인 src 만 컴파일한다)와 단위 테스트 커버리지(rootDir 이 src)에 들어가지 않고, * 이름이 `*.spec.ts` 가 아니라 테스트로 돌지도 않는다. 단위 테스트는 `@/../test/cpu-time` 으로 부른다 — @@ -19,17 +33,35 @@ const MICROSECONDS_PER_MS = 1_000; +/** CPU 시간 읽기 — 앞서 읽은 값을 주면 그때부터 쓴 만큼 (process.cpuUsage 와 같은 모양) */ +type CpuUsageReader = (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage; + /** - * 지금부터 재기 시작한다. 돌려준 함수를 부르면 그때까지 이 프로세스가 쓴 CPU 시간(ms)을 준다. + * 지금 스레드의 CPU 시간 읽기를 고른다 — 쓸 수 없으면 프로세스 전체. + * 함수가 있어도 플랫폼이 스레드 시간을 주지 못하면(libuv 의 uv_getrusage_thread 가 ENOTSUP) 부르는 순간 던지므로 한 번 불러 본다. + */ +function pickCpuUsageReader(): CpuUsageReader { + if (typeof process.threadCpuUsage === 'function') { + try { + process.threadCpuUsage(); + return (previous) => process.threadCpuUsage(previous); + } catch { + // 스레드 시간을 주지 못하는 플랫폼 — 아래 프로세스 전체로 + } + } + return (previous) => process.cpuUsage(previous); +} + +const readCpuUsage = pickCpuUsageReader(); + +/** + * 지금부터 재기 시작한다. 돌려준 함수를 부르면 그때까지 이 스레드가 쓴 user CPU 시간(ms)을 준다. * * const cpuMs = startCpuTimer(); * sanitizeMailHtml(html); * expect(cpuMs()).toBeLessThan(5_000); */ export function startCpuTimer(): () => number { - const started = process.cpuUsage(); - return () => { - const { user, system } = process.cpuUsage(started); - return (user + system) / MICROSECONDS_PER_MS; - }; + const started = readCpuUsage(); + return () => readCpuUsage(started).user / MICROSECONDS_PER_MS; } -- 2.52.0