# 인프라 주간 모니터링 보고서 - 구현 계획서 ## 프로젝트 정보 - 프로젝트명: infra-report - Gitea: https://gitea.inje-private.com/soo-rnd/infra-report - 프레임워크: Next.js 14 (App Router) + TypeScript - 이미지: harbor.inje-private.com/infra/infra-report - Helm Chart: infra-report - 배포: K8s Pod (namespace: soo) - ArgoCD: https://argocd.gpulive.cloud (app: infra-report) - Ingress: infra-report.gpulive.cloud --- ## STEP 0. 인프라 사전 준비 (완료) | 작업 | 상태 | |---|---| | Gitea repo 생성 (soo-rnd/infra-report) | 완료 | | Gitea Actions 활성화 | 완료 | | Gitea Secrets 등록 (REGISTRY_URL, USER, PASSWORD) | 완료 | | Gitea Runner 연결 (soo-rnd 조직) | 완료 | --- ## STEP 1. 프로젝트 초기화 (임시 Pod 방식) ✅ bastion에서 임시 Node.js Pod을 띄워 프로젝트를 스캐폴딩하고, 결과물을 bastion으로 꺼내 git에 커밋한다. ### 1-1. 임시 Pod 생성 ```bash kubectl run init-nextjs --image=node:20-alpine --restart=Never -n soo -- sleep 3600 ``` ### 1-2. Pod 접속 → 프로젝트 생성 + 패키지 설치 + next.config.js 덮어쓰기 ```bash kubectl exec -it init-nextjs -n soo -- sh ``` ```sh mkdir /app && cd /app npx create-next-app@14 . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --no-git npm install echarts echarts-for-react @tanstack/react-query axios @kubernetes/client-node date-fns zod jspdf html2canvas npm install -D @types/node cat > next.config.js << 'EOF' /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', serverExternalPackages: ['@kubernetes/client-node'], }; module.exports = nextConfig; EOF exit ``` ### 1-3. 결과물을 bastion으로 복사 + Pod 삭제 ```bash kubectl cp soo/init-nextjs:/app ./infra-report-src kubectl delete pod init-nextjs -n soo ``` ### 1-4. bastion에서 git에 커밋 ```bash cd infra-report-src git init git remote add origin https://gitea.inje-private.com/soo-rnd/infra-report.git git add -A git commit -m "chore: scaffold Next.js project with dependencies" git push -u origin main ``` ### 1-6. 생성 확인 파일 목록 - `package.json` / `package-lock.json` - `next.config.js` (standalone + serverExternalPackages) - `tsconfig.json` - `tailwind.config.ts` - `src/app/` (기본 page, layout, globals.css) - `public/` --- ## STEP 2. Helm Chart ✅ ### 2-1. 디렉토리 구조 ``` charts/infra-report/ ├── Chart.yaml ├── values.yaml └── templates/ ├── _helpers.tpl ├── deployment.yaml ├── service.yaml ├── ingress.yaml ├── serviceaccount.yaml ├── clusterrole.yaml ├── clusterrolebinding.yaml ├── configmap.yaml └── imagepullsecret.yaml ``` ### 2-2. charts/infra-report/Chart.yaml ```yaml apiVersion: v2 name: infra-report description: Infrastructure Weekly Monitoring Report type: application version: 0.1.0 appVersion: "0.1.0" ``` ### 2-3. charts/infra-report/values.yaml ```yaml replicaCount: 1 image: repository: harbor.inje-private.com/infra/infra-report tag: "latest" pullPolicy: Always imagePullSecrets: - name: harbor-pull-secret serviceAccount: name: infra-report-sa service: type: ClusterIP port: 80 targetPort: 3000 ingress: enabled: true className: nginx host: infra-report.gpulive.cloud tls: secretName: gpulive-new-ssl annotations: nginx.ingress.kubernetes.io/rewrite-target: / nginx.ingress.kubernetes.io/ssl-redirect: "true" config: PROMETHEUS_URL: "http://prometheus-server.monitoring" REPORT_STEP: "5m" REPORT_RANGE_DAYS: "7" TZ: "Asia/Seoul" QUERY_CPU: '100 - (avg by (instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)' QUERY_MEMORY: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100' QUERY_DISK: 'sum by (mountpoint,fstype)((1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|nfs",mountpoint!~"/boot.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|nfs",mountpoint!~"/boot.*"}) * 100)' QUERY_NAS: '((node_filesystem_size_bytes{fstype=~"nfs", node=~".*(bastion).*"} - node_filesystem_free_bytes{fstype=~"nfs", node=~".*(bastion).*"}) / node_filesystem_size_bytes{fstype=~"nfs", node=~".*(bastion).*"}) * 100' QUERY_NET_RX: 'sum(rate(container_network_receive_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))' QUERY_NET_TX: 'sum(rate(container_network_transmit_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))' resources: requests: cpu: "100m" memory: "256Mi" limits: cpu: "500m" memory: "512Mi" probes: liveness: path: /api/health initialDelaySeconds: 10 periodSeconds: 30 readiness: path: /api/health initialDelaySeconds: 5 periodSeconds: 10 ``` ### 2-4. charts/infra-report/templates/_helpers.tpl ```yaml {{- define "infra-report.name" -}} {{- .Chart.Name | trunc 63 | trimSuffix "-" }} {{- end }} {{- define "infra-report.fullname" -}} {{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" }} {{- end }} {{- define "infra-report.labels" -}} app.kubernetes.io/name: {{ include "infra-report.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end }} {{- define "infra-report.selectorLabels" -}} app.kubernetes.io/name: {{ include "infra-report.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} ``` ### 2-5. charts/infra-report/templates/serviceaccount.yaml ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: {{ .Values.serviceAccount.name }} namespace: {{ .Release.Namespace }} labels: {{- include "infra-report.labels" . | nindent 4 }} ``` ### 2-6. charts/infra-report/templates/clusterrole.yaml ```yaml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: {{ include "infra-report.fullname" . }}-reader labels: {{- include "infra-report.labels" . | nindent 4 }} rules: - apiGroups: [""] resources: ["nodes"] verbs: ["get", "list"] - apiGroups: ["metrics.k8s.io"] resources: ["nodes"] verbs: ["get", "list"] ``` ### 2-7. charts/infra-report/templates/clusterrolebinding.yaml ```yaml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: {{ include "infra-report.fullname" . }}-reader-binding labels: {{- include "infra-report.labels" . | nindent 4 }} subjects: - kind: ServiceAccount name: {{ .Values.serviceAccount.name }} namespace: {{ .Release.Namespace }} roleRef: kind: ClusterRole name: {{ include "infra-report.fullname" . }}-reader apiGroup: rbac.authorization.k8s.io ``` ### 2-8. charts/infra-report/templates/configmap.yaml ```yaml apiVersion: v1 kind: ConfigMap metadata: name: {{ include "infra-report.fullname" . }}-config namespace: {{ .Release.Namespace }} labels: {{- include "infra-report.labels" . | nindent 4 }} data: {{- range $key, $value := .Values.config }} {{ $key }}: {{ $value | quote }} {{- end }} ``` ### 2-9. charts/infra-report/templates/imagepullsecret.yaml ```yaml {{- if .Values.imagePullSecrets }} apiVersion: v1 kind: Secret metadata: name: harbor-pull-secret namespace: {{ .Release.Namespace }} labels: {{- include "infra-report.labels" . | nindent 4 }} type: kubernetes.io/dockerconfigjson data: .dockerconfigjson: {{ template "imagePullSecret" . }} {{- end }} ``` > 실제 운영에서는 `kubectl create secret docker-registry` 로 별도 생성하거나, > Helm values에 base64 인코딩된 dockerconfigjson을 주입한다. > ArgoCD에서 관리할 경우 sealed-secret 또는 external-secrets 사용 권장. **수동 생성 방식:** ```bash kubectl create secret docker-registry harbor-pull-secret \ --docker-server=harbor.inje-private.com \ --docker-username=admin \ --docker-password='Ijinc123!@#$' \ -n soo ``` ### 2-10. TLS 인증서 Secret 복사 monitoring 네임스페이스의 `gpulive-new-ssl` Secret을 soo 네임스페이스로 복사한다. ```bash kubectl get secret gpulive-new-ssl -n monitoring -o json \ | jq 'del(.metadata.creationTimestamp, .metadata.resourceVersion, .metadata.uid, .metadata.annotations, .metadata.managedFields) | .metadata.namespace = "soo"' \ | kubectl apply -f - ``` ### 2-12. charts/infra-report/templates/deployment.yaml ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "infra-report.fullname" . }} namespace: {{ .Release.Namespace }} labels: {{- include "infra-report.labels" . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} selector: matchLabels: {{- include "infra-report.selectorLabels" . | nindent 6 }} template: metadata: labels: {{- include "infra-report.selectorLabels" . | nindent 8 }} spec: serviceAccountName: {{ .Values.serviceAccount.name }} {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - containerPort: {{ .Values.service.targetPort }} envFrom: - configMapRef: name: {{ include "infra-report.fullname" . }}-config resources: {{- toYaml .Values.resources | nindent 12 }} livenessProbe: httpGet: path: {{ .Values.probes.liveness.path }} port: {{ .Values.service.targetPort }} initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} periodSeconds: {{ .Values.probes.liveness.periodSeconds }} readinessProbe: httpGet: path: {{ .Values.probes.readiness.path }} port: {{ .Values.service.targetPort }} initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} periodSeconds: {{ .Values.probes.readiness.periodSeconds }} ``` ### 2-13. charts/infra-report/templates/service.yaml ```yaml apiVersion: v1 kind: Service metadata: name: {{ include "infra-report.fullname" . }} namespace: {{ .Release.Namespace }} labels: {{- include "infra-report.labels" . | nindent 4 }} spec: type: {{ .Values.service.type }} selector: {{- include "infra-report.selectorLabels" . | nindent 4 }} ports: - port: {{ .Values.service.port }} targetPort: {{ .Values.service.targetPort }} protocol: TCP ``` ### 2-14. charts/infra-report/templates/ingress.yaml ```yaml {{- if .Values.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "infra-report.fullname" . }} namespace: {{ .Release.Namespace }} labels: {{- include "infra-report.labels" . | nindent 4 }} {{- with .Values.ingress.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: ingressClassName: {{ .Values.ingress.className }} {{- if .Values.ingress.tls }} tls: - hosts: - {{ .Values.ingress.host }} secretName: {{ .Values.ingress.tls.secretName }} {{- end }} rules: - host: {{ .Values.ingress.host }} http: paths: - path: / pathType: Prefix backend: service: name: {{ include "infra-report.fullname" . }} port: number: {{ .Values.service.port }} {{- end }} ``` --- ## STEP 3. Dockerfile ✅ ### 3-1. Dockerfile ```dockerfile FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" CMD ["node", "server.js"] ``` ### 3-2. .dockerignore ``` node_modules .next .git *.md charts/ .env.local .gitea/ ``` --- ## STEP 4. CI 워크플로우 ✅ ### 4-1. .gitea/workflows/ci.yaml ```yaml name: CI/CD on: push: branches: [main] tags: ['v*'] env: REGISTRY: ${{ secrets.REGISTRY_URL }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} IMAGE_NAME: ${{ secrets.REGISTRY_URL }}/infra/infra-report CHART_NAME: infra-report jobs: build-and-push: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set image tag id: tag run: | if [[ "${GITHUB_REF}" == refs/tags/* ]]; then echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV else echo "TAG=sha-$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_ENV fi - name: Login to Harbor run: | echo "${{ env.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u ${{ env.REGISTRY_USER }} --password-stdin - name: Build and push Docker image run: | docker build -t ${{ env.IMAGE_NAME }}:${{ env.TAG }} . docker tag ${{ env.IMAGE_NAME }}:${{ env.TAG }} ${{ env.IMAGE_NAME }}:latest docker push ${{ env.IMAGE_NAME }}:${{ env.TAG }} docker push ${{ env.IMAGE_NAME }}:latest - name: Install Helm run: | curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - name: Package Helm chart run: | sed -i "s/appVersion: .*/appVersion: \"${{ env.TAG }}\"/" charts/infra-report/Chart.yaml helm package charts/infra-report --version 0.1.0 --app-version ${{ env.TAG }} - name: Push Helm chart to Harbor (OCI) run: | echo "${{ env.REGISTRY_PASSWORD }}" | helm registry login ${{ env.REGISTRY }} -u ${{ env.REGISTRY_USER }} --password-stdin helm push ${CHART_NAME}-0.1.0.tgz oci://${{ env.REGISTRY }}/infra ``` --- ## STEP 5. ArgoCD 연동 ✅ ### 5-1. Harbor OCI repo를 ArgoCD에 등록 ```bash argocd repo add oci://harbor.inje-private.com/infra \ --type helm \ --name harbor-infra \ --enable-oci \ --username admin \ --password 'Ijinc123!@#$' ``` 또는 ArgoCD UI에서: Settings → Repositories → Connect Repo - Type: Helm - Repository URL: `harbor.inje-private.com/infra` - Enable OCI: 체크 - Username / Password 입력 ### 5-2. ArgoCD Application 생성 ```yaml apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: infra-report namespace: argocd spec: project: default source: chart: infra-report repoURL: harbor.inje-private.com/infra targetRevision: 0.1.0 helm: values: | image: tag: "latest" ingress: host: infra-report.gpulive.cloud destination: server: https://kubernetes.default.svc namespace: soo syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=true ``` CLI로 생성: ```bash argocd app create infra-report \ --repo harbor.inje-private.com/infra \ --helm-chart infra-report \ --revision 0.1.0 \ --dest-server https://kubernetes.default.svc \ --dest-namespace soo \ --sync-policy automated \ --auto-prune \ --self-heal \ --helm-set image.tag=latest \ --helm-set ingress.host=infra-report.gpulive.cloud ``` ### 5-3. imagePullSecret 생성 (soo namespace) ```bash kubectl create secret docker-registry harbor-pull-secret \ --docker-server=harbor.inje-private.com \ --docker-username=admin \ --docker-password='Ijinc123!@#$' \ -n soo ``` ### 5-4. 전체 배포 흐름 ``` 코드 Push (main) → Gitea Actions (ci.yaml) → docker build + push (harbor.inje-private.com/infra/infra-report:sha-xxxx) → helm package + push (oci://harbor.inje-private.com/infra/infra-report:0.1.0) → ArgoCD 감지 → helm install/upgrade → soo namespace 배포 → Pod pull image (harbor-pull-secret 사용) ``` --- ## STEP 6. 백엔드 라이브러리 (src/lib/) ✅ ### 6-1. src/lib/prometheus.ts Prometheus HTTP API 클라이언트. ```typescript import axios from 'axios'; const PROMETHEUS_URL = process.env.PROMETHEUS_URL || 'http://prometheus-server.monitoring'; interface PrometheusRangeResult { metric: Record; values: [number, string][]; } interface PrometheusResponse { status: string; data: { resultType: string; result: PrometheusRangeResult[]; }; } export async function queryRange( query: string, start: Date, end: Date, step: string = '5m' ): Promise { const res = await axios.get(`${PROMETHEUS_URL}/api/v1/query_range`, { params: { query, start: start.toISOString(), end: end.toISOString(), step, }, }); return res.data.data.result; } export async function queryInstant(query: string): Promise { const res = await axios.get(`${PROMETHEUS_URL}/api/v1/query`, { params: { query }, }); return res.data.data.result; } export const QUERIES = { cpuUsage: process.env.QUERY_CPU || '100 - (avg by (instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', memoryUsage: process.env.QUERY_MEMORY || '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', diskUsage: process.env.QUERY_DISK || 'sum by (mountpoint,fstype)((1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|nfs",mountpoint!~"/boot.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|nfs",mountpoint!~"/boot.*"}) * 100)', nasUsage: process.env.QUERY_NAS || '((node_filesystem_size_bytes{fstype=~"nfs", node=~".*(bastion).*"} - node_filesystem_free_bytes{fstype=~"nfs", node=~".*(bastion).*"}) / node_filesystem_size_bytes{fstype=~"nfs", node=~".*(bastion).*"}) * 100', networkReceive: process.env.QUERY_NET_RX || 'sum(rate(container_network_receive_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))', networkTransmit: process.env.QUERY_NET_TX || 'sum(rate(container_network_transmit_bytes_total{namespace="ingress-nginx", pod=~"ingress-nginx-controller-.*"}[5m]))', }; ``` ### 6-2. src/lib/kubernetes.ts K8s API 클라이언트. Pod 내부에서는 in-cluster config, 로컬에서는 kubeconfig 사용. ```typescript import * as k8s from '@kubernetes/client-node'; function getKubeConfig(): k8s.KubeConfig { const kc = new k8s.KubeConfig(); try { kc.loadFromCluster(); } catch { kc.loadFromDefault(); } return kc; } export interface NodeInfo { name: string; role: string; status: string; cpuCapacity: string; memoryCapacity: string; osImage: string; kubeletVersion: string; conditions: { type: string; status: string }[]; } export async function getNodes(): Promise { const kc = getKubeConfig(); const coreApi = kc.makeApiClient(k8s.CoreV1Api); const res = await coreApi.listNode(); return res.items.map((node) => { const labels = node.metadata?.labels || {}; const role = Object.keys(labels) .find((k) => k.startsWith('node-role.kubernetes.io/')) ?.replace('node-role.kubernetes.io/', '') || 'worker'; const readyCondition = node.status?.conditions?.find((c) => c.type === 'Ready'); return { name: node.metadata?.name || '', role, status: readyCondition?.status === 'True' ? 'Ready' : 'NotReady', cpuCapacity: node.status?.capacity?.cpu || '', memoryCapacity: node.status?.capacity?.memory || '', osImage: node.status?.nodeInfo?.osImage || '', kubeletVersion: node.status?.nodeInfo?.kubeletVersion || '', conditions: (node.status?.conditions || []).map((c) => ({ type: c.type || '', status: c.status || '', })), }; }); } ``` ### 6-3. src/lib/analytics.ts 통계 계산 + Peak 감지. ```typescript export interface DataPoint { timestamp: number; value: number; } export interface Stats { avg: number; max: number; min: number; p95: number; current: number; } export interface PeakInfo { startTime: number; endTime: number; maxValue: number; avgValue: number; } export function calculateStats(dataPoints: DataPoint[]): Stats { const values = dataPoints.map((d) => d.value); const sorted = [...values].sort((a, b) => a - b); return { avg: values.reduce((a, b) => a + b, 0) / values.length, max: Math.max(...values), min: Math.min(...values), p95: sorted[Math.floor(sorted.length * 0.95)], current: values[values.length - 1], }; } export function detectPeaks( dataPoints: DataPoint[], threshold: number = 2 ): { points: (DataPoint & { zScore: number; isPeak: boolean })[]; peaks: PeakInfo[] } { const values = dataPoints.map((d) => d.value); const mean = values.reduce((a, b) => a + b, 0) / values.length; const stddev = Math.sqrt( values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length ); const points = dataPoints.map((d) => { const zScore = stddev === 0 ? 0 : (d.value - mean) / stddev; return { ...d, zScore, isPeak: Math.abs(zScore) > threshold }; }); const peaks: PeakInfo[] = []; let currentPeak: DataPoint[] | null = null; for (const point of points) { if (point.isPeak) { if (!currentPeak) currentPeak = []; currentPeak.push(point); } else if (currentPeak) { peaks.push({ startTime: currentPeak[0].timestamp, endTime: currentPeak[currentPeak.length - 1].timestamp, maxValue: Math.max(...currentPeak.map((p) => p.value)), avgValue: currentPeak.reduce((a, b) => a + b.value, 0) / currentPeak.length, }); currentPeak = null; } } if (currentPeak) { peaks.push({ startTime: currentPeak[0].timestamp, endTime: currentPeak[currentPeak.length - 1].timestamp, maxValue: Math.max(...currentPeak.map((p) => p.value)), avgValue: currentPeak.reduce((a, b) => a + b.value, 0) / currentPeak.length, }); } return { points, peaks }; } ``` ### 6-4. src/lib/formatters.ts 단위 변환 유틸리티. ```typescript export function formatBytes(bytes: number): string { const units = ['B', 'KB', 'MB', 'GB', 'TB']; let value = bytes; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex++; } return `${value.toFixed(1)} ${units[unitIndex]}`; } export function formatBytesPerSec(bytesPerSec: number): string { return `${formatBytes(bytesPerSec)}/s`; } export function formatPercent(value: number): string { return `${value.toFixed(1)}%`; } export function formatKiBytes(kiBytes: string): string { const bytes = parseInt(kiBytes.replace('Ki', '')) * 1024; return formatBytes(bytes); } export function formatTimestamp(ts: number): string { return new Date(ts * 1000).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }); } ``` --- ## STEP 7. API Routes (src/app/api/) ✅ ### 7-1. src/app/api/health/route.ts ```typescript import { NextResponse } from 'next/server'; export async function GET() { return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() }); } ``` ### 7-2. src/app/api/nodes/route.ts ```typescript import { NextResponse } from 'next/server'; import { getNodes } from '@/lib/kubernetes'; export async function GET() { try { const nodes = await getNodes(); return NextResponse.json({ nodes }); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to fetch nodes' }, { status: 500 } ); } } ``` ### 7-3. src/app/api/metrics/[type]/route.ts 메트릭 타입별 통합 API Route. `type`은 cpu, memory, disk, nas, network 중 하나. ```typescript import { NextRequest, NextResponse } from 'next/server'; import { queryRange, QUERIES } from '@/lib/prometheus'; import { calculateStats, detectPeaks, DataPoint } from '@/lib/analytics'; const QUERY_MAP: Record = { cpu: QUERIES.cpuUsage, memory: QUERIES.memoryUsage, disk: QUERIES.diskUsage, nas: QUERIES.nasUsage, network: [QUERIES.networkReceive, QUERIES.networkTransmit], }; function parsePrometheusValues(values: [number, string][]): DataPoint[] { return values.map(([ts, val]) => ({ timestamp: ts, value: parseFloat(val) })); } export async function GET( request: NextRequest, { params }: { params: { type: string } } ) { const { type } = params; const searchParams = request.nextUrl.searchParams; const days = parseInt(searchParams.get('days') || process.env.REPORT_RANGE_DAYS || '7'); const step = searchParams.get('step') || process.env.REPORT_STEP || '5m'; const end = new Date(); const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000); const queries = QUERY_MAP[type]; if (!queries) { return NextResponse.json({ error: `Unknown metric type: ${type}` }, { status: 400 }); } try { if (type === 'network') { const [rxResults, txResults] = await Promise.all([ queryRange(QUERIES.networkReceive, start, end, step), queryRange(QUERIES.networkTransmit, start, end, step), ]); const receive = rxResults.map((r) => { const dataPoints = parsePrometheusValues(r.values); return { instance: r.metric.instance, dataPoints, stats: calculateStats(dataPoints), peaks: detectPeaks(dataPoints), }; }); const transmit = txResults.map((r) => { const dataPoints = parsePrometheusValues(r.values); return { instance: r.metric.instance, dataPoints, stats: calculateStats(dataPoints), peaks: detectPeaks(dataPoints), }; }); return NextResponse.json({ type, period: { start, end, step }, receive, transmit }); } const results = await queryRange(queries as string, start, end, step); const instances = results.map((r) => { const dataPoints = parsePrometheusValues(r.values); return { instance: r.metric.instance, metric: r.metric, dataPoints, stats: calculateStats(dataPoints), peaks: detectPeaks(dataPoints), }; }); return NextResponse.json({ type, period: { start, end, step }, instances }); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to fetch metrics' }, { status: 500 } ); } } ``` ### 7-4. src/app/api/report/route.ts 모든 메트릭 + 노드 정보를 한 번에 조회하는 통합 API. ```typescript import { NextRequest, NextResponse } from 'next/server'; import { queryRange, QUERIES } from '@/lib/prometheus'; import { getNodes } from '@/lib/kubernetes'; import { calculateStats, detectPeaks, DataPoint } from '@/lib/analytics'; function parseValues(values: [number, string][]): DataPoint[] { return values.map(([ts, val]) => ({ timestamp: ts, value: parseFloat(val) })); } function processResults(results: { metric: Record; values: [number, string][] }[]) { return results.map((r) => { const dataPoints = parseValues(r.values); return { instance: r.metric.instance, metric: r.metric, dataPoints, stats: calculateStats(dataPoints), peaks: detectPeaks(dataPoints), }; }); } export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const days = parseInt(searchParams.get('days') || process.env.REPORT_RANGE_DAYS || '7'); const step = searchParams.get('step') || process.env.REPORT_STEP || '5m'; const end = new Date(); const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000); try { const [cpuData, memData, diskData, nasData, rxData, txData, nodes] = await Promise.all([ queryRange(QUERIES.cpuUsage, start, end, step), queryRange(QUERIES.memoryUsage, start, end, step), queryRange(QUERIES.diskUsage, start, end, step), queryRange(QUERIES.nasUsage, start, end, step).catch(() => []), queryRange(QUERIES.networkReceive, start, end, step), queryRange(QUERIES.networkTransmit, start, end, step), getNodes(), ]); return NextResponse.json({ period: { start, end, days, step }, nodes, metrics: { cpu: processResults(cpuData), memory: processResults(memData), disk: processResults(diskData), nas: processResults(nasData), network: { receive: processResults(rxData), transmit: processResults(txData), }, }, }); } catch (error) { return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to generate report' }, { status: 500 } ); } } ``` --- ## STEP 8. 타입 정의 (src/types/) ✅ ### 8-1. src/types/metrics.ts ```typescript export interface DataPoint { timestamp: number; value: number; } export interface Stats { avg: number; max: number; min: number; p95: number; current: number; } export interface PeakInfo { startTime: number; endTime: number; maxValue: number; avgValue: number; } export interface MetricInstance { instance: string; metric: Record; dataPoints: DataPoint[]; stats: Stats; peaks: { points: (DataPoint & { zScore: number; isPeak: boolean })[]; peaks: PeakInfo[]; }; } export interface NetworkMetrics { receive: MetricInstance[]; transmit: MetricInstance[]; } export interface NodeInfo { name: string; role: string; status: string; cpuCapacity: string; memoryCapacity: string; osImage: string; kubeletVersion: string; conditions: { type: string; status: string }[]; } export interface ReportData { period: { start: string; end: string; days: number; step: string; }; nodes: NodeInfo[]; metrics: { cpu: MetricInstance[]; memory: MetricInstance[]; disk: MetricInstance[]; nas: MetricInstance[]; network: NetworkMetrics; }; } ``` --- ## STEP 9. 프론트엔드 컴포넌트 ✅ ### 9-1. src/app/providers.tsx ```typescript 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; export default function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, }, }, }) ); return {children}; } ``` ### 9-2. src/app/layout.tsx ```typescript import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import Providers from './providers'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: '인프라 주간 모니터링 보고서', description: 'Infrastructure Weekly Monitoring Report', }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ### 9-3. src/hooks/useReport.ts ```typescript 'use client'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; import type { ReportData } from '@/types/metrics'; export function useReport(days: number = 7) { return useQuery({ queryKey: ['report', days], queryFn: async () => { const res = await axios.get(`/api/report?days=${days}`); return res.data; }, }); } ``` ### 9-4. src/components/report/SummaryCards.tsx ```tsx 'use client'; import { formatPercent, formatBytesPerSec } from '@/lib/formatters'; import type { MetricInstance, NetworkMetrics } from '@/types/metrics'; interface SummaryCardsProps { cpu: MetricInstance[]; memory: MetricInstance[]; disk: MetricInstance[]; network: NetworkMetrics; } interface CardData { label: string; avg: string; max: string; min: string; color: string; } function getClusterStats(instances: MetricInstance[]): { avg: number; max: number; min: number } { if (instances.length === 0) return { avg: 0, max: 0, min: 0 }; const avg = instances.reduce((sum, i) => sum + i.stats.avg, 0) / instances.length; const max = Math.max(...instances.map((i) => i.stats.max)); const min = Math.min(...instances.map((i) => i.stats.min)); return { avg, max, min }; } export default function SummaryCards({ cpu, memory, disk, network }: SummaryCardsProps) { const cpuStats = getClusterStats(cpu); const memStats = getClusterStats(memory); const diskStats = getClusterStats(disk); const netRxAvg = network.receive.reduce((sum, i) => sum + i.stats.avg, 0); const netTxAvg = network.transmit.reduce((sum, i) => sum + i.stats.avg, 0); const cards: CardData[] = [ { label: 'CPU', avg: formatPercent(cpuStats.avg), max: formatPercent(cpuStats.max), min: formatPercent(cpuStats.min), color: cpuStats.max > 90 ? 'border-red-500' : cpuStats.max > 70 ? 'border-yellow-500' : 'border-green-500', }, { label: 'Memory', avg: formatPercent(memStats.avg), max: formatPercent(memStats.max), min: formatPercent(memStats.min), color: memStats.max > 90 ? 'border-red-500' : memStats.max > 70 ? 'border-yellow-500' : 'border-green-500', }, { label: 'Disk', avg: formatPercent(diskStats.avg), max: formatPercent(diskStats.max), min: formatPercent(diskStats.min), color: diskStats.max > 90 ? 'border-red-500' : diskStats.max > 70 ? 'border-yellow-500' : 'border-green-500', }, { label: 'Network', avg: `RX ${formatBytesPerSec(netRxAvg)}`, max: `TX ${formatBytesPerSec(netTxAvg)}`, min: '', color: 'border-blue-500', }, ]; return (
{cards.map((card) => (

{card.label}

avg: {card.avg}

max: {card.max}

{card.min &&

min: {card.min}

}
))}
); } ``` ### 9-5. src/components/charts/TimeSeriesChart.tsx ```tsx 'use client'; import ReactECharts from 'echarts-for-react'; import type { MetricInstance } from '@/types/metrics'; import { formatTimestamp } from '@/lib/formatters'; interface TimeSeriesChartProps { title: string; instances: MetricInstance[]; unit?: string; height?: number; } export default function TimeSeriesChart({ title, instances, unit = '%', height = 300, }: TimeSeriesChartProps) { const series = instances.map((inst) => ({ name: inst.instance, type: 'line' as const, smooth: true, symbol: 'none', data: inst.dataPoints.map((d) => [d.timestamp * 1000, d.value.toFixed(2)]), markArea: { silent: true, itemStyle: { color: 'rgba(255, 0, 0, 0.08)' }, data: inst.peaks.peaks.map((peak) => [ { xAxis: peak.startTime * 1000 }, { xAxis: peak.endTime * 1000 }, ]), }, })); const option = { title: { text: title, left: 'center', textStyle: { fontSize: 14 } }, tooltip: { trigger: 'axis', formatter: (params: any[]) => { const time = formatTimestamp(params[0].data[0] / 1000); const lines = params.map( (p: any) => `${p.marker} ${p.seriesName}: ${parseFloat(p.data[1]).toFixed(1)}${unit}` ); return `${time}
${lines.join('
')}`; }, }, legend: { bottom: 0, type: 'scroll' }, grid: { left: '3%', right: '3%', bottom: '15%', top: '15%', containLabel: true }, xAxis: { type: 'time', axisLabel: { formatter: (val: number) => { const d = new Date(val); return `${(d.getMonth() + 1).toString().padStart(2, '0')}/${d.getDate().toString().padStart(2, '0')}\n${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; }, }, }, yAxis: { type: 'value', axisLabel: { formatter: `{value}${unit}` }, }, dataZoom: [ { type: 'inside', start: 0, end: 100 }, { type: 'slider', start: 0, end: 100, height: 20, bottom: 30 }, ], series, }; return ; } ``` ### 9-6. src/components/charts/NetworkChart.tsx ```tsx 'use client'; import ReactECharts from 'echarts-for-react'; import type { NetworkMetrics } from '@/types/metrics'; import { formatTimestamp, formatBytes } from '@/lib/formatters'; interface NetworkChartProps { data: NetworkMetrics; height?: number; } export default function NetworkChart({ data, height = 300 }: NetworkChartProps) { const rxSeries = data.receive.map((inst) => ({ name: `RX ${inst.instance}`, type: 'line' as const, smooth: true, symbol: 'none', areaStyle: { opacity: 0.3 }, data: inst.dataPoints.map((d) => [d.timestamp * 1000, d.value]), })); const txSeries = data.transmit.map((inst) => ({ name: `TX ${inst.instance}`, type: 'line' as const, smooth: true, symbol: 'none', lineStyle: { type: 'dashed' as const }, data: inst.dataPoints.map((d) => [d.timestamp * 1000, -d.value]), })); const option = { title: { text: 'Network Traffic', left: 'center', textStyle: { fontSize: 14 } }, tooltip: { trigger: 'axis', formatter: (params: any[]) => { const time = formatTimestamp(params[0].data[0] / 1000); const lines = params.map( (p: any) => `${p.marker} ${p.seriesName}: ${formatBytes(Math.abs(p.data[1]))}/s` ); return `${time}
${lines.join('
')}`; }, }, legend: { bottom: 0, type: 'scroll' }, grid: { left: '3%', right: '3%', bottom: '15%', top: '15%', containLabel: true }, xAxis: { type: 'time' }, yAxis: { type: 'value', axisLabel: { formatter: (val: number) => `${formatBytes(Math.abs(val))}/s`, }, }, dataZoom: [ { type: 'inside', start: 0, end: 100 }, { type: 'slider', start: 0, end: 100, height: 20, bottom: 30 }, ], series: [...rxSeries, ...txSeries], }; return ; } ``` ### 9-7. src/components/report/NodeTable.tsx ```tsx 'use client'; import type { MetricInstance, NodeInfo } from '@/types/metrics'; import { formatPercent, formatKiBytes } from '@/lib/formatters'; interface NodeTableProps { nodes: NodeInfo[]; cpu: MetricInstance[]; memory: MetricInstance[]; disk: MetricInstance[]; } function findInstanceStats(instances: MetricInstance[], nodeName: string) { const inst = instances.find((i) => i.instance.includes(nodeName)); return inst?.stats || null; } export default function NodeTable({ nodes, cpu, memory, disk }: NodeTableProps) { return (
{nodes.map((node) => { const cpuStats = findInstanceStats(cpu, node.name); const memStats = findInstanceStats(memory, node.name); const diskStats = findInstanceStats(disk, node.name); return ( ); })}
Node Role Status Spec CPU avg CPU max CPU min MEM avg MEM max MEM min DISK max
{node.name} {node.role} {node.status} {node.cpuCapacity} CPU / {formatKiBytes(node.memoryCapacity)} {cpuStats ? formatPercent(cpuStats.avg) : '-'} {cpuStats ? formatPercent(cpuStats.max) : '-'} {cpuStats ? formatPercent(cpuStats.min) : '-'} {memStats ? formatPercent(memStats.avg) : '-'} {memStats ? formatPercent(memStats.max) : '-'} {memStats ? formatPercent(memStats.min) : '-'} {diskStats ? formatPercent(diskStats.max) : '-'}
); } ``` ### 9-8. src/components/report/AlertSection.tsx ```tsx 'use client'; import type { MetricInstance } from '@/types/metrics'; import { formatTimestamp, formatPercent } from '@/lib/formatters'; interface AlertSectionProps { cpu: MetricInstance[]; memory: MetricInstance[]; disk: MetricInstance[]; } interface Alert { level: 'warning' | 'critical'; instance: string; metric: string; message: string; } export default function AlertSection({ cpu, memory, disk }: AlertSectionProps) { const alerts: Alert[] = []; cpu.forEach((inst) => { if (inst.stats.max > 90) { alerts.push({ level: 'critical', instance: inst.instance, metric: 'CPU', message: `CPU 최대 ${formatPercent(inst.stats.max)} 도달`, }); } inst.peaks.peaks.forEach((peak) => { alerts.push({ level: 'warning', instance: inst.instance, metric: 'CPU', message: `${formatTimestamp(peak.startTime)} ~ ${formatTimestamp(peak.endTime)} 구간 Peak 감지 (max: ${formatPercent(peak.maxValue)})`, }); }); }); memory.forEach((inst) => { if (inst.stats.max > 85) { alerts.push({ level: inst.stats.max > 95 ? 'critical' : 'warning', instance: inst.instance, metric: 'Memory', message: `Memory 최대 ${formatPercent(inst.stats.max)} 도달`, }); } }); disk.forEach((inst) => { if (inst.stats.max > 80) { alerts.push({ level: inst.stats.max > 90 ? 'critical' : 'warning', instance: inst.instance, metric: 'Disk', message: `Disk 사용률 ${formatPercent(inst.stats.max)} (증설 검토 필요)`, }); } }); if (alerts.length === 0) { return (

특이사항 없음

); } return (

주의 구간 ({alerts.length}건)

    {alerts.map((alert, idx) => (
  • [{alert.metric}] {alert.instance}

    {alert.message}

  • ))}
); } ``` ### 9-9. src/components/report/ReportHeader.tsx ```tsx 'use client'; import { format } from 'date-fns'; import { ko } from 'date-fns/locale'; interface ReportHeaderProps { start: string; end: string; onExportPdf: () => void; } export default function ReportHeader({ start, end, onExportPdf }: ReportHeaderProps) { const startDate = format(new Date(start), 'yyyy.MM.dd', { locale: ko }); const endDate = format(new Date(end), 'yyyy.MM.dd', { locale: ko }); return (

인프라 주간 모니터링 보고서

{startDate} ~ {endDate}

); } ``` --- ## STEP 10. 메인 페이지 ✅ ### 10-1. src/app/page.tsx ```tsx 'use client'; import { useRef, useState } from 'react'; import { useReport } from '@/hooks/useReport'; import ReportHeader from '@/components/report/ReportHeader'; import SummaryCards from '@/components/report/SummaryCards'; import TimeSeriesChart from '@/components/charts/TimeSeriesChart'; import NetworkChart from '@/components/charts/NetworkChart'; import NodeTable from '@/components/report/NodeTable'; import AlertSection from '@/components/report/AlertSection'; import html2canvas from 'html2canvas'; import jsPDF from 'jspdf'; export default function Home() { const [days, setDays] = useState(7); const { data, isLoading, error } = useReport(days); const reportRef = useRef(null); const handleExportPdf = async () => { if (!reportRef.current) return; const canvas = await html2canvas(reportRef.current, { scale: 2, useCORS: true }); const imgData = canvas.toDataURL('image/png'); const pdf = new jsPDF('p', 'mm', 'a4'); const pdfWidth = pdf.internal.pageSize.getWidth(); const pdfHeight = (canvas.height * pdfWidth) / canvas.width; let heightLeft = pdfHeight; let position = 0; pdf.addImage(imgData, 'PNG', 0, position, pdfWidth, pdfHeight); heightLeft -= pdf.internal.pageSize.getHeight(); while (heightLeft > 0) { position -= pdf.internal.pageSize.getHeight(); pdf.addPage(); pdf.addImage(imgData, 'PNG', 0, position, pdfWidth, pdfHeight); heightLeft -= pdf.internal.pageSize.getHeight(); } pdf.save(`infra-report-${days}d.pdf`); }; if (isLoading) { return (
데이터 로딩 중...
); } if (error || !data) { return (
데이터 로딩 실패: {error?.message}
); } return (
{[1, 3, 7, 14, 30].map((d) => ( ))}
{data.metrics.nas.length > 0 && ( )}

노드별 상세

주의 구간

); } ``` --- ## STEP 11. 빌드 및 배포 ✅ ### 11-1. 로컬 개발 ```bash npm run dev ``` ### 11-2. 수동 배포 (CI 없이 테스트 시) ```bash docker build -t harbor.inje-private.com/infra/infra-report:v0.1.0 . docker push harbor.inje-private.com/infra/infra-report:v0.1.0 kubectl create secret docker-registry harbor-pull-secret \ --docker-server=harbor.inje-private.com \ --docker-username=admin \ --docker-password='Ijinc123!@#$' \ -n soo helm install infra-report charts/infra-report -n soo \ --set image.tag=v0.1.0 ``` ### 11-3. CI/CD 배포 (정상 흐름) ```bash git add . git commit -m "feat: initial infra-report" git remote add origin https://gitea.inje-private.com/soo-rnd/infra-report.git git push -u origin main ``` 이후 자동: 1. Gitea Actions → Docker build + push + Helm push 2. ArgoCD → Helm chart 변경 감지 → soo namespace 배포 ### 11-4. 배포 확인 ```bash kubectl get pods -n soo -l app.kubernetes.io/name=infra-report kubectl logs -n soo -l app.kubernetes.io/name=infra-report curl https://infra-report.gpulive.cloud/api/health argocd app get infra-report ``` --- ## 파일 생성 순서 요약 | 순서 | 파일 | 역할 | |---|---|---| | 1 | next.config.js | standalone 빌드 설정 | | 2 | .env.local | 로컬 환경변수 | | 3 | Dockerfile | 컨테이너 빌드 | | 4 | .dockerignore | 빌드 제외 | | 5 | charts/infra-report/Chart.yaml | Helm 차트 정의 | | 6 | charts/infra-report/values.yaml | Helm 기본값 | | 7 | charts/infra-report/templates/_helpers.tpl | Helm 헬퍼 | | 8 | charts/infra-report/templates/serviceaccount.yaml | SA | | 9 | charts/infra-report/templates/clusterrole.yaml | RBAC Role | | 10 | charts/infra-report/templates/clusterrolebinding.yaml | RBAC Binding | | 11 | charts/infra-report/templates/configmap.yaml | 환경변수 | | 12 | charts/infra-report/templates/deployment.yaml | 워크로드 | | 13 | charts/infra-report/templates/service.yaml | 서비스 | | 14 | charts/infra-report/templates/ingress.yaml | 외부 접근 | | 15 | .gitea/workflows/ci.yaml | CI/CD 파이프라인 | | 16 | src/types/metrics.ts | 타입 정의 | | 17 | src/lib/prometheus.ts | Prometheus 클라이언트 | | 18 | src/lib/kubernetes.ts | K8s 클라이언트 | | 19 | src/lib/analytics.ts | 통계/Peak 감지 | | 20 | src/lib/formatters.ts | 단위 변환 | | 21 | src/app/api/health/route.ts | 헬스체크 | | 22 | src/app/api/nodes/route.ts | 노드 API | | 23 | src/app/api/metrics/[type]/route.ts | 메트릭 API | | 24 | src/app/api/report/route.ts | 통합 보고서 API | | 25 | src/app/providers.tsx | React Query Provider | | 26 | src/app/layout.tsx | 레이아웃 | | 27 | src/hooks/useReport.ts | 데이터 조회 훅 | | 28 | src/components/report/ReportHeader.tsx | 보고서 헤더 | | 29 | src/components/report/SummaryCards.tsx | 요약 카드 | | 30 | src/components/charts/TimeSeriesChart.tsx | 시계열 차트 | | 31 | src/components/charts/NetworkChart.tsx | 네트워크 차트 | | 32 | src/components/report/NodeTable.tsx | 노드 테이블 | | 33 | src/components/report/AlertSection.tsx | 알림 섹션 | | 34 | src/app/page.tsx | 메인 페이지 | --- ## 남은 작업 체크리스트 | 작업 | 상태 | |---|---| | Gitea repo 생성 | 완료 | | Gitea Actions/Secrets/Runner | 완료 | | Harbor `infra` 프로젝트 생성 | 확인 필요 | | Next.js 프로젝트 초기화 | 미완 | | Helm chart 작성 | 미완 | | CI 워크플로우 작성 | 미완 | | imagePullSecret 생성 (soo ns) | 미완 | | ArgoCD Harbor OCI repo 등록 | 미완 | | ArgoCD Application 생성 | 미완 | | 앱 코드 구현 (lib/api/components) | 미완 | | 첫 배포 + 동작 확인 | 미완 |