EC2 on premise 환경에 Jar 파일(컨테이너X)을 배포하는 과정을 기록하려고 한다.
쿠버네티스[EKS] 환경에 CICD Pipeline은 따로 포스팅해두었으니 해당 글을 참고하면 될 듯 하다.
https://sh970901.tistory.com/165
본론으로 돌아와 CI 도구는 Gitlab-Runner를 사용하였다. 깃랩과 통합이 잘되었고 익숙한 도구였다. CD는 AWS의 CodeDeploy를 사용하였다. AWS에 특화된 CD 도구인데, 멀티 클러스터 혹은 타 클라우드 서비스이면 이보다 Spinnaker와 같은 툴이 더 좋을 수도 있을 거 같다. Gitlab-Runner도 뭔가 Gitlab에 종속된 CI 도구라서 Jenkins와 같은 툴을 쓰는 것도 좋은 선택이다.
지금보면 하나의 종속된 도구를 쓰는 것이 장단점이 뚜렷한 거 같다.
CICD 구성도를 그려보면 다음과 같다.
AWS Codedeploy를 활용해본 경험자라면, 혹은 Jenkins와 같은 CI 도구를 활용해본 엔지니어라면 눈에 와닿을 수 있지만 나와 같은 주니어 또는 신입 엔지니어를 위해 과정을 섬세하게 기록해보려고 한다.
CICD 구성도를 그려보면 다음과 같다. AWS Codedeploy를 활용해본 경험자라면, 혹은 Jenkins와 같은 CI 도구를 활용해본 엔지니어라면 눈에 와닿을 수 있지만 나와 같은 주니어 또는 신입 엔지니어를 위해 과정을 섬세하게 기록해보려고 한다.
build 단계의 compile과 test 단계의 test는 주석 처리하였다.
Maven의 lifecycle을 잘 이해하고 불필요한 단계를 스킵하여 궁극적인 목표는 개발 단계에서 빠르게 개발, 운영 환경에 배포하는 것이였다. 따라서 실제 진행되는 단계는 package -> deploy -> release 이며 package 단계에서 jar파일을 생성하여 S3에 업로드하고 deploy 단계에서는 aws codedeploy를 활용하여 배포한다. aws codedeploy 설정에 따라서 Blue/Green 또는 Rolling update가 가능하다.
전체 스크립트이다.
스크립트와 구성도를 같이 본다면 이해가 더 빠를 것 같다.
stages:
# - build
# - test
- package
- deploy
- release
cache:
paths:
- .m2/repository/
- target/
variables:
MAVEN_CLI_OPTS: "-U"
.package_template:
script: &package
- mvn $MAVEN_CLI_OPTS clean package
- aws s3 cp ./target/${CI_PROJECT_NAME}.jar s3://${S3_BUCKET_NAME}/config/jar/${CI_PROJECT_NAME}/ ; fi;
.deploy_template:
script: &deploy
- echo "Deploying the app"
- if [ ! -d deploy ]; then mkdir deploy; fi;
- aws s3 cp s3://${S3_BUCKET_NAME}/config/comm/${CI_PROJECT_NAME}/start.sh ./deploy
- aws s3 cp s3://${S3_BUCKET_NAME}/config/comm/${CI_PROJECT_NAME}/appspec.yml ./deploy
- aws s3 cp s3://${S3_BUCKET_NAME}/config/zone/${CI_PROJECT_NAME}/${CI_COMMIT_BRANCH}.conf ./deploy/${CI_PROJECT_NAME}.conf
- aws s3 cp s3://${S3_BUCKET_NAME}/config/jar/${CI_PROJECT_NAME}/${CI_PROJECT_NAME}.jar ./deploy
- zip -r deploy.zip ./deploy/*
- aws s3 cp ./deploy.zip s3://${S3_BUCKET_NAME}/${CI_COMMIT_BRANCH}/${CI_PROJECT_NAME}/
- aws s3 cp ./deploy.zip s3://${S3_BUCKET_NAME}/${CI_COMMIT_BRANCH}/history/${CI_PROJECT_NAME}/${CI_PIPELINE_ID}.zip;
- aws deploy create-deployment --application-name ${APPLICATION_NAME} --deployment-group-name ${DEPLOYMENT_GROUP} --s3-location bucket=${S3_BUCKET_NAME},bundleType=zip,key=${CI_COMMIT_BRANCH}/${CI_PROJECT_NAME}/${DEPLOY}.zip --region ap-northeast-2 | grep -G -o d-.*[A-Z0-9]| while read line; do echo "https://ap-northeast-2.console.aws.amazon.com/codesuite/codedeploy/deployments/"$line"?region=ap-northeast-2"; aws deploy wait deployment-successful --deployment-id "$line"; done
#build:
# stage: build
# tags:
# - devzone
# script:
# - mvn $MAVEN_CLI_OPTS clean compile
# only:
# - dev
#test:
# stage: test
# tags:
# - devzone
# script:
# - mvn $MAVEN_CLI_OPTS clean test
# allow_failure: true
# only:
# - dev
#
#sonarqube-check:
# stage: test
# tags:
# - devzone
# image: maven:3.6.3-jdk-11
# variables:
# SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar" # Defines the location of the analysis task cache
# GIT_DEPTH: "0" # Tells git to fetch all the branches of the project, required by the analysis task
# cache:
# key: "${CI_JOB_NAME}"
# paths:
# - .sonar/cache
# script:
# - mvn verify sonar:sonar
# allow_failure: true
# only:
# - dev
package:
stage: package
variables:
S3_BUCKET_NAME: "dalgona-deploy"
script: *package
tags:
- devzone
only:
- dev
deploy:
stage: deploy
variables:
S3_BUCKET_NAME: "dalgona-deploy"
APPLICATION_NAME: ${CI_COMMIT_BRANCH}
DEPLOYMENT_GROUP: ${CI_PROJECT_NAME}
DEPLOY: "deploy"
script: *deploy
environment:
name: development
tags:
- devzone
only:
- dev
# See https://docs.gitlab.com/ee/ci/yaml/#release for available properties
tagging:
stage: release
script:
- echo "running release_job"
allow_failure: true
rules:
- if: "$CI_PIPELINE_SOURCE == 'push' && $CI_COMMIT_BRANCH == 'master'"
tags:
- dev
release:
tag_name: 'Pipeline.$CI_PIPELINE_ID'
description: 'Pipeline.$CI_PIPELINE_ID'
ref: '$CI_COMMIT_SHA'
package 단계와 deploy 단계에 스크립트는 template화 하여 분리하였다. 정확히는 변수에 따라 스크립트를 메소드화했다.
브랜치별로 다른 설정(변수) 값들이 들어오는 것을 그에 맞춰서 적용하기 위함이였다. 그게 아니면 브랜치 별로 각 Job을 모두 만들어야하면 코드 중복이 생긴다.
maven compile은 package 단계에서 어차피 수행하기에 생략하였다.
maven lifecycle에서 이전 단계가 생략되어있으면 수행되기 때문이다. 예를 들어 compile -> test -> package 에서 package만 수행해도 compile -> test는 자동으로 수행될 것이다.
Gitlab-Runner를 호스트에 설치해서 사용하는 경우는 .m2 캐싱을 활용하는데 따로 볼륨을 잡거나 할 필요가 없기에 두번쨰 빌드부터는 라이브러리가 캐싱되어 빠른 속도를 느낄 수 있었다.
SonarQube를 활용한 코드 커버리지 등의 분석은 프로젝트 개발 단계에서 너무 많은 시간을 차지했고 현재 주어진 환경과 고려해야할 여러 부분들을 생각했을때 제거하자는 의견이 수립되어 제거하게 되었다. 서로 코드에 대한 리뷰가 중요하고 눈높이를 맞추거나 지속적으로 코드 품질이 관리되어야 하는 환경이라면 강력한 도구인 것은 확실한 것 같다.
스크립트를 확인하면 start.sh, appspec.yml, conf 파일 이 세가지를 같이 Jar 파일과 묶어 zip을 만들었다.
일단 codedeploy가 zip 파일을 대상으로 deploy할 수 있기에 zip을 만들었고 codedeploy가 실행할 수 있는 파일인 appspec.yml 파일을 만들었다.
https://docs.aws.amazon.com/ko_kr/codedeploy/latest/userguide/reference-appspec-file.html
conf 파일은 브랜치 별로 동적으로 실행시킬수 있는 JAVA_OPTS가 포함된 설정 파일이다. 이는 자바 실행 파일 jar를 실행할 때 필요한 conf 파일이다.
이렇게 하나의 zip으로 묶어 codedeploy를 실행하였다.
각 배포마다 tagging을 활용하여 당시 소스 상태로 롤백하거나 소스를 pull 받거나 등의 작업을 할 수 있다. 또한 운영 환경에서 버전, 릴리스를 관리할 수 있으니 JOB에 추가하였다.
| grep -G -o d-.*[A-Z0-9]| while read line; do echo "https://ap-northeast-2.console.aws.amazon.com/codesuite/codedeploy/deployments/"$line"?region=ap-northeast-2"; aws deploy wait deployment-successful --deployment-id "$line"; done
해당 부분은 codedeploy의 현재 배포가 끝날때까지 Runner Job을 종료하지 않기 위해 추가하였다.
aws deploy create-deployment 를 실행하면 결과 값으로 deployment-id를 반환해준다.
자세한 wait 관련 CLI는 해당 문서를 참고하자.
https://docs.aws.amazon.com/cli/latest/reference/deploy/wait/deployment-successful.html
codedeploy에 설정에 알림 규칙을 생성할 수 있다. codedeploy Application을 만들고 설정에 알림 규칙을 생성할 수 있다. 알림 규칙은 deployment에 대하여 Succeeded, Failed, Started에 대해 트리거 할 수 있으며 대상으로 SNS, AWS Chatbot이 가능하다.
(필자는 SNS 주제를 만들어 람다함수를 만들었지만 AWS Chatbot을 써보니 너무 편리하게 메신저와 연동이되며 원하는 기능을 람다 함수로 만들 수 있도록 구성이 잘 되어있는거 같았다.)
람다 구성도 그리 어렵진 않다. 많은 엔지니어 분들이 만들어 놓은 코드가 있어서 그대로 활용하였다.
람다의 환경 변수로 CHANNEL과 SERVICES를 설정하여 사용하도록 한다.
key value
SERVICES | /services/token |
CHANNEL | #codedeploy |
런타임 환경 Node.js 16.X
var services = process.env.SERVICES; //Slack service
var channel = process.env.CHANNEL; //Slack channel
var https = require('https');
var util = require('util');
// 타임존 UTC -> KST
function toYyyymmddhhmmss(date) {
if(!date){
return '';
}
function utcToKst(utcDate) {
return new Date(utcDate.getTime() + 32400000);
}
function pad2(n) { return n < 10 ? '0' + n : n }
var kstDate = utcToKst(date);
return kstDate.getFullYear().toString()
+ '-'+ pad2(kstDate.getMonth() + 1)
+ '-'+ pad2(kstDate.getDate())
+ ' '+ pad2(kstDate.getHours())
+ ':'+ pad2(kstDate.getMinutes())
+ ':'+ pad2(kstDate.getSeconds());
}
var formatFields = function(string) {
var message = JSON.parse(string),
fields = [],
deploymentOverview;
// Make sure we have a valid response
if (message) {
fields = [
{
"title" : "Deployment Id",
"value" : message.detail.deploymentId, //
"short" : true
},
{
"title" : "Status",
"value" : message.detail.state, //
"short" : true
},
{
"title" : "Application",
"value" : message.detail.application, //
"short" : true
},
{
"title" : "Deployment Group",
"value" : message.detail.deploymentGroup, //
"short" : true
},
{
"title" : "Region",
"value" : message.region, //
"short" : true
},
{
"title" : "Deployment Link",
"value" : 'https://'+message.region+'.console.aws.amazon.com/codesuite/codedeploy/deployments/'+message.detail.deploymentId+'?region='+message.region, //
"short" : true
},
{
"title" : "Time",
"value" : toYyyymmddhhmmss(new Date((message.time) ? message.time : '')), //
"short" : true
}
/*
,{
"title" : "Error Code",
"value" : message.errorInformation? JSON.parse(message.errorInformation).ErrorCode: '',
"short" : true
},
{
"title" : "Error Message",
"value" : message.errorInformation? JSON.parse(message.errorInformation).ErrorMessage: '',
"short" : true
}
*/
];
}
return fields;
};
exports.handler = function(event, context) {
var postData = {
"channel": channel,
"username": "AWS SNS via Lamda :: CodeDeploy Status",
"text": "*" + JSON.parse(event.Records[0].Sns.Message).detailType + "*"
};
var fields = formatFields(event.Records[0].Sns.Message);
var message = event.Records[0].Sns.Message;
var severity = "good";
var dangerMessages = [
" but with errors",
" to RED",
"During an aborted deployment",
"FAILED",
"Failed to deploy application",
"Failed to deploy configuration",
"has a dependent object",
"is not authorized to perform",
"Pending to Degraded",
"Stack deletion failed",
"Unsuccessful command execution",
"You do not have permission",
"FAILURE",
"Your quota allows for 0 more running instance"];
var warningMessages = [
" aborted operation.",
" to YELLOW",
"Adding instance ",
"Degraded to Info",
"Deleting SNS topic",
"is currently running under desired capacity",
"Ok to Info",
"Ok to Warning",
"Pending Initialization",
"Removed instance ",
"Rollback of environment",
];
for(var dangerMessagesItem in dangerMessages) {
if (message.indexOf(dangerMessages[dangerMessagesItem]) != -1) {
severity = "danger";
break;
}
}
// Only check for warning messages if necessary
if (severity === "good") {
for(var warningMessagesItem in warningMessages) {
if (message.indexOf(warningMessages[warningMessagesItem]) != -1) {
severity = "warning";
break;
}
}
}
postData.attachments = [
{
"color": severity,
"fields": fields
}
];
var options = {
method: 'POST',
hostname: 'hooks.slack.com',
port: 443,
path: services // Defined above
};
var req = https.request(options, function(res) {
res.setEncoding('utf8');
res.on('data', function (chunk) {
context.done(null);
});
});
req.on('error', function(e) {
console.log('problem with request: ' + e.message);
});
req.write(util.format("%j", postData));
req.end();
};
다음 그림처럼 Deployment Status를 포함한 Deployment Job에 대한 정보를 한눈에 확인할 수 있다.
그러면 어떻게 codedeploy가 jar파일을 재실행할 수 있을까?
codedeploy 설정에 따라서 rolling, blue/green을 설정할 수 있지만 자바 실행 파일을 어떻게 돌리는지는 appspec.yml을 활용하였다.
appspec.yml
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/applications/api/sample-api-temp/
permissions:
- object: /
pattern: "**"
owner: ec2-user
group: ec2-user
hooks:
AfterInstall:
- location: start.sh
timeout: 300
runas: ec2-user
https://docs.aws.amazon.com/ko_kr/codedeploy/latest/userguide/reference-appspec-file.html
해당 문서를 참고하였고 zip 파일이 destination으로 설정한 경로에 설치되었을때 hooks으로 실행되게 하였다.
start.sh
#!/bin/bash
if [ ! -d /home/ec2-user/applications/api/sample-api ]; then mkdir /home/ec2-user/applications/api/sample-api; fi;
cp -r /home/ec2-user/applications/api/sample-api-temp/* /home/ec2-user/applications/api/sample-api
sudo systemctl restart sample-api.service
결국 aws codedeploy가 지정한 위치에 zip파일을 떨어트리고 start.sh를 실행하는데, 여기서 jar를 재실행하는 로직을 추가하였다.
nohub을 이용하거나 다양한 방법으로 데몬 프로세스로 돌릴텐데 필자는 비교적 최근 방법인 service 파일을 활용하였다. service 파일로 설정하여 systemd라는 프로세스가 데몬들을 관리하도록 설정하였다. service는 시스템 데몬 및 사용자 정의 데몬을 의미하는데 OS 부팅 시 systemd 프로세스가 먼저 실행되기 때문에 같이 init 될 수 있다.
자세한 systemd, service, systemctl은 더 찾아보도록 하자.
/etc/systemd/system/sample-api.service
[Unit]
Description=sample-api.jar
After=syslog.target
[Service]
User=ec2-user
ExecStart=/home/ec2-user/applications/api/sample-api/sample-api.jar
SuccessExitStatus=143
[Install]
WantedBy=multi-user.target
다음처럼 지정해주면 sample-api.jar가 실행된다.
동일한 경로에 sample-api.conf 처럼 동일한 이름으로 지정해주면 --config가 적용된다.
sample-api.conf
JAVA_HOME=/etc/alternatives/java
JAVA_OPTS="-Xms2G -Xmx2G -XX:+UseG1GC -XX:+UseStringDeduplication -XX:MaxMetaspaceSize=512M -XX:+UnlockExperimentalVMOptions -XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=25
-Dcontainer.name=sample-api
-Dspring.profiles.active=dev"
JAVA_HOME을 지정해주고 JAVA_OPTS가 적용된다.
sudo systemctl enable 명령을 통해 서비스를 부팅 시 자동으로 시작하도록 설정하고
sudo systemctl start sample-api.service를 실행해주도록 한다. 이후 status로 조회해보면 active 상태를 확인할 수 있다.
EC2 호스트 위에 Jar파일이 올라간 구조에서 CICD Pipeline을 구성해보았다. 하지만 단일 호스트에 Jar가 올라가는 형식은 리소스 낭비가 심하였고 관리하는 것, 자동화 하는 것 모두 번거럽고 같은 일의 반복이였다. 대신 초기 설정이 간단한 장점은 있었다. 또한 해당 인스턴스의 모든 자원을 활용할 수 있으니 성능 저하가 없이 일관된 성능을 쓸 수 있는 것도 장점은 장점인 것 같다. 그래서 단일 호스트의 Jar 를 두개 이상 올렸더니 하나의 서비스가 CPU를 점유하면 다른 서비스는 장애가 발생하였다. 또한 java 자체는 힙이 메모리를 너무 먹고 들어가는게 아닌가 싶었다. 이렇게 VM이 나와서 환경을 격리시키게 되었고 VM보다 더 가벼운 환경이 필요해 OS를 추상화해 컨테이너가 나오지 않았나 생각이 들었다. 이미 컨테이너의 시대를 살고 있어서 내가 정답을 내릴 수는 없는 것 같다.
쿠버네티스[EKS] 환경에 CICD Pipeline은 따로 포스팅해두었으니 해당 글을 참고하면 될 듯 하다.
https://sh970901.tistory.com/165
추가로 설치한 JDK와 Maven은 기록만 하도록 하겠다.
Install JDK 17
wget https://download.java.net/java/GA/jdk17.0.1/2a2082e5a09d4267845be086888add4f/12/GPL/openjdk-17.0.1_linux-x64_bin.tar.gz
tar zxvf openjdk-17.0.1_linux-x64_bin.tar.gz
sudo mv jdk-17.0.1 /usr/bin/
sudo alternatives --install /usr/bin/java java /usr/bin/jdk-17.0.1 1
sudo vim /etc/profile.d/java.sh
export JAVA_HOME=/etc/alternatives/java
export PATH=${JAVA_HOME}/bin:${PATH}
sudo chmod +x /etc/profile.d/java.sh
source /etc/profile.d/java.sh
java -version
Install Maven 3.8
wget https://downloads.apache.org/maven/maven-3/3.8.3/binaries/apache-maven-3.8.3-bin.tar.gz -P /tmp
sudo tar xf /tmp/apache-maven-3.8.3-bin.tar.gz -C /opt
sudo ln -s /opt/apache-maven-3.8.3 /opt/maven
sudo vim /etc/profile.d/maven.sh
export M2_HOME=/opt/maven
export MAVEN_HOME=/opt/maven
export PATH=${M2_HOME}/bin:${PATH}
sudo chmod +x /etc/profile.d/maven.sh
source /etc/profile.d/maven.sh
mvn -version
'IT' 카테고리의 다른 글
스프링부트 Virtual Thread, Spring Data Redis(Lettuce) 도입기 (0) | 2025.01.09 |
---|---|
쿠버네티스[EKS] Strimzi Kafka 구축 (Kafka-Exporter, Metrics, Kafka-UI) (1) | 2024.11.18 |
쿠버네티스[EKS] CICD review (ArgoCD+GitLab Runner) (0) | 2024.11.12 |
쿠버네티스 오브젝트 정리 (2) | 2024.11.07 |
쿠버네티스 아키텍쳐 & 컴포넌트 (3) | 2024.11.06 |