이번에 사내에서 배치 관리 툴을 구축하면서 배치 관리 툴을 구축하기 위해 어떤 요소가 필요하고 어떤 생각을 거치게 되었는지 해당 내용을 공유하고자 작성하게 되었습니다.
배치 어플리케이션은 사전에 정의된 작업을 자동으로 수행하는 소프트웨어입니다. 이러한 어플리케이션은 일반적으로 사용자의 개입 없이 큰 데이터 세트를 처리하거나 복잡한 계산을 반복적으로 수행합니다. 배치 어플리케이션은 주로 데이터 처리, 파일 관리, 시스템 유지보수 작업 등에 사용됩니다.
하나의 EC2 인스턴스에서 crontab으로 실행하면서 관리하는 방식으로 운영했습니다.
AWS Batch로 위 요구사항들을 만족 시킬 수 있고 금액적인 부분도 절감할 수 있습니다. 하지만 AWS Cloud 방식에 대해 팀원 모두가 이해하고 있어야 관리를 할 수 있다는 부분과 또한 나중에 추가적인 특별한 요구사항이 발생하거나 Cloud 플랫폼을 변경하는 경우 AWS Batch에 종속적이기 때문에 확장에 좀 제한적일 수 있다 생각이 들었습니다.
빠르게 개발해서 적용해야 했기 때문에 많은 고민을 할 수 없어 해당 요구사항을 쉽게 처리할 수 있고 무료인 플랫폼이 무엇이 있을까 생각하다가 오픈소스인 Jenkins을 이용하면 좋을 것 같다는 생각을 하게 되었습니다. 이전에 Jenkins를 사용해본 경험이 있기 때문에 이런 접근을 할 수 있었습니다.
Jenkins에서 지원하는 기능
Pipeline으로 배치 어플리케이션을 실행시키는 방식으로 어느정도 요구사항들을 충족시킬 수 있다고 판단이 되었고 가이드라인만 잘 작성하고 Pipeline을 템플릿화 시키면 팀원들도 쉽게 관리할 수 있겠다 생각도 했습니다. 그래서 직접 구현하게 되었습니다.
Jenkins는 오픈 소스 자동화 서버로, 소프트웨어 개발의 지속적 통합(CI)과 지속적 배포(CD)를 지원합니다. 다양한 플러그인을 통해 빌드, 테스트 및 배포 프로세스를 자동화하여 개발의 효율성을 크게 향상시킬 수 있습니다. Jenkins는 Java로 작성되었으며, 다양한 운영 체제에서 실행될 수 있습니다. 사용자는 웹 인터페이스를 통해 작업을 구성하고, 실행 결과를 모니터링하며, 상세한 로그 정보를 확인할 수 있습니다.
Jenkins에서는 마스터-에이전트(또는 마스터-슬레이브) 아키텍처를 사용하여 작업 부하를 분산시키고, 다양한 환경에서 작업을 실행할 수 있습니다. 이 구조는 큰 규모의 프로젝트와 다수의 작업을 효율적으로 처리하기 위해 설계되었습니다.
EC2 템플릿을 이용하면 Jenkins Master를 구동하기 편리하게 만들어 줍니다.
#!/bin/bash
# 도커 설치
sudo yum update -y
sudo yum install docker -y
sudo service docker start
sudo usermod -aG docker ec2-user
# 백업 파일이 존재하다면 백업 파일 S3에서 다운로드
aws s3 cp [s3 백업 경로] /home/ec2-user
tar -xzvf /home/ec2-user/jenkins-data -C /home/ec2-user
rm /home/ec2-user/jenkins-data
sudo chown ec2-user:ec2-user /home/ec2-user/jenkins-data
# 컨테이너 ID 또는 이름 설정
container_name="jenkins-docker"
# Jenkins 실행
docker run --name $container_name --restart=always --detach --privileged --volume /home/ec2-user/jenkins-data:/var/jenkins_home --publish 2376:8080 --publish 50000:50000 jenkins/jenkins:jdk17
# AWS CLI가 설치되어 있는지 확인하고, 없다면 설치
docker exec -u root $container_name which aws || docker exec -u root $container_name apt-get update && docker exec -u root $container_name apt-get install -y awscli
# rsync 설치 (백업 용도)
docker exec -u root $container_name which rsync || docker exec -u root $container_name apt-get update && docker exec -u root $container_name apt-get install -y rsync
Pipeline Job을 별도의 분리된 환경에서 실행하기 위해서는 Jenkins Agent를 이용해야하는데 각기 다른 VPC 환경에서 실행해야하는 Agent를 어떤 방식으로 Jenkins Master와 연결할 수 있는지 알아보도록 하겠습니다.
EC2 템플릿을 이용하면 Jenkins Agent를 Jenkins Master에 자동으로 연결하게 설계할 수 있고 각기 다른 VPC EC2 인스턴스를 설정할 수 있습니다. VPC 환경에 맞는 AGENT를 생성하고 Jenkins Master로 연결할 수 있습니다.
값에 들어가는 ENV의 값은 환경별로 “STG, DEV” 이렇게 설정 해줍니다. 해당 값에서 ENV 값은 VPC 환경을 식별하기 위해 추가해주는 값으로 항상 명시 해야합니다.
만약 일반 배치라고 하면 AgentType를 넣어주지 않아도 됩니다. 만약 Worker성 작업인 경우 필수적으로 ENV-worker-agent 즉 worker-agent로 끝나는 값을 넣어주어야합니다. 해당 값에서 ENV 값은 VPC 환경을 식별하기 위해 추가해주는 값으로 항상 명시 해야합니다.
인스턴스가 실행할 때 Jenkins Agent를 실행 및 Jenkins Master에 연결하고 다른 기본적인 환경 설정을 하기 위해 필요합니다.
#!/bin/bash
# 도커 설치 및 실행
sudo yum update -y
sudo yum install docker -y
sudo service docker start
# 사용자 ec2-user를 docker 그룹에 추가하여 Docker 명령을 실행할 때마다 sudo 권한이 필요하지 않도록 하는 명령어
sudo usermod -aG docker ec2-user
# Jenkins Master에 연결하기 위한 스크립트 S3에서 가져옴
sudo aws s3 cp [s3 connect-agent-scripts 경로] /home/ec2-user/
# 어떤 환경에서 배치 Agent가 실행중인지 식별하기 위해 작성
ENV=batch
# 연결 스크립트 압축 풀기
sudo tar -xzvf /home/ec2-user/connect-batch-agent-scripts -C /home/ec2-user
sudo rm connect-batch-agent-scripts
cd /home/ec2-user/connect-agent
# jenkins_agent_start 스크립트 ENV 환경변수와 실행
sudo ENV="$ENV" sh jenkins_agent_start.sh
Jenkins Agent가 Jenkins Master에 연결하기 위한 Script입니다.
사용자 데이터에서 Connect Agent Script를 다운받아 실행할 수 있도록 S3 저장소에 해당 스크립트 들을 저장하도록 합니다.
FROM jenkins/agent
USER root
# AWS CLI 설치, GIT 설치, rsync
RUN apt-get update && apt-get install -y \
awscli \
rsync \
jq \
&& rm -rf /var/lib/apt/lists/*
USER jenkins
해당 스크립트는 Jenkins Node를 생성하기 위한 스크립트 입니다. Jenkins Node를 생성해야 해당 Node로 Agent가 연결할 수 있기 때문에 연결하기전 필수적으로 실행해야하는 스크립트입니다.
# ENV 환경변수가 존재하는지 확인
if [ -z "$ENV" ]; then
echo "환경 값이 설정 되지 않아 연결할 수 없습니다"
exit
fi
# EC2의 Instance Id를 가져옴
INSTANCE_ID=$(ec2-metadata -i | awk '{print $2}')
# Jenkins Master의 Url 설정
JENKINS_URL="Jenkins Master Url"
# Jenkins Credential 정보
JENKINS_USER="user"
JENKINS_TOKEN="token"
# Node 이름은 Instance Id와 EC2-AGENT 조합으로 설정
NODE_NAME="$INSTANCE_ID-EC2-AGENT"
# Node에서 한번에 실행할 수 있는 작업의 갯수 설정 기본적으로 2로 설정
EXECUTOR_SIZE=2
# EC2의 리소스태그에서 Agent의 타입을 가져와 label 설정 (아무것도 설정하지 않았다면 batch-agent)
AGENT_TYPE=$(aws ec2 describe-tags --filters "Name=resource-id,Values=$(ec2-metadata -i | awk '{print $2}')" "Name=key,Values=AgentType" --query 'Tags[0].Value' --output text)
[[ "$AGENT_TYPE" == "None" ]] && AGENT_TYPE="batch-agent"
if [[ "$AGENT_TYPE" == *worker-agent ]]; then
EXECUTOR_SIZE=1
fi
# AGENT를 등록하는 groovy 스크립트
ADD_AGENT_SCRIPT="import hudson.model.*
import hudson.slaves.*
String nodeName = '${NODE_NAME}'
String nodeDescription = 'A new jenkins agent node'
String remoteFS = '/home/jenkins'
int numExecutors = '${EXECUTOR_SIZE}'
String labelString = '${ENV}-${AGENT_TYPE}'
Node.Mode nodeMode = Node.Mode.NORMAL
DumbSlave newSlave = new DumbSlave(nodeName,
nodeDescription,
remoteFS,
Integer.toString(numExecutors),
nodeMode,
labelString,
new JNLPLauncher(),
RetentionStrategy.INSTANCE,
new LinkedList<>())
Jenkins.instance.addNode(newSlave)
"
curl -X POST "$JENKINS_URL/scriptText" \
--data-urlencode "script=$ADD_AGENT_SCRIPT" \
--user "$JENKINS_USER:$JENKINS_TOKEN"
해당 스크립트는 Jenkins Node에 연결하기 위한 스크립트 입니다.
# EC2의 Instance Id를 가져옴
INSTANCE_ID=$(ec2-metadata -i | awk '{print $2}')
# Jenkins Master의 ip 설정
# 만약 도메인이 ALB에 설정되어 있으면 HTTP로만 통신이 가능하기 때문에
# 50000번 포트로 TCP 연결 할 수 없어 고정 Public IP로 연결 해야함
JENKINS_URL="Jenkins Master Url"
# Jenkins Credential 정보
JENKINS_USER="user"
JENKINS_TOKEN="token"
# Node 이름은 create_jenkins_agent.sh에서 생성했던 Node 이름으로 설정
NODE_NAME="$INSTANCE_ID-EC2-AGENT"
# Jenkins Node의 정보를 가져오기 위한 Groovy 스크립트 (Node의 비밀 키를 알아야 연결할 수 있음)
GET_AGENT_SCRIPT="import jenkins.model.Jenkins;
import hudson.slaves.SlaveComputer;
Jenkins jenkins = Jenkins.getInstance();
SlaveComputer computer = (SlaveComputer) jenkins.getComputer('$NODE_NAME');
if (computer != null) { computer.getJnlpMac(); } else { ''; }"
# Jenkins Rest API 호출을 통해 Groovy 스크립트 실행 및 에이전트 비밀 키 얻기
SECRET=$(curl -s -u $JENKINS_USER:$JENKINS_TOKEN "$JENKINS_URL/scriptText" --data-urlencode "script=$GET_AGENT_SCRIPT" | grep 'Result:' | cut -d ' ' -f2)
# jenkins-agent 폴더를 마운트 하기 위해 폴더 생성 및 권한 설정
sudo mkdir -p /home/ec2-user/jenkins-agent/
sudo chown ec2-user:ec2-user /home/ec2-user/jenkins-agent
# jenkins agent 연결
echo 'Agent 연결'
docker run -d -i --rm -v /home/ec2-user/jenkins-agent:/home/jenkins/agent --name jenkins-agent jenkins/agent java -jar /usr/share/jenkins/agent.jar -url "$JENKINS_URL" -secret "$SECRET" -name "$NODE_NAME" -workDir "/home/jenkins/agent"
해당 스크립트는 Jenkins Agent를 실행하는 스크립트로 Jenkins Node 생성, Jenkins Agent 연결, Jenkins Master가 존재하는지 체크합니다.
if [ -z "$ENV" ]; then
echo "환경 값이 설정 되지 않아 연결할 수 없습니다"
exit
fi
# EC2의 Instance Id를 가져옴
INSTANCE_ID=$(ec2-metadata -i | awk '{print $2}')
# Agent 이름 설정(Jenkins Node 이름)
AGENT_NAME="$INSTANCE_ID-EC2-AGENT"
# Jenkins Credential 정보
USER="user"
TOKEN="token"
# Jenkins Master의 Url 설정
JENKINS_URL="Jenkins Master Url"
# Jenkins Node의 정보를 가져옴
RESPONSE=$(curl -s --user $USER:$TOKEN "$JENKINS_URL/computer/$AGENT_NAME/api/json")
echo 'Agent 연결확인'
# Jenkins Node가 존재하는지 확인
if echo "$RESPONSE" | grep -q "offline"; then
# Offline 상태인 경우에 연결을 시도합니다.
if echo "$RESPONSE" | grep -q '"offline":false' ; then
echo "Agent '$AGENT_NAME'이 이미 연결 되어 있습니다."
else
sudo docker stop jenkins
echo "Agent '$AGENT_NAME'이 연결되지 않았기 때문에 연결을 시도합니다."
sudo sh connect_jenkins_agent.sh
fi
else
sudo docker stop jenkins-agent
echo "Agent '$AGENT_NAME'가 존재하지 않기 때문에 Agent를 생성합니다"
sudo sh /home/ec2-user/deploy/scripts/create_jenkins_agent.sh
echo "Agent '$AGENT_NAME' 연결 시도중..."
sudo sh /home/ec2-user/deploy/scripts/connect_jenkins_agent.sh
fi
Shared Library를 통해 Jenkins Pipeline에서 반복적인 함수들을 정의해서 사용할 수 있습니다.
(root)
+- src # Groovy source files
| +- org
| +- foo
| +- Bar.groovy # for org.foo.Bar class
+- vars
| +- foo.groovy # for global 'foo' variable
| +- foo.txt # help for 'foo' variable
+- resources # resource files (external libraries only)
| +- org
| +- foo
| +- bar.json # static helper data for org.foo.Bar
Shared Library의 폴더구조는 위와 같습니다. 일반적으로는 Global로 사용해야하기 때문에 vars 폴더에 groovy 스크립트를 작성합니다. groovy script는 아래와 같이선언해 사용할 수 있습니다.
def call() {
로직
}
agent_tamplate_name을 인자로 받아 EC2 인스턴스 탬플릿을 실행하고 EC2 인스턴스의 id를 반환합니다.
def call(String agent_template_name) {
return sh(script: "aws ec2 run-instances --launch-template LaunchTemplateName='${agent_template_name}' --query 'Instances[0].InstanceId' --output text", returnStdout: true).trim()
}
EC2 인스턴스 id를 받아 해당 인스턴스를 종료합니다.
def call(String instance_id) {
sh "aws ec2 terminate-instances --instance-ids '${instance_id}'"
}
위에서 다룬 Shared Library를 활용하여 아래와 같이 Pipeline을 구성할 수 있습니다. pipeline이 종료되면 EC2 Agent도 종료해 줘야하기 때문에 post always에서 EC2 Instance를 terminate 시켜줍니다.
def instance_id = ''
pipeline {
aagent {
node {
label 'master'
}
}
stages {
stage('start ec2-agent') {
steps{
script {
instance_id=create_ec2_agent("ECHO-BATCH-JENKINS-AGENT")
}
}
}
stage('Do Something') {
agent { label "${instance_id}-EC2-AGENT" }
steps {
script {
sh "Do Something"
}
}
}
}
post {
always{
terminate_ec2_agent("${instance_id}")
}
}
}
유의할 점은 배치와 관련된 작업을 실행하는 Stage에서는 항상 agent를 아래와 같이 설정해야합니다.
stage('작업') {
agent { label "${instance_id}-EC2-AGENT" }
steps {
script {
작업
}
}
}
import java.text.SimpleDateFormat
def call(String environment, String appName) {
def dateFormat = new SimpleDateFormat("MMMM dd, yyyy, 'at' hh:mm a")
dateFormat.setTimeZone(TimeZone.getTimeZone("Asia/Seoul"))
def currentDate = dateFormat.format(new Date())
def jobName = env.JOB_NAME.tokenize('/').last().toUpperCase()
def message = [
[
"type": "header",
"text": [
"type": "plain_text",
"text": ":cool-roomba: Start Batch (${environment}) :cool-roomba:"
]
],
[
"type": "divider"
],
[
"type": "section",
"fields": [
[
"type": "mrkdwn",
"text": "*App Name:*\n${appName}"
],
[
"type": "mrkdwn",
"text": "*Job Name:*\n${jobName}"
],
[
"type": "mrkdwn",
"text": "*When Build Start:*\n${currentDate}"
],
[
"type": "mrkdwn",
"text": "*Build Cause:*\n${currentBuild.rawBuild.getCauses()[0].getShortDescription()}"
]
]
],
[
"type": "actions",
"elements": [
[
"type": "button",
"text": [
"type": "plain_text",
"text": "Job Status",
"emoji": true
],
"style": "primary",
"url": "${env.BUILD_URL}/job/${env.JOB_NAME}/${env.BUILD_NUMBER}"
]
]
]
]
slackSend(blocks: message)
}
def instance_id = ''
def startBuildDate = new Date()
pipeline {
aagent {
node {
label 'master'
}
}
environment {
BATCH_JOB = 'BATCH_JOB'
APP_NAME = 'batch-app'
ENV = 'develop'
VPC_ENV = 'DEV'
}
stages {
stage('start ec2-agent') {
steps{
script {
def response = job_start_send_slack(env.ENV, env.APP_NAME)
threadId = response.threadId
instance_id=create_ec2_agent("${VPC_ENV}-BATCH-JENKINS-AGENT")
}
}
}
stage('Checkout Batch Application') {
agent { label "${instance_id}-EC2-AGENT" }
steps {
script {
fetch_batch_application(env.ENV ,env.APP_NAME)
}
}
}
stage('Check Batch Jar') {
agent { label "${instance_id}-EC2-AGENT" }
steps {
script {
exist_batch_jar(env.APP_NAME)
}
}
}
stage('XXXX 배치 작업') {
agent { label "${instance_id}-EC2-AGENT" }
steps {
script {
start_batch_timestamp_job(env.BATCH_JOB, env.PROFILE)
}
}
}
}
post {
always{
terminate_ec2_agent("${instance_id}")
}
success {
job_success_send_slack(startBuildDate, threadId)
}
failure {
job_fail_send_slack(startBuildDate, threadId)
}
}
}
인스턴스를 생성하면서 매번 새 노드를 생성하기 때문에 생성한 노드를 사용하고 난 후 정리해줘야합니다.
아래 파이프라인을 실행해 Offline 노드를 제거 합니다.
pipeline {
agent {
node {
label 'master'
}
}
stages {
stage('Remove Offline Nodes') {
steps {
script {
def jenkinsInstance = Jenkins.get()
// 모든 노드 중에서 오프라인 노드만 필터링
def offlineNodes = jenkinsInstance.nodes.findAll { node ->
node.toComputer().isOffline()
}
// 오프라인 노드 제거
for (def node : offlineNodes) {
removeNode(node)
}
}
}
}
}
}
Jenkins의 스크립트 보안 정책으로 인해 이러한 종류의 스크립트는 별도의 권한을 추가해 줘야하는데 스크립트 실행하면서 아래와 같은 권한을 하나씩 추가해줘야합니다.
method hudson.model.AbstractCIBase getNodes
method hudson.model.Computer isOffline
method hudson.model.Node toComputer
staticMethod jenkins.model.Jenkins get
Jenkins로 운영하다보면 주기적으로 데이터들을 백업해야하는데 아래와 같은 파이프라인으로 백업을 S3저장소로 할 수 있습니다. 백업 도중에 pipeline이 실행될 수 있기 때문에 안전한 유틸리티인 resync를 통해 tmp 폴더로 옮기고 tmp 폴더에 있는 데이터를 S3 저장소에 압축해서 저장하도록 하였습니다.
pipeline {
agent {
node {
label 'master'
}
}
stages {
stage('Prepare Backup') {
steps {
script {
sh 'rm -rf /tmp/jenkins_backup'
sh 'mkdir -p /tmp/jenkins_backup'
// rsync를 사용하여 /var/jenkins_home의 내용을 /tmp/jenkins_backup으로 복사합니다.
sh 'rsync -av /var/jenkins_home/ /tmp/jenkins_backup/'
}
}
}
stage('tar jenkins_home') {
steps {
script {
// /tmp/jenkins_backup 디렉토리를 tar로 압축합니다.
sh 'tar -zcvf /tmp/jenkins_home_backup.tar.gz -C /tmp/jenkins_backup .'
sh 'ls -l /tmp/jenkins_home_backup.tar.gz'
}
}
}
stage('Backup S3') {
steps {
script {
// 생성된 tar 파일을 S3로 복사합니다.
sh "aws s3 cp /tmp/jenkins_home_backup.tar.gz [S3 저장소 경로]"
}
}
}
}
}
Worker Application 같은 경우는 지속적으로 실행되기 때문에 배포시에는 기존 Application을 종료하고 배포된 Application으로 재시작 해야하기 때문에 아래와 같은 스크립트를 실행해 배포합니다.
ENV="환경 정보"
# 변수 설정
JENKINS_URL="Jenkins Master Url"
JENKINS_USER="user"
JENKINS_TOKEN="token"
SEARCH_STRING="${ENV}-worker"
worker_jobs=$(curl -s -u $JENKINS_USER:$JENKINS_TOKEN "$JENKINS_URL/api/json?tree=jobs%5Bname%5D" | jq -r ".jobs[] | select(.name | contains(\"$SEARCH_STRING\")) | .name")
if [ -z "$worker_jobs" ]; then
echo "존재하는 Worker Job이 없습니다"
exit
fi
for JOB_NAME in "${worker_jobs[@]}"; do
BUILD_NUMBER=$(curl -s "$JENKINS_URL/job/$JOB_NAME/api/json?tree=lastBuild%5Bnumber%5D" --user $JENKINS_USER:$JENKINS_TOKEN | jq -r '.lastBuild.number')
echo "$JOB_NAME : $BUILD_NUMBER 종료"
curl -X POST "$JENKINS_URL/job/$JOB_NAME/$BUILD_NUMBER/stop" --user "$JENKINS_USER:$JENKINS_TOKEN"
echo "$JOB_NAME 재실행"
curl -X POST "$JENKINS_URL/job/$JOB_NAME/buildWithParameters?ENV=${ENV}" --user "$JENKINS_USER:$JENKINS_TOKEN"
done
```![](https://velog.velcdn.com/images/kjy0302014/post/f55f5f34-8fbf-4101-9f33-f4aac90c83cc/image.md)