generated from ttipo/project-base
Compare commits
8 Commits
b43b747107
...
6dc2777834
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dc2777834 | |||
| 3b59497d59 | |||
| 5829814dce | |||
| 3f8ba0081a | |||
| 9b530da6e1 | |||
| feabe0d8e1 | |||
| 0388cf609a | |||
| 07b7fcca0f |
@@ -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`)
|
||||
|
||||
@@ -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)를
|
||||
|
||||
+111
-17
@@ -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:<BACKEND_PORT>/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`) — 웹훅 받는 쪽이 접속 주소(소켓)가 이 대역인지 본다 |
|
||||
@@ -288,7 +291,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 비공개 버킷 · 백업 전용 API 인증키(아래 "백업 › 설치" — 메인 계정 키를 고른 까닭과 위험) · restic 비밀번호(비밀번호 관리자에 따로 보관),
|
||||
Healthchecks.io 점검 둘 · UptimeRobot 감시 셋 ([백업](#백업) · [감시](#감시-13단계)).
|
||||
`.env.production` 의 `DB_IMAGE` 를 예시와 같은 값(판 · digest 고정 — 옛 파일은 `postgres:15-alpine`)으로 바꾼다 — 다음 `up -d` 에서 db 가 다시 만들어진다(몇 초 · 데이터는 볼륨 그대로, 같은 메이저 15 라 덤프 · 복원은 필요 없다).
|
||||
frontend 는 `MAIL_DOMAIN` · `MAIL_HOSTNAME` 으로 [메일 프로그램 자동 설정](#메일-프로그램-자동-설정) 응답을 만든다 — 이미 있는 값이라 새로 넣을 것은 없다
|
||||
@@ -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 | |
|
||||
| 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 · 페이지 캐시가 쓴다 |
|
||||
@@ -959,13 +996,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 +1025,10 @@ node --env-file=.env.production mail/scripts/setup.mjs
|
||||
- 5)의 출력에 `[변경] 관리자 역할("System Administrator") — 대리 접근(impersonate) 뺌`, `[변경] 관리자 … — 허용 IP <APP_NETWORK_SUBNET>`, 휴지통 · 정리 시각 줄,
|
||||
그리고 `[확인] 관리자 … — 대리 접근 권한 없음 (권한 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-터널)로만 들어간다.
|
||||
@@ -1494,7 +1539,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)의 것)
|
||||
|
||||
@@ -1660,6 +1705,25 @@ 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 <PROJECT_NAME>-mail` 뒤 그 사람들만 다시 — 모두 통과 |
|
||||
|
||||
- `docker update` 는 재시작 · 멈춤에도 남지만 **컨테이너를 다시 만들면 compose 의 1g 로 돌아간다** — 다시 만들었으면 다시 건다. 확인 `sudo docker inspect -f '{{.HostConfig.Memory}} {{.RestartCount}}' <PROJECT_NAME>-mail`(2147483648 이면 2g).
|
||||
- 110 MiB 보다 큰 메일은 옮기지 못한다 — 운영에서는 보내지 못한 초안 두 통(244 · 135 MiB)이었고, 사용자가 따로 보관하기로 했다(그 사람만 끝까지 `Detected 2 errors` · 114).
|
||||
- **이전을 마치면 되돌린다**(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 <PROJECT_NAME>-mail`.
|
||||
|
||||
받는 한도를 낮추기 전에 1g 로 돌리면 바깥 큰 메일 한 통에 엔진이 죽을 수 있다([엔진 한도](#엔진-한도) "받는 메일 크기"). 올린 채 두면 엔진에 2g 가 들고, [메모리 예산](#메모리-예산)의 합이 7.15GB, 백업이 도는 동안은 8.15GB 로 8GB 를 넘는다.
|
||||
|
||||
#### 개발에서 확인한 것 (imapsync — 2026-11-02)
|
||||
|
||||
mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a1d2f6dc164880cd7bfa74009106dfa46b36a6a2ee636d921` — mailcow 와 같은 2.3 줄기, 비-root 로 띄움, 시험 CA 의 인증서)에
|
||||
@@ -1687,7 +1751,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/` — 주소 · 폴더 이름이 들어 있어 한 주쯤 뒤 지운다)에 있다.
|
||||
|
||||
---
|
||||
|
||||
@@ -1745,12 +1810,16 @@ 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
|
||||
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
|
||||
@@ -1758,10 +1827,25 @@ 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)를 쓴다. **서버 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'` —
|
||||
지역 `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`(`<Name>` 에 버킷 · `<KeyCount>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`.
|
||||
@@ -1807,14 +1891,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 가 들어 있다 — 접근을 제한하고 서버 밖에도 두며, 쓸모가 끝나면 지운다.
|
||||
|
||||
---
|
||||
|
||||
@@ -2203,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 재실행 |
|
||||
|
||||
+201
-2
@@ -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:<Type> 싱글톤의 칸들 — 읽지 못하면(메서드 오류 · 요청 실패 · 싱글톤 없음) 멈추지 않고 { 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);
|
||||
|
||||
+8
-4
@@ -4,15 +4,18 @@
|
||||
# 이 파일은 예시다. 서버에서는 저장소 밖에 둔다 — 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)하지 않고 줄마다 읽는다 — 값에 공백이 있으면 작은따옴표로 감싼다.
|
||||
# ==========================================
|
||||
|
||||
# 저장소 폴더 (git clone 한 곳) — 스크립트가 여기의 .env.production · backend/.env.production 을 읽는다
|
||||
# (PROJECT_NAME · 메일 도메인 · 관리자 비밀번호(대기열 확인) · Spamhaus 키 — 따로 복사해 두지 않는다)
|
||||
# 아래 예시 값(/home/ubuntu/…)을 그대로 두지 않는다 — 클론한 사용자의 홈으로(서버 B 는 /home/ara/arasystem). 틀리면 .env.production 을 찾지 못해 멈춘다
|
||||
ARASYSTEM_DIR=/home/ubuntu/arasystem
|
||||
# 프로젝트 이름 — 볼륨 · 컨테이너 이름(<PROJECT_NAME>_mail-data · <PROJECT_NAME>-mail)과 백업 스냅숏의 host 이름이 이 값이다.
|
||||
# 비우면 위 저장소의 .env.production 에서 읽는다. ⚠️ 새 서버를 되살릴 때는 적는다(옛 서버의 값 — 예: arasystem) — 그 단계에는 .env.production 이
|
||||
@@ -29,7 +32,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 인증키 — 버킷에는 따로 키가 없다. 서버 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 (그보다 오래된 백업은 지운다 — 지운 메일도 이 기간까지만 백업에 남는다)
|
||||
|
||||
Reference in New Issue
Block a user