Skip to content

기술공유 08. 컨버터 서버, request 큐를 이용해 방어시스템 구축하기

Aeree Cho edited this page Dec 19, 2019 · 4 revisions

첫번째 데모데이에 컨버터 서버의 심각한 결함을 발견하였습니다. 동시에 여러 명이 파일을 업로드 했을 때, 컨버터 서버가 다운되어 버린것입니다🤯. 물론 동시에 많은 사용자가 이미지 변환을 요청하면 서버에 무리가 갈 것이라고 생각했지만 이렇게 금방 문제에 봉착하게 될 줄은 몰랐습니다. 저희는 바로 다음 스프린트로 컨버터 서버 모니터링과 개선 작업을 추가하였습니다.

우선은 도대체 무엇이 문제인지 파악해야 했으므로 서버 모니터링에 대한 학습이 필요했습니다. ngrinder로 여러개의 요청을 보내는 것부터 해보았고, vmstat, htop을 이용하여 서버 상태를 체크했습니다. 처음에는 ngrinder로 스트레스 테스트를 하는 것이 목적이었으나, 10개의 요청도 견디지 못했기 때문에 현 상태에 대한 보다 디테일한 조사가 우선임을 깨달았습니다😂. 직접 console.time을 이용해 각각의 미들웨어에 걸리는 시간을 체크하는 테스트 라우터를 만들어 log를 확인했습니다.

모니터링 결과 1코어인 현재의 컨버터 서버는 10mb 한장의 pdf 파일을 컨버팅하는데 loadAverage로 약 0.2를 기록하였으나, 이미지 장수가 늘어남에 따라 1/10 용량임에도 불구하고 loadAverage가 급격하게 치솟았습니다. 적정수준인 1~2 를 훌쩍 넘긴 수치입니다.

load Average는 Process 작업의 대기를 의미합니다. 만약 1분간 평균 load average가 1이라면 1분동안 한개의 작업이 대기하고 있었음을 의미합니다. 보통 load average가 1보다 작으면 아주 양호한 상태, 4 정도를 넘으면 부하를 받고 있음, 15이상이면 심각한 문제가 있다고 판단할 수 있습니다. 출처

이를 납득하기 위해서는 저희가 의존하고 있는 이미지 프로세싱 어플리케이션인 graphicsMagick(이하 gm)에 대한 이해가 먼저 필요했습니다. OS단에서 동작하는 gm어플리케이션은 openMP기술을 이용하여 이미지 프로세싱 요청에 대해 사용가능한 모든 CPU자원을 최대로 사용하는 멀티스레드 방식으로 동작합니다. 따라서 한번에 많은 요청이 들어오면 서버가 CPU부하를 견디지 못하고 다운되었던 것입니다.

또한 모니터링의 결과, gm의 멀티스레드 프로세싱으로 인해 n개의 요청에 대한 응답시간이 n * m 이라는 것을 확인하였습니다. 저희는 사용자가 대기하게 되더라도 안전성을 보장하고, 다같이 늦게 응답을 받는것 보다 한명이라도 빨리 응답을 받는것이 낫다고 판단했습니다. 성능 개선은 추후 서버 인스턴스 자체의 scale up과 scale out으로 이루고자 계획하였습니다.

graphcsMagik의 동작을 제어할 수 있을까?

그렇다면 서버의 안정성을 보장하는 방안은 무엇이 있을까요? 간단하게 생각할수 있는 방법으로는 gm의 openMP의 멀티 스레드 생성을 제한하여 병렬처리를 막고 순차처리를 강제하는 것입니다. gm API로 thread를 제한하는 limit옵션을 줄 수 있습니다. 그러나 이미지 프로세싱과 서버 퍼포먼스에 관련한 지식, 벤치마킹을 진행 할 시간 부족으로 어느정도의 thread 개수가 가장 효율적인지 쉽게 판단할 수 없었습니다. 게다가 사용자가 보내는 요청은 pdf용량, 이미지 장수 등 변화의 폭이 크고 저희의 서버자원은 한정되어있어 동적인 자원 할당이 필요했습니다.

일단 사용자의 요청을 순서대로 처리하자

저희의 한정된 시간과 자원 안에서 일단은 여러 요청이 들어와도 다운되지 않는 안전한 시스템을 구현하는 것이 먼저라고 생각했습니다. 따라서 request를 대기시킬 수 있는 queue시스템을 구현하기로 하였습니다.

컨버터 서버는 사용자가 올린 파일을 임시폴더에 저장하는 save미들웨어, 이를 컨버팅하는 convert미들웨어, 오브젝트 스토리지에 upload하는 미들웨어, 임시폴더의 내용을 삭제하는 remove미들웨어를 거쳐 컨버팅 완료한 데이터를 응답하는 방식으로 구현되어 있습니다.

부하가 큰 convert미들웨어 작업에 동시에 여러 요청이 진입하지 않도록, 해당 미들웨어 직전 요청을 큐에 넣고 정해진 요청 개수의 컨버팅이 끝나면 다음 요청을 큐에서 꺼내 진행하도록 하였습니다. 한 번에 실행할 작업의 개수도 고민해야 했는데 CPU상태를 체크하여 유동적으로 정할 수도 있었지만, 모니터링을 통해 병렬처리가 순차처리보다 비효율적임을 파악했기 때문에 한번에 한개의 요청씩 처리하도록 하였습니다.
새로운 이미지 컨버팅 프로세스는 아래와 같습니다.

이미지 컨버팅 미들웨어 process

  1. auth
    • 사용자 인증이 실패하면 401에러를 응답한다.
  2. save
    • 요청이 들어오면 pdf파일을 temp폴더에 write한다.
  3. queue
    • 요청을 하나의 job으로 생성한다.
    • job을 큐에 넣어 관리한다.
  4. convert
    • 이미지 컨버팅 작업을 진행한다.
    • 컨버팅이 완료되면 다음 작업을 큐에서 꺼내 시작한다.
  5. upload
    • 컨버팅 완료된 작업을 오브젝트 스토리지에 upload한다.
  6. remove
    • 업로드 완료된 파일을 temp폴더에서 unlink하여 제거한다.
  7. response
    • 오브젝트 스토리지에 업로드된 이미지url을 클라이언트에 응답한다.

queue class

먼저 request큐 모듈을 class로 구현하였습니다. 작업의 상태 변경을 다른 미들웨어에서 캐치해야하므로 EventEmitter를 상속합니다. 큐의 사이즈와, 동시 실행할 active작업 개수값을 config객체로 받아 생성합니다.

class Queue extends EventEmitter {
  constructor(config) {
    super();
    this.activeLimit = config.activeLimit;
    this.queueLimit = config.queueLimit;
    this.activeCount = 0;
    this.queue = [];
    this.active = [];
  }
  ...

job class

큐에 넣을 requestJob을 class로 구현하였습니다. queue객체와, (requset, response 객체 next함수)를 data로 받습니다. 또한 자신의 상태를 변경하고 이를 emit하기 위한 setState함수를 가지고 있습니다. 생성 시 자신의 state를 'new'로 변경합니다.

class Job {
  constructor(queue, data) {
    this.queue = queue;
    this.data = data;
    this.setState(false, 'new');
  }

  setState(emit, state, ...args) {
    this.state = state;
    if (emit) this.queue.emit(state, this, ...args);

    return this;
  }
}

queue process

요청이 들어오면 requestJob 객체를 생성하고, 실행중인 작업 개수와 큐를 체크하여 당장 시작이 가능하면 시작, 아니면 큐에 넣습니다. 설정한 큐사이즈를 초과하면 바로 reject 이벤트를 발생시켜 요청을 끝냅니다. 작업이 시작되면 process상태를 emit합니다.

 createJob(data) {
    const job = new Job(this, data);

    if (!this.canQueue()) return job.setState(true, 'reject');
    if (this.canStart() && !this.queue.length) return this.startJob(job);
    this.enqueueJob(job);

    return job;
  }
  
 startJob(job) {
    this.activeCount += 1;
    this.active.push(job);
    job.setState(true, 'process', () => {
      this.completeJob(job);
    });

    return job;
  }

queue middleware

queue 미들웨어는 process 이벤트에 대한 리스너를 가지고 있습니다. 작업이 process상태가 되면 요청의 response객체에 'complete'이벤트에 대한 리스너를 달아주고 next를 호출하여 다음 미들웨어로 보냅니다.

  queue.on('process', (job, complete) => {
    job.data.res.once('complete', () => {
      cleanListeners(job);
      complete();
    });
    job.data.next();
  });

convert middleware

complete이벤트는 실제로 컨버팅 작업이 완료 된 후 발생합니다. 이벤트 리스너는 이를 캐치하여 작업 배열에서 해당 작업을 삭제하고 큐에서 작업을 빼내 시작합니다.

 const convertDone = (slides) => {
   req.slides = slides;
   req.slideRatioList = slides.map((slide) => slide.ratio);
   if (req.isStop) return;
   res.emit('complete'); 
   next();
 };

이제 동시에 여러개의 요청이 들어와도 죽지 않고 버틸 수 있는 컨버터 서버가 되었습니다. 하지만 조금이라도 사용자의 기다림을 줄이고자 많은 고민을 하였습니다.

scale up 해보자

그 고민 중 하나로 1코어 짜리 서버 인스턴스를 4코어로 스케일 업 해보았습니다. 그리고 ngrinder로 한번에 10개의 요청을 보내보았습니다.
응답까지의 평균시간을 보면 결과적으로 퍼포먼스가 2배 가량 향상되었음을 알 수 있습니다.

요청 close 상황 방어하기

이제 부하가 큰 컨버팅 작업이 안전하게 순차 처리되는 시스템을 구현했습니다. 그러나 예기치 못한 문제가 있었습니다. 유저가 돌연 요청을 중단했을 때, 각 미들웨어마다 생성된 임시 파일을 삭제해야 하는 태스크가 있었기 때문입니다.😭

  1. save :
    • save된 파일이 있다면 temp폴더에서 제거
  2. queue
    • save된 파일이 있다면 temp폴더에서 제거
  3. convert
    • gm process를 멈춤
    • 현재까지 컨버팅을 진행한 이미지파일과 pdf파일을 삭제
    • 큐에서 다음 요청을 꺼내 시작
  4. upload
    • upload 중지는 불가능함
    • temp폴더 remove 진행
  5. remove
    • temp폴더 remove 진행
  6. response
    • response end

이를 처리하기 위해 먼저 큐 미들웨어에서 요청이 close되었을 때의 리스너 함수를 작성해주었습니다.

res.once('close', () => {
      cleanListeners(job);
      stopHandler(job)[job.state]();
    });

stopHandler는 현재 작업의 상태(진행 중, 대기 중)에 따라 결정됩니다. 작업에 큐에 대기중이라면 단순히 dequeue함수를 호출하지만, 작업이 현재 진행중이라면 조금 복잡해 집니다.

작업이 어느 미들웨어를 거치고 있는지 체크하기 위해 각 미들웨어마다 requset객체에 stage 프로퍼티를 설정해주었습니다. 실행중인 작업을 멈추는 stopJob 메소드는 현재 request객체의 stage를 파악하여 이에 맞는 handler함수를 호출하는 중간자 역할을 합니다.

 stopJob(job) {
    const { req, res } = job.data;
    const stage = req.stage.next ? 'next' : req.stage.stage;
    const stopHandler = this.stopHandler(job);

    this.dequeueActive();
    job.setState(false, 'stop');

    if (!stopHandler[stage]) res.end();
    else stopHandler[stage]();

    process.nextTick(this.checkQueue.bind(this));
  }

stopHandler가 실행하는 로직이 다음 작업을 큐에서 빼내 시작하는 것보다 항상 먼저 처리되어야 합니다. 이를 보장하기 위해 process.nextTick을 이용하여 checkQueue 작업을 연기해 주었습니다.

트러블슈팅

😭요청 중단 되었는데 promiseAll과 엮여 있을 때..

초기에는 이미지 컨버팅 작업이 promiseAll을 반환하도록 되어있었습니다. 요청이 close되어 클라이언트와 연결이 끊겼을 때, 컨버팅 작업을 멈추려고 시도해도 promiseAll의 종료시점을 제어할 수 없어 불가능합니다.

따라서 이미지 컨버팅 작업을 promise chaning방식으로 변경하여 요청 중단에 대한 방어코드를 넣어주었습니다.

😭multer와 fs.exist, fs.access, fs.stat..

save단계에서 파일이 temp폴더에 써졌다면 요청 중단시 이를 지워주어야 합니다. 이를 위해 fs모듈을 사용하여 파일이 존재하는지 확인하고 unlink하는 로직을 작성하였습니다. 그러나 fs모듈의 exist, access, stat 등의 메소드로 테스트 하였을 때, 분명 파일이 존재한다고 판단하여 unlink를 시도하지만 사실상 temp폴더에 아직 써지지 않아 에러가 발생했습니다..이유를 찾으려 애썼지만.. 정확한 원인을 파악하지 못했습니다. 파일 저장에 multer를 사용하고 있어서 multer의 내부로직이 원인이 아닐까 추측하고 있습니다..

removeSavedFile(job) {
    setTimeout(() => {
      fs.unlink(job.data.req.stage.path, (err) => {
        if (err) this.removeSavedFile(job);
      });
      job.data.res.end();
}, CLEAR_TIME);

어쨌든 파일을 지우기 위해서 setTimeout을 이용하여 err시 로직을 재귀적으로 호출하도록 하였습니다.

😭timeout 발생시 retry 한다.

요청이 중단되는 상황으로 timeout이 있습니다. 이 문제를 캐치한 것은 용량이 10mb, 장수가 80장인 큰 pdf 파일을 올렸을 때 갑자기 요청이 처리가 되지 않고 두번 반복되는 현상을 발견했기 때문입니다. 원인을 찾아보니 timeout이 발생하면 default로 재시도를 하기 때문이라는 사실을 알게되었습니다. 우선 저희 서비스의 특징 상 컨버팅 대기 시간이 길어질 수 있기 때문에 서버의 timeout을 server.setTimeout(TIMEOUT);으로 늘려주었습니다.

😭동일호스트에서 3개 이상의 pdf업로드 요청을 보내면 블락킹 되어 큐에 들어가지 않는다.

아직 원인을 찾고 있는 이슈입니다. express에서 따로 동일 호스트의 요청을 관리하고 있는 것이 아닐까 추측하고 있습니다.

추후 시도해 볼 것

  • loadbalancing을 통한 scale out
  • kafka등 메시지 큐 시스템 도입하여 서버 분리
Clone this wiki locally