generated from ttipo/project-base
feat(docker): 13단계 운영 기반 — 백업 · 감시 · 관리자 단단히 · 휴지통 30일 · 메일 프로그램 자동 설정
전 직원 전환 전에 서버가 스스로 지키고 되살릴 수 있게 했다. - 관리자 단단히(setup): 관리자 역할에서 impersonate 를 빼고 관리자 허용 IP 를 앱 네트워크로 — 관리자 비밀번호로 바깥 993 · JMAP 에서 직원 메일함에 대리 로그인되던 길을 막았다. 운영에서 처음 걸 때는 되돌릴 복구 관리자가 있을 때만 건다(관리자 잠김 방지). - 휴지통 · 스팸 30일 정리를 KST 03:00 으로 명시. imapsync 로 옮긴 메일도 옮긴 때부터 센다(임시 엔진 실측). - restic 백업(호스트 systemd 03:30 — 메일 볼륨 warm → 엔진 멈춤 9초 안팎 → final, pg_dump, 업로드, env) · 복원(새 서버 차례를 빈 클론에서 실측) · 15분 점검 · 부팅 되살리기, Healthchecks 하트비트. - 메일 프로그램 자동 설정: frontend nginx 의 autoconfig · autodiscover 고정 응답(엔진 것은 POP3 를 알리고 입력을 되돌려 쓴다). - 모든 서비스 docker 로그 보존, DB 이미지 판 · digest 고정. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+376
-19
@@ -49,6 +49,7 @@ app-network(신뢰 대역)에 붙이지 않는다 ([악성 검사 (ClamAV)](#악
|
||||
| dev 오버라이드 | `docker-compose.dev.yml` | 운영 포트 제거, `127.0.0.1:${MAIL_ADMIN_PORT}` → 8080 만 |
|
||||
| `mailpit` | `docker-compose.dev.yml` 전용 (profile `mail`) | 비-root(65534), UI `127.0.0.1:${MAILPIT_UI_PORT}` |
|
||||
| setup 스크립트 | `mail/scripts/setup.mjs` | 아래 "setup 스크립트가 하는 일" |
|
||||
| 백업 · 15분 점검 (13단계) | `ops/` — 스크립트 `backup/` · `monitor/` · `lib/`, 호스트의 systemd 유닛 `systemd/`, 설정 예시 `ops.env.example` | 운영 서버의 호스트에서 돈다(컨테이너가 아니다) — [백업](#백업) · [감시](#감시-13단계) |
|
||||
|
||||
| 포트 | 운영 | 개발 |
|
||||
| --- | --- | --- |
|
||||
@@ -58,8 +59,8 @@ app-network(신뢰 대역)에 붙이지 않는다 ([악성 검사 (ClamAV)](#악
|
||||
|
||||
| 계정 | 용도 | 비밀번호 |
|
||||
| --- | --- | --- |
|
||||
| `<MAIL_ADMIN_LOCAL_PART>@<MAIL_DOMAIN>` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 | `MAIL_ADMIN_PASSWORD` |
|
||||
| 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다 | `MAIL_RECOVERY_ADMIN` |
|
||||
| `<MAIL_ADMIN_LOCAL_PART>@<MAIL_DOMAIN>` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다. 13단계부터 **대리 접근 권한이 없고 앱 네트워크 안에서만 로그인된다** ([관리자 계정 단단히](#관리자-계정-단단히)) | `MAIL_ADMIN_PASSWORD` |
|
||||
| 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다. 역할 · 허용 IP 를 보지 않아 관리자가 막혔을 때 되살리는 길이다 | `MAIL_RECOVERY_ADMIN` |
|
||||
| `MAIL_GATEWAY_USER` | 백엔드의 대리 접근 전용 서비스 계정 — 역할 권한은 `authenticate`·`impersonate` 뿐, 신뢰 대역(`APP_NETWORK_SUBNET`)에서만 로그인 | `MAIL_GATEWAY_PASSWORD` |
|
||||
| `<MAIL_DIRECTORY_LOCAL_PART>@<MAIL_DOMAIN>` (기본 `mail-directory@`) | 사내 목록 계정 — 백엔드가 엔진의 계정 목록(Principal)에서 직원 이름·주소만 읽는다. 역할 권한은 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 뿐, 게이트웨이 대리 접근으로만 쓰고 메일을 받지 않는다. **복구 관리자로만 만들어진다** ([사내 목록 계정](#사내-목록-계정)) | 없음 |
|
||||
| `ara.kim@` 김아라 · `doyun.lee@` 이도윤 · `seojun.park@` 박서준 | **개발 전용** 테스트 직원 | `MAIL_DEV_USER_PASSWORD` |
|
||||
@@ -124,6 +125,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
**서브넷보다 넓거나 밖에 있는 대역이 들어오면 실패한다.** 이 스크립트가 만든 예전 항목은 지운다.
|
||||
5. **게이트웨이** — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만
|
||||
6. **잠금** — 직원(`User`) 역할에서 Principal 조회 권한 13개(`jmapPrincipal*`·`davPrincipal*`)를 뺀다(→ `InvalidateCaches`).
|
||||
**관리자 단단히**(13단계) — 관리자 역할(System Administrator)의 빼는 권한에 `impersonate` 를 두고(→ `InvalidateCaches`), 영구 관리자 비밀번호의 허용 IP 를 `APP_NETWORK_SUBNET` 으로 묶는다
|
||||
(이 실행의 관리 API 주소가 앱 네트워크 안으로 보일 때만 — 건 뒤 다시 로그인해 보고, 막혔으면 복구 관리자로 되돌린다). 리로드 뒤 관리자의 실제 권한(`/api/account`)에 `impersonate` 가 없는지 본다
|
||||
([관리자 계정 단단히](#관리자-계정-단단히))
|
||||
공개 443(https 리스너)의 HTTP 는 `/.well-known/mta-sts.txt` 만 열고 나머지는 403 (`x:Http` 의 `allowedEndpoints`) — [보안](#보안)
|
||||
메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — 받아 놓고 반송하지 않게
|
||||
(`x:MtaStageRcpt` 의 `script` + 시스템 Sieve 스크립트 — [보안](#보안) "메일을 받지 않는 시스템 계정")
|
||||
@@ -138,6 +142,8 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
스팸 필터의 Spamhaus 두 항목은 키(`MAIL_SPAMHAUS_DQS_KEY`)가 있고 시험을 통과하면 DQS 로 켜고 아니면 끈다(공개 미러는 쓰지 않는다 — [DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)).
|
||||
**엔진 웹훅**(12단계) — 인증 이벤트 셋을 백엔드의 받는 곳(`http://backend:<BACKEND_PORT>/api/internal/mail-engine-events`)으로 `MAIL_WEBHOOK_SECRET` 로 서명해 보내는
|
||||
`x:WebHook` 하나를 맞춘다(setup 표시 머리글로 찾는다 — 키가 비면 운영은 중단, 개발은 `[주의]` 뒤 있던 것을 끈다 — [엔진 웹훅](#엔진-웹훅--메일-프로그램-로그인-기록)).
|
||||
**휴지통 · 스팸메일함 30일**(13단계) — `x:DataRetention` 의 `expungeTrashAfter` 30일과 정리 시각(계정 정리 KST 03:00 · 데이터 04:00 · 원문 04:30 — 컨테이너 시각으로 바꿔 넣는다)
|
||||
([휴지통 · 스팸메일함 자동 비우기](#휴지통--스팸메일함-자동-비우기)).
|
||||
모두 9 의 리로드로 반영된다
|
||||
9. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다.
|
||||
운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 —
|
||||
@@ -282,6 +288,10 @@ 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 비밀번호(비밀번호 관리자에 따로 보관),
|
||||
Healthchecks.io 점검 둘 · UptimeRobot 감시 셋 ([백업](#백업) · [감시](#감시-13단계)).
|
||||
`.env.production` 의 `DB_IMAGE` 를 예시와 같은 값(판 · digest 고정 — 옛 파일은 `postgres:15-alpine`)으로 바꾼다 — 다음 `up -d` 에서 db 가 다시 만들어진다(몇 초 · 데이터는 볼륨 그대로, 같은 메이저 15 라 덤프 · 복원은 필요 없다).
|
||||
frontend 는 `MAIL_DOMAIN` · `MAIL_HOSTNAME` 으로 [메일 프로그램 자동 설정](#메일-프로그램-자동-설정) 응답을 만든다 — 이미 있는 값이라 새로 넣을 것은 없다
|
||||
|
||||
복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다.
|
||||
|
||||
@@ -295,6 +305,7 @@ sudo -E docker compose --env-file .env.production up -d --build
|
||||
|
||||
# 3) setup --recovery — 호스트에서, docker 를 쓸 수 있는 사용자로 실행한다 (운영은 docker 가 없으면 아무것도 바꾸지 않고 실패한다).
|
||||
# 8080 을 공개하지 않으므로 mail 컨테이너의 내부 주소로 붙는다 (리눅스 호스트는 컨테이너 IP 로 바로 닿는다).
|
||||
# 앱 네트워크의 주소여야 한다 — 13단계부터 관리자는 그 대역에서만 로그인된다(다른 네트워크 주소면 403 — "관리자 계정 단단히")
|
||||
export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080"
|
||||
# (A) 호스트 Node (24 LTS 권장, 최소 20.6 — --env-file) — docker 그룹이면 그대로:
|
||||
node --env-file=.env.production mail/scripts/setup.mjs --recovery
|
||||
@@ -335,13 +346,16 @@ node --env-file=.env.production mail/scripts/setup.mjs
|
||||
3. backend 가 healthy 가 된 뒤(`sudo docker compose --env-file .env.production ps backend`) 3)처럼 `MAIL_ADMIN_URL` 을 구해 setup(복구 관리자 없이 — 5 와 같은 형태) → `[생성] 엔진 웹훅` · `[확인] 엔진 웹훅 — … 받았다`
|
||||
4. [운영 확인 (12단계)](#운영-확인-12단계) — 메일 프로그램으로 한 번 로그인해 로그인 기록에 IMAP 줄이 생기는지 본다
|
||||
- 운영 setup 은 끝에 **권장 DNS 레코드**(DKIM·SPF·MX·DMARC·SRV·MTA-STS·autoconfig)를 출력한다. 그대로 붙이지 말고 아래 전환 절차 순서를 따른다.
|
||||
`autoconfig` · `autodiscover` 의 권장값(엔진 — mx1)은 쓰지 않는다 — 13단계부터 메일 프로그램 자동 설정은 엔진이 아니라 포털 쪽 nginx 가 답한다 ([메일 프로그램 자동 설정](#메일-프로그램-자동-설정)).
|
||||
- **TLS 인증서 이름을 `MAIL_HOSTNAME` 하나로 정한다** (2026-10-31 운영에서 겪음). bootstrap 이 켠 도메인의 자동 인증서(ACME)는 이름을 정하지 않으면 기본 묶음 —
|
||||
`mta-sts` · `autoconfig` · `autodiscover` · `ua-auto-config` · MX 이름 · 호스트 이름 — 을 **한 주문**으로 요청하고, ACME 는 하나라도 실패하면 전체가 실패한다.
|
||||
전환 전에는 그 이름들이 옛 서버(mailcow)를 가리키거나 DNS 에 없으므로 mx1 까지 인증서를 받지 못하고, 엔진은 993 · 465 · 25(STARTTLS)에 자체 서명 인증서(`CN=rcgen self signed cert`)를 낸다.
|
||||
관리 화면 Domains › `arasystem.it.kr` › Certificate Management 의 **Additional Hostnames** 에 `mx1`(도메인은 엔진이 붙인다 — Enter 로 항목을 만든 뒤 저장)만 넣고,
|
||||
Management › Tasks › Scheduled 에서 **AcmeRenewal** 을 **한 번** 건다(재시작만으로는 바로 요청하지 않는다. 걸 때마다 새로 발급하니 여러 번 걸지 않는다 — Let's Encrypt 같은 인증서 주에 5번).
|
||||
확인: `docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep -i acme` 에 `hostname = ["mx1.arasystem.it.kr"]` · `acme.order-completed`, 바깥에서 `openssl s_client -connect mx1.arasystem.it.kr:993` 의 발급자가 Let's Encrypt.
|
||||
**전면 전환** 때 `autoconfig` · `autodiscover` · `mta-sts` 의 DNS 를 새 서버로 옮긴 뒤 그 이름들을 이 목록에 더하고 AcmeRenewal 을 한 번 더 건다.
|
||||
**전면 전환** 때 `mta-sts` 의 DNS 를 새 서버로 옮긴 뒤(MTA-STS 를 게시할 때 — 아래 전환 절차 "주의할 레코드") 그 이름을 이 목록에 더하고 AcmeRenewal 을 한 번 더 건다.
|
||||
`autoconfig` · `autodiscover` 는 **넣지 않는다** — 그 이름은 서버 A 의 외부 프록시(NPM)가 인증서를 받고 이 서버의 frontend nginx 가 답한다 ([메일 프로그램 자동 설정](#메일-프로그램-자동-설정)).
|
||||
엔진 쪽에 넣으면 그 이름이 엔진을 가리키지 않아 ACME 주문 전체가 실패하고 mx1 인증서까지 갱신되지 않는다.
|
||||
- 공개 443 차단은 setup 이 컨테이너 안과 `MAIL_PUBLIC_URL`(호스트에서) 두 곳에서 확인한다. 호스트에서 자기 공인 주소로 되돌아오는 경로가 막힌 환경이면
|
||||
경고만 남기므로, 그때는 바깥에서 확인한다: `curl -s -o /dev/null -w '%{http_code}\n' https://<MAIL_HOSTNAME>/jmap/session` → `403`.
|
||||
|
||||
@@ -373,8 +387,8 @@ openssl s_client -quiet -connect mx1.arasystem.it.kr:993
|
||||
# 메일 호스트에서
|
||||
sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep 'auth.success' | grep 'imaps' | tail -3
|
||||
```
|
||||
- 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 내부 8080 에 붙는다:
|
||||
`ssh -L 8180:<mail 컨테이너 IP>:8080 <메일 호스트>` → <http://localhost:8180/admin/>. setup 은 운영에서 직원 계정을 만들지 않는다.
|
||||
- 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 mail 컨테이너의 **앱 네트워크 주소**:8080 에 붙는다
|
||||
(13단계부터 관리자는 앱 네트워크 안에서만 로그인된다 — 명령은 [관리 화면 — SSH 터널](#관리-화면--ssh-터널)). setup 은 운영에서 직원 계정을 만들지 않는다.
|
||||
⚠️ **공용 주소(Group 계정)는 일반 관리자로 만들 수 없다** — Group 역할에 관리자 역할에는 없는 Principal 권한 13개가 있어
|
||||
`You are not authorized to grant permissions: jmapPrincipalCreate, …` 로 거절된다(2026-10-29 확인, 직원 계정 · 그룹에 구성원 넣기는 관리자로 된다).
|
||||
공용 주소를 만들 때는 복구 관리자를 쓴다 ("운영 배포" 1)·`up -d mail` 뒤 그 아이디로 관리 화면 · 관리 API → 4)·5) 로 끈다).
|
||||
@@ -776,6 +790,7 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne
|
||||
| backend | 1g | 0.3~0.5GB | 조각 · 받기는 스트림이라 파일 크기와 상관없다 |
|
||||
| db · frontend · mail-resolver | 512m · 512m · 128m | 작다 | |
|
||||
| **합** | **6.15GB** | 2.5~3GB | 나머지 1.8GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 |
|
||||
| restic (백업 — 13단계, compose 밖) | 1g | 돌 때만 — 매일 03:30 몇 분 | 0.63GB (데이터 1.2GB — 2026-10-31 MinIO). 그때는 합이 7.15GB — 사용이 적은 새벽이라 둔다. 복원 연습은 엔진 1g · DB 512m 를 더 띄운다(한 달에 한 번 — 낮에 하지 않는다) |
|
||||
|
||||
- **ClamAV 의 `ConcurrentDatabaseReload` 를 켜지 않는다**(기본은 켬) — 켜면 서명을 다시 읽는 동안(하루 몇 번) 두 벌(2GB 남짓)을 들어 2g 한도에서 죽는다.
|
||||
한도를 올려 켜려면 합이 8GB 를 넘지 않는지 다시 본다.
|
||||
@@ -914,7 +929,7 @@ unset OTP_ADMIN_PW OTP_TARGET
|
||||
켤 때의 주소는 계약대로 이름 `ARA SYSTEM`(띄어쓰기)이었고 엔진이 그 주소로 코드를 확인했다(직원 경로로 끄기도 됨). 끝나고 처음 상태(OTP 꺼짐 · 앱 비밀번호 0개 · 비밀번호 그대로)로 되돌렸다.
|
||||
- 웹메일의 "켠 때"(`enabledAt`)는 서버가 엔진에서 꺼진 것을 보면 지운다 (계약 5절 "2단계 인증").
|
||||
|
||||
**2) 관리 화면** (운영은 SSH 터널 — `ssh -L 8180:<mail 컨테이너 IP>:8080 <메일 호스트>` → <http://localhost:8180/admin/>, [운영 배포](#운영-배포) 아래 "실제 직원 계정" 줄) — **직접 눌러 보지 못했다**:
|
||||
**2) 관리 화면** (운영은 SSH 터널 — [관리 화면 — SSH 터널](#관리-화면--ssh-터널)) — **직접 눌러 보지 못했다**:
|
||||
|
||||
1. Management › Directory › **Accounts** 에서 그 직원을 연다.
|
||||
2. Authentication 의 **Credentials** 에서 비밀번호 자격 증명(Password)을 연다.
|
||||
@@ -936,6 +951,161 @@ unset OTP_ADMIN_PW OTP_TARGET
|
||||
|
||||
---
|
||||
|
||||
## 메일 운영 설정 (13단계 — 인프라)
|
||||
|
||||
전환 준비(13단계)에서 setup · compose · nginx 가 더 맡은 것이다. 백업 · 감시 · 로그는 따로 — [백업](#백업) · [감시](#감시-13단계) · [로그 보존](#로그-보존-13단계).
|
||||
|
||||
### 13단계 전부터 운영 중인 서버
|
||||
|
||||
이미 떠 있는 운영 서버(서버 B)에 13단계를 거는 차례다. **2026-11-04 뒤에** 한다 — 고정한 DB 이미지(digest)가 그날 2주가 찬다(`.env.production.example` 의 `DB_IMAGE` 주석).
|
||||
모든 컨테이너가 다시 만들어진다(로그 한도 · 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
|
||||
# 2) .env.production 의 DB_IMAGE 를 예시와 같은 고정값으로 (옛 값 postgres:15-alpine) — 편집기로 그 한 줄만
|
||||
grep -n '^DB_IMAGE=' .env.production .env.production.example
|
||||
# 3) 복구 관리자를 일회성으로 준 채 전부 다시 만든다 — 관리자 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 setup 이 건다(막히면 되돌릴 길)
|
||||
read -rsp '임시 복구 비밀번호: ' P; echo; export MAIL_RECOVERY_ADMIN="admin:$P"
|
||||
sudo -E docker compose --env-file .env.production up -d --build
|
||||
# 4) mail 의 앱 네트워크 주소가 바뀌었다 — 다시 구한다 ("운영 배포" 3 과 같다)
|
||||
export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080"
|
||||
# 5) 첫 setup 은 --recovery — 끝에 "복구 관리자 끄기"를 안내하고 종료 3
|
||||
node --env-file=.env.production mail/scripts/setup.mjs --recovery
|
||||
# 6) 복구 관리자 끄기 → mail 다시 만들기 → 주소 다시 → 보통 setup (모두 [유지] 와 [확인] 관리자 줄)
|
||||
unset MAIL_RECOVERY_ADMIN P; sudo docker compose --env-file .env.production up -d mail
|
||||
export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080"
|
||||
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)부터).
|
||||
- 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-터널)로만 들어간다.
|
||||
|
||||
### 관리자 계정 단단히
|
||||
|
||||
엔진의 기본 관리자 역할(System Administrator)에는 대리 접근(`impersonate`)이 있어, 관리자 비밀번호 하나로 `<직원 주소>%<관리자 주소>` 로그인 — **모든 직원의 메일함** — 이 됐다.
|
||||
setup 이 두 가지를 걸고 매번 확인한다(되돌려 두었으면 다시 건다).
|
||||
|
||||
| 무엇 | setup 이 하는 일 | 효과 |
|
||||
| --- | --- | --- |
|
||||
| 대리 접근 빼기 | 관리자 역할의 빼는 권한(`disabledPermissions`)에 `impersonate` → `InvalidateCaches` | 관리자 비밀번호로는 어디서든 대리 접근이 403(`security.unauthorized … details = "impersonate"`). 관리 화면 · 관리 API 는 그대로 |
|
||||
| 허용 IP | 영구 관리자 비밀번호 자격의 `allowedIps` = `APP_NETWORK_SUBNET` | 바깥(993 · 465 · 공개 443 — 게시 포트의 접속은 원래 주소로 보인다)에서 관리자 비밀번호 로그인이 403(`security.ip-unauthorized` "IP address not allowed."). 관리자 비밀번호가 통하는 곳은 서버 안(호스트에서 도는 setup · SSH 터널 · 앱 네트워크의 컨테이너)뿐이다 |
|
||||
|
||||
- 확인 줄 — `[확인] 관리자 … — 대리 접근 권한 없음 (권한 N개에 impersonate 없음) · 허용 IP <대역> 안에서 로그인됨`(관리자 세션의 실제 권한 `GET /api/account` 로 본다).
|
||||
- 역할의 빼기가 계정 쪽 권한보다 앞선다 — 관리자가 자기 계정에 `impersonate` 를 더해도(Merge — 저장은 된다) 대리 접근은 403 이고, 관리자는 스스로 되돌리지 못한다
|
||||
(역할의 빼기 지우기 · 게이트웨이 역할 받기 · `impersonate` 를 주는 새 역할 만들기가 모두 `You are not authorized to grant permissions: impersonate`). 되돌리는 길은 복구 관리자뿐이다.
|
||||
- 허용 IP 는 이 실행의 관리 API 주소가 앱 네트워크 안으로 보일 때만 건다 — 루프백(개발 포트 매핑) · `mail` · 앱 네트워크 안의 IPv4(운영의 `MAIL_ADMIN_URL` — "운영 배포" 3).
|
||||
**운영에서 처음 걸 때는 복구 관리자가 있어야 건다**(`--recovery` — 걸자마자 막히면 되돌릴 길이 그것뿐이다). 걸지 못하면 운영은 종료 1, 개발은 `[주의]` 다.
|
||||
건 뒤 관리자로 다시 로그인해 보고, 막혔으면 복구 관리자로 되돌린다.
|
||||
- 운영자가 대역 안으로 **더 좁힌 값**(예: 관리 화면에서 `172.16.71.1` 하나)은 넓히지 않고 `[유지]` 한다. 대역 밖 항목이 하나라도 있으면(옛 대역 · `0.0.0.0/0`) 대역 하나로 바꾼다.
|
||||
- **`APP_NETWORK_SUBNET` 을 바꾸면** 관리자 허용 IP 가 옛 대역에 남아 setup 이 `관리자(…) 로그인이 HTTP 403 입니다` 로 멈춘다 →
|
||||
복구 관리자를 일회성으로 주고 `--recovery` 로 돌리면 지금 대역으로 고친다(`[변경] 관리자 … — 허용 IP <옛 대역> → <새 대역> …` — "운영 배포" 1 · 3 · 4 · 5 차례). 개발은 env 에 복구 관리자가 있어 보통 실행이 고친다.
|
||||
허용 IP 에 지금 대역 안의 항목이 있는데(좁힌 값) 403 이면 고치지 않고 멈춘다 — 이 실행의 주소가 좁힌 값 밖이다(`MAIL_ADMIN_URL` · 관리 화면에서 넓히기).
|
||||
- 관리자의 **다른 자격**(앱 비밀번호 · API 키 — 엔진은 비밀번호 자격을 계정마다 하나만 받는다)의 허용 IP 가 대역 밖이거나 없으면 `[주의]` — 그 자격으로 바깥에서 관리자가 된다.
|
||||
쓰지 않으면 관리 화면 › 계정 › 관리자 › Credentials 에서 지운다.
|
||||
- 다른 관리자 역할 계정 — setup 은 비밀번호를 몰라 걸지 않고 `[주의]` 를 낸다. 관리 화면 › 계정 › 그 계정 › Credentials 의 Allowed IPs 를 같은 대역으로 두거나, 쓰지 않으면 지운다.
|
||||
- **그대로인 것** — 게이트웨이(`MAIL_GATEWAY_*`)는 여전히 모든 직원과 관리자까지 대리 접근한다(백엔드가 막는다 — [보안](#보안)). 관리자는 직원의 비밀번호를 바꿀 수 있다(그러면 직원이 로그인하지 못해 드러난다).
|
||||
복구 관리자는 역할 · 허용 IP 를 보지 않는다 — 운영은 그래서 파일에 두지 않는다.
|
||||
|
||||
시험 (0.16.27 — 2026-10-31 임시 엔진 · 2026-11-02 다시, 개발 엔진):
|
||||
|
||||
| 어디서 | 무엇 | 걸기 전 | 건 뒤 |
|
||||
| --- | --- | --- | --- |
|
||||
| 서버 안(앱 네트워크) | 관리자 세션 · 관리 API(`x:Account/query`) | 200 | 200 |
|
||||
| 서버 안 | `<직원>%<관리자>` (JMAP · IMAP) | 200 · 로그인 | 403 · 실패 |
|
||||
| 서버 안 | `<직원>%<게이트웨이>` (웹메일이 쓰는 길) | 200 | 200 |
|
||||
| 바깥(다른 네트워크) | 관리자 본인 JMAP · IMAP | 200 · 로그인 | 403 · 실패 |
|
||||
| 바깥 | 직원 본인 JMAP (대조) | 200 | 200 |
|
||||
| 호스트 → 앱 네트워크 주소 (SSH 터널) | 관리자 세션 · 관리 화면 `/admin/` | 200 | 200 · 200 (엔진이 본 주소 `172.16.71.1` — 앱 네트워크의 게이트웨이) |
|
||||
| 호스트 → 리졸버 네트워크(mail-dns) 주소 | 관리자 세션 | 200 | 403 (`remoteIp = 172.16.73.1` · IP address not allowed) |
|
||||
|
||||
#### 관리 화면 — SSH 터널
|
||||
|
||||
관리 화면 · 관리 API(8080)는 바깥에 열지 않는다 — SSH 로 서버 B 에 붙어 mail 컨테이너의 **앱 네트워크 주소**로 터널을 연다.
|
||||
호스트에서 컨테이너 주소로 가는 연결은 그 네트워크의 게이트웨이 주소(`APP_NETWORK_SUBNET` 의 `.1`)로 나가 관리자 허용 IP 안에 든다.
|
||||
mail 컨테이너의 다른 주소(리졸버 네트워크 `172.16.73.x`)로 열면 403 이다.
|
||||
|
||||
```bash
|
||||
# 1) 서버 B 에서 — mail 컨테이너의 앱 네트워크 주소 (mail 을 다시 만들면 바뀐다 — 열 때마다 본다)
|
||||
sudo docker inspect -f '{{index .NetworkSettings.Networks "arasystem_app-network" "IPAddress"}}' arasystem-mail
|
||||
# 2) 노트북에서 — 서버 A 를 거쳐(-J) 서버 B 로. 로컬 포트는 127.0.0.1 에만
|
||||
ssh -N -J <서버 A> -L 127.0.0.1:8180:<1의 주소>:8080 <서버 B>
|
||||
# 3) 브라우저 http://localhost:8180/admin/ — 관리자 주소 · MAIL_ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
- 서버 B(와 `-J` 로 거치는 서버 A)의 sshd 가 TCP 전달을 허락해야 한다(`AllowTcpForwarding` — Ubuntu 기본 yes. 꺼 두었으면 `administratively prohibited`). Windows 의 OpenSSH 도 같은 명령이다.
|
||||
- 개발은 터널 없이 <http://localhost:8180/admin/>(포트 매핑 — 앱 네트워크의 게이트웨이 주소로 보인다).
|
||||
|
||||
### 휴지통 · 스팸메일함 자동 비우기
|
||||
|
||||
setup 이 엔진의 `x:DataRetention` 을 맞춘다 — 휴지통 · 스팸메일함에 **들어온 때부터 30일**(`expungeTrashAfter`)이 지난 메일을 엔진의 계정 정리가 영구 삭제한다.
|
||||
|
||||
- 기준은 받은 시각이 아니라 그 메일함에 들어온 시각이다(엔진 안의 삭제 시각 칸 — 0.16.27 `email/src/message/delete.rs`). 받은편지함에도 함께 든 메일은 통째로 지워진다.
|
||||
- **옮겨 온 메일(imapsync)도 옮긴 때부터 센다** — 엔진은 휴지통 · 스팸메일함에 든 순간의 시각(`now()`)을 그 칸에 쓴다(0.16.27 `email/src/message/index/mod.rs` — 내부 날짜가 아니다).
|
||||
시험(2026-11-02 임시 엔진, 기간 70초): 내부 날짜 2025-01-01 로 휴지통 · 스팸메일함에 IMAP APPEND → 바로 정리해도 남고, 70초 뒤 정리에 지워졌다(받은편지함의 같은 날짜 메일은 그대로).
|
||||
그래서 이전 다음 날 30일 넘은 옛 휴지통 메일이 한꺼번에 지워지지 않는다 — 이전한 날부터 30일 뒤에 지워진다(직원에게 알릴 거리).
|
||||
- 자동 비우기는 **엔진의 휴지통 · 스팸메일함(특수 메일함 — "Deleted Items" · "Junk Mail") 둘에만** 걸린다. 이름이 같은 다른 폴더(imapsync 가 "Trash" 를 따로 만든 경우)는 지워지지 않는다 —
|
||||
[imapsync](#imapsync) 의 메일함 맞추기를 본다.
|
||||
- 엔진 기본값도 30일이지만 기대지 않고 맞추고, 관리 화면에서 바꾼 값은 되돌린다(`[변경]`). 휴지통에서 지운 메일도 [백업](#백업)의 보존 기간(최대 6개월)까지는 백업에 남는다.
|
||||
- **정리 시각을 새벽으로 옮긴다** — 엔진의 정리 작업 셋은 메일 컨테이너의 현지 시각(UTC — compose 가 TZ 를 주지 않는다)으로 돌아 기본값(00:00 · 02:00 · 04:00)이 한국 시각 09:00 · 11:00 · 13:00 이었다.
|
||||
|
||||
| 작업 | 엔진 칸 | 한국 시각 | 컨테이너(UTC)에 넣는 값 |
|
||||
| --- | --- | --- | --- |
|
||||
| 계정 정리 — 휴지통 · 스팸 30일 | `expungeSchedule` | 03:00 | 18:00 |
|
||||
| (백업 — 메일을 몇 초 멈춘다) | — | 03:30 | — |
|
||||
| 데이터 정리 | `dataCleanupSchedule` | 04:00 | 19:00 |
|
||||
| 원문(blob) 정리 | `blobCleanupSchedule` | 04:30 | 19:30 |
|
||||
|
||||
- setup 은 컨테이너의 UTC 차이(`date +%z`)로 바꿔 넣는다. 바뀐 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 다시 뜬 뒤)부터다 — 엔진이 다음 시각을 기동 때와 실행 직후에만 정한다.
|
||||
- 확인 — setup 요약의 `휴지통 들어온 지 30일 · 정리 KST 03:00 · 04:00 · 04:30`, 다음 날 엔진 로그의 그 시각(UTC) 작업 줄.
|
||||
|
||||
### 메일 프로그램 자동 설정
|
||||
|
||||
메일 프로그램(Thunderbird · 클래식 Outlook 등)에 주소만 넣으면 서버 · 포트가 채워지게, **포털의 frontend nginx 가** `autoconfig.<MAIL_DOMAIN>` · `autodiscover.<MAIL_DOMAIN>` 에 고정 응답을 낸다(B안).
|
||||
엔진의 같은 경로는 쓰지 않는다 — 요청에 실린 주소를 이스케이프 없이 XML 에 되돌려 넣고, 열지 않은 POP3 · DAV 까지 알린다. 엔진의 공개 443 은 그대로 닫아 둔다.
|
||||
|
||||
| 경로 (그 두 호스트에서만) | 누가 묻나 | 응답 |
|
||||
| --- | --- | --- |
|
||||
| `GET /mail/config-v1.1.xml` · `GET /.well-known/autoconfig/mail/config-v1.1.xml` | Thunderbird · Thunderbird for Android(K-9) · Evolution 등 | IMAP `MAIL_HOSTNAME`:993 SSL · SMTP :465 SSL, 사용자 이름 `%EMAILADDRESS%`(프로그램이 채운다) |
|
||||
| `POST /autodiscover/autodiscover.xml` (대소문자 무관) | 클래식 Outlook (POX) | 같은 IMAP · SMTP. `LoginName` 은 주지 않는다(요청 값을 되돌려 쓰지 않으려고) — 그러면 Outlook 은 `@` 앞부분으로 로그인하는데 엔진은 그것도 받는다(IMAP · SMTP · 앱 비밀번호 — 2026-10-31 임시 엔진) |
|
||||
| 그 밖 | — | 404 (`/api` 도 — 백엔드로 넘기지 않는다) |
|
||||
|
||||
- 요청 값(질의의 `emailaddress` · POST 본문)은 읽지 않는다 — 응답은 늘 같다. 접근 로그는 쿼리 없이(주소가 질의에 실린다), 본문 한도 64k.
|
||||
- 값은 루트 env 의 `MAIL_DOMAIN` · `MAIL_HOSTNAME`(compose 가 frontend 에 넘긴다 — `frontend/nginx-env.envsh` 가 검사). 하나라도 비면 이 server 의 이름은 어느 요청에도 맞지 않는 `autoconfig.invalid` 다.
|
||||
모양이 틀리거나(영문 · 숫자 · `-` · `.` 의 점 있는 이름만 — 소문자로 바꿔 쓴다), `autoconfig.` · `autodiscover.<MAIL_DOMAIN>` 이 공개 내려받기 호스트나 `CORS_ORIGIN` 의 호스트와 같으면 frontend 가 뜨지 않는다(fail-fast — `docker logs arasystem-frontend` 의 `nginx-env.envsh:` 줄).
|
||||
- CI 의 infra 잡이 렌더링(server 이름 · 호스트 이름)과 실제 요청(주소를 꾸민 질의 · POST 가 응답에 들어가지 않는지, 그 밖 경로 404, 포털 · 공개 호스트에서는 열리지 않는지)을 본다.
|
||||
|
||||
**전면 전환 때 할 일** — ⚠️ **지금은 DNS 를 바꾸지 않는다.** 두 이름은 아직 mailcow 를 가리키고 mailcow 직원이 쓰고 있다(전환 체크리스트의 일):
|
||||
|
||||
1. **미리 확인** (DNS 를 바꾸기 전 — 서버 A 에서 서버 B 의 사설 주소로): `curl -s -H 'Host: autoconfig.arasystem.it.kr' http://<서버 B 사설 IP>:8080/mail/config-v1.1.xml | grep -E 'hostname|port'` → `mx1.arasystem.it.kr` · 993 · 465.
|
||||
2. **NPM** (서버 A): Proxy Host 둘 — `autoconfig.arasystem.it.kr` · `autodiscover.arasystem.it.kr` → `http://<서버 B 사설 IP>:8080`(포털과 같은 곳, Host 는 그대로 — NPM 기본).
|
||||
접근 로그는 끈다(Thunderbird 는 질의에 메일 주소를 싣는다). 인증서는 3 다음에.
|
||||
3. **DNS** (가비아): `autoconfig` · `autodiscover` A → 서버 A 의 공인 IP(지금 mailcow 를 가리키는 레코드를 바꾼다). TTL 은 다른 전환 레코드와 함께 미리 낮춘다.
|
||||
전파된 뒤 NPM 의 두 Proxy Host 에서 SSL 탭 › Let's Encrypt 인증서(HTTP 검증 — 그래서 DNS 가 먼저다) · Force SSL.
|
||||
이 이름들의 인증서는 NPM 이 갖는다 — 엔진의 인증서 이름(Additional Hostnames)에는 넣지 않는다("운영 배포"의 TLS 인증서 줄).
|
||||
4. **SRV** (RFC 6186 · RFC 8314 — 지원하는 프로그램이 쓴다):
|
||||
|
||||
| 이름 | 값 | 뜻 |
|
||||
| --- | --- | --- |
|
||||
| `_imaps._tcp.arasystem.it.kr` | `SRV 0 1 993 mx1.arasystem.it.kr.` | IMAP — 처음부터 TLS |
|
||||
| `_submissions._tcp.arasystem.it.kr` | `SRV 0 1 465 mx1.arasystem.it.kr.` | 보내기 — 처음부터 TLS (465) |
|
||||
| `_submission._tcp` · `_imap._tcp` · `_pop3._tcp` · `_pop3s._tcp` | `SRV 0 0 0 .` | 열지 않음 — `_submission` 은 587(STARTTLS)용이라 465 를 적으면 프로그램이 465 에 STARTTLS 를 시도해 실패한다 |
|
||||
|
||||
가비아가 대상 `.` 을 받지 않으면 "열지 않음" 줄은 빼도 된다(없으면 프로그램이 다음 방법으로 넘어간다).
|
||||
5. **확인** — 바깥에서 `curl -s https://autoconfig.arasystem.it.kr/mail/config-v1.1.xml | grep hostname`,
|
||||
`curl -s -X POST -H 'Content-Type: text/xml' --data '<x/>' https://autodiscover.arasystem.it.kr/autodiscover/autodiscover.xml | grep Server`,
|
||||
`dig +short SRV _imaps._tcp.arasystem.it.kr`. Thunderbird · 클래식 Outlook 으로 새 계정을 한 번씩 만들어 본다(2단계 인증을 켠 직원은 앱 비밀번호).
|
||||
클래식 Outlook 은 도메인 자체(`https://arasystem.it.kr/autodiscover/autodiscover.xml` — 회사 홈페이지)를 먼저 묻는다 — 거기서 오래 붙잡히거나 인증서 경고가 나지 않는지도 본다.
|
||||
새 Outlook · 휴대폰 기본 메일 앱은 자기 방식(마이크로소프트 · 제조사의 자동 감지)을 써서 이 응답을 쓰지 않을 수 있다 — 그때는 직원이 서버를 손으로 넣는다(값은 웹메일 설정 › 앱 비밀번호의 메일 프로그램 설정 표).
|
||||
|
||||
---
|
||||
|
||||
## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저)
|
||||
|
||||
전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다.
|
||||
@@ -1293,7 +1463,8 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul
|
||||
- **DMARC** — setup 이 출력하는 권장값은 `p=reject; rua=mailto:postmaster@…` 다. 전환 중에는 **지금의 `p=none`·`rua=dmarc@` 를 유지**하고,
|
||||
리포트로 두 서버 모두 통과하는 것을 본 뒤 `quarantine` → `reject` 로 올린다.
|
||||
- **MTA-STS** — 정책이 MX 와 어긋나면 MTA-STS 를 지키는 발신자가 배달에 실패한다. **전환이 끝난 뒤** 게시한다.
|
||||
- **SRV·autoconfig·autodiscover** — 메일 클라이언트(IMAP) 자동 설정용. 웹메일만 쓰면 필요 없다.
|
||||
- **SRV·autoconfig·autodiscover** — 메일 프로그램(IMAP) 자동 설정용. setup 이 출력하는 권장값(엔진 · mx1)이 아니라 [메일 프로그램 자동 설정](#메일-프로그램-자동-설정)의 "전면 전환 때 할 일"대로 — autoconfig · autodiscover 는 서버 A(NPM) 로, SRV 는 `_imaps` · `_submissions` → mx1.
|
||||
전환 전에는 그대로 둔다(mailcow 직원이 쓰고 있다). MX 를 바꾸는 날 함께 바꾼다.
|
||||
- **DKIM 자동 교체** — 도메인 설정은 `dkimManagement: Automatic`(`rotateAfter` 90일 · `retireAfter` 7일 · `deleteAfter` 30일)이지만,
|
||||
**0.16.27 은 엔진이 DNS 를 직접 고칠 때(`dnsManagement: Automatic` + DKIM 게시)만 교체한다.** DNS 를 손으로(가비아) 관리하는 지금 설정(`dnsManagement: Manual`)에서는
|
||||
활성 키에 교체 일정이 생기지 않아(`x:DkimSignature` 의 `nextTransitionAt` 가 비어 있다) bootstrap 때 만든 두 키를 계속 쓴다
|
||||
@@ -1315,6 +1486,9 @@ imapsync \
|
||||
|
||||
- 원본(mailcow)은 **Dovecot 마스터 계정**으로 직원 비밀번호 없이 읽는다. mailcow 의 마스터 계정 설정 방법과
|
||||
로그인 형식(Dovecot 기본 구분자는 `*`)은 **mailcow 문서를 확인해야 한다** (여기서는 검증하지 않았다).
|
||||
- **휴지통 · 스팸메일함은 엔진의 특수 메일함으로 맞춘다** — 엔진의 이름은 "Deleted Items" · "Junk Mail"(IMAP SPECIAL-USE `\Trash` · `\Junk`)이고 mailcow 는 "Trash" · "Junk" 다.
|
||||
`--automap` 이 특수 용도로 짝짓지만, 한 사람으로 먼저 돌려 새 엔진에 "Trash" · "Junk" 폴더가 따로 생기지 않았는지 본다 — 생겼으면 `--f1f2 'Trash=Deleted Items' --f1f2 'Junk=Junk Mail'` 로 다시.
|
||||
따로 생긴 폴더는 자동 비우기(30일 — [휴지통 · 스팸메일함 자동 비우기](#휴지통--스팸메일함-자동-비우기))가 걸리지 않는다. 옮긴 휴지통 메일의 30일은 옮긴 날부터 센다(같은 절 — 시험함)
|
||||
- 대상(Stalwart)은 계정을 만들 때 정한 초기 비밀번호로 로그인한다. 게이트웨이 대리 접근(`%`)은 HTTP 에서만 검증했고 IMAP 에서는 확인하지 않았다.
|
||||
게이트웨이는 신뢰 대역에서만 로그인되므로 외부에서 쓸 수도 없다.
|
||||
- 직원 비밀번호는 정책 결정이 필요하다 — 새 초기 비밀번호를 배포하고 첫 로그인 때 바꾸게 하거나,
|
||||
@@ -1324,15 +1498,119 @@ imapsync \
|
||||
|
||||
## 백업
|
||||
|
||||
| 볼륨 | 내용 |
|
||||
| --- | --- |
|
||||
| `<PROJECT_NAME>_mail-config` | `config.json` (작다). **없으면 bootstrap 모드로 뜬다** |
|
||||
| `<PROJECT_NAME>_mail-data` | RocksDB — 메일·계정·설정 전체 |
|
||||
| `<PROJECT_NAME>_files` | 대용량 첨부 파일(11단계) — **백업하지 않는다**(보낸 날부터 30일짜리 전송물 · 계약 4절). 기록(이름 · 크기 · 링크)은 DB 백업에 든다 — 복원한 뒤 바이트가 없는 파일은 받는 쪽에 "찾을 수 없음"이다. ⚠️ `docker compose down -v` 가 지운다 |
|
||||
| `<PROJECT_NAME>_clamav-db` | 악성 검사 서명 — 백업하지 않는다. 지우면 `clamav-updater` 가 다시 받는다(1분 안팎) |
|
||||
13단계부터 서버 B 의 호스트가 **매일 03:30(KST)** 혼자 백업한다 — `ops/backup/arasystem-backup.sh` 를 systemd 타이머가 돌리고,
|
||||
암호화한 restic 저장소를 **NCP Object Storage**(서버 밖)에 둔다. 결과는 Healthchecks.io 로 알린다([감시](#감시-13단계)).
|
||||
|
||||
RocksDB 는 실행 중 파일 복사로는 일관성이 보장되지 않으므로 **잠시 멈추고** 받는다.
|
||||
멈춘 동안 외부 서버는 배달을 재시도(보통 수일)하므로 짧은 중지로 메일이 유실되지 않는다.
|
||||
| 무엇 | 어디서 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 메일 엔진 `<PROJECT_NAME>_mail-config` · `_mail-data` | 볼륨 (읽기 전용으로 붙인다) | RocksDB — 메일 · 계정 · 설정 · DKIM 키 · 인증서 전체. `config.json` 이 없으면 bootstrap 모드로 뜬다 |
|
||||
| DB | `pg_dump -Fc` (멈추지 않는다) | 로그인 세션 · 서명 · 설정 · 대용량 첨부 기록 등. 덤프를 `pg_restore -l` 로 읽어 본 뒤 올린다 |
|
||||
| 업로드 `<PROJECT_NAME>_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` |
|
||||
| env 파일 | `.env.production` · `backend/.env.production` | 비밀이 든 파일 — 저장소가 restic 비밀번호로 암호화된다. 새 서버를 되살릴 때 필요하다 |
|
||||
| 받지 않는 것 | — | 대용량 첨부 `_files`(30일짜리 전송물 — 기록은 DB 에 있어 복원하면 바이트가 없는 파일은 "찾을 수 없음"), 악성 검사 서명 `_clamav-db`(다시 받는다), 리졸버 · frontend(상태 없음), 로그 |
|
||||
|
||||
**차례** — RocksDB 는 켜 둔 채 파일을 복사하면 일관되지 않아 잠시 멈춘다. 멈추는 시간을 줄이려고 두 번 올린다:
|
||||
|
||||
0. 사전 확인 — 메일 엔진 · DB 가 healthy 인지. 부팅 직후 타이머가 놓친 백업을 바로 할 때(`Persistent=true`)는 엔진이 아직 starting 이라
|
||||
healthy 를 `BACKUP_HEALTHY_WAIT`(기본 300초)까지 기다린 뒤 판단한다(멈췄거나 없으면 기다리지 않는다)
|
||||
1. DB 덤프 · env 파일 복사(작업 폴더 `/var/lib/arasystem-ops` — 0700. 어떻게 끝나도 지운다)
|
||||
2. **warm** — 엔진을 켜 둔 채 올린다(바뀐 것 대부분이 여기서 올라간다. 이 스냅숏은 복원에 쓰지 않는다 — 복원 명령은 id 를 줘도 final 만 받는다).
|
||||
도는 엔진이 그사이 지운 파일을 읽지 못하면 restic 이 종료 3(일부를 읽지 못함)으로 끝나는데, warm 에서만 받아들이고 한 줄 남긴다
|
||||
3. 엔진을 멈추고(`docker stop`) **final** — 같은 경로를 다시. RocksDB 의 파일은 한 번 쓰면 바뀌지 않아 그사이 바뀐 것만 올라간다.
|
||||
final 은 종료 3 도 실패다. **시간 상한**(`BACKUP_FINAL_TIMEOUT`, 기본 15분)을 넘으면 restic 을 멈추고 엔진을 다시 띄운 뒤 실패로 알린다 — 메일이 오래 멈춰 있지 않게.
|
||||
상한은 `timeout --foreground` 로 건다 — 손으로 돌릴 때 Ctrl-C 가 restic 에 바로 닿아 정리로 간다(그룹이 다르면 상한까지 기다렸다 — 시험함)
|
||||
4. 엔진을 띄우고 `/healthz/ready` 까지 기다린다(3분). 스크립트가 어떻게 끝나도(오류 · 시간 초과 · 중지) 멈춘 엔진을 다시 띄운다 — 멈추는 중에 끊겼으면 멈춤이 끝난 뒤에 띄운다
|
||||
(docker 가 stop 을 처리하는 동안 부른 start 는 아무 일도 하지 않고, 그 뒤 stop 이 끝나 엔진이 멈춘 채 남는다 — 시험함). 그래도 남으면 systemd `ExecStopPost` 와
|
||||
부팅 때의 `arasystem-mail-recover.service`(백업보다 먼저 — 도커가 늦으면 30초마다 10번까지 다시)가 표시 파일(`mail-stopped-by-backup`)을 보고 띄운다 —
|
||||
엔진이 떠 있는 것을 본 뒤에만 표시를 지운다. 이것(`ensure-mail-running`)도 백업 잠금을 잡는다 — 백업이 도는 동안 손으로 부르면 아무것도 하지 않고 그만둔다
|
||||
(final 도중에 엔진을 띄우면 엔진이 쓰는 RocksDB 를 읽은 스냅숏이 final 로 남아 복원의 latest 가 된다)
|
||||
끊겼을 때의 정리 차례: 비밀 사본 지우기 → 엔진 띄우기 → 상태 파일 · Healthchecks `/fail` → 그 뒤 엔진 준비 기다리기(systemd 가 시간 상한으로 끊어도 `/fail` 이 빠지지 않게)
|
||||
5. 보존 — final 은 **매일 7 · 매주 4 · 매달 6**, warm 은 마지막 1. 일요일(KST)에 `prune` 과 `check`(데이터 5% 를 내려받아 본다)
|
||||
6. 상태 파일(15분 점검이 읽는다) · Healthchecks(시작 · 성공 + 요약 한 줄 · 실패 + 까닭 — ping 주소는 curl 의 설정 글로 표준 입력에 준다, 명령줄에 남지 않게)
|
||||
7. 같은 잠금(`backup.lock`)을 복원 연습 · `ensure-mail-running` 도 잡는다 — 함께 돌지 않는다(백업 쪽은 건너뛰고 Healthchecks 에 `/fail "건너뜀 …"` 한 줄, 연습 쪽은 멈추고, ensure 는 그만둔다)
|
||||
|
||||
영향 (임시 엔진 + 로컬 MinIO 로 잰 값 — 2026-10-31):
|
||||
|
||||
| 항목 | 값 |
|
||||
| --- | --- |
|
||||
| 메일 엔진 멈춤 | 8~11초 (데이터 0.1~1.3GB). 그동안 웹메일은 잠깐 `MAIL_001`, IMAP 은 다시 붙고, 들어오는 메일은 보낸 서버가 다시 보낸다 |
|
||||
| 시간 | warm 28초(1.2GB, 처음) — NCP 로는 서버의 바깥 대역폭에 달렸다. 다음 날부터는 바뀐 만큼만 |
|
||||
| 메모리 · CPU | restic 컨테이너 — 메모리 한도 1g(잰 최대 0.63GB — [메모리 예산](#메모리-예산)) · CPU 1 · CPU 몫 512(다른 컨테이너의 절반). systemd 의 `Nice=10` · IO 낮은 우선은 스크립트 · docker CLI 에만 걸린다(컨테이너는 dockerd 아래라 물려받지 않는다) |
|
||||
| 디스크 | 복사본을 만들지 않는다(볼륨을 직접 읽는다) — DB 덤프(압축) · env 만 작업 폴더에. restic 캐시 볼륨 `<PROJECT_NAME>-backup-cache`(잰 값 144KB — 저장소가 크면 는다) |
|
||||
| 되살릴 수 있는 시점 | 마지막 03:30 (그 뒤 하루치는 잃을 수 있다) |
|
||||
|
||||
### 설치 (서버 B — 한 번)
|
||||
|
||||
```bash
|
||||
# 1) 스크립트 · 유닛 — 저장소의 ops/ 를 /opt 로 복사한다 (저장소를 고쳐도 복사본은 그대로 — ops/ 가 바뀌면 이 단계를 다시)
|
||||
sudo install -d -m 0755 /opt/arasystem-ops
|
||||
sudo cp -r ops/lib ops/backup ops/monitor /opt/arasystem-ops/ && sudo chown -R root:root /opt/arasystem-ops && sudo chmod -R go-w /opt/arasystem-ops
|
||||
sudo install -m 0644 ops/systemd/arasystem-*.service ops/systemd/arasystem-*.timer /etc/systemd/system/ && sudo systemctl daemon-reload
|
||||
|
||||
# 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 키를 회사의 비밀번호 관리자에도 둔다 — 서버를 잃으면 이것 없이는 백업을 풀 수 없다
|
||||
|
||||
# 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
|
||||
|
||||
# 4) 타이머 켜기 — 백업 03:30 · 15분 점검, 그리고 부팅 때 백업이 멈춘 엔진 되살리기(표시 파일이 있을 때만 — 평소에는 아무 일도 하지 않는다)
|
||||
sudo systemctl enable --now arasystem-backup.timer arasystem-check.timer
|
||||
sudo systemctl enable arasystem-mail-recover.service
|
||||
systemctl list-timers 'arasystem-*'
|
||||
```
|
||||
|
||||
- **NCP Object Storage** — 콘솔에서 버킷을 만든다(공개 안 함 · 수명 주기 삭제 규칙 없음 — 지우기는 restic 이 한다). API 키는 이 버킷만 쓰는 서브 계정으로.
|
||||
`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` 로 처음 확인한다.
|
||||
- 설정 파일 `/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`.
|
||||
docker 는 `Wants` 로만 묶는다 — `Requires` 면 docker 를 다시 시작할 때 백업도 함께 멈춘다.
|
||||
- 바깥 연결 — 서버 B 에서 NCP Object Storage(443)로 나갈 수 있어야 한다. restic 이미지는 처음 한 번 받는다(`docker pull` — Docker Hub).
|
||||
|
||||
### 복원
|
||||
|
||||
`ops/backup/arasystem-restore.sh` (root — 설정은 백업과 같다):
|
||||
|
||||
| 명령 | 하는 일 |
|
||||
| --- | --- |
|
||||
| `snapshots` | final 스냅숏 목록 (`.env.production` 없이 돈다) |
|
||||
| `check [5%]` | 저장소 검사 — 데이터를 그만큼 내려받아 본다 (`.env.production` 없이 돈다) |
|
||||
| `drill [스냅숏\|latest] [--keep]` | **복원 연습** — 운영을 건드리지 않는다. 임시 볼륨(`<PROJECT_NAME>-drill_*`)에 풀고 같은 판의 엔진 · DB 를 **네트워크 없이** 띄워 계정 수 · 계정마다 메일 수 · DB 표마다 행 수 · 업로드 수 · env 지문을 지금 운영과 견준 뒤 지운다. 백업과 같은 잠금을 잡는다 |
|
||||
| `drill-clean` | 남은 연습 자원을 지운다 |
|
||||
| `files <스냅숏> <폴더>` | DB 덤프 · env 파일만 폴더로 꺼낸다 (`.env.production` 없이 돈다 — 새 서버의 첫 단계) |
|
||||
| `volumes <스냅숏>` | 메일 엔진 · 업로드 볼륨을 되살린다 — 볼륨이 없거나 비었고 쓰는 컨테이너가 멈춰 있을 때만(compose 의 이름표를 붙여 만든다). 볼륨을 들여다보지 못하면 비었다고 보지 않고 멈춘다 |
|
||||
| `db <덤프>` | 빈 DB 에 덤프를 넣는다(표준 입력으로) — 표가 있으면 멈춘다 |
|
||||
|
||||
- 스냅숏은 **final 만** 받는다 — id 를 직접 줘도 태그를 보고 warm 이면 멈춘다(켜 둔 엔진에서 받은 것이라 RocksDB 가 일관되지 않을 수 있다).
|
||||
- 보조 컨테이너는 모두 권한을 버리고(`--cap-drop ALL` · `no-new-privileges`) 꼭 필요한 것만 더한다 — 볼륨 들여다보기는 파일 읽기, 복원(restic)은 쓰기 · 소유자 되살리기,
|
||||
연습 DB 는 postgres 시작 스크립트가 쓰는 것, 연습 엔진은 실행 파일의 낮은 포트 권한(`NET_BIND_SERVICE` — 없으면 exec 가 거절된다), 연습의 복구 관리자 비밀번호는 `-e 이름` 으로만.
|
||||
- **연습은 한 달에 한 번**(그리고 백업 설정을 바꾼 뒤) — `sudo bash /opt/arasystem-ops/backup/arasystem-restore.sh drill`. 출력의 계정 · 메일 수가 운영과 같고 DB 표의 행 수가 백업 시각 이후 바뀐 만큼만 다른지 본다.
|
||||
연습 엔진은 운영의 호스트 이름 · 인증서 · 발송 대기열 · 웹훅을 안고 뜨므로 네트워크 없이 띄운다(대기열의 메일이 다시 나가지 않게). 디스크는 데이터만큼 잠시 더 쓴다.
|
||||
- 시험 (2026-10-31 · 11-02, MinIO — 11-02 는 서버와 같은 Ubuntu 24.04 · bash · flock · timeout 컨테이너에서): 백업 → 연습 · `files` · `volumes` → 되살린 엔진의 계정 · 메일함 · 메일 수 지문이 원본과 같았다.
|
||||
`volumes` 는 1.2GB 를 14초(로컬). 멈추는 도중 · final 도중에 SIGTERM · 시간 상한이 와도 엔진이 다시 떴다. 새 서버 차례(아래 1~5)를 빈 클론에서 끝까지 돌렸다(DB 행 수 · 엔진 지문 같음).
|
||||
|
||||
**서버를 통째로 잃었을 때** (새 서버 — 차례가 중요하다: backend 가 먼저 뜨면 빈 DB 에 마이그레이션을 돌려 덤프를 넣을 수 없다):
|
||||
|
||||
1. 새 서버를 [운영 배포](#운영-배포)의 사전 조건까지 준비하고 저장소를 같은 경로에 클론한다. 위 설치 1) · 2)(restic 비밀번호 · 키는 비밀번호 관리자에서 — 새로 만들지 않는다).
|
||||
**`/etc/arasystem/ops.env` 에 `PROJECT_NAME` 을 옛 서버의 값으로 적는다** — 이 단계에는 `.env.production` 이 없고, 백업 스냅숏의 host 이름이 그 값이다(비우면 "PROJECT_NAME 을 알 수 없습니다"로 멈춘다)
|
||||
2. `sudo bash /opt/arasystem-ops/backup/arasystem-restore.sh files latest /root/restore`(없거나 빈 폴더) → `/root/restore/backup/env/.env.production` · `…/backup/env/backend__.env.production` 을
|
||||
저장소의 `.env.production` · `backend/.env.production` 으로(0600 — 저장소 사용자 소유)
|
||||
3. `… restore.sh volumes latest` — 메일 엔진 · 업로드 볼륨 (여기부터는 `.env.production` 의 `PROJECT_NAME` 과 ops.env 의 값이 같아야 한다 — 다르면 멈춘다)
|
||||
4. `sudo docker compose --env-file .env.production up -d db` → healthy 뒤 `… restore.sh db /root/restore/backup/db/db.dump`. 다 끝나면 `/root/restore` 를 지운다(비밀이 들어 있다)
|
||||
5. `sudo docker compose --env-file .env.production up -d --build` — 메일 엔진은 되살린 설정으로 정상 모드로 뜬다(bootstrap 아님)
|
||||
6. setup — "운영 배포" 5)와 같은 형태(`--recovery` 없이). 서버 IP 가 바뀌었으면 PTR · SPF · `mx1` A · NPM 의 전달 주소 · `TRUST_PROXY` · `FRONTEND_PRIVATE_BIND` 를 함께 고친다.
|
||||
`APP_NETWORK_SUBNET` 을 바꿨다면 관리자 허용 IP 가 옛 대역이라 403 으로 멈춘다 — 복구 관리자 + `--recovery` 가 고친다([관리자 계정 단단히](#관리자-계정-단단히))
|
||||
7. 웹메일 로그인 · 메일 수 · `sudo systemctl start arasystem-check.service` · 백업 타이머 · 부팅 유닛(설치 4)
|
||||
|
||||
- 멈춘 동안 들어오던 메일은 보낸 서버가 며칠 다시 보낸다 — 되살리는 데 걸린 시간만큼 늦게 도착하고 잃지 않는다. 백업 뒤 ~ 사고 사이에 받은 메일 · 바꾼 설정은 없다.
|
||||
|
||||
### 손으로 받을 때 (타이머 없이 — 예전 방법)
|
||||
|
||||
```bash
|
||||
sudo docker compose --env-file .env.production stop mail
|
||||
@@ -1341,8 +1619,80 @@ sudo docker run --rm -v "${PROJECT_NAME}_mail-config:/config:ro" -v "${PROJECT_N
|
||||
sudo docker compose --env-file .env.production start mail
|
||||
```
|
||||
|
||||
- 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /` , 소유자 UID 2000 유지) 컨테이너를 띄운다. 복원 연습을 주기적으로 한다.
|
||||
- 백업 파일은 다른 장소에도 둔다. 메일 전체가 들어 있으므로 접근을 제한한다.
|
||||
- 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /`, 소유자 UID 2000 유지) 컨테이너를 띄운다. 백업 파일에는 메일 전체가 들어 있다 — 접근을 제한하고 서버 밖에도 둔다.
|
||||
|
||||
---
|
||||
|
||||
## 감시 (13단계)
|
||||
|
||||
두 겹이다 — **바깥**(UptimeRobot: 서버 · 포트가 바깥에서 보이는지)과 **안**(15분마다 도는 점검 스크립트 → Healthchecks.io). Healthchecks 는 "점검이 아예 돌지 않음"도 알린다(신호가 끊기면).
|
||||
|
||||
**알림 받는 곳은 우리 메일 서버가 아닌 주소로** 둔다(운영자 둘 이상의 Gmail 등) — 메일 서버가 서면 회사 주소로는 알림이 오지 않는다.
|
||||
|
||||
### Healthchecks.io (무료) — 체크 둘
|
||||
|
||||
| 체크 | 일정 | 유예 | 넣는 곳 |
|
||||
| --- | --- | --- | --- |
|
||||
| `arasystem-backup` | Cron `30 3 * * *` · 시간대 `Asia/Seoul` | 3시간 | `/etc/arasystem/ops.env` 의 `HC_BACKUP_URL` — 시작 · 성공(요약) · 실패(까닭) |
|
||||
| `arasystem-check` | 15분마다 (Simple — Period 15분) | 20분 | `HC_CHECK_URL` — 매번 정상(요약 한 줄) 또는 실패(문제 목록) |
|
||||
|
||||
- ping 주소를 아는 사람은 거짓 "정상"을 보낼 수 있다 — `ops.env`(0600) 밖에 적어 두지 않는다. 비워 두면 알리지 않고 로그 · 상태 파일만 남긴다.
|
||||
|
||||
### UptimeRobot (무료 — 5분 간격) — 감시 셋
|
||||
|
||||
| 종류 | 대상 | 보는 것 |
|
||||
| --- | --- | --- |
|
||||
| HTTP(s) | `https://portal.arasystem.it.kr/` | 포털(서버 A 의 NPM → 서버 B frontend)이 200 |
|
||||
| Port | `mx1.arasystem.it.kr` : 993 | IMAP — 서버 B 가 바깥에서 보이는지 |
|
||||
| Port | `mx1.arasystem.it.kr` : 25 | 메일 받기 |
|
||||
|
||||
### 15분 점검 (`ops/monitor/arasystem-check.sh`)
|
||||
|
||||
| 항목 | 문제로 보는 것 (값 — `ops.env`) | 어떻게 |
|
||||
| --- | --- | --- |
|
||||
| 디스크 | `/` 와 도커 데이터 폴더 사용률 85% 이상 (`CHECK_DISKS` · `CHECK_DISK_MAX_PERCENT`) | `df` |
|
||||
| 컨테이너 | frontend · backend · db · mail · mail-resolver · clamav · clamav-updater 가 healthy 가 아님 | `docker inspect` (clamav-updater 가 unhealthy 면 "서명 갱신이 이틀 넘게 멈춤") |
|
||||
| 백엔드 | `/api/health` 가 200 이 아님 — 상태 코드와 실패한 구성 요소 이름만 | backend 컨테이너 안에서 |
|
||||
| 메일 대기열 | 50통 이상 또는 가장 오래된 것이 24시간 이상 (`CHECK_QUEUE_MAX` · `CHECK_QUEUE_MAX_AGE_HOURS`) | backend 컨테이너의 node → 엔진 `x:QueuedMessage` (관리자로) |
|
||||
| 인증서 | 믿을 수 없는 체인 · 이름 불일치 · 남은 날 14일 미만 (`CHECK_CERTS` · `CHECK_CERT_MIN_DAYS`) — 기본은 `MAIL_HOSTNAME`:993 · 포털 · 공개 내려받기 호스트 :443 | `openssl s_client -verify_hostname` |
|
||||
| 백업 | 마지막 성공에서 26시간 이상 · 백업 타이머가 꺼짐 (`CHECK_BACKUP_MAX_AGE_HOURS`) | 백업의 상태 파일 · `systemctl` |
|
||||
| 백업 표시 | 표시 파일(`mail-stopped-by-backup`)이 남았는데 백업 서비스가 돌지 않고 15분보다 오래됨 — 끊긴 백업이 엔진을 멈춘 채 남겼을 수 있다 | 표시 파일 · `systemctl show arasystem-backup.service` |
|
||||
| Spamhaus 키 | 키가 있는데 시험 항목(`127.0.0.2` · `127.0.1.2`)이 오지 않음 — 체험 기간 끝 등 | 메일 컨테이너의 perl 로 리졸버에 묻는다(키는 표준 입력으로) |
|
||||
|
||||
- 백업이 메일 엔진을 멈춘 동안은 메일 쪽 항목을 건너뛴다 — 표시 파일만 믿지 않고, 백업 서비스가 지금 돌거나 표시가 15분 안에 생긴 것일 때만이다(남은 표시가 점검을 끝없이 조용하게 하지 않게). 문제가 있으면 종료 1 — `journalctl -u arasystem-check -n 20 --no-pager`.
|
||||
- **대기열 확인의 비밀** — 관리자 비밀번호는 따로 두지 않는다: 점검이 돌 때마다 `.env.production` 의 `MAIL_ADMIN_PASSWORD` 를 읽어 `docker exec -e`(환경 변수 — 명령줄에 남지 않는다)로 backend 컨테이너의 node 에 넘긴다.
|
||||
그 node 는 앱 네트워크에서 엔진에 붙으므로 관리자 허용 IP 안이다. 읽기만 한다(대기열 조회).
|
||||
- 결과 예 (개발에서): `정상 — 디스크 / 72% · 컨테이너 5개 정상 · 백엔드 health 정상 · 대기열 0통 · 인증서 … 88일 · 백업 0시간 전 · Spamhaus 키 없음`.
|
||||
|
||||
### 알림이 오면
|
||||
|
||||
| 알림 | 먼저 볼 것 |
|
||||
| --- | --- |
|
||||
| 백업 실패 · 26시간 넘음 | `journalctl -u arasystem-backup -n 50` — NCP 키 · 버킷 · 바깥 연결, 메일 엔진이 healthy 였는지. 다시: `sudo systemctl start arasystem-backup.service` |
|
||||
| 백업 표시가 남아 있음 | `sudo docker compose --env-file .env.production ps mail` — 멈춰 있으면 `sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running`(띄우고, 떠 있는 것을 본 뒤 표시를 지운다). 백업이 돌고 있으면 `[건너뜀]` 으로 그만둔다 — 그 백업이 끝나며 스스로 띄우니 끝난 뒤 다시 본다(`systemctl status arasystem-backup`). 그 뒤 `journalctl -u arasystem-backup` 로 왜 끊겼는지 |
|
||||
| 디스크 | `sudo docker system df -v` · 도커 데이터 폴더(`docker info -f '{{.DockerRootDir}}'` — 서버 B 는 `/data/docker`). 대용량 첨부(`_files`)는 백엔드가 보관량 · 여유를 본다([대용량 첨부](#대용량-첨부-11단계--인프라)) · 로그는 [로그 보존](#로그-보존-13단계) |
|
||||
| 컨테이너 unhealthy | `sudo docker compose --env-file .env.production ps` · 그 컨테이너 로그. 리졸버면 [리졸버 장애 때](#리졸버-장애-때) |
|
||||
| 대기열 쌓임 | 엔진 로그의 배달 실패(바깥 25 막힘 · 받는 쪽 거절 · DNS) — 관리 화면 › Queue (SSH 터널) |
|
||||
| 인증서 | mx1 은 엔진의 ACME("운영 배포"의 TLS 인증서 줄), 포털 · 공개 호스트는 NPM 의 인증서 |
|
||||
| Spamhaus 키 | 체험 기간 · 구독 — 끝났으면 키를 비우고 setup ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)) |
|
||||
| UptimeRobot 만 | 서버 A(NPM) · 서버 B 가 살아 있는지, NCP ACG · 공인 IP |
|
||||
|
||||
---
|
||||
|
||||
## 로그 보존 (13단계)
|
||||
|
||||
모든 compose 서비스에 도커 로그 한도를 적었다(`json-file` · 넘으면 돌려 쓰고 지난 파일은 압축) — 전에는 도커 기본값(한도 없음)이라 로그가 디스크를 채울 수 있었다.
|
||||
|
||||
| 서비스 | 한도 | 대략 남는 기간 (직원 50명 추정) |
|
||||
| --- | --- | --- |
|
||||
| frontend · backend · mail | 20MB × 10개 (`x-logging-long` — 글로 140MB 안팎) | mail(인증 · 거절 · 배달) 하루 10MB 안팎 → 2주쯤, frontend(접근 로그) 하루 20MB 안팎 → 1주쯤, backend(경고 · 오류 · 주요 일만) 몇 주 |
|
||||
| db · redis · mail-resolver · clamav · clamav-updater (개발 mailpit · remote-proxy 의 frontend-bind-check) | 10MB × 5개 (`x-logging-default`) | 평소 거의 쓰지 않는다 |
|
||||
|
||||
- 추정의 가정은 직원 50명 · 하루 받는 메일 3,000통 · 보내는 메일 750통 · 한 사람 하루 1,500 요청(줄 길이는 개발에서 잰 값 — `docker-compose.yml` 머리말). 운영에서 `sudo docker logs --since 24h arasystem-mail 2>&1 | wc -c` 로 다시 잰다.
|
||||
- 한도는 압축 전 크기다(json-file 은 줄마다 시각 · stream 껍데기가 붙어 글의 1.4배 안팎) — 디스크에는 모두 차도 0.75GB 안팎이고, 지난 파일은 gzip 이라 실제로는 그 몇 분의 1이다. `docker logs --since …` 는 지난 파일까지 읽는다.
|
||||
- **처음 적용할 때 컨테이너가 다시 만들어진다**(다음 `up -d`) — 그때까지의 로그는 컨테이너와 함께 지워진다. 남길 로그가 있으면 먼저 `sudo docker logs arasystem-mail > mail-$(date +%F).log 2>&1`.
|
||||
- 더 오래 남기려면 숫자를 늘리지 말고 바깥 수집기(Loki · CloudWatch 등 — 로깅 규칙 "운영 확장")로 보낸다. 같은 호스트의 다른 프로젝트(도커 데몬 설정)에는 영향이 없다.
|
||||
- CI 의 infra 잡이 모든 서비스에 `logging` 이 있는지 센다 — 새 서비스를 더하면 앵커를 붙인다.
|
||||
|
||||
---
|
||||
|
||||
@@ -1374,8 +1724,8 @@ sudo docker compose --env-file .env.production start mail
|
||||
- ⚠️ **루프백(127.0.0.1)에서 온 요청은 정책을 건너뛴다** — 메일 컨테이너 안에서 `https://127.0.0.1` 로 시험하면 200 이 나온다.
|
||||
확인은 컨테이너의 네트워크 주소로 한다 (setup 이 그렇게 확인한다). 이미지 healthcheck 는 https 실패 시 http 8080 으로 대체되어 영향이 없다.
|
||||
- ACME(TLS-ALPN-01)는 TLS 단계에서 끝나 HTTP 경로 제한과 무관하다 (운영 인증서 발급으로는 아직 확인하지 않았다).
|
||||
- 관리 화면은 SSH 터널로 내부 8080 에 붙는다: `ssh -L 8180:<mail 컨테이너 IP>:8080 <메일 호스트>` → <http://localhost:8180/admin/>.
|
||||
직원 셀프서비스(`/account/` — 비밀번호 변경 등)도 공개 443 에서는 막혀 있다. 필요해지면 백엔드 API 로 만든다.
|
||||
- 관리 화면은 SSH 터널로 mail 컨테이너의 앱 네트워크 주소:8080 에 붙는다 — [관리 화면 — SSH 터널](#관리-화면--ssh-터널).
|
||||
직원 셀프서비스(`/account/` — 비밀번호 변경 등)도 공개 443 에서는 막혀 있다. 웹메일 설정 화면(비밀번호 · 2단계 · 앱 비밀번호)이 그 일을 한다.
|
||||
- **직원은 계정 목록을 볼 수 없다.** 기본 `User` 역할에는 `Principal/get` 으로 관리자·게이트웨이를 포함한 전체 계정을 보는 권한
|
||||
(`jmapPrincipal*`·`davPrincipal*` 13개)이 있어서, setup 이 뺀다 (역할 권한은 캐시되므로 `InvalidateCaches` 로 반영).
|
||||
- 2026-10-26 확인: 테스트 직원의 `Principal/get` → forbidden. 백엔드를 거친 웹메일(폴더·목록·읽기·첨부 올리기/내려받기·발송·중복 발송 방지)은 그대로 동작.
|
||||
@@ -1397,6 +1747,7 @@ sudo docker compose --env-file .env.production start mail
|
||||
그때는 그 식에 이 계정들일 때 `arasystem-system-recipients` 를 고르는 갈래를 합친다.
|
||||
- 확인 (2026-10-29, 0.16.27): 바꾸기 전 세 주소 모두 RCPT 250 → 배달 때 거절 → 보낸 사람(개발은 Mailpit)에게 반송. 바꾼 뒤 대소문자 · +태그 꼴까지 550,
|
||||
직원 · 직원 +태그 · 이름이 `admin` 으로 시작하는 직원 · 공용 주소(Group, +태그 포함)는 250 이고 저장됐다. setup 을 이어서 돌리면 `[유지]`, 관리자를 더 만들거나 지우면 `[변경]`.
|
||||
- **관리자 비밀번호는 대리 접근을 못 하고 서버 안에서만 통한다**(13단계 — setup 이 건다) — [관리자 계정 단단히](#관리자-계정-단단히). 관리 화면은 SSH 터널로만 ([관리 화면 — SSH 터널](#관리-화면--ssh-터널)).
|
||||
- **복구 관리자는 값이 있는 동안 정상 모드에서도 관리자로 로그인된다** (검증함). 운영은 `.env.production` 에 두지 않고
|
||||
일회성 override + `--recovery` 로만 쓴다 — 파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 ("운영 배포" 4·5).
|
||||
- **포트는 IPv4(`0.0.0.0`)로만 공개한다.** 주소 없이 쓰면 IPv6 로도 열리는데, IPv6 접속은 docker-proxy 가 중계해 컨테이너에는
|
||||
@@ -1714,6 +2065,12 @@ sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"
|
||||
| clamav 의 디스크(쓰기 계층)가 줄지 않는다 · `docker system df` 의 clamav 컨테이너가 크다 | 검사 중에 clamd 가 죽으며 남긴 임시 파일이다(`/tmp/clamav-<hex>.tmp` — 받던 스트림 · 푼 내용). compose 는 clamd 를 띄울 때마다 지우므로 다시 뜬 뒤에도 크면 지금 검사 중인 것이다(한 번에 2개 × 최대 6GB). `sudo docker exec "${PROJECT_NAME}-clamav" du -sh /tmp` · `ls -la /tmp`. 자주 죽는다면 까닭을 본다 — `docker inspect -f '{{.State.OOMKilled}} {{.RestartCount}}' "${PROJECT_NAME}-clamav"`(메모리 한도 — [메모리 예산](#메모리-예산)) · `docker logs` |
|
||||
| frontend 가 뜨지 않는다 — `nginx-env.envsh: SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처…` | `.env.production` 의 `SHARE_PUBLIC_URL` 을 `https://호스트[:포트]` 로(경로 · 끝의 `/` · 끝 점 없이) 고친 뒤 `up -d frontend` |
|
||||
| `up` 이 `all predefined address pools have been fully subnetted` 로 멈춘다 | 도커 기본 주소 풀(172.17~31.x · 192.168.x — 네트워크 31개)이 다 찼다 — 서브넷을 정하지 않은 네트워크(`clamav` · `clamav-egress` · `mail-dns-egress`)를 만들 자리가 없다. 쓰지 않는 네트워크를 지운다(`docker network prune` — 개발 PC 는 다른 프로젝트 · CI 의 것이 쌓인다) |
|
||||
| setup 이 `관리자(…) 로그인이 HTTP 403 입니다` 로 멈춘다 (13단계부터) | 관리자 허용 IP(앱 네트워크) 밖에서 붙었다 — 운영은 `MAIL_ADMIN_URL` 을 mail 컨테이너의 **앱 네트워크** 주소로("운영 배포" 3 — 다른 네트워크 주소면 403). `APP_NETWORK_SUBNET` 을 바꾼 뒤라면 복구 관리자 + `--recovery` 가 고친다([관리자 계정 단단히](#관리자-계정-단단히)). 엔진이 본 주소는 `docker logs` 의 `security.ip-unauthorized` 줄 |
|
||||
| 관리 화면 로그인이 안 된다 — SSH 터널로 (13단계부터) | 터널 대상이 mail 컨테이너의 앱 네트워크 주소인지([관리 화면 — SSH 터널](#관리-화면--ssh-터널)) — mail 을 다시 만들면 주소가 바뀐다. 바깥(993 · 공개 443)에서는 관리자 비밀번호가 통하지 않는 것이 정상이다 |
|
||||
| 백업이 `메일 엔진(…)이 healthy 가 아니라 백업하지 않았습니다` · `restic 저장소에 닿지 못했습니다` | 앞은 엔진부터 본다(`ps` · 로그) — 백업은 멈춘 엔진을 건드리지 않는다. 뒤는 NCP 키 · 버킷 · `RESTIC_OPTIONS` · 바깥 443, 처음이면 `arasystem-backup.sh init`([백업](#백업)) |
|
||||
| 백업이 `final 이 시간 상한(…)을 넘어 멈췄습니다` 로 실패 | 바깥 대역폭 · NCP 상태를 본다 — 엔진은 다시 떠 있다. 끊긴 restic 의 저장소 잠금은 30분 뒤 오래된 잠금이 된다 — 30분 안에 손으로 다시 돌리면 보존 정리(forget)가 잠금에 걸릴 수 있다(30분 뒤 다시, 다음 날 백업은 그대로). 평소보다 바뀐 양이 크면 `BACKUP_FINAL_TIMEOUT` 을 늘린다 |
|
||||
| 백업 뒤 메일 엔진이 멈춰 있다 | 정상이면 생기지 않는다(스크립트의 EXIT 처리 — 멈추는 중이었으면 멈춤이 끝난 뒤 띄운다 — 와 systemd `ExecStopPost` · 부팅 유닛이 다시 띄운다). 먼저 백업이 아직 도는지 본다(`systemctl status arasystem-backup` — 돌고 있으면 끝나며 띄운다). 끝났으면 `sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running`(백업 잠금을 잡는다 — 백업이 돌면 `[건너뜀]`) → 그래도면 `sudo docker compose --env-file .env.production up -d mail` 과 `journalctl -u arasystem-backup` |
|
||||
| frontend 가 뜨지 않는다 — `nginx-env.envsh: MAIL_DOMAIN …` · `… autoconfig …` | `MAIL_DOMAIN` · `MAIL_HOSTNAME` 의 모양(영문 · 숫자 · `-` · `.` 의 점 있는 이름 — 끝 점 · 밑줄 없이), `autoconfig.` · `autodiscover.<MAIL_DOMAIN>` 이 `CORS_ORIGIN` · `SHARE_PUBLIC_URL` 의 호스트와 겹치지 않는지([메일 프로그램 자동 설정](#메일-프로그램-자동-설정)) |
|
||||
|
||||
### 리졸버 장애 때
|
||||
|
||||
|
||||
Reference in New Issue
Block a user