1만 통 측정에서 색인 찾기는 표 · 색인이 메모리에 있을 때만 1초 안이었고 캐시가 비면 3~8배 느렸다(사용자 결정 2026-11-05). 8GB 서버의 한도 합은 6.65GB · 백업 때 7.65GB 다. 복원 연습 때는 8.15GB 로 8GB 를 넘는다 — 사실만 적고 다룰 방법은 따로 정한다. mail/README 에 운영에 올리는 차례(db 만 먼저 up · 한도 · shared_buffers 확인)를 적었다. docker 스킬의 연습 주기를 기준 문서(README — 한 달에 한 번)에 맞췄다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
메일 엔진 (Stalwart)
사내 메일은 Stalwart(JMAP) 를 자체 호스팅한다. 직원 로그인 = 메일 계정이다. 웹메일 UI 는 자체 제작하고, 백엔드(NestJS)가 내부 JMAP 으로 메일 엔진에 중계한다.
- 이미지:
stalwartlabs/stalwart:v0.16.27— 패치까지 고정 (비-rootstalwart, UID 2000 · healthcheck 내장. 올릴 때는 docker 스킬 "이미지 버전 고정") - 설정값 SSOT: 루트
.env.{APP_ENV}의 메일 섹션 (env-structure) - 초기화·계정 구성:
scripts/setup.mjs— 의존성 없음, 여러 번 실행해도 안전 - 이름은 루트 env 의
PROJECT_NAME을 따른다 — 컨테이너${PROJECT_NAME}-mail, 볼륨${PROJECT_NAME}_mail-config·_mail-data, 네트워크${PROJECT_NAME}_app-network(신뢰 대역)·${PROJECT_NAME}_edge(frontend ↔ backend). 대용량 첨부(11단계)는 볼륨${PROJECT_NAME}_files(파일) ·_clamav-db(악성 검사 서명), 컨테이너${PROJECT_NAME}-clamav·-clamav-updater, 네트워크${PROJECT_NAME}_clamav(backend ↔ 검사기) ·_clamav-egress(서명 갱신) — 악성 검사 (ClamAV). 아래 명령은 셸에 한 번 읽어 둔 값을 쓴다:export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.development)" # 운영은 .env.production
구조
브라우저 ─▶ frontend nginx ─(edge)─▶ backend ──HTTP JMAP (app-network, http://mail:8080)──▶ mail (Stalwart)
│ 로그인: <직원 주소>:<비밀번호> 로 세션 조회 (200 / 401) │ 25 SMTP 수신
│ 메일함: <직원 주소>%<게이트웨이 주소> 로 대리 접근 │ 465 SMTPS 제출 · 993 IMAPS
│ 443 HTTPS — ACME·MTA-STS 만 (JMAP·관리 API·포털은 403)
▼ 사내 도메인 밖 (개발 환경만)
mailpit — 외부 수신자 메일 수집
메일 엔진은 app-network 대역 전체를 신뢰한다(자동 차단 예외·게이트웨이 로그인). 그래서 app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만
붙이고, 인터넷을 향한 frontend 는 edge 로만 backend 에 닿는다. setup 이 앱 네트워크에 붙은 컨테이너를 확인한다.
엔진의 DNS 는 메일 전용 리졸버(mail-resolver)가 맡는다 — mail 과 리졸버만 붙는 mail-dns 로 닿는다 (DNS 리졸버와 스팸 차단 목록).
대용량 첨부(11단계)의 파일은 엔진이 아니라 backend 의 볼륨에 두고, 악성 검사기(clamav)는 backend 와만 닿는 clamav 네트워크에 둔다 —
app-network(신뢰 대역)에 붙이지 않는다 (악성 검사 (ClamAV)).
브라우저(직원) ─▶ portal. ┐ ┌─(app-network)─▶ db · mail
받는 사람(바깥) ─▶ files. ┴▶ 외부 프록시 ─▶ frontend nginx ─(edge)─▶ backend ─┤ /app/files = 볼륨 <PROJECT_NAME>_files
(Host 로 server 를 가른다) └─(clamav — internal)─▶ clamav(clamd) ◀─ 볼륨 <PROJECT_NAME>_clamav-db(읽기 전용)
clamav-updater(freshclam) ─(clamav-egress)─▶ database.clamav.net
| 구성 요소 | 위치 | 비고 |
|---|---|---|
mail 서비스 |
docker-compose.yml (profile mail) |
named volume <PROJECT_NAME>_mail-config(config.json) · <PROJECT_NAME>_mail-data(RocksDB) |
mail-resolver |
docker-compose.yml (profile mail) · 설정 mail/resolver/unbound.conf |
엔진 전용 재귀 DNS(unbound) — mail 만 닿는 mail-dns(internal)와 자기 바깥 길 mail-dns-egress 에만 붙는다. 상태 없음(볼륨 없음) — DNS 리졸버와 스팸 차단 목록 |
clamav · clamav-updater |
docker-compose.yml (운영은 늘 · 개발은 profile clamav) · 설정 mail/clamav/clamd.conf · freshclam.conf |
대용량 첨부의 악성 검사기(clamd)와 서명 갱신(freshclam) — 둘로 나눠 검사기는 바깥 길이 없다. 서명은 named volume <PROJECT_NAME>_clamav-db — 악성 검사 (ClamAV) |
| 대용량 첨부 파일 | backend 의 named volume <PROJECT_NAME>_files → /app/files (개발도 같다) |
기록은 DB. 30일짜리 전송물이라 백업하지 않는다 — 백업 · 대용량 첨부 — 공개 내려받기 호스트 |
| 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 |
운영 서버의 호스트에서 돈다(컨테이너가 아니다) — 백업 · 감시 |
| 포트 | 운영 | 개발 |
|---|---|---|
| 25 · 465 · 993 · 443 | 호스트에 공개 (0.0.0.0, IPv4 만 — 보안). 443 의 HTTP 는 MTA-STS 정책 파일만 연다 |
공개하지 않음 |
| 8080 (HTTP JMAP·관리 API) | 공개하지 않음 (backend 전용 내부망) | 127.0.0.1:${MAIL_ADMIN_PORT} (관리 화면·setup 스크립트) |
| 995 pop3s · 4190 sieve | 리스너는 있으나 공개하지 않음 | — |
| 계정 | 용도 | 비밀번호 |
|---|---|---|
<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 |
개발 시작
# 0) env 파일 (처음 한 번) — change-me-* 를 무작위 영문·숫자로 교체한다
cp .env.development.example .env.development
cp backend/.env.development.example backend/.env.development
# 1) 기동 — COMPOSE_PROFILES=mail 이 기본이라 backend·db·mail·mail-resolver(메일 전용 DNS)·mailpit 이 함께 뜬다
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 2) 메일 엔진 설정 (처음 한 번 — 이후에도 env 를 바꾸면 다시 실행한다)
node --env-file=.env.development mail/scripts/setup.mjs
| 확인 | 주소 |
|---|---|
| 관리 화면 (웹 포털) | http://localhost:8180/admin/ — admin@arasystem.test / MAIL_ADMIN_PASSWORD |
| 직원 셀프서비스 | http://localhost:8180/account/ |
| Mailpit (외부 수신자 메일) | http://localhost:8025 |
- 첫 실행은 bootstrap → mail 컨테이너를 스스로 재시작(
docker restart <PROJECT_NAME>-mail)한 뒤 이어서 진행한다. - 출력의
[생성]·[변경]·[유지]로 무엇이 바뀌었는지 보인다. 두 번째 실행부터는 전부[유지]여야 정상이다. - ⚠️ 외부 발송 경로(Mailpit)는 setup 이 설정·리로드한 뒤에야 적용된다. 그 전에 외부 주소로 보내면 실제 인터넷으로 나간다.
그래서 스크립트는 Mailpit 경로를 먼저 확정하고 나서 테스트 계정을 만든다. 시험 수신자는 예약 도메인(
example.net등)만 쓴다. - 악성 검사기(ClamAV)는 개발에서 기본으로 띄우지 않는다(메모리 1GB 남짓 · 처음 기동에 서명 110MB 를 받는다) — 그동안 다 올린 대용량 파일은 바로 "검사하지 못함"이다.
켜려면
.env.development에COMPOSE_PROFILES=mail,clamav와CLAMAV_HOST=clamav를 두고up -d(backend 가 새 값으로 다시 만들어진다) — 악성 검사 (ClamAV). 서명은 볼륨에 남아 다음에는 바로 뜬다. - 개발 메일 데이터를 버리고 처음부터:
(
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development rm -sf mail mailpit docker volume rm "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data" # 이후 1) 2) 를 다시docker compose down -v도 이 볼륨을 지운다 — 의도한 게 아니라면 쓰지 않는다)
setup 스크립트가 하는 일
- 사전 확인 (아무것도 바꾸기 전) — app-network 의 실제 서브넷이
APP_NETWORK_SUBNET인지, 앱 네트워크에 정해진 컨테이너 (backend·db·redis·mail·mailpit)만 붙어 있는지, 백엔드 주소가 신뢰 대역 안인지. 어긋나면 엔진 설정을 바꾸지 않고 중단한다 (신뢰 대역을 바꾼 뒤에 알면 전 직원의 메일이 멈추고 백엔드 주소가 자동 차단 예외에서 빠진다). 앱 네트워크에 다른 컨테이너가 있으면 운영은 중단, 개발은 경고. 메일 전용 리졸버도 본다 —mail-dns네트워크 · 리졸버 주소 · mail 의dns, 그리고 메일 컨테이너에서 UDP · TCP 로 답하고 DNSSEC 을 검증하는지. 닿지 않는 리졸버에 엔진의 DNS 를 맡기면 바깥 메일이 멈추므로 하나라도 어긋나면 중단한다(답 확인만은 개발에서[주의]— 개발 메일은 Mailpit 이다). Spamhaus 키가 있으면 그 키로 시험 항목이 나오는지도 본다 (DNS 리졸버와 스팸 차단 목록) - 모드 감지 — bootstrap(첫 기동, 8080 만 열림) / bootstrap 완료·재시작 대기 / 정상
- bootstrap — 호스트명·기본 도메인·DKIM 키(ed25519·rsa)·로그(표준 출력) → 재시작.
응답의 무작위 관리자 비밀번호는 한 번만 보이므로
data/mail/bootstrap-admin.json(git 무시)에 잠시 보관했다가 교체 후 지운다. ⚠️Bootstrap/set은 원자적이지 않다 — 다시 부르면 도메인 중복으로 거부되면서도 tracer 가 하나 더 생겨 설정 오류가 난다. 그래서- 부르기 전에
data/mail/에 실제로 써 보고, 쓸 수 없으면 아무것도 바꾸지 않고 멈춘다 - 재실행 때는 mail 컨테이너의
/etc/stalwart/config.json으로 "bootstrap 은 끝났고 재시작만 남았다"를 알아보고 재시작만 한다 (Bootstrap/set 은 재시작 전에 이미 이 파일을 쓴다 — 보관 파일이 없어도 다시 부르지 않는다. docker 를 쓸 수 없으면 보관 파일로 판단)
- 부르기 전에
- 관리자 —
<MAIL_ADMIN_LOCAL_PART>@도메인비밀번호를MAIL_ADMIN_PASSWORD로 맞춘다 (나중에 env 값을 바꾸면 복구 관리자로 다시 맞춘다 — 운영은--recovery). 갓 bootstrap 한 엔진이면 bootstrap 이 만든admin@의 아이디를MAIL_ADMIN_LOCAL_PART로 바꾼다 (Bootstrap/set 은 아이디를 받지 않는다). 이미 쓰던 엔진에서 env 아이디의 관리자가 없으면(bootstrap 뒤에 값만 바꾼 경우) 중단한다 - 자동 차단 예외 — 신뢰 대역을 AllowedIp 로 등록 (기본 보안 설정은 IP 당 인증 실패 하루 100회면 차단하는데, 모든 로그인이 백엔드 IP 하나를 거친다).
신뢰 대역 =
MAIL_TRUSTED_NETWORKS, 비우면APP_NETWORK_SUBNET(compose 가 고정한 app-network 서브넷). 서브넷보다 넓거나 밖에 있는 대역이 들어오면 실패한다. 이 스크립트가 만든 예전 항목은 지운다. - 게이트웨이 — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만
- 잠금 — 직원(
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 스크립트 — 보안 "메일을 받지 않는 시스템 계정") - 사내 목록 계정 — 계정 목록 읽기 전용 역할(
authenticate·jmapPrincipalGet·jmapPrincipalQuery) + 비밀번호 없는 계정<MAIL_DIRECTORY_LOCAL_PART>@도메인. 엔진 관리자는 이 권한을 내줄 수 없어 복구 관리자가 있을 때만(개발 · 운영--recovery) 만들고 맞춘다. 없으면 관리자로 되는 것(표시 이름·저장 한도·비밀번호 빼기)만 맞추고 역할·계정이 없거나 다르면[주의]— 실패로 끝내지 않는다. 준비되면 백엔드처럼 게이트웨이 대리 접근으로Principal/query·get을 해 본다 (사내 목록 계정) - 엔진 한도 — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 —
로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·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 리졸버와 스팸 차단 목록). 엔진 웹훅(12단계) — 인증 이벤트 셋을 백엔드의 받는 곳(http://backend:<BACKEND_PORT>/api/internal/mail-engine-events)으로MAIL_WEBHOOK_SECRET로 서명해 보내는x:WebHook하나를 맞춘다(setup 표시 머리글로 찾는다 — 키가 비면 운영은 중단, 개발은[주의]뒤 있던 것을 끈다 — 엔진 웹훅). 휴지통 · 스팸메일함 30일(13단계) —x:DataRetention의expungeTrashAfter30일과 정리 시각(계정 정리 KST 03:00 · 데이터 04:00 · 원문 04:30 — 컨테이너 시각으로 바꿔 넣는다) (휴지통 · 스팸메일함 자동 비우기). 모두 9 의 리로드로 반영된다 - 설정 리로드 → 반영 확인 — 메일 컨테이너의 네트워크 주소로
https://…:443/jmap/session이 403, 내부 8080 이 200 인지 본다. 운영은 바깥에서 쓰는 주소 그대로(MAIL_PUBLIC_URL/jmap/session, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 — 200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할curl명령을 보여 준다. 게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번[주의](보안). 시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다). 보낸 사람은 빈 봉투(MAIL FROM:<>)다 — 사내 도메인 밖이라 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고, 어긋나 DATA 까지 가더라도 반송이 생기지 않는다. 엔진의 이번 기동 로그에 "DNS resolver cannot validate DNSSEC"(DANE 꺼짐) 경고가 없는지도 본다 — 리로드는 이 경고를 남기지 않아 기동 로그만 볼 수 있다. 엔진 웹훅은 리로드 뒤 다시 읽어 맞는지 보고([확인] 엔진 웹훅 켜짐), 메일 컨테이너 안에서 서명한 빈 묶음을 받는 곳에 보내 200 인지 본다 ([확인] 엔진 웹훅 — … 받았다— 401 · 404 는 운영은 실패, 개발은[주의]. 이번 실행에서 엔진에 키를 넣었으면 401 도[주의]와 다음 명령 — 엔진 웹훅) - [개발] 외부 발송 경로 — Relay
mailpit:1025+ 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 9 의 리로드로 반영된 것을 확인한 뒤에만 11 (7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 그보다 먼저 만들어도 된다) - [개발] 테스트 직원 계정 → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(
Principal/get→ forbidden)까지 자체 확인 - 시범 공존 (
MAIL_PILOT_LEGACY_HOST가 있을 때 — 파일럿 공존) — 바꾸기 전에 도메인 · 서버 이름 · 기존 서버에 닿는지 · 자기 자신이 아닌지를 보고, 막는 것(맨 앞에 주인 표시인 자동 차단 예외 · RCPT 가드 · MAIL FROM 위조 막기 · 기존 서버 IP 예외)을 6 · 8 과 함께 저장해 9 의 리로드로 먼저 걸고, RCPT 가드가 답하는지 본 뒤 → 발송 경로legacy· 발송 전략 → 리로드 → 도메인allowRelaying(저장 즉시) → 다시 RCPT · MAIL FROM 을 실제로 보내 확인한다 — 켜기 전에 가드가 2초 안에 답하지 않으면 켜지 않고, 켠 뒤에 답하지 않으면allowRelaying을 바로 다시 끄고 멈춘다(끄지 못하면 그렇게 알린다). 값이 비면 거꾸로(allowRelaying먼저, 꺼진 것을 다시 읽어 확인한 뒤에만 나머지) 지운다 — 운영은--end-pilot이 있어야 한다
그 밖의 안전장치:
- 운영은 docker 를 쓸 수 있어야 한다 — 없으면 아무것도 바꾸지 않고 실패한다 (네트워크·복구 관리자 확인, bootstrap 뒤 재시작).
- 셸에
APP_ENV·APP_NETWORK_SUBNET등이 이미 있으면 중단한다. Node 의--env-file은 기존 환경변수를 덮어쓰지 않아, 셸에APP_ENV=development가 남은 채.env.production으로 돌리면 운영에 개발 설정(Mailpit 경로)을 넣게 된다. (MAIL_RECOVERY_ADMIN은 예외 — 원래 셸에서 일회성으로 주는 값이다. 시범 공존 값MAIL_PILOT_LEGACY_*는 개발에서만 예외 — 시험할 때 셸에서 한 번 켜 본다. 운영은 env 파일에 없는 시범 값이 셸에 있어도 중단한다 — 시범 상태는 엔진에 남는 설정이라 env 파일만 보고도 알 수 있어야 한다) APP_NETWORK_SUBNET은 사설 대역의 네트워크 주소, 접두 길이/24~/28만 받는다 — 이 대역 전체가 신뢰 대역이라 넓게 잡지 않는다 (넓을수록 사무실 LAN·VPN 같은 실제 클라이언트 주소와 겹친다./28보다 좁으면 컨테이너 주소가 모자란다).MAIL_ADMIN_LOCAL_PART는 백엔드와 같은 형식 규칙(영문·숫자·.·_·-, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시)이다. 로그인 확인은 2xx 만 "맞음", 401 만 "틀림"으로 보고 그 밖(429·5xx)은 중단한다 — 비밀번호를 잘못 덮어쓰지 않게. 402(비밀번호는 맞고 그 계정에 2단계 인증이 켜져 있음 — 12단계)를 "맞음"으로 보는 것은 테스트 직원뿐이다 — 웹메일에서 2단계 인증을 켠 테스트 직원의 비밀번호를 덮어쓰지 않고, 그 직원의 계정 목록 비공개 확인은 게이트웨이 대리 접근으로, 시범 공존의 인증 발송 확인은 2단계가 꺼진 직원으로 본다(모두 켜져 있으면[건너뜀]). 게이트웨이가 402 면 아무것도 바꾸기 전에 멈춘다 — 백엔드의 대리 접근이 게이트웨이 자신의 2단계 인증에 걸려 모두 막히고 웹메일 전체가 선다. 관리자 · 복구 관리자에 2단계 인증이 켜져 있어도 멈춘다(비밀번호만으로 관리 API 에 들어가지 못한다).- 운영에서는
change-me예시 값을 거부하고,MAIL_PUBLIC_URL이 경로 없는https://가 아니면 실패, 발송 전략이 Mailpit 을 가리키면 실패로 보고한다. - 운영의 복구 관리자 규칙 — env 파일에 있으면 실패, 셸에 있는데
--recovery가 없으면 실패,--recovery실행은 끝에 "복구 관리자 끄기"를 안내하고 종료 코드 3, 그 뒤 일반 실행은 mail 컨테이너에 복구 관리자가 남아 있으면 실패. - 관리자 역할 계정이 백엔드 차단 목록(
MAIL_ADMIN_LOCAL_PART관리자 +MAIL_BLOCKED_ACCOUNTS)에서 빠져 있으면[주의]. - 종료 코드
0성공 ·1실패 ·3후속 조치 필요(개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영--recovery뒤 복구 관리자 끄기 — 안내대로 한 뒤 다시 실행).
백엔드 연결 정보
| env (backend 에 compose 가 주입) | 값 |
|---|---|
MAIL_JMAP_URL |
http://mail:8080 (내부 JMAP) |
MAIL_DOMAIN |
arasystem.test / 운영 arasystem.it.kr |
MAIL_GATEWAY_USER · MAIL_GATEWAY_PASSWORD |
게이트웨이 주소 · 비밀번호 |
MAIL_ATTACHMENT_MAX_SIZE_MB |
첨부 1개 최대 크기 (기본 20). 프론트 nginx 본문 한도는 이 값 + 1MB 로 자동, 외부 프록시는 직접 — docker 스킬 "운영 환경" |
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) — 웹훅 받는 쪽이 접속 주소(소켓)가 이 대역인지 본다 |
- 로그인 확인:
GET {MAIL_JMAP_URL}/jmap/session+ Basic<직원 주소>:<비밀번호>→ 200 / 401 - 메일함 접근: Basic
<직원 주소>%<MAIL_GATEWAY_USER>:MAIL_GATEWAY_PASSWORD(게이트웨이 비밀번호 틀림·없는 직원 → 401, 대리 접근 권한 없는 계정 → 403) - 사내 목록: Basic
<MAIL_DIRECTORY_LOCAL_PART>@<MAIL_DOMAIN>%<MAIL_GATEWAY_USER>:MAIL_GATEWAY_PASSWORD→ 세션의urn:ietf:params:jmap:principals계정으로Principal/query·Principal/get(사내 목록 계정 — 계정이 없으면 401, 역할에authenticate가 없으면 403) - ⚠️ 세션 응답의
apiUrl·downloadUrl·uploadUrl·eventSourceUrl은MAIL_PUBLIC_URL기준 절대 주소다 (개발http://localhost:8180/jmap/). 백엔드는 이 주소를 따르지 말고MAIL_JMAP_URL에 경로만 붙여 쓴다. Email/query의subject필터는 단어 단위 검색이고 제목 앞의[태그]를 떼고 색인하는 것으로 보인다 — 부분 문자열 검색이 아니다.- 대리 접근은 메일 엔진 로그
auth.success에accountName(대상 직원)·details(게이트웨이)·remoteIp로 남는다. - 신뢰 대역은 자동 차단에서 빠지므로 로그인 무차별 대입 방어는 백엔드가 맡아야 한다 (로그인 엔드포인트 엄격 한도 — security).
사내 목록 계정
백엔드가 사내 목록(계약 3절 "사내 목록" — GET /api/directory-entries 와 받는 사람 추천의 "사내")을 채우려고
엔진의 계정 목록(JMAP Principal)에서 직원 이름·주소만 읽는 전용 계정이다. 직원 역할에서는 이 권한을 뺐으므로(보안) 계정 목록을 읽는 길은 이 계정 하나뿐이다.
| 항목 | 값 |
|---|---|
| 주소 | <MAIL_DIRECTORY_LOCAL_PART>@<MAIL_DOMAIN> (기본 mail-directory@) — 관리자·게이트웨이 아이디와 같으면 setup 이 아무것도 바꾸기 전에 멈춘다 |
| 역할 | 설명 사내 목록 — 계정 목록 읽기 전용 [setup.mjs] · 권한 authenticate·jmapPrincipalGet·jmapPrincipalQuery 뿐 (빼는 권한·물려받는 역할 없음). authenticate 가 없으면 대리 접근이 403 이다 |
| 계정 | 표시 이름 사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs] · 비밀번호 없음 · 저장 한도 quotas.maxDiskQuota 1,048,576바이트 |
| 쓰는 법 | 백엔드만 — 게이트웨이 대리 접근 <사내 목록 주소>%<MAIL_GATEWAY_USER> 로 세션의 urn:ietf:params:jmap:principals 계정에 Principal/query·Principal/get |
| 엔진이 주는 칸 | id·type(individual 개인 · group 공용 주소)·name(로그인 이름)·description(표시 이름)·email 뿐 — 비밀번호 같은 칸은 없다. 관리자·게이트웨이·이 계정도 목록에 나오므로 빼는 것은 백엔드 몫이다 (계약 3절 "빼는 계정") |
| 메일 | 받지 않는다 — 이 주소(+태그 꼴 포함)로 오는 메일은 RCPT 에서 없는 주소처럼 550 이다(보안 "메일을 받지 않는 시스템 계정"). 그 뒤의 막으로 역할에 emailReceive 가 없고(배달하지 않는다) 저장 한도도 작다 |
- 왜 복구 관리자인가 — 엔진 관리자 역할(System Administrator)에는
jmapPrincipal*이 없고, 엔진은 역할·계정의 권한을 바꿀 때 바꾼 뒤의 권한 전체를 바꾸는 쪽이 가졌는지 본다. 그래서 관리자로는 이 역할을 만들 수도, 계정에 줄 수도, 이 역할에서 다른 권한을 뺄 수도 없다 (You are not authorized to grant permissions: jmapPrincipalQuery, jmapPrincipalGet). 관리자로 되는 것은 계정의 표시 이름·저장 한도 고치기, 비밀번호 빼기, 계정 지우기다 (2026-10-29, 0.16.27 에서 확인). - 언제 만들어지나 — 개발은 setup 을 돌릴 때마다(env 파일에 복구 관리자가 있다). 운영은 운영 배포의
--recovery실행(첫 배포)에서 만들어진다. 이미 운영 중인 서버는 같은 절차(1 복구 관리자 주기 →up -d mail→ 3--recovery→ 4·5 끄기)를 한 번 더 한다 — 이미 맞는 나머지는[유지]다. - 복구 관리자 없이 돌리면(운영의 보통 실행) — 읽어서 확인하고, 관리자로 되는 것(표시 이름·저장 한도·비밀번호 빼기·옛 계정 지우기)만 맞춘다.
역할·계정이 없거나 역할이 다르면
[주의]만 내고 실패로 끝내지 않는다 — 그동안 백엔드는 사내 목록 대신 로그인한 직원 기록(최근 90일)을 쓴다 (계약 3절).--recovery로 돌렸는데 복구 관리자로 로그인하지 못해 맞추지 못했으면 실패로 끝난다(mail 컨테이너를 그 값으로 다시 만들었는지 본다). - 비밀번호가 없다 — 이 계정으로는 직접 로그인할 수 없고, 게이트웨이 비밀번호를 가진 백엔드만 대리 접근으로 쓴다. 누가 비밀번호를 넣으면 그것으로 계정 목록(관리자·게이트웨이 주소 포함)을 읽을 수 있으므로 setup 이 다음 실행에서 뺀다(관리자로도 된다).
- 이 역할은 기본 직원 역할(
User)과 따로다 — 직원 역할에서jmapPrincipal*을 빼는 단계(setup 스크립트가 하는 일 6)는 이 역할을 건드리지 않는다.
지우거나 바꾸면:
| 한 일 | 생기는 일 | 되돌리기 |
|---|---|---|
| 계정을 지움 | 대리 접근이 401 → 사내 목록 503 MAIL_001, 받는 사람 추천의 "사내"는 로그인한 직원 기록으로 |
복구 관리자로 setup (운영 --recovery) |
| 역할을 지움 | 계정이 쓰는 동안은 엔진이 거절한다(objectIsLinked) — 계정을 먼저 지워야 지워진다 |
위와 같다 |
| 역할에 권한을 더함 · 물려받는 역할을 붙임 | 그 권한이 이 계정(= 게이트웨이를 가진 백엔드)에 열린다 — setup 이 [변경](복구 관리자) 또는 [주의] |
복구 관리자로 setup — 관리자로는 뺄 수 없다 |
역할에서 authenticate·jmapPrincipal* 을 뺌 (빼는 권한에 넣음 포함) |
계정 목록을 읽지 못한다 → 사내 목록 503 MAIL_001 |
복구 관리자로 setup |
| 계정의 역할을 바꿈 | 위의 둘 중 하나 | 복구 관리자로 setup |
| 계정에 비밀번호를 넣음 | 그 비밀번호로 계정 목록을 읽을 수 있다 | setup (관리자로도 뺀다) |
| 다른 계정에 이 역할을 줌 | 그 계정이 계정 목록 전체를 읽는다 — setup 이 [주의] |
관리 화면 › 계정에서 그 계정의 역할을 바꾼다 (관리자로 된다) |
MAIL_DIRECTORY_LOCAL_PART 를 바꿈 |
복구 관리자로 돌린 setup 이 새 계정을 만들고, 그 뒤에 옛 계정(setup 표시 + 이 역할 하나)을 지운다 — backend 도 다시 띄운다(up -d backend) |
값을 되돌리고 같은 절차 |
- 확인 (2026-10-29, 개발 엔진 0.16.27): 처음 실행은
[생성]둘(역할·계정)과[확인] 사내 목록 — 대리 접근 … (Principal/query · get), 다시 돌리면[유지]. 역할에 권한·빼는 권한·물려받는 역할을 붙이고 계정에 비밀번호를 넣은 뒤 돌리면 복구 관리자로 모두[변경], 복구 관리자 없이 돌리면 계정만[변경](관리자로)이고 역할은[주의]· 종료 코드 0. 계정을 지우고 복구 관리자 없이 돌리면[주의] 사내 목록 계정이 없습니다 (계정 없음)· 종료 코드 0. 아이디를 바꿨다 되돌리면[생성]새 계정 ·[삭제]옛 계정.
운영 배포
사전 조건:
- 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 대안 참조 · 프록시가 다른 서버에 있고 사설망으로 붙으면 리버스 프록시가 다른 서버에 있을 때)
MAIL_HOSTNAME의 A 레코드 → 이 호스트 IP, 그 IP 의 PTR →MAIL_HOSTNAME(PTR)- 아웃바운드 25 허용 (클라우드는 기본 차단인 곳이 많다 — 막혀 있으면 외부 릴레이)
- 아웃바운드 HTTPS — Stalwart 는 기동 때 GitHub 에서 웹 포털을 내려받고, ACME(Let's Encrypt)로 인증서를 받는다
- 메일 서버 전용 재귀 DNS — compose 에 들어 있다(
mail-resolver— 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막힌다). 이 서버에서 바깥 53(UDP · TCP)으로 나갈 수 있어야 한다 — 리졸버가 루트부터 각 도메인의 DNS 서버에 직접 묻는다. 막혀 있으면 setup 이 사전 확인에서 멈춘다 (DNS 리졸버와 스팸 차단 목록) - Spamhaus 이용 조건 — 공개 미러는 쓰지 않는다(소규모 · 비상업 용도). Spamhaus 의 "Blocklists via DNS Query" 키를
.env.production의MAIL_SPAMHAUS_DQS_KEY에 넣으면 두 항목을 그 키로 묻고, 비워 두면 두 항목이 꺼진다. 무료 계정은 Fair Use(소규모 · 비상업) 조건이다 — 회사가 대상인지는 Spamhaus 가 판단하고, 안 되면 끈 채로 두거나 유료(30일 체험). 체험 키는 30일이 지나면 답이 멈춘다 — 구독하거나 키를 비우고 setup (멈춘 줄은 다음 setup 이 종료 1 로 알린다 — 주기적으로 돌리거나 계정의 만료일을 본다). 나머지 16개 목록도 이용 조건을 확인한다 (같은 절) - 엔진 한도 — 받는 쪽 한도 값, 직원마다 내보내는 양의 한도, 직원마다 1시간 업로드 한도(크기 · 개수)를 정한다 (엔진 한도)
- 포털(웹메일) 앞의 외부 리버스 프록시 — 요구사항은 docker 스킬 "운영 환경" (같은 주소 배포 ·
X-Forwarded-For· IPv6 · 본문 한도·시간 제한). 다른 서버의 Nginx Proxy Manager 라면 설정 ·TRUST_PROXY는 리버스 프록시가 다른 서버에 있을 때 APP_NETWORK_SUBNET·EDGE_NETWORK_SUBNET·MAIL_RESOLVER_SUBNET이 그 호스트의 다른 도커 네트워크와 겹치지 않는다 (겹치면up이 "Pool overlaps" 로 실패한다)- 저장소는 기본 umask(022)로 클론한다 —
mail/resolver/unbound.conf를 리졸버 컨테이너 사용자(101:102)가 읽어야 한다(umask 077 이면 리졸버가 뜨지 않는다) - rootful 도커 + iptables (도커 기본값). 게시 포트(25·465·993·443)로 들어온 접속이 원래 주소 그대로 컨테이너에 보여야 한다 —
rootless 도커(포트 전달이 주소를 바꾼다)나
"iptables": false(모든 접속이 docker-proxy 를 거친다)면 외부 접속이 도커 내부 주소로 보여 자동 차단·게이트웨이 IP 제한이 무력화된다. 배포 뒤 원래 접속 주소 확인으로 확인한다. - setup 을 돌릴 사용자 — docker 를 쓸 수 있어야 한다(docker 그룹, 또는 매번
sudo -E). 저장소는 그 사용자로 클론한다(sudo git clone금지) —data/가 그 사용자 소유여야 setup 이 bootstrap 임시 비밀번호를 보관할 수 있다 (data/.gitkeep이 있어 클론 때 생긴다). MAIL_ADMIN_LOCAL_PART를 bootstrap 전에 정한다 — mailcow 에 실제admin@메일함이 있으면 다른 아이디로 (bootstrap 뒤에는 바꿀 수 없다).MAIL_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본mail-directory)가 옮겨 올 메일함과 겹치지 않는다 — 겹치면 setup 이 그 계정을 건드리지 않고 실패한다 (사내 목록 계정).- 대용량 첨부(11단계) —
.env.production의SHARE_PUBLIC_URL(공개 내려받기 호스트 —https://files.arasystem.it.kr,CORS_ORIGIN에 넣지 않는다) ·CLAMAV_HOST=clamav(둘 다 운영 필수 — 없으면 백엔드가 뜨지 않는다), 그 호스트의 DNS · 외부 프록시 Proxy Host (대용량 첨부 — 공개 내려받기 호스트), 악성 검사 서명 갱신의 바깥 HTTPS(database.clamav.net) · 메모리 · 디스크(메모리 예산 — 8GB 서버에 스택 전체 + ClamAV). 첫up은clamav-updater가 서명을 받는 동안(1~3분)clamav가 기다린다 - 계정 보안(12단계) —
.env.production의MAIL_WEBHOOK_SECRET(엔진 웹훅의 서명 키 —openssl rand -hex 24로 만든 값, 사람이 지은 값은 쓰지 않는다). 운영 필수다 — 아래 2)의up전에 넣는다(없으면 백엔드가 env 검사에서 멈춰 웹메일 전체가 서고, setup 도 멈춘다). 직원에게 미리 알린다 — 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 앱 비밀번호로 다시 연결해야 한다 (계정 보안) - 전환 준비(13단계) — 이미 떠 있는 서버는 13단계 전부터 운영 중인 서버의 차례대로(첫 setup 은
--recovery). 첫 직원을 옮기기 전에 백업과 15분 점검을 켠다: NCP Object Storage 비공개 버킷 · 백업 전용 API 인증키(아래 "백업 › 설치" — 메인 계정 키를 고른 까닭과 위험) · restic 비밀번호(비밀번호 관리자에 따로 보관), Healthchecks.io 점검 둘 · UptimeRobot 감시 셋 (백업 · 감시)..env.production의DB_IMAGE를 예시와 같은 값(판 · digest 고정 — 옛 파일은postgres:15-alpine)으로 바꾼다 — 다음up -d에서 db 가 다시 만들어진다(몇 초 · 데이터는 볼륨 그대로, 같은 메이저 15 라 덤프 · 복원은 필요 없다). frontend 는MAIL_DOMAIN·MAIL_HOSTNAME으로 메일 프로그램 자동 설정 응답을 만든다 — 이미 있는 값이라 새로 넣을 것은 없다
복구 관리자(MAIL_RECOVERY_ADMIN)는 .env.production 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다.
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)"
# 1) 복구 관리자를 일회성으로 준다 — 셸 기록에 남지 않게 read 로 받는다 (compose 는 셸 값이 env 파일보다 우선한다)
read -rsp '임시 복구 비밀번호: ' P; echo; export MAIL_RECOVERY_ADMIN="admin:$P"
# 2) 기동 (mail 은 bootstrap 모드로 뜬다 — 8080 만 열림). sudo 를 쓰면 -E 로 셸 값을 넘긴다
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
# docker 그룹이 아니면 sudo 로 (셸 값과 PATH 를 넘긴다). ⚠️ 그러면 이후 실행도 모두 같은 형태로 — data/mail/ 이 root 소유가 된다
sudo -E env "PATH=$PATH" node --env-file=.env.production mail/scripts/setup.mjs --recovery
# (B) 호스트에 Node 가 없으면 — 공식 배포 파일을 임시 폴더에 받아 그대로 쓴다 (설치하지 않는다, 체크섬 확인. ARM 서버는 linux-arm64)
NODE_DIST=node-v24.22.1-linux-x64; NODE_TMP="$(mktemp -d)"
curl -fsSL -o "$NODE_TMP/$NODE_DIST.tar.xz" "https://nodejs.org/dist/v24.22.1/$NODE_DIST.tar.xz"
curl -fsSL "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt" | grep " $NODE_DIST.tar.xz\$" | (cd "$NODE_TMP" && sha256sum -c -)
tar -xJf "$NODE_TMP/$NODE_DIST.tar.xz" -C "$NODE_TMP" && export PATH="$NODE_TMP/$NODE_DIST/bin:$PATH"
# → 이후는 (A) 와 같다. 끝나면 rm -rf "$NODE_TMP"
# --recovery 실행은 설정을 마친 뒤 "복구 관리자 끄기"를 안내하고 종료 코드 3 으로 끝난다 → 4) 로
# 4) 복구 관리자 끄기 — 셸 값을 지우고 mail 컨테이너를 다시 만든다 (빈 값이면 복구 자격 증명은 401 — 검증함)
unset MAIL_RECOVERY_ADMIN P
sudo docker compose --env-file .env.production up -d mail
# 5) --recovery 없이 다시 실행 (3 과 같은 형태 — 컨테이너를 다시 만들었으므로 MAIL_ADMIN_URL 도 다시 구한다)
# 전부 [유지] 이고, 컨테이너에 복구 관리자가 남아 있지 않은지 확인한다 (남아 있으면 실패)
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
- 3)의
--recovery실행은 사내 목록 계정도 만든다 — 엔진 관리자는 그 권한을 내줄 수 없어 복구 관리자로만 된다 (사내 목록 계정). 이 기능(8단계) 전부터 운영 중인 서버는 1)~5) 를 한 번 더 한다. 5)의 보통 실행은 그 계정이 있고 맞는지 읽어서 확인하고, 없으면[주의]만 낸다. - DNS — 2)의
up이 메일 전용 리졸버(mail-resolver)를 mail 보다 먼저 띄운다. 3)에서 setup 이 리졸버를 확인한 뒤 엔진의 DNS 를 그쪽으로 바꾸고([변경] 엔진 DNS — … Custom), 4)에서 다시 만든 mail 은 그 설정으로 뜬다 — 5)의 출력에[확인] 메일 전용 리졸버 …와[확인] 엔진 기동 로그(…)에 DNSSEC 경고가 없다가 있어야 한다. Spamhaus 키가 있으면[확인] Spamhaus 키 … 시험 항목 …두 줄, 없으면[주의] MAIL_SPAMHAUS_DQS_KEY 가 없어 …다 (DNS 리졸버와 스팸 차단 목록). 리졸버 전에 띄운 서버(이 기능 전 배포)는sudo docker compose --env-file .env.production up -d(mail 이 다시 만들어진다 — 몇 초 끊긴다) 뒤 5)와 같은 형태로 setup 을 한 번 돌린다.up이optional dependency "mail-resolver" failed to start경고를 내면 mail 은 떴지만 리졸버가 unhealthy · 멈춤이다 — 리졸버 장애 때. - 엔진 웹훅(12단계) — 5)의 출력에
[유지] 엔진 웹훅 → http://backend:3000/api/internal/mail-engine-events …(처음은[생성]),[확인] 엔진 웹훅 켜짐 — …,[확인] 엔진 웹훅 — 메일 컨테이너에서 서명한 빈 묶음을 백엔드가 받았다 (HTTP 200 …)가 있어야 한다. 3)은 백엔드가 아직 뜨는 중이면 마지막 줄이[주의] … 닿지 못했습니다일 수 있다(5 에서 본다) — 나머지 확인은 계정 보안 — 운영 확인 - 12단계 전부터 운영 중인 서버 — 차례가 중요하다(키보다 새 이미지를 먼저 띄우면 백엔드가 env 검사에서 멈춰 웹메일 전체가 선다):
.env.production에MAIL_WEBHOOK_SECRET을 넣는다 —printf 'MAIL_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 24)" >> .env.production(값은 화면에 찍지 않는다. 파일 끝에 줄바꿈이 없으면 앞 줄에 붙으니 먼저tail -c1 .env.production | xxd로0a인지 본다)sudo docker compose --env-file .env.production up -d --build— backend(마이그레이션AddAccountSecurity가 뜰 때 적용된다)와 frontend(/api/internal/을 닫는 nginx 스니펫 · 잘못된 요청 대상의 400)를 함께 새로 만든다- backend 가 healthy 가 된 뒤(
sudo docker compose --env-file .env.production ps backend) 3)처럼MAIL_ADMIN_URL을 구해 setup(복구 관리자 없이 — 5 와 같은 형태) →[생성] 엔진 웹훅·[확인] 엔진 웹훅 — … 받았다 - 운영 확인 (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. 전면 전환 때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.
bootstrap 직후에 멈췄다면 (다시 실행하기 전에)
setup 이 bootstrap 과 재시작 사이(또는 직후)에 멈췄으면 — 연결 끊김, 권한 오류, 종료 코드 1 — 볼륨을 지우거나 Bootstrap/set 을 손으로 부르지 않는다.
- 엔진이 어디까지 갔는지 본다:
sudo docker exec "${PROJECT_NAME}-mail" test -s /etc/stalwart/config.json && echo "bootstrap 끝남" - 실패 원인을 고친다 — 출력의
[실패]줄.data/mail에 쓸 수 없다고 나왔다면 소유자를 바꾼다:sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail(sudo 로 돌릴 거라면 그대로 둔다 — 매번 같은 형태로) - 셸에
MAIL_RECOVERY_ADMIN이 남아 있는 채로 같은 명령(--recovery)을 다시 실행한다. bootstrap 이 끝났으면 재시작만 하고(config.json 으로 판단 — 임시 비밀번호 파일이 없어도 된다) 복구 관리자로 관리자 비밀번호·아이디를 맞춘다. bootstrap 이 거부되었습니다 (primaryKeyViolation)가 나오면 문제 해결 의 해당 줄을 본다.
원래 접속 주소 확인 (배포 직후)
자동 차단·게이트웨이 IP 제한은 접속 주소를 믿고 동작한다. 바깥(다른 네트워크의 PC)에서 실제 계정(본인 메일함)으로 IMAP 에 한 번 로그인한 뒤,
메일 엔진 로그의 auth.success 줄의 remoteIp 가 그 PC 의 공인 주소인지 본다.
도커 내부 주소(172.16.71.1 같은 게이트웨이 등)로 보이면 rootless·iptables 설정을 고친다.
- 틀린 비밀번호로는 확인할 수 없다 — setup 이 정한 로그 수준(info)에서는 실패한 로그인이 로그에 남지 않는다
(2026-10-27 Stalwart 0.16.27 에서 IMAP·HTTP 모두 확인. 성공은
auth.success … listenerId = "imaps", remoteIp = …로 남는다).
# 바깥 PC 에서 — 연결되면 아래 두 줄을 직접 입력한다 (명령에 비밀번호를 적지 않아 셸 기록에 남지 않는다)
openssl s_client -quiet -connect mx1.arasystem.it.kr:993
# a LOGIN <본인 주소> <비밀번호>
# a LOGOUT
# 메일 호스트에서
sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep 'auth.success' | grep 'imaps' | tail -3
- 실제 직원 계정은 운영 도구 「입사」로 만든다(13단계 — 계약 6절 "운영 도구". mailcow 에서 메일을 옮겨 올 사람은 imapsync 의 이전용 비밀번호로).
관리 화면이나 관리 API(
x:Account/set)로도 만들 수 있다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 mail 컨테이너의 앱 네트워크 주소:8080 에 붙는다 (13단계부터 관리자는 앱 네트워크 안에서만 로그인된다 — 명령은 관리 화면 — SSH 터널). setup 은 운영에서 직원 계정을 만들지 않는다. ⚠️ 공용 주소(Group 계정)는 일반 관리자로 만들 수 없다 — Group 역할에 관리자 역할에는 없는 Principal 권한 13개가 있어You are not authorized to grant permissions: jmapPrincipalCreate, …로 거절된다(2026-10-29 확인, 직원 계정 · 그룹에 구성원 넣기는 관리자로 된다). 공용 주소를 만들 때는 복구 관리자를 쓴다 ("운영 배포" 1)·up -d mail뒤 그 아이디로 관리 화면 · 관리 API → 4)·5) 로 끈다). MAIL_HOSTNAME은 bootstrap 때만 반영된다. 바꾸려면 관리 화면에서 고친다.- 관리자 비밀번호를 바꾸려면: env 의
MAIL_ADMIN_PASSWORD변경 → 위 1)·up -d mail→--recovery실행 → 4)·5). - 이미 서브넷을 고정하기 전에 만든 환경이라면 처음 한 번은
sudo docker compose --env-file .env.production down후up -d로 네트워크와 컨테이너를 함께 다시 만든다. 그냥up -d만 하면 네트워크는 새로 만들어지지만 재생성되지 않은 컨테이너가 서비스 이름(db·mail등) 별칭을 잃어 백엔드가getaddrinfo ENOTFOUND db로 뜨지 않는다 (개발 환경에서 확인 —up -d --force-recreate로도 풀린다).
PTR
메일 호스트 IP 의 역방향 DNS 를 MAIL_HOSTNAME(전환 기간에는 mx1.arasystem.it.kr)으로 설정한다
(가비아가 아니라 IP 를 준 호스팅사 콘솔 또는 지원 요청).
A(MAIL_HOSTNAME) → IP 와 PTR(IP) → MAIL_HOSTNAME 이 서로 맞고(FCrDNS), SMTP 인사말(EHLO = MAIL_HOSTNAME)과도 같아야
Gmail·네이버 등이 거부하거나 스팸 처리하지 않는다.
외부 릴레이 (SES 등)
아웃바운드 25 가 막혀 있거나 IP 평판이 나쁠 때 외부 메일을 릴레이로 보낸다. 개발의 Mailpit 경로와 같은 방식이고 인증만 다르다.
관리자 Basic 인증으로 POST {관리 API}/jmap/ (using: ["urn:ietf:params:jmap:core","urn:stalwart:jmap"]):
[
["x:MtaRoute/set", { "accountId": "<세션의 urn:stalwart:jmap 계정>", "create": { "ses": {
"@type": "Relay", "name": "ses", "description": "외부 발송 — Amazon SES",
"address": "email-smtp.ap-northeast-2.amazonaws.com", "port": 587,
"authUsername": "<SMTP 사용자>",
// 비밀번호를 DB 에 두지 않고 mail 컨테이너의 환경변수에서 읽는다 → compose 의 mail.environment 에 추가
"authSecret": { "@type": "EnvironmentVariable", "variableName": "MAIL_RELAY_PASSWORD" }
} } }, "0"],
["x:MtaOutboundStrategy/set", { "accountId": "<…>", "update": { "singleton": { "route": {
"match": { "0": { "if": "is_local_domain(rcpt_domain)", "then": "'local'" } }, "else": "'ses'" } } } }, "1"],
// 리로드 전에는 반영되지 않는다
["x:Action/set", { "accountId": "<…>", "create": { "r": { "@type": "ReloadSettings" } } }, "2"]
]
- SPF 에 릴레이 쪽을 넣는다 (예: SES 는
include:amazonses.com). DKIM 은 Stalwart 가 보내기 전에 서명한다. - 포트별 TLS 방식(587 STARTTLS / 465 암묵적 TLS) 속성은 관리 화면에서 확인한다. 이 예시는 실제 SES 로 검증하지 않았다.
DNS 리졸버와 스팸 차단 목록 (DNSBL)
스팸 필터는 기본으로 외부 차단 목록(DNSBL) 18개를 DNS 로 조회한다 — Spamhaus(zen IP · dbl 도메인), SURBL, URIBL, Barracuda, SpamCop,
DNSWL, Mailspike, SenderScore 등 (목록: 관리 화면 Settings › Spam Filter › DNSBL, 또는 x:SpamDnsblServer/query).
이 목록들은 Stalwart 와 별개 서비스라 각자 이용 조건이 있다. 그리고 공용 · ISP DNS(KT 168.126.63.1 · 8.8.8.8 · 1.1.1.1 등)를 거친 조회는
Spamhaus 가 거부한다 — 오류 코드 127.255.255.254 (2026-10-26 개발 환경의 KT DNS 로 늘 등록된 시험 주소를 물어 확인).
그래서 메일 엔진 전용 재귀 리졸버를 compose 에 두고 엔진의 DNS 를 그쪽으로 돌린다. DNSSEC 도 검증해 기동 로그의
"DNS resolver cannot validate DNSSEC. DANE has been disabled" 경고가 사라진다.
메일 전용 리졸버 (mail-resolver)
mail ──(mail-dns — internal, MAIL_RESOLVER_SUBNET)──▶ mail-resolver MAIL_RESOLVER_IP:53 ──(mail-dns-egress)──▶ 루트 · 각 도메인의 DNS 서버
└──(app-network)──▶ 바깥 25 · 443 (메일 · ACME) — 그대로 (서버 자신의 공인 IP 로)
| 항목 | 값 |
|---|---|
| 서비스 | docker-compose.yml 의 mail-resolver (profile mail — mail 과 함께 뜬다, mail 은 리졸버가 healthy 가 된 뒤에 뜬다. 리졸버가 unhealthy 거나 떠 있지 못하면 compose 가 경고만 내고 mail 을 띄운다 — required: false, 리졸버 장애 때). 설정 resolver/unbound.conf |
| 이미지 | klutchell/unbound:1.26.2 — NLnet Labs 원본 소스를 체크섬으로 확인해 빌드한 distroless 이미지(셸 없음, drill 포함 · 비공식). digest sha256:50cbeb32b583…(2026-10-30 latest 와 같음). 1.26.2 는 보안 릴리스(2026-10-21 — CVE-2026-96405 이름 압축 풀기로 서비스 저하: 바깥 DNS 서버의 응답으로 닿는다)라 2주를 기다리지 않았다 (docker 스킬 "이미지 버전 고정") |
| 네트워크 | mail-dns — mail ↔ 리졸버 전용, internal(이 네트워크로는 바깥에 못 나간다), 서브넷 MAIL_RESOLVER_SUBNET(기본 172.16.73.0/24) · 리졸버 주소 MAIL_RESOLVER_IP(기본 172.16.73.254 — 서브넷의 끝 쪽. 도커는 mail 에 앞쪽부터 차례로 준다). mail-dns-egress — 리졸버의 바깥 조회 길, 리졸버 혼자(서브넷은 도커가 정한다). app-network(신뢰 대역)에는 붙이지 않는다 |
| 메일 엔진만 닿게 | 두 네트워크에 붙는 것은 mail · 리졸버뿐(setup 이 확인 — 운영은 그 밖이 있으면 중단). 호스트 포트를 열지 않는다. unbound 의 access-control 은 사설 대역 · 자기 자신만 받는다 — 누가 53 을 공개해도 바깥에서 쓰는 열린 리졸버가 되지 않게. 알려진 한계 — mail-dns 는 양쪽으로 열린 브리지라 리졸버 쪽에서도 mail 의 포트(관리 API 8080 포함)에 닿는다. 그 주소는 신뢰 대역(APP_NETWORK_SUBNET) 밖이라 자동 차단 예외 · 게이트웨이 로그인은 받지 못하고, 관리 API 는 관리자 자격 증명이 있어야 한다 — 리졸버가 뚫렸을 때의 일이라 HTTP 정책 식은 바꾸지 않았다 |
| 실행 | 비-root 101:102(이미지의 unbound 사용자 — 이미지는 root 로 떠서 내려가는 꼴이라 처음부터 이 사용자로 띄운다) · 권한(capability) 모두 뺌 · no-new-privileges · 메모리 128m. 비-root 가 53 에 붙는 것은 도커가 컨테이너마다 net.ipv4.ip_unprivileged_port_start=0 으로 두기 때문이다 |
| DNSSEC | 검증한다 — 루트 신뢰 앵커는 이미지에 든 root.key(KSK-2017 · KSK-2024)이고 RFC 5011 로 스스로 고쳐 쓴다(그래서 read_only 로 두지 않는다). 2026-10-30 의 루트 DNSKEY 는 KSK-2024(key tag 38696)가 서명한다 |
| 그 밖의 설정 | 사설 주소로 풀리는 바깥 이름은 버린다(private-address — 바깥 도메인의 MX 가 VPC · 도커 네트워크를 가리켜도 엔진이 그쪽으로 보내지 않게, 127/8 은 넣지 않는다 — DNSBL 은 127.0.0.x 로 답한다) · 리졸버 자신도 사설 주소의 DNS 서버에는 묻지 않는다(do-not-query-address — 같은 대역. 바깥 도메인이 이름 서버 주소(glue)를 사설 주소로 두어 리졸버가 VPC · 호스트 · 도커 네트워크의 53 에 묻게 만드는 것을 막는다, 127/8 은 기본값이 막는다) · 조회 이름은 필요한 만큼만 위로(QNAME minimisation — 루트 · .net 서버는 DQS 키가 든 이름을 보지 못한다) · 로그는 동작 정보만(verbosity: 1 — 조회 이름을 남기지 않는다) |
| healthcheck | drill-hc localhost @127.0.0.1 — 데몬이 답하는지만 본다(바깥 DNS 가 잠깐 막혀도 mail 이 뜨지 못하는 일이 없게). 바깥 조회 · DNSSEC 은 setup 이 본다 |
compose 서비스로 둔 까닭 (호스트에 unbound 를 까는 것과 견줌):
- 개발 · 운영이 같은 정의다 — 개발(Docker Desktop)에서 그대로 띄워 시험하고, 버전 고정 · CI 검사(
unbound-checkconf) · 배포 절차(up -d)에 들어간다. 호스트 패키지는 저장소 밖의 상태라 서버를 다시 만들 때 빠뜨리기 쉽다. - 메일 엔진만 닿게 네트워크로 가를 수 있다 — 호스트에 깔면 모든 컨테이너(인터넷을 향한 frontend 포함)가 호스트 주소로 닿아, 막으려면 도커의 iptables 와 얽힌 호스트 방화벽 규칙이 필요하다. Ubuntu 의 systemd-resolved(127.0.0.53:53)와 자리도 겹친다.
- 대가 — 이미지를 판까지 고정하고 우리가 올린다(호스트 apt 는 자동 보안 업데이트를 받는다). NLnet Labs 의 보안 릴리스 공지(https://nlnetlabs.nl/projects/unbound/security-advisories/)를 본다. 리졸버는 엔진 DNS 의 단일 지점이다 — 멈추면 바깥 메일의 DNS 가 모두 실패한다(보내는 메일은 대기열에서 다시 시도, 받는 메일은 검사가 약해진다). 그래서 공용 DNS 로 넘어가는 대체 길은 두지 않는다 — 넘어가면 Spamhaus 가 다시 막는다.
엔진의 DNS (x:DnsResolver 싱글톤 — 0.16.27 의 @type 은 System · Custom · Cloudflare · Quad9 · Google, Custom 은 servers { "<n>": { protocol udp|tcp|tls, address(IP), port } }.
@type 만 바꾸는 부분 갱신을 받고 공통 칸(attempts · timeout · enableEdns …)은 그대로 둔다 — 2026-10-30 개발 엔진에서 읽고 바꿔 확인):
| 환경 | setup 이 맞추는 값 | 길 | 까닭 |
|---|---|---|---|
| 운영 | Custom — MAIL_RESOLVER_IP:53 의 UDP + TCP |
엔진 → 리졸버 | 도커 DNS 를 거치지 않아 도커 내부 이름(backend · db …)이 엔진에 풀리지 않는다 — 바깥 도메인의 MX 가 내부 이름을 가리켜도 닿지 않는다. 엔진은 TCP 연결이 있어야 그 리졸버로 DNSSEC 을 믿는다(소스 common/src/config/smtp/resolver.rs 의 ensure_dnssec) |
| 개발 | System |
엔진 → 도커 DNS(127.0.0.11) → 리졸버 | 개발의 외부 발송 경로가 도커 내부 이름 mailpit 이다 — Custom 이면 그 이름을 풀지 못해 바깥 주소 앞 메일이 delivery.ip-lookup-failed 로 영구 실패(반송) 했다(2026-10-30 개발). 도커 DNS 는 바깥 이름을 compose 의 dns(= 리졸버)로 넘기고 DNSSEC 자료(RRSIG)도 UDP · TCP 그대로 넘긴다 |
- mail 컨테이너의
dns는 두 환경 모두 이 리졸버다 — 컨테이너 안의 이름 풀기(ACME · 웹 포털 내려받기 · setup 의getent·curl)도 리졸버로 간다. - 바뀐 값은 setup 의
ReloadSettings로 반영된다(리로드가 엔진 설정 전체를 다시 만든다). 다만 DNSSEC 확인의 경고는 기동 때만 로그에 남는다(리로드 응답 · 로그에는 없다) — setup 은 이번 기동의 로그에서 그 경고를 찾는다([확인] 엔진 기동 로그(…)에 DNSSEC 경고가 없다). 엔진 DNS 를 그 실행에서 바꿨다면 다음 기동부터 새 설정의 확인이다. - 서버를 다시 켜면 도커가 compose 의 차례(리졸버 먼저)를 지키지 않아 mail 이 먼저 뜰 수 있다 — 그러면 그 기동은 DNSSEC 을 믿지 못한 채(DANE 꺼짐) ·
스팸 필터의 Pyzor 없이(주소를 풀지 못해 그 항목만 빼고 뜬다 — 리졸버 장애 때 3.) 뜨고,
바깥 DNS 는 리졸버가 뜬 뒤부터 된다. 재부팅 뒤에는
sudo docker compose --env-file .env.production restart mail뒤 setup 으로[확인]을 본다 (개발에서 리졸버를 멈춘 채 mail 을 띄워 이 경고가 남고 setup 이[주의]로 알리는 것을 확인했다 — 2026-10-30).
setup 이 하는 일 (DNS):
- 사전 확인 — 바꾸기 전에 (어긋나면 아무것도 바꾸지 않고 종료 1):
mail-dns의 서브넷이MAIL_RESOLVER_SUBNET이고 internal 인지 · 구성원이 mail · 리졸버뿐인지 ·mail-dns-egress에 리졸버만 붙었는지(구성원은 운영만 중단, 개발은[주의]) · 리졸버가 떠 있고 주소가MAIL_RESOLVER_IP인지 · mail 이mail-dns에 있고dns가 그 주소인지, 그리고 메일 컨테이너 안에서 그 주소로 DNSSEC 자료와 함께(DO) 물어 검증된 답(ad)과 서명(RRSIG)이 오는지 — 엔진과 같은 자리 · 같은 주소다. UDP 는 루트 SOA(389B 안팎), TCP 는 루트 DNSKEY(엔진의 기동 확인과 같은 질문 — 소스ensure_dnssec)를 묻는다. UDP 로 루트 DNSKEY 를 묻지 않는 까닭: 지금 1,139B 인데 루트 키 교체(ZSK 미리 올리기 · KSK-2017 폐기) 동안에는 1,400B 를 넘어 EDNS 1232 의 UDP 로는 잘린다(TC — 답 0 · 서명 0). 엔진은 그때 TCP 로 다시 묻으므로 멀쩡한 리졸버인데 setup 만 멈추게 된다(2026-10-30 리뷰 — EDNS 1100 으로 잘리는 조건을 만들어 확인). 이 답 확인은 운영만 멈추고 개발은[주의]뒤 이어 간다 — 바깥 53 이 막힌 개발 망에서도 개발 메일(Mailpit — 도커 DNS 가 푸는 이름)은 되기 때문이다. 다만 리졸버가 바깥에 전혀 닿지 않으면 뒤의 설정 리로드를 엔진이 거절해(Pyzor 주소를 같은 리졸버로 푼다 — 리졸버 장애 때 3.) 개발 setup 도 거기서 종료 1 이다 (2026-10-30 개발에서 리졸버의 바깥 길을 끊어 확인 — 저장한 설정은 남고restart mail때 적용된다). 개발 PC 도 리졸버가 바깥 53 에 닿아야 setup 이 끝난다. (이미지에dig이 없어 이미지의 perl 로 묻는다 — Debian 기본perl-base만 쓴다. 이미지에 perl 이 없으면(docker exec종료 126 · 127) 리졸버 문제가 아니라 확인 도구 문제로 따로 알린다.) - 엔진 DNS — 위 표의 값으로(
[유지]·[변경] … · 전 System). 견주는 것은@type과 서버마다의 protocol · address · port 뿐이다 — 엔진은Custom의 servers 를 넣은 그대로 읽어 준다(2026-10-30 개발 엔진에 잠깐 저장해 확인), 엔진이 나중에 칸을 더 붙여 읽어 줘도 매번[변경]이 되지 않게. - Spamhaus 두 항목 — 아래.
- 반영 확인 — 리로드 뒤 이번 기동 로그에 DNSSEC 경고가 없는지. 그 기동의 로그가 남아 있지 않으면(기동 첫 줄
server.startup이 없다 — 로그 회전 등) "경고 없음"이 아니라[주의] … 확인하지 못했습니다다(확인하려면restart mail뒤 setup — 몇 초 끊긴다).
MAIL_RESOLVER_SUBNET · MAIL_RESOLVER_IP 는 setup 도 검사한다 — 사설 대역 · 네트워크 주소 · /24~/29 · 앱 네트워크와 겹치지 않음 · 주소는 그 안이고 네트워크 주소 · 첫 주소(도커 게이트웨이) · 끝 주소가 아님.
바꿀 때는 둘을 함께 고치고 docker compose … down 후 up -d(네트워크를 다시 만든다) → setup.
Spamhaus — 조회 키(MAIL_SPAMHAUS_DQS_KEY)
- 공개 미러(
zen.spamhaus.org·dbl.spamhaus.org)는 쓰지 않는다 — Spamhaus 의 Fair Use(소규모 · 비상업) 용도다. - 키는 Spamhaus 의 "Blocklists via DNS Query"(옛 Data Query Service — DQS) 계정에서 받는다. 무료 계정은 Spamhaus 의 Fair Use(소규모 · 비상업) 조건이다 — 회사가 대상인지는 Spamhaus 가 판단하고(신청 페이지: "If you are a non-commercial entity or individual with low query volumes, you may qualify"), 안 되면 두 항목을 끈 채로 두거나 유료(상업용 30일 체험 뒤 구독)로 간다. 무료 계정 신청 · 공개 미러에서 옮기기 · 시험
- 키는 서버의
.env.production에만 둔다(MAIL_SPAMHAUS_DQS_KEY=— 26자 영문 · 숫자). 파일에 없거나 파일과 다른 값이 셸에 있으면 setup 이 멈춘다(셸 값으로는 그 실행만 켜지고 다음 실행이 끈다). 개발은 비워 둔다. - 체험(30일) 키는 기간이 끝나면 답이 멈춘다 — 그 전에 구독하거나, 키를 비우고 setup 을 돌려 두 항목을 끈다. 계정이 끝나거나 키가 멈추면
엔진의 Spamhaus 조회는 실패하고 표시(tag)가 붙지 않는다 — 메일은 그대로 받는다(닫히지 않고 열린 쪽 · fail-open). 조회마다 몇 초 늦어질 수 있다
(틀린 키는 SERVFAIL 5초 남짓이거나 답이 없었다 — 아래 표). 엔진 로그에는 남지 않는다(조회 실패
spam.dnsbl-error도 debug — 아래 "키가 남는 곳"). setup 은 다음에 돌 때에야 시험 항목을 받지 못해 두 항목을 끄고 종료 1 로 알린다 — 그래서 운영은 setup 을 주기적으로 돌려 종료 0 인지 보거나 Spamhaus 계정의 만료일을 본다.
| 상태 | setup 이 하는 일 |
|---|---|
| 키 있음 · 시험 통과 | 그 항목을 DQS 로 켠다 — 조회 이름 식 ip_reverse + '.<키>.zen.dq.spamhaus.net' · value + '.<키>.dbl.dq.spamhaus.net'. 응답 코드가 공개 미러와 같아 tag 식(점수)은 그대로 |
| 키 있음 · 시험 실패 | 그 항목을 끄고 실패로 보고(종료 1) — 시험은 메일 컨테이너에서 리졸버로 늘 등록된 시험 항목을 묻는다: 2.0.0.127 → 127.0.0.2(ZEN, 127.0.0.4 · 127.0.0.10 도 온다) · dbltest.com → 127.0.1.2(DBL). 형식만 맞는 가짜 키는 SERVFAIL(5초 남짓)이거나 12초 안에 답이 없었다(2026-10-30) |
| 키 없음 | 두 항목을 끈다 — 운영 [주의], 개발 [참고](개발도 운영처럼 끈다: 공개 미러의 비상업 조건은 회사 개발 PC 에도 같고, 키는 운영 서버에만 둔다) |
| 끌 때 | 조회 이름 식도 엔진 기본(공개 미러)으로 되돌린다 — 옛 키가 엔진에 남지 않게. 지금 식이 무엇이었는지는 키 없이 알린다(전 켜짐 · 공개 미러 … · DQS …(다른 키)) |
- 키가 남는 곳 — setup 은 키를 출력하지 않는다(식 · 옛 식도). 엔진의 오류 응답 · 거절 사유에 실려 와도 setup 의 출력(
[변경]·[주의]·[실패]줄 모두)은 키를<MAIL_SPAMHAUS_DQS_KEY>로 바꿔 찍는다. 시험 조회 이름은 표준 입력으로만 넘긴다(docker exec명령줄 · 도커 이벤트에 남지 않게). ⚠️ 그러나 엔진 설정(두 항목의 조회 이름 식)에 키가 그대로 저장된다 — 관리 화면 · 관리 API(x:SpamDnsblServer/get) · 메일 볼륨 백업으로 보인다. 조회 자체에도 키가 실린다(평문 DNS — DQS 의 방식이다. 위 QNAME minimisation 으로 Spamhaus 앞 단계는 보지 못한다). 엔진은 DNSBL 조회 이름을 debug 수준에서만 남긴다 — 답(spam.dnsbl)도, 실패(spam.dnsbl-error— 조회 이름 전체와 리졸버 오류를 싣는다)도 따로 정한 수준이 없어 기본값 debug 다(0.16.27 소스crates/trc/src/event/enums_impl.rs의EventType::level). 그래서 키가 멈춰 조회가 모두 실패해도 setup 이 둔 info 로그에는 쏟아지지 않는다. 로그 수준을 debug 로 올리면 키가 로그에 남는다. - 바꾸거나 비우면 setup 을 다시 실행한다. 항목을 지웠거나 이름을 바꿨으면 setup 은 만들지 않고
[주의]만 낸다. - 나머지 16개 목록 — 운영 전에 각 서비스의 이용 조건(상업적 사용 · 조회량 · 사전 등록)을 확인하고 맞지 않는 것은 관리 화면에서 끈다(setup 은 Spamhaus 두 항목만 맡는다).
- 조회 거부 응답의 점수 — 0.16.27 기본값에서 Spamhaus 의 거부 · 오류 응답 tag(
RBL_SPAMHAUS_BLOCKED_OPENRESOLVER·RBL_SPAMHAUS_BLOCKED·DBL_BLOCKED_OPENRESOLVER·DBL_BLOCKED, 그리고RECEIVED_…)는 0점이다(개발 엔진의x:SpamTag, 2026-10-30) — 거부 응답이 정상 메일의 점수를 올리지는 않는다(점수가 붙는다는 사용자 보고는 이 판에서는 아니다). 이 리졸버로는 그 응답이 오지 않는다.
# 리졸버가 Spamhaus 에 막혀 있지 않은지 — 127.0.0.2 는 늘 등록된 시험 주소다 (공개 미러 한 번 — 연결 확인용)
sudo docker exec "${PROJECT_NAME}-mail-resolver" drill 2.0.0.127.zen.spamhaus.org @127.0.0.1 A
# 127.0.0.x 가 오면 정상, 127.255.255.254 면 막혀 있다(공용 · 열린 리졸버로 보인다)
# DNSSEC — dnssec.works 는 flags 에 ad, fail01.dnssec.works 는 SERVFAIL
sudo docker exec "${PROJECT_NAME}-mail-resolver" drill -D dnssec.works @127.0.0.1 A
sudo docker exec "${PROJECT_NAME}-mail-resolver" drill fail01.dnssec.works @127.0.0.1 A
# 엔진 기동 로그의 DNSSEC 경고 — 나오지 않아야 한다
sudo docker logs "${PROJECT_NAME}-mail" 2>&1 | grep 'cannot validate DNSSEC'
# DQS 키는 명령줄에 적지 않는다 — setup 의 "[확인] Spamhaus 키 … 시험 항목 …" 줄로 본다
확인 (2026-10-30, 개발 — Docker Desktop 28.5 · KT 망, 엔진 0.16.27):
- 리졸버로
2.0.0.127.zen.spamhaus.org→127.0.0.10·127.0.0.4·127.0.0.2(UDP · TCP),dbltest.com.dbl.spamhaus.org→127.0.1.2— 같은 PC 의 KT DNS 로는127.255.255.254였다.dnssec.works는 ad,fail01.dnssec.works는 SERVFAIL. 도커 DNS 를 거쳐도(dns지정) 루트 DNSKEY 의 ad · RRSIG 가 UDP · TCP 그대로 왔다. - 엔진 기동 로그 — 리졸버 전(2026-10-26 기동, KT DNS)에는
DnsResolver … cannot validate DNSSEC경고가 있었고, 리졸버 뒤System(개발) ·Custom(운영 꼴을 개발에서 잠시) 기동 모두 없었다. - 메일 — 465 인증 제출로 바깥 주소(Mailpit 에 도착) · 사내 주소(받은편지함), 25 인증 없는 수신(
someone@gmail.com→ doyun.lee)의spf=softfail(google 의 SPF 를 실제로 따라감) ·dmarc=fail … policy.dmarc=none이 그대로 나왔다.Custom으로 바꾼 동안은 Mailpit 경로가 위 표처럼 영구 실패했고, setup 으로System에 되돌린 뒤 다시 Mailpit 에 왔다. - setup — 처음
[변경]둘(Spamhaus 두 항목 끔), 다시 돌리면 바뀐 줄 0 · 종료 0. 형식만 맞는 가짜 키 → 두 항목 꺼진 채 종료 1 · 출력에 키 없음. 리졸버를 멈추면 사전 확인에서 종료 1(바뀐 것 없음).mail-dns에 다른 컨테이너를 붙이면 개발은[주의]· 종료 0. 실제 Spamhaus 키로는 아직 시험하지 않았다 — 키를 받은 뒤 운영에서[확인] Spamhaus 키 …두 줄을 본다. - 리뷰 반영(같은 날) — 루트 DNSKEY(DO)는 1,139B 라 메일 컨테이너에서 EDNS 1100 으로 물으면 UDP 가 잘렸다(TC · 답 0 · 서명 0 — 예전 사전 확인이면 멈췄을 자리).
같은 조건에서 지금 확인(UDP 루트 SOA · TCP 루트 DNSKEY)은 둘 다 ad · 서명 1 로 통과했다. compose 의
required: false— 리졸버 healthcheck 실패(unhealthy) · 설정 오류로 바로 끝남(exited (1)) · 운영 재시작 정책(unless-stopped)으로 재시작을 되풀이함 모두optional dependency "mail-resolver" failed to start경고 뒤 mail 이 떴다(5~9초). 바꾸기 전 설정(required없음)은dependency failed to start: container … is unhealthy로 끝나고 mail 이Created에 남았다(옛 mail 은 지워진 채).up -d --no-deps mail은 리졸버를 기다리지 않는다. 엔진의x:DnsResolver를 잠깐Custom으로 저장해 읽으면 servers 가 넣은 그대로였다(되돌림).domain-insecure한 줄은 그 도메인만 검증을 끈다(fail01.dnssec.worksSERVFAIL → NOERROR,dnssec.works는 그대로 ad,fail02.dnssec.works는 그대로 SERVFAIL — 시험용 리졸버 컨테이너). 리졸버의 바깥 길을 끊으면(캐시도 비움) 개발 setup 은 사전 확인을[주의]로 넘기고 리로드에서 엔진의 거절(Pyzor 주소)로 종료 1 이었다 — 바뀐 것 0. 그 상태에서 엔진 DNSQuad9저장 → 리로드 거절 →restart mail은 DNSSEC 경고 없이 떴다(비상 길 — 리졸버 장애 때). 모두 되돌린 뒤 setup 은[확인]· 바뀐 줄 0.
참고: Spamhaus — 공용 DNS 로 무료 목록 쓰기, Spamhaus — KT 경유 조회 안내, Stalwart DNSBL 문서
엔진 한도
받는 쪽 한도(x:MtaInboundThrottle — 관리 화면 Settings › MTA › Rates & Quotas › Inbound Rate Limits)는 엔진을 처음 설치하면 아래 두 개다.
한도를 넘은 바깥 메일 서버는 거절이 아니라 미루기(452 4.4.5 Rate limit exceeded 또는 연결 끊김)를 받고 나중에 다시 보낸다 — 늦게 받을 뿐 잃지는 않는다.
| 한도 (엔진의 설명 · key) | 세는 것 | 기본값 | 넘으면 | setup |
|---|---|---|---|---|
Sender address to recipient throttle · senderDomain·rcpt |
같은 보낸 도메인 → 같은 받는 사람마다 받는 사람 수 (RCPT TO 때) | 시간당 25 | 그 받는 사람만 452 4.4.5 |
인증 없는 세션에만 건다 |
Sender IP throttle · remoteIp |
접속 IP 마다 연결 수 (연결할 때) | 초당 5 | 인사 없이 연결을 끊는다 | 그대로 둔다 |
- 바꾸는 까닭 — 기본 조건(
match)은 모든 세션({"else":"true"})이라 로그인한 직원의 발송에도 걸린다 (엔진 문서 Rate limits "Default throttles"). 사내 메일은 보낸 도메인이 모두 같아서, 동료 모두 합쳐 한 사람에게 시간당 25통을 넘으면 그 뒤의 웹메일 발송(JMAP 제출)은 그 받는 사람이452 4.4.5로 거절됐다 (2026-10-28 개발 엔진 — 로그smtp.rate-limit-exceeded … id = "MtaInboundThrottle with id …"). 바깥 서버와 달리 엔진은 그 메일을 다시 보내지도 반송하지도 않는다 — 제출 기록의deliveryStatus에만 남는다 (백엔드가 읽어 알린다 — 계약 2절 "받는 사람별 결과"). - setup 이 하는 일 — 설명과 key 로 찾아(id 는 설치마다 다르다)
match를{"match":{},"else":"is_empty(authenticated_as)"}로 맞추고 설정 리로드로 반영한다. 엔진 기본값이 스팸 필터를 인증 없는 세션에만 거는 식(x:MtaStageData의enableSpamFilter)과 같다.authenticated_as는 인증한 계정 이름이고, 인증 없는 세션(AUTH 없이 들어온 SMTP — 바깥 서버가 25 로 넣는 메일)이면 빈 값이다. 이 식(RCPT TO 단계)에서 쓸 수 있는 변수다 (MtaInboundThrottle · MtaRcptToVariable · is_empty). 엔진은 저장할 때 식을 검사한다 — 모르는 변수·이 맥락에 없는 변수(HTTP 의path등)는 거부한다.- 웹메일 발송(JMAP 제출)은 엔진 안에서 그 직원 계정으로 인증된 SMTP 세션으로 돌아 빠진다. 메일 프로그램의 SMTP 제출(465, AUTH 뒤)도 같은 까닭으로 빠진다 (465 는 시험하지 않았다).
- 찾지 못하면 만들지 않고
[주의]만 낸다 — 지웠다면 그대로 두고, 이름을 바꿨다면 그 한도의match를 직접 본다. - 한도 값(
rate)·켜짐(enable)은 건드리지 않는다.match는 실행할 때마다 맞추므로, 받는 사람별 예외처럼 조건을 따로 둬야 하면 이 한도를 끄고(enable: false) 다른 설명으로 새 한도를 만든다 (setup 은 그 한도를 건드리지 않는다).
- 바깥에서 들어오는 메일(인증 없는 SMTP)에는 그대로 건다 — 같은 바깥 도메인 → 한 사람 시간당 25통.
- 보낸 IP 한도는 그대로 둔다 — 연결할 때 세는 한도라 웹메일 발송에는 처음부터 걸리지 않는다 (JMAP 제출은 엔진 안의 세션이라 연결 단계를 거치지 않는다 —
0.16.27 소스
smtp/src/inbound/spawn.rs). 조건에authenticated_as를 쓰면 엔진이 그 한도를 MAIL FROM 단계로 옮겨(한도는 key 와 식이 쓰는 변수를 모두 아는 첫 단계에서 센다 — 같은 문서 "Evaluation stages", 소스common/src/config/smtp/queue.rs의parse_inbound) 연결이 아니라 MAIL FROM 을 세게 되고, 바깥에서 몰려오는 연결은 더는 막지 못한다. - 확인 (2026-10-28, 0.16.27): 바꾼 뒤 웹메일과 같은 방식(대리 접근 JMAP 제출)으로 ara.kim → doyun.lee 30통(초당 40통 남짓)이 모두
250 2.1.5 Queued(엔진 로그에 한도 기록 없음), 인증 없는 SMTP 는 같은 바깥 도메인(example.net) → doyun.lee 26번째부터452 4.4.5(로그id = "MtaInboundThrottle with id …",limit = [25, 3600000ms]). setup 을 이어서 두 번 돌리면 첫 번째는[변경], 두 번째는[유지]다(엔진이 돌려주는match·key모양이 그대로 비교된다 — 바뀌면 매번[변경]이 나온다).
임시 업로드 한도 (x:Jmap 싱글톤) — 엔진은 계정마다 1시간 동안 올린 것을 크기 합계와 개수로 센다.
첨부 올리기만이 아니라 필터 · 부재중 저장(백엔드가 Sieve 스크립트를 올린다)과 서명 그림이 든 발송(그림마다 하나)도 같은 몫을 쓴다.
| 설정 | 뜻 | 기본값 | setup |
|---|---|---|---|
uploadQuota |
계정마다 uploadTtl 동안 올린 크기 합계 |
50,000,000바이트 | 첨부 합계 한도의 20배보다 작으면 그 값으로 올린다 (기본 524,288,000바이트 = 25MB × 20) |
maxUploadCount |
같은 동안 올린 개수 | 1,000 | 그대로 둔다 |
uploadTtl |
올린 것이 남는 시간 = 위 둘을 세는 창 | 3,600,000ms (1시간) | 그대로 둔다 |
maxUploadSize |
한 번에 올리는 크기 | 50,000,000바이트 | 그대로 둔다 |
- 넘으면 — 그 계정의 업로드가 모두 HTTP 429(
Quota exceeded— "You have exceeded the blob upload quota of 1000 files or 50000000 bytes.")로, 올린 것이 만료될 때까지 막힌다. 백엔드는 크기 · 개수 어느 쪽이든 429REQ_001로 내리고 로그에{"reason":"mail-engine-rate-limit","call":"upload"}를 남긴다. 엔진 로그(info)에는 남지 않는다(크기 합계로 확인). - 바꾸는 까닭 (2026-10-29 개발 엔진, 0.16.27) — 한 계정으로 1,000개를 넘기자 그 계정의 필터 저장이 막혔다. 크기 합계도 그대로 걸려서 20MiB 두 개 뒤 세 번째가 429 였다 — 기본값이면 20MB 첨부 세 개로 그 직원의 첨부 · 필터 · 부재중 저장이 1시간까지 막힌다 (계약 2절 "첨부 업로드 한도").
- setup 이 하는 일 —
uploadQuota가 첨부 합계 한도의 20배(한 시간에 합계 한도를 꽉 채운 메일 20통)보다 작으면 그 값으로 올리고 설정 리로드로 반영한다.- 첨부 합계 한도는 백엔드(
mail.settings.ts)와 같게 센다 —MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB(비우면 25)이되 파일 하나 한도MAIL_ATTACHMENT_MAX_SIZE_MB(비우면 20)보다 작아지지 않고, MB = 1,048,576바이트. 둘 중 하나라도 양의 정수 표기(앞자리 0·부호·소수점 없이)가 아니면 아무것도 바꾸지 않고 멈춘다 — 백엔드 env 검증과 같은 규칙. - 더 크게 둔 값은 내리지 않는다(운영에서 정한 것). 0 이면 뜻(제한 없음인지)을 확인하지 않았으므로 바꾸지 않고
[주의]를 낸다. - 리로드가 있어야 반영된다 — 저장만 한 뒤에는 엔진이 옛 한도로 계속 429 를 냈고(메시지의 바이트 수도 옛 값),
ReloadSettings뒤에 올라갔다 (같은 날 확인). - 첨부 한도 env 를 바꾸면 backend 를 다시 띄우는 것과 함께 setup 도 다시 실행한다.
- 첨부 합계 한도는 백엔드(
- 개수 · 기간 · 한 번 크기는 그대로 둔다
- 개수 1,000 — 사람이 쓰는 양으로는 닿기 어렵다(한 시간에 첨부 · 필터 저장 · 서명 그림을 합쳐 1,000번). 계정이 털리거나 스크립트가 돌 때 엔진을 지키는 몫으로 남긴다.
- 기간 1시간 — 늘리면 같은 한도를 더 긴 동안 세어 오히려 빡빡해지고, 줄이면 올린 첨부가 메일(임시 저장본)에 붙기 전에 만료될 수 있다(시험하지 않았다).
- 한 번 크기 50,000,000바이트 — 파일당 첨부 한도(기본 20MB)보다 크다.
MAIL_ATTACHMENT_MAX_SIZE_MB를 48 이상(48MB = 50,331,648바이트)으로 올리면 엔진이 그 파일을 400urn:ietf:params:jmap:error:limit(maxSizeUpload)로 거절하고 사용자에게는FILE_002로 보인다 — 그때는 이 값도 함께 올린다(관리 APIx:Jmap/set→ReloadSettings).
- 확인 (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.다. 웹메일은 큰 파일을 대용량 첨부(링크)로 보내므로 걸리지 않는다. -
IMAP · 메일 크기 한도를 바꾸지 않는 까닭 — imapsync 로 옮기는 동안 큰 메일 때문에 잠시 올린다(큰 메일). 그 도중에 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 이 났다(큰 메일).
- 받는 한도 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통 넘게 받으면 그 뒤는 바깥 서버가 미뤘다가 다시 보낸다(늦게 받는다).
알림 메일(모니터링·결제·배포 알림 등)처럼 한 곳에서 같은 주소로 많이 오는 주소를 먼저 꼽고, 한도를 올리거나(관리 화면 — setup 은
rate를 건드리지 않는다) 위처럼 따로 한도를 둔다. - 한 IP 초당 연결 5개 — 큰 메일 서비스가 여러 연결로 한꺼번에 배달하면 넘을 수 있다(인사 없이 끊기고 바깥 서버가 다시 시도한다 — 늦게 받는다).
메일 프로그램(465)으로 보내는 직원이 사무실 NAT 뒤에 많아도 한 IP 로 센다(연결은 인증 전에 센다 — 시험하지 않았다).
두 한도 모두 넘으면 로그
smtp.rate-limit-exceeded가 남으니 운영을 시작한 뒤 이 로그로 정한다. - 직원마다 내보내는 양의 한도(
x:MtaOutboundThrottle)는 비어 있다 — 이제 웹메일 발송에는 엔진의 받는 쪽 한도도 걸리지 않으므로, 남는 것은 백엔드의 요청 수 한도(루트 envTHROTTLE_*— IP 당)뿐이다. 계정이 털리거나 잘못 쓰여 한꺼번에 많이 보내면 바깥 서버들의 한도에 걸려 서버 평판이 떨어진다. 보낸 사람마다 한도(keysender— 넘으면 거절이 아니라 대기열에서 미뤄진다, Outbound rate limits)를 둘지 정한다 — 사내 배달에도 걸리는지는 확인하지 않았다. - 직원마다 1시간 업로드 한도(크기 · 개수) — setup 은 크기 합계만 첨부 합계 한도의 20배로 올리고 개수(1,000)는 그대로 둔다. 운영을 시작한 뒤 백엔드 로그
"reason":"mail-engine-rate-limit","call":"upload"로 막힌 직원이 있는지 보고 크기 · 개수를 정한다(엔진 로그에는 남지 않는다). 올린 값은 setup 이 내리지 않는다.
대용량 첨부 (11단계 — 인프라)
일반 첨부 한도(20MB · 합계 25MB)를 넘는 파일은 메일 엔진이 아니라 backend 의 볼륨(<PROJECT_NAME>_files → /app/files)에 조각으로 올리고,
메일에는 받는 사람이 로그인 없이 여는 내려받기 링크(SHARE_PUBLIC_URL/s/<토큰>)를 넣는다. 다 올린 파일은 ClamAV 로 검사한다.
데이터 · 규칙은 계약 4절 "대용량 첨부", env 는 루트 예시 env 의 "대용량 첨부" 칸이다. 이 절은 컨테이너 · 프록시 · 운영만 다룬다.
악성 검사 (ClamAV)
backend ─(clamav — internal · 바깥 길 없음)─▶ clamav clamd :3310 (INSTREAM) ◀── 볼륨 <PROJECT_NAME>_clamav-db (읽기 전용)
clamav-updater freshclam ──(clamav-egress)──▶ database.clamav.net (HTTPS · DNS)
└──────────────── 볼륨 <PROJECT_NAME>_clamav-db (씀) ──┘
| 항목 | 값 |
|---|---|
| 서비스 | docker-compose.yml 의 clamav(검사기 clamd) · clamav-updater(서명 갱신 freshclam). 운영은 profile 없이 늘 뜬다(CLAMAV_HOST 가 운영 필수라 검사기가 없으면 검사가 서 버린다). 개발은 profile clamav — COMPOSE_PROFILES=mail,clamav + CLAMAV_HOST=clamav 일 때만 |
| 이미지 | 공식 clamav/clamav:1.4.6_base — digest sha256:e7399106…(2026-10-30 manifest list, linux/amd64 만). 1.4 는 ClamAV 의 LTS(2024-08 부터 3년 — 서명을 받을 수 있는 기간, https://docs.clamav.net/faq/faq-eol.html), 1.4.6 은 2026-08-07 보안 릴리스(2주가 지났다). _base 는 서명이 들지 않은 이미지다(서명은 볼륨에). 같은 판의 Debian 판(_base-debian)은 200MB 검사가 17% 느렸다(23.7초 · 20.3초) |
| 둘로 나눈 까닭 | clamd 는 바깥에서 온 파일(악의적인 압축 · 문서)을 연다 — 그 해석기에 구멍이 나도(2026 년에만 heap overflow CVE 여럿) 바깥 · 다른 컨테이너에 닿지 못하게 clamd 는 internal 네트워크 하나에만 둔다. 바깥에 나가는 것은 서명을 받는 freshclam 뿐이고, 그것은 backend · clamd 와 잇지 않는다. 새 서명은 clamd 가 10분마다(SelfCheck) 볼륨을 보고 다시 읽는다(NotifyClamd 를 쓰지 않는다) |
| 네트워크 | clamav — backend ↔ clamav 전용, internal. clamav-egress — updater 혼자. 둘 다 서브넷은 도커가 정한다(주소로 믿는 곳이 없다 — 기본 주소 풀 172.17~31.x · 192.168.x 이 서버 VPC 와 겹치지 않는지 본다). clamd 는 암호화 없는 TCP 이고 접속을 가리지 않으므로 다른 컨테이너를 붙이지 않는다. app-network(신뢰 대역)에는 붙이지 않는다 |
| 실행 | 둘 다 이미지의 clamav 사용자(100:101) · 권한(capability) 모두 뺌 · no-new-privileges · init: true(PID 1 tini). 이미지의 /init 은 root 로 소유자를 바꾸고 freshclam 과 clamd 를 함께 띄우므로 쓰지 않고 clamd --foreground · freshclam --daemon --foreground 를 바로 띄운다(멈추면 컨테이너가 끝나 재시작 정책을 탄다). clamd 는 띄우기 전에 /tmp 의 clamav-*(지난번에 검사 중 죽으며 남긴 임시 파일)를 지운다 — 아래 "임시 디스크". 호스트 포트는 어떤 환경에서도 열지 않는다 |
| healthcheck | clamav — 이미지의 clamdcheck.sh(3310 PING → PONG). updater — 서명(main · daily)이 있고 daily 가 이틀 안에 바뀌었는지(멈추면 unhealthy 로 보인다 — 검사는 옛 서명으로 이어 간다). clamav 는 updater 가 healthy 가 된 뒤에 뜬다(required: false — 서명이 오래됐다고 검사기를 세우지 않는다) |
| 서명 갱신 | 하루 12번 묻고(DNS TXT current.cvd.clamav.net) 새 판이 있을 때만 받는다(cdiff). 받은 서명은 바꿔 넣기 전에 따로 읽어 본다(TestDatabases yes — 깨진 서명이 clamd 를 멈추지 않게). 처음 기동은 main · daily · bytecode 110MB(2026-10-30 — 서명 363만 개)를 받는 데 1분 안팎이었다 |
| 설정 | clamav/clamd.conf · clamav/freshclam.conf — 이미지 기본 설정을 통째로 바꾼다(저장소는 umask 022 로 클론 — 컨테이너 사용자 100 이 읽어야 한다). 바꾼 뒤에는 up -d --force-recreate clamav(또는 clamav-updater). CI 의 infra 잡이 clamconf 로 읽고 아래 값이 그대로인지 본다 |
한도 · 판정 (clamd.conf) — 한 파일 한도(MAIL_LARGE_FILE_MAX_SIZE_MB, 최대 2,047MB)까지 끝까지 검사한다:
| 설정 | 값 | 까닭 |
|---|---|---|
StreamMaxLength |
2047M | INSTREAM 으로 받는 양. 같으면 받고 1바이트 넘으면 INSTREAM size limit exceeded. ERROR — 백엔드는 이보다 큰 파일을 만들지 않는다 |
MaxFileSize |
2047M | 한 파일(압축 안의 파일 포함). ClamAV 는 2GiB − 1 넘게는 검사하지 못한다(1.4.6 이 그 위를 2,147,483,645 로 줄이고 경고) |
MaxScanSize |
4094M | 파일 하나에서 검사하는 양의 합(압축을 푼 내용까지). 푼 내용은 /tmp(컨테이너 쓰기 계층)에 잠시 쓴다 |
MaxScanTime |
300000(5분) | 1.4 에서는 ZIP 에만 걸린다. 백엔드가 다 보낸 뒤 판정을 기다리는 시간(30분 + 보낸 1MB 마다 1초 — 2,047MB 면 약 64분, 아래 "백엔드 쪽 시간")보다 짧게 둔다 — 넘으면 백엔드가 끊고 미뤄서 처음부터 다시 검사한다 |
AlertExceedsMax · AlertEncrypted |
yes · yes | 끄면 한도를 넘거나 암호가 걸린 파일이 stream: OK(깨끗함)로 온다(아래 표의 "끈 때") |
ConcurrentDatabaseReload |
no | 서명을 다시 읽을 때 두 벌을 들지 않는다 — 1GB 그대로, 그 30초 안팎 동안 검사 · PING 이 기다린다 |
판정 실측 (2026-10-30, 개발 — clamd 1.4.6 · 위 설정. 백엔드와 같은 규약의 INSTREAM 시험 클라이언트, 그리고 백엔드로 끝까지):
| 보낸 것 | clamd 의 답 (\0 앞) |
백엔드 판정 |
|---|---|---|
| 깨끗한 파일(18B · 10MB · 40MB · 2,047MB 무작위) | stream: OK |
clean |
| EICAR · EICAR 를 든 zip | stream: Eicar-Test-Signature FOUND |
infected |
| 암호 zip(ZipCrypto · AES-256 — 안에 EICAR 가 있어도) | stream: Heuristics.Encrypted.Zip FOUND |
unscannable |
| 암호 7z(내용만 · 머리글까지) | stream: Heuristics.Encrypted.7Zip FOUND |
unscannable |
| 사용자 암호 pdf / 소유자 암호만 있는 pdf | stream: Heuristics.Encrypted.PDF FOUND / stream: OK |
unscannable / clean |
| 20겹 zip · 파일 12,000개 zip | stream: Heuristics.Limits.Exceeded.MaxRecursion FOUND · …MaxFiles FOUND |
unscannable |
| 풀면 2,100MB · 4,500MB 인 zip(0 으로 채움) | stream: Heuristics.Limits.Exceeded.MaxScanTime FOUND(5분) |
unscannable |
| 풀면 600MB 인 zip | stream: OK(100초) |
clean |
| 암호 항목 + EICAR 항목이 함께 든 zip · 한도 넘는 항목 + EICAR | stream: Eicar-Test-Signature FOUND — 진짜 서명이 휴리스틱보다 먼저다 |
infected |
StreamMaxLength 보다 큰 스트림(시험 clamd — 10M 에 20MB · 10M+1B) |
INSTREAM size limit exceeded. ERROR 를 보내고 FIN — 클라이언트가 계속 보내면 clamd 쪽이 RST. 클라이언트가 답을 읽기 전에 RST 가 오면 답 없이 EPIPE 다(같은 시험에서 한 번은 답, 한 번은 EPIPE) |
(백엔드 — 설정 오류로 검사 중 그대로 · error) |
끈 때(AlertExceedsMax no, 시험 clamd — MaxFileSize 5M) |
7MB 무작위 · 20겹 zip · 파일 12,000개 zip 모두 stream: OK |
⚠️ clean 이 된다 |
| ⚠️ 시험 clamd(MaxFileSize 5M · MaxScanSize 8M)에서 zip 안의 한도 넘는 항목(6MB · 600MB 의 0) | AlertExceedsMax yes 인데도 stream: OK |
clean — 아래 "알려진 한계" |
- 로그는
instream(<백엔드 주소>@<포트>): <이름> FOUND만 남는다(파일 이름 없음 · 깨끗한 파일은 남기지 않음). 백엔드는 판정 이름 · 파일 id 를 남긴다. - 알려진 한계 — (1) 1.4.6 은 zip 안의 항목이
MaxFileSize·MaxScanSize를 넘으면 그 항목을 건너뛰고 알리지 않을 때가 있다(위 표의 마지막 줄 — 운영 값에서는 풀면 2GB 가 넘는 zip 이 먼저MaxScanTime에 걸렸다). (2) 검사 한 번은 코어 하나로 2,047MB 에 5분 안팎이다(이 PC — 1분에 400MB, 운영 CPU 가 느리면 더 걸린다). zip 밖의 압축(7z · tar.gz 등)은 시간 상한 없이MaxScanSize(4GB)까지 푼다 — 4,094MB 를 코어 하나로 10분 안팎, 운영 CPU 가 23배 느리면 2030분이다. 백엔드의 판정 대기(30분 + 1MB 마다 1초)가 그것을 덮는다 — 넘으면 끊고 미뤄서 처음부터 다시 검사한다(그동안 "검사 중"). (3) 검사는 한 번이다 — 나중에 나온 서명으로 다시 보지 않는다(계약 4절). - 백엔드 쪽 시간 (
backend/src/modules/file-storage/file-storage.constants.ts) — 연결 10초, 흘려보내는 동안 아무것도 오가지 않으면 10분(clamd 는 받는 대로 임시 파일에 적는다), 다 보낸 뒤 판정까지 30분 + 보낸 1MB 마다 1초(clamavVerdictTimeoutMs— 2,047MB 면 3,847초 ≈ 64분). 연결에는 TCP keepalive(첫 대기 60초)를 켜서 컨테이너째 사라진 clamd(RST 도 오지 않는다)를 긴 판정 대기와 상관없이 1분 남짓에 알아차린다. 어느 쪽이든 끊기면 미뤄서 다시 한다. - CPU —
cpus: 2.0(백엔드가 한 번에 2개까지 보낸다) ·cpu_shares: 512(다른 컨테이너와 다툴 때 절반 몫 — 웹메일 · 메일 엔진이 먼저). 서버가 2 vCPU 보다 적으면cpus를 낮춘다(넘으면up이 실패한다). - 임시 디스크 — clamd 는 받은 스트림을
/tmp에 다 쓴 뒤 검사하고 지운다(2,047MB 시험 — 2GB 썼다가 0 으로). 한 번에 2개 × (2GB + 푼 내용 4GB 까지)가 도커 data-root 의 디스크(볼륨과 같은 디스크)에 잠시 든다. 검사 중에 clamd 가 죽으면(OOM 등) 그 임시 파일(/tmp/clamav-<hex>.tmp)은 지워지지 않고, 재시작은 같은 쓰기 계층을 쓰므로 죽을 때마다 쌓인다 — 그래서 compose 가 clamd 를 띄우기 전에rm -rf /tmp/clamav-*한다(2026-10-30 시험 clamd: 800MB 를 흘려보내다 죽이기를 두 번 — 고치기 전에는 324MB → 681MB 로 쌓였고, 고친 뒤에는 다시 뜰 때마다 비었다).recreate없이restart만 해도 지운다.
# 상태 — 둘 다 healthy, 서명 판(daily 번호 · 날짜)
sudo docker compose --env-file .env.production ps clamav clamav-updater
sudo docker exec "${PROJECT_NAME}-clamav" sh -c 'echo VERSION | nc localhost 3310' # ClamAV 1.4.6/<daily 판>/<날짜>
sudo docker logs --tail 20 "${PROJECT_NAME}-clamav-updater" # daily.cld updated / database is up-to-date
# 판정 시험 — EICAR 는 파일로 두지 않는다(서버 백신이 지울 수 있다). 백엔드 컨테이너의 node 로 clamav 에 INSTREAM 한 번
sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').connect(3310,'clamav'),e=Buffer.from('WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo=','base64'),h=Buffer.alloc(4);h.writeUInt32BE(e.length);s.on('connect',()=>s.end(Buffer.concat([Buffer.from('zINSTREAM\0'),h,e,Buffer.alloc(4)])));s.on('data',d=>console.log(String(d)))"
# → stream: Eicar-Test-Signature FOUND
대용량 첨부 — 공개 내려받기 호스트
받는 사람은 포털과 다른 호스트(SHARE_PUBLIC_URL — 운영 https://files.arasystem.it.kr)에서 받는다. 털린 계정이 퍼뜨린 악성 파일로 포털 주소까지
차단 목록(세이프 브라우징 등)에 오르는 위험을 떼어 놓고, 포털 쿠키(호스트 한정)도 그 호스트로 가지 않는다. 외부 프록시는 포털과 같은 사설 IP:8080 으로 넘긴다 —
프론트 nginx 가 Host 로 server 를 가른다(frontend/nginx.conf):
포털 (portal. — default_server) |
공개 내려받기 (files. — SHARE_PUBLIC_URL 의 호스트 이름) |
|
|---|---|---|
| 화면 | 웹메일 전부. /s/… 는 404 |
/s/<토큰>(SPA 의 공개 라우트 — index.html, no-cache · X-Robots-Tag: noindex, nofollow · Referrer-Policy: no-referrer) · /assets/ · /favicon.ico 만. 그 밖(/ · /index.html · /mail/…)은 404 |
| API | /api/… 전부. /api/public-links/… 는 404 RES_001(JSON — 대소문자 변형 /api/Public-Links/… · %5C 도) |
/api/public-links/… 만(대소문자를 가리지 않는다). 그 밖의 /api/… 는 404 RES_001, 1m 넘는 본문은 413 FILE_002(둘 다 JSON) |
| 두 호스트 공통 | 원문 요청 대상에 날 \(경로) · # · 0x80 이상 바이트가 있으면 location 을 고르기 전에 400 VAL_001(JSON) — 백엔드가 그 꼴을 다르게 읽어 닫기를 비켜 갔다 (계정 보안 "잘못된 요청 대상은 400") |
|
| 백엔드 | 같은 판정으로 두 겹 막는다 — Host 의 호스트 이름이 공개 호스트면 /api/public-links/ 만, 아니면 그것만 404(modules/file-sharing/public-host.ts) |
SHARE_PUBLIC_URL이 비었거나 그 호스트 이름이CORS_ORIGIN에 있으면 따로 두지 않는다 — 포털이 둘 다 연다(개발 · 같은 호스트 운영.nginx-env.envsh가 고르고docker logs첫 줄에 남긴다). 운영은 넣지 않는다 —CORS_ORIGIN에 공개 호스트를 넣으면 분리가 꺼진다(백엔드가 운영에서는 기동을 막는다).SHARE_PUBLIC_URL의 모양(출처 — 경로 · 끝의/· 끝 점 없이)이 틀리면 frontend 가 뜨지 않는다(nginx-env.envsh:줄).- 조각 올리기
PUT /api/file-uploads/{id}/chunks/{n}만 본문 17m(조각 16MiB + 여유 — 계약 상수라 env 로 바꾸지 않는다) · 읽기 90초. 요청 본문은 nginx 가 다 받아 둔 뒤 넘긴다(/tmp/client_temp). 그 밖의/api/file-uploads·/api/file-quota·/api/share-links·/api/share-link-lookups는 JSON 1m 그대로다. 조각 요청은 포털 호스트로 온다. 이 경로의 오류 로그는error부터다 — 조각마다 nginx 가a client request body is buffered to a temporary file(warn)을 남겨 2GB 파일 하나에 128줄이 쌓였다(본문 버퍼는 키우지 않는다 — 동시 조각 수 × 16MiB 가 메모리에 앉는다). - 받기
/api/public-links/<토큰>/files/…(공개) ·/api/messages/<id>/large-files/…(앱 안) — 응답 버퍼링 · 압축을 끄고X-Accel-Buffering: no를 외부 프록시로 넘긴다(실시간 알림과 같다),Range·If-Range는 그대로 넘긴다(백엔드가 206 · 416). 머리글 버퍼는 16k(proxy_buffer_size— 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이라 기본 4k 로는 빠듯하다). 동시 연결 — 클라이언트 주소마다 공개 10 · 앱 안 30(사무실 NAT), 전체는 공개 400 · 앱 안 200 을 따로 센다(공개 받기가 가득 차도 직원의 앱 안 받기는 된다). 넘으면 429REQ_001(JSON). 클라이언트 주소는X-Forwarded-For의 마지막 칸(외부 프록시가 이어 붙인 값)이다 — 앞에 꾸민 값을 붙여도 마지막 칸으로 센다. 두 경로는 대소문자 ·%2F· 겹/를 가리지 않고 같은 설정을 탄다 — 백엔드(Express)의 라우팅이 대소문자를 가리지 않아/api/Messages/…/LARGE-FILES/…도 받기 처리기에 닿기 때문이다 (nginx 의 대소문자를 가리는 prefix 에 기대면 한도 · 버퍼링 끔을 비켜 간다 — 2026-10-30 리뷰. CI 의 infra 잡 "대용량 받기 경로 시험"이 변형마다 실제 요청으로 본다). 그릇은 nginx 워커마다worker_connections4096(이미지 기본 1024 에서 올림 —frontend/Dockerfile) · frontend 의 열린 파일 수 8192(composeulimits)다 — 받기 600 개(연결 1,200) · 실시간 알림(브라우저마다 연결 둘) · 포털 요청이 워커 하나에 몰려도 모자라지 않게 (2026-10-30 운영 이미지 시험: 받기 600 개를 붙잡아 둔 채 401번째 공개 · 201번째 앱 안 받기가 429, 그때도 다른 API · 실시간 알림은 200). - 느린 받는 사람 (2026-10-30, 운영 이미지 · 개발 백엔드 · 40MB 파일): 조금씩이라도 받아 가면 끊지 않는다 — 초당 120KB 로 80초(읽기 제한 60초를 넘겨) 그대로 받았다.
proxy_read_timeout은 받는 쪽이 느려 nginx 가 백엔드를 읽지 않는 동안에는 돌지 않았다(5초로 줄인 시험에서도 15초 동안 받았다). 받는 쪽이 아무것도 받지 않으면send_timeout(60초 — 기본값)에 끊긴다 — 소켓 버퍼만큼(5MB) 보낸 뒤 61.7초에 닫고 그 연결 수를 돌려준다(10초로 줄인 시험에서는 10.9초). 끊긴 받기는 브라우저 · 내려받기 관리자가Range로 이어 받는다. - 접근 로그 — 토큰을
***로 가린다(요청 줄 · Referer — 예"GET /api/public-links/***/files/<id> HTTP/1.1"). 두 가지로 겹쳐 본다:/s/<…>·/api/public-links/<…>의 그 칸(대소문자 무시 · 길이 상관없음), 그리고 토큰 모양(base64url 딱 43자 — 앞뒤가[A-Za-z0-9_-]가 아닌 덩어리,%XY바로 뒤의 덩어리)이면 경로 어디서든 — nginx 는 정규화해 받지만 로그는 받은 그대로라//s/<토큰>·/./s/<토큰>·/%73/<토큰>·/x/../s/<토큰>·/api/public%2Dlinks/<토큰>·/api/public-links%2F<토큰>같은 꼴도 가린다. 43자가 아닌 값(UUID 36자 · 메일 id · 화면 자산 이름 — 가장 긴 것 35자)은 그대로 남는다. 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴은 가리지 못한다(그 토큰을 가진 본인만 만든다). 오류 로그(502 · 504 등)는 지금 정책대로 요청 줄을 그대로 남긴다(운영 담당만 읽는다). 동시 연결 초과는 오류 로그에 남기지 않는다(접근 로그의 429 로 본다).
외부 프록시 (서버 A 의 Nginx Proxy Manager) — 포털 Proxy Host와 같은 칸으로 하나 더 만든다:
| 칸 | 값 |
|---|---|
| Domain Names | files.arasystem.it.kr (가비아 files A → 서버 A 공인 IP — 이미 있다) |
| Scheme · Forward Hostname / IP · Port | http · 서버 B 사설 IP · 8080 — 포털과 같은 곳(Host 는 NPM 이 그대로 넘긴다 — proxy.conf 의 proxy_set_header Host $host) |
| Cache Assets · Block Common Exploits · Websockets | 끔 · 끔 · 끔 (포털과 같다) |
| SSL | Let's Encrypt 새 인증서(files.) · Force SSL · HTTP/2 |
| Advanced | 포털과 같은 다섯 줄(proxy_read_timeout 150s; · access_log off; · real_ip_header proxy_protocol; · proxy_buffer_size 16k; · proxy_busy_buffers_size 16k; — 포털 Proxy Host). access_log off; 는 꼭 — 경로에 내려받기 토큰이 있다(NPM 의 proxy 형식은 경로를 그대로 남긴다) |
- 포털 Proxy Host 의 본문 한도(NPM 기본 2000m)는 조각 17MB 보다 크다 — 줄였다면 17MB 이상, 읽기는 이미 150초다.
- 받기 응답은 우리가 넘기는
X-Accel-Buffering: no를 NPM(nginx)이 따라 버퍼링하지 않는다 —proxy_ignore_headers로 이 머리글을 무시하게 했다면 그 호스트에proxy_buffering off;를 더한다 (버퍼링하면 느린 받는 사람마다 NPM 이 임시 파일을 1GB 까지 만든다). - 받기 응답의 머리글(한글 255자 파일 이름이면 3.5KB 쯤)은 NPM 의 머리글 버퍼에도 들어가야 한다 — NPM v2.16.0 은 버퍼 크기를 정하지 않아 nginx 기본(4k — 그 이미지의 페이지 크기 4096)이고,
넘치면 NPM 이 502(HTML)를 낸다. 그래서 두 Proxy Host 의 Advanced 에
proxy_buffer_size 16k;와proxy_busy_buffers_size 16k;를 함께 넣는다 — 앞의 것만 넣으면 nginx 가 설정을 거절해("proxy_busy_buffers_size" must be less than the size of all "proxy_buffers" minus one buffer) NPM 이 그 Proxy Host 를 Offline 으로 둔다 (2026-10-30,jc21/nginx-proxy-manager:2.16.0이미지의 OpenResty 1.31.1.1 로nginx -t— 한 줄만이면 실패, 두 줄이면 통과. 이미지의nginx.conf·conf.d/include/*.conf에는proxy_buffer*가 없다). - 확인: 바깥에서
curl -sI https://files.arasystem.it.kr/→ 404 ·curl -s https://files.arasystem.it.kr/api/auth/me→ 404RES_001·curl -s https://portal.arasystem.it.kr/api/public-links/x→ 404RES_001, 보낸 메일의 링크(/s/…)가 열리고 받아진다. 서버 B 의sudo docker logs --tail 5 "${PROJECT_NAME}-frontend"에 토큰이***로 보인다.
메모리 예산
8GB 서버(서버 B) 하나에 스택 전체가 올라간다. 한도(compose deploy.resources.limits.memory)의 합이 실제 메모리를 넘지 않게 둔다 — 평소에는 한도보다 훨씬 적게 쓴다.
| 컨테이너 | 한도 | 평소 (2026-10-30 개발에서 잰 값) | 가장 클 때 |
|---|---|---|---|
| clamav (clamd) | 2g | 0.95GB — 서명 363만 개를 읽은 뒤 | 서명을 다시 읽을 때 1.0GB(두 벌을 들지 않는다), 2GB 검사 중 1.0~1.2GB + 받은 파일의 페이지 캐시(한도 안에서 줄여 쓴다) |
| clamav-updater (freshclam) | 1g | 수십 MB | 처음 기동(서명 전체를 받아 읽어 봄) 0.7GB, 평소 갱신은 daily 만 |
| 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, 큰 메일 · 받는 메일 크기) | |
| backend | 1g | 0.3~0.5GB | 조각 · 받기는 스트림이라 파일 크기와 상관없다 |
| db | 1g (14단계에 512m 에서) | 0.16GB 안팎(색인을 채우는 동안도) · 찾기 시험 동안 최대 0.24GB (2026-11-05 · 그때는 공유 버퍼 128MB · 한도 512m) | 공유 버퍼(shared_buffers 256MB — compose 의 command)가 차고 그 위로 표 · 색인의 페이지 캐시(한도 안에서 줄여 쓴다). 메일 검색은 이것이 메모리에 있을 때만 1초 안이다 — 캐시가 비면 3~8배(1만 통 · 색인 121MB) |
| frontend · mail-resolver | 512m · 128m | 작다 | |
| 합 | 6.65GB | 2.5~3GB (14단계 전 — db 는 공유 버퍼가 찬 만큼 는다) | 나머지 1.3GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 |
| restic (백업 — 13단계, compose 밖) | 1g | 돌 때만 — 매일 03:30 몇 분 | 0.63GB (데이터 1.2GB — 2026-10-31 MinIO). 그때는 합이 7.65GB — 사용이 적은 새벽이라 둔다. 복원 연습은 엔진 1g · DB 512m(스크립트에 고정 — compose 의 db 한도를 따르지 않는다)를 더 띄워 합이 8.15GB 로 8GB 를 넘는다(14단계부터 — 그 전은 7.65GB. 한 달에 한 번 — 낮에 하지 않는다) |
- db 1g · 공유 버퍼 256MB 를 운영에 올릴 때(14단계 배포와 함께 — 한 번): 다음
up이 db 를 다시 만든다 — 수 초 멈추고(데이터는DB_DATA_DIR그대로) 떠 있는 backend 는 다시 붙는다(그동안의 요청은 실패). 퇴근 뒤 · 백업(03:30) · 복원 연습을 피해git pull→sudo docker compose --env-file .env.production up -d db(db 만 먼저) →sudo docker stats --no-stream "${PROJECT_NAME}-db"의 한도1GiB·sudo docker exec "${PROJECT_NAME}-db" sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "show shared_buffers"'가256MB→ 14단계의up -d --build. mail 이 이전 때의 2g(docker update)로 남아 있으면 먼저 되돌린다(큰 메일 3. — 그대로면 합이 7.65GB · 백업 때 8.65GB). - ClamAV 의
ConcurrentDatabaseReload를 켜지 않는다(기본은 켬) — 켜면 서명을 다시 읽는 동안(하루 몇 번) 두 벌(2GB 남짓)을 들어 2g 한도에서 죽는다. 한도를 올려 켜려면 합이 8GB 를 넘지 않는지 다시 본다. - 서명은 해마다 는다 —
docker stats의 clamav 가 한도의 80%(1.6GB)를 넘으면 한도를 올리거나(합을 다시 본다) 서버를 키운다. - 디스크(블록 스토리지 100GB · 도커 data-root
/data/docker) — 대용량 첨부 파일(직원마다 10GB ·FILE_STORAGE_MAX_TOTAL_GB) · 메일 볼륨 · DB · 이미지 · ClamAV 의 임시 파일(검사 중 최대 12GB)이 함께 쓴다. 백엔드는 올리기를 만들 때FILE_STORAGE_MIN_FREE_GB(10GB)를 남긴다 — 메일 볼륨이 자랄 몫을 생각해FILE_STORAGE_MAX_TOTAL_GB를 정해 둔다(예: 40).
계정 보안 (12단계 — 인프라)
2단계 인증(인증 앱 TOTP) · 앱 비밀번호 · 로그인 기록. 데이터 · 규칙은 계약 5절 "계정 보안", env 는 루트 예시 env 의 MAIL_WEBHOOK_SECRET 이다.
엔진(0.16.27)이 코드 확인 · 앱 비밀번호를 맡고, 백엔드가 그 앞의 규칙(첫 코드 확인 · 다시 쓴 코드 막기 · 실패 한도 · 웹메일에서 앱 비밀번호 거절)과 로그인 기록을 맡는다.
이 절은 엔진 설정 · 프록시 · 운영만 다룬다.
엔진 웹훅 — 메일 프로그램 로그인 기록
메일 프로그램 ─(993 IMAPS · 465 SMTPS)─▶ mail ─(웹훅 · app-network · 1초 묶음)─▶ backend POST /api/internal/mail-engine-events → 로그인 기록(90일)
X-Signature = base64(HMAC-SHA256(본문, MAIL_WEBHOOK_SECRET))
바깥 ─▶ 외부 프록시 ─▶ frontend nginx /api/internal/… → 404 RES_001 (두 server 모두 — 백엔드로 넘기지 않는다)
엔진에는 계정별 로그인 기록이 없어(공개판 — x:Trace 는 엔터프라이즈 전용) 엔진이 인증 이벤트를 백엔드로 보내고 백엔드가 남긴다. 웹메일 로그인은 백엔드가 스스로 남긴다(엔진에는 백엔드 IP 로 보인다).
| 항목 | 값 |
|---|---|
| 엔진 객체 | x:WebHook 하나 — 관리 화면 Settings › Telemetry › Webhooks. setup 이 만들고 맞춘다(여러 번 돌려도 같다). 관리 화면에서 고치지 않는다 — 다음 setup 이 되돌린다 |
| 받는 곳 | http://backend:<BACKEND_PORT>/api/internal/mail-engine-events — compose 의 서비스 이름. mail 과 backend 가 함께 붙는 네트워크는 app-network 하나라 엔진의 접속 주소가 그 대역이다 |
| 이벤트 | auth.success · auth.failed · auth.mfa-required 만(eventsPolicy: include) — 엔진 기본(exclude + 빈 목록)은 모든 이벤트다 |
| 그 밖의 칸 | level: trace(이 판은 웹훅에 수준을 쓰지 않지만 새 판 문서는 쓴다 — info 면 debug 인 auth.failed · trace 인 auth.mfa-required 가 빠진다) · throttle 1초(묶음) · timeout 5초 · discardAfter 5분 · lossy: false · HTTP 인증 없음 |
| 서명 | 본문 원문의 HMAC-SHA256 을 base64 로 X-Signature 에 — 키 MAIL_WEBHOOK_SECRET(openssl rand -hex 24 로 만든 값 — 사람이 지은 키는 쓰지 않는다). 키는 엔진 설정에 그대로 저장된다 — 관리 화면 · 관리 API 는 **** 로 가리지만 메일 볼륨 백업에는 있다 |
| setup 표시 | x:WebHook 에는 이름 · 설명 칸이 없다 — 머리글 X-Arasystem-Setup: [setup.mjs] key=<소금>.<키 지문> 을 setup 표시로 쓴다. 키는 읽으면 **** 라 키가 바뀌었는지는 이 지문(scrypt — 소금은 setup 이 웹훅을 쓸 때마다 새로 뽑는다)으로 안다. 엔진이 보내는 요청마다 실린다(백엔드는 쓰지 않는다). ⚠️ 관리 화면에서 서명 키만 바꾸면(머리글은 그대로) setup 이 알아채지 못한다 — 키는 env 와 setup 으로만 바꾼다 |
| 받는 쪽 (세 겹) | 앞단 nginx 가 /api/internal/ 을 404 RES_001(두 server · 대소문자 · %2F · %5C · 겹 / 변형 — frontend/nginx-internal-api-closed.conf), 원문 요청 대상에 날 \(경로) · # · 0x80 이상 바이트가 있으면 location 을 고르기 전에 400 VAL_001(두 server — 아래) → 백엔드가 접속 주소(소켓)가 APP_NETWORK_SUBNET 인지 → 서명. 하나라도 아니면 401 AUTH_001 (계약 5절 "엔진 웹훅 받기"). CI 의 "닫는 경로 시험"이 두 server 에서 실제 요청으로 본다 |
| 이름 풀기 | 웹훅은 엔진의 DNS(x:DnsResolver — 운영은 리졸버에 바로)가 아니라 메일 컨테이너의 시스템 DNS(도커 DNS)로 backend 를 푼다(엔진의 HTTP 라이브러리 기본값 — 0.16.27 소스). 개발 엔진의 DNS 를 운영처럼 Custom 으로 바꿔 확인했다(아래 "확인") |
- 받는 것 — IMAP(993) · SMTP(465) 로그인의 성공 · 실패 · "2단계 필요"(2단계를 켠 계정에 비밀번호로). 엔진 HTTP 리스너의 이벤트(웹메일 확인 · 게이트웨이 대리 접근)도 오지만 백엔드가 버린다. 엔진은 서버 전체의 이벤트를 보낸다 — 사내 직원이 아닌 주소의 실패 등은 백엔드가 거른다 (계약 5절).
- 끊기면 잃는 기록 — 엔진의 보낼 대기열은 메모리에 있다. 백엔드가 5분 넘게 받지 못하면(
discardAfter) 그 묶음을 버리고, 엔진을 다시 띄우면 보내지 못한 것이 사라진다 — 그동안의 메일 프로그램 로그인 기록과 앱 비밀번호의 "마지막 사용"이 빠진다(웹메일 로그인 기록은 백엔드가 남기므로 그대로). 백엔드가 멈춘 동안 엔진 로그에WARN Webhook collector error (telemetry.webhook-error) … error sending request가 보낼 것이 있는 동안 1초마다 남고, 5분이 지나 버릴 때 같은 이벤트로Discarded stale events(버린 수)가 남는다 — 백엔드가 돌아오면 멎는다.lossy: false라도 엔진의 다른 일을 막지 않는다 — 엔진은 구독자에게 기다리지 않고 넘기고(가득 차면 그 묶음을 메모리에 더 쌓는다), 5분 지난 것은 버린다(0.16.27 소스) (lossy: true면 쌓인 묶음이 32,768개를 넘을 때 error 수준만 남겨 로그인 이벤트를 버린다). - 키를 바꾸면 — 차례:
.env.production의 값을 바꾸고(openssl rand -hex 24) →sudo docker compose --env-file .env.production up -d backend(백엔드가 새 키로 확인) → setup(엔진이 새 키로 서명 —[변경] 엔진 웹훅 — 서명 키·[확인] … 받았다). 둘 사이에 엔진이 보낸 묶음은 백엔드가 401 로 거절한다(엔진은 5분까지 다시 보낸다) — 둘을 이어서 하고, 그 몇 분의 메일 프로그램 기록은 빠질 수 있다고 본다. setup 을 먼저 돌렸으면 받는 곳 확인이 401 인데, 이번 실행에서 엔진에 키를 넣었으므로 실패가 아니라[주의]와 다음 명령(up -d backend뒤 setup 한 번 더)이다 — 그대로 한 뒤[확인] … 받았다를 본다. - 키가 비면 — 운영은 setup · 백엔드 모두 멈춘다(운영 필수). 개발은 setup 이
[주의]를 내고 있던 웹훅을 끈다(enable: false— 지우지 않는다). 키를 넣고 다시 돌리면 켠다. - setup 의 확인 — 리로드 뒤 다시 읽어 setup 의 값 그대로인지(
[확인] 엔진 웹훅 켜짐), 그리고 메일 컨테이너 안(엔진과 같은 자리 · 같은 DNS)에서 서명한 빈 묶음{"events":[]}을 보내 200 인지([확인] 엔진 웹훅 — … 받았다 (HTTP 200 — 백엔드 쪽(받는 곳 · 접속 주소 · 키)이 맞다)). 엔진에 든 키는 읽을 수 없어 표시 머리글의 지문으로만 안다(위 표). 401(키 · 접속 주소)은 운영은 실패, 개발은[주의](이번 실행에서 키를 넣었으면 운영도[주의]— 위 "키를 바꾸면"), 안내는up -d backend. 404(받는 곳이 없는 12단계 전 backend)는 운영은 실패로up -d --build backend frontend, 개발은[주의]로restart backend를 안내한다. 닿지 않으면 몇 번 다시 해 본 뒤[주의]. 서명은 docker exec 의 명령줄이 아니라 표준 입력으로 넘긴다(도커 이벤트 ·ps에 남지 않게). - 잘못된 요청 대상은 400 (두 server 모두 —
frontend/nginx.conf의$request_target_rejected) — 원문 대상에 날\(경로) · 날#· 0x80 이상 바이트가 있으면 location 을 고르기 전에 400VAL_001(JSON — 화면 경로도 같다)로 끝낸다. nginx 는 그 꼴을 닫는 정규식(/api/internal·/api/health· 공개 API)에 맞추지 못하고 원문 그대로 넘기는데, 백엔드의 Express(parseurl)는#· NBSP(0xA0) 같은 글자가 있으면 옛url.parse로 다시 읽어 경로의\를/로 바꾼다 —POST /api/internal\mail-engine-events#x가 개발 백엔드의 웹훅 가드(401)까지 닿았다(2026-10-31 리뷰 — 같은 원인으로/api/health· 포털의 공개 API 닫기도 11단계 전부터 넘어갔다). 브라우저는 이런 요청을 보내지 않는다(경로의\는/로 바꾸고, 비 ASCII 는 퍼센트 인코딩하고,#뒤는 보내지 않는다). 쿼리의\는 받는다(주소창에 친 검색어 —url.parse는?앞의\만 바꾼다).%5C는 nginx 가\로 풀어 고르므로 닫는 정규식이[/\x5c]로 받는다(두 겹). - setup 표시 없이 같은 받는 곳으로 가는 웹훅이 있으면
[주의]만 낸다(사람이 만든 것으로 본다 — 같은 이벤트가 두 번 가거나 다른 키로 서명돼 거절된다) — Settings › Telemetry › Webhooks 에서 지운다. - 게이트웨이에는 2단계 인증을 켜지 않는다 — 대리 접근은 게이트웨이 자신의 비밀번호 · OTP 를 확인하므로 백엔드의 대리 접근이 모두 402 로 막혀 웹메일 전체가 선다. setup 은 게이트웨이가 402 면 아무것도 바꾸기 전에 멈춘다 — 아래 "휴대폰을 잃은 직원"의 절차를 게이트웨이 주소로 해서 끈다.
- 확인 (2026-10-31, 개발 엔진 0.16.27):
- setup — 처음 실행
[생성]→ 다시 돌리면[유지](바뀐 것 0줄). 이벤트를 하나 더하고timeout을 바꾼 뒤 →[변경] … 이벤트, timeout, 키 지문을 바꾼 뒤 →[변경] … 서명 키, 키를 비우고 →[주의]·[변경] … 끔, 되돌리면[변경] … 꺼짐(다시 켬) →[유지]. 읽으면 서명 키는"****"다. 옛 모양의 표시 머리글(소금 없음)은 한 번[변경] … 서명 키로 맞추고 그다음은[유지]. 다른 키로 돌리자 받는 곳 확인이 401 →[주의] … 이번 실행에서 엔진에 … 넣었고· 원래 키로 되돌리자[확인] … 받았다 (HTTP 200 …). 게이트웨이를 OTP 를 켠 임시 계정으로 가리키자 첫 줄 다음에[실패] 게이트웨이 계정(…)에 2단계 인증(OTP)이 켜져 있습니다— 바꾼 것 없음. - 배달 — 앱 비밀번호로 IMAP · SMTP 로그인, 틀린 비밀번호로 IMAP 로그인을 하자 로그인 기록에 imap 성공(앱 비밀번호 이름) · smtp 성공 · imap 비밀번호 틀림이 생겼고 앱 비밀번호의 "마지막 사용"도 채워졌다(메인).
- 운영 모양의 엔진 DNS — 개발 엔진의
x:DnsResolver를 운영처럼 Custom(리졸버172.16.73.254의 UDP · TCP)으로 바꾸고 리로드한 뒤 IMAP 틀린 비밀번호 한 번 → 백엔드 기록에imap · 실패 · wrongPassword · 172.16.71.2가 생겼고 엔진의 웹훅 오류는 없었다. 메일 컨테이너의/etc/resolv.conf는 도커 DNS(127.0.0.11)이고backend는 앱 네트워크 주소(172.16.71.5)로 풀린다. setup 으로 System 으로 되돌렸다. - 백엔드가 떠 있지 않을 때 — 리로드 뒤 틀린 비밀번호 로그인 한 번에 엔진이 받는 곳으로 보내려다 위의 warn 을 1초마다 남겼다.
- setup — 처음 실행
휴대폰을 잃은 직원 — 2단계 인증 끄기
복구 코드는 없다 — 인증 앱을 쓸 수 없게 된 직원은 운영자가 엔진에서 그 직원의 OTP 를 지운다. 직원 비밀번호 없이 된다. 끄면 그 직원은 비밀번호만으로 로그인하고, 웹메일 설정에서 새 휴대폰으로 다시 켠다 (직원 본인은 끄지 못한다 — 엔진이 끌 때 지금 코드를 요구한다). 먼저 본인인지 확인한다(전화 · 대면 등 — 이 절차는 2단계 인증을 비켜 가는 길이다). 바꾸는 것은 그 계정의 비밀번호 자격 증명에 붙은 OTP 하나뿐이다 — 비밀번호 · 앱 비밀번호 · 웹메일 세션은 그대로다.
1) 관리 API (2026-10-31 개발 엔진에서 아래 명령 그대로 확인 — 관리자 Basic, 직원 비밀번호 없음)
8080(관리 API)은 바깥에 열지 않는다 — 메일 호스트에서 backend 컨테이너의 node 로 앱 네트워크의 http://mail:8080 에 붙는다(관리자 아이디 · 도메인 · 주소는 그 컨테이너의 env 를 쓴다).
관리자 비밀번호는 화면에서 받아 환경변수로만 넘긴다 — 명령줄 · 셸 기록 · ps 에 남지 않는다(docker exec -e 이름 은 값을 명령줄에 싣지 않는다).
# 메일 호스트의 저장소 폴더에서 (docker 그룹이면 sudo --preserve-env=… 없이 docker exec 부터)
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)" # 개발은 .env.development
read -rp '끌 직원의 주소: ' OTP_TARGET
read -rsp '관리자 비밀번호(MAIL_ADMIN_PASSWORD): ' OTP_ADMIN_PW; echo
export OTP_TARGET OTP_ADMIN_PW
sudo --preserve-env=OTP_TARGET,OTP_ADMIN_PW docker exec -i -e OTP_TARGET -e OTP_ADMIN_PW "${PROJECT_NAME}-backend" node - <<'EOF'
const base = process.env.MAIL_JMAP_URL || 'http://mail:8080';
const admin = `${(process.env.MAIL_ADMIN_LOCAL_PART || 'admin').trim().toLowerCase()}@${process.env.MAIL_DOMAIN}`;
const auth = 'Basic ' + Buffer.from(`${admin}:${process.env.OTP_ADMIN_PW}`).toString('base64');
const target = (process.env.OTP_TARGET || '').trim().toLowerCase();
const using = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
(async () => {
const session = await fetch(`${base}/jmap/session`, { headers: { authorization: auth } });
if (session.status !== 200) throw new Error(`관리자 로그인 실패 (HTTP ${session.status})`);
const accountId = (await session.json()).primaryAccounts['urn:stalwart:jmap'];
const call = async (method, args) => {
const res = await fetch(`${base}/jmap/`, {
method: 'POST',
headers: { authorization: auth, 'content-type': 'application/json' },
body: JSON.stringify({ using, methodCalls: [[method, { accountId, ...args }, 'c']] }),
});
const [[name, result]] = (await res.json()).methodResponses;
if (name === 'error') throw new Error(`${method}: ${result.type}`);
return result;
};
let account = null;
for (let position = 0; !account; position += 200) {
const { ids } = await call('x:Account/query', { position, limit: 200 });
if (ids.length === 0) break;
const { list } = await call('x:Account/get', { ids, properties: ['name', 'emailAddress', 'credentials'] });
account = list.find((item) => (item.emailAddress || '').toLowerCase() === target) || null;
}
if (!account) throw new Error(`계정이 없습니다: ${target}`);
const [key, credential] = Object.entries(account.credentials || {}).find(([, item]) => item['@type'] === 'Password') || [];
if (!key) throw new Error('비밀번호 자격 증명이 없습니다');
if (!credential.otpAuth) return console.log(`이미 꺼져 있습니다 — ${target}`);
const result = await call('x:Account/set', { update: { [account.id]: { [`credentials/${key}/otpAuth`]: null } } });
if (!result.updated || !(account.id in result.updated)) throw new Error(`바꾸지 못했습니다: ${JSON.stringify(result.notUpdated)}`);
const { list } = await call('x:Account/get', { ids: [account.id], properties: ['credentials'] });
console.log(list[0].credentials[key].otpAuth ? `아직 켜져 있습니다 — ${target}` : `껐습니다 — ${target} (자격 증명 ${key})`);
})().catch((error) => { console.error(`[실패] ${error.message}`); process.exitCode = 1; });
EOF
unset OTP_ADMIN_PW OTP_TARGET
- 하는 일: 관리자의
x:계정(primaryAccounts["urn:stalwart:jmap"])으로x:Account/query·get(주소emailAddress로 찾는다) → 그 계정의credentials에서"@type":"Password"인 칸(보통"0"— OTP 가 켜져 있으면"otpAuth":"****")의otpAuth만x:Account/set의{"credentials/<번호>/otpAuth": null}로 지우고 다시 읽어 본다.secret은 보내지 않는다("****"를 보내도 엔진이 원래 값을 둔다). - 확인한 것: 시험 직원(
seojun.park)에 직원 경로(게이트웨이 대리 접근의x:AccountPassword/set—currentSecret+otpAuth.otpUrl)로 OTP 를 켜자 Basic 로그인이 402, 위 명령(개발 —arasystem-backend)을 돌리자껐습니다 — …와 함께otpAuth가 없어지고 Basic 로그인이 200 이었다. 다시 돌리면이미 꺼져 있습니다. 켤 때의 주소는 계약대로 이름ARA SYSTEM(띄어쓰기)이었고 엔진이 그 주소로 코드를 확인했다(직원 경로로 끄기도 됨). 끝나고 처음 상태(OTP 꺼짐 · 앱 비밀번호 0개 · 비밀번호 그대로)로 되돌렸다. - 웹메일의 "켠 때"(
enabledAt)는 서버가 엔진에서 꺼진 것을 보면 지운다 (계약 5절 "2단계 인증").
2) 관리 화면 (운영은 SSH 터널 — 관리 화면 — SSH 터널) — 직접 눌러 보지 못했다:
- Management › Directory › Accounts 에서 그 직원을 연다.
- Authentication 의 Credentials 에서 비밀번호 자격 증명(Password)을 연다.
- Secrets 의 OTP Authentication URI 칸(켜져 있으면
****)을 비우고 저장한다. 같은 묶음의 Password 칸(****)은 건드리지 않는다 — 그대로 두면 엔진이 원래 값을 지킨다. - 확인 — 그 직원이 비밀번호만으로 웹메일에 로그인된다(코드를 묻지 않는다). 위 1)의 명령을 다시 돌리면
이미 꺼져 있습니다다.
메뉴 · 칸 이름은 개발 엔진의 관리 화면이 그리는 스키마(/api/schema 의 layouts · forms)에서 찾았다. 칸을 비우면 화면이 null 을 보낸다(관리 화면 코드 — 비울 수 있는 글 칸) — 1)과 같은 요청이다.
⚠️ Account › Credentials › Password(직원 셀프서비스 화면)의 Disable 은 지금 코드를 요구한다 — 휴대폰을 잃은 직원에게는 쓸 수 없다.
운영 확인 (12단계)
- setup —
[확인] 엔진 웹훅 켜짐 — …과[확인] 엔진 웹훅 — 메일 컨테이너에서 서명한 빈 묶음을 백엔드가 받았다 (HTTP 200 …)두 줄이 있다 (운영 배포 5). - 메일 프로그램으로 한 번 로그인한다(아웃룩 · 휴대폰 메일 앱 — IMAP 993) → 웹메일 설정 › 로그인 기록에 그 시각의 IMAP 줄(바깥 주소)이 생긴다.
안 생기면 엔진 로그(
sudo docker logs --since 10m "${PROJECT_NAME}-mail" 2>&1 | grep -i webhook)와 백엔드 로그를 본다. - 바깥에서 막혔는지 —
curl -s -X POST https://portal.arasystem.it.kr/api/internal/mail-engine-events→ 404RES_001(JSON),/api/Internal/…·/api/internal%5Cmail-engine-events도 같다. 날 역슬래시 꼴은 400 이다 —curl -s 'https://portal.arasystem.it.kr/api/health\'→ 400VAL_001(JSON — 외부 프록시가 원문 그대로 넘기는지도 함께 본다). - 직원 안내 — 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 비밀번호로 연결되지 않는다 → 웹메일 설정 › 앱 비밀번호에서 만든 비밀번호로 다시 연결한다(사용자 이름은 메일 주소 그대로). 웹메일 비밀번호를 바꾸면 앱 비밀번호가 모두 지워진다 — 그때도 다시 만든다. 휴대폰을 잃으면 운영자에게 문의한다(위 절).
메일 운영 설정 (13단계 — 인프라)
전환 준비(13단계)에서 setup · compose · nginx 가 더 맡은 것이다. 백업 · 감시 · 로그는 따로 — 백업 · 감시 · 로그 보존.
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분 끊기므로 사용이 적은 시간에 한다. 그 전에 손으로 백업을 받아 두기를 권한다(손으로 받을 때).
cd ~/arasystem && git pull # 13단계 코드 (저장소 사용자로)
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)"
# 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 이 건다(막히면 되돌릴 길)
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)부터).
- 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"'). -
- 바깥에서 관리자 비밀번호가 막혔는지 — 다른 네트워크의 PC 에서
openssl s_client -quiet -connect mx1.arasystem.it.kr:993뒤a LOGIN <관리자 주소> <비밀번호>→NO(또는 끊김). 관리 화면은 이제 SSH 터널로만 들어간다.
- 바깥에서 관리자 비밀번호가 막혔는지 — 다른 네트워크의 PC 에서
관리자 계정 단단히
엔진의 기본 관리자 역할(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 이다.
# 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.27email/src/message/index/mod.rs— 내부 날짜가 아니다). 시험(2026-11-02 임시 엔진, 기간 70초): 내부 날짜 2025-01-01 로 휴지통 · 스팸메일함에 IMAP APPEND → 바로 정리해도 남고, 70초 뒤 정리에 지워졌다(받은편지함의 같은 날짜 메일은 그대로). 그래서 이전 다음 날 30일 넘은 옛 휴지통 메일이 한꺼번에 지워지지 않는다 — 이전한 날부터 30일 뒤에 지워진다(직원에게 알릴 거리). - 자동 비우기는 엔진의 휴지통 · 스팸메일함(특수 메일함 — "Deleted Items" · "Junk Mail") 둘에만 걸린다. mailcow 의 Trash · Junk 는 imapsync
--automap이 이 둘로 옮긴다(2026-11-02 개발 시험 — 따로 생긴 폴더 없음). 따로 생긴 "Trash" · "Junk" 같은 폴더는 지워지지 않는다 — 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 직원이 쓰고 있다(전환 체크리스트의 일):
-
미리 확인 (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. -
NPM (서버 A): Proxy Host 둘 —
autoconfig.arasystem.it.kr·autodiscover.arasystem.it.kr→http://<서버 B 사설 IP>:8080(포털과 같은 곳, Host 는 그대로 — NPM 기본). 접근 로그는 끈다(Thunderbird 는 질의에 메일 주소를 싣는다). 인증서는 3 다음에. -
DNS (가비아):
autoconfig·autodiscoverA → 서버 A 의 공인 IP(지금 mailcow 를 가리키는 레코드를 바꾼다). TTL 은 다른 전환 레코드와 함께 미리 낮춘다. 전파된 뒤 NPM 의 두 Proxy Host 에서 SSL 탭 › Let's Encrypt 인증서(HTTP 검증 — 그래서 DNS 가 먼저다) · Force SSL. 이 이름들의 인증서는 NPM 이 갖는다 — 엔진의 인증서 이름(Additional Hostnames)에는 넣지 않는다("운영 배포"의 TLS 인증서 줄). -
SRV (RFC 6186 · RFC 8314 — 지원하는 프로그램이 쓴다):
이름 값 뜻 _imaps._tcp.arasystem.it.krSRV 0 1 993 mx1.arasystem.it.kr.IMAP — 처음부터 TLS _submissions._tcp.arasystem.it.krSRV 0 1 465 mx1.arasystem.it.kr.보내기 — 처음부터 TLS (465) _submission._tcp·_imap._tcp·_pop3._tcp·_pop3s._tcpSRV 0 0 0 .열지 않음 — _submission은 587(STARTTLS)용이라 465 를 적으면 프로그램이 465 에 STARTTLS 를 시도해 실패한다가비아가 대상
.을 받지 않으면 "열지 않음" 줄은 빼도 된다(없으면 프로그램이 다음 방법으로 넘어간다). -
확인 — 바깥에서
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 에 그대로 둔다.
MX 는 mailcow 그대로이고, mailcow 의 Transport Map 이 시범 사용자 주소 하나만 새 서버로 넘긴다. 새 서버 쪽은 루트 env 두 개로 setup 이 맞춘다.
- 예시 값: 시범 사용자
owner@arasystem.it.kr, 동료(mailcow)colleague@arasystem.it.kr, mailcowmail.arasystem.it.kr(46.250.248.43), 새 서버 IP<MX1_IP> - 근거: 2026-10-29 개발 엔진(0.16.27) 조사 · 시험과 mailcow(2026-10 판) 설정 파일 · 문서 조사. mailcow 쪽 절차와 운영의 실제 흐름은 아직 해 보지 않았다 — "개발 확인"이라고 적은 것만 개발 엔진에서 봤다.
메일 흐름
| 메일 | 길 | 새 서버가 하는 일 |
|---|---|---|
| 바깥 → 시범 사용자 | 바깥 → mailcow(MX · 스팸 검사) → Transport Map → mx1:25 | mailcow IP 에서 인증 없이 받는다 — 그 IP 는 SPF(EHLO · MAIL FROM) · 역방향 DNS · DMARC 검사를 하지 않는다(연결 IP 가 mailcow 라 늘 실패한다). DKIM 검증 · 스팸 판정은 그대로 |
| 동료 → 시범 사용자 | SOGo · 메일 프로그램 → mailcow → Transport Map → mx1:25 | 사내 도메인 MAIL FROM 을 mailcow IP 에서만 받는다. 받는 쪽 한도 · 자동 차단에서 mailcow IP 를 뺀다 |
| 시범 사용자 → 동료 | 웹메일 · 465 → mx1 → 발송 경로 legacy → mailcow:25 → 동료 사서함 |
새 서버에 없는 사내 주소도 받아서(allowRelaying) mailcow 로 넘긴다. DKIM 은 새 selector 로 서명 |
| 새 서버가 만든 메일 → 동료 | 반송 · 필터 전달 · 부재중 → legacy → mailcow |
봉투 보낸 사람은 빈 봉투 <>(반송)이거나 시범 사용자 주소(전달 · 부재중) — mailcow 의 reject_unlisted_sender 를 통과한다 |
| 시범 사용자 → 바깥 | mx1 → 받는 쪽 MX 직접 | 시범 앞과 같다 — mailcow 를 거치지 않는다(SPF 에 새 IP) |
| 시범 사용자 → 시범 사용자 | mx1 안 | 로컬 |
| 인증 없이 mx1:25 로 온, 새 서버에 없는 사내 주소 (mailcow 포함 어디서든) | — | RCPT 550 5.1.2 — 받아 놓고 되돌려 보내지 않는다 (루프 1차 막기) |
| mailcow 가 아닌 IP 에서 온 사내 도메인 MAIL FROM | — | 550 5.7.1 (위조 막기 — 빈 봉투 <> 는 그대로 받는다) |
루프는 두 겹으로 막는다 — ① RCPT 가드: 인증 없는 세션의 새 서버에 없는 사내 주소는 받지 않는다 ② 발송 전략: legacy 로는 인증한 제출(웹메일 · 465)과 엔진이 만든 메일(필터 전달 · 부재중 · 반송 · 보고)만 보낸다 —
인증 없이 들어온 메일은 로컬(배달 실패 → 보낸 사람에게 반송, 그 반송은 동료 앞이면 legacy 로). 엔진의 홉 한도(x:MtaStageData 의 maxReceivedHeaders 50 → 450 4.4.6)가 마지막 막이다.
RCPT 가드 없이 allowRelaying 만 켜진 상태는 setup 이 만들지 않는다 — 켤 때는 가드가 걸린 뒤에 켜고, 켠 뒤 가드가 답하지 않으면 바로 다시 끄고, 끌 때는 allowRelaying 이 꺼진 것을 엔진에서 다시 읽어 확인한 뒤에만 가드를 뺀다(2026-10-30 리뷰 H1 · M1).
사전 조건
- 운영 전환 체크리스트의 "1 결정 · 준비" · "2 서버 배포"를 마쳤다 — 새 서버가 mx1 로 떠 있고(운영 배포), setup 을 다시 돌리면 전부
[유지]다. - DNS 는 더하기만 (가비아) — 전환 절차의 단계 1 · 3 · 4:
mx1A →<MX1_IP>· PTR →mx1.arasystem.it.kr, setup 이 출력한 새 DKIM selector TXT 두 개(v1-ed25519-…·v1-rsa-…), SPF 에ip4:<MX1_IP>. DKIM TXT 는 첫 시범 실행 전에 퍼져 있어야 한다 — 운영 setup 이 이 호스트의 리졸버로 읽어, 없거나 키가 다르면 켜지 않고 멈춘다. setup 출력의 한 줄은<이름>.arasystem.it.kr. IN TXT "<값>"이다 — 가비아의 호스트 칸에는 끝의.arasystem.it.kr.를 뗀 이름, 값 칸에는 따옴표 안의 글만 넣는다. RSA 값(420자 안팎)은 setup 이 255자 + 나머지로 나눠 보이지만 가비아는 조각마다 250자 미만 · 모두 500자까지만 받는다 — 조각을 다시 나눠"…" "…"꼴로 넣는다(2026-10-31):awk '/v1-rsa-/{on=1} on{print} on && /\)/{on=0}' <setup 출력 파일> | grep -o '"[^"]*"' | tr -d '"\n' | fold -w 210 | sed 's/.*/"&"/' | paste -sd' '. 넣은 뒤Resolve-DnsName v1-rsa-….arasystem.it.kr -Type TXT -Server ns.gabia.co.kr로 조각이 이어져 키가 끝까지(…IDAQAB) 들어갔는지 본다. MX · DMARC · autodiscover 는 바꾸지 않는다. mx1 에 AAAA 는 두지 않는다(아래 "주의" 의 IPv6). - 새 서버의 계정은 시범 사용자 하나만. 다른 직원 · 별칭 · 공용 주소 ·
dmarc@·postmaster@는 만들지 않는다(아래 "주의"). - 새 서버 → mailcow 25 가 열려 있다(setup 이 확인한다 — 운영은 25 만 받는다. 587 · 465 는 인증이 있어야 중계되는데 setup 은 발송 경로에 인증을 넣지 않는다).
mailcow 가 새 서버에 붙을 때의 IP 를 안다 — 보통
mail.arasystem.it.kr의 A(46.250.248.43). 받는 IP 와 나가는 IP 가 다를 수 있다 — 운영 mailcow 는 받기46.250.248.43· 나가기125.143.81.248(KT, PTR 없음)이었다(2026-10-31). 빗나가면 Transport Map 의 Test 가550 5.7.1 Sender address not allowed다 — 새 서버 로그docker logs --since 10m "${PROJECT_NAME}-mail" 2>&1 | grep -o 'remoteIp = [0-9.]*' | sort | uniq -c에서 Test 의 EHLO(mail.arasystem.it.kr)를 보낸 IP 를 찾아MAIL_PILOT_LEGACY_IPS에 콤마로 더하고 setup 을 다시 돌린다. 그 IP 가 고정인지 mailcow 쪽에 확인한다(바뀌면 동료 메일이 다시 550). MAIL_ADMIN_LOCAL_PART(기본admin) ·MAIL_DIRECTORY_LOCAL_PART(기본mail-directory) · 게이트웨이MAIL_GATEWAY_USER의 @ 앞(예mail-gateway)이 mailcow 의 실제 주소와 겹치지 않는다 — 셋 다 새 서버의 로컬 주소(메일을 받지 않는 시스템 계정)라, 시범 사용자가 mailcow 의 같은 이름 주소로 보내면 새 서버가not-found로 거절한다.- 기존 서버 IP(
MAIL_PILOT_LEGACY_IPS)에 사람이 만든 자동 차단 예외(x:AllowedIp)가 없다 — setup 이 같은 주소로 시범 표시 항목을 만들어야 한다(같은 주소의 항목은 둘일 수 없다). 있으면 setup 이 멈춘다 — 지운 뒤 다시 실행한다. - 시범 사용자의 mailcow 사서함은 그대로 둔다 — Transport Map 은 mailcow 에 있는 받는 사람에게만 먹고, 새 서버가 owner@ 명의로 동료에게 보낼 때도 mailcow 의
reject_unlisted_sender를 이 사서함으로 통과한다.
시범 주소가 mailcow 에 없던 새 주소일 때 (실제 시범: sysadmin@arasystem.it.kr · 표시 이름 "시스템 관리자" — 직원 메일함이 아니라 관리용 새 주소로 시험한다):
mailcow 에 같은 주소의 메일함을 먼저 만든다 — 받기만 하는 자리다. Transport Map 은 mailcow 에 있는 받는 사람에게만 먹어 이 주소 앞 메일을 새 서버로 넘기고,
새 서버가 이 주소로 동료에게 보낼 때 mailcow 의 reject_unlisted_sender 를 이 메일함으로 통과한다. 로그인은 처음부터 막아 둔다 — Active 를 "Disallow login (incoming mail is still accepted)",
Allowed protocols 와 SOGo 접근을 모두 끄고 비밀번호는 무작위로(아래 9 를 미리 해 두는 것과 같다). 옛 메일이 없으니 imapsync(아래 5 · 7)는 건너뛰고,
되돌리기의 "mailcow 로 되복사"(되돌리기 3)는 시범 동안 새 서버에 온 · 보낸 메일뿐이다. 새 서버에서 이 주소는 보통 직원 계정(User 역할)이다 —
엔진 관리자(MAIL_ADMIN_LOCAL_PART)가 아니고 백엔드 차단 목록(MAIL_BLOCKED_ACCOUNTS)에도 넣지 않는다(넣으면 웹메일에 로그인하지 못한다). 아래 절차의 owner@ 를 이 주소로 읽는다.
env 와 실행 (새 서버)
.env.production 에 두 값을 적는다 — 셸이 아니라 파일에 (운영은 env 파일에 없는 시범 값이 셸에 있으면 setup 이 멈춘다):
MAIL_PILOT_LEGACY_HOST=mail.arasystem.it.kr # host · host:port · [host] · [host]:port (포트 기본 25 — 운영은 25 만)
MAIL_PILOT_LEGACY_IPS=46.250.248.43 # mailcow 가 이 서버에 붙을 때의 공인 IPv4 · CIDR (콤마)
그리고 운영 배포 5) 와 같은 형태로(MAIL_ADMIN_URL · sudo 여부도 같게) setup 을 실행한다:
node --env-file=.env.production mail/scripts/setup.mjs
| 값 | 받는 꼴 | 운영에서 막는 것 |
|---|---|---|
MAIL_PILOT_LEGACY_HOST |
host · host:port · [host] · [host]:port — 포트는 1~65535, 앞자리 0 없이(기본 25). IPv6 는 받지 않는다 |
25 가 아닌 포트 · mailpit · 점 없는 이름(도커 내부 이름으로 풀릴 수 있다) · 사설 · 특수 IPv4(문서 · 시험 대역 192.0.2.0/24 · 198.51.100.0/24 · 203.0.113.0/24 · 198.18.0.0/15 · 192.0.0.0/24 포함) · MAIL_HOSTNAME(모든 환경). 이름이 사설 · 특수 주소로 풀려도 멈춘다(실행할 때 메일 컨테이너 안에서 푼다) |
MAIL_PILOT_LEGACY_IPS |
IPv4 · CIDR(네트워크 주소로)을 콤마로 | 비어 있음(HOST 가 있으면 필수) · 사설 · 특수 대역(위와 같은 문서 · 시험 대역 포함) · APP_NETWORK_SUBNET 과 겹침 · /24 보다 넓음 · 이 서버 자신의 주소 |
- 기존 서버 주소가 IPv4(예:
46.250.248.43)면 발송 경로의 인증서 이름 확인을 끈다(allowInvalidCerts: true— 출력에 알린다). 이름(mail.arasystem.it.kr)이면 mailcow 의 공인 인증서로 확인한다 — 이름을 권한다. - 개발은 셸에서 일회성으로 켜 본다(개발만 셸 값을 받는다) — 기존 서버 역은 Mailpit, 기존 서버 IP 는 비우면 백엔드 컨테이너 주소(개발의 시험 SMTP 가 나가는 곳 — 앱 네트워크 전체는 쓰지 않는다):
MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs→ 값 없이 다시 돌리면 바로 끈다.
setup 이 하는 일
먼저 확인한다 — 하나라도 어긋나면 아무것도 바꾸지 않고 멈춘다. 다만 RCPT 가드 없이 allowRelaying 이 열려 있으면 그것부터 닫는다([변경], 다시 읽어 확인) —
그다음 확인이 모두 맞으면 아래 차례(A → B → C)로 다시 켜고, 어긋나면 닫힌 채 멈춘다(머리글 "allowRelaying 만 닫고 중단합니다").
닫지 못하면(저장이 거절되거나 · 엔진에 닿지 않거나 · 다시 읽어도 켜져 있으면) 다른 확인 · 설정(경로 · 전략 · C) 없이 멈추되(종료 1), 멈추기 전에 RCPT 가드만 다시 건다 —
RCPT 단계 식이 setup 의 것이거나 비었을 때만 가드 갈래를 저장하고 시스템 Sieve 스크립트를 켠 뒤 리로드하고 가드를 확인한다(2026-10-30 3차 리뷰 R1). 멈춤 글이 그 결과를 싣는다 —
"대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다(550 · Nms). relay 는 여전히 켜져 있습니다" 또는 "RCPT 가드도 다시 걸지 못했습니다 — 지금 가드 없이 열려 있습니다"
(운영자의 식이면 건드리지 않고 그렇다고 적는다). 어느 쪽이든 관리 화면에서 그 도메인의 relay 허용(allowRelaying)을 끈 뒤 다시 실행한다(N1). 멈출 때는 그 전에 쌓인 확인 실패도 함께 찍는다.
| 확인 | 까닭 |
|---|---|
x:MtaStageRcpt 의 waitOnFail 이 고정 값이고 4초 이상(기본 5초) · (allowRelaying 이 이미 켜져 있으면) 인증 없는 세션의 새 서버에 없는 사내 주소 RCPT 가 2초 안에 550 이다 |
가드(Sieve)의 거절은 곧바로(개발 0.2waitOnFail 뒤(개발 4,999waitOnFail 이 바뀌어 있어도 확인은 보낸다 — 250 이면(시간과 상관없이 가드가 없다) 닫고, 550 이면 relay 를 그대로 두고 waitOnFail 문제로 멈춘다(N3) |
| 도메인에 catch-all 이 없다 | catch-all 은 allowRelaying 보다 먼저라 동료 앞 메일이 catch-all 함으로 간다 |
하위 주소(subAddressing)가 기본(Enabled — +), 바깥 디렉터리(directoryId)가 없다 |
RCPT 가드가 + 를 가정한다 · 엔진의 계정만 로컬로 본다 |
기존 서버 주소가 엔진의 서버 이름(x:SystemSettings 의 defaultHostname · MAIL_HOSTNAME)과 다르고, 메일 컨테이너 안에서 풀어도 엔진 자신(메일 컨테이너 주소 · 이 호스트의 IPv4 · 서버 이름의 주소 · 루프백)이 아니며, 기존 서버의 인사(220)가 엔진의 서버 이름이 아니다 · (운영) 이름이 사설 · 특수 주소로 풀리지 않는다 |
자기 자신으로 보내면 메일이 돈다 — 인사 이름으로 보는 것은 DNS · NAT 와 상관없이 잡힌다 |
메일 컨테이너 안에서 curl -X NOOP smtp://<기존 서버>:<포트>/<서버 이름> 이 220 · 250 — 운영은 --ssl-reqd(STARTTLS 와 인증서까지, IPv4 주소면 인증서는 보지 않는다) |
닿지 않으면 동료 앞 메일이 새 서버 대기열에 쌓인다. 메일은 보내지 않는다. 이미 켜진 시범의 같은 기존 서버가 잠깐 닿지 않거나 이름이 풀리지 않으면 [주의] 만 하고 이어 간다(다른 정비를 막지 않는다) — 기존 서버를 바꾸는 실행이면 멈춘다 |
발송 경로 이름 legacy · RCPT 단계 스크립트 · 운영의 발송 전략("사내 도메인은 로컬, 나머지는 경로 하나" 꼴)을 다른 설정이 쓰지 않는다 |
setup 은 남의 설정을 덮어쓰지 않는다 — 외부 릴레이(SES 등)를 쓰고 있으면 그 경로가 바깥 발송으로 남는다 |
기존 서버 IP 에 메일 컨테이너 주소가 없다 · (운영) 이 서버 자신의 주소가 없다 · (운영) 기존 서버 이름의 주소가 목록에 있는지(없으면 [주의]) |
위조 막기를 메일 컨테이너에서 확인한다 |
기존 서버 IP 에 사람이 만든 자동 차단 예외(x:AllowedIp)가 없다 |
setup 의 시범 표시 항목이 그 주소를 써야 한다(아래 "주인 표시") — 지운 뒤 다시 실행하면 setup 이 같은 주소로 만든다 |
| 기존 서버 IP 로만 된 시범 꼴의 갈래(위조 막기 · 검사 예외 · "Sender IP throttle")가 표시 없이 있지 않다 · 한 칸에 setup 꼴의 갈래가 둘 이상이 아니다 | 같은 원칙(N4) — 운영자가 넣은 같은 IP 의 갈래는 표시를 만든 다음 실행부터 setup 의 갈래와 가를 수 없어 합쳐지고, 시범을 끝낼 때 함께 지워진다. 그 갈래를 지운 뒤(IP 한도는 엔진 기본 — 모든 IP — 으로) 다시 실행한다. 켜진 시범에서 setup 의 시범 표시(자동 차단 예외)만 지워진 것이면 setup 의 갈래도 이렇게 보인다 — 그때는 같은 설명으로 표시를 다시 만들어도 된다. 둘 이상이면 하나만 남기고 지운다. 다른 IP 의 같은 꼴 갈래는 막지 않고 건드리지도 않는다 |
| (운영) 활성 DKIM selector 의 TXT 가 DNS 에 있고 공개 키가 엔진의 키와 같다 — 이 호스트의 리졸버로 읽는다 | 다르면 mailcow 가 owner 의 메일을 위조로 거절한다(R_DKIM_REJECT → SPOOFED_UNAUTH — 아래 mailcow 절차 4). 없으면 DMARC 가 SPF 정렬에만 기대게 되어(R_DKIM_PERMFAIL 은 0점), 반송(빈 봉투 — SPF 는 HELO 이름으로 본다)이나 SPF 가 빗나간 메일이 거절된다. 처음 켤 때 다르거나 TXT 가 확실히 없으면(ENOTFOUND · ENODATA) 멈춘다 — relay 를 켜지 않는다, TXT 를 넣고 퍼진 뒤 다시(N8). 이미 켜져 있으면 실패로 보고만(닫지 않는다). 리졸버 오류 · 엔진이 공개 키를 주지 않으면 [주의](견줄 수 없음 — 운영에서는 아직 돌려 보지 않았다) |
그다음 순서대로 맞춘다 — 막는 것부터:
| 차례 | 엔진 설정 | 값 |
|---|---|---|
| A | x:AllowedIp |
기존 서버 IP — 설명 [setup.mjs] 시범 공존 — 기존 서버(mailcow) 중계 IP (→ ReloadBlockedIps). 시범 설정의 주인 표시라 A 의 맨 앞에서 만든다 — 그 뒤 어디서 멈춰도 다음 실행이 setup 의 갈래 · 한도 예외를 알아본다(N5). 바뀐 IP 의 옛 항목은 식을 모두 고친 뒤에 지운다 |
| A | x:MtaStageRcpt 의 script |
시스템 계정 갈래 + RCPT 가드 — 인증 없는 세션의 새 서버에 없는 사내 주소(+태그 꼴 포함)는 시스템 계정과 같은 스크립트로 550 5.1.2 |
| A | x:MtaInboundThrottle "Sender address to recipient throttle" |
인증 없는 세션 중 기존 서버 IP 가 아닌 것만 센다 |
| A | x:MtaStageMail 의 isSenderAllowed |
맨 앞 갈래 — 인증 없는 세션의 사내 도메인 MAIL FROM 이 기존 서버 IP 가 아니면 false → 550 5.7.1 |
| A | x:SenderAuth 의 spfEhloVerify · spfFromVerify · reverseIpVerify · dmarcVerify |
맨 앞 갈래 — 기존 서버 IP 면 disable (나머지 갈래는 그대로) |
| A | x:MtaInboundThrottle "Sender IP throttle" |
기존 서버 IP 를 세지 않는다(찾지 못하거나 엔진 기본 · setup 의 값이 아니면 [주의]) |
ReloadSettings |
A 가 걸린다 | |
| 켜기 전 확인 | 인증 없는 세션의 새 서버에 없는 사내 주소 RCPT 가 2초 안에 550 — relay 가 꺼져 있어도 같은 판별이다(RCPT 단계 Sieve 는 받는 사람 해석보다 먼저 돈다 · 개발 실측 0.3~0.7ms). 아니면 B · C 없이 종료 1(N2) | |
| B | x:MtaRoute Relay legacy |
<HOST>:<포트>, STARTTLS 가 있으면 쓴다 — 설명 시범 공존 — … [setup.mjs] |
| B | x:MtaOutboundStrategy 의 route |
0 새 서버의 주소 → 로컬 · 1 그 밖의 사내 주소 + source 가 authenticated · autogenerated · dsn · report → legacy · 2 그 밖의 사내 주소 → 로컬 · 나머지 → 시범 앞의 값(운영 mx, 개발 mailpit) |
ReloadSettings |
B 가 걸린다 | |
| C | x:Domain 의 allowRelaying |
true — 저장 즉시 먹는다(리로드가 필요 없다). 그래서 A · B 가 걸리고 가드를 확인한 뒤에 둔다 |
- 갈래 1 의
source는 허용 목록이다(2026-10-30 리뷰 L1). 0.16.27 의 값은authenticated(웹메일 · SMTP 인증 제출) ·unauthenticated·dmarc_pass(인증 없는 수신) ·dsn(반송) ·report(보고) ·autogenerated(필터 전달 · 부재중 등 Sieve) ·unknown(표시가 없는 대기열 메일)이다(소스smtp/src/queue/mod.rs). 빠진 셋은 legacy 로 가지 않는다 — 인증 없는 수신은 RCPT 가드가 먼저 막고,unknown은 0.16.27 이 새로 만드는 메일에는 붙지 않는다(옛 판에서 남은 대기열 메일뿐). 엔진 마이너를 올리면 이 목록을 다시 본다. - 주인 표시 — 식(위조 막기 · 검사 예외 · "Sender IP throttle")에는 설명 칸이 없어, setup 은 시범 표시가 있는 자동 차단 예외의 주소와 같은 IP 의 갈래만 자기 것으로 본다.
표시가 없으면 같은 꼴이어도 운영자 것으로 보고 켤 때도 끌 때도 건드리지 않는다(시범 설정이 있을 때만
[주의]로 알린다). 뺄 때는 맨 앞만이 아니라 모든 갈래에서 찾는다. 기존 서버 IP 로만 된 운영자 갈래는 켜기 전에 멈추고(위 확인 표), 한 칸에 setup 꼴의 갈래가 둘 이상이면(setup 은 하나만 넣는다 — 나머지는 운영자가 같은 글로 넣은 것) 켤 때는 멈추고 끌 때는 맨 앞 하나만 빼고 나머지를 남긴다([주의]) — 켤 때도 끌 때도 운영자 갈래를 합치거나 지우지 않는다(N4). - 받는 사람 한도("Sender address to recipient throttle")는 표시로 가리지 않는다 — 8단계부터 setup 이 match 를 통째로 정하는 칸이라, 켤 때는 기존 서버 IP 를 뺀 식으로, 끌 때는 "인증 없는 세션에만"으로 되돌린다. 표시를 보는 것은 끄는 실행이 relay 가 켜져 있어 시범 설정을 남길 때 그 예외가 setup 의 것인지 가르는 데뿐이다(N5).
확인 — 메일 컨테이너 안에서 자기 네트워크 주소의 25 로, RCPT 까지만 (메일은 보내지 않는다):
- 켜기 전(A 의 리로드 뒤 · B 앞): 인증 없는 세션
RCPT TO:<setup-check-<난수>@도메인>→ 550 이고 2초 안 (RCPT 가드, 보낸 사람은 빈 봉투). 아니면 relay 를 켜지 않고 종료 코드 1(2차 리뷰 N2). 이미 켜진 시범이면 relay 가 켜진 채로 보고, 가드가 답하지 않으면 곧바로 끈다. - 켠 뒤(C 바로 뒤): 같은 확인. 아니면
allowRelaying을 곧바로 다시 끄고(다시 읽어 확인) 종료 코드 1 — 가드 · 경로는 남겨 두어 원인을 고친 뒤 같은 명령으로 다시 켠다(2026-10-30 리뷰 M1). 끄지 못하면 "끄려 했지만 끄지 못했습니다 — 지금 가드 없이 열려 있을 수 있습니다"로 알린다(N1). - 인증 없는 세션
MAIL FROM:<setup-check-<난수>@도메인>→550 5.7.1(메일 컨테이너는 기존 서버 IP 가 아니다) - [개발] 인증한 세션(465, 테스트 직원)
RCPT TO:<setup-check-…@도메인>→ 250 뒤 RSET — 운영은 직원 비밀번호가 없어 하지 않는다(아래 mailcow 절차 4 의 실제 발송으로 본다). 비밀번호는docker exec의 환경변수가 아니라 표준 입력으로 넘긴다
다시 돌리면 전부 [유지], 값이 바뀌면 그 칸만 [변경] 이다. 끌 때는 거꾸로 — allowRelaying 을 먼저 끄고(저장 즉시) 엔진에서 다시 읽어 꺼진 것을 확인한 뒤에만
발송 전략 → RCPT 가드 · 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외 → 리로드 → legacy 경로(발송 전략을 되돌린 리로드 뒤 — 걸려 있는 전략이 지워진 경로를 고르는 틈이 없게, L5).
운영은 값을 비우는 것만으로는 끄지 않는다 — env 파일에서 값이 빠진 채 돌리면 "값을 다시 넣거나 --end-pilot" 으로 멈춘다(아무것도 바꾸지 않는다). 개발은 바로 끈다.
allowRelaying 은 켜져 있는데 setup 의 legacy 경로가 없으면(관리 화면에서 경로만 지웠을 수도 있다) 누가 켠 것인지 알 수 없어 끄지 않고 실패로 보고하며,
그동안 RCPT 가드 · 위조 막기 · 예외 · 발송 전략도 남긴다(가드 없는 relay 를 setup 이 만들지 않는다). allowRelaying 을 끈 뒤 다시 돌리면 그때 뺀다.
다른 도메인이 allowRelaying 을 켜 두었으면 setup 은 그 도메인을 끄지 않고 시범 설정을 남긴다(RCPT 가드는 모든 사내 도메인을 덮는다) — "다른 도메인 … 이 켜 두어"로 알린다.
시범 설정이 하나도 없는데 allowRelaying 만 켜져 있으면 바꾸지 않고 실패로 알린다(요약 시범 설정 없음 · allowRelaying 켜짐 — 가드 없이 열린 relay 다).
개발 확인 (2026-10-29, 0.16.27 — 기존 서버 역 Mailpit, 기존 서버 IP 역 백엔드 컨테이너):
- 처음 실행은 A · B · C 가
[생성]·[변경], 다시 돌리면 전부[유지]· 종료 코드 0. 기존 서버 IP 를 둘로 늘렸다 줄이면 그 칸만[변경]·[삭제](여러 IP 의 식도 엔진이 받았다). - 웹메일 → 새 서버에 없는 사내 주소는
legacy경로로 한 번 배달(엔진 로그delivery.delivered … hostname = "<legacy 주소>", Mailpit 의 id 가 같다), 있는 계정은 로컬, 바깥 주소는 개발 외부 경로로, 시스템 계정은 여전히not-found. - 인증 없는 25 의 없는 사내 주소(+태그 포함) 550 · 있는 계정(+태그 · 대문자 포함) 250, 메일 컨테이너의 사내 도메인 MAIL FROM
550 5.7.1, 기존 서버 IP 의 사내 도메인 MAIL FROM 은 받아서 배달(Authentication-Results: … none). - 기존 서버 IP 가 새 서버에 없는 주소를 되돌려 보내면 RCPT 550 — Mailpit 에 되돌아 배달된 것이 없고 대기열이 빈다.
- 기존 서버 주소가 엔진 자신(
mail:25) ·MAIL_HOSTNAME· 닿지 않는 주소면 아무것도 바꾸지 않고 멈췄다. 값 없이 다시 돌리면 엔진 설정이 시범 앞과 같아졌다(스냅숏 비교) — 웹메일의 없는 사내 주소는 다시not-found.
개발 확인 (2026-10-30 리뷰 수정 뒤, 기존 서버 역 arasystem-mailpit:1025 — 엔진 로그의 hostname 으로 개발 외부 경로 mailpit 과 가른다):
- 허용 목록 경로 — 465 인증 제출(
queue.authenticated-message-queued) · doyun 의 필터 전달 · 부재중 응답(queue.autogenerated-queued)이 새 서버에 없는 사내 주소로legacy에 한 번씩. 부재중은 개발의.test도메인 탓에 스팸 점수가 높아(7.5) 시험 동안만 기존 서버 IP 를 스팸 필터에서 빼고 되돌렸다. RCPT 가드 갈래만 잠시 뺀 엔진에서 기존 서버 IP 가 보낸 인증 없는 메일은legacy로 가지 않고 로컬 실패(delivery.dsn-perm-fail) → 그 반송은 동료(새 서버에 없는 사내 주소) 앞이라legacy로 한 통. - 닫힌 쪽(M1) — 위처럼 가드 갈래가 빠진 채 relay 가 열린 엔진 · 시스템 Sieve 스크립트를 끈 엔진(가드 식은 있지만 RCPT 가 250)에서 시범 값으로 돌리자,
setup 이 먼저
allowRelaying을 끄고([변경] … RCPT 가드 없이 allowRelaying 이 켜져 있어 …·… RCPT 가드가 답하지 않아(… 250 2.1.5 OK) …) 가드 · 스크립트를 되살린 뒤 C 로 다시 켰다 — 켠 뒤 확인 1ms 안팎, 종료 코드 0. - 이미 켜진 시범(L2) — 기존 서버 역을 멈춘(
docker pause) 채 같은 값으로 돌리면[주의] … 닿지 않습니다 … 이미 켜진 시범이라 이어 간다· 종료 코드 0. 다른 기존 서버(닿지 않는 포트)로 바꾸는 실행은 멈췄고 엔진 스냅숏이 그대로였다. - H1 — 시범이 켜진 엔진에서
legacy경로만 지우고 값 없이 돌리면[유지] … allowRelaying 이 켜져 있어 끝내지 않는다· 가드 그대로(RCPT 550) · 종료 코드 1.allowRelaying을 손으로 끈 뒤 다시 돌리자 나머지를 뺐고, 끝난 엔진 스냅숏이 시험 전과 같았다. - 켬 → 같은 값으로 다시(전부
[유지]) → 값 없이(끄기 —[삭제] … legacy가 리로드 뒤) → 다시 값 없이(전부[유지]) 뒤 엔진 스냅숏이 시험 전과 같았다.
개발 확인 (2026-10-30 2차 리뷰 수정 뒤):
- 켜기 전 확인(N2) — 처음 켤 때
[생성] 자동 차단 예외(표시)가 A 의 맨 앞에 나오고, A 의 리로드 뒤 relay 가 꺼진 채[확인] … 켜기 전 확인(allowRelaying 꺼짐): … 곧바로 거절 (… 0.4ms)다음에 C. 시범이 켜진 엔진에서 relay 만 잠시 끄고 잰 RCPT 5번 0.30.7ms(켠 채로는 0.40.7ms) — relay 와 상관없이 가드의 빠른 550 이다. - 다시 읽기(N1) —
x:Domain/set(allowRelaying false · true) 바로 뒤x:Domain/get100번 모두 저장한 값과 같았다. - waitOnFail 을 바꾼 엔진(N3) —
waitOnFail1s + 가드가 먹는 엔진:[주의] RCPT 가드가 550 으로 답합니다 …뒤 relay 를 그대로 두고waitOnFail문제로 멈춤(종료 1, "켜진 채로 두었습니다"). 거기서 시스템 Sieve 를 끄면 확인이 250 →[변경] … 껐다· "allowRelaying 만 닫고 중단합니다"(종료 1).waitOnFail을 되돌리고 다시 돌리자 Sieve 를 되살리고 다시 켰다(종료 0). - 운영자 갈래(N4) — 켜기 전 엔진의
spfFromVerify에 기존 서버 IP 갈래를 손으로 넣자 사전 확인에서 멈췄고(종료 1) 엔진이 그대로였다. 켜진 엔진의dmarcVerify에 setup 과 같은 글의 갈래를 하나 더 넣자 "둘 이상"으로 멈췄고(종료 1) 바뀐 것이 없었다. - 켬 → 같은 값으로 다시(바뀐 줄 없음) → 값 없이(끄기) → 다시 값 없이 뒤, 그리고 위 시험들을 되돌린 뒤 엔진 스냅숏이 시험 전과 같았다.
mailcow 쪽 절차 (mailcow 호스트 /opt/mailcow-dockerized — 아직 해 보지 않았다)
mailcow 2026-10(master) 의 설정 파일 · 화면 · 문서로 정리했다. 운영 중인 mailcow 가 더 오래됐으면 화면 이름이 다를 수 있다 — 0 의 명령으로 먼저 대조한다.
cd /opt/mailcow-dockerized
alias dcx='docker compose exec postfix-mailcow'
SSH 가 없을 때 (mailcow 관리 화면만) — 운영 mailcow 에는 관리 화면만 쓸 수 있다. 아래 절차의 명령은 SSH 가 있을 때의 길이고, 화면으로는 이렇게 한다:
| 절차 | 화면으로 |
|---|---|
| 0 읽기 점검(명령) | SSH 가 있을 때만 한다 — 없으면 건너뛰고 4 · 6 의 시험 메일과 화면의 로그로 대신 본다. 도메인(백업 MX · "Relay all recipients" 가 꺼져 있는지)과 Synchronizations 는 화면으로 본다 |
| 1 postscreen 허용 목록 | 파일이라 화면으로는 할 수 없다 — 대신 새 서버 IP(<MX1_IP>)가 차단 목록에 없는지 본다: Spamhaus IP 조회 · MXToolbox Blacklist Check. PTR(mx1)이 있고 목록에 없으면 보통 postscreen 을 통과한다 |
| 2 Fail2ban 예외 | 그대로 화면 — System → Configuration → Options → Fail2ban parameters |
4 · 6 의 로그(docker compose logs postfix-mailcow · rspamd-mailcow) |
System → Information → Logs 의 Postfix · Rspamd (판에 따라 메뉴 이름이 다를 수 있다) |
6 postmap -q · postfix reload |
하지 않는다 — Transport Map 은 저장하면 1분 안에 먹는다(reload 없이). 목록의 Test 버튼과 실제 시험 메일로 본다 |
10 · 되돌리기 2 의 대기열(postqueue) |
E-Mail → Queue Manager — 대기열을 보고 Flush queue 로 다시 보낸다 |
되돌리기 1 의 postfix reload · postmap -q |
하지 않는다 — Deactivate 뒤 1분 기다린 다음 시험 메일이 mailcow 사서함에 오는지 본다 |
-
읽기만 하는 점검 (SSH 가 있을 때 — 없으면 위 표) — 로컬로 새는 길이 없는지:
grep -n -A3 '^transport_maps' data/conf/postfix/main.cf # custom_transport.pcre, local_transport, mysql_relay_ne.cf, mysql_transport_maps.cf 차례 dcx postconf -P 'smtpd/pass/smtpd_sender_restrictions' # permit_mynetworks,reject_unlisted_sender,reject_unknown_sender_domain dcx cat /opt/postfix/conf/custom_transport.pcre # 비어 있거나 owner@ · 도메인과 무관해야 dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_virtual_mailbox_maps.cf # 사서함이 받는 사람으로 살아 있다 docker compose exec acme-mailcow dig +short A mx1.arasystem.it.kr @172.22.1.254 # <MX1_IP> docker compose exec acme-mailcow dig +short AAAA mx1.arasystem.it.kr @172.22.1.254 # 비어 있어야도메인이 백업 MX · "Relay all recipients" 가 아닌지, owner 앞 Sync job 이 없는지(E-Mail → Configuration → Synchronizations)도 본다.
-
postscreen 예외 —
data/conf/postfix/custom_postscreen_whitelist.cidr에<MX1_IP>/32 permit(재시작 없이 다시 읽는다). 새 IP 가 차단 목록 하나에만 있어도 postscreen 에서 끊길 수 있다. Forwarding Hosts 에는 넣지 않는다 — 넣으면 그 IP 의 메일은 스팸 검사 · 위조 판정 없이 들어온다. -
Fail2ban 예외 — System → Configuration → Options → Fail2ban parameters → "Allowlisted networks/hosts" 에
<MX1_IP>(imapsync 로그인 실패가 쌓여 mx1 IP 가 막히면 SMTP 도 끊긴다). mailcow 가 mx1 을 사설 주소로 보면 막히지 않아 필요 없다 — IMAP(993)은 앞단 터널 주소10.9.0.1로 보였고(2026-11-03), 25 는 보지 않았다. 넣어도 해는 없다(imapsync › 원본의 접속 주소). -
새 서버 setup — 위 "env 와 실행". 이때부터 새 서버의 owner@ 가 동료에게 보낼 수 있다(mailcow 는 아직 owner 앞 메일을 자기 사서함에 넣는다).
-
반대 방향부터 시험 — 새 서버의 owner@(웹메일)로 동료에게 한 통. 실사용자에게 영향이 없는 방향이라 먼저 한다:
docker compose logs --since 15m postfix-mailcow | grep -E 'from=<owner@arasystem\.it\.kr>|\[<MX1_IP>\]' # 좋음: client=mx1.arasystem.it.kr[<MX1_IP>] … 다음 줄에 relay=dovecot[…]:24 status=sent # 나쁨: NOQUEUE: reject … Sender address rejected (mailcow 에 없는 봉투 발신자) · milter-reject … 5.7.1 (rspamd 거절) docker compose logs --since 15m rspamd-mailcow | grep -E 'SPOOFED_UNAUTH|R_SPF_FAIL|R_DKIM_REJECT|DMARC_POLICY_SOFTFAIL' # 없어야동료가 받은 메일 머리글이
Authentication-Results … spf=pass dkim=pass (header.s=v1-…) dmarc=pass이고X-Spamd-Result에SPOOFED_UNAUTH가 없어야 한다. SPF · DKIM · DMARC 가운데 하나라도 실패하면 mailcow 가 위조로 보고(+50점) 거절한다 — DMARC 가p=none이어도 막아 주지 않는다. DKIM 은 키가 있는데 서명이 맞지 않을 때(R_DKIM_REJECT)다 — selector 의 키를 찾지 못하는 것(R_DKIM_PERMFAIL)은 0점이라 SPF 정렬로 DMARC 가 통과하면 받는다. 없는 주소(colleaguee@)로 보내면 mailcow 가 550 → 새 서버가 owner 에게 반송한다(루프 없음). -
imapsync 1차 — mailcow → 새 서버, owner 만 (imapsync — owner 계정은 운영 도구 「입사」에서 옮겨 올 메일 y 로 만들어 이전용 비밀번호로 옮긴다).
-
Transport Map 추가 — System → Configuration → Routing → Transport Maps → "Add transport":
칸 값 Destination owner@arasystem.it.kr(owner+태그@도 맞는다)Next hop [mx1.arasystem.it.kr]:25— 대괄호는 MX 조회를 끈다. 앞에smtp:를 쓰지 않는다(mailcow 가 붙인다)Username / Password 비움 "Destination is a regular expression to match against MX name" 끔 Active 켬 목록의 Test 버튼(보내는 주소
colleague@arasystem.it.kr, 받는 주소owner@arasystem.it.kr— PHP 가 next hop 에 직접 시험 메일을 보낸다)으로 새 서버가 mailcow IP 의 사내 도메인 메일을 받는지 본다. 그다음:dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf # smtp_via_transport_maps:[mx1.arasystem.it.kr]:25 dcx postmap -q colleague@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf # (출력 없음) dcx postfix reload # 바로 반영 (안 해도 30초~1분) # 실제 발송 — 바깥(Gmail) · SOGo 동료 · owner 가 든 별칭 · owner+test@ 에서 각 한 통 docker compose logs --since 15m postfix-mailcow | grep -E 'to=<owner(\+[^@]*)?@arasystem\.it\.kr>' # relay=mx1.arasystem.it.kr[<MX1_IP>]:25 … status=sent -
마지막 imapsync(차이분) → 운영 도구 「메일 이전 › 이전 마치기」(owner) → owner 에게 임시 비밀번호(첫 로그인 때 새 비밀번호를 정한다 — 그 뒤 8).
-
owner 의 메일 프로그램을 mx1 로 — IMAP
mx1.arasystem.it.kr:993· SMTP:465, 손으로 설정한다(autodiscover 는 mailcow 를 가리킨다). 휴대폰 ActiveSync 등 옛 프로필은 지운다. -
mailcow 의 옛 사서함 접근 줄이기 — 사서함 편집의 Allowed protocols(IMAP · POP3 · SMTP · Sieve · ActiveSync · CalDAV/CardDAV)와 SOGo 접근을 줄이고 앱 비밀번호를 지운 뒤, 마지막 동기화가 끝나면 Active 를 "Disallow login (incoming mail is still accepted)" 로. 지우거나 "Inactive" 로 두지 않는다 — 그러면 owner 앞 메일이 모두 550 이고, 새 서버에서 owner 가 동료에게 보내는 메일도
reject_unlisted_sender로 거절된다. -
며칠 본다 — mailcow Queue Manager(E-Mail → Queue Manager), rspamd 기록, 반송, DMARC 보고서.
지켜볼 것
새 서버에서 owner 가 받은 메일의 X-Spam-Score · X-Spam-Result 를 세 통 본다 — 바깥(Gmail) 한 통, 동료(SOGo) 한 통, DMARC 가 엄격한(p=reject) 바깥 도메인 한 통.
mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(Authentication-Results: … none) DMARC_NA · AUTH_NA 가 각 1점 붙는다. 스팸 문턱은 5점이다.
- 개발에서는 점수가 늘 높다 — 기존 서버 IP 로 넘긴 동료 흉내 메일이 7.5점(
.test도메인 탓FROM_INVALID·ENV_FROM_INVALID각 2점, 시험 글의 제목 인코딩 1점). 운영 값으로 판단한다. 조사 때 p=reject 바깥 발신(서명 없음)을 넘기자 이 설정으로 3.5점(받은편지함), 검사 예외가 없을 때는 11.0점(스팸함)이었다. - mailcow 가 8~15점에 붙이는
X-Spam-Flag: YES는 새 서버 규칙(SPAM_FLAG+5)이 받아 스팸함으로 간다. owner 의 mailcow Sieve · 부재중 · 전달 규칙은 더 돌지 않는다 — 새 서버에 다시 만든다. - 동료가 받은 owner 의 메일 —
X-Spamd-Result에SPOOFED_UNAUTH·R_DKIM_REJECT가 없어야 한다(위 4). - 운영 결과(2026-10-31): 새 서버 → 동료는 spf · dkim(두 selector) · dmarc pass · mailcow 점수 −2.95, Gmail → mailcow → 새 서버는 0.80(ham), 새 서버 → Gmail 은 spf · dkim(RSA) · dmarc pass.
Gmail 의
dkim=neutral (no key)(ed25519 selector)는 정상이다 — Gmail 은 ed25519 를 검증하지 않고 RSA 서명이 대신한다. - 새 서버 → mailcow 의 첫 전달은
451 timeout processing message(mailcow rspamd)로 두어 번 미뤄질 수 있다 — 처음 보는 IP · 도메인 · DKIM 키를 조회하느라 늦은 것으로 보이고, 새 서버가 2분 · 5분 뒤 다시 보내 전달됐다(반송 없음 — 451 은 일시 오류). 계속되면 mailcow 의 Rspamd 로그를 본다.
되돌리기 (시범을 멈추고 mailcow 로)
- mailcow — Transport Maps 의 owner@ 행 → Quick actions → Deactivate(행을 남긴다) →
dcx postfix reload. 30초~1분 안에 다시 mailcow 사서함으로 배달된다.dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf가 비어야 한다. - 큐에 남은 owner 앞 메일 —
dcx postqueue -p로 보고dcx postqueue -f(다시 경로를 받아 로컬로). - 시범 동안 새 서버로 들어온 메일 · 보낸 메일을 mailcow 로 되복사 — 반대 방향 imapsync(새 서버 → mailcow) 또는 mailcow 의 Synchronizations 에 mx1 IMAP 을 원본으로.
- owner 의 mailcow 접근 되살리기 — Active 를 Active 로, Allowed protocols · SOGo · 앱 비밀번호, 메일 프로그램을 mailcow 로.
- 새 서버 대기열에 동료 앞 메일이 남지 않았는지 본다 — 관리 화면의 메일 대기열(Queue) 또는 관리 API
x:QueuedMessage/query·/get. 시범을 끄면 발송 전략이 "사내 도메인 → 로컬"로 돌아가, 아직 mailcow 로 못 간 동료 앞 메일(mailcow 일시 장애 · 지연 — 다시 시도는 3일, 동료 앞 반송은 10번까지)은 다음 시도에서 로컬 배달 실패 → owner 에게 반송된다. 남아 있으면 mailcow 가 받을 수 있게 된 뒤 다 나갈 때까지 기다리거나(대기열에서 바로 다시 시도), 기다릴 수 없으면 그 목록(받는 사람 · 제목)을 적어 두고 끈 뒤 owner 가 mailcow 에서 다시 보낸다. - 새 서버 — Transport Map 을 끈 뒤에 시범을 끝낸다: 두 값을 비우고
--end-pilot(아래). 거꾸로 하면 그사이 owner 가 동료에게 보낸 메일이not-found로 거절된다. - 넣었던 것 정리 — postscreen 예외 줄, Fail2ban 예외. DNS 의
ip4:<MX1_IP>· 새 DKIM TXT 는 남겨도 해가 없다.
시범 끝내기 — 전면 전환
전환 절차대로 모두의 계정을 새 서버에 만들고(단계 2) MX 를 mx1 로 바꾼다(단계 6). 그 뒤:
- MX 전파(TTL)가 끝나 mailcow 에 새 수신이 없어지면 mailcow 의 Transport Map 행을 지운다(Remove) — 그때까지 mailcow 로 온 owner 앞 메일은 그대로 새 서버로 넘어온다.
- 새 서버 —
.env.production의 두 값을 비우고--end-pilot으로 실행한다:node --env-file=.env.production mail/scripts/setup.mjs --end-pilotallowRelaying(꺼진 것을 다시 읽어 확인) → 발송 전략 → RCPT 가드 · 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외 차례로 지우고 리로드한 뒤legacy경로를 지운다. 다음 실행부터는--end-pilot없이[유지] 시범 공존 — 꺼짐이다.
- 계정을 만든 순간부터 그 주소 앞 사내 메일은 새 서버의 로컬 배달이다 — 「입사」 → imapsync 1차 → MX 바꾸기를 짧게 이어서 한다(imapsync 의 차례).
- 위조 막기(인증 없는 사내 도메인 MAIL FROM 은 mailcow IP 에서만)도 함께 빠진다. 전환 뒤에도 남길지는 정할 일이다(원래 봉투를 그대로 넘기는 바깥 전달 서비스가 막힌다).
주의
- 시범 기간에는 동료 계정을 새 서버에 만들지 않는다 — 새 서버에 있는 주소는 늘 로컬 배달이라, 만든 순간부터 owner 가 그 동료에게 보낸 메일이 새 서버에 쌓이고 mailcow 로 가지 않는다(동료는 mailcow 에서 읽는데).
postmaster@·dmarc@· 공용 주소 · 별칭도 같다. 이주 기간에 미리 만들어야 하면 발송 전략을 "옮긴 사람 목록" 꼴로 바꿔야 한다 — setup 은 지원하지 않는다. - DKIM — 동료 앞 메일은 새 selector 두 개(
v1-ed25519-…·v1-rsa-…)로 서명된다. 키가 다르면 mailcow 가 위조로 거절하고, TXT 가 없으면 DMARC 가 SPF 정렬에만 기대게 되어 반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다(위 mailcow 절차 4). setup 이 활성 selector 를 알리고, 운영에서는 켜기 전에 DNS 와 견준다(처음 켤 때 키가 다르거나 TXT 가 확실히 없으면allowRelaying을 켜지 않고 멈춘다 — TXT 는 첫 시범 실행 전에 넣고 퍼진 것을 본다. 리졸버 오류는[주의]). DNS 를 손으로 관리하는 동안 엔진은 키를 스스로 바꾸지 않는다 — 다만 키를 지우거나 알고리즘을 더하면 새 키를 DNS 에 넣기 전에 쓴다(전환 절차 "주의할 레코드" 의 DKIM 자동 교체). - SOGo 일정 초대 — mailcow 사용자가 SOGo 에서 owner 를 초대하면 메일을 거치지 않고 owner 의 옛 SOGo 달력에 바로 들어간다(알림 메일만 새 서버로 온다 — SOGo 버그 트래커의 관리자 답, 확인하지 않았다).
- autodiscover — 그대로 mailcow 를 가리킨다. owner 의 프로그램이 자동 설정을 쓰면 mailcow(새 메일이 없는 옛 함)에 잡힌다 — 손으로 설정한다.
- 크기 — mailcow 와 새 서버 모두 메일 한 통 100MB(104,857,600바이트 — 새 서버는 엔진 기본값)다. 웹메일 첨부는 따로
MAIL_ATTACHMENT_*한도다. - IPv6 · SPF — mx1 에 AAAA 를 두지 않는다. mailcow 가 IPv6 로 붙으면 IPv4 예외가 모두 빗나가 동료 메일이
550 5.7.1이다(mailcow 는inet_protocols = all). 새 서버 → mailcow 는 IPv4 로만 간다(컨테이너에 IPv6 가 없고 Relay 경로는 IPv4 먼저) — SPF 는ip4:만 더한다. - EHLO 이름 — 새 서버가 mailcow 에 붙을 때의 이름은 엔진의 서버 이름(
defaultHostname= bootstrap 때의MAIL_HOSTNAME= mx1)이다.mail.arasystem.it.kr이면 mailcow 가 "loops back to myself" 로 멈춘다 — setup 은 기존 서버 주소가 서버 이름과 같으면 멈춘다. - 사내 오타 주소 — 새 서버에 없는 사내 주소는 모두 "받음"이라, 웹메일의 즉시
not-found(받는 사람별 결과) 대신 mailcow 의 550 → 몇 초 뒤 반송(DSN)이 받은편지함에 온다. 제출 기록에도 남지 않는다(대기열에서 빠지면250 2.1.5 Queued로 돌아간다). - 사내 목록 — 받는 사람 추천 · 주소록의 "사내"는 새 서버의 계정 목록이라 시범 사용자 혼자만 보인다. 동료는 개인 주소록 · 최근 보낸 사람으로만 나온다(시범 동안 백엔드는 바꾸지 않는다).
- 주소 추측 막기가 약하다 — RCPT 가드의 거절(Sieve)은 엔진 자체의 없는 주소 거절(5초 대기 · 세션당 실패 5번이면 끊기 · 자동 차단 셈)을 거치지 않는다(개발: RCPT 한 번에 가드 0.2
1.1ms, 엔진 자체 거절 4,9995,003ms — setup 은 이 차이로 가드가 걸렸는지 본다). 시범 중 MX 는 mailcow 라 mx1:25 에 바로 붙는 것은 스캐너뿐이다. 시스템 계정 거절도 같은 성질이다. - mailcow 가 멈추면 — 동료 앞 메일은 새 서버 대기열(
local일정 — 3일)에서 다시 시도하다 반송되고, owner 앞 메일은 mailcow 큐(4시간 뒤 지연 안내, 5일 뒤 반송)에서 기다린다. - DMARC 보고 — 엔진은 자기 도메인(arasystem.it.kr) 메일로는 DMARC · DKIM 실패 보고를 만들지 않는다(소스
smtp/src/reporting/dmarc.rs의is_local_report_domain) —rua=dmarc@로 보고를 보내 mailcow 의reject_unlisted_sender에 걸리는 일은 없다. 다른 도메인에 대한 집계 보고(기본 하루 한 번)는 그 도메인의rua로 새 서버가 직접 보낸다(봉투noreply-dmarc@arasystem.it.kr). mailcow IP 의 DMARC 검사를 끄므로 mailcow 를 거친 메일로는 보고 거리가 생기지 않는다. - mailcow 의 공용 주소 · 별칭(
all@등)으로 보내면 mailcow 가 펼치고 owner 몫은 새 서버로 되돌아온다(자기 사본 — 확인하지 않았다).
arasystem.it.kr 전환 절차 (mailcow → Stalwart)
전면 전환 전에 한 사람이 먼저 새 서버를 쓴다 — 파일럿 공존(MX 는 mailcow 그대로, 아래 단계 1 · 3 · 4 의 DNS 만 더한다).
시범을 거친 뒤 아래 표대로 모두를 옮기고, MX 를 바꾼 다음 시범 설정을 끝낸다(--end-pilot).
현재 (DNS: 가비아):
| 레코드 | 값 |
|---|---|
arasystem.it.kr MX |
10 mail.arasystem.it.kr (mailcow) |
arasystem.it.kr TXT (SPF) |
v=spf1 mx ip4:46.250.248.43 -all |
_dmarc.arasystem.it.kr TXT |
v=DMARC1; p=none; rua=mailto:dmarc@arasystem.it.kr |
dkim._domainkey.arasystem.it.kr TXT |
mailcow DKIM (selector dkim) |
원칙:
- 메일박스를 먼저 만들고 MX 를 바꾼다. 거꾸로면 없는 주소로 들어온 메일이 반송(550)된다.
- 새 엔진은 새 호스트명(
mx1.arasystem.it.kr) 으로 띄우고 MX 대상만 바꾼다.mail.arasystem.it.kr은 끝까지 mailcow 를 가리키므로, MX 만 되돌리면 롤백된다. - SPF 가
-all이다. 새 서버 IP 를 SPF 에 넣기 전에 새 서버에서 외부로 보내면 수신 측이 거부하거나 스팸 처리한다.
| 단계 | 할 일 | 확인 |
|---|---|---|
| 0. TTL 낮추기 (D-3 이상) | MX·SPF TXT·mx1 A 의 TTL 을 300초로. 기존 TTL 만큼 기다린 뒤 다음 단계 |
dig +noall +answer MX arasystem.it.kr 의 TTL |
| 1. 새 호스트 | mx1.arasystem.it.kr A → 새 IP, PTR → mx1.arasystem.it.kr, .env.production 에 MAIL_HOSTNAME·MAIL_PUBLIC_URL = mx1, 운영 배포 |
컨테이너 로그에 인증서 발급, openssl s_client -connect mx1.arasystem.it.kr:993 |
| 2. 같은 주소 생성 | 직원 메일함은 운영 도구 「입사」를 한 사람씩 — 옮겨 올 메일이 있으면 y(이전용 비밀번호 — imapsync). 별칭은 없다(2026-11-02). 그리고 dmarc@(현재 DMARC 리포트 수신처) · postmaster@(Stalwart 기본 리포트 수신처). 시범 공존 중이면 전면 전환 때 — 5 · 6 과 짧게 잇는다 — 만든 순간부터 그 주소 앞 사내 메일이 새 서버에 쌓인다(파일럿 공존 "주의") |
운영 도구 「메일 이전 › 이전 현황」 · 관리 화면 계정 목록 |
| 3. DKIM selector 추가 | setup 이 출력한 v1-ed25519-YYYYMMDD._domainkey·v1-rsa-YYYYMMDD._domainkey TXT 를 추가. mailcow 의 dkim._domainkey 는 그대로 둔다 (selector 가 달라 공존) |
dig TXT v1-rsa-YYYYMMDD._domainkey.arasystem.it.kr |
| 4. SPF 갱신 | v=spf1 mx ip4:46.250.248.43 ip4:<새 IP> -all (+ 권장: mx1.arasystem.it.kr TXT "v=spf1 a -all") — MX 변경·외부 발송 전에 |
새 서버에서 외부(본인 Gmail 등)로 1통 → 헤더의 spf=pass dkim=pass |
| 5. 메일 이전 1차 | imapsync — 한 사람으로 먼저(--justlogin · 폴더 맞춤), 그다음 주소 목록 전부 |
사람마다 Detected 0 errors · 폴더별 수 · 「이전 현황」의 메일함 사용량 |
| 6. MX 전환 | arasystem.it.kr MX 10 mx1.arasystem.it.kr 로 교체. mailcow 는 계속 켜 둔다 (전파 중에는 mailcow 로도 들어온다). 이때부터 9 까지 직원은 새 서버에 들어오지 못한다 — 짧게(예: 금요일 저녁 ~ 월요일 아침) |
외부에서 보낸 메일이 새 엔진 로그에 수신 |
| 7. 마지막 imapsync | TTL 이 지나 mailcow 에 새 수신이 없어진 뒤 같은 명령을 한 번 더(차이분 — 이미 옮긴 메일은 건너뛴다). 그 전에 직원에게 mailcow 를 그만 쓰게 알린다 | mailcow 로그에 새 수신 없음 · 사람마다 Detected 0 errors |
| 8. 이전 마치기 | 마지막 imapsync 뒤 5분쯤 지나 운영 도구 「메일 이전 › 이전 마치기」(주소 빈 값 — 이전 중인 계정 모두) → 주소 · 임시 비밀번호 표가 한 번 보인다. mailcow 메일함은 Disallow login, 마스터 계정 두 값 비우기 · 서버 B 의 비밀번호 파일 지우기(imapsync 단계 8) |
「이전 현황」이 비었다 |
| 9. 임시 비밀번호 나눠 주기 | 직원에게 직접(말 · 쪽지). 직원은 첫 로그인 때 새 비밀번호를 정하고, 회사 정책이면 2단계 인증을 켠다 | 직원이 웹메일에 들어온다 |
| 10. 병행 1~2주 후 정리 | SPF 에서 ip4:46.250.248.43 제거, dkim._domainkey 삭제, mailcow 중지. 필요하면 mail.arasystem.it.kr 을 새 호스트로 옮기고(인증서 포함) MAIL_PUBLIC_URL 변경 |
— |
롤백: MX 를 10 mail.arasystem.it.kr 로 되돌린다. 그 사이 새 엔진에 들어온 메일은 반대 방향 imapsync 로 옮긴다(원본 · 대상과 그 자격을 서로 바꿔 — 새 서버가 --host1).
- 되돌릴 때 — 임시 비밀번호를 나눠 주기 전(9 전)이면 새 서버 쪽 자격은 이전용 비밀번호다(이전 마치기를 했으면 「이전용 비밀번호 다시 만들기」에 주소마다 — 만료된 계정도 받는다, 빈 값에는 빠진다) · mailcow 메일함은
Disallow login을 했으면 Active 로 되살린다. 나눠 준 뒤면 직원이 정한 비밀번호라 운영자에게 새 서버 쪽 자격이 없고(운영 도구도 그 계정에는 이전용 비밀번호를 걸지 않는다) 직원이 새 서버에서 보낸 · 정리한 메일까지 옮겨야 한다 — 직원마다 앱 비밀번호로 하는 길은 확인하지 않았다.
주의할 레코드:
- DMARC — setup 이 출력하는 권장값은
p=reject; rua=mailto:postmaster@…다. 전환 중에는 지금의p=none·rua=dmarc@를 유지하고, 리포트로 두 서버 모두 통과하는 것을 본 뒤quarantine→reject로 올린다. - MTA-STS — 정책이 MX 와 어긋나면 MTA-STS 를 지키는 발신자가 배달에 실패한다. 전환이 끝난 뒤 게시한다.
- SRV·autoconfig·autodiscover — 메일 프로그램(IMAP) 자동 설정용. setup 이 출력하는 권장값(엔진 · mx1)이 아니라 메일 프로그램 자동 설정의 "전면 전환 때 할 일"대로 — autoconfig · autodiscover 는 서버 A(NPM) 로, SRV 는
_imaps·_submissions→ mx1. 전환 전에는 그대로 둔다(mailcow 직원이 쓰고 있다). MX 를 바꾸는 날 함께 바꾼다. - DKIM 자동 교체 — 도메인 설정은
dkimManagement: Automatic(rotateAfter90일 ·retireAfter7일 ·deleteAfter30일)이지만, 0.16.27 은 엔진이 DNS 를 직접 고칠 때(dnsManagement: Automatic+ DKIM 게시)만 교체한다. DNS 를 손으로(가비아) 관리하는 지금 설정(dnsManagement: Manual)에서는 활성 키에 교체 일정이 생기지 않아(x:DkimSignature의nextTransitionAt가 비어 있다) bootstrap 때 만든 두 키를 계속 쓴다 (소스services/src/task_manager/dkim.rs— 일정은 DNS 게시가 있을 때만 잡는다, 엔진 시험tests/src/automation/dkim.rs의 "manual DNS" — 교체도 다시 시도도 하지 않는다. 개발 엔진의 두 키도 일정이 없다 — 2026-10-29).- 주의 — 알고리즘에 활성 키가 없으면 엔진이 새 키를 만들어 DNS 에 게시하기 전에 곧바로 서명에 쓴다(DNS 수동일 때). 키를 지우거나, 알고리즘(
algorithms)을 더하거나,dkimManagement를 다른 값에서Automatic으로 바꿀 때가 그렇다 — 그런 일을 하면 새 selector 의 TXT 를 바로 넣는다. - 자동 DNS(
Automatic)라면 새 키를 먼저 게시하고 전파를 확인한 뒤에 쓴다(실패하면pending으로 두고 옛 키로 계속 서명). - 키를 손으로 바꾸는 방법은 아직 확인하지 않았다 — 엔진은 활성(
active) 키를 모두 서명에 쓴다. 교체가 필요해지면 개발 엔진에서 먼저 시험한다.
- 주의 — 알고리즘에 활성 키가 없으면 엔진이 새 키를 만들어 DNS 에 게시하기 전에 곧바로 서명에 쓴다(DNS 수동일 때). 키를 지우거나, 알고리즘(
imapsync
전 직원 전환 때 mailcow 의 메일만 새 엔진으로 옮긴다 — 메일함 15개 남짓, 별칭 없음(모두 @arasystem.it.kr 메일함 — 2026-11-02 결정).
원본(mailcow)은 Dovecot 마스터 계정으로 직원 비밀번호 없이 읽고, 대상(새 엔진)은 운영 도구가 만든 이전용 비밀번호로 IMAP 에 로그인한다.
- 옮기지 않는 것 — SOGo 주소록 · 달력, 메일 필터 · 부재중 · 전달 설정, 서명. 직원이 웹메일에서 새로 만든다. 필요하면 mailcow 를 해지하기 전에 SOGo 의 주소록 · 달력을 내보내 둔다.
- 이전용 비밀번호 — 운영 도구 「입사」에서 용량 다음의
mailcow 에서 옮겨 올 메일이 있습니까?에 y 로 답하면 직원용 임시 비밀번호 대신 만든다(한 번만 보인다). 만료가 만든 때 + 30일이라 그때까지 IMAP 이 되고, 지나면 엔진이 저절로 막는다. imapsync 에만 쓰고 직원에게 주지 않는다. 운영 도구를 한 번 띄운 동안의 「입사」는 같은 값이다 — 모두를 한 실행에서 만들면 비밀번호 파일이 하나이고, 사람마다 다른 것은--user1·--user2뿐이다(원본도 마스터 비밀번호 파일 하나). 이전 중인 계정은 운영 도구 메뉴11. 메일 이전이 다룬다 —1. 이전 현황·2. 이전용 비밀번호 다시 만들기·3. 이전 마치기(2 · 3 은주소 (빈 값이면 이전 중인 계정 모두):). "이전 중"은 엔진 비밀번호 만료가 미래(30일 안)이면서 운영 도구의 실행 기록에서 마지막으로 건 비밀번호가 이전용인 계정이다(계약 6절 "메일 이전"). 엔진의 비밀번호 기본 만료(x:Authentication의passwordDefaultExpiry— 관리 화면의 인증 설정)는 켜지 않는다 — 켜면 직원 비밀번호가 주기적으로 만료되고, 끈 뒤에도 그동안 바꾼 비밀번호의 만료가 남는다. 운영 도구는 켜져 있으면 첫 화면에 알린다. - 근거는 아래 개발에서 확인한 것 — mailcow 를 흉내 낸 Dovecot → 개발 엔진. 운영(mailcow → 서버 B)은 2026-11-03 에 원본 로그인만 · 1차 · 2차를 15계정으로 돌렸다 — 운영에서만 나온 것은 아래 큰 메일(엔진의 크기 한도 · 메모리).
차례 (단계 번호는 전환 절차의 것)
| 단계 | 할 일 | 비고 |
|---|---|---|
| 준비 | 단계 2 며칠 전 — mailcow 마스터 계정 고정(아래 "원본") · 서버 B 에 마스터 비밀번호 파일 · 원본 로그인만 미리(아래 "돌리는 곳"의 준비 줄 — 대상 계정 없이) · 주소 목록 | 원본 쪽 문제(마스터 · 인증서 · 앞단 길)를 계정을 만들기 전에 고친다 — 단계 2 부터는 짧게 이어야 한다(아래). mailcow 의 Fail2ban 예외는 필요 없다 — mailcow 는 바깥 접속을 모두 앞단 터널 주소(사설 10.9.0.1)로 보고, 사설 주소는 막지 않는다(아래 "원본"의 접속 주소) |
| 2 | 계정 만들기 — 운영 도구 「입사」를 한 사람씩(모두를 한 실행에서), 옮겨 올 메일 y. 처음 보이는 이전용 비밀번호를 그 자리에서 서버 B 의 비밀번호 파일로(아래 — 다시 볼 수 없다) | 「메일 이전 › 이전 현황」에 이전 중인 계정 · 끝나는 때 · 메일함 사용량이 보인다 |
| 5 | imapsync 1차 — 한 사람으로 먼저(--justlogin · 엔진이 본 접속 주소 · 1차 · 아래 "사람마다 확인할 것"), 그다음 주소 목록 전부 |
메일이 많으면 MX 전날에 — 단계 7 의 차이분이 짧아진다 |
| 6 | MX 바꾸기 | 이때부터 단계 9 까지 직원은 새 서버에 들어오지 못한다(비밀번호를 아직 받지 않았다) |
| 7 | 마지막 imapsync — TTL 이 지나 mailcow 에 새 수신이 없어진 뒤 같은 명령 | 그사이 mailcow 에 온 메일 · 바뀐 읽음 표시만 옮긴다. 직원에게는 그 전에 mailcow 를 그만 쓰게 알린다 — 이 뒤에 mailcow 에서 한 일은 옮겨지지 않는다 |
| 8 | 마지막 imapsync 가 끝나고 5분쯤 지난 뒤 운영 도구 「메일 이전 › 이전 마치기」 — 주소 빈 값(이전 중인 계정 모두). 그다음 mailcow 정리 — 메일함을 Disallow login (incoming mail is still accepted) 로, 마스터 계정 두 값 비우기(아래 "원본"), 서버 B 의 비밀번호 파일 지우기 |
사람마다 직원용 임시 비밀번호(만료 — 첫 로그인 때 바꾸기), imapsync 의 로그인 기록 지우기. 끝에 주소 · 임시 비밀번호 표가 한 번 보인다. 5분을 기다리는 까닭 — 엔진 웹훅은 1초씩 묶어 보내고 실패하면 5분까지 다시 보낸다, 그 전에 하면 늦게 온 IMAP 로그인 몇 줄이 직원의 로그인 기록에 남는다. mailcow 메일함은 지우거나 Inactive 로 두지 않는다(파일럿 mailcow 절차 9 와 같다) |
| 9 | 임시 비밀번호 나눠 주기 — 직원에게 직접(말 · 쪽지) | 직원은 첫 로그인 때 새 비밀번호를 정하고, 회사 정책이면 2단계 인증을 켠다(계약 6절 "제한된 세션") |
- 단계 6 ~ 9 는 짧게 잡는다 — 예: 금요일 저녁 MX → 토요일 마지막 imapsync · 이전 마치기 → 월요일 아침 나눠 주기. 그동안 바깥 메일은 새 서버에 쌓이고, 직원은 단계 7 전까지 mailcow 를 그대로 쓸 수 있다.
- 단계 2 ~ 6 도 짧게 잇는다 — 계정을 만든 순간부터 그 주소 앞 사내 메일(새 서버 안에서 보낸 것 — 시범 사용자 등)은 새 서버로 배달된다(시범 끝내기).
- 이전용 비밀번호를 잃었거나, 「입사」를 여러 실행에 나눠 해 값이 다르거나, 30일이 다가오면 — 「메일 이전 › 이전용 비밀번호 다시 만들기」를 주소 빈 값(이전 중인 계정 모두)으로 한 번 해서 한 값으로 맞춘다(지금부터 30일). 서버 B 의 비밀번호 파일도 그 값으로 다시 만든다.
- 이전 중인 계정에는 「비밀번호 재설정」을 쓰지 않는다 — 운영 도구가 막고 위 메뉴로 안내한다.
- 휴지통 · 스팸메일함으로 옮긴 메일의 30일은 옮긴 날부터 센다(엔진은 그 메일함에 들어온 때로 센다 — 자동 비우기) — 이전 다음 날 옛 휴지통이 한꺼번에 비지 않고, 옮긴 날 + 30일에 지워진다. 직원에게 알린다.
원본 — mailcow 마스터 계정
mailcow 는 dovecot-mailcow 를 다시 띄울 때마다 무작위 마스터 계정을 만든다(평소에는 그대로 둔다). 이전하는 동안만 고정한다 — mailcow 문서 "Static master user"(https://docs.mailcow.email/manual-guides/Dovecot/u_e-dovecot-static_master/, 몇 해 된 글):
# mailcow 호스트(사무실 PC 의 WSL 셸) — mailcow.conf 는 root 소유라 sudo 로 연다: sudo nano /opt/mailcow-dockerized/mailcow.conf
# 두 줄은 mailcow 기본 설정에 빈 값으로 이미 있다(DOVECOT_MASTER_USER= · DOVECOT_MASTER_PASS=). 둘 다 값을 넣는다 — 하나라도 비면 무작위로 만든다
DOVECOT_MASTER_USER=<이름> # 로그인 이름에는 @mailcow.local 이 저절로 붙는다
DOVECOT_MASTER_PASS=<비밀번호> # 영문 · 숫자만(특수 문자 금지 — mailcow 설정의 주석) · 길게 — 그 PC 에서 openssl rand -hex 24 로 만들어 비밀번호 관리자에 둔다
# 그리고 설치 폴더에서 다시 띄운다 — 두 값을 받는 dovecot-mailcow 만 다시 만들어진다
cd /opt/mailcow-dockerized && sudo docker compose up -d
- IMAP 로그인 이름은
<직원 주소>*<이름>@mailcow.local(예hong@arasystem.it.kr*migrator@mailcow.local—@가 둘), 비밀번호는DOVECOT_MASTER_PASS다. IMAP 전용 — SOGo(mailcow 웹메일) 로그인에는 쓰지 못한다. - 다시 띄우는 동안 직원의 IMAP · 웹메일(SOGo)이 잠깐 끊긴다 — 메일 프로그램은 다시 잇고, 들어오는 메일은 postfix 가 붙잡았다가 넘긴다. 한가한 때에 한다.
- 마스터 비밀번호는 모든 메일함을 연다 — 고정은 단계 2 며칠 전(원본 로그인을 미리 볼 수 있게)부터 단계 8 까지만 둔다. 끝나면(단계 8) 두 값을 비우고(줄은 둔다) 같은 명령으로 다시 띄운다 — 무작위로 돌아간다.
mailcow.conf는 mailcow 호스트 — mailcow 컨테이너가 도는 사무실 PC(Windows 의 WSL — 설치 폴더/opt/mailcow-dockerized, 바깥mail.arasystem.it.kr은 Contabo 앞단을 거쳐 이 PC 로 온다)에 있다. 운영자가 그 PC 의 WSL 셸에서 고친다(2026-11-02 사용자 확인 — 파일럿 때 적은 "관리 화면만"(파일럿 공존의 "SSH 가 없을 때")은 원격으로 들어갈 길 이야기다). 이전하는 동안 그 PC 가 절전 · 재부팅되면 mailcow 가 멈춰 imapsync 가 끊긴다 — 전원 연결 시 절전 안 함으로 둔다.mailcow.conf는 root 만 읽고 저장소도 root 소유라 확인 명령은 sudo 로 한다 — 값(비밀번호)은 출력하지 않는다(git config safe.directory는 건드리지 않는다):운영(2026-11-03 확인): 설치 폴더# mailcow PC 의 WSL 셸에서 (sudo 를 쓸 수 있는 사용자) sudo docker inspect "$(sudo docker ps -qf name=dovecot-mailcow)" --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' # → /opt/mailcow-dockerized sudo awk -F= '/^DOVECOT_MASTER_(USER|PASS)=/ { print $1, ($2 == "" ? "비어 있음" : "값 있음") }' /opt/mailcow-dockerized/mailcow.conf # 값 대신 비었는지만. 아무것도 안 나오면 줄이 없거나 주석 sudo git -C /opt/mailcow-dockerized log -1 --format='%cd %h' --date=short # mailcow 판(커밋 날짜)/opt/mailcow-dockerized, 판 2026-05-12(32156a33), 두 줄은 주석 없이 빈 값이다(아직 아무도 고정하지 않았다). "값 있음"이면 누군가 이미 고정해 둔 것이다 — 바꾸기 전에 그 값을 쓰는 곳(백업 스크립트 등)이 있는지 알아본다.- mailcow 가 보는 접속 주소 — 바깥 접속은 모두
10.9.0.1(2026-11-03 운영 — 최근 7일 로그인 줄 · 지금 연결 모두):mail.arasystem.it.kr:993은 Contabo 앞단에서 터널을 지나 이 PC 의 mailcow 에 닿고, mailcow 는 그 터널 주소 하나로 본다(공인 주소는 0 — 서버 B 도 같은 길이다). 안쪽172.22.1.248은 SOGo(웹메일),172.22.1.8은 mailcow 안의 다른 컨테이너(7일에 3만 8천 줄 — 상태 점검으로 보인다)다.- mailcow 의 Fail2ban(netfilter-mailcow — 기본 10분에 실패 10번이면 30분)은 사설 · 루프백 주소는 막지 않는다(Python
ipaddress의is_private·is_loopback— mailcow 원본data/Dockerfiles/netfilter/main.py). 그래서 서버 B 를 Fail2ban 예외에 넣을 필요가 없다. 거꾸로 지금 mailcow 는 바깥에서 오는 IMAP 무차별 대입을 IP 로 막지 못한다(전환 뒤 새 엔진은 실제 주소로 막는다). - 남는 것은 Dovecot 의 늦추기다 — 실패가 있었던 주소의 다음 로그인을 늦춘다(최대 15초). 바깥 접속이 모두 한 주소라 imapsync 의 실패가 쌓이면 바깥 직원의 로그인도 함께 늦어진다 — 실패를 되풀이하지 않는다.
- 이 mailcow 의 docker 로그(json-file · 회전 없음 · Dovecot 만 428MB)는
--since만 주면 0줄이다 — 처음부터 읽다가 중간의 읽지 못하는 줄(10-24 재시작 무렵으로 보인다)에서 docker 가 말없이 멈춘다.--tail은 끝에서부터 읽어 된다 — 아래처럼 늘--tail을 준다. 앞단을 바꾸는 등 다시 볼 때:
100.64–100.127.x(Tailscale 같은 공유 주소)는 사설로 치지 않아 Fail2ban 이 막을 수 있다 — 앞단을 바꿔 바깥 접속이 그런 주소나 공인 주소 하나로 보이게 되면 그 주소가 예외에 있는지 본다(막히면 바깥 직원 모두가 함께 막힌다). 세 명령은 Dovecot 2.3 의 로그 · doveadm 꼴(# 최근 10만 줄(끝에서부터 — 본 범위의 처음 · 끝 시각도 나온다)의 로그인 줄이 어떤 주소였는지. 공인 주소는 세기만 하고 보이지 않는다 sudo docker logs -t --tail 100000 "$(sudo docker ps -qf name=dovecot-mailcow)" 2>&1 | awk -v front=46.250.248.43 'NR == 1 { a = substr($1, 1, 19) } { z = substr($1, 1, 19) } /-login: / && match($0, /rip=[0-9A-Fa-f:.]+/) { ip = substr($0, RSTART + 4, RLENGTH - 4); if (ip == front) f++; else if (ip ~ /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|::1$|[Ff][CcDd]|[Ff][Ee]80:)/) p[ip]++; else { if (!(ip in g)) d++; if (++g[ip] > m) m = g[ip]; n++ } } END { print "본 줄", NR, "· 처음", a, "· 끝", z; for (ip in p) print "내부", ip, p[ip] "줄"; print "앞단", front, f + 0, "줄"; print "그 밖의 공인", d + 0, "곳", n + 0, "줄 · 가장 많은 한 곳", m + 0, "줄" }' # 지금 붙어 있는 IMAP · POP3 연결의 주소(로그 없이 — Dovecot 이 아는 연결) sudo docker exec "$(sudo docker ps -qf name=dovecot-mailcow)" doveadm -f flow who -1 2>&1 | grep -oE 'ip=[0-9A-Fa-f:.]+' | cut -d= -f2 | awk -v front=46.250.248.43 '$0 == front { f++; next } /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|::1$|[Ff][CcDd]|[Ff][Ee]80:)/ { p[$0]++; next } { if (!($0 in g)) d++; if (++g[$0] > m) m = g[$0]; n++ } END { for (ip in p) print "내부", ip, p[ip] "개"; print "앞단", front, f + 0, "개"; print "그 밖의 공인", d + 0, "곳", n + 0, "개 · 가장 많은 한 곳", m + 0, "개" }' # 준비의 "원본 로그인만"(서버 B — 아래 "돌리는 곳") 뒤 15분 안에 — 마스터 로그인만 골라 그 주소(10.9.0.1 이면 예상대로). 직원의 평소 로그인은 나오지 않는다 sudo docker logs --since 15m --tail 20000 "$(sudo docker ps -qf name=dovecot-mailcow)" 2>&1 | awk '/Master user logging in as / { m = $NF; next } m != "" && index($0, "Login: user=<" m ">") { if (match($0, /rip=[0-9A-Fa-f:.]+/)) print m, substr($0, RSTART + 4, RLENGTH - 4); m = "" }'imap-login: Login: user=<…>, …, rip=…·Master user logging in as …·username=… proto=… pid=… ip=…)로 맞췄다 — 개발 흉내(Dovecot 2.3.21.1)의 실제 출력과 mailcow 꼴 표본으로, 우분투 · 데비안의 기본 awk 인 mawk 1.3.4 에서 시험했다(2026-11-03). 앞의 둘은 운영 mailcow 에서도 돌렸다. mailcow 는 지금 원본 compose 에서도dovecot:2.3.21.1-2다. - mailcow 의 Fail2ban(netfilter-mailcow — 기본 10분에 실패 10번이면 30분)은 사설 · 루프백 주소는 막지 않는다(Python
- 확인하지 않은 것 — 운영 mailcow 판(2026-05-12)에서 이 설정이 문서대로인지(같은 꼴로 인증에 실패했다는 커뮤니티 글이 있다 — 해결 기록 없음. 그래서 준비의 "원본 로그인만"으로 먼저 본다), 메일함 편집의 Allowed protocols 에서 IMAP 이 꺼진 메일함이 마스터로도 막히는지(그래서 mailcow 접근 줄이기는 마지막 imapsync 뒤 — 단계 8).
- 개발 흉내(Dovecot 2.3 에 같은 꼴 — 구분자
*· 마스터 이름migrator@mailcow.local)에서는 imapsync 가 그 이름을 그대로 넘겨 Dovecot 이Master user logging in as kim@legacy.test로 받았다. mailcow 에 없는 메일함은 마스터로도NO [AUTHORIZATIONFAILED] Authorization failed다.
돌리는 곳 · 명령 (서버 B 호스트)
이미지는 imapsync 저자의 공식 이미지를 판 · digest 까지 고정한다 — gilleslamiral/imapsync:2.326@sha256:ece980cb0fd2806369975d2989a3e8d9fbf28d632cdd45a05961a1e511612ed5
(2026-10-02 · amd64 만 · 받는 양 181MB · Debian 13 · 비-root nobody). 올릴 때는 docker 스킬 "이미지 버전 고정"의 갱신 규칙대로.
# 준비 — 비밀번호 파일은 root 폴더(0700) 안, imapsync 컨테이너 사용자(nobody 65534)만 읽는다(0400).
# read 로 받아 화면 · 셸 기록 · ps 에 남기지 않는다. 줄 끝은 LF 여야 한다 — CRLF(Windows 에서 만든 파일)면 imapsync 가 \r 까지 비밀번호로 읽어 로그인에 실패한다
sudo install -d -m 0700 /root/imapsync /root/imapsync/log
read -rsp 'mailcow 마스터 비밀번호: ' P; echo; printf '%s\n' "$P" | sudo install -m 0400 -o 65534 -g 65534 /dev/stdin /root/imapsync/mailcow-master.pass; unset P
# 단계 2 — 「입사」가 이전용 비밀번호를 보여 줄 때 (다시 만들기로 바꾼 뒤에도 같은 줄로 덮어쓴다)
read -rsp '이전용 비밀번호: ' P; echo; printf '%s\n' "$P" | sudo install -m 0400 -o 65534 -g 65534 /dev/stdin /root/imapsync/migration.pass; unset P
sudo tee /root/imapsync/addresses.txt >/dev/null # 주소 목록 — mailcow 관리 화면의 메일함 목록에서 한 줄에 하나, 붙여 넣고 Ctrl-D
B_IP=<서버 B 의 사설 IP> # NPM 이 :8080 으로 붙는 그 주소 (ip -4 addr)
MASTER='<DOVECOT_MASTER_USER>@mailcow.local'
IMAPSYNC_IMAGE='gilleslamiral/imapsync:2.326@sha256:ece980cb0fd2806369975d2989a3e8d9fbf28d632cdd45a05961a1e511612ed5'
imapsync_one() { # $1 = 직원 주소, 그 뒤는 덧붙일 옵션
sudo docker run --rm --network host --add-host "mx1.arasystem.it.kr:${B_IP}" \
--user 65534:65534 --cap-drop ALL --security-opt no-new-privileges --read-only --tmpfs /tmp --tmpfs /var/tmp \
--mount type=bind,src=/root/imapsync/mailcow-master.pass,dst=/run/secrets/master,readonly \
--mount type=bind,src=/root/imapsync/migration.pass,dst=/run/secrets/migration,readonly \
"$IMAPSYNC_IMAGE" imapsync \
--host1 mail.arasystem.it.kr --port1 993 --ssl1 --sslargs1 SSL_verify_mode=1 \
--user1 "$1*$MASTER" --passfile1 /run/secrets/master \
--host2 mx1.arasystem.it.kr --port2 993 --ssl2 --sslargs2 SSL_verify_mode=1 \
--user2 "$1" --passfile2 /run/secrets/migration \
--automap --syncinternaldates --addheader --nolog --noreleasecheck "${@:2}" </dev/null
}
# 준비 — 원본 로그인만 미리: 대상 계정 없이 두 쪽 모두 mailcow 로 --justlogin(이전용 비밀번호 파일이 아직 없어도 된다).
# 끝 줄이 `Exiting with return value 0` 이면 된다 — 아니면 grep 없이 다시 보고 아래 "실패할 때"로(되풀이하지 않는다 — 한 번에 실패가 둘이고, Dovecot 이 바깥 직원 모두와 같은 주소의 로그인을 늦춘다).
# 된 뒤 15분 안에 mailcow PC 에서 서버 B 가 어떤 주소로 보였는지 본다(예상 10.9.0.1 — 위 "원본"의 접속 주소)
A=hong@arasystem.it.kr
sudo docker run --rm --network host --user 65534:65534 --cap-drop ALL --security-opt no-new-privileges --read-only --tmpfs /tmp --tmpfs /var/tmp \
--mount type=bind,src=/root/imapsync/mailcow-master.pass,dst=/run/secrets/master,readonly \
"$IMAPSYNC_IMAGE" imapsync \
--host1 mail.arasystem.it.kr --port1 993 --ssl1 --sslargs1 SSL_verify_mode=1 --user1 "$A*$MASTER" --passfile1 /run/secrets/master \
--host2 mail.arasystem.it.kr --port2 993 --ssl2 --sslargs2 SSL_verify_mode=1 --user2 "$A*$MASTER" --passfile2 /run/secrets/master \
--justlogin --nolog --noreleasecheck </dev/null | grep -E '^(Host[12]: success login|Host[12] failure|Exiting with)'
# 단계 5 — 한 사람으로 먼저: 두 서버에 로그인만 → 엔진이 본 접속 주소(remoteIp)가 B_IP 인지 → 1차
imapsync_one hong@arasystem.it.kr --justlogin
sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep auth.success | grep imaps | tail -1
imapsync_one hong@arasystem.it.kr | sudo tee /root/imapsync/log/hong-1.log | tail -45
# 단계 5 · 7 — 모두. 사람마다 로그 하나(R=1 · 단계 7 은 R=2), 화면에는 끝의 판정 줄만.
# /root 는 이 셸이 읽지 못한다 — 목록은 sudo cat 으로, 지울 파일은 이름으로 준다(< /root/… · /root/*.pass 는 셸이 풀지 못한다)
R=1; sudo cat /root/imapsync/addresses.txt | while read -r A; do
imapsync_one "$A" | sudo tee "/root/imapsync/log/${A%@*}-$R.log" | grep -E '^(Detected|The sync looks good|There is no unidentified|Exiting with)'
done
# 단계 8 — 비밀번호 파일을 지운다(로그에는 비밀번호가 없다 — 파일 경로만)
sudo rm -f /root/imapsync/mailcow-master.pass /root/imapsync/migration.pass
--host2는 이름mx1.arasystem.it.kr그대로, 길은--add-host로 서버 B 의 사설 IP —SSL_verify_mode=1이 엔진의 Let's Encrypt 인증서를 그 이름으로 확인하고(주소로 붙으면hostname verification failed— 개발 시험), 연결은 서버 안에서 끝난다. 게시 포트 규칙(iptables)을 거치므로 엔진은 접속 주소를 서버 B 의 사설 IP 로 본다(신뢰 대역 밖 — 바깥 메일 프로그램과 같은 대접) — 원리에 따른 판단이다, 첫 사람의--justlogin뒤remoteIp로 확인한다.- 쓰지 않는 길: 공인 주소(이름을 그대로 풀면) — 서버가 자기 공인 IP 로 되돌아오는 길을 NCP 가 주는지 확인하지 않았다(setup 의 공개 443 확인도 이 길을 경고로만 다룬다 — 운영 배포 끝).
127.0.0.1· mail 컨테이너 주소 · 앱 네트워크 — 엔진이 앱 네트워크의 게이트웨이(172.16.71.1— 신뢰 대역, 자동 차단 예외)로 본다. 앱 네트워크에 다른 컨테이너를 붙이는 것은 setup 이 운영에서 막는다. --network host— 기본 브리지에서 사설 IP 로 가면 도커 규칙에 따라 주소가 바뀌거나(MASQUERADE) 네트워크 사이에서 막힐 수 있어 호스트 네트워크로 한다(확인하지 않았다). 컨테이너는 비-root · 권한 없음 · 읽기 전용이다.
- 쓰지 않는 길: 공인 주소(이름을 그대로 풀면) — 서버가 자기 공인 IP 로 되돌아오는 길을 NCP 가 주는지 확인하지 않았다(setup 의 공개 443 확인도 이 길을 경고로만 다룬다 — 운영 배포 끝).
- 인증서 확인을 켠다 — imapsync 는 기본으로 확인하지 않는다(2.326 로그
SSL default mode is like --sslargs1 "SSL_verify_mode=0").SSL_verify_mode=1은 이미지의 CA 묶음(Debian — Let's Encrypt 루트가 든다)으로 CA 와 이름을 모두 본다. mailcow(mail.arasystem.it.kr)도 공인 인증서다. mx1 이 아직 자체 서명이면(엔진의 ACME 가 실패 — "운영 배포"의 TLS 인증서 줄)certificate verify failed로 멈춘다 — 확인을 끄지 말고 인증서를 고친다. --automap— 특수 폴더를 SPECIAL-USE 로 짝짓는다.--syncinternaldates— 받은 때(내부 날짜)를 그대로(기본으로도 켜져 있다).--addheader— Message-ID 가 없는 메일(일부 프로그램의 초안 등)은 이것 없이는 옮기지 않는다(엔진 쪽 사본에 머리글 하나를 더한다).--noreleasecheck— 새 판 확인으로 imapsync 저자의 서버에 묻지 않게.--nolog— 로그는 컨테이너(읽기 전용) 대신tee로 호스트에 남긴다.- 원본에서 지운 메일은 새 서버에서 지우지 않는다 — 차이분은 더하기 · 읽음 표시 맞추기뿐이다(
--delete2는 쓰지 않는다). - 한 사람씩 차례로 돌린다. 메일 14통에 5초 안팎(개발) — 운영의 양(수 GB)은 mailcow ↔ 서버 B 사이 대역폭이 정한다.
사람마다 확인할 것
- imapsync 끝의 판정 —
Detected 0 errors,The sync looks good, all N identified messages in host1 are on host2.,There is no unidentified message on host1.(단계 7 뒤에는The sync is not strict, there are N … in host2 that are not on host1이 나올 수 있다 — 원본에서 지운 메일 · 그사이 새 서버로 바로 온 메일이라 정상이다.) - 폴더 맞춤 —
Folders mapping from --automap feature아래[Drafts] -> [Drafts]·[Junk] -> [Junk Mail]·[Sent] -> [Sent Items]·[Trash] -> [Deleted Items]. 엔진에 "Trash" · "Junk" 같은 폴더가 따로 생겼으면(그 폴더는 자동 비우기가 걸리지 않는다) 그 사람만--f1f2 'Trash=Deleted Items' --f1f2 'Junk=Junk Mail'을 덧붙여 다시 돌리고, 따로 생긴 폴더는 웹메일에서 지운다. mailcow 의 Archive(늘 있는 보관함)는 엔진에 같은 특수 메일함이 없어 보통 폴더 "Archive" 로 온다(비어 있어도 생긴다). - 폴더별 수 —
Folders sizes after the synchronization의 Host1 · Host2Messages:가 폴더마다 같다(--addheader로 머리글이 붙은 메일은 크기가 수십 바이트 다르다). - 메일함 사용량 — 운영 도구 「메일 이전 › 이전 현황」이 mailcow 관리 화면의 그 메일함 사용량과 비슷하다.
실패할 때
| imapsync 출력 (종료 코드) | 까닭 | 할 일 |
|---|---|---|
Host2 failure: Error login on [mx1.arasystem.it.kr] with user [<주소>] auth [LOGIN]: 2 NO [AUTHENTICATIONFAILED] Authentication failed (162) |
새 서버 쪽 비밀번호가 다르다 — 파일 줄 끝이 CRLF, 다른 실행에서 만든 이전용 비밀번호, 「다시 만들기」로 바뀐 값, 「이전 마치기」를 이미 했다 | 파일을 위처럼 다시 만들거나 「메일 이전 › 이전용 비밀번호 다시 만들기」 |
Host2 failure: Error login … socket closed while reading data from server (162) |
비밀번호는 맞지만 만료 — 30일이 지났다(엔진이 답 없이 끊는다) | 「이전용 비밀번호 다시 만들기」에 그 주소로(지금부터 30일 — 빈 값은 아직 이전 중인 계정만이라 30일이 지난 계정은 빠진다) |
Host1 failure: Error login on [mail.arasystem.it.kr] with user [<주소>*…@mailcow.local] … NO [AUTHENTICATIONFAILED] (161) |
mailcow 마스터 — 비밀번호 파일, DOVECOT_MASTER_* 가 먹지 않음, 이름 꼴(* · @mailcow.local) |
위 "원본" |
Host1 failure: … NO [AUTHORIZATIONFAILED] Authorization failed (161) |
마스터는 맞지만 mailcow 에 그 메일함이 없다(주소 오타) | 주소 목록 |
… SSL connect attempt failed … certificate verify failed · hostname verification failed (10) |
인증서 — 자체 서명이거나 이름이 다르다(--add-host 없이 주소로 붙음) |
위 --host2 · 인증서 |
- 같은 실패를 모두에게 되풀이하지 않는다 — 엔진은 IP 마다 인증 실패 하루 100회면 그 IP 를 막는다(자동 차단 — 위 "setup 스크립트가 하는 일" 4. 서버 B 의 사설 IP 는 신뢰 대역 밖이다).
한 사람에서 실패하면 멈추고 고친다. 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 사용자 결정) — 「이전 마치기」 뒤 차례대로:
- 관리 화면에서 세 값을 기본으로 — E-mail Size
75000000· Attachment Size50000000· IMAPmaxRequestSize52428800. - setup 을 돌려
[완료](종료 0)로 끝나고[확인] 받는 메일 크기 한도 — EHLO SIZE 52,428,800줄이 있는지, 크기 한도[주의]가 없는지 본다 —[변경]·[유지]줄은 저장한 값이고, 엔진에 걸린 값은 리로드 뒤의 이[확인]줄이다(리로드가 실패하면 엔진은 옛 한도로 받는다). - 메모리를 compose 의 값으로 —
sudo docker update --memory 1g --memory-swap 2g <PROJECT_NAME>-mail.
받는 한도를 낮추기 전에 1g 로 돌리면 바깥 큰 메일 한 통에 엔진이 죽을 수 있다(엔진 한도 "받는 메일 크기"). 올린 채 두면 엔진에 2g 가 들고, 메모리 예산의 합이 7.65GB, 백업이 도는 동안은 8.65GB 로 8GB 를 넘는다(14단계의 db 1g 부터 — 그 전은 7.15 · 8.15GB).
- 관리 화면에서 세 값을 기본으로 — E-mail Size
개발에서 확인한 것 (imapsync — 2026-11-02)
mailcow 를 흉내 낸 Dovecot(dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a1d2f6dc164880cd7bfa74009106dfa46b36a6a2ee636d921 — mailcow 와 같은 2.3 줄기, 비-root 로 띄움, 시험 CA 의 인증서)에
시험 직원 둘 — mailcow 와 같은 특수 폴더 이름(Sent · Drafts · Trash · Junk · Archive), 한글 하위 폴더, 2025년 날짜의 메일, 읽음 · 안 읽음 · 답장함 · 깃발, 첨부 하나, Message-ID 없는 초안 —
을 넣고, 개발 엔진(0.16.27)에 이전용 비밀번호(만료 30일 뒤 · 두 계정 한 값)로 만든 계정 둘로 위와 같은 옵션의 imapsync 를 돌렸다.
| 무엇 | 결과 |
|---|---|
| 원본 로그인 | <주소>*migrator@mailcow.local 을 imapsync 가 그대로 넘겼다. 원본에 없는 메일함은 마스터로도 NO [AUTHORIZATIONFAILED] |
| 원본 로그인만(준비) | 두 쪽을 같은 원본 · 같은 마스터 로그인으로 준 --justlogin 을 imapsync 가 받는다 — Host1: success login · Host2: success login · Exiting with return value 0(2026-11-03). Dovecot 로그는 로그인마다 Master user logging in as <주소> 다음 Login: user=<주소>, …, rip=<imapsync 주소> |
| 폴더 맞춤 | INBOX → Inbox(inbox) · Sent → Sent Items(sent) · Drafts → Drafts(drafts) · Trash → Deleted Items(trash) · Junk → Junk Mail(junk) — 따로 생긴 폴더 없음, --f1f2 가 필요 없었다. Archive 는 보통 폴더 "Archive"(role 없음) |
| 폴더마다 메일 수 | 원본과 같다 (메일 14 · 5통, 폴더 8 · 7개) |
| 받은 때 | 모두 같다 — 2025년 내부 날짜가 초까지 그대로(JMAP receivedAt) |
| 표시 | 읽음 · 답장함 · 깃발 · 초안 모두 같다 |
| 한글 폴더 | 프로젝트/2026 계약 이 프로젝트 아래로, & 가 든 거래처 & 협력 도 그대로 |
| 첨부 | 한글 이름 PDF 48KB — 이름 · 크기 · SHA-256 이 같다 |
| Message-ID 없는 메일 | --addheader 없이는 옮기지 않는다(Messages void (noheader) on host1 : 1 … add option --addheader). 넣으면 옮기고, 다시 돌려도 겹치지 않는다 |
| 다시 돌리기 | 바뀐 것이 없으면 Messages transferred : 0 (건너뜀 14 · 5) |
| 차이분 | 원본에 새로 온 1통만 옮기고, 원본에서 읽음으로 바꾼 것도 맞췄다(--resyncflags — 기본). 원본에서 지운 메일은 엔진에 남았다 |
| 만료 뒤 | 이전용 비밀번호의 만료를 지난 시각으로 바꾸자 Host2 failure: Error login … socket closed while reading data from server (162) |
| 틀린 비밀번호 | CRLF 비밀번호 파일 → Host2 failure … NO [AUTHENTICATIONFAILED] (162). 틀린 마스터 비밀번호 → 같은 꼴의 Host1 failure (161) |
| 인증서 | 기본은 확인하지 않는다. SSL_verify_mode=1 이면 자체 서명 certificate verify failed, 인증서에 없는 이름 hostname verification failed (10), 맞는 CA · 이름이면 통과 |
| 로그인 기록 | imapsync 의 IMAP 로그인도 웹훅으로 로그인 기록에 쌓인다(실행마다 한 줄 · 틀린 비밀번호의 실패도) — 「이전 마치기」가 지우는 까닭 |
| 메모리 | imapsync 170MB 남짓 |
- 개발과 운영이 다른 점 — 대상 인증서가 자체 서명이라
--sslargs2 SSL_verify_mode=0으로 돌렸고, imapsync 를 잠시 앱 네트워크에 붙여mail:993으로 갔다(엔진은 신뢰 대역 주소로 본다 — 운영에서는 하지 않는다). 원본은 시험 CA 로SSL_verify_mode=1이었다. - 개발에서 확인하지 않은 것 — 운영 mailcow(2026-05-12 판)의 마스터 계정 · Allowed protocols · 서버 B 의 길(
--network host+--add-host)은 2026-11-03 운영에서 됐다(원본 로그인만 → 1차 · 2차 15계정). 아직 보지 않은 것은 엔진이 보는 서버 B 의 주소다. 운영의 양 · 시간 · 직원이 만든 폴더 이름은 사람마다의 imapsync 로그(서버 B/root/imapsync/log/— 주소 · 폴더 이름이 들어 있어 한 주쯤 뒤 지운다)에 있다.
백업
13단계부터 서버 B 의 호스트가 매일 03:30(KST) 혼자 백업한다 — ops/backup/arasystem-backup.sh 를 systemd 타이머가 돌리고,
암호화한 restic 저장소를 NCP Object Storage(서버 밖)에 둔다. 결과는 Healthchecks.io 로 알린다(감시).
| 무엇 | 어디서 | 비고 |
|---|---|---|
메일 엔진 <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 는 켜 둔 채 파일을 복사하면 일관되지 않아 잠시 멈춘다. 멈추는 시간을 줄이려고 두 번 올린다:
- 사전 확인 — 메일 엔진 · DB 가 healthy 인지. 부팅 직후 타이머가 놓친 백업을 바로 할 때(
Persistent=true)는 엔진이 아직 starting 이라 healthy 를BACKUP_HEALTHY_WAIT(기본 300초)까지 기다린 뒤 판단한다(멈췄거나 없으면 기다리지 않는다) - DB 덤프 · env 파일 복사(작업 폴더
/var/lib/arasystem-ops— 0700. 어떻게 끝나도 지운다) - warm — 엔진을 켜 둔 채 올린다(바뀐 것 대부분이 여기서 올라간다. 이 스냅숏은 복원에 쓰지 않는다 — 복원 명령은 id 를 줘도 final 만 받는다). 도는 엔진이 그사이 지운 파일을 읽지 못하면 restic 이 종료 3(일부를 읽지 못함)으로 끝나는데, warm 에서만 받아들이고 한 줄 남긴다
- 엔진을 멈추고(
docker stop) final — 같은 경로를 다시. RocksDB 의 파일은 한 번 쓰면 바뀌지 않아 그사이 바뀐 것만 올라간다. final 은 종료 3 도 실패다. 시간 상한(BACKUP_FINAL_TIMEOUT, 기본 15분)을 넘으면 restic 을 멈추고 엔진을 다시 띄운 뒤 실패로 알린다 — 메일이 오래 멈춰 있지 않게. 상한은timeout --foreground로 건다 — 손으로 돌릴 때 Ctrl-C 가 restic 에 바로 닿아 정리로 간다(그룹이 다르면 상한까지 기다렸다 — 시험함) - 엔진을 띄우고
/healthz/ready까지 기다린다(3분). 스크립트가 어떻게 끝나도(오류 · 시간 초과 · 중지) 멈춘 엔진을 다시 띄운다 — 멈추는 중에 끊겼으면 멈춤이 끝난 뒤에 띄운다 (docker 가 stop 을 처리하는 동안 부른 start 는 아무 일도 하지 않고, 그 뒤 stop 이 끝나 엔진이 멈춘 채 남는다 — 시험함). 그래도 남으면 systemdExecStopPost와 부팅 때의arasystem-mail-recover.service(백업보다 먼저 — 도커가 늦으면 30초마다 10번까지 다시)가 표시 파일(mail-stopped-by-backup)을 보고 띄운다 — 엔진이 떠 있는 것을 본 뒤에만 표시를 지운다. 이것(ensure-mail-running)도 백업 잠금을 잡는다 — 백업이 도는 동안 손으로 부르면 아무것도 하지 않고 그만둔다 (final 도중에 엔진을 띄우면 엔진이 쓰는 RocksDB 를 읽은 스냅숏이 final 로 남아 복원의 latest 가 된다) 끊겼을 때의 정리 차례: 비밀 사본 지우기 → 엔진 띄우기 → 상태 파일 · Healthchecks/fail→ 그 뒤 엔진 준비 기다리기(systemd 가 시간 상한으로 끊어도/fail이 빠지지 않게) - 보존 — final 은 매일 7 · 매주 4 · 매달 6, warm 은 마지막 1. 일요일(KST)에
prune과check(데이터 5% 를 내려받아 본다) - 상태 파일(15분 점검이 읽는다) · Healthchecks(시작 · 성공 + 요약 한 줄 · 실패 + 까닭 — ping 주소는 curl 의 설정 글로 표준 입력에 준다, 명령줄에 남지 않게)
- 같은 잠금(
backup.lock)을 복원 연습 ·ensure-mail-running도 잡는다 — 함께 돌지 않는다(백업 쪽은 건너뛰고 Healthchecks 에/fail "건너뜀 …"한 줄, 연습 쪽은 멈추고, ensure 는 그만둔다)
영향 (임시 엔진 + 로컬 MinIO 로 잰 값 — 2026-10-31):
| 항목 | 값 |
|---|---|
| 메일 엔진 멈춤 | 8MAIL_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 — 한 번)
# 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
# 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; 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
sudo systemctl enable arasystem-mail-recover.service
systemctl list-timers 'arasystem-*'
- 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). NCP 로 확인했다(2026-11-03 서버 B —init· 첫 백업 14초(메일 엔진 멈춤 6초 · 새로 올린 양 363KB · DB 덤프 30KB) · 아래drill이 계정 · 메일 · DB 표마다 행 수 · env 를 운영과 같게 되살렸다). init이Stat: Access Denied로 멈추면 — restic 의 첫 요청은 본문 없는 HEAD 라 S3 가 거절한 까닭이 보이지 않는다. 같은 설정으로 목록 조회를 해 오류 코드를 본다(키는 출력하지 않는다):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. 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) · 2)(restic 비밀번호 · 키는 비밀번호 관리자에서 — 새로 만들지 않는다).
/etc/arasystem/ops.env에PROJECT_NAME을 옛 서버의 값으로 적는다 — 이 단계에는.env.production이 없고, 백업 스냅숏의 host 이름이 그 값이다(비우면 "PROJECT_NAME 을 알 수 없습니다"로 멈춘다) 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 — 저장소 사용자 소유)… restore.sh volumes latest— 메일 엔진 · 업로드 볼륨 (여기부터는.env.production의PROJECT_NAME과 ops.env 의 값이 같아야 한다 — 다르면 멈춘다)sudo docker compose --env-file .env.production up -d db→ healthy 뒤… restore.sh db /root/restore/backup/db/db.dump. 다 끝나면/root/restore를 지운다(비밀이 들어 있다)sudo docker compose --env-file .env.production up -d --build— 메일 엔진은 되살린 설정으로 정상 모드로 뜬다(bootstrap 아님)- setup — "운영 배포" 5)와 같은 형태(
--recovery없이). 서버 IP 가 바뀌었으면 PTR · SPF ·mx1A · NPM 의 전달 주소 ·TRUST_PROXY·FRONTEND_PRIVATE_BIND를 함께 고친다.APP_NETWORK_SUBNET을 바꿨다면 관리자 허용 IP 가 옛 대역이라 403 으로 멈춘다 — 복구 관리자 +--recovery가 고친다(관리자 계정 단단히) - 웹메일 로그인 · 메일 수 ·
sudo systemctl start arasystem-check.service· 백업 타이머 · 부팅 유닛(설치 4)
- 멈춘 동안 들어오던 메일은 보낸 서버가 며칠 다시 보낸다 — 되살리는 데 걸린 시간만큼 늦게 도착하고 잃지 않는다. 백업 뒤 ~ 사고 사이에 받은 메일 · 바꾼 설정은 없다.
손으로 받을 때 (타이머 없이 — 예전 방법)
배포 전 등에 한 번 받을 때 — DB 는 멈추지 않고, 메일 엔진만 30초쯤 멈춘다(2026-11-03 서버 B 13단계 배포 전에 이대로 받았다 — DB 표 20개 · 메일 1MB).
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"
- 메일 볼륨 줄은 한 줄이다 — 앞 단계가 실패하면 엔진을 멈추지 않게
&&로 묶었다(나눠 붙이다IMG=를 빠뜨리면invalid reference format에 빈 파일이다 — 2026-11-03 한 번 겪음).start mail은 어떻게 끝나도 돈다. - 이미지를 따로 받지 않는 까닭 — 떠 있는 태그(
alpine등)를 쓰면 그날의 새 이미지가 들어온다(docker 스킬 "이미지 버전 고정"). - 복원은 DB 는 빈 DB 에
pg_restore, 메일은 빈 볼륨에 같은 경로로 풀고(tar xzf … -C /, 소유자 UID 2000 유지) 컨테이너를 띄운다. 백업 파일에는 메일 전체 · DKIM 키 · DB 가 들어 있다 — 접근을 제한하고 서버 밖에도 두며, 쓸모가 끝나면 지운다.
감시 (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)는 백엔드가 보관량 · 여유를 본다(대용량 첨부) · 로그는 로그 보존 |
| 컨테이너 unhealthy | sudo docker compose --env-file .env.production ps · 그 컨테이너 로그. 리졸버면 리졸버 장애 때 |
| 대기열 쌓임 | 엔진 로그의 배달 실패(바깥 25 막힘 · 받는 쪽 거절 · DNS) — 관리 화면 › Queue (SSH 터널) |
| 인증서 | mx1 은 엔진의 ACME("운영 배포"의 TLS 인증서 줄), 포털 · 공개 호스트는 NPM 의 인증서 |
| Spamhaus 키 | 체험 기간 · 구독 — 끝났으면 키를 비우고 setup (DNS 리졸버와 스팸 차단 목록) |
| 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이 있는지 센다 — 새 서비스를 더하면 앵커를 붙인다.
보안
- 게이트웨이 비밀번호 = 관리자급 비밀. 전 직원 메일함의 열쇠일 뿐 아니라, 관리자 계정도 대리 접근된다 —
<관리자 주소>%<게이트웨이 주소>로 로그인하면 관리 API(x:Account/get등)가 열린다 (백엔드 확인, setup 이 매번[주의]로 보여 준다).- Stalwart 0.16.27 에는 대리 접근 대상을 제한하는 방법이 없다 (2026-10-27 확인). 역할 권한(
enabledPermissions)은 접속하는 쪽의 권한만 정하고, 테넌트로 나누면 테넌트에 든 게이트웨이는impersonate권한 자체를 잃는다 — 테넌트 권한을Merge·Replace로 주거나 계정에 직접 줘도 모든 대리 접근이 403(security.unauthorized details = "impersonate") 이었다. - 그래서 막는 일은 백엔드가 한다 — 관리자(
MAIL_ADMIN_LOCAL_PART)·MAIL_BLOCKED_ACCOUNTS계정은 웹메일 로그인·대리 접근 대상에서 뺀다 (+ 대리 접근한 세션에 관리 권한이 있으면 거부하는 2차 확인). 관리자 계정을 더 만들면MAIL_BLOCKED_ACCOUNTS에 넣는다 (빠지면 setup 이[주의]). - 지킬 것: 백엔드 env(루트
.env.production)에만 둔다 · 다른 곳에 재사용하지 않는다(관리자·DB 비밀번호와도 다르게) · 무작위로 길게 · 정기 교체 · 게이트웨이 허용 IP(신뢰 대역 =APP_NETWORK_SUBNET)를 넓히지 않는다 — 이 제한이 바깥에서의 사용을 막는 유일한 장치다. - 교체 절차:
.env.production의MAIL_GATEWAY_PASSWORD를 새 무작위 값으로 →node --env-file=.env.production mail/scripts/setup.mjs(운영 배포 3 과 같은 형태 —[변경] 게이트웨이 … 비밀번호) →sudo docker compose --env-file .env.production up -d backend(새 값으로 다시 만든다). setup 과 backend 재생성 사이에는 웹메일이MAIL_001로 잠시 멈춘다 — 사용이 적은 시간에 한다. 사용 기록은auth.success로그에 남는다.
- Stalwart 0.16.27 에는 대리 접근 대상을 제한하는 방법이 없다 (2026-10-27 확인). 역할 권한(
- 신뢰 대역 = 고정한 앱 네트워크 서브넷. compose 가 app-network 서브넷을
APP_NETWORK_SUBNET(기본172.16.71.0/24)으로 고정하고, setup 은 그 안의 대역만 신뢰 대역으로 받는다 (더 넓으면 실패). 예전처럼 도커 주소 풀 전체(172.16.0.0/12·192.168.0.0/16)를 믿지 않는다. app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만 붙인다 — 인터넷을 향한 frontend 는 edge 네트워크로만 backend 에 닿는다 (frontend 가 붙어 있으면 그 주소가 신뢰 대역에 들고mail:8080관리 API 에 닿는다. setup 이 구성원을 확인한다). - 8080 은 절대 공개하지 않는다 — 평문 HTTP 관리 API·JMAP 이다.
- 공개 443 은 ACME·MTA-STS 만 — setup 이
x:Http싱글톤의allowedEndpoints를listener == 'https' && path != '/.well-known/mta-sts.txt'→ 403 으로 둔다. 웹메일은 백엔드 → 내부http(8080)라 영향이 없다.- 2026-10-26 확인: https 의
/jmap/session·/.well-known/jmap·/admin/·/account/·/login·OAuth 디스커버리·/healthz는 403,/.well-known/mta-sts.txt는 200, 내부 8080 은 그대로. 호스트·다른 컨테이너·AllowedIp 에 든 주소에서 와도 403 이다. - 식의 변수는
listener·path(그 밖에url·method·remote_ip·local_ip·local_port·protocol·headers·is_tls)가 통하고url_path·host등은 저장할 때 거부된다. 바꾼 뒤에는ReloadSettings가 있어야 반영된다. - ⚠️ 루프백(127.0.0.1)에서 온 요청은 정책을 건너뛴다 — 메일 컨테이너 안에서
https://127.0.0.1로 시험하면 200 이 나온다. 확인은 컨테이너의 네트워크 주소로 한다 (setup 이 그렇게 확인한다). 이미지 healthcheck 는 https 실패 시 http 8080 으로 대체되어 영향이 없다. - ACME(TLS-ALPN-01)는 TLS 단계에서 끝나 HTTP 경로 제한과 무관하다 (운영 인증서 발급으로는 아직 확인하지 않았다).
- 관리 화면은 SSH 터널로 mail 컨테이너의 앱 네트워크 주소:8080 에 붙는다 — 관리 화면 — SSH 터널.
직원 셀프서비스(
/account/— 비밀번호 변경 등)도 공개 443 에서는 막혀 있다. 웹메일 설정 화면(비밀번호 · 2단계 · 앱 비밀번호)이 그 일을 한다.
- 2026-10-26 확인: https 의
- 직원은 계정 목록을 볼 수 없다. 기본
User역할에는Principal/get으로 관리자·게이트웨이를 포함한 전체 계정을 보는 권한 (jmapPrincipal*·davPrincipal*13개)이 있어서, setup 이 뺀다 (역할 권한은 캐시되므로InvalidateCaches로 반영).- 2026-10-26 확인: 테스트 직원의
Principal/get→ forbidden. 백엔드를 거친 웹메일(폴더·목록·읽기·첨부 올리기/내려받기·발송·중복 발송 방지)은 그대로 동작. - ⚠️ 되돌리기는 일반 관리자로는 안 된다 — 관리자 역할(System Administrator)에 이 권한이 없어 "You are not authorized to grant permissions" 로 거부된다.
복구 관리자(일회성)로
x:Role/setupdate {"<User 역할 id>": {"enabledPermissions/jmapPrincipalGet": true, …}}→InvalidateCaches를 한다. (setup 은 다음 실행 때 다시 뺀다 — 계속 필요하면ensureUserRoleWithoutPrincipals단계를 먼저 고친다) - 백엔드가 사내 목록을 읽는 것은 따로 만든 사내 목록 계정(권한 셋, 비밀번호 없음)으로만이다 — 직원 역할에 되돌려 주지 않는다 (사내 목록 계정).
- 직원이 공용 주소(Group 계정)의 구성원이 되어도 Group 역할의 Principal 권한을 물려받지 않는다 (2026-10-29 확인 — 구성원이 된 뒤에도
Principal/query→ forbidden).
- 2026-10-26 확인: 테스트 직원의
- 메일을 받지 않는 시스템 계정은 RCPT 에서 거절한다 (backscatter 막기). 관리자 · 게이트웨이 · 사내 목록 계정의 역할에는
emailReceive가 없다(관리자의 System Administrator 역할도). 그대로 두면 엔진은 그 주소로 온 메일을 RCPT 에서 250 으로 받아 놓고, 배달할 때 거절해(security.unauthorized … details = "emailReceive") 보낸 사람에게 반송(DSN)한다 — 보낸 사람을 위조한 스팸이면 모르는 사람에게 반송이 나가 서버 평판이 떨어진다. 없는 주소는 처음부터 RCPT 에서550 5.1.2다.- setup 이
x:MtaStageRcpt의script식을 "받는 주소가 그 계정이거나 그 +태그 꼴일 때만" 시스템 Sieve 스크립트arasystem-system-recipients(reject "550 5.1.2 Mailbox does not exist.")를 고르게 맞춘다(ReloadSettings 로 반영). 없는 주소와 같은 응답이라 바깥에서 이 계정들이 있는지도 알 수 없다. 직원 · 공용 주소는 식에 걸리지 않아 스크립트가 돌지 않는다. - 대상: 게이트웨이 · 사내 목록 계정과, 관리자 역할에
emailReceive가 없을 때의 관리자 역할 계정 모두(나중에 더 만든 관리자도 — setup 을 다시 돌리면 식에 더해진다). 사내 목록 주소를 setup 이 만들지 않은 계정이 쓰고 있으면 그 주소는 넣지 않는다(실제 메일함일 수 있다). - 엔진은 식의
rcpt를 소문자로 주지만 +태그(부주소)는 떼지 않는다 — 그래서<아이디>+…@<도메인>꼴을 따로 본다(구분자는 엔진 기본+로 가정한다). - 웹메일에서 이 주소로 보내면(엔진 안의 인증된 세션) 같은 RCPT 에서 끝난다 — 제출의 받는 사람별 결과가
delivered: no·550 5.1.2로 바로 오고(계약 2절 "받는 사람별 결과") 반송은 없다. - RCPT 단계 스크립트(
x:MtaStageRcpt의script)는 하나뿐이다. 기본값(없음)이거나 setup 이 써 둔 식일 때만 고치고, 다른 설정이 쓰고 있으면 건드리지 않고[주의]를 낸다 — 그때는 그 식에 이 계정들일 때arasystem-system-recipients를 고르는 갈래를 합친다. - 확인 (2026-10-29, 0.16.27): 바꾸기 전 세 주소 모두 RCPT 250 → 배달 때 거절 → 보낸 사람(개발은 Mailpit)에게 반송. 바꾼 뒤 대소문자 · +태그 꼴까지 550,
직원 · 직원 +태그 · 이름이
admin으로 시작하는 직원 · 공용 주소(Group, +태그 포함)는 250 이고 저장됐다. setup 을 이어서 돌리면[유지], 관리자를 더 만들거나 지우면[변경].
- setup 이
- 관리자 비밀번호는 대리 접근을 못 하고 서버 안에서만 통한다(13단계 — setup 이 건다) — 관리자 계정 단단히. 관리 화면은 SSH 터널로만 (관리 화면 — SSH 터널).
- 복구 관리자는 값이 있는 동안 정상 모드에서도 관리자로 로그인된다 (검증함). 운영은
.env.production에 두지 않고 일회성 override +--recovery로만 쓴다 — 파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 ("운영 배포" 4·5). - 포트는 IPv4(
0.0.0.0)로만 공개한다. 주소 없이 쓰면 IPv6 로도 열리는데, IPv6 접속은 docker-proxy 가 중계해 컨테이너에는 도커 게이트웨이 주소로 보인다 → 신뢰 대역으로 취급되어 자동 차단 예외와 게이트웨이 IP 제한이 무력화된다. IPv6 수신이 필요하면 Docker 데몬의 IPv6(ip6tables)를 켜 원본 주소가 보존되는 것을 확인한 뒤 바인딩을 추가한다. X-Forwarded-For는 기본 설정에서 신뢰하지 않는다 (x:Http의useXForwarded: false— 위조 헤더로 IP 판정이 바뀌지 않음을 검증). 프록시 뒤에서 이를 켤 때는 프록시 주소로 한정해야 한다.- 신뢰 대역은 자동 차단에서 빠진다 → 무차별 대입 방어는 백엔드 rate limit 이 담당한다.
- 비밀번호는 영문·숫자로만 —
#·따옴표·$·공백은 compose 와 Node(--env-file,#부터 주석)가 다르게 읽는다.
리버스 프록시와 같은 호스트에 둘 때 (대안)
기본 구성은 Stalwart 가 호스트 443 을 직접 쓴다. 같은 호스트의 리버스 프록시가 이미 443 을 쓰면 아래 중 하나를 택한다. (1~3 은 문서로만 정리 — 검증하지 않았다) 메일 서버를 따로 둘 수 있으면 4 를 권한다 — 프록시와 메일 엔진이 443 을 나눠 쓰지 않는다.
- IP 를 하나 더 붙인다 (가장 단순) — 메일 포트를 그 IP 에만 바인딩:
"<메일 전용 IP>:443:443"등. 나머지는 그대로. - SNI 패스스루 — L4 프록시(nginx
stream+ssl_preread, HAProxy, Traefik TCP)가 SNI 가mx1.arasystem.it.kr인 TLS 를 복호화하지 않고 mail 의 443 으로 넘긴다. Stalwart 가 TLS 와 ACME(TLS-ALPN-01)를 그대로 처리한다. compose 에서 mail 의 443 매핑을 뺀다.- ⚠️ mail 과 L4 프록시만 붙는 전용 네트워크를 새로 만든다 (예:
<PROJECT_NAME>_mail-ingress, 서브넷 고정, 다른 컨테이너를 붙이지 않는다). 함께 쓰는proxy네트워크나 app-network 에 mail 을 붙이지 않는다 — 그 네트워크의 모든 컨테이너가mail:8080(평문 관리 API·JMAP)에 닿는다. - 프록시의 대상은 서비스 이름(
mail)이 아니라<PROJECT_NAME>-mail:443으로 적는다 (다른 프로젝트의mail과 겹치지 않게).
stream { map $ssl_preread_server_name $upstream { mx1.arasystem.it.kr <PROJECT_NAME>-mail:443; # 전용 네트워크로만 닿는다 default 127.0.0.1:8443; # 기존 웹 TLS 종단 } server { listen 443; ssl_preread on; proxy_pass $upstream; } } - ⚠️ mail 과 L4 프록시만 붙는 전용 네트워크를 새로 만든다 (예:
- 프록시가 TLS 종단 →
mail:8080— 993·465 용 인증서는 여전히 Stalwart 가 받아야 하므로 ACME 를 HTTP-01 등으로 바꿔야 한다. - 메일 서버를 따로 둔다 — 프록시는 지금 서버에 그대로 두고, 새 서버에 웹메일 스택 전체를 올려 메일 엔진이 그 서버의 443 을 직접 쓴다. 프록시는 포털만 같은 VPC 사설망으로 새 서버의 frontend(8080)에 넘긴다 — 공인 IP 를 서버 수만큼만 주는 곳(NCP 등)에서 1 대신. → 리버스 프록시가 다른 서버에 있을 때
⚠️ 2·3 은 Stalwart 에 프록시 주소가 접속 주소로 보인다. 프록시를 app-network(신뢰 대역 APP_NETWORK_SUBNET)에 두면
인터넷 접속이 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다(setup 의 구성원 확인에서도 걸린다). 프록시는 반드시 app-network 밖(위의 전용 네트워크)에 두고,
PROXY protocol(2 — 리스너별 overrideProxyTrustedNetworks, 검증 필요) 또는 그 프록시로만 한정한 X-Forwarded-For(3)로 원본 주소를 넘긴다.
3 은 공개 443 정책(listener == 'https')이 http 리스너에는 걸리지 않으므로 프록시 쪽에서 /jmap·관리 경로를 따로 막아야 한다.
리버스 프록시가 다른 서버에 있을 때 (NCP 등 — 사설망)
포털의 TLS 를 다른 서버의 리버스 프록시(예: 기존 서버의 Nginx Proxy Manager — 다른 사이트들과 80 · 443 을 함께 쓴다)가 끝내고,
같은 VPC 사설망으로 메일 서버의 frontend(8080)에 넘기는 구성이다 — 위 대안의 4.
메일 서버에는 웹메일 스택 전체(mail · backend · db · frontend)를 그대로 올리고 메일 엔진이 그 서버의 443 을 직접 쓴다(ACME TLS-ALPN · MTA-STS).
예시 값: 서버 A(기존 — NPM) 사설 IP 10.0.1.11, 서버 B(메일 — mx1.arasystem.it.kr) 사설 IP 10.0.1.12.
portal.arasystem.it.kr (11단계부터 files. 도) → 서버 A 공인 IP mx1.arasystem.it.kr → 서버 B 공인 IP (PTR 도)
브라우저 ─TLS─▶ 서버 A (기존) Nginx Proxy Manager — 다른 사이트들과 80 · 443 을 함께
│ http · 같은 VPC 사설망 → 10.0.1.12:8080 (서버 B 의 ACG: 8080 은 10.0.1.11 에서만)
▼
서버 B (메일 — 새로) docker-compose.yml + docker-compose.remote-proxy.yml
<PROJECT_NAME>-frontend :8080 (사설 IP 에만 게시) ─(edge)─▶ backend ─(app-network)─▶ db · mail
mail 25 · 465 · 993 · 443 (호스트에 직접, 0.0.0.0) ◀── 바깥 메일 서버 · 메일 프로그램 · ACME
- 선택 파일
docker-compose.remote-proxy.yml이 바꾸는 것은 frontend 하나다 —proxy네트워크에서 빼고(서버 B 에는 없다), 8080 을FRONTEND_PRIVATE_BIND(사설 IPv4:포트)에만 게시하고, 그 값을 먼저 보는frontend-bind-check를 붙인다. frontend 의 환경 · nginx 설정(X-Forwarded-For이어 붙이기 ·X-Request-Id· 실시간 알림)과 다른 서비스 · 네트워크(edge · app-network)는 기본 구성과 같다. - 서버 A → B 구간은 평문 HTTP 다 — 같은 VPC 사설망 안이라 받아들이고, 그래서 8080 은 서버 A 에서만 받는다(아래 ACG).
NCP 쪽 할 일
| 할 일 | 값 |
|---|---|
| 서버 B 만들기 | 서버 A 와 같은 VPC 의 Public 서브넷, 공인 IP 하나, 메모리 8GB (11단계의 ClamAV 까지 같은 서버에 올린다). 다른 VPC 면 Peering 이 있어야 한다 — 이 절은 같은 VPC 만 다룬다 |
| VPC 대역 | APP_NETWORK_SUBNET(기본 172.16.71.0/24) · EDGE_NETWORK_SUBNET(기본 172.16.72.0/24) · MAIL_RESOLVER_SUBNET(기본 172.16.73.0/24)과 겹치지 않게 — 겹치면 서버 B 가 그 대역의 VPC 주소(서버 A 등)로 갈 길을 도커 브리지로 보낸다. 겹치면 그 값을 다른 사설 /24 로 바꾸고(리졸버는 MAIL_RESOLVER_IP 도 함께) mail setup 을 다시 돌린다. 리졸버의 바깥 길 mail-dns-egress 는 도커가 기본 주소 풀(172.17~31.x · 192.168.x)에서 정한다 — VPC 가 그 대역이면 겹치지 않는지 본다 |
| 서버 B ACG — 인바운드 | TCP 25 · 465 · 993 · 443 ← 0.0.0.0/0 (메일 수신 · 메일 프로그램 · ACME TLS-ALPN · MTA-STS) · TCP 8080 ← 서버 A 사설 IP /32 만 · TCP 22 ← 관리 IP 만. 그 밖(80 · 3000 등)은 열지 않는다 |
| 서버 B ACG — 아웃바운드 | 좁힌다면 25(발송 — 열려 있음을 확인했다) · 443 · 80(ACME · 웹 포털 내려받기 · 외부 이미지 중계) · 53 UDP · TCP 를 모든 곳으로(메일 전용 리졸버가 루트 · 각 도메인의 DNS 서버에 직접 묻는다 — NCP 의 DNS 서버만 열면 안 된다)은 남긴다 |
| 서버 A ACG — 아웃바운드 | 좁혀 두었다면 서버 B 사설 IP 의 TCP 8080 을 더한다 |
| Network ACL | 서브넷의 NACL 을 바꿔 두었다면 위 포트를 같게 연다 |
| PTR | 서버 B 공인 IP → mx1.arasystem.it.kr — 가비아가 아니라 NCP 고객지원에 요청한다. 가비아의 mx1 A 레코드를 먼저 넣는다(정방향 · 역방향이 맞아야 한다 — PTR) |
| DNS (가비아) | mx1 A → 서버 B 공인 IP · portal A → 서버 A 공인 IP(지금 그대로) · files A → 서버 A 공인 IP(11단계) |
- 게시 주소로는 바깥을 막지 못한다 — NCP 의 공인 IP 는 서버 안에 없고 사설 IP 로 옮겨(NAT) 들어와, 사설 IP 에 게시한 8080 에도 공인 IP 로 닿는다.
8080 을 막는 것은 ACG 다. 서버 B 의
ip -4 addr에 공인 IP 가 없으면 이 경우다 — 바깥 PC 에서curl -m 5 http://<서버 B 공인 IP>:8080/이 시간 초과여야 한다. (NCP 의 NAT · ACG 는 NCP 문서 기준이고 이 저장소에서 시험하지 않았다 — 배포 뒤 아래 "확인"으로 본다) - 메일 포트(25 · 465 · 993 · 443)는 기본 구성 그대로
0.0.0.0에 게시한다. 공인 IP 로 들어온 접속이 원래 주소 그대로 보이는지는 원래 접속 주소 확인으로 본다. - 두 서버의 사설 IP 를 바꾸면(서버를 다시 만들면)
FRONTEND_PRIVATE_BIND·TRUST_PROXY· NPM 의 Forward IP · ACG 를 함께 고친다.
서버 B — env 와 실행
.env.production(운영 배포)에 셋을 넣는다 — 앞의 둘은 예시 env 의 "리버스 프록시가 다른 서버에 있을 때" 칸, TRUST_PROXY 는 바꾼다:
COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml # 리눅스는 : 로 잇는다
FRONTEND_PRIVATE_BIND=10.0.1.12:8080 # 서버 B 의 사설 IPv4:포트 (ip -4 addr)
TRUST_PROXY=172.16.72.0/24,10.0.1.11 # <EDGE_NETWORK_SUBNET>,<서버 A 사설 IP> — 아래 "TRUST_PROXY"
COMPOSE_FILE은 env 파일에 둔다 — compose 가--env-file의 이 값을 읽으므로 이 문서의 운영 명령(sudo docker compose --env-file .env.production …)이-f없이 그대로 두 파일을 읽는다.-f두 개로 쓰면 명령마다 붙여야 하고, 하나라도 빠뜨린 명령은 기본 구성(서버 B 에 없는proxy네트워크)으로 돈다.-f를 주면COMPOSE_FILE보다 그쪽이 이긴다.- 그다음은 운영 배포 절차 그대로다. 띄우기 전 · 뒤에 본다:
sudo docker compose --env-file .env.production config --networks # edge · app-network 만 — proxy 가 보이면 COMPOSE_FILE 이 먹지 않았다 sudo docker port "${PROJECT_NAME}-frontend" # 8080/tcp -> 10.0.1.12:8080 한 줄 sudo docker compose --env-file .env.production logs frontend-bind-check - 게시 주소 확인 —
FRONTEND_PRIVATE_BIND가 비어 있으면 compose 가 바로 멈춘다(required variable FRONTEND_PRIVATE_BIND is missing a value). 값이 있으면 frontend 보다 먼저frontend-bind-check(셸만 쓰는 한 번짜리 컨테이너 — DB 이미지를 빌린다 · 네트워크 없음 · 비-root)가 RFC 1918 사설 IPv4(10/8 · 172.16/12 · 192.168/16):포트(1~65535, 앞자리 0 없이)인지 본다. 아니면up이service "frontend-bind-check" didn't complete successfully: exit 1로 멈추고 frontend 는 뜨지 않는다 — 포트뿐인 값(8080·:8080— compose 는 모든 주소에 연다) ·0.0.0.0·127.0.0.1· 공인 IP · IPv6. 떠 있던 frontend 의 값을 잘못 바꾸면 compose 가 frontend 를 다시 만든 뒤 띄우지 않으므로 포털이 멈춘다(닫힌 쪽) — 값을 고치고up -d. 끝나면 멈춰 있어ps에는 없고ps -a에Exited (0)이다. 앱 네트워크에 붙지 않으므로 mail setup 의 구성원 확인과도 상관없다. - 확인 (2026-10-30 — compose v2.40.2(윈도) · 5.4.0(리눅스, CI 잡 이미지), 예시 env + 위 값):
config의 frontend 는 networksedge하나, portshost_ip: 10.0.1.12·published: "8080"·target: 8080, depends_on 에frontend-bind-check(service_completed_successfully) — 기본 구성과의 차이는 이 셋뿐이고 환경 · healthcheck 는 같다. 맨 위 networks 에proxy가 없다.COMPOSE_FILE을--env-file에만 두고-f없이 돌린config가 위와 같았다(윈도는;로 잇는다).COMPOSE_PROFILES=mail도 그대로 먹었고,-f docker-compose.yml을 주면 기본 구성이 나왔다.frontend-bind-check—10.0.1.12:8080·172.31.255.254:65535·192.168.0.10:18080은 0,8080·:8080·0.0.0.0:8080·127.0.0.1:8080·203.0.113.5:8080·172.32.0.1:8080·[::]:8080·10.0.1.12:0·10.0.1.12:08080은 1 (10.0.1.12·010.0.1.12:8080·10.0.1.12:65536은 compose 가 먼저 거절). 잘못된 값의up -d는 종료 코드 1 · frontendCreated(시작하지 않음)이고, 떠 있던 frontend 에서 값을 잘못 바꾸면 다시 만들어진 frontend 가Created로 남았다 (시험용 프로젝트 — frontend 대신 alpine).
TRUST_PROXY — 서버 A 의 주소 목록
-
값은
<EDGE_NETWORK_SUBNET>,<서버 A 사설 IP>(예172.16.72.0/24,10.0.1.11) — 백엔드의 운영 env 검증이 받는다(2026-10-30 확인). 서버 A 사설 IP 는 frontend 가 보는 접속 주소다 — 서버 A 의 NPM 은 도커지만 서버 밖으로는 서버 A 의 사설 IP 로 나간다. 배포 뒤sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"의 첫 칸으로 확인하고, 같은 줄의 X-Forwarded-For(넷째"…"칸) 끝이 브라우저의 공인 주소인지 본다. -
홉 수
2도 정상 흐름에서는 같은 IP 를 읽지만 "오른쪽에서 두 번째 주소"를 누가 붙었든 믿는다. 같은 VPC 의 다른 서버가 8080 에 바로 붙으면(ACG 를 잘못 열었을 때)X-Forwarded-For를 꾸며 백엔드가 그 값을 클라이언트 IP 로 쓴다 — 로그인 실패 한도 · 요청 수 한도가 IP 기준이다. 주소 목록은 서버 A 에서 온 것만 믿어, 다른 곳에서 바로 붙으면 그 서버 자신의 주소로 센다.frontend 가 받은 X-Forwarded-For(백엔드가 보는 접속은 edge 의 frontend)주소 목록 홉 수 2정상 — 203.0.113.50, 10.0.1.11203.0.113.50203.0.113.50브라우저가 꾸밈 — 1.2.3.4, 203.0.113.50, 10.0.1.11203.0.113.50203.0.113.50같은 VPC 의 다른 서버가 8080 에 바로 — 1.2.3.4, 10.0.2.2010.0.2.201.2.3.4(꾸민 값)서버 A 의 다른 컨테이너가 8080 에 바로 — 1.2.3.4, 10.0.1.111.2.3.4(꾸민 값)1.2.3.4(꾸민 값)(2026-10-30, 개발 백엔드 컨테이너의 Express 5.2.1 · proxy-addr 2.0.8 과 백엔드의
resolveTrustProxy로 확인) -
남는 것 — 서버 A 의 모든 것(다른 사이트의 컨테이너 포함)은 같은 사설 IP 로 나가 NPM 과 가를 수 없다(표의 마지막 줄). 서버 A 는 믿는 쪽에 든다 — 그 서버에 믿지 못할 것을 두지 않는다. NPM 이 사설 대역에서 온 접속의
X-Real-IP를 클라이언트 주소로 믿는 것(같은 VPC 의 다른 서버가 NPM 을 거쳐 꾸밀 수 있다)은 아래 고급 칸으로 끈다.
Nginx Proxy Manager 설정
서버 A 의 NPM 에 Proxy Host 를 하나 만든다. 기본값은 NPM v2.16.0(2026-09-24, 지금 최신) 소스로 확인했다 —
nginx.conf ·
proxy.conf ·
block-exploits.conf ·
assets.conf ·
log-proxy.conf ·
템플릿 proxy_host.conf ·
_listen.conf. 서버 A 의 NPM 판이 다르면 같은 파일을 대조한다.
| 탭 · 칸 | 값 | 까닭 |
|---|---|---|
| Details › Domain Names | portal.arasystem.it.kr |
|
| Details › Scheme · Forward Hostname / IP · Forward Port | http · 서버 B 사설 IP(10.0.1.12) · 8080 |
FRONTEND_PRIVATE_BIND 와 같은 값 |
| Details › Cache Assets | 끔 | 켜면 .js · .css · .png 등으로 끝나는 주소를 NPM 이 30분 캐시한다 — 응답의 Cache-Control · Set-Cookie 를 무시하고 키가 호스트 + 주소뿐이라(로그인한 사람을 가르지 않는다), 그런 주소의 API 가 생기면 남의 응답이 나간다(지금 서명 그림 · 첨부는 id 로 끝난다). 우리 캐시 머리글(index.html no-cache · /assets/ 30일 immutable)도 지운다 |
| Details › Block Common Exploits | 끔 | PHP 사이트용 패턴이 메일 검색어에 걸린다 — 아래 표 |
| Details › Websockets Support | 끔 | 웹메일은 웹소켓을 쓰지 않는다 (실시간 알림은 SSE — 보통 HTTP 응답) |
| Details › Access List | Publicly Accessible | 로그인은 웹메일이 한다 |
| SSL › SSL Certificate | Let's Encrypt 새 인증서 (portal.) |
서버 A 의 80 으로 HTTP-01 |
| SSL › Force SSL | 켬 | |
| SSL › HTTP/2 Support | 켬 | 실시간 알림 연결이 HTTP/1.1 의 주소당 연결 6개 가운데 하나를 1시간 잡지 않게. NPM → frontend 는 HTTP/1.1 이다(proxy_http_version 1.1) |
| SSL › HSTS Enabled | 선택 | 켜면 브라우저가 이 주소를 https 로만 연다 |
| Advanced › Custom Nginx Configuration | 아래 다섯 줄 | 템플릿이 server 단계, 이 Proxy Host 의 access_log · error_log 뒤에 넣는다. location / 는 쓰지 않는다(쓰면 NPM 이 기본 location 을 빼고 그것을 쓴다) |
# 첨부 올리기 — 프론트 nginx 가 백엔드를 150초까지 기다린다(/api/mail-attachments). NPM 기본은 http 단계 90s
proxy_read_timeout 150s;
# 접근 로그에 검색어 · 토큰(쿼리)이 남지 않게 — NPM 의 proxy 형식은 "$request_uri"(쿼리째)와 Referer 를 남긴다.
# 접속 기록은 프론트 nginx 의 접근 로그(쿼리 없음 · 브라우저 주소는 X-Forwarded-For 칸)로 본다
access_log off;
# 사설 대역에서 온 접속의 X-Real-IP 를 클라이언트 주소로 받지 않는다 — NPM 기본은 받는다(nginx.conf 의 set_real_ip_from · real_ip_header).
# 이 호스트는 PROXY protocol 로 받지 않아 바꿔 넣을 주소가 없으므로 접속 주소가 그대로 남는다
real_ip_header proxy_protocol;
# 대용량 첨부 받기(11단계 — 앱 안 받기는 이 포털로 온다)의 응답 머리글이 한글 255자 파일 이름이면 3.5KB 쯤이다 — 기본 4k 로는 빠듯하다(넘치면 502).
# 두 줄을 함께 — 앞의 것만 넣으면 nginx 가 설정을 거절해 이 Proxy Host 가 Offline 이 된다(아래 표 "머리글 버퍼")
proxy_buffer_size 16k;
proxy_busy_buffers_size 16k;
files.arasystem.it.kr— 11단계(대용량 첨부의 공개 내려받기,SHARE_PUBLIC_URL)부터 쓴다. 포털 호스트와 같은 사설 IP:8080 으로 Proxy Host 를 하나 더 만든다 — 위와 같은 칸 · 같은 고급 설정(다섯 줄), 인증서는files.로 새로. 접근 로그는 꼭 끈다 — 경로에 내려받기 토큰이 있다. 그 호스트에서 여는 것 · 확인은 대용량 첨부 — 공개 내려받기 호스트.- 고급 칸을 저장한 뒤 Proxy Host 가 Online 인지 본다 (NPM 이 저장할 때
nginx -t를 돌리고, 실패하면 그 설정을 빼고 Offline 으로 오류를 보여 준다 —backend/internal/nginx.js).
v2.16.0 기본값과 이 포털에 필요한 것:
| 항목 | v2.16.0 기본 | 필요 | 할 일 |
|---|---|---|---|
| 본문 한도 | client_max_body_size 2000m (http) |
MAIL_ATTACHMENT_MAX_SIZE_MB + 1MB(기본 21m) 이상 |
그대로 — 줄이면 그 이상으로 |
| 읽기 시간 | proxy_read_timeout 90s (http) |
150s 이상(첨부) · 실시간 알림은 25초 ping 보다 길면 된다 | 고급 칸 150s |
| 응답 전체 시간 상한 | 없음 | 실시간 알림 1시간 | 그대로 |
| 응답 버퍼링 | nginx 기본(켬) — X-Accel-Buffering 을 무시하게 한 곳이 없다 |
/api/mail-events 만 바로 흘려보낸다 |
그대로 — 프론트 nginx 가 그 응답에 X-Accel-Buffering: no 를 넘긴다 |
| 압축 | gzip on · gzip_types 없음 → text/html 만 |
text/event-stream 은 압축하지 않는다 |
그대로 — custom http.conf 등으로 gzip_types 를 넓히지 않는다 |
Accept-Encoding |
http 단계에 proxy_set_header Accept-Encoding "" 가 있지만, Proxy Host 의 location / 가 proxy.conf 로 머리글을 따로 정해 물려받지 않는다 |
그대로 — 브라우저의 값이 frontend 까지 가서 JS · CSS · JSON 은 프론트 nginx 가 압축한다(SSE 는 하지 않는다) | |
| 클라이언트가 끊으면 | proxy_ignore_client_abort off |
바로 끊는다 | 그대로 |
X-Forwarded-For |
proxy.conf 의 $proxy_add_x_forwarded_for (이어 붙인다) |
이어 붙인다 | 그대로 |
X-Real-IP 믿기 |
set_real_ip_from 10/8 · 172.16/12 · 192.168/16 + real_ip_header X-Real-IP |
사설 대역에서 와도 접속 주소 그대로 | 고급 칸 real_ip_header proxy_protocol; |
| 접근 로그 | Proxy Host 마다 proxy 형식 — "$request_uri"(쿼리째) · Referer |
쿼리를 남기지 않는다 | 고급 칸 access_log off; |
| 머리글 버퍼 | 정하지 않음 → nginx 기본 proxy_buffer_size 4k · proxy_buffers 8 4k (이미지의 페이지 크기 4096 — 2026-10-30 이미지로 확인) |
대용량 받기의 응답 머리글 3.5KB + 여유 | 고급 칸 proxy_buffer_size 16k; + proxy_busy_buffers_size 16k; — 앞의 것만이면 proxy_busy_buffers_size 의 기본(2 × 16k)이 proxy_buffers 의 "하나 뺀 합"(28k)을 넘어 nginx -t 가 실패한다(NPM 2.16.0 이미지의 OpenResty 1.31.1.1 로 확인). 버퍼링을 끈 받기는 본문도 이 크기로 넘긴다 |
Block Common Exploits(block-exploits.conf)는 $query_string 을 대소문자 가려 보고, 걸리면 NPM 의 403(HTML — 표준 실패 JSON 이 아니다)을 낸다. 웹메일에서 걸리는 것:
| 요청 | 걸리는 패턴 | 결과 |
|---|---|---|
메일 검색 API ?q=viagra (소문자 — valium · xanax · cialis · cocaine · tramadol · prozac · libido 등) |
스팸 낱말 | 403 |
?q=concat( · ?q=<b>javascript</b>(%3C…script…%3E) |
SQL · 스크립트 | 403 |
검색 화면 새로 고침 — /mail/search?q=http://example.com · ?q=../etc (화면 주소는 / · : 를 인코딩하지 않는다) |
파일 주입 | 403 |
같은 검색어의 API(q=http:%2F%2F… · q=..%2F…) · 한글 검색어 |
— | 통과 |
(2026-10-30, block-exploits.conf 를 그대로 넣고 realip · 로그 형식 · 템플릿 차례를 흉내 낸 nginx 1.30.6 으로 확인. 같은 시험에서 사설 주소에서 온 X-Real-IP: 1.2.3.4 가 $remote_addr 를 바꾸고
고급 칸의 real_ip_header proxy_protocol; 이 막는 것, 템플릿의 access_log 뒤의 access_log off; 로 그 Proxy Host 의 로그가 비는 것, location / 가 머리글을 따로 정하면 Accept-Encoding 이
그대로 넘어가는 것도 봤다. 실제 NPM 에는 넣어 보지 않았다)
확인 (배포 뒤)
# 서버 A 에서 — 사설망으로 닿는다 (200)
curl -sS -o /dev/null -w '%{http_code}\n' http://10.0.1.12:8080/
# 바깥 PC 에서 — 공인 IP 의 8080 은 닫혀 있다 (시간 초과)
curl -m 5 http://<서버 B 공인 IP>:8080/
# 서버 B 에서 — 첫 칸이 서버 A 사설 IP, X-Forwarded-For 칸 끝이 브라우저의 공인 주소
sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"
- 포털에 로그인해 20MB 첨부를 올려 본다(본문 한도 · 150s). 브라우저 개발자 도구 › 네트워크의
mail-events가 200 으로 열린 채 25초마다ping을 받는다(버퍼링 · 압축 없음). https://portal.arasystem.it.kr/api/health는 바깥에서 404(RES_001JSON)다 — 기본 구성과 같다.
문제 해결
| 증상 | 조치 |
|---|---|
셸 환경변수가 env 파일 값을 가리고 있습니다 |
표시된 변수를 지우고 다시 (unset APP_ENV · PowerShell Remove-Item Env:APP_ENV) |
메일 엔진에 연결할 수 없습니다 |
COMPOSE_PROFILES=mail 로 떠 있는지, MAIL_ADMIN_PORT 가 맞는지. 운영은 MAIL_ADMIN_URL 로 컨테이너 내부 주소를 준다 ("운영 배포" 3) |
운영에서 메일 엔진이 응답하지 않습니다: http://mail:8080 (EAI_AGAIN) |
새 셸이라 MAIL_ADMIN_URL 이 비었다 — 그러면 도커 안에서만 풀리는 mail 로 붙는다. PROJECT_NAME · MAIL_ADMIN_URL 을 다시 export ("운영 배포" 3). 아무것도 바뀌지 않았다 |
Spamhaus 키 … 시험 항목 … 을 받지 못해 그 항목을 꺼 둡니다 — SERVFAIL (종료 1) |
한 번은 일시 오류였다 — setup 을 다시 돌리면 다시 켜진다(2026-10-31). 거듭되면 Spamhaus 계정에서 키 · 서비스(ZEN · DBL)가 켜져 있는지 본다. 그동안 메일은 오간다 |
mx1 이 자체 서명 인증서(CN=rcgen self signed cert)를 낸다 · 로그에 autoconfig · autodiscover · mta-sts 의 acme.auth-error |
인증서 이름이 기본 묶음이다 — "운영 배포"의 TLS 인증서 줄(Additional Hostnames 에 mx1 + AcmeRenewal 한 번) |
운영에서는 docker 를 쓸 수 있어야 합니다 |
docker 그룹 사용자로, 또는 sudo -E env "PATH=$PATH" node … 로 호스트에서 실행 ("운영 배포" 3). 아무것도 바뀌지 않았다 |
임시 비밀번호 파일 자리(…/data/mail)에 쓸 수 없어 bootstrap 을 시작하지 않고 중단합니다 |
아무것도 바뀌지 않았다. data/·data/mail/ 을 실행하는 사용자 소유로 (sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail) 또는 늘 sudo -E 로 — 같은 명령 다시 |
bootstrap 직후 멈춤 (bootstrap 은 끝났지만 임시 비밀번호를 보관하지 못했습니다 등) |
bootstrap 직후에 멈췄다면 — 볼륨을 지우지 말고 원인을 고친 뒤 같은 명령(--recovery) 다시. config.json 으로 판단해 재시작만 한다 |
재시작 후에도 bootstrap 모드입니다 (config.json 은 이미 있음) |
설정 파일이 있는데 엔진이 bootstrap 모드로 떴다 — docker logs "${PROJECT_NAME}-mail" 의 기동 오류를 본다. Bootstrap/set 을 다시 부르지 않는다 |
bootstrap 이 거부되었습니다 + primaryKeyViolation |
데이터 볼륨에는 이전 설치가 있는데 설정 볼륨에 config.json 이 없다 (예: _mail-config 만 지움). 새 설치면 두 볼륨을 다 지우고 처음부터 (docker volume rm "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data"), 운영 중이던 서버면 백업에서 _mail-config 를 복원한다 |
사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다 |
아래 줄의 항목을 고치고 다시 — 엔진 설정은 그대로다 |
앱 네트워크 … 에 신뢰 대역에 있으면 안 되는 컨테이너가 붙어 있습니다 |
docker network disconnect "${PROJECT_NAME}_app-network" <컨테이너>, compose 의 networks 확인 (frontend 는 edge 만). 개발은 경고만 |
APP_NETWORK_SUBNET(…) — 접두 길이는 /24 ~ /28 … · 네트워크 주소로 … · 사설 대역 … |
사설 대역의 네트워크 주소, /24~/28 로 (예: 172.16.71.0/24). 바꾸면 down 후 up -d 로 네트워크를 다시 만든다 |
메일 엔진의 관리자(admin@…)와 MAIL_ADMIN_LOCAL_PART(…) 가 다릅니다 |
bootstrap 뒤에 MAIL_ADMIN_LOCAL_PART 만 바꿨다 — bootstrap 전에만 바꿀 수 있다. (1) env 를 엔진 관리자의 아이디로 되돌리고 up -d backend (가장 간단). (2) 꼭 바꿔야 하면: 복구 관리자로 엔진의 관리자 계정 아이디를 바꾼다(관리 화면 › 계정, 또는 x:Account/set update {"<id>": {"name": "<새 아이디>"}} — 주소가 함께 바뀐다) → env 를 같은 값으로 → up -d backend → setup 재실행 |
…(MAIL_ADMIN_LOCAL_PART)로 로그인은 되지만 관리 권한이 없습니다 · … 는 메일 엔진의 관리자 계정이 아닙니다 |
MAIL_ADMIN_LOCAL_PART 가 직원 계정을 가리킨다 — 엔진의 관리자 아이디로 맞춘다 (메시지에 엔진의 관리자 목록이 나온다) |
… 의 로그인 확인이 예상하지 못한 응답으로 끝났습니다 (HTTP 429·5xx) |
비밀번호를 덮어쓰지 않으려고 멈췄다. 429 면 몇 분 뒤, 5xx 면 docker logs 로 엔진 상태를 본 뒤 다시 |
[주의] 게이트웨이로 관리자 … 를 대리 접근하면 관리 API 가 열립니다 |
알려진 남은 위험이다 (Stalwart 0.16 은 막을 수 없다) — 보안의 게이트웨이 비밀번호 수칙을 지킨다. 실패가 아니다 |
[주의] 관리자 역할 계정 … 가 백엔드 차단 목록에 없습니다 |
그 주소를 MAIL_BLOCKED_ACCOUNTS 에 넣고 up -d backend, 쓰지 않는 관리자라면 지운다 |
관리자로 로그인할 수 없습니다 |
개발: MAIL_RECOVERY_ADMIN 을 채워 up -d mail 후 setup 재실행. 운영: "운영 배포" 1)·up -d mail → --recovery → 4)·5) |
[재시작 필요] · [후속 조치] (종료 코드 3) |
안내대로 한 뒤 같은 명령으로 다시 (운영 --recovery 뒤에는 --recovery 없이) |
MAIL_TRUSTED_NETWORKS 의 … 가 APP_NETWORK_SUBNET(…) 보다 넓거나 그 밖에 있습니다 |
MAIL_TRUSTED_NETWORKS 를 비우거나(= 서브넷) 서브넷 안의 더 좁은 대역으로 |
앱 네트워크 … 의 서브넷(…)이 APP_NETWORK_SUBNET(…) 과 다릅니다 |
서브넷을 고정하기 전에 만든 네트워크 — down 후 up -d 로 다시 만들고 setup 재실행 |
서브넷을 바꾼 뒤 백엔드가 getaddrinfo ENOTFOUND db 로 뜨지 않는다 |
네트워크만 새로 만들어지고 다른 컨테이너가 서비스 이름 별칭을 잃었다 — up -d --force-recreate (또는 down 후 up -d) |
운영 env 파일에 MAIL_RECOVERY_ADMIN 가 들어 있습니다 등 복구 관리자 오류 |
파일에서 지우고 셸에서 일회성으로만 준다 ("운영 배포") |
… 에 복구 관리자(STALWART_RECOVERY_ADMIN)가 남아 있습니다 |
unset MAIL_RECOVERY_ADMIN 후 docker compose --env-file .env.production up -d mail |
백엔드 주소 … 가 신뢰 대역(…) 밖입니다 |
MAIL_TRUSTED_NETWORKS 를 비우고(= APP_NETWORK_SUBNET) setup 재실행 |
공개 443 차단 확인 실패 |
setup 이 ReloadSettings 까지 했는지, x:Http 의 allowedEndpoints 가 위 식인지 (관리 화면 › Settings › HTTP) |
[주의] 받는 쪽 한도 "Sender address to recipient throttle"(…)을 찾지 못해 만들지 않았습니다 |
엔진 기본 한도를 지웠거나 이름을 바꿨다. 지웠다면 그대로 두고, 이름을 바꿨다면 그 한도의 match 가 {"match":{},"else":"is_empty(authenticated_as)"} 인지 관리 화면에서 본다 (엔진 한도) |
웹메일 발송의 받는 사람이 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)으로 되돌린다(큰 메일). 이전 중이면 그대로 둔다 — 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 로 내리지 않는다(큰 메일) |
[주의] 받는 메일 크기 한도가 엔진에 걸렸는지 확인하지 못했습니다 — … |
메일 컨테이너 안의 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 재실행 |
사내 목록 계정으로 계정 목록을 읽지 못했습니다 — …: 세션 HTTP 403 · Principal/… → forbidden |
역할에 authenticate·jmapPrincipalGet·jmapPrincipalQuery 가 없거나 빼는 권한에 들었다 — 복구 관리자로 setup. 세션 HTTP 401 이면 계정이 없거나 게이트웨이 비밀번호·허용 IP 문제다 |
[주의] 사내 목록 역할을 다른 계정도 가지고 있습니다: … |
관리 화면 › 계정에서 그 계정의 역할을 바꾼다 (관리자로 된다) |
[유지] 옛 사내 목록 계정 … — 새 계정 … 가 준비된 뒤에 지운다 |
MAIL_DIRECTORY_LOCAL_PART 를 바꿨는데 새 계정을 아직 만들지 못했다(복구 관리자 없음) — 복구 관리자로 setup 을 돌리면 새 계정을 만들고 옛 계정을 지운다 |
사내 목록(GET /api/directory-entries)이 503 MAIL_001 |
setup 출력의 요약 사내 목록 줄을 본다 — 준비되지 않았으면 위 줄들. 준비됐는데도 그러면 backend 의 MAIL_DIRECTORY_LOCAL_PART 가 setup 과 같은지 보고 up -d backend |
[주의] RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 바꾸지 않았습니다 |
메일을 받지 않는 시스템 계정은 지금처럼 받아 놓고 반송한다. 그 식에 시스템 계정일 때 arasystem-system-recipients 를 고르는 갈래를 합치거나, 그 설정이 필요 없으면 기본값({"match":{},"else":"false"})으로 되돌리고 setup 재실행 (보안) |
시스템 계정의 RCPT 거절 확인 실패 — … |
setup 이 ReloadSettings 까지 했는지, x:MtaStageRcpt 의 script 식과 시스템 Sieve 스크립트 arasystem-system-recipients(isActive: true)를 관리 API 로 본다. 확인은 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 |
시범 공존 사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다 · … allowRelaying 만 닫고 중단합니다 |
아무것도 바뀌지 않았다 — 뒤의 머리글이면 가드 없이 열려 있던 relay 만 닫았다(위에 [변경] … allowRelaying 을 껐다, 엔진에 없는 사내 주소는 다시 550). 아래 줄의 항목을 고친다 — 기존 서버에 닿지 않으면 이 호스트의 바깥 25 · 기존 서버의 STARTTLS · 인증서 이름, 자기 자신으로 풀리면 MAIL_PILOT_LEGACY_HOST, 기존 서버 IP 의 사람이 만든 자동 차단 예외 · 기존 서버 IP 로 된 운영자 갈래(아래 줄)는 지운 뒤 다시, waitOnFail 은 기본값(5s)으로, DKIM 은 아래 줄 (파일럿 공존 "setup 이 하는 일"). 이미 켜진 시범이면 "시범은 켜진 채로 두었습니다" — 고친 뒤 같은 명령으로 다시 실행한다 |
MAIL_PILOT_LEGACY_HOST 가 비어 있는데 메일 엔진에 시범 공존 설정이 남아 있습니다 |
운영의 env 파일에서 시범 값이 빠졌다. 시범을 이어 가려면 두 값을 다시 넣고, 끝내려면(전면 전환 — MX 를 옮긴 뒤) 값을 비운 채 --end-pilot 으로 실행한다. 아무것도 바뀌지 않았다 |
운영의 시범 공존 값(…)은 셸이 아니라 .env.production 에 둡니다 |
셸에 MAIL_PILOT_LEGACY_* 가 남아 있다 — 파일에 적고 unset 뒤 다시 |
도메인 … 의 allowRelaying 이 켜져 있습니다 — 시범 설정은 없고(setup 이 켠 것이 아니다) RCPT 가드도 없어 … |
시범을 켠 적이 없는 엔진에서 누가 relay 만 켜 두었다 — 가드 없이 열린 relay 라 엔진에 없는 사내 주소 앞 메일을 받아 놓고 반송한다. setup 은 남의 설정이라 끄지 않는다(요약 시범 설정 없음 · allowRelaying 켜짐, 종료 1). 의도한 것이 아니면 관리 화면에서 그 도메인의 relay 허용을 끈다 |
도메인 … 의 allowRelaying 이 켜져 있는데 setup 이 만든 시범 발송 경로(legacy)가 없습니다 |
누가 켰는지 알 수 없어 끄지 않았다 — 관리 화면에서 legacy 경로만 지웠을 수도 있다. RCPT 가드 · 위조 막기 · 예외 · 발송 전략은 남겼다(가드 없는 relay 를 만들지 않는다 — 요약은 시범 공존 끝내지 못함). 끝낼 것이면 관리 API x:Domain/set 으로 allowRelaying: false 뒤 다시 실행하면 나머지를 뺀다. 이어 갈 것이면 시범 값을 넣고(운영은 env 파일) 실행하면 경로를 다시 만든다 |
RCPT 가드가 답하지 않아(…) 시범 공존의 allowRelaying 을 켜지 않고 중단합니다 |
켜기 전 확인(A 의 리로드 뒤 · relay 꺼진 채)에서 엔진에 없는 사내 주소가 2초 안에 550 이 아니었다 — relay 는 열지 않았다(엔진에 없는 사내 주소는 그대로 550). 가드 · 표시 · 예외는 저장돼 있다. setup 이 리로드까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트 arasystem-system-recipients(isActive)를 관리 API 로 본 뒤 같은 명령으로 다시 실행한다 |
… allowRelaying 을 끄려 했지만 끄지 못했습니다 (…) 뒤에 — 대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다(…) |
가드가 답하지 않아 relay 를 닫으려 했는데 저장이 거절되었거나 · 엔진에 닿지 않았거나 · 다시 읽어도 켜져 있다. setup 이 멈추기 전에 RCPT 가드만 다시 걸었고 지금은 가드가 550 으로 거절한다 — relay 는 켜진 채다. 관리 화면에서 그 도메인의 relay 허용(관리 API x:Domain/set 의 allowRelaying: false)을 끄고, 꺼진 것을 본 뒤 같은 명령으로 다시 실행한다 |
… 끄지 못했습니다 (…) 뒤에 RCPT 가드도 다시 걸지 못했습니다 — 지금 가드 없이 열려 있습니다 · … 열려 있을 수 있습니다 |
relay 를 닫지 못했고 가드도 다시 걸지 못했다(저장 · 리로드 실패, 다시 걸었는데 확인이 250 이거나 확인하지 못함, 또는 RCPT 단계 식을 다른 설정이 써서 건드리지 않음) — 지금 열려 있다(있을 수 있다). 관리 화면에서 relay 허용을 바로 끄고, 꺼진 것을 본 뒤 다시 실행한다. 켜기 전 · 켠 뒤 확인에서 끄지 못한 경우(가드는 이미 걸었다)도 "열려 있을 수 있습니다"로 같다. setup 은 그 밖의 설정을 더 바꾸지 않고 멈췄다 |
RCPT 가드가 걸리지 않아 시범 공존의 allowRelaying 을 다시 끄고 중단합니다 |
켠 뒤 확인에서 엔진에 없는 사내 주소가 2초 안에 550 이 아니었다 — allowRelaying 을 다시 껐다(엔진에 없는 사내 주소는 다시 550, owner 가 동료에게 보내는 메일도 not-found). 가드 · 경로는 남아 있다. setup 이 리로드까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트 arasystem-system-recipients(isActive)를 관리 API 로 본 뒤 같은 명령으로 다시 실행한다 |
[변경] 시범 공존 — RCPT 가드 없이 allowRelaying 이 켜져 있어 → … allowRelaying 을 껐다 · … RCPT 가드가 답하지 않아(…) → … |
실행을 시작할 때 relay 가 가드 없이 열려 있었다(누가 RCPT 식을 바꿨거나 시스템 Sieve 스크립트를 껐다) — 먼저 닫았다. 같은 실행이 확인을 마치면 가드를 되살리고 다시 켠다 — 끝이 [완료] 면 된 것이다 |
시범 공존 확인 실패 — … (위조 막기 · 인증한 세션) |
설정은 저장됐다. setup 이 리로드까지 했는지, x:MtaStageMail 의 isSenderAllowed · 도메인의 allowRelaying 을 관리 API 로 본다. 고치기 어려우면 값을 비우고(운영은 --end-pilot) 끈다 |
다른 도메인 … 이 allowRelaying 을 켜 두어 시범 설정(…)을 빼지 않았습니다 |
시범을 끄는 실행인데 시범 도메인 밖의 도메인이 relay 를 켜 두었다 — RCPT 가드가 모든 사내 도메인을 덮으므로 가드 · 위조 막기 · 예외 · 발송 전략을 남겼다. setup 은 그 도메인을 끄지 않는다 — 의도한 것이 아니면 그 도메인의 relay 허용을 끈 뒤 다시 실행한다 |
도메인 … 의 allowRelaying 을 끄려 했지만 다시 읽어도 켜져 있습니다 |
시범 도메인의 relay 를 끄는 저장을 엔진이 받았는데 반영되지 않았다 — 시범 설정을 그대로 두었다. 관리 화면에서 relay 허용을 끈 뒤 다시 실행한다 |
운영의 MAIL_PILOT_LEGACY_HOST 포트는 25 만 받습니다 |
587 · 465 는 인증이 있어야 중계되는데 setup 은 발송 경로에 인증을 넣지 않는다 — mailcow 의 25 를 쓴다([mail.arasystem.it.kr]:25 또는 포트 없이) |
[주의] 시범 꼴과 같은 기존 서버 IP 갈래가 있지만 setup 의 표시 … 에 없는 IP 라 운영자 것으로 보고 건드리지 않습니다 |
위조 막기 · 검사 예외 · IP 한도 식에 시범 꼴의 갈래가 있는데 시범 표시(자동 차단 예외)에 없는 IP 다 — 운영자가 넣은 것이면 그대로 둔다(기존 서버 IP 와 같은 IP 면 아래 줄처럼 멈춘다). setup 이 넣은 것인데 표시 항목을 지웠다면 관리 화면에서 그 갈래를 지운다 |
… 에 기존 서버 IP(…)로 된 갈래가 setup 의 표시 없이 있습니다 — 운영자가 넣은 것이거나, setup 의 시범 표시 … 가 지워진 것입니다 |
켜기 전에 멈췄다(아무것도 바뀌지 않았다). 운영자가 넣은 것이면 — 켜면 다음 실행부터 setup 의 갈래와 가를 수 없어 합쳐지고 끝낼 때 지워지므로 — 그 갈래를 지우고(IP 한도는 엔진 기본 — 모든 IP — 으로) 다시 실행한다(시범 동안은 setup 의 갈래가 같은 일을 한다). 켜진 시범에서 setup 의 시범 표시(자동 차단 예외)만 지워진 것이면 그 IP 로 같은 설명([setup.mjs] 시범 공존 — 기존 서버(mailcow) 중계 IP)의 자동 차단 예외를 다시 만든 뒤 실행해도 된다 |
… 에 setup 의 시범 갈래와 같은 글의 갈래가 둘 이상 있습니다 |
setup 은 한 칸에 하나만 넣는다 — 나머지는 운영자가 같은 글로 넣은 것이다. 하나만 남기고 지운 뒤 다시 실행한다. 끄는 실행에서는 맨 앞 하나만 빼고 나머지를 남긴다([주의] … 맨 앞 하나만 빼고) |
DKIM TXT … 의 공개 키가 엔진의 키와 다릅니다 · DKIM TXT … 가 DNS 에 없습니다 |
키가 다르면 동료(mailcow) 앞 메일이 위조로 거절되고, 없으면 DMARC 가 SPF 정렬에만 기대게 되어 반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다. setup.mjs --print-dns 가 보여 주는 selector TXT 로 DNS 를 고치거나 넣고, 바깥에서 dig TXT <selector>._domainkey.<도메인> 으로 보인 뒤 다시 실행한다. 처음 켜는 실행이면 relay 를 켜지 않고 멈췄고, 이미 켜진 시범이면 켠 채로 실패만 알렸다 |
[주의] 관리자 역할("System Administrator")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 … |
관리자 역할의 이름을 바꿨거나 emailReceive 를 더했다 — 관리자 주소는 RCPT 거절에서 빠진다(메일을 받는 계정을 막지 않으려고). 관리자가 메일을 받지 않게 둔다면 역할을 되돌린다 |
Password is too weak |
더 길고 무작위인 값으로 (예: change-me-in-local-env 는 통과, devpassword1 은 거부) |
리졸버 네트워크 … 가 없습니다 · … 가 리졸버 네트워크 … 에 없습니다 · … 의 dns(…)가 MAIL_RESOLVER_IP(…) 하나가 아닙니다 · … 컨테이너가 없습니다 |
메일 전용 리졸버가 든 compose 로 아직 다시 띄우지 않았다 — docker compose … up -d(mail-resolver 를 띄우고 mail 을 다시 만든다 — 몇 초 끊긴다) 뒤 setup. 아무것도 바뀌지 않았다 |
… 가 떠 있지 않습니다(mail-resolver) |
docker logs "${PROJECT_NAME}-mail-resolver" — 설정 오류면 줄 번호가 나온다(unbound-checkconf). could not open … unbound.conf·Permission denied 면 저장소를 umask 077 로 클론했다 — chmod 644 mail/resolver/unbound.conf. 고친 뒤 docker compose … up -d |
mail-resolver 가 Address already in use 로 뜨지 않는다 |
다른 컨테이너가 MAIL_RESOLVER_IP 를 먼저 가져갔다(도커는 앞쪽부터 차례로 준다). docker compose … up -d --force-recreate mail-resolver mail, 되풀이되면 MAIL_RESOLVER_IP 를 서브넷의 끝 쪽으로 |
… 의 … 주소(…)가 MAIL_RESOLVER_IP(…) 와 다릅니다 · 서브넷(…)이 MAIL_RESOLVER_SUBNET(…) 과 다릅니다 · internal 이 아닙니다 |
env 를 바꾼 뒤 컨테이너 · 네트워크를 다시 만들지 않았다 — docker compose … down 후 up -d(네트워크 · 컨테이너를 함께), 그다음 setup |
리졸버 … 가 메일 컨테이너에서 DNSSEC 을 검증한 답을 주지 않습니다 |
리졸버가 바깥에 묻지 못하거나 DNSSEC 이 깨졌다 — 괄호 안이 답마다의 결과다(UDP 루트 SOA · TCP 루트 DNSKEY). 이 서버의 바깥 53(UDP · TCP) 아웃바운드(NCP ACG), docker exec "${PROJECT_NAME}-mail-resolver" drill . @127.0.0.1 NS, 서버 시계(DNSSEC 은 시각을 본다), docker logs 의 리졸버 오류. 운영은 아무것도 바꾸지 않고 멈췄다. 개발은 [주의] 뒤 이어 가지만(개발 메일은 Mailpit), 리졸버가 바깥에 전혀 닿지 않으면 뒤의 리로드를 엔진이 거절해 거기서 종료 1 이다(아래 줄) |
[실패] x:Action/set 요청이 거부되었습니다 + 생성 거부 (reload): validationFailed — Invalid address: failed to lookup address information |
엔진이 설정을 다시 만들며 스팸 필터의 Pyzor 주소(public.pyzor.org)를 mail 컨테이너의 DNS(= 메일 전용 리졸버)로 풀지 못해 리로드를 통째로 거절했다 — 리졸버가 바깥에 닿지 못한다. docker exec "${PROJECT_NAME}-mail-resolver" drill public.pyzor.org @127.0.0.1 A 로 보고 리졸버 장애 때. 저장한 설정은 남아 있다(다음 리로드 · restart mail 때 적용) |
메일 컨테이너(…)에서 리졸버를 확인하지 못했습니다 — 메일 컨테이너에 perl 이 없음(docker exec 종료 코드 127) |
리졸버 문제가 아니다 — setup 은 메일 이미지의 perl 로 묻는데 MAIL_IMAGE 를 바꾼 새 이미지에 perl 이 없다(docker exec "${PROJECT_NAME}-mail" perl -v). 운영은 여기서 멈춘다 — 이미지를 되돌리거나, setup 의 리졸버 확인(DNS_PROBE_SCRIPT)을 그 이미지에 맞춘다. 개발은 [주의] |
[주의] … 의 이번 기동 … 로그가 남아 있지 않아 … DNSSEC 을 검증했는지 확인하지 못했습니다 |
mail 이 오래전에 떠서 그 기동의 로그가 회전(json-file 의 max-size · max-file 등)으로 지워졌다 — 실패가 아니다(메일 · 리졸버는 사전 확인대로다). 확인하려면 docker compose … restart mail(몇 초 끊긴다) 뒤 setup |
compose up 이 optional dependency "mail-resolver" failed to start 경고를 낸다 |
mail 은 떴지만 리졸버가 unhealthy · 멈춤이라 바깥 DNS(바깥 메일 · SPF · DKIM · DNSBL)가 안 된다 — 리졸버 장애 때 |
특정 회사(도메인)로만 메일이 안 간다 · 그 도메인에서 온 메일만 SPF · DKIM · DMARC 가 temperror |
그 도메인의 DNSSEC 이 깨졌을 수 있다(리졸버가 검증해 SERVFAIL 로 답한다) — DNSSEC 이 깨진 도메인 |
[주의] 리졸버 네트워크 … 에 mail · mail-resolver 밖의 컨테이너가 붙어 있습니다 · 리졸버의 바깥 길 … 에 다른 컨테이너가 (운영은 실패) |
그 컨테이너도 리졸버에 닿는다(리졸버도 그 컨테이너에 닿는다) — docker network disconnect <네트워크> <컨테이너>, compose 의 networks 확인 |
[주의] 엔진이 … 때 리졸버로 DNSSEC 을 검증하지 못했다고 남겼습니다 |
mail 이 리졸버보다 먼저 떴거나(서버 재부팅 — 도커는 compose 의 차례를 지키지 않는다. docker compose … restart mail 뒤 setup 으로 다시 본다), 리졸버가 TCP 로 답하지 못한다. 메일은 오가고 DANE 만 꺼진다. 리로드도 같은 확인을 다시 하지만 결과가 로그에 남지 않아 기동 로그로만 본다 |
[주의] MAIL_SPAMHAUS_DQS_KEY 가 없어 Spamhaus 두 항목 … 을 꺼 둔다 (운영) |
키가 없으면 늘 나온다 — 실패가 아니다. 키를 받으면 .env.production 에 넣고 setup (DNS 리졸버와 스팸 차단 목록 "Spamhaus") |
Spamhaus 키(…)로 … 시험 항목 … 을 받지 못해 그 항목을 꺼 둡니다 |
키가 틀렸거나 · 계정이 끝났거나(체험 30일이 지났다 — 구독하거나 키를 비우고 setup) · 그 서비스(IP · 도메인)가 계정에서 꺼져 있다(꺼진 서비스는 SERVFAIL). Spamhaus 계정 화면에서 키 · 서비스를 본 뒤 setup. 그 항목은 꺼진 채다 — 공개 미러로 묻지 않는다. 키가 멈춘 뒤 이 실행 전까지는 엔진의 조회가 실패해 표시가 붙지 않았다(fail-open — "Spamhaus" 절) |
MAIL_SPAMHAUS_DQS_KEY 는 Spamhaus 가 준 26자 영문 · 숫자 키여야 합니다 · 운영의 MAIL_SPAMHAUS_DQS_KEY 는 셸이 아니라 … |
키를 .env.production 에 따옴표 · 공백 없이 그대로 넣는다(셸에는 두지 않는다 — unset MAIL_SPAMHAUS_DQS_KEY) |
[주의] 스팸 차단 목록 …(STWT_…)을 찾지 못해 맞추지 않았습니다 |
엔진 기본 항목을 지웠거나 이름을 바꿨다 — 바꿨다면 그 항목의 조회 이름 · 켜짐을 관리 화면 › Settings › Spam Filter › DNSBL 에서 직접 본다 |
| 외부로 보낸 메일이 Mailpit 에 없다 | setup 의 [확인] 설정 리로드 완료 가 나왔는지. 로그: docker logs "${PROJECT_NAME}-mail" 의 delivery.*. delivery.ip-lookup-failed … hostname = "mailpit" 이면 개발 엔진의 DNS 가 Custom 이다 — setup 을 다시 돌리면 System 으로 돌아온다 |
| 대용량 파일이 오래 "검사 중"이다 · 백엔드 로그에 검사기 오류(세 번째부터 error) | 검사기가 멈췄거나 닿지 않는다 — 판정 없이 받게 되는 일은 없다(닫힌 쪽), 고치면 백엔드가 차례대로 다시 한다(1분부터 두 배씩 1시간까지). sudo docker compose --env-file .env.production ps clamav clamav-updater(둘 다 healthy) · sudo docker logs --tail 30 "${PROJECT_NAME}-clamav" · 백엔드에서 sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').connect(3310,'clamav');s.on('connect',()=>s.write('zPING\0'));s.on('data',d=>{console.log(String(d));process.exit(0)});s.on('error',e=>{console.log(e.code);process.exit(1)})" → PONG. ENOTFOUND 면 clamav 가 없다(up -d), ECONNREFUSED 면 서명을 읽는 중이거나 죽었다(로그). clamav 가 Exited 면 서명이 없거나(updater 를 먼저 본다) 메모리 한도(docker inspect … OOMKilled) — 악성 검사 (ClamAV) |
| 2GB 가까운 파일만 오래 "검사 중"이다 | 검사 한 번이 백엔드의 판정 대기(30분 + 보낸 1MB 마다 1초 — 2,047MB 면 약 64분)를 넘는다 — 넘으면 끊고 미뤄서 처음부터 다시 검사한다. 코어 하나로 2GB 에 5분 안팎(개발 PC)이라 보통은 넉넉하다 — 운영 CPU 가 아주 느리거나 zip 밖의 큰 압축(7z · tar.gz — 시간 상한 없음, MaxScanSize 4GB 까지 푼다)이면 더 걸린다. 백엔드 로그의 clamd 가 다 받은 뒤 …초 안에 판정을 보내지 않았습니다 · clamav 로그에 그 판정이 늦게 남는지 본다. 한 번에 둘을 검사하면 cpus(2.0)를 나눠 쓴다 — CPU 를 늘리거나 백엔드에 알린다 |
clamav-updater 가 unhealthy · 서명이 오래됐다 (echo VERSION | nc localhost 3310 의 날짜) |
updater 의 healthcheck 는 daily 서명이 이틀 넘게 바뀌지 않으면 실패한다. sudo docker logs --tail 50 "${PROJECT_NAME}-clamav-updater" — 바깥 HTTPS · DNS(database.clamav.net · current.cvd.clamav.net)가 막혔거나(NCP ACG 아웃바운드 443 · 53), CDN 이 잦은 요청으로 잠시 막았거나(You are on cool-down until … — 그 시각까지 기다린다, 컨테이너를 되풀이해 다시 만들지 않는다), 판이 끝나(EOL) 서명을 주지 않는다(clamav/clamav 판을 올린다 — docker 스킬 "이미지 버전 고정"). 검사는 옛 서명으로 이어 간다 |
up 이 optional dependency "clamav-updater" failed to start 경고를 내고 clamav 가 곧 끝난다 |
처음 기동에서 서명을 받지 못했다(updater 가 unhealthy — clamd 는 서명이 없으면 뜨지 않는다). updater 로그를 보고 고친 뒤 up -d. 서명 볼륨을 지웠다면 updater 가 다시 받는 1분 안팎을 기다린다 |
대용량 파일을 올릴 수 없다 — 507 FILE_006 |
서버 총량(FILE_STORAGE_MAX_TOTAL_GB) 또는 디스크 여유(FILE_STORAGE_MIN_FREE_GB)에 걸렸다 — df -h /data/docker · sudo docker system df -v(볼륨 <PROJECT_NAME>_files 크기). 기간이 지나거나 끊은 링크의 파일은 한 시간마다 지워진다. ClamAV 가 검사하는 동안 /tmp 에 잠시 쓰는 몫(최대 12GB)도 같은 디스크다 |
받는 사람이 내려받기에서 429 REQ_001 |
프론트 nginx 의 동시 연결 수 — 같은 주소에서 공개 받기 10개 · 앱 안 받기 30개, 또는 전체 공개 받기 400개 · 앱 안 받기 200개(따로 센다)를 넘었다(접근 로그의 429 — 대소문자를 바꾼 경로도 같은 수로 센다). 백엔드의 요청 수 한도(공개 받기 IP 당 1분 300회)와 같은 코드다 — 백엔드 로그에 없으면 nginx 쪽이다. 사무실 NAT 뒤에서 앱 안 받기가 잦으면 frontend/nginx.conf 의 수를 올린다(전체를 올리면 worker_connections · nofile 그릇도 다시 본다 — 받기 하나가 연결 둘) |
| 내려받기만 502(HTML — NPM 의 오류 화면)이고 다른 화면 · API 는 된다 | 외부 프록시(NPM)의 머리글 버퍼에 받기 응답 머리글이 넘쳤다 — 긴 한글 파일 이름이면 3.5KB 를 넘는다. 두 Proxy Host 의 Advanced 에 proxy_buffer_size 16k; · proxy_busy_buffers_size 16k; 가 있는지 본다(Nginx Proxy Manager 설정). NPM 의 오류 로그(NPM 컨테이너 안 /data/logs/proxy-host-<번호>_error.log — 2.16.0 템플릿)에 upstream sent too big header 가 남는다. 프론트 nginx 는 16k 다 — 프론트 nginx 의 502 면 JSON SYS_001 이다 |
NPM 의 Proxy Host 가 Offline — "proxy_busy_buffers_size" must be less than … |
Advanced 에 proxy_buffer_size 16k; 만 넣었다 — proxy_busy_buffers_size 16k; 를 함께 넣고 저장한다 |
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 터널) — 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 의 호스트와 겹치지 않는지(메일 프로그램 자동 설정) |
리졸버 장애 때
메일 전용 리졸버(mail-resolver)가 멈추거나 바깥에 묻지 못하면 엔진의 바깥 DNS 가 모두 실패한다 — 바깥으로 보내는 메일은 MX 를 찾지 못해 대기열에서 다시 시도하고,
받는 메일은 SPF · DKIM · DMARC · DNSBL 검사가 약해진다. 사내 메일 · 웹메일은 그대로 된다. 다만 설정 리로드가 막힌다(엔진이 Pyzor 주소를 풀지 못해 리로드를 통째로 거절 — 아래 3.).
표시는 compose 의 optional dependency "mail-resolver" failed to start 경고, setup 사전 확인의 … 가 떠 있지 않습니다 · … DNSSEC 을 검증한 답을 주지 않습니다,
setup · 관리 화면 리로드의 validationFailed — Invalid address: failed to lookup address information 다. 자동으로 공용 DNS 로 넘어가는 길은 두지 않는다
(넘어가면 Spamhaus 가 다시 막고, 장애를 모른 채 지나간다 — 메일 전용 리졸버 "대가"). 아래는 사람이 고르는 길이다.
- 리졸버부터 고친다 (운영 명령 — 개발은
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development):healthy 가 되면 setup 을 돌려sudo docker ps -a --filter "name=${PROJECT_NAME}-mail-resolver" # Up (healthy) 여야 한다 sudo docker logs --tail 20 "${PROJECT_NAME}-mail-resolver" # 설정 오류면 파일 · 줄 번호가 나온다 sudo docker exec "${PROJECT_NAME}-mail-resolver" drill . @127.0.0.1 NS # 바깥(루트)에 묻는가 # 설정을 고쳤으면 리졸버만 다시 만든다 (설정 파일만 바뀌면 compose 가 다시 만들지 않는다 — mail 은 그대로 둔다) sudo docker compose --env-file .env.production up -d --force-recreate mail-resolver[확인] 메일 전용 리졸버 …를 본다("운영 배포" 5)와 같은 형태). mail 이 리졸버 없이 떴다면 그 기동은 DANE 이 꺼지고 Pyzor 가 빠진 채다 — setup 의 리로드가 둘 다 다시 만들지만 리로드의 DNSSEC 확인은 로그에 남지 않으므로, 확인하려면sudo docker compose --env-file .env.production restart mail(몇 초 끊긴다) 뒤 setup 의[확인] 엔진 기동 로그(…)에 DNSSEC 경고가 없다를 본다. - mail 이 리졸버 때문에 뜨지 않을 때 — compose 는 리졸버가 unhealthy · 멈춤(설정 오류로 바로 끝남) · 재시작을 되풀이해도 경고만 내고 mail 을 띄운다
(mail 의
depends_on이required: false— 2026-10-30 개발에서 셋 다 확인. 리졸버의 healthcheck 가 unhealthy 로 판정할 때까지는 기다린다 — 최대 2분쯤). 그래도 mail 이Created에 남았다면(required를 지운 compose 등) 리졸버를 기다리지 않고 띄운다:sudo docker compose --env-file .env.production up -d --no-deps mail - 리졸버를 곧 고칠 수 없을 때 — 비상 길: 엔진 DNS 를 공용 DNS 로. 엔진 DNS 를
System으로 돌려도 소용없다 — mail 컨테이너의dns:가 같은 리졸버라 도커 DNS 도 바깥 이름을 그 리졸버에 묻는다. 관리 API(관리 화면의 DNS Resolver 설정과 같다 — 운영은 SSH 터널, "운영 배포")로@type을Quad9이나Cloudflare로 저장한 뒤 리로드가 아니라 mail 을 다시 띄운다 (외부 릴레이와 같은 호출 형식):[["x:DnsResolver/set", { "accountId": "<세션의 urn:stalwart:jmap 계정>", "update": { "singleton": { "@type": "Quad9" } } }, "0"]]sudo docker compose --env-file .env.production restart mail # 몇 초 끊긴다- 리로드(
ReloadSettings· 관리 화면의 Reload)는 이때 엔진이 거절한다 — 설정을 다시 만들며 스팸 필터의 Pyzor 주소(public.pyzor.org)를 mail 컨테이너의 DNS(= 죽은 리졸버)로 풀다 실패하면 리로드 전체를 버린다(validationFailed — Invalid address: failed to lookup address information). 기동은 그 항목(Pyzor)만 빼고 뜬다(로그registry.build-error … SpamPyzor) — 0.16.27 소스crates/common/src/config/mailstore/spamfilter.rs·crates/common/src/cache/reload.rs. 2026-10-30 개발에서 리졸버의 바깥 길을 끊고 확인했다:System으로 다시 띄우면 DNSSEC 경고(엔진 DNS 가 안 된다),Quad9저장 뒤 리로드는 위 거절,restart mail뒤에는 DNSSEC 경고 없이 떴다(엔진 DNS 가 Quad9 로 된다 — Pyzor 만 빠진 채). - 엔진은
@type만 바꾸는 부분 갱신을 받고, 두 값 모두 기본이"useTls": true(DNS over TLS — 바깥 853/TCP)다. 이 서버에서 바깥 853 이 막혀 있으면"useTls": false를 함께 준다(53 UDP · TCP). - 그동안은 공용 DNS 를 거친다 — 공용 DNS 조회를 거부하는 차단 목록은 답하지 않아 검사가 약해진다(메일은 오간다). mail 컨테이너 자체의 이름 풀기(Pyzor · ACME ·
getent)는 여전히 리졸버라 안 된다. - 리졸버를 고친 뒤 setup 을 다시 돌린다 — 사전 확인을 통과하면
[변경] 엔진 DNS — … Custom … · 전 Quad9처럼 리졸버로 되돌리고 리로드한다(개발은System) — 그 리로드가 빠졌던 Pyzor 도 다시 만든다. 기동 로그로 DNSSEC 을 확인하려면 그 뒤restart mail한 번과 setup([확인] 엔진 기동 로그 …). 운영 setup 은 리졸버가 확인되기 전에는 사전 확인에서 멈춰 엔진 DNS 를 바꾸지 않는다 — 비상 길을 연 채로 돌려도 공용 DNS 가 그대로 남으니 리졸버부터 고친다 (개발 setup 은[주의]뒤 이어 가며System으로 저장한다 — 개발은 Mailpit 이라 비상 길이 필요 없다).
- 리로드(
DNSSEC 이 깨진 도메인 (특정 회사로만 메일이 안 간다)
메일 전용 리졸버는 DNSSEC 을 검증한다 — 서명이 깨진 도메인(만료된 RRSIG · 키를 바꾸다 상위의 DS 를 고치지 못함 등)은 SERVFAIL 이 된다.
그 도메인으로 보내는 메일은 MX 를 찾지 못해 대기열에서 다시 시도하다 끝내 반송되고, 그 도메인에서 오는 메일은 SPF · DKIM · DMARC 조회가 temperror 다.
검증을 켜면서 새로 생길 수 있는 증상이다(검증하지 않는 DNS 로는 답이 왔다). 다른 회사로는 잘 간다면 이것부터 본다.
- 그 도메인 쪽 문제인지 본다 — 검증을 끄고(CD 비트) 물으면 답이 오는데 검증하면 SERVFAIL 이면 그 도메인의 DNSSEC 이 깨졌다:
(예: 시험용으로 일부러 깨 둔
sudo docker exec "${PROJECT_NAME}-mail-resolver" drill <도메인> @127.0.0.1 MX # rcode: SERVFAIL sudo docker exec "${PROJECT_NAME}-mail-resolver" drill -o CD <도메인> @127.0.0.1 MX # rcode: NOERROR + 답 → DNSSEC 이 깨졌다fail01.dnssec.works는 앞 줄 SERVFAIL, 뒷줄5.45.109.212— 2026-10-30.) CD 로 물어도 SERVFAIL 이면 DNSSEC 이 아니라 그 도메인의 DNS 서버가 답하지 않는 것이다 — 아래 예외로 풀리지 않는다. 바깥 도구로도 한 번 더 본다: https://dnsviz.net/ · https://dnssec-analyzer.verisignlabs.com/ (빨간 표시 — 다른 검증 리졸버(Google · Cloudflare)도 같은 SERVFAIL 을 준다). - 그 도메인의 관리자에게 알린다 — 그쪽이 고치면 저절로 풀린다(unbound 는 검증 실패를 1분만 기억한다 —
val-bogus-ttl기본값 60). - 급하면 그 도메인만 검증 예외로 —
mail/resolver/unbound.conf의server:안에 한 줄 더하고 리졸버만 다시 만든다:domain-insecure: "example.com"그 도메인과 하위 도메인 전체의 DNSSEC 검증을 끈다 — 그 도메인으로 가는 메일의 DANE 도 꺼진다. 다른 도메인은 그대로 검증한다 (시험용 리졸버 컨테이너에서sudo docker compose --env-file .env.production up -d --force-recreate mail-resolver # 몇 초 동안 바깥 DNS 가 끊긴다(엔진이 다시 시도한다)fail01.dnssec.works만 SERVFAIL → NOERROR,dnssec.works는 그대로 ad ·fail02.dnssec.works는 그대로 SERVFAIL — 2026-10-30). 예외는 저장소 파일이라 커밋 기록에 남는다 — 그쪽이 고친 것을 1. 로 확인하면 지운다.