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; + } +}