diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..80e5b4e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +build/ +coverage/ +.idea/ +.vscode/ +.git/ +.gitignore +.dockerignore +Dockerfile +docker-compose.yml +k8s/ +README*.md +*.log +.env* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..765f6a2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# syntax=docker/dockerfile:1.7 +# Multi-stage build for egovframe-template-simple-react. +# Stage 1 builds the static Vite bundle with Node. +# Stage 2 serves it from nginx as a non-root user. + +# ---------- Build ---------- +FROM node:22-alpine AS build +WORKDIR /workspace + +# Reproducible install: use lock file +COPY package.json package-lock.json ./ +RUN npm ci + +COPY index.html vite.config.js ./ +COPY public ./public +COPY src ./src + +# SERVER_URL 은 기본값(/api) 상대경로를 사용하므로 빌드 시 백엔드 호스트를 주입하지 않는다. +# 백엔드 호스트는 런타임에 nginx /api/ 프록시(BACKEND_URL)로 결정된다. +RUN npm run build + +# ---------- Runtime ---------- +FROM nginxinc/nginx-unprivileged:1.27-alpine + +# 백엔드 주소. nginx 공식 이미지의 envsubst 단계(/docker-entrypoint.d/20-envsubst-on-templates.sh)가 +# 컨테이너 기동 시 default.conf.template 의 ${BACKEND_URL} 를 이 값으로 치환한다. +ENV BACKEND_URL=http://egov-simple-backend:8080 + +# Static bundle +COPY --from=build /workspace/dist /usr/share/nginx/html + +# nginx 설정 템플릿. /etc/nginx/templates/*.template -> envsubst -> /etc/nginx/conf.d/ 로 전개된다. +# ${BACKEND_URL} 만 치환하고 nginx 변수($uri 등)는 그대로 두기 위해 치환 대상을 한정한다. +ENV NGINX_ENVSUBST_TEMPLATE_SUFFIX=.template \ + NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx/conf.d \ + NGINX_ENVSUBST_FILTER=BACKEND_URL + +COPY <<'NGINX' /etc/nginx/templates/default.conf.template +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # 백엔드 API 리버스 프록시. + # 프론트엔드는 SERVER_URL=/api 기준으로 호출하므로(/api/board, /api/auth/login-jwt 등) + # 여기서 /api prefix 를 제거하고 백엔드(context-path=/)로 전달한다. + # 쿠키 기반 JWT(httpOnly) 전달을 위해 헤더를 보존한다. + location /api/ { + proxy_pass ${BACKEND_URL}/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection ""; + proxy_cookie_path / /; + # 첨부파일 업로드 대비 (게시판/갤러리) + client_max_body_size 20m; + proxy_read_timeout 120s; + } + + # SPA fallback for client-side routing + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets aggressively (Vite emits hashed filenames) + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Disable cache for index.html + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } +} +NGINX + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1 diff --git a/README.md b/README.md index b254a34..fdedd0d 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,77 @@ npm run test npm run test:run ``` +## 컨테이너 배포 (Docker / Kubernetes) + +정적 번들을 nginx 로 호스팅하는 컨테이너 이미지와 Kubernetes 매니페스트를 제공한다. +nginx 가 `/api/*` 요청을 백엔드로 리버스 프록시하므로, 프론트엔드는 백엔드 주소를 빌드 시점에 고정하지 않고 +**동일 출처 상대경로(`/api`)** 로 호출한다. 백엔드 주소는 런타임에 `BACKEND_URL` 환경변수로 주입한다. + +> API base 는 `src/config.js` 의 `SERVER_URL` 로 결정되며 기본값은 `/api` 이다. +> 별도 호스트로 직접 호출해야 하는 경우에만 빌드 시 `VITE_APP_API_BASE_URL` 로 절대 URL 을 지정한다. + +### 1. 이미지 빌드 + +Dockerfile 은 heredoc(`COPY <<'NGINX' ...`) 구문을 사용하므로 **BuildKit 이 필요**하다. +Docker 20.10+ 에서는 기본 활성화되어 있으며, 비활성 환경이라면 `DOCKER_BUILDKIT=1` 을 지정한다. + +```bash +DOCKER_BUILDKIT=1 docker build -t egovframe-template-simple-react:5.0.0 . +``` + +### 2. 로컬 실행 (docker compose) + +#### 2-1. 프론트엔드만 실행 (백엔드는 외부에서 구동 중인 경우) + +```bash +# 외부 백엔드 주소를 BACKEND_URL 로 지정 (기본값: http://egov-simple-backend:8080) +BACKEND_URL=http://host.docker.internal:8080 docker compose up -d web + +# http://localhost:3000/ 접속 (WEB_PORT 로 변경 가능) +``` + +#### 2-2. 백엔드까지 함께 실행 — 로그인/CRUD 통합 (fullstack 프로파일) + +백엔드 이미지와 DB 초기화 스크립트가 필요하다. 다음을 전제로 한다. + +- [심플 홈페이지 Backend](https://github.com/eGovFramework/egovframe-template-simple-backend.git) 를 본 저장소와 **같은 상위 디렉토리**에 clone (DB 초기화 SQL 마운트 경로 기준, 다르면 `BACKEND_DB_DIR` 로 조정) +- 백엔드 이미지 사전 빌드: `egovframe-template-simple-backend:5.0.0` + +```bash +# 백엔드 이미지 빌드 (백엔드 저장소에서) +git clone https://github.com/eGovFramework/egovframe-template-simple-backend.git +(cd egovframe-template-simple-backend && DOCKER_BUILDKIT=1 docker build -t egovframe-template-simple-backend:5.0.0 .) + +# DB 비밀번호 등 .env 작성 (프론트엔드 저장소 루트) +cat > .env <<'ENV' +MYSQL_ROOT_PASSWORD=changeit_root +MYSQL_PASSWORD=changeit_app +ENV + +# 프론트(nginx) + 백엔드 + MySQL 동시 기동 +docker compose --profile fullstack up -d + +# http://localhost:3000/ 접속 후 admin / Admin@1234 로 로그인 +``` + +이 구성에서 브라우저 → nginx(`/api/*`) → 백엔드(`:8080`) → MySQL 경로로 로그인과 게시판 CRUD 가 한 번에 동작한다. + +### 3. Kubernetes 배포 + +```bash +kubectl apply -f k8s/ + +# Service 는 ClusterIP 이므로 port-forward 로 접속한다. +kubectl port-forward svc/egov-simple-react 3000:8080 +# http://localhost:3000/ 접속 +``` + +백엔드 연동: `k8s/deployment.yaml` 의 `BACKEND_URL` 환경변수가 같은 네임스페이스의 백엔드 Service +(`http://egov-simple-backend:8080`)를 가리키도록 기본 설정되어 있다. 백엔드를 함께 배포한 뒤 +클러스터 구성에 맞게 값을 조정한다(예: `http://egov-simple-backend..svc.cluster.local:8080`). +nginx 컨테이너는 `readOnlyRootFilesystem` 이므로 기동 시 `BACKEND_URL` 치환 결과를 +`emptyDir`(`/etc/nginx/conf.d`)에 기록한다. + --- ### 참조 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6e55c1f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + # 프론트엔드(nginx 정적 호스팅 + /api 리버스 프록시) + web: + build: . + image: egovframe-template-simple-react:${APP_VERSION:-5.0.0} + container_name: egov-simple-react + ports: + - "${WEB_PORT:-3000}:8080" + environment: + # nginx 가 /api/* 요청을 전달할 백엔드 주소. + # 기본값은 아래 fullstack 프로파일의 backend 서비스를 가리킨다. + - BACKEND_URL=${BACKEND_URL:-http://egov-simple-backend:8080} + restart: unless-stopped + + # 백엔드(심플 홈페이지 Backend) — fullstack 프로파일에서만 기동되어 로그인/CRUD 까지 한 번에 동작한다. + # 전제: egovframe-template-simple-backend:${BACKEND_VERSION} 이미지가 미리 빌드되어 있어야 한다. + # git clone https://github.com/eGovFramework/egovframe-template-simple-backend.git + # cd egovframe-template-simple-backend && docker build -t egovframe-template-simple-backend:5.0.0 . + backend: + image: egovframe-template-simple-backend:${BACKEND_VERSION:-5.0.0} + container_name: egov-simple-backend + profiles: ["fullstack"] + environment: + - SPRING_PROFILES_ACTIVE=prod + - DB_TYPE=mysql + - DB_URL=jdbc:mysql://mysql-db:3306/${MYSQL_DATABASE:-egovdb}?useSSL=false&allowPublicKeyRetrieval=true + - DB_USERNAME=${MYSQL_USER:-egov} + - DB_PASSWORD=${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in .env} + depends_on: + mysql-db: + condition: service_healthy + restart: unless-stopped + + mysql-db: + image: mysql:${MYSQL_VERSION:-8.0.39} + container_name: mysql-db + profiles: ["fullstack"] + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set in .env} + MYSQL_DATABASE: ${MYSQL_DATABASE:-egovdb} + MYSQL_USER: ${MYSQL_USER:-egov} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in .env} + command: > + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + volumes: + - mysql-data:/var/lib/mysql + # 최초 기동 시 스키마(DDL)/데이터(DML)를 적재한다. + # 경로는 백엔드 저장소를 이 저장소와 같은 상위 디렉토리에 clone 했다고 가정한다. + # 위치가 다르면 BACKEND_DB_DIR 환경변수로 조정한다. + - ${BACKEND_DB_DIR:-../egovframe-template-simple-backend/DATABASE}/all_sht_ddl_mysql.sql:/docker-entrypoint-initdb.d/01-ddl.sql:ro + - ${BACKEND_DB_DIR:-../egovframe-template-simple-backend/DATABASE}/all_sht_data_mysql.sql:/docker-entrypoint-initdb.d/02-data.sql:ro + healthcheck: + test: ["CMD-SHELL", "mysql --protocol=TCP -h 127.0.0.1 -u root --password=$$MYSQL_ROOT_PASSWORD -e 'SELECT 1' || exit 1"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + restart: unless-stopped + +volumes: + mysql-data: {} diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..e2f4430 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: egov-simple-react + labels: + app.kubernetes.io/name: egov-simple-react + app.kubernetes.io/part-of: egovframe-template +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: egov-simple-react + template: + metadata: + labels: + app.kubernetes.io/name: egov-simple-react + app.kubernetes.io/part-of: egovframe-template + spec: + securityContext: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + fsGroup: 101 + containers: + - name: web + # Replace with your registry/tag, e.g. ghcr.io//egov-simple-react:5.0.0 + image: egovframe-template-simple-react:5.0.0 + imagePullPolicy: IfNotPresent + env: + # nginx 가 /api/* 요청을 전달할 백엔드 주소. + # 같은 네임스페이스에 백엔드 Service(egov-simple-backend:8080)가 있다고 가정한다. + # 클러스터 구성에 맞게 수정한다(예: http://egov-simple-backend..svc.cluster.local:8080). + - name: BACKEND_URL + value: "http://egov-simple-backend:8080" + ports: + - name: http + containerPort: 8080 + resources: + requests: + cpu: "50m" + memory: "32Mi" + limits: + cpu: "300m" + memory: "128Mi" + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 3 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + volumeMounts: + - name: nginx-cache + mountPath: /var/cache/nginx + - name: nginx-run + mountPath: /var/run + - name: nginx-tmp + mountPath: /tmp + # readOnlyRootFilesystem 환경에서 envsubst 가 BACKEND_URL 을 치환한 + # 설정을 기동 시 기록할 수 있도록 conf.d 를 쓰기 가능 볼륨으로 제공한다. + - name: nginx-conf + mountPath: /etc/nginx/conf.d + volumes: + - name: nginx-cache + emptyDir: {} + - name: nginx-run + emptyDir: {} + - name: nginx-tmp + emptyDir: {} + - name: nginx-conf + emptyDir: {} diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..90792b5 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: egov-simple-react + labels: + app.kubernetes.io/name: egov-simple-react + app.kubernetes.io/part-of: egovframe-template +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: egov-simple-react + ports: + - name: http + port: 8080 + targetPort: http diff --git a/src/config.js b/src/config.js index 48e9cfa..bbb1f4e 100644 --- a/src/config.js +++ b/src/config.js @@ -1,5 +1,12 @@ -// Configuration for different environments -export const SERVER_URL = process.env.NODE_ENV === 'test' ? 'http://localhost:8080' : 'http://localhost:8080'; +// API 호출의 base URL. +// 빈 문자열(기본값)이면 동일 출처(same-origin) 상대경로로 호출되어 +// 배포 환경에서는 앞단 nginx 가 /api 경로를 백엔드로 리버스 프록시한다. +// 별도 호스트로 직접 호출해야 할 때만 VITE_APP_API_BASE_URL 로 절대 URL 을 지정한다. +// 예) VITE_APP_API_BASE_URL=http://localhost:8080 +// 미지정 시 "/api" prefix 를 사용해 nginx location /api/ 프록시 규칙과 맞춘다. +const API_BASE_URL = import.meta.env.VITE_APP_API_BASE_URL ?? "/api"; + +export const SERVER_URL = API_BASE_URL; export const DEFAULT_BBS_ID = "BBSMSTR_AAAAAAAAAAAA"; // default = 공지사항 게시판 아이디 export const NOTICE_BBS_ID = "BBSMSTR_AAAAAAAAAAAA"; // 공지사항 게시판 아이디 diff --git a/vite.config.js b/vite.config.js index a1432a7..ef0a006 100644 --- a/vite.config.js +++ b/vite.config.js @@ -16,6 +16,16 @@ export default defineConfig(({ mode }) => ({ base: "/", server: { port: 3000, + // 개발 서버에서도 배포(nginx)와 동일하게 /api prefix 를 백엔드로 프록시한다. + // 대상 호스트는 VITE_APP_API_PROXY_TARGET 로 바꿀 수 있고, 미지정 시 로컬 백엔드를 사용한다. + // /api/board -> {target}/board 형태로 prefix 를 제거해 전달한다. + proxy: { + "/api": { + target: process.env.VITE_APP_API_PROXY_TARGET || "http://localhost:8080", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, }, resolve: { alias: [