From 18ee20283f6bdfbd8fb5c633ac1d8ed9228baa44 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 5 Apr 2026 22:29:10 +0900 Subject: [PATCH 1/8] feat: add /ready endpoint with DB health check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add checkDbHealth decorator to dbPlugin (routes don't access pg pool directly) - Add /ready route using app.checkDbHealth() — returns 503 when DB is unavailable - /health remains liveness-only (process alive, DB-agnostic) Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/plugins/db.ts | 10 ++++++++++ packages/api/src/routes/health.ts | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/api/src/plugins/db.ts b/packages/api/src/plugins/db.ts index 2422d40..edc3e43 100644 --- a/packages/api/src/plugins/db.ts +++ b/packages/api/src/plugins/db.ts @@ -2,10 +2,20 @@ import fp from 'fastify-plugin' import postgres from '@fastify/postgres' import { FastifyInstance } from 'fastify' +declare module 'fastify' { + interface FastifyInstance { + checkDbHealth: () => Promise + } +} + async function dbPlugin(app: FastifyInstance) { app.register(postgres, { connectionString: process.env.DATABASE_URL, }) + + app.decorate('checkDbHealth', async () => { + await app.pg.pool.query('SELECT 1') + }) } export default fp(dbPlugin) diff --git a/packages/api/src/routes/health.ts b/packages/api/src/routes/health.ts index a04d3f9..4c9ef2e 100644 --- a/packages/api/src/routes/health.ts +++ b/packages/api/src/routes/health.ts @@ -5,4 +5,13 @@ export default async function healthRoute(app: FastifyInstance) { app.get<{ Reply: HealthResponse }>('/health', async () => { return { status: 'ok' } }) + + app.get('/ready', async (_req, reply) => { + try { + await app.checkDbHealth() + reply.send({ status: 'ok' }) + } catch (err) { + reply.code(503).send({ status: 'unavailable' }) + } + }) } From fd5890ab4e38788e79478590689d51e6c0f5c51d Mon Sep 17 00:00:00 2001 From: jun Date: Mon, 6 Apr 2026 02:27:11 +0900 Subject: [PATCH 2/8] chore: add session logging hook and doc-writer skill - PostToolUse hook: auto-logs bash commands + output to .claude/session-log.md - doc-writer skill: reads session-log.md and writes troubleshooting docs - bypassPermissions mode in settings.json - session-log.md added to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .claude/hooks/log-bash.sh | 38 +++++++++++++++++ .claude/settings.json | 18 +++++++++ .claude/skills/doc-writer/SKILL.md | 65 ++++++++++++++++++++++++++++++ .gitignore | 1 + 4 files changed, 122 insertions(+) create mode 100755 .claude/hooks/log-bash.sh create mode 100644 .claude/settings.json create mode 100644 .claude/skills/doc-writer/SKILL.md diff --git a/.claude/hooks/log-bash.sh b/.claude/hooks/log-bash.sh new file mode 100755 index 0000000..3e3493a --- /dev/null +++ b/.claude/hooks/log-bash.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# PostToolUse 훅: Bash 툴 실행 결과를 session-log.md에 기록 +# stdin으로 JSON을 받아 command와 output을 추출한다 + +INPUT=$(cat) + +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""') +OUTPUT=$(echo "$INPUT" | jq -r '.tool_response | if type == "string" then . else (.stdout // "") end' 2>/dev/null || echo "") +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +# 출력이 없거나 짧은 명령어는 기록하지 않음 +if [ -z "$OUTPUT" ] || [ "$OUTPUT" = "null" ] || [ ${#OUTPUT} -lt 10 ]; then + exit 0 +fi + +# git, ls 같은 탐색 명령어는 스킵 +case "$COMMAND" in + git\ log*|git\ status*|git\ diff*|ls*|pwd|echo*|cat*) + exit 0 + ;; +esac + +LOG_FILE=".claude/session-log.md" + +# 파일이 없으면 헤더 생성 +if [ ! -f "$LOG_FILE" ]; then + echo "# Session Log" > "$LOG_FILE" + echo "" >> "$LOG_FILE" +fi + +{ + echo "### $TIMESTAMP" + echo '```bash' + echo "$ $COMMAND" + echo "$OUTPUT" + echo '```' + echo "" +} >> "$LOG_FILE" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1111815 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "defaultMode": "bypassPermissions" + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/log-bash.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/doc-writer/SKILL.md b/.claude/skills/doc-writer/SKILL.md new file mode 100644 index 0000000..63f692f --- /dev/null +++ b/.claude/skills/doc-writer/SKILL.md @@ -0,0 +1,65 @@ +--- +name: doc-writer +description: 최근 구현 작업을 분석해 docs/ 에 트러블슈팅 기록을 작성한다. 에러 메시지, 원인, 해결책, 재현 명령어를 포함한다. +argument-hint: [docs-file-path] +allowed-tools: Read Glob Grep Bash Edit Write +--- + +## 역할 + +최근 작업에서 발생한 에러와 해결 과정을 분석해 `docs/` 디렉토리에 트러블슈팅 문서를 작성한다. + +## 작업 순서 + +### 1. 세션 로그 확인 + +`.claude/session-log.md`를 읽어 이번 세션에서 실행된 명령어와 출력을 파악한다. +에러가 포함된 출력을 중심으로 문서화 대상을 추출한다. + +### 2. 문서 파일 결정 + +`$ARGUMENTS`가 주어지면 해당 경로에 작성한다. +비어있으면 `docs/` 디렉토리의 기존 파일 목록을 확인하고 가장 관련성 높은 파일을 선택한다. +관련 파일이 없으면 새 파일을 생성한다. + +### 3. 현재 대화 컨텍스트에서 에러 수집 + +대화에서 발생했던 에러를 정리한다: +- 실제 에러 메시지 (터미널 출력 그대로) +- 에러가 발생한 상황 +- 시도했던 해결 방법들 +- 최종 해결책 + +### 4. 문서 작성 + +아래 형식으로 각 문제를 작성한다: + +```markdown +## 문제 N: 제목 — 한 줄 요약 + +### 증상 + +\``` +실제 에러 메시지를 그대로 붙여넣는다. +생략하거나 요약하지 않는다. +\``` + +### 원인 + +왜 이 에러가 발생했는지 설명한다. + +### 해결 + +실제로 동작한 명령어나 코드 변경사항을 작성한다. + +\```bash +# 실행한 명령어 +\``` +``` + +## 주의사항 + +- 에러 메시지는 절대 요약하지 않는다. 터미널 출력 그대로 코드 블록에 넣는다. +- 해결되지 않은 문제는 "미해결" 또는 "근본 해결 방향"으로 구분한다. +- 기존 문서가 있으면 덮어쓰지 말고 새 섹션을 추가한다. +- 재현 가능한 최소 명령어를 항상 포함한다. diff --git a/.gitignore b/.gitignore index 3232860..0dc9b13 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ !.env.example *.log .plan/ +.claude/session-log.md From 870a5aa581b1c923d52a9f43824cf2101136ac26 Mon Sep 17 00:00:00 2001 From: jun Date: Mon, 6 Apr 2026 20:36:21 +0900 Subject: [PATCH 3/8] feat: add initial Kubernetes Deployment and Kustomize base Co-Authored-By: Claude Sonnet 4.6 --- infra/k8s/base/deployment.yaml | 41 +++++++++++++++++++++++++++++++ infra/k8s/base/kustomization.yaml | 5 ++++ 2 files changed, 46 insertions(+) create mode 100644 infra/k8s/base/deployment.yaml create mode 100644 infra/k8s/base/kustomization.yaml diff --git a/infra/k8s/base/deployment.yaml b/infra/k8s/base/deployment.yaml new file mode 100644 index 0000000..08674de --- /dev/null +++ b/infra/k8s/base/deployment.yaml @@ -0,0 +1,41 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + labels: + app: api +spec: + replicas: 1 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: devopsim-api:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3000 + protocol: TCP + + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + timeoutSeconds: 5 + + readinessProbe: + httpGet: + path: /ready + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 3 + timeoutSeconds: 3 diff --git a/infra/k8s/base/kustomization.yaml b/infra/k8s/base/kustomization.yaml new file mode 100644 index 0000000..88a04b5 --- /dev/null +++ b/infra/k8s/base/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - deployment.yaml From 5b731fda1635438c99799150b7597a33a3d2c0c3 Mon Sep 17 00:00:00 2001 From: jun Date: Mon, 6 Apr 2026 21:28:10 +0900 Subject: [PATCH 4/8] feat: add PostgreSQL StatefulSet and DB connection to api - Move deployment.yaml into api/ directory - Add api/secret.yaml (api-secret structure) - Add db/statefulset.yaml with liveness/readiness probes and PVC - Add db/service.yaml (ClusterIP, name: db) - Add db/secret.yaml (postgres-secret structure) - Add wait-for-postgres initContainer and DATABASE_URL env to api Deployment - Update kustomization.yaml resource paths Co-Authored-By: Claude Sonnet 4.6 --- infra/k8s/base/{ => api}/deployment.yaml | 16 ++++++ infra/k8s/base/api/secret.yaml | 10 ++++ infra/k8s/base/db/secret.yaml | 14 +++++ infra/k8s/base/db/service.yaml | 15 ++++++ infra/k8s/base/db/statefulset.yaml | 66 ++++++++++++++++++++++++ infra/k8s/base/kustomization.yaml | 4 +- 6 files changed, 124 insertions(+), 1 deletion(-) rename infra/k8s/base/{ => api}/deployment.yaml (66%) create mode 100644 infra/k8s/base/api/secret.yaml create mode 100644 infra/k8s/base/db/secret.yaml create mode 100644 infra/k8s/base/db/service.yaml create mode 100644 infra/k8s/base/db/statefulset.yaml diff --git a/infra/k8s/base/deployment.yaml b/infra/k8s/base/api/deployment.yaml similarity index 66% rename from infra/k8s/base/deployment.yaml rename to infra/k8s/base/api/deployment.yaml index 08674de..c83a694 100644 --- a/infra/k8s/base/deployment.yaml +++ b/infra/k8s/base/api/deployment.yaml @@ -14,10 +14,26 @@ spec: labels: app: api spec: + initContainers: + - name: wait-for-db + image: postgres:16-alpine + command: + [ + "sh", + "-c", + "until pg_isready -h db -p 5432; do echo 'waiting...'; sleep 2; done", + ] + containers: - name: api image: devopsim-api:latest imagePullPolicy: IfNotPresent + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: api-secret + key: database-url ports: - containerPort: 3000 protocol: TCP diff --git a/infra/k8s/base/api/secret.yaml b/infra/k8s/base/api/secret.yaml new file mode 100644 index 0000000..4a09de4 --- /dev/null +++ b/infra/k8s/base/api/secret.yaml @@ -0,0 +1,10 @@ +# 로컬 적용 방법: +# kubectl create secret generic api-secret \ +# --from-literal=database-url="postgresql://devopsim:devopsim@db:5432/devopsim" +apiVersion: v1 +kind: Secret +metadata: + name: api-secret +type: Opaque +stringData: + database-url: "" diff --git a/infra/k8s/base/db/secret.yaml b/infra/k8s/base/db/secret.yaml new file mode 100644 index 0000000..5eb3794 --- /dev/null +++ b/infra/k8s/base/db/secret.yaml @@ -0,0 +1,14 @@ +# 로컬 적용 방법: +# kubectl create secret generic postgres-secret \ +# --from-literal=postgres-db=devopsim \ +# --from-literal=postgres-user=devopsim \ +# --from-literal=postgres-password=devopsim +apiVersion: v1 +kind: Secret +metadata: + name: db-secret +type: Opaque +stringData: + postgres-db: "" + postgres-user: "" + postgres-password: "" diff --git a/infra/k8s/base/db/service.yaml b/infra/k8s/base/db/service.yaml new file mode 100644 index 0000000..82a5778 --- /dev/null +++ b/infra/k8s/base/db/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: db + labels: + app: db +spec: + type: ClusterIP + selector: + app: db + ports: + - name: postgres + port: 5432 + targetPort: 5432 + protocol: TCP diff --git a/infra/k8s/base/db/statefulset.yaml b/infra/k8s/base/db/statefulset.yaml new file mode 100644 index 0000000..eea1951 --- /dev/null +++ b/infra/k8s/base/db/statefulset.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: db + labels: + app: db +spec: + serviceName: db + replicas: 1 + selector: + matchLabels: + app: db + template: + metadata: + labels: + app: db + spec: + containers: + - name: db + image: postgres:16-alpine + ports: + - containerPort: 5432 + protocol: TCP + env: + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: postgres-secret + key: postgres-db + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: postgres-secret + key: postgres-user + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-secret + key: postgres-password + + livenessProbe: + exec: + command: ["pg_isready", "-U", "devopsim"] + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 5 + + readinessProbe: + exec: + command: ["pg_isready", "-U", "devopsim"] + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 5 + + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi diff --git a/infra/k8s/base/kustomization.yaml b/infra/k8s/base/kustomization.yaml index 88a04b5..2b64946 100644 --- a/infra/k8s/base/kustomization.yaml +++ b/infra/k8s/base/kustomization.yaml @@ -2,4 +2,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - deployment.yaml + - api/deployment.yaml + - db/statefulset.yaml + - db/service.yaml From 69fab591d901a15f1a59aa278837db9024f82de2 Mon Sep 17 00:00:00 2001 From: jun Date: Mon, 6 Apr 2026 21:31:16 +0900 Subject: [PATCH 5/8] feat: add local overlay with Kustomize - replicas: 3 for api - imagePullPolicy: Never patch (minikube local image) - environment: local label - migrate-job.yaml for DB migration (local only) Co-Authored-By: Claude Sonnet 4.6 --- infra/k8s/overlays/local/kustomization.yaml | 29 ++++++++++++++++ infra/k8s/overlays/local/migrate-job.yaml | 38 +++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 infra/k8s/overlays/local/kustomization.yaml create mode 100644 infra/k8s/overlays/local/migrate-job.yaml diff --git a/infra/k8s/overlays/local/kustomization.yaml b/infra/k8s/overlays/local/kustomization.yaml new file mode 100644 index 0000000..4c1fd40 --- /dev/null +++ b/infra/k8s/overlays/local/kustomization.yaml @@ -0,0 +1,29 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + - migrate-job.yaml + +labels: + - pairs: + version: "0.0.1" + environment: local + includeSelectors: false + +replicas: + - name: api + count: 3 + +images: + - name: devopsim-api + newTag: latest + +patches: + - patch: |- + - op: replace + path: /spec/template/spec/containers/0/imagePullPolicy + value: Never + target: + kind: Deployment + name: api diff --git a/infra/k8s/overlays/local/migrate-job.yaml b/infra/k8s/overlays/local/migrate-job.yaml new file mode 100644 index 0000000..b0065e0 --- /dev/null +++ b/infra/k8s/overlays/local/migrate-job.yaml @@ -0,0 +1,38 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate +spec: + ttlSecondsAfterFinished: 60 # 완료 후 1분 뒤 자동 삭제 + backoffLimit: 3 # 실패 시 최대 3번 재시도 + template: + spec: + restartPolicy: OnFailure + + initContainers: + - name: wait-for-db + image: postgres:16-alpine + command: + [ + "sh", + "-c", + 'until pg_isready -h db -p 5432; do echo "waiting..."; sleep 2; done', + ] + + containers: + - name: migrate + image: devopsim-api:latest + imagePullPolicy: Never + command: + [ + "node_modules/.bin/node-pg-migrate", + "-m", + "packages/api/migrations", + "up", + ] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: api-secret + key: database-url From e751945ae899a50048c8809a1e6f31a37f8f8c3a Mon Sep 17 00:00:00 2001 From: jun Date: Mon, 6 Apr 2026 21:32:09 +0900 Subject: [PATCH 6/8] feat: add Service, Ingress and /api prefix to items routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move service.yaml and ingress.yaml into api/ directory - Add api Service (ClusterIP port 80→3000) - Add Ingress (nginx, routes / to api service) - Register itemsRoute with prefix /api in app.ts - Update test URLs /items → /api/items - Update README endpoint table and curl examples Co-Authored-By: Claude Sonnet 4.6 --- README.md | 14 +++++----- infra/k8s/base/api/ingress.yaml | 20 +++++++++++++++ infra/k8s/base/api/service.yaml | 15 +++++++++++ infra/k8s/base/kustomization.yaml | 2 ++ packages/api/src/app.ts | 2 +- packages/api/src/test/items.test.ts | 40 ++++++++++++++--------------- 6 files changed, 65 insertions(+), 28 deletions(-) create mode 100644 infra/k8s/base/api/ingress.yaml create mode 100644 infra/k8s/base/api/service.yaml diff --git a/README.md b/README.md index a2ebf64..60dea32 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,12 @@ curl http://localhost:3000/health curl http://localhost:3000/ready # 아이템 생성 -curl -X POST http://localhost:3000/items \ +curl -X POST http://localhost:3000/api/items \ -H "Content-Type: application/json" \ -d '{"name": "test", "description": "hello"}' # 목록 조회 -curl http://localhost:3000/items +curl http://localhost:3000/api/items ``` ### 4. 종료 @@ -148,11 +148,11 @@ TEST_DATABASE_URL=postgresql://devopsim:devopsim@localhost:5432/devopsim \ |--------|------|------| | GET | /health | liveness — 프로세스 생존 확인 | | GET | /ready | readiness — DB 연결 상태 확인 | -| POST | /items | 아이템 생성 | -| GET | /items | 목록 조회 | -| GET | /items/:id | 상세 조회 | -| PUT | /items/:id | 수정 | -| DELETE | /items/:id | 삭제 | +| POST | /api/items | 아이템 생성 | +| GET | /api/items | 목록 조회 | +| GET | /api/items/:id | 상세 조회 | +| PUT | /api/items/:id | 수정 | +| DELETE | /api/items/:id | 삭제 | --- ## 주차별 진행 diff --git a/infra/k8s/base/api/ingress.yaml b/infra/k8s/base/api/ingress.yaml new file mode 100644 index 0000000..842612c --- /dev/null +++ b/infra/k8s/base/api/ingress.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: api + labels: + app: api + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / +spec: + ingressClassName: nginx + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api + port: + number: 80 diff --git a/infra/k8s/base/api/service.yaml b/infra/k8s/base/api/service.yaml new file mode 100644 index 0000000..0afa7dc --- /dev/null +++ b/infra/k8s/base/api/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: api + labels: + app: api +spec: + type: ClusterIP + selector: + app: api + ports: + - name: http + port: 80 + targetPort: 3000 + protocol: TCP diff --git a/infra/k8s/base/kustomization.yaml b/infra/k8s/base/kustomization.yaml index 2b64946..d35fc5c 100644 --- a/infra/k8s/base/kustomization.yaml +++ b/infra/k8s/base/kustomization.yaml @@ -3,5 +3,7 @@ kind: Kustomization resources: - api/deployment.yaml + - api/service.yaml + - api/ingress.yaml - db/statefulset.yaml - db/service.yaml diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index cef0a3f..50120d1 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -30,7 +30,7 @@ export function buildApp(opts: { logger?: boolean } = {}) { app.after(() => { const repo = pgItemRepository(app.pg.pool) const service = itemService(repo) - app.register(itemsRoute, { service }) + app.register(itemsRoute, { service, prefix: '/api' }) }) app.register(healthRoute) diff --git a/packages/api/src/test/items.test.ts b/packages/api/src/test/items.test.ts index 3ceb64c..f7d7aea 100644 --- a/packages/api/src/test/items.test.ts +++ b/packages/api/src/test/items.test.ts @@ -19,7 +19,7 @@ describe('POST /items', () => { test('정상 요청 → 201 + 생성된 item 반환', async () => { const res = await app.inject({ method: 'POST', - url: '/items', + url: '/api/items', payload: { name: '테스트 아이템', description: '설명' }, }) expect(res.statusCode).toBe(201) @@ -33,7 +33,7 @@ describe('POST /items', () => { test('description 없이 name만 → 201', async () => { const res = await app.inject({ method: 'POST', - url: '/items', + url: '/api/items', payload: { name: '이름만' }, }) expect(res.statusCode).toBe(201) @@ -45,7 +45,7 @@ describe('POST /items', () => { test('name 누락 → 400', async () => { const res = await app.inject({ method: 'POST', - url: '/items', + url: '/api/items', payload: { description: '이름없음' }, }) expect(res.statusCode).toBe(400) @@ -54,7 +54,7 @@ describe('POST /items', () => { test('허용되지 않은 필드 포함 → Fastify가 strip 후 201 (additionalProperties 제거)', async () => { const res = await app.inject({ method: 'POST', - url: '/items', + url: '/api/items', payload: { name: '아이템', hack: true }, }) // Fastify 기본 동작: 추가 필드를 거절이 아닌 제거(strip)하고 처리 @@ -67,7 +67,7 @@ describe('POST /items', () => { test('name 0자(빈 문자열) → 400', async () => { const res = await app.inject({ method: 'POST', - url: '/items', + url: '/api/items', payload: { name: '' }, }) expect(res.statusCode).toBe(400) @@ -76,7 +76,7 @@ describe('POST /items', () => { test('name 1자(최솟값) → 201', async () => { const res = await app.inject({ method: 'POST', - url: '/items', + url: '/api/items', payload: { name: 'a' }, }) expect(res.statusCode).toBe(201) @@ -85,7 +85,7 @@ describe('POST /items', () => { test('name 255자(최댓값) → 201', async () => { const res = await app.inject({ method: 'POST', - url: '/items', + url: '/api/items', payload: { name: 'a'.repeat(255) }, }) expect(res.statusCode).toBe(201) @@ -94,7 +94,7 @@ describe('POST /items', () => { test('name 256자(최댓값+1) → 400', async () => { const res = await app.inject({ method: 'POST', - url: '/items', + url: '/api/items', payload: { name: 'a'.repeat(256) }, }) expect(res.statusCode).toBe(400) @@ -121,13 +121,13 @@ describe('GET /items/:id', () => { // --- ECP --- test('존재하는 id → 200 + item 반환', async () => { - const res = await app.inject({ method: 'GET', url: `/items/${createdId}` }) + const res = await app.inject({ method: 'GET', url: `/api/items/${createdId}` }) expect(res.statusCode).toBe(200) expect(res.json().id).toBe(createdId) }) test('존재하지 않는 id → 404', async () => { - const res = await app.inject({ method: 'GET', url: '/items/999999' }) + const res = await app.inject({ method: 'GET', url: '/api/items/999999' }) expect(res.statusCode).toBe(404) expect(res.json().message).toBe('Item not found') }) @@ -155,7 +155,7 @@ describe('PUT /items/:id', () => { test('name만 수정 → 200 + 변경 반영', async () => { const res = await app.inject({ method: 'PUT', - url: `/items/${createdId}`, + url: `/api/items/${createdId}`, payload: { name: '수정된 이름' }, }) expect(res.statusCode).toBe(200) @@ -166,7 +166,7 @@ describe('PUT /items/:id', () => { test('description만 수정 → 200', async () => { const res = await app.inject({ method: 'PUT', - url: `/items/${createdId}`, + url: `/api/items/${createdId}`, payload: { description: '수정된 설명' }, }) expect(res.statusCode).toBe(200) @@ -176,7 +176,7 @@ describe('PUT /items/:id', () => { test('name + description 동시 수정 → 200', async () => { const res = await app.inject({ method: 'PUT', - url: `/items/${createdId}`, + url: `/api/items/${createdId}`, payload: { name: '새이름', description: '새설명' }, }) expect(res.statusCode).toBe(200) @@ -189,7 +189,7 @@ describe('PUT /items/:id', () => { test('빈 body → 400', async () => { const res = await app.inject({ method: 'PUT', - url: `/items/${createdId}`, + url: `/api/items/${createdId}`, payload: {}, }) expect(res.statusCode).toBe(400) @@ -198,7 +198,7 @@ describe('PUT /items/:id', () => { test('존재하지 않는 id → 404', async () => { const res = await app.inject({ method: 'PUT', - url: '/items/999999', + url: '/api/items/999999', payload: { name: '수정' }, }) expect(res.statusCode).toBe(404) @@ -209,7 +209,7 @@ describe('PUT /items/:id', () => { test('name 1자 수정(최솟값) → 200', async () => { const res = await app.inject({ method: 'PUT', - url: `/items/${createdId}`, + url: `/api/items/${createdId}`, payload: { name: 'a' }, }) expect(res.statusCode).toBe(200) @@ -218,7 +218,7 @@ describe('PUT /items/:id', () => { test('name 255자 수정(최댓값) → 200', async () => { const res = await app.inject({ method: 'PUT', - url: `/items/${createdId}`, + url: `/api/items/${createdId}`, payload: { name: 'a'.repeat(255) }, }) expect(res.statusCode).toBe(200) @@ -227,7 +227,7 @@ describe('PUT /items/:id', () => { test('name 256자 수정(최댓값+1) → 400', async () => { const res = await app.inject({ method: 'PUT', - url: `/items/${createdId}`, + url: `/api/items/${createdId}`, payload: { name: 'a'.repeat(256) }, }) expect(res.statusCode).toBe(400) @@ -252,12 +252,12 @@ describe('DELETE /items/:id', () => { }) test('존재하는 id → 204', async () => { - const res = await app.inject({ method: 'DELETE', url: `/items/${createdId}` }) + const res = await app.inject({ method: 'DELETE', url: `/api/items/${createdId}` }) expect(res.statusCode).toBe(204) }) test('존재하지 않는 id → 404', async () => { - const res = await app.inject({ method: 'DELETE', url: '/items/999999' }) + const res = await app.inject({ method: 'DELETE', url: '/api/items/999999' }) expect(res.statusCode).toBe(404) }) }) From d648057b0ede4d30b2af59e0cb28f3774430fcd8 Mon Sep 17 00:00:00 2001 From: jun Date: Mon, 6 Apr 2026 21:42:17 +0900 Subject: [PATCH 7/8] refactor: apply official Kubernetes recommended labels - Replace app: with app.kubernetes.io/name, component, part-of - api: component=backend, db: component=database - part-of=devopsim on all resources Co-Authored-By: Claude Sonnet 4.6 --- infra/k8s/base/api/deployment.yaml | 10 +++++++--- infra/k8s/base/api/ingress.yaml | 4 +++- infra/k8s/base/api/service.yaml | 6 ++++-- infra/k8s/base/db/service.yaml | 6 ++++-- infra/k8s/base/db/statefulset.yaml | 10 +++++++--- infra/k8s/overlays/local/kustomization.yaml | 2 +- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/infra/k8s/base/api/deployment.yaml b/infra/k8s/base/api/deployment.yaml index c83a694..f94b146 100644 --- a/infra/k8s/base/api/deployment.yaml +++ b/infra/k8s/base/api/deployment.yaml @@ -3,16 +3,20 @@ kind: Deployment metadata: name: api labels: - app: api + app.kubernetes.io/name: api + app.kubernetes.io/component: backend + app.kubernetes.io/part-of: devopsim spec: replicas: 1 selector: matchLabels: - app: api + app.kubernetes.io/name: api template: metadata: labels: - app: api + app.kubernetes.io/name: api + app.kubernetes.io/component: backend + app.kubernetes.io/part-of: devopsim spec: initContainers: - name: wait-for-db diff --git a/infra/k8s/base/api/ingress.yaml b/infra/k8s/base/api/ingress.yaml index 842612c..ae46607 100644 --- a/infra/k8s/base/api/ingress.yaml +++ b/infra/k8s/base/api/ingress.yaml @@ -3,7 +3,9 @@ kind: Ingress metadata: name: api labels: - app: api + app.kubernetes.io/name: api + app.kubernetes.io/component: backend + app.kubernetes.io/part-of: devopsim annotations: nginx.ingress.kubernetes.io/rewrite-target: / spec: diff --git a/infra/k8s/base/api/service.yaml b/infra/k8s/base/api/service.yaml index 0afa7dc..25c4f28 100644 --- a/infra/k8s/base/api/service.yaml +++ b/infra/k8s/base/api/service.yaml @@ -3,11 +3,13 @@ kind: Service metadata: name: api labels: - app: api + app.kubernetes.io/name: api + app.kubernetes.io/component: backend + app.kubernetes.io/part-of: devopsim spec: type: ClusterIP selector: - app: api + app.kubernetes.io/name: api ports: - name: http port: 80 diff --git a/infra/k8s/base/db/service.yaml b/infra/k8s/base/db/service.yaml index 82a5778..2a67ad5 100644 --- a/infra/k8s/base/db/service.yaml +++ b/infra/k8s/base/db/service.yaml @@ -3,11 +3,13 @@ kind: Service metadata: name: db labels: - app: db + app.kubernetes.io/name: db + app.kubernetes.io/component: database + app.kubernetes.io/part-of: devopsim spec: type: ClusterIP selector: - app: db + app.kubernetes.io/name: db ports: - name: postgres port: 5432 diff --git a/infra/k8s/base/db/statefulset.yaml b/infra/k8s/base/db/statefulset.yaml index eea1951..2f8c542 100644 --- a/infra/k8s/base/db/statefulset.yaml +++ b/infra/k8s/base/db/statefulset.yaml @@ -3,17 +3,21 @@ kind: StatefulSet metadata: name: db labels: - app: db + app.kubernetes.io/name: db + app.kubernetes.io/component: database + app.kubernetes.io/part-of: devopsim spec: serviceName: db replicas: 1 selector: matchLabels: - app: db + app.kubernetes.io/name: db template: metadata: labels: - app: db + app.kubernetes.io/name: db + app.kubernetes.io/component: database + app.kubernetes.io/part-of: devopsim spec: containers: - name: db diff --git a/infra/k8s/overlays/local/kustomization.yaml b/infra/k8s/overlays/local/kustomization.yaml index 4c1fd40..5109732 100644 --- a/infra/k8s/overlays/local/kustomization.yaml +++ b/infra/k8s/overlays/local/kustomization.yaml @@ -26,4 +26,4 @@ patches: value: Never target: kind: Deployment - name: api + name: api # metadata.name 으로 매핑 From 68265678f706e4233fae92ffec96470f1dc568bc Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 11 Apr 2026 15:29:04 +0900 Subject: [PATCH 8/8] chore: add inline comments to k8s manifests Co-Authored-By: Claude Sonnet 4.6 --- infra/k8s/base/api/ingress.yaml | 5 ++++- infra/k8s/base/api/service.yaml | 1 + infra/k8s/base/db/statefulset.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/infra/k8s/base/api/ingress.yaml b/infra/k8s/base/api/ingress.yaml index ae46607..472661c 100644 --- a/infra/k8s/base/api/ingress.yaml +++ b/infra/k8s/base/api/ingress.yaml @@ -1,5 +1,5 @@ apiVersion: networking.k8s.io/v1 -kind: Ingress +kind: Ingress # nginx를 쓴다 metadata: name: api labels: @@ -8,6 +8,7 @@ metadata: app.kubernetes.io/part-of: devopsim annotations: nginx.ingress.kubernetes.io/rewrite-target: / + # spec: ingressClassName: nginx rules: @@ -20,3 +21,5 @@ spec: name: api port: number: 80 +# L4 +# kind: Ingress vs Service: lb diff --git a/infra/k8s/base/api/service.yaml b/infra/k8s/base/api/service.yaml index 25c4f28..38936bb 100644 --- a/infra/k8s/base/api/service.yaml +++ b/infra/k8s/base/api/service.yaml @@ -15,3 +15,4 @@ spec: port: 80 targetPort: 3000 protocol: TCP +# internal loadbalancer diff --git a/infra/k8s/base/db/statefulset.yaml b/infra/k8s/base/db/statefulset.yaml index 2f8c542..4d2db24 100644 --- a/infra/k8s/base/db/statefulset.yaml +++ b/infra/k8s/base/db/statefulset.yaml @@ -8,7 +8,7 @@ metadata: app.kubernetes.io/part-of: devopsim spec: serviceName: db - replicas: 1 + replicas: 3 selector: matchLabels: app.kubernetes.io/name: db