diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..13cf64be --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Node & TypeScript +node_modules +dist +npm-debug.log +yarn-debug.log +yarn-error.log + +# Environment +.env +.env.* +!env.example # 예시 파일 허용 + +# Git / Actions +.git +.gitignore +.github + +# Prisma +src/prisma/*.db +src/prisma/migrations/dev.db-journal +src/prisma/dev.db + +# Docker +Dockerfile +docker-compose.yaml +docker-compose.prod.yaml + +# OS / IDE +.DS_Store +.vscode +.idea +*.swp +*.swo +*.bak +Thumbs.db diff --git a/.env.example b/.env.example index 863bd857..e62c63d6 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,17 @@ # PostgreSQL DB DATABASE_URL=postgresql://username:password@your-rds-endpoint:5432/panda_market -# BACKEND PORT +# App PORT=3000 +BASE_URL=https://pandamarket.mimu.live # JWT Secrets ACCESS_TOKEN_SECRET= REFRESH_TOKEN_SECRET= -# BASE URL (API ORIGIN) -BASE_URL=https://pandamarket.mimu.live +# Uploads +UPLOAD_STRATEGY=local # local | s3 +UPLOAD_DIR=/app/uploads # AWS S3 AWS_REGION=ap-northeast-2 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 00000000..51baef84 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,60 @@ +name: CD + +on: + push: + branches: + - main + +jobs: + deploy: + name: Deploy to EC2 + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Deploy to EC2 + run: | + ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'EOF' + # 작업 디렉토리로 이동 또는 생성 + mkdir -p ~/app + cd ~/app + + # docker-compose.prod.yaml 생성 + cat > docker-compose.prod.yaml << 'YML' + version: "3.9" + services: + web: + image: ${{ secrets.IMAGE }} + env_file: + - .env.prod + ports: + - "3000:3000" + restart: unless-stopped + YML + + # .env.prod 생성 + cat > .env.prod << 'ENV' + DATABASE_URL=${{ secrets.DATABASE_URL }} + ACCESS_TOKEN_SECRET=${{ secrets.ACCESS_TOKEN_SECRET }} + REFRESH_TOKEN_SECRET=${{ secrets.REFRESH_TOKEN_SECRET }} + BASE_URL=${{ secrets.BASE_URL }} + AWS_REGION=${{ secrets.AWS_REGION }} + AWS_S3_BUCKET=${{ secrets.AWS_S3_BUCKET }} + AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} + NODE_ENV=production + PORT=3000 + UPLOAD_STRATEGY=s3 + ENV + + # Docker compose 재시작 + docker compose -f docker-compose.prod.yaml pull + docker compose -f docker-compose.prod.yaml up -d + EOF diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f618e70f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI (PR) + +on: + pull_request: + branches: ["**"] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: panda_market_test + POSTGRES_USER: app + POSTGRES_PASSWORD: app + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U app -d panda_market_test" + --health-interval=5s --health-timeout=3s --health-retries=10 + + env: + NODE_ENV: test + DATABASE_URL: postgres://app:app@localhost:5432/panda_market_test + ACCESS_TOKEN_SECRET: test-access + REFRESH_TOKEN_SECRET: test-refresh + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - name: Prisma generate & migrate + run: | + npx prisma generate --schema=src/prisma/schema.prisma + npx prisma migrate deploy --schema=src/prisma/schema.prisma + + - name: Test + run: CI=true npm test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..1720dab9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# deps / build +FROM node:20-alpine AS deps +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +RUN npx prisma generate --schema=src/prisma/schema.prisma +RUN npm run build + +# runtime +FROM node:20-alpine +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV UPLOAD_DIR=/app/uploads + +# Prisma 및 bcrypt 등 네이티브 모듈 호환성을 위한 보강 +RUN apk add --no-cache openssl libc6-compat + +# non-root +RUN addgroup -S app && adduser -S app -G app +RUN mkdir -p /app/uploads && chown -R app:app /app +USER app + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/dist ./dist +COPY --from=deps /app/src/prisma ./src/prisma +COPY package*.json ./ + +EXPOSE 3000 +# Prisma 마이그레이션 후 서버 기동 +CMD sh -c "npx prisma migrate deploy --schema=src/prisma/schema.prisma && node dist/app.js" diff --git a/README.md b/README.md index b8c4e413..29795e14 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,47 @@ ## 미션 목표 -- 판다마켓 서비스를 AWS로 배포하기 -- AWS S3 적용 -- AWS RDS 적용 -- AWS EC2에 Express 서버 배포하기 -- (심화) 프로세스 매니저 적용 -- (심화) 리버스 프록시 적용 +- Github Actions로 테스트, 배포 자동화 +- Docker 이미지 만들기 ## 요구사항 -### 기본 요구사항 -- [x] 프로젝트에 프로덕션 배포를 위한 환경 변수 설정을 해 주세요. +### Github Actions 활용 +- [x] 브랜치에 pull request가 발생하면 테스트를 실행하는 액션을 구현해 주세요. +- [x] `main` 브랜치에 push가 발생하면 AWS 배포를 진행하는 액션을 구현해 주세요. +- [x] 개인 Github 리포지터리에서 Actions 동작을 확인해 보세요. ### AWS S3 적용 - [x] AWS S3 버킷을 생성하고, 퍼블릭 액세스를 허용해 주세요. - [x] 일반 사용자가 S3 업로드된 파일에 접근할 수 있도록 S3 버킷 정책을 설정해 주세요. -- [x] AWS EC2에서 AWS S3를 사용하기 위한 액세스 키를 AWS IAM에서 발급하세요. -- [x] 프로덕션 환경에서는 파일 업로드에 AWS S3를 사용하도록 구현을 수정해 주세요. +- [x] 데이터베이스는 Postgres 이미지를 사용해 연결하도록 구현해 주세요. +- [x] 실행된 Express 서버 컨테이너는 호스트 머신에서 3000번 포트로 접근 가능하도록 구현해 주세요. -### AWS RDS 적용 -- [x] AWS RDS 프리티어에 해당하는 인스턴스를 생성합니다. -- [x] RDS 인스턴스에 대한 보안 그룹을 설정합니다. -- [x] 프로덕션 환경에서는 Prisma에 프로젝트 데이터베이스와 연결하도록 합니다. - -### AWS EC2에 Express 서버 배포하기 -- [x] AWS EC2 프리티어에 해당하는 인스턴스를 생성합니다. -- [x] SSH를 사용해 EC2 인스턴스에 접속해 Express 서버를 배포해 주세요. +### Docker 이미지 만들기 +다음을 만족하는 Dockerfile과 docker-compose.yaml을 작성해 주세요. -### 심화 요구사항 -- [x] EC2 인스턴스에서 pm2 프로세스 매니저를 사용하여 애플리케이션을 실행해 주세요. -- [x] EC2 인스턴스에서 nginx 리버스 프록시를 설정해 서버를 80번 포트로 서비스합니다. +- [x] Express 서버를 실행하는 Dockerfile을 작성해 주세요. +- [x] Express 서버가 파일 업로드를 처리하는 폴더는 Docker의 Volume을 활용하도록 구현해 주세요. +- [x] 프로덕션 환경에서는 Prisma에 프로젝트 데이터베이스와 연결하도록 합니다. --- ## 멘토에게 -- AWS 프리티어 사용이 불가하여, 비용 문제로 다른 서버에 배포하였습니다. - - AWS 관련 설정은 캡쳐한 후 인스턴스 및 스토리지 삭제하는 방식으로 미션 진행하였습니다. - - 제출한 이미지 업로드 코드는 S3 기반으로 작성하였지만, 실제 배포 환경에서는 이미지 파일을 로컬 스토리지에 업로드합니다. - - 엔드포인트는 https://pandamarket.mimu.live 입니다. -- 요구사항에는 없지만, PM2 및 Nginx 환경 설정을 일부 보강하여 적용하였습니다. - - PM2 - - 프로세스 안정성을 고려하여, 메모리 300MB 초과 시에 자동 재시작하도록 설정했습니다. - 중급 프로젝트 시에 메모리 문제로 서비스가 종료된 적이 있어 추가 설정하였습니다. - - TS 기반으로 작성되었으며, 배포 환경에서 불필요한 재시작 루프를 방지하기 위해 - watch 옵션을 명시적으로 비활성화했습니다. - - Nginx - - 파일 업로드 용량 제한을 2MB로 명시적으로 설정했습니다. - - `real_ip_header CF-Connecting-IP` 및 Cloudflare IP 대역을 지정하여, - 프록시 환경에서도 실제 클라이언트 IP를 확인할 수 있도록 구성했습니다. - - 단순 IP가 아닌 실제 도메인으로 접근 가능하도록 설정하여 구현했습니다. \ No newline at end of file +- 기본 요구사항은 모두 충족하였으며, 추가 구현한 내용을 아래에 기재합니다. + +### 헬스체크 +- `docker-compose.yaml` 내에서 Postgres 컨테이너의 상태를 `pg_isready`로 주기적으로 확인하도록 설정했습니다. +- 애플리케이션 컨테이너는 DB 서비스가 정상 상태(`healthy`)일 때만 기동되도록 의존성을 설정했습니다. + +### Volume 구성 강화 +- 업로드 파일을 컨테이너 외부에서도 보존할 수 있도록 `/app/uploads` 경로를 호스트와 Volume으로 연결했습니다. +- 애플리케이션 실행 시 업로드 폴더가 자동 생성되도록 Dockerfile 내에 디렉터리 생성 및 권한 설정을 추가했습니다. + +### Prisma 마이그레이션 자동화 +- 배포 시 Prisma 마이그레이션이 자동으로 반영되도록 Dockerfile의 CMD 단계에 `migrate deploy` 명령어를 포함했습니다. + +### 보안 및 권한 관리 +- 컨테이너 내에서 애플리케이션이 root 권한으로 실행되지 않도록 `USER app`을 설정했습니다. +- 이미지 빌드 시 불필요한 파일 및 비공개 설정이 포함되지 않도록 `.dockerignore`를 구성했습니다. + +### upload 미들웨어 +- `.env`의 실행 환경 설정에 따라 로컬 스토리지 또는 AWS S3 중 하나를 선택하여 파일 업로드를 처리하도록 수정했습니다. +- 이에 따라 `.env.example`에 `UPLOAD_STRATEGY` 항목을 추가했습니다. \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..acfa7063 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,35 @@ +version: '3.9' + +services: + # PostgreSQL 데이터베이스 서비스 + db: + image: postgres + restart: always + ports: + - '5432:5432' + volumes: + - db:/var/lib/postgresql/data + env_file: + - .env + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB'] + interval: 5s + timeout: 3s + retries: 10 + + # Node.js 애플리케이션 서비스 + web: + build: . + restart: always + ports: + - '3000:3000' + depends_on: + db: + condition: service_healthy + env_file: + - .env + volumes: + - ./uploads:/app/uploads + +volumes: + db: diff --git a/package-lock.json b/package-lock.json index 69009bf2..822e7e72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.896.0", "@prisma/client": "^6.10.0", + "@types/multer-s3": "^3.0.3", "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.5.0", @@ -3006,7 +3007,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -3017,7 +3017,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3043,7 +3042,6 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -3055,7 +3053,6 @@ "version": "5.0.7", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -3068,7 +3065,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -3131,7 +3127,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -3145,12 +3140,22 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/express": "*" } }, + "node_modules/@types/multer-s3": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/multer-s3/-/multer-s3-3.0.3.tgz", + "integrity": "sha512-VgWygI9UwyS7loLithUUi0qAMIDWdNrERS2Sb06UuPYiLzKuIFn2NgL7satyl4v8sh/LLoU7DiPanvbQaRg9Yg==", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-s3": "^3.0.0", + "@types/multer": "*", + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "24.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", @@ -3164,21 +3169,18 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -3189,7 +3191,6 @@ "version": "1.15.8", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", diff --git a/package.json b/package.json index 0d6a4119..38449f4f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.896.0", "@prisma/client": "^6.10.0", + "@types/multer-s3": "^3.0.3", "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.5.0", diff --git a/src/middlewares/upload.ts b/src/middlewares/upload.ts index 194cae4c..ca8949cc 100644 --- a/src/middlewares/upload.ts +++ b/src/middlewares/upload.ts @@ -1,53 +1,77 @@ -import multer from "multer"; -import path from "path"; -import multerS3 from "multer-s3"; -import { S3Client } from "@aws-sdk/client-s3"; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import multerS3 from 'multer-s3'; +import { S3Client } from '@aws-sdk/client-s3'; // 확장자 -const allowedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; +const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; // 최대 용량 const MAX_FILE_SIZE = 1 * 1024 * 1024; -// S3 클라이언트 -const s3 = new S3Client({ - region: process.env.AWS_REGION, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, - }, -}); +// local / s3 분기 +const strategy = process.env.UPLOAD_STRATEGY || 'local'; -// S3 스토리지 -const storage = multerS3({ - s3, - bucket: process.env.AWS_S3_BUCKET!, - acl: "public-read", - key: (req, file, cb) => { - const ext = path.extname(file.originalname); - const base = path.basename(file.originalname, ext); - const unique = Date.now(); - cb(null, `${base}-${unique}${ext}`); - }, -}); +let storage: multer.StorageEngine; + +if (strategy === 's3') { + const s3 = new S3Client({ + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, + }); + + // multerS3.AUTO_CONTENT_TYPE은 런타임에는 존재하지만, 타입 정의에는 누락되어 있어 'as any'로 접근 + const AUTO_CONTENT_TYPE = (multerS3 as any).AUTO_CONTENT_TYPE; + + storage = multerS3({ + s3, + bucket: process.env.AWS_S3_BUCKET!, + acl: 'public-read', + contentType: AUTO_CONTENT_TYPE, + key: (_req, file, cb) => { + const ext = path.extname(file.originalname); + const base = path.basename(file.originalname, ext); + const unique = Date.now(); + cb(null, `${base}-${unique}${ext}`); + }, + }); +} else if (strategy === 'local') { + const uploadDir = process.env.UPLOAD_DIR || path.resolve(process.cwd(), 'uploads'); + fs.mkdirSync(uploadDir, { recursive: true }); + + storage = multer.diskStorage({ + destination: (_req, _file, cb) => { + cb(null, uploadDir); + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname); + const base = path.basename(file.originalname, ext); + const unique = Date.now(); + cb(null, `${base}-${unique}${ext}`); + }, + }); +} else { + throw new Error(`지원하지 않는 업로드 전략입니다: ${strategy}`); +} /** * Multer 파일 업로드 미들웨어 설정 * - * - 파일을 S3 버킷에 업로드합니다. + * - 전략에 따라 로컬 또는 S3에 파일을 저장합니다. * - 파일명은 `{원본명}-{타임스탬프}.{확장자}` 형식으로 저장됩니다. * - 1MB 이하의 이미지 파일(`jpg`, `png`, `gif`, `webp`)만 허용됩니다. + * + * @type {import('multer').Multer} */ export const upload = multer({ storage, - fileFilter: (req, file, cb) => { + fileFilter: (_req, file, cb) => { if (!allowedMimeTypes.includes(file.mimetype)) { - return cb( - new multer.MulterError( - "LIMIT_UNEXPECTED_FILE", - "허용되지 않는 파일 형식입니다." - ) - ); + return cb(new multer.MulterError('LIMIT_UNEXPECTED_FILE', '허용되지 않는 파일 형식입니다.')); } cb(null, true); },