From 035eb0259ffdafd37a319e5658d43e816b0a681d Mon Sep 17 00:00:00 2001 From: chpark Date: Wed, 22 Apr 2026 23:59:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Kubernetes=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: Expo web export → nginx multi-stage 빌드 - nginx.conf: SPA fallback, gzip, health endpoint - K8s manifests: namespace, deployment (2 replicas), service, ingress - Traefik IngressRoute (선택적) 포함 - Gitea Actions workflow: push 시 빌드→Gitea Registry push→rollout restart - DEPLOY.md: 초기 설정 가이드 (kubeconfig, secrets, DNS) Domain: insurance.junggomoa.com Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 14 ++ .gitea/workflows/deploy.yml | 85 ++++++++++++ DEPLOY.md | 191 +++++++++++++++++++++++++++ Dockerfile | 20 +++ deploy/k8s/deployment.yaml | 61 +++++++++ deploy/k8s/ingress.yaml | 25 ++++ deploy/k8s/ingressroute-traefik.yaml | 43 ++++++ deploy/k8s/namespace.yaml | 7 + deploy/k8s/service.yaml | 16 +++ deploy/nginx.conf | 44 ++++++ 10 files changed, 506 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/deploy.yml create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 deploy/k8s/deployment.yaml create mode 100644 deploy/k8s/ingress.yaml create mode 100644 deploy/k8s/ingressroute-traefik.yaml create mode 100644 deploy/k8s/namespace.yaml create mode 100644 deploy/k8s/service.yaml create mode 100644 deploy/nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..59f06c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +.expo +.expo-shared +dist +web-build +.git +.gitea +.vscode +.idea +*.log +.DS_Store +.env* +README.md +DEPLOY.md diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..ea5826b --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,85 @@ +name: Build & Deploy + +on: + push: + branches: + - master + - main + workflow_dispatch: + +env: + REGISTRY: git.junggomoa.com + IMAGE_NAME: chpark/insurance + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set short SHA + run: echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.SHORT_SHA }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + + - name: Set up kubectl + uses: azure/setup-kubectl@v4 + with: + version: "v1.29.0" + + - name: Configure kubeconfig + run: | + mkdir -p $HOME/.kube + echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config + chmod 600 $HOME/.kube/config + + - name: Ensure namespace & registry secret + run: | + kubectl apply -f deploy/k8s/namespace.yaml + kubectl -n insurance create secret docker-registry gitea-registry \ + --docker-server=${{ env.REGISTRY }} \ + --docker-username=${{ secrets.REGISTRY_USER }} \ + --docker-password=${{ secrets.REGISTRY_TOKEN }} \ + --dry-run=client -o yaml | kubectl apply -f - + + - name: Apply manifests + run: | + kubectl apply -f deploy/k8s/deployment.yaml + kubectl apply -f deploy/k8s/service.yaml + if [ "${{ secrets.INGRESS_MODE }}" = "ingressroute" ]; then + kubectl apply -f deploy/k8s/ingressroute-traefik.yaml + else + kubectl apply -f deploy/k8s/ingress.yaml + fi + + - name: Update deployment image & restart + run: | + kubectl -n insurance set image deployment/insurance-web \ + web=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.SHORT_SHA }} + kubectl -n insurance rollout status deployment/insurance-web --timeout=180s + + - name: Show deployment info + run: | + kubectl -n insurance get deployment,svc,ingress + echo "" + echo "🚀 Deployed: https://insurance.junggomoa.com" diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..6b9f41d --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,191 @@ +# 🚀 배포 가이드 + +## 자동 배포 파이프라인 + +``` +git push → Gitea Actions → Docker 빌드 → Container Registry → Kubernetes rollout + ↓ + insurance.junggomoa.com +``` + +--- + +## 📋 한 번만 해야 하는 초기 설정 + +### 1단계 — Gitea 측 설정 (웹 UI) + +#### ① Gitea Actions Runner 활성화 +1. [https://git.junggomoa.com/chpark/insurance](https://git.junggomoa.com/chpark/insurance) → **Settings** +2. 좌측 **Actions** 메뉴 → **Enable Actions** 체크 +3. 조직/인스턴스 관리자가 **Runner**를 하나 등록해둬야 합니다 + - Runner 없다면 관리자에게 요청 + - 또는 Kubernetes에 `act-runner` Helm 차트로 직접 설치 (아래 스크립트 참고) + +#### ② Container Registry 접근 토큰 발급 +1. Gitea → 우측 상단 프로필 → **Settings** → **Applications** +2. **Generate New Token** → 권한 `write:package`, `read:package` 체크 +3. 발급된 토큰 복사 (한 번만 보임) + +#### ③ Repository Secrets 등록 +Repo → **Settings** → **Secrets and Variables** → **Actions** → **Add Secret**: + +| Name | Value | 설명 | +|---|---|---| +| `REGISTRY_USER` | `chpark` | Gitea 사용자명 | +| `REGISTRY_TOKEN` | (위에서 발급한 토큰) | Container Registry 인증 | +| `KUBE_CONFIG` | (아래 2단계에서 생성) | base64 인코딩된 kubeconfig | +| `INGRESS_MODE` | `ingress` 또는 `ingressroute` | Traefik 설치 방식 (아래 확인) | + +--- + +### 2단계 — Kubernetes 측 설정 (서버 SSH 1회) + +SSH 접속: +```bash +ssh chpark@183.99.177.40 +``` + +#### ① Traefik 설치 방식 확인 +```bash +kubectl api-resources | grep -i traefik +``` +- 결과에 `ingressroutes.traefik.io` 나오면 → `INGRESS_MODE=ingressroute` +- 아무것도 안 나오면 → `INGRESS_MODE=ingress` (표준 Ingress 사용) + +#### ② CI용 kubeconfig 생성 (서비스 어카운트 방식, 권장) +```bash +# 네임스페이스·서비스어카운트 선생성 +kubectl create namespace insurance 2>/dev/null || true +kubectl -n insurance create serviceaccount gitea-deployer +kubectl create clusterrolebinding gitea-deployer \ + --clusterrole=cluster-admin \ + --serviceaccount=insurance:gitea-deployer + +# 토큰 생성 (24시간 기본, 필요 시 --duration=8760h 로 1년) +TOKEN=$(kubectl -n insurance create token gitea-deployer --duration=8760h) + +# 현재 API 서버 주소 +SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}') +CA=$(kubectl -n insurance get secret \ + $(kubectl -n insurance get sa gitea-deployer -o jsonpath='{.secrets[0].name}' 2>/dev/null || echo "") \ + -o jsonpath='{.data.ca\.crt}' 2>/dev/null || \ + kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}') + +# kubeconfig 생성 +cat < /tmp/gitea-kubeconfig +apiVersion: v1 +kind: Config +clusters: +- cluster: + server: ${SERVER} + certificate-authority-data: ${CA} + name: cluster +contexts: +- context: + cluster: cluster + user: gitea-deployer + namespace: insurance + name: default +current-context: default +users: +- name: gitea-deployer + user: + token: ${TOKEN} +EOF + +# base64 인코딩 → Gitea secret에 넣을 값 +base64 -w0 /tmp/gitea-kubeconfig +``` +출력된 긴 문자열을 복사해서 Gitea의 `KUBE_CONFIG` secret에 붙여넣으세요. + +#### ③ DNS 확인 +`insurance.junggomoa.com` 이 k8s 클러스터의 Traefik LoadBalancer IP 로 등록돼 있어야 합니다. +```bash +dig insurance.junggomoa.com +short +``` +IP가 안 나오거나 잘못됐다면 DNS 관리자에게 추가 요청: +- `insurance.junggomoa.com A <클러스터 LB IP>` + +--- + +## ✅ 이후부터 — 자동 배포 + +코드 수정 후: +```bash +git add . +git commit -m "수정 내용" +git push +``` + +Gitea Actions 탭에서 진행 상황 확인: +- [https://git.junggomoa.com/chpark/insurance/actions](https://git.junggomoa.com/chpark/insurance/actions) + +**약 3~5분 후** `https://insurance.junggomoa.com` 에 새 버전 반영됩니다. + +--- + +## 🧪 수동 1회 배포 (CI 세팅 전 테스트용) + +SSH 접속 후: +```bash +# 코드 받기 +git clone https://git.junggomoa.com/chpark/insurance.git +cd insurance + +# 도커 이미지 빌드 +docker build -t git.junggomoa.com/chpark/insurance:latest . + +# 레지스트리 로그인 & push +docker login git.junggomoa.com -u chpark -p <토큰> +docker push git.junggomoa.com/chpark/insurance:latest + +# K8s 시크릿 & 매니페스트 적용 +kubectl apply -f deploy/k8s/namespace.yaml +kubectl -n insurance create secret docker-registry gitea-registry \ + --docker-server=git.junggomoa.com \ + --docker-username=chpark \ + --docker-password=<토큰> +kubectl apply -f deploy/k8s/deployment.yaml +kubectl apply -f deploy/k8s/service.yaml +kubectl apply -f deploy/k8s/ingress.yaml # 또는 ingressroute-traefik.yaml + +# 상태 확인 +kubectl -n insurance get all +kubectl -n insurance get ingress +``` + +--- + +## 🔧 트러블슈팅 + +| 증상 | 원인 | 해결 | +|---|---|---| +| `ImagePullBackOff` | Registry 인증 실패 | `gitea-registry` secret 재생성 | +| `404 page not found` (Traefik) | Ingress host 불일치 | `insurance.junggomoa.com` 철자 확인 | +| TLS 인증서 없음 | cert-manager 미설정 | 관리자에게 와일드카드 인증서 요청 또는 cert-manager 설치 | +| Actions 실패: `no runner` | Runner 미등록 | 조직에 act-runner 등록 필요 | + +로그 확인: +```bash +kubectl -n insurance logs -l app.kubernetes.io/name=insurance-web --tail=100 +kubectl -n insurance describe deployment insurance-web +kubectl -n insurance describe ingress insurance-web +``` + +--- + +## 📁 배포 관련 파일 + +``` +Dockerfile # Multi-stage 빌드 (Expo → nginx) +.dockerignore +.gitea/workflows/deploy.yml # CI/CD 파이프라인 +deploy/ + ├── nginx.conf # SPA fallback + gzip + health + └── k8s/ + ├── namespace.yaml + ├── deployment.yaml # 2 replicas, rolling update + ├── service.yaml # ClusterIP :80 + ├── ingress.yaml # 표준 Ingress (Traefik ingressClass) + └── ingressroute-traefik.yaml # Traefik CRD 버전 (선택) +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a35b5a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine AS builder +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --legacy-peer-deps --no-audit --no-fund + +COPY . . +RUN npx expo export --platform web + +FROM nginx:1.27-alpine AS runner +RUN rm -rf /usr/share/nginx/html/* +COPY --from=builder /app/dist /usr/share/nginx/html +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -q --spider http://localhost:80/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml new file mode 100644 index 0000000..a458fb6 --- /dev/null +++ b/deploy/k8s/deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: insurance-web + namespace: insurance + labels: + app.kubernetes.io/name: insurance-web + app.kubernetes.io/component: frontend +spec: + replicas: 2 + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: insurance-web + template: + metadata: + labels: + app.kubernetes.io/name: insurance-web + annotations: + kubectl.kubernetes.io/restartedAt: "placeholder-will-be-patched-by-ci" + spec: + imagePullSecrets: + - name: gitea-registry + containers: + - name: web + image: git.junggomoa.com/chpark/insurance:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 80 + protocol: TCP + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 300m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: false + capabilities: + drop: ["ALL"] + add: ["CHOWN", "SETGID", "SETUID", "NET_BIND_SERVICE"] diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml new file mode 100644 index 0000000..19b341d --- /dev/null +++ b/deploy/k8s/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: insurance-web + namespace: insurance + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" +spec: + ingressClassName: traefik + tls: + - hosts: + - insurance.junggomoa.com + secretName: insurance-tls + rules: + - host: insurance.junggomoa.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: insurance-web + port: + number: 80 diff --git a/deploy/k8s/ingressroute-traefik.yaml b/deploy/k8s/ingressroute-traefik.yaml new file mode 100644 index 0000000..e2e6f16 --- /dev/null +++ b/deploy/k8s/ingressroute-traefik.yaml @@ -0,0 +1,43 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: insurance-web + namespace: insurance +spec: + entryPoints: + - websecure + routes: + - match: Host(`insurance.junggomoa.com`) + kind: Rule + services: + - name: insurance-web + port: 80 + tls: + secretName: insurance-tls +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: insurance-web-http + namespace: insurance +spec: + entryPoints: + - web + routes: + - match: Host(`insurance.junggomoa.com`) + kind: Rule + middlewares: + - name: insurance-redirect-https + services: + - name: insurance-web + port: 80 +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: insurance-redirect-https + namespace: insurance +spec: + redirectScheme: + scheme: https + permanent: true diff --git a/deploy/k8s/namespace.yaml b/deploy/k8s/namespace.yaml new file mode 100644 index 0000000..a7ae797 --- /dev/null +++ b/deploy/k8s/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: insurance + labels: + app.kubernetes.io/name: insurance + app.kubernetes.io/managed-by: gitea-actions diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml new file mode 100644 index 0000000..f1a1802 --- /dev/null +++ b/deploy/k8s/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: insurance-web + namespace: insurance + labels: + app.kubernetes.io/name: insurance-web +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: insurance-web + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..861f9f6 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,44 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/javascript + text/xml + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + location = /health { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +}