-
Notifications
You must be signed in to change notification settings - Fork 2
[재하] 1123(목) 개발기록
- 트러블 슈팅 : Object Storage 에러 => 해결
- AWS-SDK를 이용한 NCP Object Storage 파일 업로드, 다운로드
- HTTPS 적용
- GitHub Actions를 활용한 자동 배포
S3와 호환되므로 AWS-SDK 모듈 등에서 Endpoint를 kr.object.ncloudstorage.com
으로 설정해서 사용하면 된다.
import * as AWS from 'aws-sdk';
import { configDotenv } from 'dotenv';
configDotenv();
export const awsConfig = {
endpoint: new AWS.Endpoint(process.env.AWS_S3_ENDPOINT),
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
};
export const bucketName = process.env.AWS_BUCKET_NAME;
// NCP Object Storage 업로드
AWS.config.update(awsConfig);
const result = await new AWS.S3()
.putObject({
Bucket: bucketName,
Key: filename,
Body: buffer,
ACL: 'public-read',
})
.promise();
Logger.log('uploadFile result:', result);
// NCP Object Storage 다운로드
AWS.config.update(awsConfig);
const result = await new AWS.S3()
.getObject({
Bucket: bucketName,
Key: filename,
})
.promise();
Logger.log(`downloadFile result: ${result.ETag}`);
NCloud 공식문서에 나와있는 S3 Endpoint 주소가 문제였다..
여기 나와있는 주소를 썼는데 ACCESS KEY가 없다고 나와 뭔가 잘못한 줄 알고 한참을 헤맸는데
혹시나 싶어 다른 문서에 나와있는
이 주소를 보니까 다른거야.. 그래서 이걸로 했더니 됨. ㅋ
알고보니 내가 보던 공식문서는 financial용 ncloud로 서버도 인증키도 따로 관리되는 거였다... 내가 바보 ㅇㅈ
이제 주석처리해 두었던 업로드 로직을 다시 추가해줬다.
async createBoard(
createBoardDto: CreateBoardDto,
userData: UserDataDto,
files: Express.Multer.File[],
): Promise<Board> {
const { title, content } = createBoardDto;
const user = await this.userRepository.findOneBy({ id: userData.userId });
const images: Image[] = [];
for (const file of files) {
const image = await this.uploadFile(file);
images.push(image);
}
const board = this.boardRepository.create({
title,
content: encryptAes(content), // AES 암호화하여 저장
user,
images,
});
const createdBoard: Board = await this.boardRepository.save(board);
createdBoard.user.password = undefined; // password 제거하여 반환
return createdBoard;
}
파일 목록에서 순서대로 읽어와 uploadFile() 호출
async uploadFile(file: Express.Multer.File): Promise<Image> {
if (!file.mimetype.includes('image')) {
throw new BadRequestException('Only image files are allowed');
}
const { mimetype, buffer, size } = file;
const filename = uuid();
// NCP Object Storage 업로드
AWS.config.update(awsConfig);
const result = await new AWS.S3()
.putObject({
Bucket: bucketName,
Key: filename,
Body: buffer,
ACL: 'public-read',
})
.promise();
Logger.log('uploadFile result:', result);
const updatedImage = await this.imageRepository.save({
mimetype,
filename,
size,
});
return updatedImage;
}
업로드 파일 로직은 uuid로 생성된 식별자를 파일이름으로 Object Storage에 업로드하고, 나머지 파일에 대한 정보는 관계형 DB 이미지 테이블에 저장한다.
yarn workspace server add form-data
폼데이터로 응답을 해야하기 때문에 form-data 모듈을 설치해줬다.
async downloadFile(filename: string): Promise<Buffer> {
// NCP Object Storage 다운로드
AWS.config.update(awsConfig);
const result = await new AWS.S3()
.getObject({
Bucket: bucketName,
Key: filename,
})
.promise();
Logger.log(`downloadFile result: ${result.ETag}`);
return result.Body as Buffer;
}
@Get(':id')
@UseGuards(CookieAuthGuard)
async findBoardById(
@Param('id', ParseIntPipe) id: number,
@Res() res,
): Promise<void> {
const found = await this.boardService.findBoardById(id);
// AES 복호화
if (found.content) {
found.content = decryptAes(found.content); // AES 복호화하여 반환
}
// 폼 데이터 만들어 반환
const formData = new FormData();
formData.append('id', found.id.toString());
formData.append('title', found.title);
formData.append('content', found.content);
formData.append('author', found.user.nickname);
formData.append('created_at', found.created_at.toString());
formData.append('updated_at', found.updated_at.toString());
formData.append('like_cnt', found.like_cnt.toString());
// NCP Object Storage 다운로드
const files = [];
for (let image of found.images) {
const file: Buffer = await this.boardService.downloadFile(image.filename);
console.log(file);
formData.append('file', file, {
filename: image.filename,
contentType: image.mimetype,
});
}
res.set({
'Content-Type': 'multipart/form-data',
});
formData.pipe(res);
// return found;
}
다운로드는 컨트롤러에서 개별 파일 다운로드하는 서비스 메소드인 downloadFile을 호출해서 순서대로 폼데이터에 넣고,
@Res()
데코레이터로 가져온 Response 객체에 전달한다.
- 업로드
- 다운로드
on:
push:
branches:
- main
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v1
with:
node-version: '20.x'
- name: Install yarn
run: npm install -g yarn
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Test and Build with yarn
run: |
yarn install
yarn workspace client build
- name: Build and Push Docker image
run: |
docker build -t ${{ secrets.DOCKER_HUB_USERNAME }}/web16-b1g1-fe:${{ github.sha }} -f ./Dockerfile-web .
docker push ${{ secrets.DOCKER_HUB_USERNAME }}/web16-b1g1-fe:${{ github.sha }}
docker build -t ${{ secrets.DOCKER_HUB_USERNAME }}/web16-b1g1-be:${{ github.sha }} -f ./Dockerfile-was .
docker push ${{ secrets.DOCKER_HUB_USERNAME }}/web16-b1g1-be:${{ github.sha }}
- name: make docker-compose file
run: |
sed -i "s/GITHUB_SHA/${{ github.sha }}/g" docker-compose.yml
- name: send docker-compose file with scp
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
source: docker-compose.yml
target: /app
- name: make .env file
run: |
echo ${{ secrets.ENV }} > .env
- name: send .env file with scp
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
source: .env
target: /app
- name: Deploy with SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
cd /app
docker pull ${{ secrets.DOCKER_HUB_USERNAME }}/web16-b1g1-fe:${{ github.sha }}
docker pull ${{ secrets.DOCKER_HUB_USERNAME }}/web16-b1g1-be:${{ github.sha }}
docker stop $(docker ps -a -q)
docker rm $(docker ps -a -q)
docker-compose up -d
rm -rf .env docker-compose.yml
앞선 GitHub Actions 스크립트 학습과, 각종 AI들의 도움을 받아 스크립트를 작성해봤다. 중간중간에 에러가 나는 부분을 잡아주느라 main PR을 여러번 올리며 고생을 좀 했다 ㅎ
주요 에러 요인들은 다음과 같다.
- SCP로 파일 전송 시 이미 있는 파일이면 에러 발생
- 배포 후 환경설정 파일(.env)이나 docker-compose.yml을 지워주는 것으로 해결
- 우리는 yarn berry(zero install) 모노레포를 사용해 docker build 전 의존성 설치과정이 필요없었는데, esbuild 패키지는 플랫폼에 종속적이라 macOS에서 개발중이던 프로젝트가 ubuntu에서 build시 에러 발생
- 리눅스 환경에서 yarn install를 한 번 더 실행하여 linux/amd64용 esbuild 설치
- .env 파일 생성 시 echo로 여러 줄의 입력을 넣으면
echo ${{ secrets.ENV }} > .env
명령으로 파일이 정상적으로 입력되지 않음- secrets.ENV의 내용을
-e "A=...\n" \ "B=...\n" \ ... ""
형태로 넣어서 스크립트 변경 없이 해결. 다음으로 해석됨
echo -e "A=...\n" \ "B=...\n" \ ... "" > .env`
- secrets.ENV의 내용을
secrets 변수들은 프로젝트 설정의 Secrets and variables => Actions
에 직접 넣어준다.
Actions 탭에서 결과 확인 가능
새로운 tag의 web, was 컨테이너가 잘 돌아가고 있는 것을 확인할 수 있다!
다양한 자료를 참고했지만, 결정적으로 다음의 boilerplate를 활용해 Let's Encrypt 인증서를 발급했다.
서버 인스턴스에 위 repository를 클론해 온 뒤
server {
listen 80;
server_name www.xn--bj0b03z.site;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name www.xn--bj0b03z.site;
server_tokens off;
ssl_certificate /etc/letsencrypt/live/www.xn--bj0b03z.site/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.xn--bj0b03z.site/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://www.xn--bj0b03z.site;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
/data/nginx/app.conf
의 도메인을 구매한 서버 도메인으로 지정한다.
#!/bin/bash
if ! [ -x "$(command -v docker-compose)" ]; then
echo 'Error: docker-compose is not installed.' >&2
exit 1
fi
domains=(www.xn--bj0b03z.site)
rsa_key_size=4096
data_path="./data/certbot"
email="적절한 이메일 입력" # Adding a valid address is strongly recommended
staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
...
다음으로 init-letsencrypt.sh
의 도메인도 적절히 변경, email 입력, staging=0
으로 설정 후 실행한다.
./init-letsencrypt.sh
알아서 nginx와 certbot docker 이미지를 다운받고 실행시켜 인증서를 받아준다.
인증서가 잘 받아졌는지 확인하고,
version: '3'
services:
was:
container_name: was
image: qkrwogk/web16-b1g1-be:GITHUB_SHA
ports:
- 3000:3000
env_file:
- .env
networks:
- b1g1-network
web:
container_name: web
image: qkrwogk/web16-b1g1-fe:GITHUB_SHA
ports:
- 80:80
- 443:443
volumes:
- ./data/certbot/conf:/etc/letsencrypt
- ./data/certbot/www:/var/www/certbot
networks:
- b1g1-network
networks:
b1g1-network:
driver: bridge
이제 우리 걸 실행한다. nginx가 있는 web 컨테이너에는 인증서 파일이 동일하게 들어가게 volumes 속성을 부여해줘야 한다. (data 폴더를 container 실행환경으로 옮겨준다)
# nginx.conf with https
server {
listen 80;
server_name www.xn--bj0b03z.site;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name www.xn--bj0b03z.site;
server_tokens off;
ssl_certificate /etc/letsencrypt/live/www.xn--bj0b03z.site/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.xn--bj0b03z.site/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ =404;
}
location /api {
proxy_pass http://was:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
rewrite ^/api(/.*)$ $1 break;
}
}
참고로 nginx conf도 https를 받을 수 있도록, ssl 인증서도 등록해주고 80으로 오면 443으로 리다이렉트 해주고 해야 한다! web 컨테이너는 이 파일을 이용해서 다시 빌드했음
이제 안전한 사이트라고 잘 뜬다!
https 적용 성공! (뿌듯)
© 2023 debussysanjang
- 🐙 [가은] Three.js와의 설레는 첫만남
- 🐙 [가은] JS로 자전과 공전을 구현할 수 있다고?
- ⚽️ [준섭] NestJS 강의 정리본
- 🐧 [동민] R3F Material 간단 정리
- 👾 [재하] 만들면서 배우는 NestJS 기초
- 👾 [재하] GitHub Actions을 이용한 자동 배포
- ⚽️ [준섭] 테스트 코드 작성 이유
- ⚽️ [준섭] TypeScript의 type? interface?
- 🐙 [가은] 우리 팀이 Zustand를 쓰는 이유
- 👾 [재하] NestJS, TDD로 개발하기
- 👾 [재하] AWS와 NCP의 주요 서비스
- 🐰 [백범] Emotion 선택시 고려사항
- 🐧 [동민] Yarn berry로 모노레포 구성하기
- 🐧 [동민] Vite, 왜 쓰는거지?
- ⚽️ [준섭] 동시성 제어
- 👾 [재하] NestJS에 Swagger 적용하기
- 🐙 [가은] 너와의 추억을 우주의 별로 띄울게
- 🐧 [동민] React로 멋진 3D 은하 만들기(feat. R3F)
- ⚽️ [준섭] NGINX 설정
- 👾 [재하] Transaction (트랜잭션)
- 👾 [재하] SSH 보안: Key Forwarding, Tunneling, 포트 변경
- ⚽️ [준섭] MySQL의 검색 - LIKE, FULLTEXT SEARCH(전문검색)
- 👾 [재하] Kubernetes 기초(minikube), docker image 최적화(멀티스테이징)
- 👾 [재하] NestJS, 유닛 테스트 각종 mocking, e2e 테스트 폼데이터 및 파일첨부
- 2주차(화) - git, monorepo, yarn berry, TDD
- 2주차(수) - TDD, e2e 테스트
- 2주차(목) - git merge, TDD
- 2주차(일) - NCP 배포환경 구성, MySQL, nginx, docker, docker-compose
- 3주차(화) - Redis, Multer 파일 업로드, Validation
- 3주차(수) - AES 암복호화, TypeORM Entity Relation
- 3주차(목) - NCP Object Storage, HTTPS, GitHub Actions
- 3주차(토) - Sharp(이미지 최적화)
- 3주차(일) - MongoDB
- 4주차(화) - 플랫폼 종속성 문제 해결(Sharp), 쿼리 최적화
- 4주차(수) - 코드 개선, 트랜잭션 제어
- 4주차(목) - 트랜잭션 제어
- 4주차(일) - docker 이미지 최적화
- 5주차(화) - 어드민 페이지(전체 글, 시스템 정보)
- 5주차(목) - 감정분석 API, e2e 테스트
- 5주차(토) - 유닛 테스트(+ mocking), e2e 테스트(+ 파일 첨부)
- 6주차(화) - ERD
- 2주차(화) - auth, board 모듈 생성 및 테스트 코드 환경 설정
- 2주차(목) - Board, Auth 테스트 코드 작성 및 API 완성
- 3주차(월) - Redis 연결 후 RedisRepository 작성
- 3주차(화) - SignUpUserDto에 ClassValidator 적용
- 3주차(화) - SignIn시 RefreshToken 발급 및 Redis에 저장
- 3주차(화) - 커스텀 AuthGuard 작성
- 3주차(수) - SignOut시 토큰 제거
- 3주차(수) - 깃헙 로그인 구현
- 3주차(토) - OAuth 코드 통합 및 재사용
- 4주차(수) - NestJS + TypeORM으로 MySQL 전문검색 구현
- 4주차(목) - NestJS Interceptor와 로거
- [전체] 10/12(목)
- [전체] 10/15(일)
- [전체] 10/30(월)
- [FE] 11/01(수)~11/03(금)
- [전체] 11/06(월)
- [전체] 11/07(화)
- [전체] 11/09(목)
- [전체] 11/11(토)
- [전체] 11/13(월)
- [BE] 11/14(화)
- [BE] 11/15(수)
- [FE] 11/16(목)
- [FE] 11/19(일)
- [BE] 11/19(일)
- [FE] 11/20(월)
- [BE] 11/20(월)
- [BE] 11/27(월)
- [FE] 12/04(월)
- [BE] 12/04(월)
- [FE] 12/09(금)
- [전체] 12/10(일)
- [FE] 12/11(월)
- [전체] 12/11(월)
- [전체] 12/12(화)