Skip to content

Latest commit

 

History

History
294 lines (207 loc) · 19.4 KB

File metadata and controls

294 lines (207 loc) · 19.4 KB

이 문서는 유지합니다. 증상 → 원인 표는 어떤 코드에도 적히지 않는 정보라, .tf와 워크플로가 생겨도 대체되지 않습니다. 같은 삽질을 두 번 하면 여기에 한 줄 추가하세요.

← 문서 인덱스

런북 — 자주 터지는 것들

최초 적용 순서

의존성 때문에 infra/terraform을 한 번에 apply할 수 없습니다. ECR이 비어 있으면 ECS 서비스가 이미지를 못 찾아 무한 재시도하므로 순서가 중요합니다.

cd infra/terraform
terraform init

# 1) 네트워크
terraform apply -target=aws_vpc.main \
                -target=aws_subnet.public -target=aws_subnet.private \
                -target=aws_internet_gateway.main

# 2) ECR 먼저 (이미지를 넣어야 ECS가 뜸)
terraform apply -target=aws_ecr_repository.api

# 3) 첫 이미지 push
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
REGISTRY=$ACCOUNT.dkr.ecr.ap-northeast-2.amazonaws.com
aws ecr get-login-password --region ap-northeast-2 \
  | docker login --username AWS --password-stdin $REGISTRY

docker buildx build --platform linux/amd64 \
  -t $REGISTRY/hacker-api:latest ./apps/api --push

# 4) RDS (5~10분 소요)
terraform apply -target=aws_db_instance.main

# 5) 전체
terraform plan -out=tfplan
terraform apply tfplan

6) ACM 인증서 DNS 검증 (#156). aws_acm_certificate.api는 신청만 하고 끝나지 않는다 — aws_acm_certificate_validation 리소스가 없어서 terraform apply는 검증을 기다리지 않고 끝나며, 인증서는 PENDING_VALIDATION으로 남는다. 이 상태로는 443 리스너가 TLS 핸드셰이크를 못 마친다.

terraform output -json acm_validation_record

나온 name/type/value를 khuhacker.com을 관리하는 DNS(이 AWS 계정의 Route53이 아니다 — 도메인을 산 등록기관 쪽)에 CNAME으로 등록한다. 전파에 몇 분에서 몇십 분 걸린다. 아래로 상태를 확인한다.

aws acm list-certificates --region ap-northeast-2 \
  --query "CertificateSummaryList[?DomainName=='api.khuhacker.com'].Status" --output text
# ISSUED가 나오면 완료

검증:

curl https://api.khuhacker.com/actuator/health
# {"status":"UP", ...} 나오면 최초 배포 확인 완료

vercel.json의 destination은 고정값 https://api.khuhacker.com/api/:path*이라 배포마다 따로 채울 게 없다 (deployment.md) — ALB의 원본 DNS 이름을 여기 넣지 않는다. 그렇게 하면 평문 ALB로 직접 프록시하게 되어 #156 이전으로 되돌아간다.

증상별 원인 / 해결

증상 원인 / 해결
exec format error 맥에서 arm64 빌드. platforms: linux/amd64 + 태스크 정의 X86_64 일치 (deployment.md)
태스크가 PENDING에서 안 넘어감 assign_public_ip = false + NAT 없음 → ECR pull 불가
ResourceInitializationError Execution Role에 ssm:GetParameters 누락 (infra.md ecs.tf)
타겟 unhealthy 무한 반복 /actuator/health가 Security에 막혀 401 / startPeriod 짧음
DB 연결 타임아웃 RDS SG에 ECS SG 미등록
GHA AccessDenied on RegisterTaskDefinition iam:PassRole 누락 (infra.md cicd.tf)
GHA OIDC 인증 실패 permissions: id-token: write 누락 또는 sub 조건 불일치
terraform이 CI 배포를 롤백 ignore_changes = [task_definition] 누락
presigned 업로드 CORS 에러 S3 allowed_origins에 localhost/Vercel 도메인 누락
Vercel에서 API 호출 실패 vercel.json의 /api/* rewrite가 없거나 SPA fallback 아래에 있다 — fallback이 API 요청까지 index.html로 삼킨다. destination은 https://api.khuhacker.com (deployment.md)
develop에 머지했는데 운영이 안 바뀜 Vercel Production Branch는 main이다. develop push는 Preview만 만든다. release/vX.Y.Z → main을 태워야 운영에 나간다 (CONTRIBUTING.md)
main 머지 후 Vercel Production 배포가 안 걸림 GitHub 장애(webhook 유실)일 수 있다. 대시보드 상단 배너 확인 → 해당 커밋의 빌드를 Promote to Production으로 수동 승격 (2026-08-17 실제 사례 — 빌드 결과물은 커밋이 같으면 동일하다)
main 머지에 API 배포가 안 돎 deploy-api.yml은 apps/api/** 변경일 때만 트리거된다. 웹만 바뀐 릴리스면 안 도는 게 정상이다
태스크가 가끔 재시작 Fargate Spot 회수. 정상 동작 (결정 3)
첫 가입자가 계속 PENDING 최초 관리자 승격을 안 했다. 아래 절차를 밟는다
관리자가 전부 사라짐 아래 절차로 복구한다 (2-2 §2-2-7)
마지막 관리자가 정지됨 새 상태 PATCH는 이 조합을 만들지 않는다. 기존 데이터·삭제/탈퇴 중단 여부를 확인하고 아래 "정지된 관리자밖에 없을 때"를 본다
승격 API가 계속 403 조건 넷 중 하나가 어긋났다. 응답은 사유를 알려주지 않으므로 서버 로그를 본다 (관리자 승격 거절 줄)

배포 직후 30분 동안 알아둘 것

배포 시점에 이미 로그인해 있던 세션은 정지·승인이 반영되지 않는다. 세션을 사용자로 찾으려면 SPRING_SESSION.PRINCIPAL_NAME이 채워져 있어야 하는데, 그 값은 로그인할 때 들어간다 (3-1 §3-1-5).

  • 세션 수명이 30분이라 그냥 두면 사라진다. 마이그레이션은 필요 없다.
  • 그 사이에 급히 정지해야 하는 계정이 있으면 DB에서 그 사람의 세션을 직접 지운다. 지우면 다음 요청이 401이 되어 정지 안내 대신 로그인 화면으로 가지만, 접근은 즉시 끊긴다.
-- 급할 때만. 평소에는 관리자 화면의 정지를 쓴다.
DELETE FROM spring_session WHERE primary_id IN (
  SELECT session_primary_id FROM spring_session_attributes
  WHERE attribute_name = 'auth.userId'
);

위 질의는 로그인한 세션 전부를 지운다 — 배포 직후 특정 계정만 골라낼 방법이 없기 때문이다(그래서 이 창이 문제다). 전원이 다시 로그인하면 된다.

관리자 직접 정지 정책 배포 전후 점검

(2026-08-30, #296.)

배포 뒤에는 PATCH /admin/users/{id}/status가 ADMIN 직접 정지를 거절하고 먼저 USER로 권한을 회수하라고 안내한다. 기존 행은 자동 보정하지 않고 DB CHECK도 추가하지 않는다. 과거 API와 삭제·탈퇴 중단 경로가 SUSPENDED ADMIN을 남길 수 있어, 자동 회수하면 REVOKE_ADMIN 감사 없이 권한이 사라지고 마지막 관리자 복구 의미도 바뀌기 때문이다.

배포 전에 읽기 전용으로 후보를 확인한다. ECS Exec로 API 컨테이너에 들어가 psql을 사용한다.

SELECT id, email, status, role, approved_at
FROM users
WHERE role = 'ADMIN' AND status = 'SUSPENDED'
ORDER BY id;

결과가 없으면 끝이다. 결과가 있으면 행마다 admin_actions와 아래 운영 로그를 확인해 원인을 판단한다. 조회 결과를 한꺼번에 UPDATE하지 않는다.

SELECT actor_id, target_id, action, created_at
FROM admin_actions
WHERE target_id = <user-id>
ORDER BY id;
판단 처리
계속 관리자여야 하고 정지만 풀면 됨 회원 관리에서 ACTIVE로 해제한다. 기존 관리자 권한을 유지한다
관리자 권한을 회수해야 함 먼저 role을 USER로 회수한다. 정지를 유지하려면 별도 status 정지를 다시 보내 세션을 확인하고, 제거할 계정이면 제거 절차를 따른다
삭제·탈퇴가 중단됨 아래 중단된 탈퇴와 CloudWatch 로그를 대조해 제거 완결 또는 해제를 고른다
근거를 찾지 못함 자동 변경하지 않는다. 계정 소유자·운영진 확인 뒤 위 셋 중 하나를 고른다

ACTIVE ADMIN의 권한 회수 뒤 정지는 두 요청으로 실행한다. 정상 결과는 REVOKE_ADMIN과 SUSPEND 두 감사 행, 세션의 USER 반영 뒤 SUSPENDED 반영이다. 이미 SUSPENDED ADMIN인 기존 행은 role 회수만 새 감사로 남고, 같은 status 재요청은 세션만 다시 확인한다. 직접 관리자 정지 거절에는 status·role·세션·감사 변화가 없어야 한다.

최초 관리자 승격

배포 직후 반드시 한 번 해야 한다 (7-DEPLOYMENT MUST). 안 하면 관리자가 0명이라 아무도 가입을 승인할 수 없고, 첫 가입자가 계속 PENDING으로 남는다. 마지막 관리자가 사라졌을 때의 복구 절차이기도 하다.

넷을 모두 만족해야 승격된다 (3-3 결정 11).

  1. 활성 관리자가 0명
  2. 요청자 이메일 == ADMIN_BOOTSTRAP_EMAIL
  3. 본문 토큰 == ADMIN_BOOTSTRAP_TOKEN
  4. 신청서 제출 완료 — 구글 로그인만으로는 안 된다

403이 나오면 사유는 응답이 아니라 로그에 있다:

aws logs filter-log-events --log-group-name /ecs/hacker-api \
  --filter-pattern "승격 거절" --region ap-northeast-2 \
  --query 'events[].message' --output text

"이메일이 설정값과 다르다"가 뜨면 SSM /hacker/dev/ADMIN_BOOTSTRAP_EMAIL을 의심한다. terraform.tfvars.example의 기본값(admin@khu.ac.kr)이 그대로 apply된 사례가 있다 (2026-08-18, #169). 고치는 곳은 terraform.tfvars이고, 콘솔에서 값만 바꾸면 다음 apply가 되돌린다. 바꾼 뒤에는 ECS 강제 재배포까지 해야 반영된다 — SSM 값은 컨테이너가 시작할 때만 읽는다.

① 정상 가입을 마친다. 구글로 로그인하고 신청 폼까지 제출한다. 4번 조건이라 건너뛰면 승격되지 않는다.

② 토큰을 조회한다.

aws ssm get-parameter --name /hacker/dev/ADMIN_BOOTSTRAP_TOKEN \
  --with-decryption --region ap-northeast-2 --query Parameter.Value --output text

파라미터 이름은 infra.md의 ssm.tf가 원본이다. 환경이 늘면 그 경로도 함께 바뀐다.

③ 브라우저에서 호출한다. 로그인한 그 브라우저의 개발자 도구 콘솔에서 실행한다 — 쿠키 세 개(SESSION·ACCESS_TOKEN·CSRF)가 모두 필요해 curl로는 번거롭다.

await fetch('/api/v1/auth/csrf', { credentials: 'include' })          // 쿠키를 먼저 받는다
const csrf = document.cookie.match(/XSRF-TOKEN=([^;]+)/)[1]
const res = await fetch('/api/v1/auth/bootstrap-admin', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': csrf },
  body: JSON.stringify({ token: '<②에서 받은 값>' }),
})
res.status   // 204면 성공

④ 확인한다. 새로고침하면 관리자 화면이 열린다 — 승격은 기존 세션에 즉시 반영되므로 재로그인이 필요 없다 (3-1 §3-1-5).

몇 번 틀리면 잠긴다

한 계정은 15분에 5회, 이 경로 전체는 15분에 20회까지 시도할 수 있습니다. 넘으면 같은 창 동안 막힙니다 (3-2 §3-2-3, #144).

잠긴 것도 다른 거절과 똑같은 403입니다. 응답만으로는 구별할 수 없으므로, 토큰이 맞는데도 계속 403이 나오면 잠겼을 가능성을 의심하세요. 진짜 사유는 API 로그에 남습니다.

관리자 승격이 계정 단위로 잠겼다: userId=... 창=15분

해제 수단은 없습니다. 15분을 기다리세요. 잠긴 동안의 요청은 세지 않으므로 재시도해도 대기 시간이 늘어나지는 않습니다. 해제 스위치는 그 자체가 새 공격 표면이고, RDS가 프라이빗이라 DB에서 직접 지우는 것은 15분보다 오래 걸립니다.

성공하면 그 계정의 시도 기록은 지워집니다. 토큰을 잘못 붙여넣어 몇 번 틀린 뒤 성공했다면, 다음 사고 때 그것 때문에 막히지 않습니다.

사고 대응 중이라면 다른 관리자 후보 계정으로 시도할 수 있습니다 — 잠금은 계정 단위입니다. 다만 ADMIN_BOOTSTRAP_EMAIL과 이메일이 일치해야 하므로, 실제로는 그 이메일을 쓰는 계정만 해당합니다.

토큰을 바꿔야 할 때

파라미터를 지우지 않는다. 태스크 정의가 두 값을 secrets로 항상 참조하므로(infra.md ecs.tf), 지우면 새 태스크가 기동 전에 실패한다 — 배포나 Spot 회수 뒤 대체 태스크가 뜨지 못해 서비스가 통째로 멈춘다.

값만 바꾸고 재배포한다. SSM 값은 컨테이너가 시작할 때 환경변수로 주입되므로, 이미 돌고 있는 태스크에는 반영되지 않는다.

Terraform을 통해 바꾼다. 이 파라미터의 값은 random_password.admin_bootstrap_token이 소유한다(infra.md ssm.tf) — aws ssm put-parameter로 직접 덮으면 다음 terraform apply가 그것을 드리프트로 보고 옛 토큰을 되살린다.

terraform apply -replace=random_password.admin_bootstrap_token
aws ecs update-service --cluster hacker-cluster --service hacker-api --force-new-deployment

Terraform을 쓸 수 없는 상황이라 CLI로 급히 바꿨다면, 그 값을 잊지 말고 Terraform 쪽에도 반영한다 — 하지 않으면 다음 apply에 조용히 되돌아간다.

정지된 관리자밖에 없을 때

마지막 활성 관리자가 SUSPENDED가 되어 0명이 된 경우는 위 절차로 복구되지 않는다. #296 뒤 일반 상태 PATCH는 이 조합을 새로 만들지 않지만, 기존 데이터나 관리자 삭제·탈퇴의 선행 정지만 남은 실패에서는 여전히 가능하다. 그 계정은 로그인 단계에서 거절되고, 세션이 남아 있어도 승격 경로가 정지된 계정을 거절한다.

  1. 신청서까지 마친 다른 계정을 준비한다 (없으면 그 사람이 먼저 가입·신청한다).
  2. ADMIN_BOOTSTRAP_EMAIL을 그 주소로 바꾼다 — 값의 소유자가 Terraform이므로 .tf에서 고쳐 apply한다.
  3. ECS를 재배포한다. SSM 값은 컨테이너가 시작할 때 읽는다.
  4. 그 계정으로 위의 승격 절차를 밟는다.
  5. 복구한 뒤 정지됐던 계정을 배포 전후 점검에 따라 해제하거나, 권한을 회수한 뒤 정지를 유지·제거한다.

토큰을 채팅·이슈·커밋에 남기지 않는다. 안전한 채널로 최초 관리자에게만 전달한다 (infra.md).

403이 나오면 응답만으로는 무엇이 틀렸는지 알 수 없다 — 사유를 알려주면 토큰을 추측할 수 있기 때문이다. CloudWatch 로그에서 관리자 승격 거절 줄을 찾으면 사유가 적혀 있다.

중단된 탈퇴를 이어받는다

(2026-08-28, #223. 규칙은 spec 3-2에 있다.)

회원 탈퇴는 계정을 SUSPENDED로 만든 뒤 지운다. 그 사이에 세션 반영 확인이나 삭제가 실패하면 계정이 정지된 채로 남는다. 이때 본인은 로그인도 API 접근도 잃어 다시 시도할 수 없다 — 운영자가 이어받아야 한다.

겉보기로는 관리자가 정지시킨 계정과 구별되지 않는다. 가르는 근거는 로그뿐이다.

# 중단된 탈퇴 찾기 — 대상 id가 함께 찍힌다
aws logs filter-log-events --log-group-name /ecs/hacker-api \
  --filter-pattern '"탈퇴 중 정지만 남았다"' \
  --start-time $(( ($(date +%s) - 7*86400) * 1000 ))

찾았으면 둘 중 하나를 고른다. 본인 의사가 "나가겠다"였으므로 기본은 제거다.

상황 무엇을
그대로 탈퇴를 완결한다 (기본) 회원 관리 화면에서 그 계정을 제거한다. 남을 콘텐츠 건수를 확인 창이 보여준다
본인이 취소를 원한다고 확인됐다 정지를 해제한다. ACTIVE로 돌아오고 본인이 다시 로그인할 수 있다

임의로 해제해 두지 않는다. 해제하면 그 사람은 자기가 탈퇴했다고 믿는 채로 계정이 살아 있게 된다. 확인이 안 되면 제거 쪽이 본인 의사에 가깝다.

"탈퇴 중 정지만 남았다" 로그가 없다고 곧바로 일반 정지로 단정하지 않는다. #296 이전 관리자 직접 정지, 관리자 제거 중단, 수동 운영 변경도 가능하다. admin_actions와 같은 시각의 CloudWatch 로그를 함께 보고 배포 전후 점검에서 처리한다.

S3 고아 오브젝트 정리 (#339)

매일 새벽 4시(한국 시간)에 자동으로 돈다 — API 컨테이너 안에서 OrphanObjectCleanupJob이 실행되는 것이지 별도 배치 인프라가 아니다. 이 저장소에 있는 유일한 @Scheduled 사용처다. 컨테이너 시간대와 무관하다 — @Scheduled(zone = "Asia/Seoul")로 못박아 두었다(#342 리뷰). 운영 이미지는 TZ를 설정하지 않아 JVM 기본이 UTC이므로, 이 지정이 없으면 같은 cron이 한국 시간 오후 1시에 돈다.

무엇을 하는가. notes/·photos/의 최종 위치(notes/uploads/·photos/uploads/ 임시 위치는 제외)를 훑어, DB(note_files.stored_path, photos.stored_path + 유도한 썸네일 키)가 참조하지 않고 1시간(app.storage.orphan-cleanup.safety-margin) 넘게 지난 오브젝트를 지운다. 등록 롤백·자료 수정·사진 삭제의 보상 S3 삭제가 실패하면(StagedUploads·PhotoService가 log.error로 남기는 자리) 이 작업이 다음 새벽에 대신 정리한다.

사람이 개입할 일은 평소 없다. 여러 태스크가 배포 중 잠깐 겹쳐도 pg_advisory_xact_lock으로 한 번에 하나만 돈다.

확인하고 싶으면 CloudWatch에서 "고아 오브젝트 정리 완료" 로그를 찾는다 — 지운 건수·안전 여유로 유보한 건수가 함께 찍힌다. "참조를 잃은 오브젝트를 지웠다"가 대량으로 반복되면 보상 삭제가 자주 실패하고 있다는 신호이니 원인(대개 일시적인 S3 오류)을 따로 살펴본다.

안전 여유·주기를 조정하려면 apps/api/src/main/resources/application.yml의 app.storage.orphan-cleanup을 바꾸고 배포한다 — SSM 값이 아니라 일반 설정이라 코드 변경 없이 값만 바꿔도 된다.


디버깅 명령어

# 컨테이너 접속
aws ecs execute-command --cluster hacker-cluster \
  --task <task-id> --container api --interactive --command "/bin/sh"

# 로그
aws logs tail /ecs/hacker-api --follow

# 서비스 이벤트 (배포 실패 원인이 여기 찍힘)
aws ecs describe-services --cluster hacker-cluster \
  --services hacker-api --query "services[0].events[:10]"

dev RDS를 직접 들여다봐야 할 때도 이 컨테이너 접속으로 들어가서 psql을 씁니다 (RDS가 프라이빗 서브넷이라 로컬에서 직접 못 붙습니다).


← 이전: 배포 · 문서 인덱스로