From 72796ef845473a0ab31f7f202d7b86d2f8452e77 Mon Sep 17 00:00:00 2001 From: dasomel Date: Wed, 20 May 2026 18:29:18 +0900 Subject: [PATCH 1/2] feat: add Dockerfile, docker-compose, and Kubernetes manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo has no container image definition or k8s manifests — the only deployment path is 'vite dev'/'vite build' + manually copying dist to a web server. Add a production-shaped set that serves the Vite bundle from nginx-unprivileged. - Dockerfile: multi-stage Node 22 -> nginx-unprivileged 1.27 Alpine. Build stage uses 'npm ci' for reproducibility and 'npm run build'. Runtime stage replaces the default nginx config with one that: - listens on 8080 (matches the unprivileged image's user) - serves the SPA with try_files fallback for HTML5 router - caches /assets/* aggressively (Vite emits hashed filenames) - bypasses cache for /index.html - .dockerignore: keep build context small (node_modules/, dist/, build/, coverage/, IDE/.env files). - docker-compose.yml: single-service compose for demo runs, image tag parameterised via ${APP_VERSION:-5.0.0}. - k8s/deployment.yaml: replicas=2 RollingUpdate, runAsNonRoot (uid 101 matches nginx-unprivileged), readOnlyRootFilesystem, drop ALL, resources sized for static serving (50m-300m CPU, 32Mi-128Mi), separate liveness/readiness probes, ephemeral /var/cache/nginx, /var/run, /tmp emptyDirs so the read-only root filesystem profile works. - k8s/service.yaml: ClusterIP exposing port 8080. --- .dockerignore | 15 +++++++++ Dockerfile | 56 +++++++++++++++++++++++++++++++++ docker-compose.yml | 8 +++++ k8s/deployment.yaml | 77 +++++++++++++++++++++++++++++++++++++++++++++ k8s/service.yaml | 15 +++++++++ 5 files changed, 171 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 k8s/deployment.yaml create mode 100644 k8s/service.yaml 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..1c3a23e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# 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 + +RUN npm run build + +# ---------- Runtime ---------- +FROM nginxinc/nginx-unprivileged:1.27-alpine + +# Static bundle +COPY --from=build /workspace/dist /usr/share/nginx/html + +# Replace default config with one that supports React Router HTML5 history mode +COPY <<'NGINX' /etc/nginx/conf.d/default.conf +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a24b46e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + build: . + image: egovframe-template-simple-react:${APP_VERSION:-5.0.0} + container_name: egov-simple-react + ports: + - "8080:8080" + restart: unless-stopped diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..662ea07 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,77 @@ +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 + 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 + volumes: + - name: nginx-cache + emptyDir: {} + - name: nginx-run + emptyDir: {} + - name: nginx-tmp + 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 From a8ee206fd1f0e271948a07ecbafc7bc092367fe4 Mon Sep 17 00:00:00 2001 From: dasomel Date: Sat, 6 Jun 2026 13:08:11 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20nginx=20API=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=EB=A1=9C=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=8B=9C=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=A0=95=EC=83=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정적 번들이 API base 를 빌드 시점에 http://localhost:8080 으로 고정해 클러스터/컨테이너에서 localhost 가 클라이언트 자신을 가리켜 로그인·CRUD 가 동작하지 않던 문제를 해결한다. - src/config.js: SERVER_URL 을 동일 출처 상대경로(/api)로 변경. 필요 시 VITE_APP_API_BASE_URL 로 절대 URL 지정 가능. - Dockerfile: nginx 에 /api/ 리버스 프록시 location 추가. prefix 를 제거해 백엔드(context-path=/)로 전달하고, 백엔드 주소는 BACKEND_URL 환경변수를 기동 시 envsubst 로 치환(nginx 템플릿 사용). - vite.config.js: 개발 서버에도 동일한 /api 프록시를 추가해 npm run dev 호환. - docker-compose.yml: fullstack 프로파일로 백엔드+MySQL 동반 기동 구성 추가. 브라우저 → nginx(/api) → 백엔드 → DB 경로로 로그인·CRUD 통합 동작. - k8s/deployment.yaml: BACKEND_URL 환경변수 및 conf.d 쓰기 볼륨 추가 (readOnlyRootFilesystem 환경에서 envsubst 출력 기록). - README: 이미지 빌드(BuildKit), docker compose 단독/통합 실행, k8s 배포 및 port-forward 접속, 백엔드 연동 방법 문서화. --- Dockerfile | 34 ++++++++++++++++++++-- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 56 ++++++++++++++++++++++++++++++++++- k8s/deployment.yaml | 12 ++++++++ src/config.js | 11 +++++-- vite.config.js | 10 +++++++ 6 files changed, 189 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1c3a23e..765f6a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,22 +15,52 @@ 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 -# Replace default config with one that supports React Router HTML5 history mode -COPY <<'NGINX' /etc/nginx/conf.d/default.conf +# 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; 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 index a24b46e..6e55c1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,62 @@ services: + # 프론트엔드(nginx 정적 호스팅 + /api 리버스 프록시) web: build: . image: egovframe-template-simple-react:${APP_VERSION:-5.0.0} container_name: egov-simple-react ports: - - "8080:8080" + - "${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 index 662ea07..e2f4430 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -31,6 +31,12 @@ spec: # 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 @@ -68,6 +74,10 @@ spec: 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: {} @@ -75,3 +85,5 @@ spec: emptyDir: {} - name: nginx-tmp emptyDir: {} + - name: nginx-conf + emptyDir: {} 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: [