feat(docker): 13단계 운영 기반 — 백업 · 감시 · 관리자 단단히 · 휴지통 30일 · 메일 프로그램 자동 설정

전 직원 전환 전에 서버가 스스로 지키고 되살릴 수 있게 했다.
- 관리자 단단히(setup): 관리자 역할에서 impersonate 를 빼고 관리자 허용 IP 를 앱 네트워크로 — 관리자 비밀번호로 바깥 993 · JMAP 에서 직원 메일함에 대리 로그인되던 길을 막았다.
  운영에서 처음 걸 때는 되돌릴 복구 관리자가 있을 때만 건다(관리자 잠김 방지).
- 휴지통 · 스팸 30일 정리를 KST 03:00 으로 명시. imapsync 로 옮긴 메일도 옮긴 때부터 센다(임시 엔진 실측).
- restic 백업(호스트 systemd 03:30 — 메일 볼륨 warm → 엔진 멈춤 9초 안팎 → final, pg_dump, 업로드, env) · 복원(새 서버 차례를 빈 클론에서 실측) · 15분 점검 · 부팅 되살리기, Healthchecks 하트비트.
- 메일 프로그램 자동 설정: frontend nginx 의 autoconfig · autodiscover 고정 응답(엔진 것은 POP3 를 알리고 입력을 되돌려 쓴다).
- 모든 서비스 docker 로그 보존, DB 이미지 판 · digest 고정.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:44:44 +09:00
parent f0e7890bf1
commit 73babeaa2c
21 changed files with 2249 additions and 34 deletions
+98 -6
View File
@@ -122,7 +122,7 @@ jobs:
run: npm run build-only
infra:
name: Infra (compose · Dockerfile · nginx)
name: Infra (compose · Dockerfile · nginx · 운영 스크립트)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 10
@@ -150,6 +150,22 @@ jobs:
if docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q 2>/dev/null; then
echo "::error::FRONTEND_PRIVATE_BIND 없이 docker-compose.remote-proxy.yml 을 받아들였습니다 (멈춰야 한다)"; exit 1
fi
# 로그 보존(13단계) — 모든 서비스에 logging 이 있어야 한다(없으면 도커 기본 — 회전 없이 디스크를 채운다).
# 서비스는 두 칸, 그 키는 네 칸 들여 쓴다(compose config 의 출력 꼴)
# 리버스 프록시가 다른 서버일 때의 조합(remote-proxy — frontend-bind-check 가 더해진다)도 센다. 게시 주소는 이 확인에서만 준다
for args in "-f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile mail --profile redis --profile frontend --profile clamav" \
"-f docker-compose.yml --env-file .env.production.example --profile mail --profile redis" \
"-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example --profile mail --profile redis"; do
# shellcheck disable=SC2086
services=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config --services | wc -l)
# shellcheck disable=SC2086
logged=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config | awk '/^ logging:/ { c++ } END { print c + 0 }')
[ "$services" -eq "$logged" ] || { echo "::error::logging 이 없는 서비스가 있습니다 ($args — 서비스 $services · logging $logged)"; exit 1; }
done
# DB 이미지는 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정")
for envf in .env.development.example .env.production.example; do
grep -Eq '^DB_IMAGE=postgres:[0-9]+\.[0-9]+-alpine[0-9.]+@sha256:[0-9a-f]{64}$' "$envf" || { echo "::error::$envf 의 DB_IMAGE 가 판 · digest 까지 고정되지 않았습니다"; exit 1; }
done
# docker-compose.remote-proxy.yml 의 frontend-bind-check — 사설 IPv4:포트만 통과해야 한다
# (포트뿐인 값 · 0.0.0.0 은 compose 가 그대로 받아 모든 주소에 연다). 프로젝트 이름을 따로 두어 다른 compose 프로젝트와 섞이지 않게 한다
@@ -182,13 +198,35 @@ jobs:
docker build --target nginx-config -t "$tag" frontend
# 공개 내려받기 호스트(11단계)를 따로 두지 않을 때 — 포털이 /s/ · /api/public-links/ 를 연다(nginx-share-open.conf · nginx-share-api-open.conf)
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t
# 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다
docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -t
rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -T 2>/dev/null)
# 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다.
# 이 꼴은 메일 프로그램 자동 설정(13단계 — autoconfig. · autodiscover.<MAIL_DOMAIN>)도 켠다
docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -t
rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -T 2>/dev/null)
for want in 'server_name files.ci.test;' 'include /etc/nginx/snippets/share-closed.conf;' 'include /etc/nginx/snippets/share-api-closed.conf;' \
'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;'; do
'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;' \
'server_name autoconfig.ci.test autodiscover.ci.test;' '<hostname>mx1.ci.test</hostname>' '<Server>mx1.ci.test</Server><Port>993</Port>'; do
printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; }
done
# 메일 값이 없으면 자동 설정은 꺼진다 — 어느 요청에도 맞지 않는 이름
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null | grep -F 'server_name autoconfig.invalid;' >/dev/null \
|| { echo "::error::MAIL_DOMAIN 없이 자동 설정 server 가 꺼지지 않았습니다"; exit 1; }
# 자동 설정의 값은 server_name · 응답 XML 에 그대로 들어간다 — 호스트 이름 꼴이 아니면 · 포털 · 공개 호스트와 겹치면 기동을 멈춰야 한다
for value in 'ci.test;return 200' 'ci..test' '-ci.test' 'ci.test.' 'citest' 'ci test' 'ci.test/x'; do
if docker run --rm -e PROJECT_NAME=ci -e "MAIL_DOMAIN=$value" -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -t >/dev/null 2>&1; then
echo "::error::MAIL_DOMAIN=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
fi
done
for value in 'mx1.ci.test<x>' 'mx1.ci.test:993' 'mx1'; do
if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e "MAIL_HOSTNAME=$value" "$tag" nginx -t >/dev/null 2>&1; then
echo "::error::MAIL_HOSTNAME=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
fi
done
# 끝의 / · 빈칸 · 여러 출처도 같게 본다 (공개 호스트 판정과 같은 읽기)
for value in 'https://autoconfig.ci.test' 'https://autoconfig.ci.test/' 'https://portal.ci.test, https://AUTODISCOVER.ci.test/'; do
if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test -e "CORS_ORIGIN=$value" "$tag" nginx -t >/dev/null 2>&1; then
echo "::error::포털(CORS_ORIGIN=$value)과 겹치는 자동 설정 호스트를 받아들였습니다 (거부해야 한다)"; exit 1
fi
done
# 12단계 — 두 server 가 모두 엔진만 부르는 /api/internal/ 을 닫고(nginx-internal-api-closed.conf) 원문 요청 대상(날 \ · # · 0x80 이상)을 400 으로 끝낸다
# (if $request_target_rejected). 두 꼴 모두 지시어가 둘씩이어야 한다 — 주석이 아니라 지시어만 센다(첫 칸이 include · if)
plain=$(docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null)
@@ -285,7 +323,7 @@ jobs:
EOF
docker build -t "$tag" "$ctx"
docker run -d --name "$name" --network none -e NGINX_BACKEND_UPSTREAM=http://127.0.0.1:3000 \
-e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag"
-e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag"
for _ in $(seq 1 20); do docker exec "$name" wget -q -O /dev/null --header 'Host: portal.ci.test' http://127.0.0.1:8080/ 2>/dev/null && break; sleep 1; done
docker exec "$name" sh /usr/local/bin/paths.sh || { docker logs --tail 40 "$name"; exit 1; }
@@ -355,6 +393,59 @@ jobs:
[ "$fails" -eq 0 ]
EOF
# 메일 프로그램 자동 설정(13단계 — frontend/nginx.conf 의 셋째 server)을 실제 요청으로 본다 — 위 단계의 컨테이너(MAIL_DOMAIN=ci.test · MAIL_HOSTNAME=mx1.ci.test).
# 고정 응답이어야 한다 — 질의 · POST 본문에 꾸민 주소를 넣어도 응답에 되돌아오지 않고, 백엔드로 넘기지 않는다(/api 도 404)
- name: 메일 프로그램 자동 설정 시험
run: |
name="ci-nginx-paths-${{ github.run_id }}"
docker exec -i "$name" sh -s <<'EOF'
fails=0
# host method path [본문] → 응답 전체(머리글 + 본문, CR 없이). 본문이 있으면 XML 로 보낸다
raw() {
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf 'Content-Type: text/xml\r\nContent-Length: %s\r\n' "${#4}"; printf 'Connection: close\r\n\r\n'; [ -n "$4" ] && printf '%s' "$4"; sleep 1; } \
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r'
}
ok() { echo "ok $1"; }
bad() { echo "FAIL $1"; fails=$((fails + 1)); }
has() { if printf '%s\n' "$2" | grep -F -- "$3" >/dev/null; then ok "$1 — '$3' 있음"; else bad "$1 — '$3' 없음"; fi; }
hasnt() { if printf '%s\n' "$2" | grep -F -i -- "$3" >/dev/null; then bad "$1 — '$3' 가 되돌아왔다"; else ok "$1 — '$3' 없음"; fi; }
INJ='%3Cx%3Ascript%20xmlns%3Ax%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%3Ealert(1)%3C%2Fx%3Ascript%3E%40ci.test'
r=$(raw autoconfig.ci.test GET "/mail/config-v1.1.xml?emailaddress=$INJ")
has "Thunderbird" "$r" "HTTP/1.1 200"; has "Thunderbird" "$r" "Content-Type: text/xml; charset=utf-8"
has "Thunderbird" "$r" "<hostname>mx1.ci.test</hostname>"; has "Thunderbird" "$r" "<port>993</port>"; has "Thunderbird" "$r" "<port>465</port>"
has "Thunderbird" "$r" "<username>%EMAILADDRESS%</username>"; hasnt "Thunderbird" "$r" "script"; hasnt "Thunderbird" "$r" "pop3"
r=$(raw autoconfig.ci.test GET /.well-known/autoconfig/mail/config-v1.1.xml)
has ".well-known" "$r" "<hostname>mx1.ci.test</hostname>"
BODY='<?xml version="1.0"?><Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><EMailAddress>&lt;script&gt;evil@ci.test</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema></Request></Autodiscover>'
for p in /autodiscover/autodiscover.xml /AutoDiscover/AutoDiscover.xml /Autodiscover/Autodiscover.xml; do
r=$(raw autodiscover.ci.test POST "$p" "$BODY")
has "Outlook $p" "$r" "HTTP/1.1 200"; has "Outlook $p" "$r" "<Server>mx1.ci.test</Server><Port>993</Port>"; has "Outlook $p" "$r" "<Type>SMTP</Type>"
hasnt "Outlook $p" "$r" "evil"; hasnt "Outlook $p" "$r" "script"
done
for p in / /api/auth/me /index.html /s/x /autodiscover/autodiscover.json; do
r=$(raw autoconfig.ci.test GET "$p"); has "그 밖 $p" "$r" "HTTP/1.1 404"
done
# 포털 · 공개 호스트는 이 경로를 자동 설정으로 답하지 않는다
r=$(raw portal.ci.test GET /mail/config-v1.1.xml); hasnt "포털" "$r" "<clientConfig"
r=$(raw files.ci.test GET /mail/config-v1.1.xml); has "공개 호스트" "$r" "HTTP/1.1 404"
echo "fails=$fails"
[ "$fails" -eq 0 ]
EOF
# 운영 서버의 백업 · 점검 스크립트(13단계 — ops/) — shellcheck(판 · digest 고정). 설정 파일 예시에 비밀이 없는지도 본다
- name: 운영 스크립트 검사
run: |
# 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다). 이 이미지에는 셸이 없다(scratch) — 그대로 shellcheck 를 부른다
tag="ci-shellcheck:${{ github.run_id }}"
printf 'FROM koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d\nCOPY . /mnt/ops\nWORKDIR /mnt/ops\n' | docker build -q -t "$tag" -f- ops >/dev/null
docker run --rm "$tag" -x -P SCRIPTDIR -S style backup/arasystem-backup.sh backup/arasystem-restore.sh monitor/arasystem-check.sh lib/common.sh
for key in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
grep -Eq "^${key}=change-me$" ops/ops.env.example || { echo "::error::ops/ops.env.example 의 $key 는 change-me 여야 합니다"; exit 1; }
done
if grep -Eq '^HC_(BACKUP|CHECK)_URL=.+' ops/ops.env.example; then
echo "::error::ops/ops.env.example 에 Healthchecks 주소가 들어 있습니다"; exit 1
fi
# 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf.
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만
# 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 —
@@ -401,6 +492,7 @@ jobs:
docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
docker image rm -f "ci-unbound-config:${{ github.run_id }}" || true
docker image rm -f "ci-clamav-config:${{ github.run_id }}" || true
docker image rm -f "ci-shellcheck:${{ github.run_id }}" || true
# 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다)
docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true