제가 OpsNow의 레거시 환경을 개선하기 위해서 가장 처음 도입하고자 마음먹은 것은 어쩌면 당연하게도 개별 서비스를 컨테이너로 만드는 것입니다. 그리고 이렇게 생성된 컨테이너 이미지를 ECS에서 Fargate에 배포함으로써 Serverless 서비스를 만들고자 하였습니다.

많은 시간과 노력을 들인 끝에, 코드에서부터 서비스 배포까지 이어지는 CI/CD 파이프라인을 구축할 수 있었습니다. 이번 글을 통해서 어떻게 OpsNow의 레거시 서비스가 Serverless로 나아갈 수 있었는지 하나하나 살펴보도록 하겠습니다.

Dockerfile

기존에 개발자들이 만든 개별 프로젝트의 대부분은 Java 기반의 jar 혹은 war 패키지를 생성하고, 이를 EC2에 SSH를 통해서 배포하는 방식으로 서비스 배포가 이루어졌습니다. 프론트 서비스도 npm을 이용하여 빌드한 다음 dist를 배포하는 방식입니다. 기존 방식도 당분간은 유지되어야 하므로, 프로젝트마다 적절한 Dockerfile을 추가하여 컨테이너 이미지를 생성할 수 있는 기반을 마련해야 했습니다.

Maven 기반의 스프링 혹은 스프링 부트의 경우에는 jar 또는 war 방식으로 서비스 배포가 가능합니다. 프론트 프로젝트라면 nginx를 이용하는 것이 가장 무난할 것입니다. 동일한 방법을 이용하되, Dockerfile에 담아서 컨테이너로 만드는 방법은 여러 가지가 있습니다.

jar 예제

Maven을 이용해서 jar 패키지를 빌드하는 경우에는 Java 이미지를 이용하여 서비스를 실행할 수 있습니다. 용량을 최소화하기 위해서 alpine-jre 이미지를 이용합니다. 그리고 스프링 부트 서비스의 경우에는 Docker 이미지를 실행하면서 PROFILE 변수를 받기 위해 환경 변수를 설정을 합니다.

FROM adoptopenjdk/openjdk8:alpine-jre

ENV PROFILE=“”
WORKDIR /root/

COPY docker-entrypoint.sh /root/
RUN chmod 755 docker-entrypoint.sh

EXPOSE 8080

COPY target/*.jar /root/ROOT.jar
ENTRYPOINT /root/docker-entrypoint.sh ${PROFILE}

Code language: Dockerfile (dockerfile)

스크립트는 Java에서 사용될 각종 설정을 추가하기 위함입니다.

#!/bin/sh

java -Dspring.profiles.active=$1 \
-Dserver.port=8080 \
-server \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseCGroupMemoryLimitForHeap \
-jar ROOT.jar

Code language: Shell Session (shell)

war 예제

war 패키지는 Tomcat을 이용하여 다음과 같이 기본 이미지의 ROOT를 삭제하고 ROOT.war를 배포하면 자연스럽게 서비스 배포가 이루어집니다. 마찬가지로, 용량을 최소화하기 위해서 jre와 alpine을 이용합니다. Jetty를 이용할 수도 있겠지요.

FROM tomcat:9-jre8-alpine

RUN rm -rf /usr/local/tomcat/webapps/ROOT
ADD ./target/*.war /usr/local/tomcat/webapps/ROOT.war

Code language: Dockerfile (dockerfile)

jib 플러그인

위와 같이 Dockerfile을 이용하지 않고도 Maven에서 플러그인을 이용하면 곧바로 컨테이너 이미지를 생성할 수 있습니다. jib 플러그인은 구글에서 제공하는 오픈소스 컨테이너 툴로써 Docker 데몬을 필요로 하지 않는 장점이 있습니다.

pom.xml에 플러그인을 추가하고, ECR 배포를 위한 설정을 추가합니다. ECR 배포에는 인증을 위한 사전 설정이 필요하지만, 위 링크의 문서를 살펴보시면 그다지 어렵지는 않습니다. 컨테이너의 기본 이미지는 구글이 사전에 지정한 이미지로 적절히 설정됩니다.

<project>
...
<build>
<plugins>
...
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.0.0</version>
<configuration>
<to>
<image>aws_account_id.dkr.ecr.region.amazonaws.com/my-app</image>
</to>
</configuration>
</plugin>
...
</plugins>
</build>
...
</project>
Code language: HTML, XML (xml)

그 후에 Maven을 이용하여 손쉽게 ECR 이미지 생성이 가능합니다.

mvn compile jib:build
Code language: Bash (bash)

nginx 예제

npm 등으로 빌드된 웹 서비스를 배포하기 가장 쉬운 방법은 nginx 이미지를 이용하는 것입니다. 사전 정의된 nginx.conf를 소스에 포함하고 다음과 같은 Dockerfile을 이용합니다.

FROM nginx:1.19-alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY dist /usr/share/nginx/html

EXPOSE 80

Code language: Dockerfile (dockerfile)

Jenkins 파이프라인

이제 소스를 가져와서 빌드할 차례입니다. 무난하게 사용하기 쉬운 Jenkins에서 파이프라인을 구축합니다. Declarative 파이프라인을 이용하여 다음과 같이 구축하였습니다.

  • Git checkout
  • Maven or npm build
  • Docker build
  • Docker push to ECR
  • Trigger CodeDeploy or ECS deployment

현재는 Jenkins 위에서 스크립트를 직접 설정하지만, 고도화가 완료되면 Jenkinsfile을 이용하여 프로젝트 코드에 포함할 수 있습니다.

패키지 빌드

파이프라인 자체는 대부분이 Parameter를 이용하도록 되어있습니다. mvn을 이용할 경우는 사전 정의된 settings.xml을 이용하도록 합니다.

stage('build-mvn') {
agent { docker { image 'maven:3-jdk-8-alpine' } }
steps {
configFileProvider([configFile(fileId: '...', variable: 'MAVEN_SETTINGS')]) {
sh 'mvn clean package -DskipTests=false -s ${MAVEN_SETTINGS}'
}
}
}
Code language: Groovy (groovy)

npm도 비슷합니다.

stage('build-npm') {
agent { docker { image 'node:14.11.0' } }
steps {
sh 'npm --version'
sh 'node --version'
sh 'npm run build'
}
}
Code language: Groovy (groovy)

컨테이너 빌드 및 배포

그 후에 Docker 이미지 생성 및 ECR 배포는 동일한 과정을 거칩니다. Maven의 jib을 이용하면 이와 같은 절차는 생략될 수 있습니다.

stage('build-docker-image') {
agent { label 'default' }
steps {
sh 'docker build -t ${REGISTRY}:${BUILD_NUMBER} -t ${REGISTRY}:latest .'
}
}
stage('push-ecr') {
agent { label 'default' }
steps {
withAWS(role: ASSUME_ROLE, roleAccount: ACCOUNT, externalId:'externalId') {
sh 'aws ecr get-login-password --region ${REGION} | docker login --username AWS --password-stdin ${REGISTRY}'
sh 'docker push ${REGISTRY}:${BUILD_NUMBER}'
sh 'docker push ${REGISTRY}:latest'
sh 'docker rmi ${REGISTRY}:${BUILD_NUMBER}'
sh 'docker rmi ${REGISTRY}:latest'
}
}
}
Code language: Groovy (groovy)

배포

마지막으로 서비스 배포를 위해서 서비스가 로드 밸런서에 연결되어 있다면 CodeDeploy를 호출하여 블루/그린 배포를 진행합니다.

stage('deploy-blue-green') {
agent { label 'default' }
steps {
withAWS(role: ASSUME_ROLE, roleAccount: ACCOUNT, externalId:'externalId') {
sh "aws deploy create-deployment --region ${REGION} --cli-input-json file://deploy.json --output text --query \'deploymentId\')"

}
}
}

Code language: Groovy (groovy)

로드 밸런서에 연결되지 않은 경우라면 ECS에 직접 서비스 업데이트를 진행합니다.

stage('deploy-ecs') {
agent { label 'default' }
steps {
withAWS(role: ASSUME_ROLE, roleAccount: ACCOUNT, externalId:'externalId') {
sh 'aws ecs update-service --force-new-deployment --cluster ${CLUSTER} --service ${SERVICE} --region ${REGION}'
sh 'echo "https://${REGION}.${CONSOLE}/ecs/v2/clusters/${CLUSTER}/services/${SERVICE}/health?region=${REGION}"'
}
}
}
Code language: Groovy (groovy)

AWS 인프라

자, 이제 코드 레벨과 Jenkins 파이프라인에서 컨테이너 빌드 및 배포가 준비되었습니다. 마지막으로 AWS 인프라만 준비가 되면 서비스 배포가 가능합니다.

  • ECR
  • ECS 서비스
  • ECS 작업 정의
  • CodeDeploy 배포

이러한 인프라는 모두 Terraform으로 관리, 배포되고 있습니다. Jenkins 파이프라인에서 인프라의 이름 등을 변수로 이용하기 때문에 가급적 공통화된 규칙을 유지해야 할 필요가 있기 때문입니다.

.
├── _env
│ ├── dev.tfvars
│ └── prd.tfvars
├── cloud-watch.tf
├── code-deploy.tf
├── deploy.sh
├── ecr-repository.tf
├── ecs-service.tf
├── ecs-task-definition.tf
├── iam.tf
├── load-balancer.tf
├── main.tf
├── security-group.tf
├── states
│ ├── dev.tfstate
│ └── prd.tfstate
└── variables.tf
Code language: CSS (css)

하나의 서비스는 위와 같은 구조로 Terraform 소스가 관리되고 있습니다. 여러 서비스가 동일한 구조를 가지도록 설계했기 때문에 추후에는 대부분을 Module로 관리할 수 있을 것으로 기대하고 있습니다만, 아직은 초기 단계라서 개별 프로젝트가 다수의 동일한 파일을 이용하고 있습니다. 😏

이처럼 IaC는 진행되고 있으나 자동 배포시에 인프라 형상 파괴 대한 리스크 헷지를 구현하지 못한 관계로 현재는 수동으로 인프라가 배포되고 있습니다. 빨리 개선할 생각에 몸이 근질근질 합니다!

서비스 배포

자, 이제 모든 단계가 완료되었습니다. 기존에 EC2로 배포되던 서비스는 다음 순서를 통해서 Serverless로 이전하게 됩니다.

  • 코드 레벨에서 Dockerfile 추가
  • ECS 서비스 등의 인프라 생성
  • CI/CD 파이프라인으로 배포
  • 기존 EC2 배포 중단 및 EC2 삭제

기존보다 빠르고 안정적인 배포가 가능해졌고, 배포 용량도 줄어들었으며 대부분이 코드로 관리가 가능해져서 점점 더 많은 부분의 형상 관리가 가능해지고 있습니다. 조금 더 나은 OpsNow가 되기 위한 여정, 계속 지켜봐주세요. 😎