Express + Typescript로 개발한 토이 프로젝트 서버를 Docker로 빌드하고 AWS EC2로 배포를 진행했다. 로컬에서 Docker 빌드 테스트를 시도하고, EC2에서 배포를 진행하면서 여러 에러에 마주쳤고 전 과정을 간단히 정리해보았다.
Typescript로 개발을 진행하여 빌드된 파일만 도커 이미지로 만들면 되기 때문에 Step 1과 2로 나누었다.
Dockerfile
# Step 1
FROM node:16.17.1 AS builder
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
RUN npm run api-docs
# Step 2
FROM node:16.17.1-alpine
WORKDIR /app
RUN npm install -g pm2
COPY --from=builder /app ./
⚠️ Error
Error: Error loading shared library /app/node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node: Exec format error
이 에러 같은 경우 bcrypt의 특성에 따라 발생하는 에러라고 한다. bcrypt 같은 경우 python을 기반으로 작동한다. 위 node alpine 이미지는 필요한 최소의 기능만 담은 경량화된 이미지이기 때문에 python이 없고, 이에 bcrypt를 인식하지 못하는 문제가 발생한다. (참고)
bcryptjs
패키지를 설치해서 사용하라는 솔루션이 많이 보였지만, .dockerignore에 node_modules 폴더를 추가하라는 조언을 찾았고 나에게도 효과가 있었다. (참고)
.dockerignore
node_modules
build
docker-compose.yaml
version: '3.9'
networks:
scratchnow-api-net:
driver: bridge
services:
db:
image: mysql:latest
restart: 'always'
ports:
- "3306:3306"
container_name: ScratchNow-DB
environment:
MYSQL_ROOT_PASSWORD: scratch
MYSQL_DATABASE: scratchnow_dev
MYSQL_USER: user
MYSQL_PASSWORD: scratch
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "ScratchNow-DB", "-uroot", "-pscratch"]
retries: 2
networks:
- scratchnow-api-net
scratchnow-api:
build:
context: .
dockerfile: Dockerfile
ports:
- "3300:3300"
container_name: ScratchNow-API
networks:
- scratchnow-api-net
depends_on:
db:
condition: service_healthy
command:
- sh
- -c
- |
npm run migrate
pm2-runtime start ecosystem.config.js --env development
docker-compose 스크립트에서 우선 mysql 컨테이너와 API 서버 앱 컨테이너를 같은 networks
bridge로 묶어주었다. 하지만 MySQL을 앞 순서로 두고 docker-compose를 진행해도 서버 앱이 DB 컨테이너 실행 도중에 함께 시작되는 것을 콘솔에서 확인했다. 이러한 시간 차로 서버가 정상적으로 DB와 연결되어 실행되지 않았다.
docker-compose 순서를 조정하는 데에는 몇 가지 방법이 보였지만, MySQL healthcheck
를 하는 방법을 선택했다. 이를 통해 MySQL 컨테이너가 온전히 다 실행되고 healthcheck가 끝난 후 정상 가동으로 판단되면 서버 앱 컨테이너가 가동되었다.
...
healthcheck: # mysql
test: ["CMD", "mysqladmin", "ping", "-h", "ScratchNow-DB", "-uroot", "-pscratch"]
retries: 2
...
depends_on: # server app
db:
condition: service_healthy
...
또한, 이번에 Typescript로 Sequelize를 사용하느라 DB migration CMD가 별도로 존재했다. docker-compose의 command 옵션으로 마이그레이션 명령어를 넣어주었더니 exit code: 0
과 함께 마이그레이션이 무한대로 실행되었다. 이유는 빌드 옵션으로 초반에 넣었던 restart: true
때문이었다. docker로 실행하는 cmd 명령어는 npm run start처럼 종료되지 않아야 한다고 한다.
빌드 옵션의 restart를 제외하고, 마이그레이션 후에 pm2로 앱을 시작하는 command를 작성해주었다. 아래는 복수의 command를 실행하는 방법으로 작성했다. 참고로, 컨테이너에서 pm2로 앱을 시작하려면 pm2가 아닌 pm2-runtime으로 시작해야 한다고 한다.
...
command:
- sh
- -c
- |
npm run migrate
pm2-runtime start ecosystem.config.js --env development
pm2 클러스터 모드로 서버 앱을 실행하기 위해 ecosystem 파일을 생성해서 사용했다. 아래 instances
옵션의 숫자 값으로 시작 시 생성할 인스턴스 수를 지정했다.
ecosystem.config.js
module.exports = {
apps: [
{
name: 'ScratchNow',
script: './build/app.js',
instances: 4,
exec_mode: 'cluster',
merge_logs: true,
autorestart: true,
watch: true,
instance_var: 'INSTANCE_ID',
env_development: {
NODE_ENV: 'development',
},
env_production: {
NODE_ENV: 'production',
},
},
],
};
EC2 인스턴스 생성
Amazon Linux 2 Kernel 5.10 AMI - t2.micro
Node 설치
# nvm 설치
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.34.0/install.sh | bash
# nvm 활성화
$ . ~/.nvm/nvm.sh
# 노드 특정 버전으로 설치(현재 lts)
$ nvm install 16.17.1
# 설치 및 버전 확인
$ node -v
$ npm -v
git 설치 및 Repository clone
$ sudo yum install git -y
$ git clone [Repository]
docker 설치 후 Run
$ sudo amazon-linux-extras install docker
$ docker-compose up -d
⚠️ Error
ERROR: Version in "./docker-compose.yaml" is unsupported. You might be seeing this error because you're using the wrong Compose file version. Either specify a supported version (e.g "2.2" or "3.3") and place your service definitions under the
services
key, or omit theversion
key and place your service definitions at the root of the file to use version 1.
docker-compose 설치를 해주지 않아서 생긴 에러이다. Mac과 Window는 docker 설치 시 자동으로 docker-compose도 설치되지만, Linux는 그렇지 않다고 한다. latest 버전으로 설치하고 실행 권한을 주었다.
$ sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
docker 데몬이 켜져 있지 않아 시작해주었다.
$ sudo service docker start
[ec2-user@ip-XXX-XX-XX-XXX server]$ docker-compose up -d
Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.24/containers/json?all=1&filters=%7B%22label%22%3A%7B%22com.docker.compose.project%3Dserver%22%3Atrue%7D%7D&limit=0": dial unix /var/run/docker.sock: connect: permission denied
파일의 권한을 666으로 변경하여 그룹 내 다른 사용자도 접근 가능하게 했다.
$ sudo chmod 666 /var/run/docker.sock
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
#0 19.16 <--- Last few GCs --->
#0 19.16
#0 19.16 [18:0x5208550] 16850 ms: Mark-sweep (reduce) 481.9 (491.1) -> 480.9 (490.6) MB, 587.0 / 0.0 ms (average mu = 0.080, current mu = 0.022) allocation failure scavenge might not succeed
#0 17.95 <--- Last few GCs --->
#0 17.95 oc[18:0x6521550] 15782 ms: Mark-sweep (reduce) 482.7 (490.9) -> 481.9 (491.4) MB, 388.9 / 0.0 ms (+ 69.7 ms in 15 steps since start of marking, biggest step 21.3 ms, walltime since start of marking 479 ms) (average mu = 0.317, current mu = 0.215) alloca[18:0x6521550] 16361 ms: Mark-sweep (reduce) 482.9 (491.4)
#0 17.95 <--- JS stacktrace --->
#0 17.95
#0 17.95 FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
#0 17.97 1: 0xb02960 node::Abort() [node]
#0 17.97 2: 0xa18149 node::FatalError(char const*, char const*) [node]
#0 17.98 3: 0xcdd22e v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [node]
#0 17.98 4: 0xcdd5a7 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [node]
#0 17.98 5: 0xe94c15 [node]
#0 17.98 6: 0xea48dd v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [node]
#0 17.98 7: 0xea75de v8::internal::Heap::AllocateRawWithRetryOrFailSlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [node]
#0 17.98 8: 0xe68b1a v8::internal::Factory::NewFillerObject(int, bool, v8::internal::AllocationType, v8::internal::AllocationOrigin) [node]
#0 17.99 9: 0x11e1886 v8::internal::Runtime_AllocateInYoungGeneration(int, unsigned long*, v8::internal::Isolate*) [node]
#0 17.99 10: 0x15d54f9 [node]
#0 21.28 Aborted (core dumped)
가장 당황했던 에러였다. AWS EC2 설정에서 인스턴스 볼륨을 8GB에서 16GB로 늘려도 해결되지 않았고, 내부에서 메모리 사이즈를 조정해도 그대로였다. (참고)
원인은 RAM에 있었던 것 같다. t2.micro RAM은 1GB뿐이라 서버 메모리가 부족해서 그렇다는 글을 보았고, 인스턴스 종료 후 t2.small(2GB)로 올려 다시 실행했더니 해결되었다. AWS 비용이 얼마나 나올지 모르겠지만 꼭 해결해보고 싶어 주저하지 않고 시도한 보람이 있었다.