diff --git a/README.md b/README.md index 777fe12..cc13894 100755 --- a/README.md +++ b/README.md @@ -1,10 +1,24 @@ -# Web Portal - Kubernetes + GitOps 배포 가이드 +# 🌐 Web Portal — Kubernetes + GitOps 홈랩 프로젝트 -## 📋 프로젝트 개요 +> 조직 내부 웹페이지를 통합 관리하는 포털 시스템을 **온프레미스 Kubernetes 환경**에서 직접 설계·구축·운영한 홈랩 프로젝트입니다. -조직 내부 웹페이지를 통합 관리하는 포털 시스템입니다. -로그인 후 본인에게 할당된 웹페이지 목록을 확인하고 접속할 수 있으며, -관리자는 페이지 및 사용자 권한을 관리할 수 있습니다. +[![Kubernetes](https://img.shields.io/badge/Kubernetes-326CE5?style=flat&logo=kubernetes&logoColor=white)](https://kubernetes.io) +[![ArgoCD](https://img.shields.io/badge/ArgoCD-EF7B4D?style=flat&logo=argo&logoColor=white)](https://argoproj.github.io/cd) +[![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=flat&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?style=flat&logo=postgresql&logoColor=white)](https://www.postgresql.org) +[![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat&logo=docker&logoColor=white)](https://www.docker.com) +[![Let's Encrypt](https://img.shields.io/badge/Let's_Encrypt-003A70?style=flat&logo=letsencrypt&logoColor=white)](https://letsencrypt.org) + +--- + +## 📌 프로젝트 목적 + +단순히 따라하는 튜토리얼이 아닌, **실제 운영 환경에서 발생하는 문제들을 직접 맞닥뜨리고 해결**하는 것을 목표로 구축했습니다. + +- 실제 도메인(`cyanburu.com`) + HTTPS 적용으로 외부 인터넷에서 접근 가능한 서비스 운영 +- GitOps 방식으로 코드 변경 시 자동 배포되는 CI/CD 파이프라인 구성 +- Pod 이상 감지, 인증서 만료 임박 시 Discord·Gmail 자동 알림 구현 +- 발생한 장애 14건을 모두 문서화하여 재현·해결 과정 기록 --- @@ -12,48 +26,207 @@ ``` 사용자 (외부 인터넷) - ├── https://cyanburu.com → Web Portal - ├── https://gitea.cyanburu.com → Gitea - └── https://argo.cyanburu.com → ArgoCD - ↓ -MSI 라우터 (포트포워딩 80/443) - ↓ -Nginx Ingress Controller ← TLS 종료, 도메인별 라우팅 - ↓ -cert-manager ← Let's Encrypt 인증서 자동 발급/갱신 - ↓ -Kubernetes 네임스페이스별 서비스 - ├── web-portal - │ ├── Nginx Frontend (ClusterIP: 80) - │ ├── FastAPI Backend (ClusterIP: 8000) - │ └── PostgreSQL DB (ClusterIP: 5432) - ├── gitea - │ └── Gitea (ClusterIP: 3000) - └── argocd - └── ArgoCD Server (ClusterIP: 443) - ↑ -개발자 (git push) - ↓ -Gitea → ArgoCD 자동 감지 & 배포 + ├── https://cyanburu.com → Web Portal + ├── https://gitea.cyanburu.com → Gitea (Self-hosted Git) + └── https://argo.cyanburu.com → ArgoCD (GitOps) + ↓ + MSI 라우터 (포트포워딩 80/443) + ↓ + Nginx Ingress Controller ← TLS 종료, 도메인별 라우팅 + ↓ + cert-manager ← Let's Encrypt 인증서 자동 발급/갱신 + ↓ + Kubernetes 네임스페이스 + ├── web-portal + │ ├── Nginx Frontend (SPA) + │ ├── FastAPI Backend (REST API) + │ └── PostgreSQL DB (PVC 영구 저장) + ├── gitea → Self-hosted Git + Container Registry + └── argocd → GitOps 자동 배포 엔진 + ↑ + 개발자 git push → Gitea → ArgoCD 자동 감지 & 배포 ``` --- ## 🛠️ 기술 스택 -| 구분 | 기술 | -|------|------| -| Frontend | Nginx + HTML/CSS/JS (SPA) | -| Backend | Python FastAPI | -| Database | PostgreSQL | -| Container | Docker Desktop | -| Orchestration | Kubernetes (Docker Desktop 내장) | -| GitOps | Gitea + ArgoCD | -| Image Registry | Gitea Container Registry | -| Ingress | Nginx Ingress Controller | -| TLS | cert-manager + Let's Encrypt | -| Domain | cyanburu.com (후이즈) | -| 서브도메인 | gitea.cyanburu.com, argo.cyanburu.com | +| 구분 | 기술 | 선택 이유 | +|------|------|-----------| +| **Container Orchestration** | Kubernetes (Docker Desktop 내장) | 온프레미스 환경에서 프로덕션과 동일한 구조 구현 | +| **GitOps** | Gitea + ArgoCD | Git을 단일 진실 소스로, 선언적 배포 자동화 | +| **Image Registry** | Gitea Container Registry | 외부 의존 없이 사내 레지스트리 자체 운영 | +| **Ingress** | Nginx Ingress Controller | 도메인 기반 라우팅, TLS 종료 처리 | +| **TLS** | cert-manager + Let's Encrypt | 인증서 발급·갱신 완전 자동화 | +| **Backend** | Python FastAPI | 비동기 REST API, JWT 인증 | +| **Database** | PostgreSQL + PVC | 컨테이너 재시작 후에도 데이터 영속 | +| **Monitoring** | APScheduler (커스텀) | Pod 상태 1분 주기, 인증서 만료 24시간 주기 체크 | +| **Alerting** | Discord Webhook + Gmail | 장애 발생 시 즉시 알림 | + +--- + +## ✨ 구현 기능 + +### 보안 (Security) +- JWT 기반 인증 / 세션 관리 +- **비밀번호 5회 오류 시 계정 자동 잠금** → 관리자 해제 +- **최초 로그인 강제 비밀번호 변경** (임시 비밀번호 발급 포함) +- **Nginx Rate Limiting** — 로그인 API 분당 5회 제한 (Brute Force 방어) +- HTTPS 자동 리다이렉트 (`ssl-redirect: "true"`) + +### 모니터링 & 알림 (Observability) +- Pod 상태 1분마다 자동 체크 → 이상/복구 시 **Discord + Gmail 알림** +- 인증서 만료 24시간 주기 체크 → 만료 임박 시 Gmail 알림 +- **웹 UI에서 알림 채널 관리** (Discord/Gmail/혼합 채널 다중 등록) +- DB 기반 알림 설정 → Pod 재시작 후에도 설정 유지 + +### 관리자 기능 +- 사용자 계정 생성/삭제, 접근 권한 설정 (체크박스 UI) +- 임시 비밀번호 자동 생성 및 발급 +- 공지사항 / 관리자 요청 게시판 (댓글·답글 포함) +- 사용자 상태 태그: `정상` / `🔒잠김` / `초기PW` / `변경요청` + +--- + +## 📅 구축 이력 + +| 날짜 | 주요 작업 | +|------|-----------| +| 2026-04-06 | K8s 초기 구축, FastAPI + Nginx + PostgreSQL 배포, Gitea + ArgoCD GitOps 구성 | +| 2026-04-10 | 도메인 연결 (HTTPS), cert-manager, CoreDNS 헤어핀 NAT 우회, 보안 기능 강화 | +| 2026-04-27 | Discord/Gmail 알림 추가, APScheduler 모니터링, 알림 채널 관리 UI | + +--- + +## 🔥 트러블슈팅 (14건 해결) + +실제 구축 과정에서 겪은 장애와 해결 방법을 기록합니다. + +
+1. NodePort 포트 충돌 + +**증상** `provided port is already allocated` +**원인** 해당 NodePort가 다른 서비스에서 이미 사용 중 +**해결** `k8s/05-frontend.yaml`에서 NodePort 번호 변경 후 서비스 재적용 + +
+ +
+2. Backend CrashLoopBackOff — Liveness Probe 실패 + +**증상** `Liveness probe failed: HTTP probe failed with statuscode: 404` +**원인** DB 연결 대기 중 liveness probe가 먼저 실패해 K8s가 강제 재시작 +**해결** `initialDelaySeconds: 60`, `failureThreshold: 5`로 대기 시간 증가 + +
+ +
+3. Nginx API 프록시 실패 — JSON 파싱 오류 + +**증상** 로그인 시 `Unexpected token '<', "..." is not valid JSON` +**원인** Nginx가 `/api/` 요청을 백엔드로 전달하지 못하고 HTML 반환 +**해결** `proxy_pass`를 K8s 내부 FQDN(`backend-service.web-portal.svc.cluster.local`)으로 변경 + +
+ +
+4. ArgoCD — authentication required: Unauthorized + +**증상** `failed to list refs: authentication required` +**원인** ArgoCD가 Gitea 저장소 인증 정보 없음 +**해결** `argocd.argoproj.io/secret-type=repository` 레이블이 있는 Secret 직접 생성 + +
+ +
+5. RepeatedResourceWarning — 리소스 중복 + +**증상** `Resource appeared 2 times among application resources` +**원인** `k8s/` 폴더 내 동일 리소스를 정의하는 YAML 중복 존재 +**해결** 중복 파일 삭제 후 push + +
+ +
+6. ImagePullBackOff — Gitea Registry HTTP 접근 오류 + +**증상** `server gave HTTP response to HTTPS client` +**원인** Gitea Registry가 HTTP인데 Docker가 HTTPS로 접근 시도 +**해결** Docker Desktop `insecure-registries` 설정 추가 + +
+ +
+7. Gitea Container Registry 로그인 실패 (context deadline exceeded) + +**증상** `Get "http://...:30000/v2/": context deadline exceeded` +**원인** Gitea ROOT_URL이 `localhost`로 설정되어 token 요청이 외부로 나가지 못함 +**해결** Helm upgrade로 `ROOT_URL`, `DOMAIN`, Packages 활성화 설정 변경 + +
+ +
+8. K8s 내부 Gitea Registry 이미지 Pull 실패 + +**증상** 외부에서는 push 성공, Pod는 `ImagePullBackOff` +**원인** K8s Pod → 외부 IP 경로 불안정 +**해결** image 주소를 K8s 내부 서비스명(`gitea-http.gitea.svc.cluster.local:3000`)으로 변경 + +
+ +
+9. 로그인 실패 — 비밀번호 해시 오염 + +**증상** 올바른 계정으로도 로그인 실패 +**원인** 터미널 색상 코드(`\x1B[0m`)가 bcrypt 해시에 섞여 DB에 저장됨 +**해결** Backend Pod에서 Python으로 해시 재생성 후 DB 직접 업데이트 + +
+ +
+10. git push 거절 — non-fast-forward + +**증상** `Updates were rejected because the tip of your current branch is behind` +**원인** Gitea UI에서 직접 파일 수정으로 로컬-원격 브랜치 diverge +**해결** `git pull origin main --rebase` 후 push + +
+ +
+11. cert-manager HTTP01 Challenge Pending — 헤어핀 NAT + +**증상** `propagation check failed: context deadline exceeded` +**원인** cert-manager가 K8s 내부에서 외부 도메인으로 self-check 시 헤어핀 NAT 미지원으로 타임아웃 +**해결** CoreDNS에 내부 도메인을 직접 등록 → K8s 내부에서 도메인을 내부 IP로 해석 + +
+ +
+12. Ingress Controller EXTERNAL-IP가 localhost로 표시 + +**증상** `kubectl get svc -n ingress-nginx` → EXTERNAL-IP: `localhost` +**원인** Docker Desktop 환경의 정상 동작 (`localhost` = 실제 PC) +**해결** 포트포워딩을 NodePort가 아닌 **80/443 → PC 내부 IP:80/443**으로 직접 설정 + +
+ +
+13. git commit 실패 — Author identity unknown + +**증상** `fatal: unable to auto-detect email address` +**원인** Git 사용자 정보 미설정 +**해결** `git config --global user.email`, `user.name` 설정 + +
+ +
+14. Gitea Registry ImagePullBackOff — 토큰 인증 헤어핀 NAT + +**증상** `Get "http://gitea-http...svc.cluster.local:3000/v2/token?...": context deadline exceeded` +**원인** Docker 데몬이 K8s 내부 DNS를 해석하지 못해 토큰 인증 실패 +**해결** image 주소와 Registry Secret을 외부 IP(`192.168.10.101:30000`)로 통일 + +
--- @@ -61,764 +234,92 @@ Gitea → ArgoCD 자동 감지 & 배포 ``` nginx-portal/ -├── .gitea/ -│ └── workflows/ -│ └── build-and-push.yaml # Gitea Actions CI (선택사항) ├── backend/ -│ ├── main.py # FastAPI 전체 API 로직 +│ ├── main.py # FastAPI 전체 API 로직 (인증, 사용자 관리, 게시판) +│ ├── notifier.py # Discord/Gmail 알림 모듈 (DB 기반 다중 채널) +│ ├── monitor.py # APScheduler 모니터링 (Pod 상태, 인증서 만료) │ ├── requirements.txt │ └── Dockerfile ├── frontend/ -│ ├── index.html # 싱글 페이지 앱 (SPA) -│ ├── nginx.conf # Nginx 설정 + /api/* 프록시 +│ ├── index.html # 싱글 페이지 앱 (SPA) +│ ├── nginx.conf # Nginx 설정 + Rate Limiting + /api/* 프록시 │ └── Dockerfile ├── k8s/ -│ ├── 01-namespace.yaml # web-portal 네임스페이스 -│ ├── 02-postgres.yaml # PostgreSQL + PVC + Service -│ ├── 03-secrets.yaml # DB/JWT 시크릿 -│ ├── 04-backend.yaml # FastAPI Deployment + Service -│ └── 05-frontend.yaml # Nginx Deployment + NodePort(30090) -├── 06-argocd-app.yaml # ArgoCD Application 정의 (k8s 폴더 밖에 위치) +│ ├── 01-namespace.yaml +│ ├── 02-postgres.yaml # PostgreSQL + PVC +│ ├── 03-secrets.yaml # DB / JWT 시크릿 +│ ├── 04-backend.yaml # FastAPI Deployment +│ ├── 05-frontend.yaml # Nginx Deployment +│ ├── 07-clusterissuer.yaml # Let's Encrypt ClusterIssuer +│ ├── 08-ingress.yaml # cyanburu.com +│ ├── 09-ingress-gitea.yaml +│ └── 10-ingress-argocd.yaml +├── 06-argocd-app.yaml # ArgoCD Application (k8s/ 폴더 밖 — 순환 참조 방지) └── README.md - -k8s/ 폴더 내 추가 파일: -├── 07-clusterissuer.yaml # Let's Encrypt ClusterIssuer -├── 08-ingress.yaml # Web Portal Ingress (cyanburu.com) -├── 09-ingress-gitea.yaml # Gitea Ingress (gitea.cyanburu.com) -└── 10-ingress-argocd.yaml # ArgoCD Ingress (argo.cyanburu.com) ``` -> ⚠️ `06-argocd-app.yaml` 은 반드시 `k8s/` 폴더 **밖**에 위치해야 합니다. -> ArgoCD가 `k8s/` 폴더를 감시하므로 해당 파일이 안에 있으면 순환 참조 문제가 발생합니다. - --- -## 🔑 기본 계정 +## 🚀 배포 방법 -| 구분 | ID | Password | -|------|-----|----------| -| 관리자 | `admin` | `admin1234` | -| 일반사용자 | `user1` | `user1234` | +### 사전 요구 사항 +- Docker Desktop (Kubernetes 활성화) +- kubectl, helm 설치 +- 공인 도메인 (A 레코드 → 공인 IP 연결) +- 라우터 포트포워딩 설정 (80, 443) ---- +### 핵심 배포 순서 -## ✨ 기능 설명 - -### 일반 사용자 -- 로그인 후 **MY Page List** 에서 본인에게 할당된 웹페이지를 카드 형태로 확인 -- 카드의 Favicon 자동 표시 (없을 경우 기본 아이콘) -- 카드 클릭 시 **새 탭에서 해당 URL로 이동** -- **공지사항** 탭에서 관리자가 등록한 공지 확인 및 댓글 작성 -- **관리자 요청** 탭에서 게시글 작성 및 답글 작성 -- 로그인 비밀번호 표시/숨김 토글 버튼 -- 로그인 실패 시 아이디 유지 (비밀번호만 초기화) -- **비밀번호 변경** 메뉴 (헤더에서 언제든 변경 가능) -- **최초 로그인 시 비밀번호 강제 변경** (변경 전까지 서비스 이용 불가) -- **비밀번호 5회 오류 시 계정 자동 잠금** → 관리자에게 잠금 해제 요청 필요 - -### 관리자 -일반 사용자 기능 + 추가: -- **페이지 관리**: 웹페이지 추가 / 수정 / 삭제 -- **사용자 관리**: 계정 생성 / 삭제 -- **권한 설정**: 사용자별 접근 가능 페이지를 체크박스로 지정 -- **비밀번호 변경**: 특정 사용자의 비밀번호 직접 변경 (변경 후 해당 사용자 강제 변경 적용) -- **임시 비밀번호 발급**: 랜덤 임시 비밀번호 자동 생성 후 화면에 표시 -- **계정 잠금 해제**: 잠긴 계정을 버튼 하나로 해제 -- **사용자 상태 확인**: 정상 / 🔒잠김 / 초기PW / 변경요청 태그로 한눈에 확인 -- **공지사항 작성**: 공지 탭에서 전체 사용자에게 공지 등록 / 삭제 - -### 게시판 (공지 / 관리자 요청) - -| 구분 | 공지 | 관리자 요청 | -|------|------|------------| -| 글 작성 | 관리자만 가능 | 모든 사용자 가능 | -| 댓글/답글 | 모든 사용자 가능 | 모든 사용자 가능 | -| 글 삭제 | 관리자만 가능 | 본인 또는 관리자 | -| 댓글 삭제 | 본인 또는 관리자 | 본인 또는 관리자 | -| 목록 항목 | 번호, 제목, 작성자, 작성일(시간포함) | 번호, 제목, 작성자, 작성일(시간포함) | - ---- - -## 🚀 최초 배포 순서 - -### 1단계. Docker Desktop insecure-registry 설정 -Gitea Registry가 HTTP이므로 Docker Desktop에서 허용 설정 필요. - -Docker Desktop → Settings → Docker Engine: -```json -{ - "builder": { - "gc": { - "defaultKeepStorage": "20GB", - "enabled": true - } - }, - "experimental": false, - "insecure-registries": ["192.168.10.101:30000"] -} -``` -**Apply & Restart** 클릭 - -### 2단계. Gitea Registry 로그인 및 이미지 빌드 & Push ```bash -# Registry 로그인 -docker login 192.168.10.101:30000 -u +# 1. Nginx Ingress Controller +kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.0/deploy/static/provider/cloud/deploy.yaml -# 백엔드 이미지 빌드 & Push -docker build -t 192.168.10.101:30000/<계정>/portal-backend:latest ./backend/ -docker push 192.168.10.101:30000/<계정>/portal-backend:latest +# 2. cert-manager +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.4/cert-manager.yaml -# 프론트엔드 이미지 빌드 & Push -docker build -t 192.168.10.101:30000/<계정>/portal-frontend:latest ./frontend/ -docker push 192.168.10.101:30000/<계정>/portal-frontend:latest -``` +# 3. CoreDNS 헤어핀 NAT 우회 설정 +kubectl patch configmap coredns -n kube-system --patch-file coredns-patch.yaml +kubectl rollout restart deployment/coredns -n kube-system -### 3단계. K8s Registry Secret 생성 -```bash -kubectl create namespace web-portal +# 4. 이미지 빌드 & Push +docker build -t /portal-backend:latest ./backend/ +docker build -t /portal-frontend:latest ./frontend/ +docker push /portal-backend:latest +docker push /portal-frontend:latest -kubectl create secret docker-registry gitea-registry-secret \ - --namespace=web-portal \ - --docker-server=gitea-http.gitea.svc.cluster.local:3000 \ - --docker-username= \ - --docker-password= -``` +# 5. K8s 리소스 배포 +kubectl apply -f k8s/ -### 4단계. ArgoCD에 Gitea 저장소 인증 등록 -```bash -kubectl create secret generic gitea-repo-secret \ - --namespace=argocd \ - --from-literal=type=git \ - --from-literal=url=http://192.168.10.101:30000/<계정>/nginx-portal.git \ - --from-literal=username= \ - --from-literal=password= - -kubectl label secret gitea-repo-secret \ - -n argocd \ - argocd.argoproj.io/secret-type=repository -``` - -### 5단계. ArgoCD Application 등록 -```bash +# 6. ArgoCD Application 등록 kubectl apply -f 06-argocd-app.yaml ``` -### 6단계. Nginx Ingress Controller 설치 (최초 1회) -```bash -kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.0/deploy/static/provider/cloud/deploy.yaml -kubectl get pods -n ingress-nginx -``` - -### 7단계. cert-manager 설치 (최초 1회) -```bash -kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.4/cert-manager.yaml -kubectl get pods -n cert-manager -``` - -### 8단계. CoreDNS 내부 도메인 등록 (헤어핀 NAT 우회) -```bash -kubectl patch configmap coredns -n kube-system --patch-file coredns-patch.yaml -kubectl rollout restart deployment/coredns -n kube-system -``` - -> `coredns-patch.yaml` 내용: -> ```yaml -> data: -> Corefile: | -> cyanburu.com { -> hosts { -> 192.168.10.101 cyanburu.com -> fallthrough -> } -> cache 30 -> } -> .:53 { -> ... (기존 내용 유지) -> } -> ``` - -### 9단계. 라우터 포트포워딩 설정 -MSI 라우터에서 설정: - -| 공용 포트 | 내부 IP | 비공개 포트 | -|-----------|---------|-------------| -| 80 | 192.168.10.101 | 80 | -| 443 | 192.168.10.101 | 443 | - -### 10단계. Ingress + ClusterIssuer 배포 -```bash -git add k8s/07-clusterissuer.yaml k8s/08-ingress.yaml k8s/05-frontend.yaml -git commit -m "feat: Ingress + cert-manager HTTPS 설정" -git push origin main -# ArgoCD가 자동 배포 -``` - -### 11단계. 인증서 발급 확인 -```bash -kubectl get certificate -n web-portal -# READY: True 확인 -``` - -### 12단계. 서브도메인 Ingress 적용 (Gitea, ArgoCD) -```bash -kubectl apply -f 09-ingress-gitea.yaml -kubectl apply -f 10-ingress-argocd.yaml - -# 인증서 발급 확인 -kubectl get certificate -n gitea -kubectl get certificate -n argocd -``` - -### 13단계. 접속 확인 - -| 서비스 | URL | -|--------|-----| -| Web Portal | `https://cyanburu.com` | -| Gitea | `https://gitea.cyanburu.com` | -| ArgoCD | `https://argo.cyanburu.com` | +자세한 배포 가이드는 [배포 문서](docs/deployment.md)를 참고하세요. --- -## 🔄 이후 배포 방법 (코드 수정 시) +## 📊 배운 점 / 핵심 인사이트 -### yaml만 변경한 경우 (설정 변경) -```bash -git add . -git commit -m "fix: 변경내용" -git push origin main -# → ArgoCD가 자동으로 감지해서 재배포 -``` - -### 이미지도 변경한 경우 (코드 변경) -```bash -# 이미지 재빌드 & Push -docker build -t 192.168.10.101:30000/<계정>/portal-backend:latest ./backend/ -docker push 192.168.10.101:30000/<계정>/portal-backend:latest - -# Pod 재시작 -kubectl rollout restart deployment/backend -n web-portal - -# yaml도 변경했다면 -git add . -git commit -m "feat: 변경내용" -git push origin main -``` +| 문제 유형 | 배운 점 | +|-----------|---------| +| **헤어핀 NAT** | K8s 내부에서 외부 도메인으로 self-check 시 발생하는 네트워크 루프 → CoreDNS 내부 오버라이드로 해결 | +| **Docker 데몬 vs kubelet DNS** | kubelet은 K8s 내부 DNS 사용 가능, Docker 데몬은 호스트 DNS만 사용 → 레지스트리 주소 통일 필요 | +| **GitOps 순환 참조** | ArgoCD Application YAML을 감시 대상 폴더 안에 넣으면 무한 루프 → 폴더 밖 별도 관리 | +| **컨테이너 시작 순서** | DB 준비 전 백엔드 probe 실행 → `initialDelaySeconds`로 의존성 순서 제어 | +| **bcrypt 해시 오염** | 터미널 ANSI 색상 코드가 해시에 섞이는 엣지 케이스 → 출력값 직접 검증 필요 | --- -## 🔧 운영 명령어 +## 🔜 개선 예정 (Next Steps) -```bash -# 전체 리소스 상태 확인 -kubectl get all -n web-portal - -# 백엔드 로그 확인 -kubectl logs -n web-portal deployment/backend -f - -# 프론트엔드 로그 확인 -kubectl logs -n web-portal deployment/frontend -f - -# Pod 재시작 -kubectl rollout restart deployment/backend -n web-portal -kubectl rollout restart deployment/frontend -n web-portal - -# 전체 삭제 -kubectl delete namespace web-portal -``` +- [ ] GitHub Actions CI/CD 파이프라인 구성 (자동 빌드 → Push → ArgoCD sync) +- [ ] Helm Chart로 패키징 (values.yaml 환경별 분리) +- [ ] Prometheus + Grafana 모니터링 스택 도입 +- [ ] Terraform으로 Azure 인프라 IaC 관리 +- [ ] Trivy 이미지 취약점 스캔 → GitHub Actions 통합 --- -## 🔐 운영 시 보안 설정 +## 📄 라이선스 -### K8s Secret 변경 -`k8s/03-secrets.yaml` 에서 반드시 변경: -```yaml -stringData: - db-password: "강력한패스워드로변경" - jwt-secret: "64자이상의랜덤문자열로변경" -``` - -### Nginx Rate Limiting (Brute Force 방어) -`frontend/nginx.conf` 상단에 추가: -```nginx -limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m; -``` -로그인 location에 적용: -```nginx -location /api/auth/login { - limit_req zone=login_limit burst=3 nodelay; - limit_req_status 429; - proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/auth/login; - ... -} -``` -같은 IP에서 분당 5회 초과 시 429 응답 반환. 계정 잠금(5회 실패)과 함께 이중으로 Brute Force를 방어합니다. - -### 추가 보안 권고사항 -- **Fail2ban**: Nginx 로그 감시 후 반복 실패 IP를 방화벽으로 자동 차단 (온프레미스 환경 권장) -- **CAPTCHA**: 로그인 3회 실패 시 Google reCAPTCHA 표시 (추가 개발 필요) -- **JWT 만료 시간 단축**: `main.py` 에서 `timedelta(hours=8)` → `timedelta(hours=2)` 변경 가능 - -### HTTPS / TLS -- cert-manager가 Let's Encrypt 인증서를 **자동으로 갱신** (만료 30일 전) -- HTTP 접속 시 자동으로 HTTPS로 리다이렉트 (`ssl-redirect: "true"`) -- 인증서 상태 확인: -```bash -kubectl get certificate -n web-portal # cyanburu.com -kubectl get certificate -n gitea # gitea.cyanburu.com -kubectl get certificate -n argocd # argo.cyanburu.com -``` - ---- - -## ❗ 트러블슈팅 - -### 1. NodePort 포트 충돌 -**증상** -``` -Service "frontend-service" is invalid: spec.ports[0].nodePort: -Invalid value: 30080: provided port is already allocated -``` -**원인** 해당 NodePort가 다른 서비스에서 이미 사용 중. - -**해결** -`k8s/05-frontend.yaml` 에서 nodePort를 다른 번호로 변경 (30000~32767 범위): -```yaml -nodePort: 30090 -``` -변경 후 기존 서비스 삭제 및 재적용: -```bash -kubectl delete service frontend-service -n web-portal -kubectl apply -f k8s/05-frontend.yaml -``` - ---- - -### 2. Backend Liveness Probe 실패로 인한 CrashLoopBackOff -**증상** -``` -Liveness probe failed: HTTP probe failed with statuscode: 404 -Back-off restarting failed container -``` -**원인** Backend가 DB 연결을 기다리는 동안 liveness probe가 먼저 실패해서 K8s가 강제 재시작. - -**해결** -`k8s/04-backend.yaml` 의 probe 대기 시간 증가: -```yaml -readinessProbe: - initialDelaySeconds: 20 - periodSeconds: 5 - failureThreshold: 6 -livenessProbe: - initialDelaySeconds: 60 - periodSeconds: 15 - failureThreshold: 5 -``` - ---- - -### 3. Nginx가 API 요청을 백엔드로 프록시 못함 -**증상** 로그인 시 `Unexpected token '<', "..." is not valid JSON` 에러. - -**원인** Nginx가 `/api/` 요청을 백엔드로 전달하지 못하고 HTML을 반환. - -**해결** -`frontend/nginx.conf` 에서 백엔드 주소를 FQDN으로 변경: -```nginx -location /api/ { - proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/; -} -``` - ---- - -### 4. ArgoCD - authentication required: Unauthorized -**증상** -``` -failed to list refs: authentication required: Unauthorized -``` -**원인** ArgoCD가 Gitea 저장소에 접근할 인증 정보가 없음. - -**해결** -kubectl로 직접 인증 Secret 생성: -```bash -kubectl create secret generic gitea-repo-secret \ - --namespace=argocd \ - --from-literal=type=git \ - --from-literal=url=http://192.168.10.101:30000/<계정>/nginx-portal.git \ - --from-literal=username=<계정> \ - --from-literal=password=<패스워드> - -kubectl label secret gitea-repo-secret \ - -n argocd \ - argocd.argoproj.io/secret-type=repository -``` - ---- - -### 5. RepeatedResourceWarning - 리소스 중복 -**증상** -``` -Resource apps/Deployment/web-portal/backend appeared 2 times among application resources -``` -**원인** `k8s/` 폴더 안에 동일한 리소스를 정의하는 yaml 파일이 중복 존재 (`portal.yaml` 등). - -**해결** -중복 파일 삭제 후 push: -```bash -rm k8s/portal.yaml -git add . -git commit -m "fix: 중복 yaml 파일 제거" -git push origin main -``` - ---- - -### 6. ImagePullBackOff - Gitea Registry HTTP 접근 오류 -**증상** -``` -Failed to pull image: server gave HTTP response to HTTPS client -``` -**원인** Gitea Registry가 HTTP인데 Docker가 HTTPS로 접근 시도. - -**해결** -Docker Desktop → Settings → Docker Engine에 insecure-registry 추가: -```json -{ - "insecure-registries": ["192.168.10.101:30000"] -} -``` -Apply & Restart 후 이미지 재빌드 & Push. - ---- - -### 7. Gitea Container Registry 로그인 실패 (context deadline exceeded) -**증상** -``` -Error response from daemon: Get "http://192.168.10.101:30000/v2/": -context deadline exceeded -``` -**원인** Gitea의 ROOT_URL이 `localhost` 로 설정되어 있어 token 요청이 외부로 나가지 못함. -또한 Packages(Container Registry) 기능이 비활성화된 상태. - -**해결** -Helm upgrade로 Gitea 설정 영구 변경: -```bash -helm repo add gitea https://dl.gitea.com/charts/ -helm repo update - -helm upgrade gitea gitea/gitea -n gitea \ - --set gitea.config.server.DOMAIN=192.168.10.101 \ - --set gitea.config.server.ROOT_URL=http://192.168.10.101:30000 \ - --set gitea.config.server.HTTP_PORT=3000 \ - --set gitea.config.packages.ENABLED=true \ - --set service.http.type=NodePort \ - --set service.http.nodePort=30000 \ - --reuse-values -``` - ---- - -### 8. K8s 내부에서 Gitea Registry 이미지 Pull 실패 -**증상** Pod가 `ImagePullBackOff` 상태. 외부에서는 push가 되지만 K8s Pod는 이미지를 못 가져옴. - -**원인** K8s Pod는 외부 IP(`192.168.10.101:30000`)로 접근이 불안정하므로 -내부 서비스명으로 접근해야 함. - -**해결** -yaml의 image 주소를 K8s 내부 서비스명으로 변경: -```bash -sed -i "s|192.168.10.101:30000|gitea-http.gitea.svc.cluster.local:3000|g" k8s/04-backend.yaml -sed -i "s|192.168.10.101:30000|gitea-http.gitea.svc.cluster.local:3000|g" k8s/05-frontend.yaml -``` -Registry Secret도 내부 주소로 재생성: -```bash -kubectl delete secret gitea-registry-secret -n web-portal - -kubectl create secret docker-registry gitea-registry-secret \ - --namespace=web-portal \ - --docker-server=gitea-http.gitea.svc.cluster.local:3000 \ - --docker-username=<계정> \ - --docker-password=<패스워드> -``` - ---- - -### 9. 로그인 실패 - 비밀번호 해시 오염 -**증상** 올바른 계정으로 로그인해도 `아이디 또는 비밀번호가 올바르지 않습니다` 출력. - -**원인** 터미널 색상 코드(`\x1B[0m` 등)가 비밀번호 해시에 섞여 DB에 저장됨. - -**해결** -Backend Pod에서 직접 Python으로 해시 재생성 후 DB 업데이트: -```bash -kubectl exec -n web-portal deployment/backend -- python3 -c " -import bcrypt, psycopg2 -conn = psycopg2.connect(host='postgres-service', database='portaldb', user='portaluser', password='portalpass') -cur = conn.cursor() -h1 = bcrypt.hashpw('admin1234'.encode(), bcrypt.gensalt()).decode() -h2 = bcrypt.hashpw('user1234'.encode(), bcrypt.gensalt()).decode() -cur.execute('UPDATE users SET password_hash=%s WHERE username=%s', (h1, 'admin')) -cur.execute('UPDATE users SET password_hash=%s WHERE username=%s', (h2, 'user1')) -conn.commit() -print('완료') -" -``` - ---- - -### 10. git push 거절 (non-fast-forward) -**증상** -``` -error: failed to push some refs -hint: Updates were rejected because the tip of your current branch is behind -``` -**원인** Gitea UI에서 직접 파일을 수정해서 로컬과 원격 브랜치가 diverge된 상태. - -**해결** -```bash -git pull origin main --rebase -git push origin main -``` - ---- - -### 11. cert-manager HTTP01 Challenge pending (헤어핀 NAT) -**증상** -``` -propagation check failed: failed to perform self check GET request -context deadline exceeded (Client.Timeout exceeded while awaiting headers) -``` -**원인** cert-manager가 K8s 내부에서 외부 도메인(`cyanburu.com`)으로 self-check 요청을 보낼 때, -공인 IP → 라우터 → 내부 PC로 돌아오는 헤어핀 NAT이 지원되지 않아 타임아웃 발생. - -**해결** -CoreDNS에 내부 도메인을 직접 등록해서 K8s 내부에서 도메인을 내부 IP로 해석하게 설정: -```bash -kubectl patch configmap coredns -n kube-system --patch-file coredns-patch.yaml -kubectl rollout restart deployment/coredns -n kube-system -``` - ---- - -### 12. Ingress Controller EXTERNAL-IP가 localhost로 표시 -**증상** `kubectl get svc -n ingress-nginx` 에서 EXTERNAL-IP가 `localhost` 로 표시됨. - -**원인** Docker Desktop 환경의 정상적인 동작. `localhost` = 실제 PC를 의미. - -**해결** 포트포워딩을 NodePort(30118, 30963)가 아닌 **80, 443 → PC내부IP:80, 443** 으로 설정. -Docker Desktop이 80/443을 받아서 Ingress Controller로 자동 전달. - ---- - -### 13. git commit 시 Author identity unknown -**증상** -``` -Author identity unknown -fatal: unable to auto-detect email address -``` -**원인** Git 사용자 정보가 설정되지 않은 상태. - -**해결** -```bash -git config --global user.email "계정@gitea.com" -git config --global user.name "계정명" -``` - ---- - -### 14. Gitea Registry ImagePullBackOff — 토큰 인증 헤어핀 NAT 문제 -**증상** -``` -Failed to pull image: Error response from daemon: -Get "http://gitea-http.gitea.svc.cluster.local:3000/v2/token?...": context deadline exceeded -``` -**원인** Docker Desktop 환경에서 kubelet이 이미지를 Pull할 때 Docker 데몬을 통해 수행하는데, -Docker 데몬은 K8s 내부 DNS(`gitea-http.gitea.svc.cluster.local`)를 해석하지 못함. -Gitea Registry가 토큰 인증을 내부 서비스명으로 리다이렉트하면 Docker 데몬이 접근 불가 → timeout 발생. - -**해결** -이미지 주소와 Registry Secret을 외부 IP로 통일: -```bash -# yaml 이미지 주소 변경 -sed -i "s|gitea-http.gitea.svc.cluster.local:3000|192.168.10.101:30000|g" k8s/04-backend.yaml -sed -i "s|gitea-http.gitea.svc.cluster.local:3000|192.168.10.101:30000|g" k8s/05-frontend.yaml - -# Registry Secret 재생성 (외부 IP 기준) -kubectl delete secret gitea-registry-secret -n web-portal -kubectl create secret docker-registry gitea-registry-secret \ - --namespace=web-portal \ - --docker-server=192.168.10.101:30000 \ - --docker-username=<계정> \ - --docker-password=<패스워드> - -# 적용 -kubectl apply -f k8s/04-backend.yaml -kubectl apply -f k8s/05-frontend.yaml -kubectl rollout restart deployment/backend -n web-portal -kubectl rollout restart deployment/frontend -n web-portal -``` - -> ⚠️ Gitea ROOT_URL을 내부 서비스명으로 변경해도 Docker 데몬은 K8s 내부 DNS를 사용할 수 없으므로 -> Docker Desktop 환경에서는 반드시 외부 IP(`192.168.10.101:30000`)를 사용해야 함. - ---- - -### 15. 모니터링 알림 환경변수 미적용 (notifier skipping) -**증상** -``` -[NOTIFIER] Discord webhook URL not set, skipping -[NOTIFIER] Gmail config not set, skipping -``` -**원인** `kubectl set image` 명령어로 이미지를 변경하면 deployment의 환경변수 설정이 초기화됨. -또는 Secret 이름이 yaml과 다르게 생성된 경우. - -**해결** -yaml의 Secret 이름(`notify-secrets`)과 key 이름을 확인 후 동일하게 Secret 재생성: -```bash -kubectl delete secret notify-secrets -n web-portal -kubectl create secret generic notify-secrets \ - --namespace=web-portal \ - --from-literal=discord-webhook-url="https://discord.com/api/webhooks/..." \ - --from-literal=gmail-user="발송계정@gmail.com" \ - --from-literal=gmail-app-password="xxxx xxxx xxxx xxxx" \ - --from-literal=alert-email-to="수신계정@gmail.com" - -kubectl rollout restart deployment/backend -n web-portal -``` -yaml 변경 후에는 반드시 `kubectl apply -f k8s/04-backend.yaml` 로 재적용할 것. - ---- - -## 📅 변경 이력 - -### 2026-04-06 (초기 구축) -- Kubernetes 환경 구성 (Docker Desktop) -- FastAPI 백엔드 + Nginx 프론트엔드 + PostgreSQL 배포 -- Gitea + ArgoCD GitOps 파이프라인 구성 -- Gitea Container Registry 연동 - -### 2026-04-10 (기능 추가 + 도메인 연결) -#### 기능 추가 -- **MY Page**: 탭명/목록 제목 영문 변경, URL 미표기, Favicon 자동 표시 -- **비밀번호 보안 강화** - - 로그인 비밀번호 표시/숨김 토글 버튼 - - 로그인 실패 시 아이디 유지 (비밀번호만 초기화) - - 최초 로그인 시 비밀번호 강제 변경 - - 비밀번호 5회 오류 시 계정 자동 잠금 - - 관리자의 사용자 비밀번호 변경 / 임시 비밀번호 발급 / 잠금 해제 -- **공지사항 탭**: 관리자 작성 전용, 모든 사용자 댓글 가능 -- **관리자 요청 탭**: 게시판 형태, 모든 사용자 작성/답글 가능 -- **Nginx Rate Limiting**: 로그인 API 분당 5회 제한 (Brute Force 방어) - -#### 도메인 연결 (HTTPS) -- **Nginx Ingress Controller** 설치 및 구성 -- **cert-manager** 설치 + Let's Encrypt 인증서 자동 발급 -- **cyanburu.com** 도메인 연결 (후이즈) -- **MSI 라우터** 포트포워딩 설정 (80/443) -- **CoreDNS** 내부 도메인 등록 (헤어핀 NAT 우회) -- **HTTPS 자동 리다이렉트** 적용 -- 최종 접속 URL: `https://cyanburu.com` - -#### 서브도메인 연결 -- **gitea.cyanburu.com** → Gitea (Let's Encrypt 인증서 자동 발급) -- **argo.cyanburu.com** → ArgoCD (Let's Encrypt 인증서 자동 발급) -- CoreDNS에 서브도메인 내부 IP 등록 (헤어핀 NAT 우회) - -### 2026-04-27 (모니터링 알림 추가 + 장애 복구) - -#### 기능 추가 -- **Discord 알림**: Pod 이상/복구, 계정 잠금, 임시 비밀번호 발급 시 Discord Webhook 알림 -- **Gmail 알림**: Pod 이상/복구, 인증서 만료 임박 시 이메일 알림 -- **자동 모니터링 스케줄러**: Pod 상태 1분마다 체크, 인증서 만료 24시간마다 체크 -- `notifier.py` — Discord/Gmail 알림 모듈 추가 -- `monitor.py` — APScheduler 기반 모니터링 모듈 추가 -- `main.py` — `asyncio.create_task()` → `await` 방식으로 수정 (동기함수 내 비동기 호출 오류 수정) - -#### 알림 설정 방법 -**1. Discord Webhook URL 발급** -Discord 서버 → 알림받을 채널 → ⚙️ 채널 설정 → 연동 → 웹후크 → 새 웹후크 생성 → URL 복사 - -**2. Gmail 앱 비밀번호 발급** -- Google 계정 → 보안 → 2단계 인증 활성화 (필수) -- `https://myaccount.google.com/apppasswords` → 앱 이름 입력 → 16자리 비밀번호 복사 - -**3. K8s Secret 등록** -```bash -kubectl create secret generic notify-secrets \ - --namespace=web-portal \ - --from-literal=discord-webhook-url="https://discord.com/api/webhooks/..." \ - --from-literal=gmail-user="발송계정@gmail.com" \ - --from-literal=gmail-app-password="xxxx xxxx xxxx xxxx" \ - --from-literal=alert-email-to="수신계정@gmail.com" -``` - -**4. 알림 테스트** -브라우저 콘솔(F12)에서 실행: -```javascript -fetch('/api/admin/notify-test', { - headers: { 'Authorization': 'Bearer ' + localStorage.getItem('portal_token') } -}).then(r => r.json()).then(console.log) -``` - -#### 장애 복구 내용 -- **backend ImagePullBackOff** — Gitea Registry 토큰 인증 문제로 발생, 외부 IP 방식으로 해결 -- **backend/frontend 이미지 주소** — 내부 서비스명(`gitea-http.gitea.svc.cluster.local:3000`) → 외부 IP(`192.168.10.101:30000`)로 변경 -- **notifier.py, monitor.py 누락** — Dockerfile에 파일이 있었으나 `--no-cache` 재빌드로 해결 -- **notify-secrets** — 기존 Secret이 잘못된 값으로 등록되어 있어 재생성 - -### 2026-04-27 (알림 채널 관리 UI 추가) - -#### 기능 추가 -- **🔔 알림 채널 관리 페이지** — 관리자 탭에 추가, 웹에서 직접 알림 채널 추가/수정/삭제 가능 -- **다중 채널 지원** — Discord 전용 / Gmail 전용 / Discord+Gmail 혼합 채널을 복수로 등록 가능 -- **채널 유형별 발송 (B방식)** — 채널 유형과 알림 종류의 교집합으로 발송 대상 자동 결정 -- **DB 기반 알림 설정** — `notify_channels` 테이블에 저장, Pod 재시작 후에도 설정 유지 -- **환경변수 fallback** — DB 채널 미등록 시 K8s Secret 환경변수로 자동 대체 -- **notifier.py 전면 개선** — 매 발송 시 DB에서 활성 채널 목록 직접 조회 후 발송 - -#### 채널 유형별 발송 규칙 - -| 채널 유형 | notify_both (Pod 이상/복구) | notify_discord_only (계정잠금/임시PW) | notify_email_only (인증서만료) | -|---------|--------------------------|--------------------------------------|-------------------------------| -| Discord+Gmail | Discord + Gmail | Discord만 | Gmail만 | -| Discord 전용 | Discord만 | Discord만 | 발송 안 함 | -| Gmail 전용 | Gmail만 | 발송 안 함 | Gmail만 | - -#### 알림 채널 등록 방법 (웹 UI) -1. 관리자 로그인 → **🔔 알림 설정** 탭 -2. 좌측 폼에서 채널 이름, 유형, Webhook URL / Gmail 정보 입력 -3. **💾 저장** 클릭 → 우측 목록에 추가됨 -4. 목록에서 ✏️ 클릭 → 폼에 값 자동 입력 후 수정/저장 -5. 🗑️ 클릭 → 채널 삭제 -6. **📨 테스트 발송** 버튼으로 등록된 모든 채널에 테스트 알림 발송 - -#### DB 테이블 수동 생성 (신규 배포 전 필요 시) -```bash -kubectl exec -n web-portal deployment/backend -- python3 -c " -import psycopg2 -conn = psycopg2.connect(host='postgres-service', database='portaldb', user='portaluser', password='portalpass') -cur = conn.cursor() -cur.execute('''CREATE TABLE IF NOT EXISTS notify_channels ( - id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, type VARCHAR(20) NOT NULL, - discord_webhook_url TEXT DEFAULT chr(0), gmail_user VARCHAR(200) DEFAULT chr(0), - gmail_app_password TEXT DEFAULT chr(0), alert_email_to VARCHAR(200) DEFAULT chr(0), - enabled BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT NOW() -)''') -conn.commit() -print('완료') -" -``` - -> ℹ️ 새 `main.py` 배포 후에는 startup 시 자동으로 테이블이 생성되므로 수동 생성 불필요. - -#### 프로젝트 구조 변경 -``` -backend/ -├── main.py ← 알림 채널 CRUD API 추가, notify_channels 테이블 자동 생성 -├── notifier.py ← DB 기반 다중 채널 발송으로 전면 재작성 -└── monitor.py ← 변경 없음 (notifier 인터페이스 동일) - -frontend/ -└── index.html ← 🔔 알림 설정 탭 추가 (좌측 폼 + 우측 리스트 UI) -``` +MIT License