Cloudflare의 R2로 간 이유는 별 이유가 없긴했다
그 당시에 그냥 나의 이미지가 어딘가로 다 없어진다는 막연함
그거 하나 때문에 그냥 R2로 옮긴거긴 했는데 .. 지금와서 생각해보니 오버테크놀로지가 아니였나 싶다
그리고 R2는 원래 image서버용으로 만들어진 버킷이 아니다.
그래서 이번 기회에 자동으로 업로드시에 섬네일, 원본, webp 파일을 생성하는 서버를 짜기로 기획해본다.
https://hono.dev/
이번에 써먹을 놈은 Hono라는 녀석이다
express가 최근들어서 async를 지원하긴 한다만.. 이미 늦어버렸으며 ..
막상 hono쓰다보니 이름에 정들어서 hono를 사용하기 시작했다
먼저 webp가 뭔지부터 알아야하는데, 간단하게 생각하면
구글이 개발한 차세대 압축기술을 이용한 개쩌는 이미지 압축기술
이라고 생각하면 된다
그럼 이 압축 기술을 어떻게 사용하느냐 ?
물론 개쩌는 개발자들은 구글이 내놓은 webp 도큐먼트를 보고 뚝딱 하고 자바스크립트로 구현하겠지만 ..
나같은 일반인은 그냥 인터넷에 있는 라이브러리를 이용하는 것이 정신건강에 좋다.
나는 sharp를 쓰기로 마음 먹었다
그리고 간단한 fs사용법, hono에대한 기본지식.
hono를 돌릴 환경에 대한 이해가 필요하다 (node, bun등 여러 환경에서 사용 가능하다)
나머지는 뭐 .. 기초 js/ts지식과 서버 지식 정도 아닐까 ??
그리고 추가적으로 docker를 쓸 예정으로 docker에대한 기본적인 지식과
귀찮음을 해결해줄 자동배포를 위하여 github action에대한 지식도 가져오자
마지막으로 제일 중요하고 어려운 ..
서버를 구해두자. (나는 aws의 lightsail서버를 이용했다)
가끔 nextjs를 쓰다보면 build 타임에 sharp를 사용하라는 권장사항 warning을 볼 수가 있을 것이다.
그렇다 이 sharp라는 개쩌는 라이브러리를 사용하여 우리는 이미지를 webp를 사용할 수 있다
node에 I/O를 하다보면 질리도록 보는게 이 fs라는 놈이다.
이전 블로그를 갈아엎게 한 장본인이기도 한 놈인데 ..
이름 그대로 file system의 약자에서 떠왔다.
사용법은 너무나 쉬우니 따로 설명하지 않는다.
이번에는 bun환경에서 hono를 돌리기로했다
이제 꽤 안정화된 상태라 판단해서 .. 그리고 bun에서 bun 이미지를 지원한다
https://bun.sh/guides/ecosystem/docker
bun의 속도가 생각보다 체감이 엄청나니 bun을 올리는 것도 좋다.
https://hono.dev/docs/getting-started/bun hono를 따로 설명 할 수 없으니 공식 문서를 참고하자.
여긴 뭐.. devOps의 영역이기도 한데 귀차니즘 해결이라면 필수적이다
워낙 유명한지라 따로 설명은 하지 않는다.
일단 필요한 모듈을 생각해보면
요거 2개만 올려놓고 나머지 db화는 외부에서 하는 것으로 결정했다.
말 그대로 "bucket"이라는 의미로 사용 될 것이기에 이 이외의 로직은 필요없다 생각했다.
먼저 image를 저장하는 로직을 작성해보자
typescriptconst saveImageWithGeneratedThumbnail = async (
file: File,
folderPath: string,
percentage: number
) => {
const fileBuffer = await file.arrayBuffer()
const fullName = file.name
const fileName = fullName.split('.')[0]
const ext = fullName.split('.').pop()
const path = `${folderPath}/${fileName}.${ext}`
if (!ext || !imageExtensions.includes(ext)) {
return { path, thumbnail: '' }
}
writeFileSync(path, new Uint8Array(fileBuffer))
await sharp(fileBuffer)
.metadata()
.then((info) => {
const width = info.width && Math.min(800, info.width)
return sharp(fileBuffer).resize(width).webp({ quality: 80 }).toBuffer()
})
.then((output) => writeFileSync(`${folderPath}/${fileName}.webp`, new Uint8Array(output)))
await sharp(fileBuffer)
.metadata()
.then((info) => {
const width = info.width && Math.round((info.width * percentage) / 100)
const height = info.height && Math.round((info.height * percentage) / 100)
return sharp(fileBuffer).resize(width, height).webp({ quality: 80 }).toBuffer()
})
.then(
(output) =>
writeFileSync(`${folderPath}/${fileName}-thumbnail.webp`, new Uint8Array(output))
)
const responseData = {
path: `${fileName}.webp`, thumbnail: `${folderPath}/${fileName}-thumbnail.webp`
}
return responseData
}
간단하게
bun의 stream을 이용하여 파일을 내보낼 것이다.
친절하게도 bun 도큐먼트에서 제공하는 stream docs를 확인하여 내용을 작성해주자
typescriptapp.get('/:imagename', async (c) => {
try {
const Env = env(c)
const folderPath = (Env.folder_path as string) || '/Desktop'
const imagename = c.req.param('imagename')
const isThumbnail = c.req.query('thumbnail')
const path = isThumbnail
? `${folderPath}/${imagename.split('.')[0]}-thumbnail.webp`
: `${folderPath}/${imagename}`
const image = readFileSync(path)
if (!image) {
return c.text('Image not found', 404)
}
return stream(c, async (stream) => {
await stream.write(new Uint8Array(image)).then(() => stream.close())
})
} catch (error) {
console.log(error)
return c.text('Failed to request', 500)
}
})
이렇게 작성하면 image의 save/read로직이 끝났으니 그냥 끝이다.
upload 로직 마저 채워 주고 router 를 작성한 뒤 서버를 올릴 준비를 해보자
shellFROM oven/bun:1 AS base
WORKDIR /usr/src/app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
COPY server.ts .
COPY .env .env
ENV NODE_ENV=production
EXPOSE 10001
ENTRYPOINT ["bun", "run", "server.ts"]
10001을 그대로 묶어줄 것이라서 10001만 expose그대로 해준다.
YAMLservices:
app:
image: '${PROJECT_NAME}'
container_name: '${PROJECT_NAME}'
restart: always
ports:
- '${PROJECT_PORT}:10001'
volumes:
- ./images:/images
environment:
- NODE_ENV=production
github action script는 docker hub를 거치지 않고
scp로 파일을 옮겨서 docker 의 컨테이너를 작성할 것이다.
docker와 docker compose파일은 이정도로 작성하면 ok
YAMLname: Deploy Project
on:
push:
branches:
- deploy
jobs:
deploy:
runs-on: ubuntu-latest
env:
PROJECT_NAME: ${{ secrets.PROJECT_NAME }}
HOST_IP: ${{ secrets.HOST_IP }}
HOST_USERNAME: ${{ secrets.HOST_USERNAME }}
PROJECT_PORT: ${{ secrets.PROJECT_PORT }}
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Build Docker Image
run: docker build -t ${{ env.PROJECT_NAME }} .
- name: Save Docker image as tar
run: docker save ${{ env.PROJECT_NAME }} -o ${{ env.PROJECT_NAME }}.tar
- name: Ensure remote directory exists
uses: appleboy/ssh-action@v0.1.8
with:
host: ${{ secrets.HOST_IP }}
username: ${{ secrets.HOST_USERNAME }}
key: ${{ secrets.PEM_KEY }}
port: 22
script: |
mkdir -p /home/${{ env.PROJECT_NAME }}
- name: Transfer Docker image to remote server
uses: appleboy/scp-action@v0.1.3
with:
host: ${{ secrets.HOST_IP }}
username: ${{ secrets.HOST_USERNAME }}
key: ${{ secrets.PEM_KEY }}
source: './${{ env.PROJECT_NAME }}.tar'
target: '/home/${{ env.PROJECT_NAME }}'
- name: Transfer docker-compose.yml to remote server
uses: appleboy/scp-action@v0.1.3
with:
host: ${{ secrets.HOST_IP }}
username: ${{ secrets.HOST_USERNAME }}
key: ${{ secrets.PEM_KEY }}
source: './docker-compose.yml'
target: '/home/${{ env.PROJECT_NAME }}'
- name: Load Docker image, remove old image, and run container on remote server
uses: appleboy/ssh-action@v0.1.8
with:
host: ${{ secrets.HOST_IP }}
username: ${{ secrets.HOST_USERNAME }}
key: ${{ secrets.PEM_KEY }}
port: 22
script: |
cd /home/${{ env.PROJECT_NAME }}
if [ ! -f ${{ env.PROJECT_NAME }}.tar ]; then
echo "Docker image file not found!"
exit 1
fi
old_image=$(docker images -q ${{ env.PROJECT_NAME }}:latest)
if [ -n "$old_image" ]; then
docker rmi -f $old_image
fi
docker load -i ${{ env.PROJECT_NAME }}.tar
PROJECT_NAME=${{ env.PROJECT_NAME }} PROJECT_PORT=${{ env.PROJECT_PORT }} docker compose down
PROJECT_NAME=${{ env.PROJECT_NAME }} PROJECT_PORT=${{ env.PROJECT_PORT }} docker compose up -d
- name: Cleanup local Docker image tar
run: rm ${{ env.PROJECT_NAME }}.tar
(좀 잘려서 그런데, 실제 사용할 때는 Indent 주의하자.)
secrets는 github setting > secrets > action에서 설정해주자 이름 그대로 설정해주면 된다.
이러고 repository를 딱 올리면 .. !
shellroot@:/home/imagebucket/images# cd ..
root@:/home/imagebucket# ls
docker-compose.yml imagebucket.tar images
root@ip-:/home/imagebucket# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
00e4213c6fd9 bblog_db "docker-entrypoint.s…" 51 minutes ago Up 51 minutes
1b72f65e140f imagebucket "bun run server.ts" About an hour ago Up About an hour 0.0.0.0:10001->10001/tcp, :::10001->10001/tcp imagebucket
b98955e3e17c template "docker-entrypoint.s…" 4 weeks ago Up 4 weeks
afc706ce9a5d mysql:latest "docker-entrypoint.s…" 10 months ago Up 10 months
root@:/home/imagebucket#
https://github.com/B-HS/Image-Bucket
위의 레포지토리에 결과물과 터미널에 저런식으로 docker가 성공적으로 올라간 것을 볼 수 있다.
내부에서 이제 10001을 바라보면서 쓰면 되니 .. host로의 ip로 10001을 요청하여 각 컨테이너로 이동하여 접근한다던가 ..
아니면 더 확실하게 이 서버를 쓰는 프로젝트에 따로 빌드해서 올리게끔하여서 docker network connect로 두 컨테이너를 이어써도 좋을 것이다.
아주 간단한 이미지 버킷을 작성해보았다.
cdn이 언제나 극한의 성능 절약을 가져오는 것은 아니기에 ..
그리고 무엇보다 하나 만들어보고싶었다 ㅋㅋ.
https://github.com/B-HS
모든 결과물은 github에 있으니 github로, 궁금한점은 메일로.