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 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/deployment.yaml b/infra/k8s/base/api/deployment.yaml new file mode 100644 index 0000000..f94b146 --- /dev/null +++ b/infra/k8s/base/api/deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + labels: + app.kubernetes.io/name: api + app.kubernetes.io/component: backend + app.kubernetes.io/part-of: devopsim +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: api + template: + metadata: + labels: + app.kubernetes.io/name: api + app.kubernetes.io/component: backend + app.kubernetes.io/part-of: devopsim + 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 + + 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/api/ingress.yaml b/infra/k8s/base/api/ingress.yaml new file mode 100644 index 0000000..472661c --- /dev/null +++ b/infra/k8s/base/api/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress # nginx를 쓴다 +metadata: + name: api + labels: + app.kubernetes.io/name: api + app.kubernetes.io/component: backend + app.kubernetes.io/part-of: devopsim + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + # +spec: + ingressClassName: nginx + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api + port: + number: 80 +# L4 +# kind: Ingress vs Service: lb 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/api/service.yaml b/infra/k8s/base/api/service.yaml new file mode 100644 index 0000000..38936bb --- /dev/null +++ b/infra/k8s/base/api/service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: api + labels: + app.kubernetes.io/name: api + app.kubernetes.io/component: backend + app.kubernetes.io/part-of: devopsim +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: api + ports: + - name: http + port: 80 + targetPort: 3000 + protocol: TCP +# internal loadbalancer 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..2a67ad5 --- /dev/null +++ b/infra/k8s/base/db/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: db + labels: + app.kubernetes.io/name: db + app.kubernetes.io/component: database + app.kubernetes.io/part-of: devopsim +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: 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..4d2db24 --- /dev/null +++ b/infra/k8s/base/db/statefulset.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: db + labels: + app.kubernetes.io/name: db + app.kubernetes.io/component: database + app.kubernetes.io/part-of: devopsim +spec: + serviceName: db + replicas: 3 + selector: + matchLabels: + app.kubernetes.io/name: db + template: + metadata: + labels: + app.kubernetes.io/name: db + app.kubernetes.io/component: database + app.kubernetes.io/part-of: devopsim + 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 new file mode 100644 index 0000000..d35fc5c --- /dev/null +++ b/infra/k8s/base/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - api/deployment.yaml + - api/service.yaml + - api/ingress.yaml + - db/statefulset.yaml + - db/service.yaml diff --git a/infra/k8s/overlays/local/kustomization.yaml b/infra/k8s/overlays/local/kustomization.yaml new file mode 100644 index 0000000..5109732 --- /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 # metadata.name 으로 매핑 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 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/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' }) + } + }) } 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) }) })