버즈빌에서는 모바일 잠금화면에 노출시키기 위한 광고와 콘텐츠 이미지를 생성하기 위해 PhantomJS 렌더링 서버를 다수 운영하고 있습니다. 일반적으로 PhantomJS는 웹페이지를 캡처할 때 많이 이용하기도 하는데 headless하게 웹페이지를 렌더링하고 캡처할 수 있다는 장점 때문에 동적인 이미지 생성에도 많이 활용합니다.

버즈빌의 렌더링 서버는 200개 이상의 콘텐츠 프로바이더로부터 실시간으로 잠금화면 콘텐츠 이미지를 생성하고 있습니다. 따라서 분당 수백 건의 이미지를 안정적으로 생성하는 것이 가능해야 하죠.

렌더링 서버의 스케일링 이슈를 해결하기 위해 버즈빌은 여러 대의 서버를 두고 횡적 확장과 개별 서버 리소스 이용률을 높이려는 목적으로  Ghost Town 라이브러리를 작성하고, PhantomJS 프로세스 풀을 구성해 사용하고 있습니다. (Scaling PhantomJS With Ghost Town )

그리고 일정 시간이 지나면서 잠금화면에서 렌더링 할 수 있는  이미지 템플릿의 종류가 다양해졌습니다. emoji를 비롯해 여러 특수문자를 표현하기 위한 렌더링 서버에 다중의 폰트(대표적으로 Noto Sans CJK)를 설치하는 요구 사항이 추가됐죠. 이 때문에 PhantomJS에서의 폰트 렌더링이 일관적이지 않은 문제가 발생합니다.

 

동일한 템플릿이지만 서로 다른 폰트로 렌더링 되고 있는 모습

 

문제의 정확한 원인은 결국 찾지 못했지만 PhantomJS의 이슈였거나 아니면 시스템 상에 폰트가 추가 설치되면서 font cache가 일정치 않았던 것으로 생각됩니다.

다른 워크로드와 마찬가지로 렌더링 서버도 처음에는 packer로 일정한 이미지를 빌드하거나 업데이트하려고 했을 겁니다. 그런데 기능 추가나 배포 같은 이슈 없이 서버만 오래 띄워놓고 수동으로 유지 보수 한 케이스들이 누적되면서, 더 이상 packer로 최신 상태를 유지하는 것이 힘들게 된 것이죠. 모든 눈꽃 송이가 자세히 보면 조금씩 다르게 생겼다는 것에서 비롯된 snowflake, 즉 배포된 서버들이 시간이 지나면서 조금씩 다른 상태가 된 것입니다. 평소에는 문제가 없어 보였지만 추가 확장이 필요해 scale out을 하거나 새로운 템플릿으로 배포할 때 문제가 발생했습니다.

사실 더 큰 문제는 PhantomJS 프로젝트가 더 이상 관리되지 않는다는 점이었습니다. 2017년 Google Chrome 59버전부터 Headless Chrome가 내장되기 시작했고, 곧바로 Node API인 puppeteer도 배포되면서 렌더링 엔진을 손쉽게 headless로 사용 가능한 환경이 됐습니다.

결국 PhantomJS 관리자는 사실상의 중단을 선언하고 2018년에 최초 개발자에 의해 프로젝트가 아카이브 됐습니다. 프로젝트가 업데이트되지 않는 것은 템플릿에 최신 CSS 스펙을 적용하지 못한다는 것을 의미하고, 버그 수정도 어렵기 때문에  애플리케이션의 유지 보수가 힘든 상황임을 의미합니다.

 

문제점을 정리하면 아래와 같습니다.

  1. 자주 배포하지 않는 서비스 특성 때문에 서버들이 snowflake화 되는 현상(특히 폰트)
  2. PhantomJS의 개발 중단 버그 픽스 및 최신 CSS 속성 사용이 어려워졌고, 향후 유지 보수나 새로운 템플릿 개발이 어려워짐

 

해결방안은 명확했습니다.

첫번째 문제는 애플리케이션과 폰트가 설치된 시스템을 통째로 컨테이너를 만들고, CI/CD 파이프라인을 통해 지속적으로 빌드해 snowflake화 되지 않도록 하면 됩니다. 맨 처음 packer로 AMI 이미지를 생성하도록 구성했기 때문에 배포 때마다 AMI를 새로 생성하고 지속적인 배포 환경만 갖춘다면 snowflake를 방지할 수 있었을 것입니다.

하지만 기능 추가가 자주 있거나 꾸준히 배포하는 서비스가 아니고 AMI 빌드 과정에서도 CI/CD에 통합되지 않은 상황이었죠. 또, 애플리케이션만 배포하는 환경이다 보니 편의상 서버를 종료하지 않고 장기간 관리를 해왔기에 새로 이미지를 빌드하는 것이 어려워졌습니다. 그래서 이미 운영하고 있었던 kubernetes 클러스터에 도커 컨테이너를 빌드하고 immutable한 형상으로 배포하기를 결정했습니다.

두 번째 문제는 PhantomJS를 puppeteer로 변경해 비교적 간단하게 해결할 수 있었습니다. puppeteer의 api는 PhantomJS와 꽤나 비슷합니다. drop-in replacement까진 아니지만, PhantomJS api 호출하는 부분만 살짝 바꿔주는 정도로 교체가 가능하였습니다. 물론 교체만 한다고 해서 기존에 개발한 템플릿이 우리의 의도대로 출력된다는 보장이 없기에, 서버가 렌더링 하는 수많은 템플릿들을 각각 출력해 비교하는 작업을 했습니다. 아직까지 수동으로 결과를 비교해야 하는 문제점이 있지만 적어도 직접 확인할 수 있다는 점은 여러모로 도움이 되었습니다. 나중에는 자동화된 테스크 케이스를 구성하고 기능 개발에 좀 더 용이하도록 보완할 계획입니다.

결과면에서 만족스러웠습니다. 대부분이 기존과 출력 결과가 달랐지만, 최신 크롬 웹킷이 사용되면서 오히려 템플릿을 개발할 때 의도했던 대로 CSS를 더 정확하게 렌더링 할 수 있게 된 것이지요.

 

FROM node:10-slim

RUN apt-get update && \
apt-get install -yq gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \
libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \
libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \
fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst ttf-freefont \
ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget unzip && \
wget https://github.com/Yelp/dumb-init/releases/download/v1.2.1/dumb-init_1.2.1_amd64.deb && \
dpkg -i dumb-init_*.deb && rm -f dumb-init_*.deb && \
apt-get clean && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*

RUN yarn global add puppeteer@1.10.0 && yarn cache clean
ENV NODE_PATH="/usr/local/share/.config/yarn/global/node_modules:${NODE_PATH}"
RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser

# Set language to UTF8
ENV LANG="C.UTF-8"

RUN wget -P ~/fonttmp \
    https://noto-website-2.storage.googleapis.com/pkgs/NotoSans-unhinted.zip \
    https://noto-website-2.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip \
    https://noto-website-2.storage.googleapis.com/pkgs/NotoSansCJKkr-hinted.zip \
    https://noto-website-2.storage.googleapis.com/pkgs/NotoSansCJKtc-hinted.zip \
    https://noto-website-2.storage.googleapis.com/pkgs/NotoSansCJKsc-hinted.zip \
    https://noto-website-2.storage.googleapis.com/pkgs/NotoColorEmoji-unhinted.zip \
 && cd ~/fonttmp \
 && unzip -o '*.zip' \
 && mv *.*tf /usr/share/fonts \
 && cd ~/ \
 && rm -rf ~/fonttmp

WORKDIR /app
# Add user so we don't need --no-sandbox.
RUN mkdir /screenshots && \
    mkdir -p /home/pptruser/Downloads && \
    mkdir -p /app/node_modules && \
    chown -R pptruser:pptruser /home/pptruser && \
    chown -R pptruser:pptruser /usr/local/share/.config/yarn/global/node_modules && \
    chown -R pptruser:pptruser /screenshots && \
    chown -R pptruser:pptruser /usr/share/fonts && \
    chown -R pptruser:pptruser /app

# Run everything after as non-privileged user.
USER pptruser
RUN fc-cache -f -v

COPY --chown=pptruser:pptruser package*.json /app/
RUN npm install && \
    npm cache clean --force
COPY --chown=pptruser:pptruser . /app/

ENTRYPOINT ["dumb-init", "--"]
CMD ["npm", "start"]

puppeteer를 사용하면서 약간의 권한 문제가 생겨서 위와 같은 Dockerfile을 작성하게 됐는데,  puppeteer 도커 이미지 작성에 관한 최신 정보는 여기서 확인할 수 있습니다.

컨테이너 오케스트레이션(K8s)을 사용하면 process 기반의 스케일링은 컨테이너를 여러대 띄워 로드밸런싱을 손쉽게 할 수 있지만, 개별 컨테이너의 throughput을 향상시키기 위해 기존에 Ghost town을 작성해 PhantomJS 프로세스 풀을 만든 것처럼 크롬 프로세스 풀을 구성하기로 하였습니다. 프로세스 풀 구성에는 generic-pool 라이브러리를 사용했고 구상 내용은 아래와 같습니다.

 

const puppeteer = require("puppeteer");
const genericPool = require("generic-pool");
const puppeteerArgs = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"];
const createPuppeteerPool = ({
  max = 5,
  min = 2,
  maxUses = 50,
  initialUseCountRand = 5,
  testOnBorrow = true,
  validator = () => Promise.resolve(true),
  idleTimeoutMillis = 30000,
  ...otherConfig
} = {}) => {
  const factory = {
    create: async () => {
      const browser = await puppeteer.launch({ headless: true, args: puppeteerArgs });
      browser.useCount = parseInt(Math.random() * initialUseCountRand);
      return browser;
    },
    destroy: (browser) => {
      browser.close();
    },
    validate: (browser) => {
      return validator(browser)
        .then(valid => Promise.resolve(valid && (maxUses <= 0 || browser.useCount < maxUses)));
    }
  };
 const pool = genericPool.createPool(factory, { max, min, testOnBorrow, idleTimeoutMillis, ...otherConfig });
 const genericAcquire = pool.acquire.bind(pool);
 pool.acquire = () => genericAcquire().then(browser => {
   browser.useCount += 1;
    return browser;
  });
  pool.use = (fn) => {
    let resource;
    return pool.acquire()
      .then(r => {
        resource = r;
        return resource;
      })
      .then(fn)
      .then((result) => {
        pool.release(resource);
        return result;
      }, (err) => {
        pool.release(resource);
        throw err;
      });
  };
  return pool;
};
module.exports = createPuppeteerPool;

Caveats

PhantomJS에서 puppeteer로 전환함에 있어서 몇가지 주의해야 할 점이 있었는데요.

첫째는 기존에 사용하던 템플릿의 html에 이미지 소스를 file:// url 프로토콜을 이용해 로드하는 경우가 있었는데, PhantomJS에서는 정상적으로 로드가 되지만 Headless Chrome에서는 보안 정책으로 인해 로컬 파일을 로드할 수 없었습니다(관련 이슈). 때문에 로컬 이미지가 필요한 템플릿은 Express 서버에서 static file serving을 하도록 하고 http:// 프로토콜로 변경하였습니다.

다음으로 발생한 문제는 PhantomJS을 이용한 기존 구현에서는 jade template을 compile한 후 page 객체의 setContent 메소드를 이용해 html을 로드하였는데, puppeteer에서는 page#setContent API 호출 시 외부 이미지가 로드될 때까지 기다리지 않는다는 점입니다. puppeteer 에 올라온 관련 이슈에서는 `=setContent`= 대신 아래와 같이 html content를 data URI로 표현하고 page#goto의 인자로 넘기면서 waitUntil 옵션을 주는 방식을 해결방법으로 권하고 있습니다.

 

await page.goto(`data:text/html,${html}`, { waitUntil: 'networkidle0' });

 

이때 주의해야 할 점은 waitUntil의 옵션인 networkidle0이나 networkidle2 등을 사용하면 외부 이미지가 충분히 로드될 때까지 기다려야 합니다. 그리고 500ms 이내로 추가 네트워크 커넥션이 발생하지 않을 때까지 기다리는 옵션이기 때문에 외부 이미지가 로드되더라도 500ms를 기다려야 합니다. 때문에 SPA 웹페이지를 캡처하는 경우가 아닌, 정적인 html을 로드하는 경우라면 `load` 이벤트로 지정하면 됩니다.

이 밖에 향후 프로젝트의 유지관리나 운영 중인 서비스 모니터링을 위해 Metrics API 엔드포인트를 만들어 prometheus에서 메트릭을 수집할 수 있도록 하고 grafana 대시보드도 구성했습니다. 이 대시보드는 어떤 템플릿이 실제 사용되고 있고, 템플릿 렌더링에 얼마큼의 시간이 소요되는지 등을 모니터링 할 수 있도록 구성합니다. 사용하지 않는 템플릿들을 화인하거나 서비스 지표를 모니터링하는데 이용하고 있지요.

 

grafana와 prometheus를 이용해 구현한 렌더링 서버 모니터링 대시보드

마치며

최근 들어 PhantomJS를 사용했던 많은 곳들이 puppeteer로 전환하는 추세라 이번 콘텐츠 내용이 새롭게 느껴지지 않을 수도 있습니다. 하지만 버즈빌의 렌더링 서버가 과거에 이미 PhantomJS 사용 전제로 최적화가 상당히 진행되왔고, 높은 수준의 동시 처리량이 요구되는 상황에서 puppeteer로 교체를 하는 것은 불확실한 요소가 수반되는 상황이었죠.

그리고 버즈빌의 핵심 비즈니스인 잠금화면에 사용되는 이미지의 렌더링 서비스가 레거시(개발이 중단된 PhantomJS)에 의존하는 코드베이스이기 때문에 중간에 변경이 어려워지면 향후 회사에 큰 기술부채로 작용할 것이라 판단했습니다.

이번에 마이그레이션을 진행하면서 컨테이너를 사용해 CI/CD 파이프라인을 구축해 이를 기반으로 지속적인 이미지 생성이 가능하도록 변경을 했고 결과 또한 만족스러웠습니다. 그간 밀려 있던 신규 템플릿 개발이나 신규 컨텐츠 프로바이더를 추가하는 과정이 더욱 수월해졌기 때문입니다.

빠르게 변하는 비즈니스 요구사항에 대응을 하다 보면 기술부채는 어쩔 수 없이 쌓일 수 밖에 없습니다. 개발자에게는 당연히 눈에 보이는 모든 기술부채를 청산하려는 욕구가 있지만 모든 시간을 빚 갚는데 할애할 수는 없습니다. 리소스적 한계 때문이죠. 만약 어떤 기술부채를 해결해야 하고, 의사결정이 필요한 상황이라면 일단 ‘측정’ 먼저 해보는 것을 추천합니다.

수치화된 지표가 있다면 당장 의사결정권자나 팀을 설득하는 데 사용할 수도 있지만, 이렇게 서비스의 핵심 지표들을 하나 둘 모니터링 해나가다 보면 회사 서비스에 가시성이 높아지고 미래에 병목이 되는 지점을 미리 파악하는데 도움이 될 것입니다.

참고 자료

 


[fbcomments url=”http://ec2-13-125-22-250.ap-northeast-2.compute.amazonaws.com/2019/01/14/buzzvil-puppeteer/” width=”100%” count=”off” num=”5″ countmsg=”wonderful comments!”]