위키
Nginx brotli 모듈 적용기

2024.07.13

Nginx brotli 모듈 적용기

얼마전 Web Vital 성능을 개선하면서 Nginx에서 정적 리소스를 gzip으로 압축하여 전송하도록 했는데요. 그 이후에 성능을 더 개선해 보고자 gzip을 brotli로 변경해 보았습니다.

이번 글에서는 brotli 도입 배경과 함께 아래 내용을 진행하면서 겪은 시행착오를 다룹니다.

  • Nginx Open Source에 brotli 모듈 적용하기
  • CodeDeploy 배포 파이프라인에 brotli 모듈 추가하기

도입 배경

brotli (opens in a new tab)는 구글에서 개발한 무손실 압축 알고리즘 입니다. 내부적으로 텍스트 파일을 효율적으로 압축할 수 있는 여러 알고리즘이 적용되어 있어서 gzip보다 압축률이 좋습니다. 결국 데이터 전송 비용이나 페이지 로드 시간을 더 많이 줄일 수 있는 셈인데요. IE를 제외한 모든 브라우저에서 지원 (opens in a new tab)하기 때문에 호환성 이슈도 없어서 프로젝트에 도입해 보기로 했습니다.

Nginx에 brotli 모듈 적용하기

Nginx Plus의 경우 brotli 모듈을 제공 (opens in a new tab)하기 때문에 비교적 간단하게 설치할 수 있습니다만, 현재 회사에서는 Nginx Open Source를 사용하고 있어서 직접 실행파일을 생성했습니다.

실행파일 생성하기

먼저 ssh를 사용해서 EC2에 접속합니다.

$ ssh [인스턴스 별칭]

EC2에는 AMI(Amazon Machine Image)를 통해 Nginx가 설치되어 있습니다. AMI를 통해 설치된 Nginx는 이미 빌드가 완료된 상태이기 때문에 brotli 실행파일을 생성할때 필요한 빌드과정을 수행할 수 없습니다. 따라서 기존에 설치된 Nginx의 버전과 동일한 버전의 Nginx를 별도로 다운로드 합니다.

$ nginx -v
$ sudo wget https://nginx.org/download/nginx-1.24.0.tar.gz
$ tar zxf nginx-1.24.0.tar.gz

그다음 brotli를 다운로드받고, 서브모듈을 최신 상태로 초기화 합니다.

$ git clone https://github.com/google/ngx_brotli.git
$ cd ngx_brotli
$ git submodule update --init

서브모듈 (opens in a new tab)은 Git 저장소 안에 또 다른 Git 저장소를 포함시킬 수 있는 기능입니다. 프로젝트 내에서 독립적으로 버전 관리를 해야 하는 외부 라이브러리나 독립된 프로젝트를 포함할때 사용됩니다.

이제 Nginx의 빌드 프로세스를 활용하여 brotli 실행파일을 생성합니다.

$ cd ../nginx-1.24.0
$ ./configure --with-compat --add-dynamic-module=../ngx_brotli
$ make modules

$ make modules 실행시 다음과 같은 에러가 발생했습니다.

[centos@ip nginx-1.24.0]$ make modules
...
-shared
/usr/bin/ld: cannot find -lbrotlienc
/usr/bin/ld: cannot find -lbrotlicommon
collect2: error: ld returned 1 exit status
make[1]: *** [objs/Makefile:1206: objs/ngx_http_brotli_filter_module.so] Error 1
make[1]: Leaving directory '/home/centos/nginx-1.24.0'
make: *** [Makefile:16: modules] Error 2

링크를 수행할때 필요한 라이브러리가 없어서 발생한 에러입니다.

설치가 필요한 라이브러리 목록을 확인하여 설치해준 뒤 $ make modules를 다시 실행하여 해결했습니다.

[centos@ip nginx-1.24.0]$ yum provides */libbrotlienc.*
...
brotli-devel-1.0.9-6.el9.x86_64 : Lossless compression algorithm (development files)
Repo        : appstream
Matched from:
Filename    : /usr/lib64/libbrotlienc.so
Filename    : /usr/lib64/pkgconfig/libbrotlienc.pc
...
[centos@ip nginx-1.24.0]$ sudo yum install brotli-devel-1.0.9-6.el9.x86_64
[centos@ip nginx-1.24.0]$ make modules

여기까치 마치면 /nginx-1.24.0/objs 하위에 ngx_http_brotli_filter_module.so, ngx_http_brotli_static_module.so 두 개의 실행파일이 만들어 집니다.

모듈 적용하기

이제 모듈을 적용할 차례입니다. 실행파일들을 원하는 위치 /etc/nginx/modules에 복사하고 nginx.conf에 다음과 같은 설정을 추가합니다.

# brotli 모듈을 동적으로 로드
load_module /etc/nginx/modules/brotli-nginx-1.24.0/ngx_http_brotli_filter_module.so;
load_module /etc/nginx/modules/brotli-nginx-1.24.0/ngx_http_brotli_static_module.so;
 
events { ... }
 
http {
    types {
        font/ttf ttf;
    }
 
    # brotli 설정
    brotli on; # 압축 활성화 여부
    brotli_static on; # 미리 압축된 파일을 제공할지 여부
    brotli_types application/javascript text/css font/ttf; # 압축할 파일 유형 (MIME type)
    brotli_min_length 10240; # 압축할 파일의 최소 크기 (byte)
    brotli_comp_level 5; # 압축 레벨 (0 ~ 11)
}

brotli_min_length의 경우 응답 헤더의 Content-Length가 brotli_min_length에 설정한 값보다 큰 경우에만 압축을 한다는 의미입니다. 크기가 너무 작은 파일을 압축할 경우 압축 알고리즘이 파일을 분석하고 압축하는 오버헤드가 파일 크기에 비해 커질 수 있고, 압축 전후 크기 차이가 크게 나지 않아서, 압축되지 않은 파일을 전송하는 것이 오히려 빠를 수 있습니다. 저는 파일 사이즈가 10KB 이상인 경우에만 압축하도록 설정했습니다.

압축 레벨의 경우 레벨이 높을수록 압축과 압축해제에 더 많은 CPU 자원을 필요로 하기 때문에 trade-off를 고려해서 적절한 압축 레벨을 선택해야 합니다. 이때 참고할 수 있는 사이트 (opens in a new tab)가 있습니다. 웹페이지 URL을 입력하면 서빙되는 컨텐츠의 길이(Content-Length)를 기반으로 압축 레벨별 압축률과 gzip 대비 압축 효율을 예측해 줍니다. 제가 담당한 서비스의 경우 압축 레벨이 5 이상이면 압축률과 압축 효율 모두 근소한 차이밖에 나지 않아서 자원을 가장 적게 사용하는 5로 설정했습니다.

brotli 압축 레벨 비교

결과

여기까지 마치고 Nginx를 재시작하면 brotli_types에 명시한 MIME 타입을 가진 리소스들을 brotli로 압축하여 전송하는것을 확인할 수 있습니다.

brotli 적용 전 (gzip)

brotli 적용 전 (gzip 적용)

brotli 적용 후

brotli 적용 후

gzip 대비 7.7% ~ 10.7% 정도 압축률이 개선되었네요👏

하지만 brotli 적용에 성공한 기쁨도 잠시.. 새로운 고민이 생겼습니다. 바로 인스턴스마다 직접 접속해서 brotli 실행파일을 생성해줘야 한다는 번거로움이 남아있었기 때문인데요.

이 문제를 해소하고자 배포 파이프라인에 brotli 모듈을 추가하기로 했습니다. 이렇게 하면 다음의 이점을 얻을 수 있습니다.

  1. brotli 실행파일을 생성하는 작업은 Nginx 버전별로 한번만 진행
  2. 인스턴스 혹은 Nginx를 처음부터 다시 세팅하더라도 배포 파이프라인을 통해 brotli를 쉽게 적용 가능

배포 파이프라인에 brotli 모듈 추가하기

현재 프론트엔드 애플리케이션의 배포 구성은 다음과 같습니다.

배포 파이프라인

GitHub Actions CI를 통해 배포 리소스를 S3에 업로드하고 CodeDeploy 배포를 생성하면 CodeDeploy가 이 리소스를 사용하여 애플리케이션을 EC2에 배포합니다.

실행파일 압축하기

우선 앞서 생성한 두 개의 실행파일 ngx_http_brotli_filter_module.so, ngx_http_brotli_static_module.so를 묶어서 하나의 압축파일로 만들어 줍니다.

$ zip brotli-nginx-1.24.0.zip ngx_http_brotli_filter_module.so ngx_http_brotli_static_module.so

brotli 모듈은 실행파일 생성 과정에서 알 수 있듯이 Nginx 버전에 의존성이 있기 때문에 파일명에 Nginx 버전을 명시하여 올바른 버전을 사용할 수 있도록 했습니다.

그런데 이 파일을 어떻게 하면 가져올 수 있을까요?

원격서버에서 실행파일 가져오기

1. scp로 파일 복사

제일 먼저 scp 파일 전송 프로토콜을 사용해서 배포 서버가 있는 원격지로부터 로컬호스트로 파일을 복사해 보기로 했습니다.

scp는 Secure Copy Protocol의 약자로, ssh 원격 접속 프로토콜을 기반으로 한 파일 전송 프로토콜 입니다. 전송 데이터 암호화, 다양한 인증 메커니즘 지원 등 ssh 프로토콜에서 제공하는 여러가지 보안 기능을 사용할 수 있기 때문에 데이터의 기밀성과 무결성을 유지하면서 호스트간 파일이나 디렉토리를 안전하게 복사할 수 있습니다.

$ scp -i /keys/[pem_key_name].pem -r centos@[ip]:/etc/nginx/modules /modules

pem키를 사용하여 centos 사용자로 원격 호스트에 접근하고 /etc/nginx/modules를 /modules로 복사

이 방법으로 배포 서버에서 gateway 서버로 파일을 복사해오는 데에는 성공했지만 gateway 서버에서 로컬호스트로의 복사는 진행하지 않았습니다.

배포 서버 -> gateway 서버 -> 로컬

scp는 ssh와 동일한 22번 포트를 사용하기 때문에 로컬호스트의 22번 포트를 열어줘야 하는데 이는 보안상 안전하지 않다고 판단했기 때문입니다.

2. curl로 이메일 발송

고민끝에 실행파일을 첨부하여 이메일을 발송해보면 어떨까 하는 생각이 들었습니다.

별도의 메일 서버 구축 없이 메일을 보내고자 curl을 사용했습니다. 이건 curl이 SMTP(Simple Mail Transfer Protocol)를 지원해 주기 때문에 가능한 방법인데요. 여러 시행착오 끝에 다음과 같은 방법으로 메일 발송에 성공했습니다.

먼저 SSL/TLS를 사용하여 gmail의 SMTP 서버로 메일 발송을 요청하는 스크립트를 작성 합니다.

send-email.sh

#!/bin/bash
 
curl -v --ssl-reqd \
--url smtps://smtp.gmail.com:465 \
--user [보내는 사람 메일 주소]:[앱 비밀번호] \
--mail-from [보내는 사람 메일 주소] \
--mail-rcpt [받는 사람 메일 주소] \
--upload-file <(echo -e "Subject: Send brotli\n\nCheck attached file.\n" ; uuencode /home/centos/brotli.zip brotli.zip)

여기서 --user의 값으로 구글 앱 비밀번호 (opens in a new tab)가 필요하다는 점에 주의해야 합니다. 또한 앱 비밀번호를 생성할때 앱 이름은 Mail 로 지정합니다.

앱 비밀번호

앞서 작성한 스크립트에서 --upload-file ... uuencode /home/centos/brotli.zip brotli.zip 부분은 uuencode를 사용해서 바이너리 파일을 ASCII 텍스트로 인코딩하는 부분입니다. 이메일 같은 텍스트 기반 전송 매체에서 바이너리 파일을 전송하기 위한 작업인데요. uuencode를 사용하기 위해 sharutils라는 패키지를 설치해 줍니다. (참고 (opens in a new tab))

$ sudo yum install sharutils

마지막으로 스크립트에 실행권한을 부여하고 실행합니다.

$ chmod +x send-email.sh

brotli 실행파일이 들어있는 이메일이 왔네요👏

curl로 이메일 발송 성공

하지만 실행파일을 이메일로 보내는 방법은 스크립트도 작성해야하고 의존성도 설치해야하고 여러가지로 번거롭습니다. 무엇보다 스크립트에 작성한 앱 비밀번호가 유출될 우려가 있습니다.

3. S3 업로드

그래서 최종적으로는 실행파일을 S3에 업로드 하는 방법을 택했습니다. (왜 진작에 이 생각을 하지 못했을까요..)

$ aws s3 cp brotli-nginx-1.24.0.zip s3://bucket-name/front-nginx-modules/

배포 파이프라인 연동

이제 배포 파이프라인에 brotli 실행파일을 가져오는 과정을 추가하는 일만 남았습니다.

CodeDeploy는 한 번 배포를 할때마다 lifecycle event hook (opens in a new tab)을 실행하는데요. 특정 hook이 실행될때 내가 원하는 스크립트를 지정하여 실행할 수 있습니다.

CodeDeploy lifecycle event hooks 순서

다음과 같이 S3로부터 실행파일을 복사하는 스크립트를 작성합니다.

load-modules.sh

#!/bin/bash
 
BUCKET_NAME="bucket-name"
MODULES_DIR="front-nginx-modules"
 
LOCAL_PATH="/etc/nginx/modules"
 
BROTLI="brotli-nginx-1.24.0.zip"
BROTLI_PATH="$LOCAL_PATH/$(basename "$BROTLI" .zip)"
 
# 실행파일을 저장할 디렉토리가 없으면 만들어 줍니다
if [ ! -d "$LOCAL_PATH" ]; then
    sudo mkdir -p "$LOCAL_PATH"
fi
 
if [ ! -d "$BROTLI_PATH" ]; then
    sudo mkdir -p "$BROTLI_PATH"
fi
 
# 실행파일이 없으면 S3에서 복사합니다
if [ ! -f "$BROTLI_PATH/ngx_http_brotli_filter_module.so" ] || [ ! -f "$BROTLI_PATH/ngx_http_brotli_static_module.so" ]; then
    sudo chmod -R o+w "$LOCAL_PATH"
 
    aws s3 cp s3://$BUCKET_NAME/$MODULES_DIR/$BROTLI $BROTLI_PATH/brotli.zip
    unzip -o $BROTLI_PATH/brotli.zip -d $BROTLI_PATH
    rm -f $BROTLI_PATH/brotli.zip
fi

그리고 AfterInstall hook이 실행될때 위 스크립트를 실행하도록 했습니다.

appspec.yml

hooks:
  AfterInstall:
    - location: load-modules.sh
      runas: centos

배포를 해서 정상적으로 brotli 모듈이 적용되는지 확인해 보았습니다.

디렉토리도 정상적으로 생성되고 S3에서 파일도 잘 가져왔는데 nginx.conf에서 해당 파일을 로드하는 시점에 Permission denied 에러가 발생했습니다.

[emerg] 1315#1315: dlopen() "/etc/nginx/modules/brotli-nginx-1.24.0/ngx_http_brotli_filter_module.so" failed (/etc/nginx/modules/brotli-nginx-1.24.0/ngx_http_brotli_filter_module.so: failed to map segment from shared object: Permission denied) in /etc/nginx/nginx.conf:13

실행파일의 권한은 이미 755인데 어째서 권한 문제가 발생했을까요?

디버깅을 하다 보니 Nginx 실행파일을 직접 실행하면 정상적으로 구동이 되지만 systemctl을 사용해서 실행하면 권한 이슈가 발생한다는 사실을 발견했습니다. 31883 PID를 가진 프로세스에서 Nginx가 실행중이고 여기서 Nginx가 실행파일에 접근할 권한이 없다는 실마리도 얻었고요.

[centos@ip ~]$ systemctl status nginx
 nginx.service - nginx - high performance web server
   Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; vendor preset: disabled)
   Active: failed (Result: exit-code) since 수 2024-07-10 21:41:35 KST; 3min 28s ago
 ...
 7월 10 21:41:35 nginx[31888]: nginx: [emerg] dlopen() "/etc/nginx/modules/brotli-nginx-1.24.0/ngx_http_brotli_filter_module.so" failed (/etc/nginx/modules/brotli-nginx-1.24.0/ngx_http_brotli_filter_module.so: failed to map segment from shared object: Permission denied) in /etc/nginx/nginx.conf:13
 ...

마침내 CentOS에서는 SELinux가 활성화되어 있을 경우 추가적인 권한 설정 필요하다는 것을 알게됐습니다.

SELinux(Security-Enhanced Linux)는 커널 레벨의 보안 모듈 입니다. 어떤 프로세스가 어떤 파일, 디렉터리, 포트등에 접근 가능한지를 엄격하게 제어해서 Linux의 보안을 강화해주는 역할을 하는데요. SELinux의 접근 통제 규칙은 기존의 접근 통제 규칙보다 우선으로 적용되기 때문에 파일의 소유자이더라도 SELinux 의 보안 정책에 맞지 않으면 Permission Denied나 File not Found 등의 에러가 발생하게 됩니다.(참고 (opens in a new tab)) CentOS와 Rocky Linux의 경우 SELinux가 기본적으로 활성화되어 있습니다. 확인해보니 정말로 SELinux가 활성화되어 있었습니다.

원인을 찾았네요. 실행파일의 권한이 755이었으나 SELinux에서 정의한 정책상 Nginx가 해당 파일에 접근할 권한이 없었기 때문에 Permission Denied 에러가 발생한 것이었습니다.

아래와 같이 Nginx가 실행파일에 접근할 수 있도록 추가적인 권한 설정을 했더니 systemctl을 통해서도 Nginx가 정상적으로 실행되었습니다.

sudo chcon -t textrel_shlib_t /etc/nginx/modules/brotli-nginx-1.24.0/ngx_http_brotli_filter_module.so;
sudo chcon -t textrel_shlib_t /etc/nginx/modules/brotli-nginx-1.24.0/ngx_http_brotli_static_module.so;

드디어 배포에 성공했습니다!

그리고 CodeDeploy 배포 파이프라인에 SELinux 권한 설정을 위한 스크립트를 추가했습니다.

selinux-config.sh

#!/bin/bash
sudo chcon -t textrel_shlib_t /etc/nginx/modules/brotli-nginx-1.24.0/ngx_http_brotli_filter_module.so;
sudo chcon -t textrel_shlib_t /etc/nginx/modules/brotli-nginx-1.24.0/ngx_http_brotli_static_module.so;

appspec.yml

hooks:
  AfterInstall:
    - location: load-modules.sh
      runas: centos
    - location: selinux-config.sh

배포에 성공하고 brotli가 적용된 것을 확인한 뒤, 마지막으로 다른 프로젝트에도 brotli를 적용할 수 있도록 프로세스를 문서화하여 팀에 공유하고 작업을 마무리했습니다.