Hyunseok
Dev

Hono js 로 image-bucket container를 작성해보자

2024-11-02

image

자가 서버에서 R2로, 그리고 다시 자가 서버로

Cloudflare의 R2로 간 이유는 별 이유가 없긴했다
그 당시에 그냥 나의 이미지가 어딘가로 다 없어진다는 막연함
그거 하나 때문에 그냥 R2로 옮긴거긴 했는데 .. 지금와서 생각해보니 오버테크놀로지가 아니였나 싶다
그리고 R2는 원래 image서버용으로 만들어진 버킷이 아니다.
그래서 이번 기회에 자동으로 업로드시에 섬네일, 원본, webp 파일을 생성하는 서버를 짜기로 기획해본다.

백엔드로 쓸 프레임워크 선정

w150
https://hono.dev/
이번에 써먹을 놈은 Hono라는 녀석이다
express가 최근들어서 async를 지원하긴 한다만.. 이미 늦어버렸으며 ..
막상 hono쓰다보니 이름에 정들어서 hono를 사용하기 시작했다

server를 작성해보자

먼저 webp가 뭔지부터 알아야하는데, 간단하게 생각하면

구글이 개발한 차세대 압축기술을 이용한 개쩌는 이미지 압축기술

이라고 생각하면 된다
그럼 이 압축 기술을 어떻게 사용하느냐 ?
물론 개쩌는 개발자들은 구글이 내놓은 webp 도큐먼트를 보고 뚝딱 하고 자바스크립트로 구현하겠지만 ..
나같은 일반인은 그냥 인터넷에 있는 라이브러리를 이용하는 것이 정신건강에 좋다.
나는 sharp를 쓰기로 마음 먹었다

그리고 간단한 fs사용법, hono에대한 기본지식.
hono를 돌릴 환경에 대한 이해가 필요하다 (node, bun등 여러 환경에서 사용 가능하다)
나머지는 뭐 .. 기초 js/ts지식과 서버 지식 정도 아닐까 ??

그리고 추가적으로 docker를 쓸 예정으로 docker에대한 기본적인 지식과
귀찮음을 해결해줄 자동배포를 위하여 github action에대한 지식도 가져오자

마지막으로 제일 중요하고 어려운 ..
서버를 구해두자. (나는 aws의 lightsail서버를 이용했다)

sharp

가끔 nextjs를 쓰다보면 build 타임에 sharp를 사용하라는 권장사항 warning을 볼 수가 있을 것이다.
그렇다 이 sharp라는 개쩌는 라이브러리를 사용하여 우리는 이미지를 webp를 사용할 수 있다

fs

node에 I/O를 하다보면 질리도록 보는게 이 fs라는 놈이다.
이전 블로그를 갈아엎게 한 장본인이기도 한 놈인데 ..
이름 그대로 file system의 약자에서 떠왔다.
사용법은 너무나 쉬우니 따로 설명하지 않는다.

hono 그리고 bun

이번에는 bun환경에서 hono를 돌리기로했다
이제 꽤 안정화된 상태라 판단해서 .. 그리고 bun에서 bun 이미지를 지원한다 https://bun.sh/guides/ecosystem/docker
bun의 속도가 생각보다 체감이 엄청나니 bun을 올리는 것도 좋다.

https://hono.dev/docs/getting-started/bun hono를 따로 설명 할 수 없으니 공식 문서를 참고하자.

docker/github action

여긴 뭐.. devOps의 영역이기도 한데 귀차니즘 해결이라면 필수적이다
워낙 유명한지라 따로 설명은 하지 않는다.

서버작성

일단 필요한 모듈을 생각해보면

  1. image response
  2. image upload

요거 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
}

간단하게

  1. image인지 확장자를 확인하고 아니면 튕구고
  2. env에 저장해둔 경로대로 file을 3가지 (thumbnail, .webp, 원본) 순서로 파일을 저장한다.

api > 파일 보내는 로직

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 를 작성한 뒤 서버를 올릴 준비를 해보자

Dockerfile 작성

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그대로 해준다.

docker compose 작성

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

github action script

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로, 궁금한점은 메일로.

R2DockerHonojsGithub Action
Comments()