Cloud Native 환경은, 말 그대로 Cloud 서버에 프로젝트를 배포하고, 이를 운용 및 관리하기 위한 목적으로 설계된 환경을 일컫는다.
기존 Monolithic 프로젝트의 전체 프로젝트의 변경점 발생 및 전면 재배포의 비효율적인 동작을, 각기 다른 도메인과 인스턴스 운영을 통해 개별적으로 관리하여, Cloud 환경에서의 배포와 관리를 좀 더 효율적인 구조, 동작으로 최적화하겠다는 의도가 그 설계 사상에 그대로 집약이 되어있다.
즉, Cloud Native 자체가 Monolithic이 아닌, MSA 환경 구성에 유리하도록 맞추어져 있고, 자연스럽게 Cloud 환경에 효율적인 배포와 유지가 가능하고, 유리하도록 MSA라는 관리전략을 도입할 수 있는 것이다.
사실 Cloud Native, MSA, Cloud의 관계는 어느 한 쪽의 체계가 다른 쪽의 체계를 수용하기 위해 만들어진 개념, 시간적 순서에 의한 일방향적 관계보다는 서로가 서로의 장점을 극대화해주는 최적화적인 존재, 즉 쌍방향 적인 관계로 보는 것이 더 좋을 것이다.
이러한 체계에서, Monolithic의 단일 CI/CD 환경보다, 각기 다른 분산된 환경의 지속적인 배포와 운용을 위한 CI/CD 개념 역시 "든든한 아군"으로써의 역할을 다한다.
마이크로서비스 환경을 좀 더 효율적으로, "MSA답게" 운용하고 배포하기 위한 CI/CD pipeline에 대해 알아보도록 하자.
Continuous Integration, Continuous Deployment.
말 그대로 지속적인 통합 및 배포가 이루어지는 체계이자, 그러한 동작이 이루어지는 시스템을 의미한다.
여기서 중요한 점은, 여러 개발자의 통합과 배포가 이루어지는 상황을 전제하는데, CI/CD가 Monolithic보다는 MSA 환경에서 각기 독립적인 서버를 배포하고 운용하는데 있어 중요한 의미를 지니는 이유이기도 하다.
CI
CI는 그 중, 개발자가 작성한 코드를 통합하고 검증하는 과정을 의미하며, 이를 자동화하는 체계까지 그 의미가 이어진다.
개발자가 코드 작성
Git에 push
CI 서버가 자동으로 실행
다음 작업 수행
빌드
테스트
이처럼, 개발자가 코드를 push하고 테스트하는 과정을 CI라 하며, Integration, 즉 통합이 가능한 상태를 항시 유지하기 위해 충돌을 사전에 검사하고 이에 대한 테스트를 할 수 있는 환경을 일컫는다.
그렇게 귀가 닳도록 들어왔던 GitLab CI/CD, Github Actions, Jenkins 등이 바로 이러한 CI 장치에 해당한다.
CI의 역할은 통합과 검증으로, CD(배포)를 위해 온전히 준비가 된 상태로 프로젝트를 다듬는 역할을 한다.
CD
그리고, 이러한 "빌드"가 되어 "배포 가능한 상태"로 되어있는 프로젝트를 자동으로 배포하는 과정이 바로 CD이다.
Build → Test → Staging → (승인) → Production 배포
좁은 의미로는 빌드한 프로젝트를 사람이 검토하여, 자동으로 배포 가능한 상태까지 만들어 놓는 과정을 지칭하기도 한다(Continuous Delivery).
Build → Test → 자동 Production 배포
하지만 대부분의 CD의 의미는, 사람이 배포 가능한 상태로 만드는 과정이 아닌, 애초에 테스트를 통과하였을 경우 자동으로 배포하는 운용체계, 지속적으로, 무중단 배포가 가능하다는 점에서 Continuous Deployment라는 개념으로 간주된다.
CI/CD의 일반적인 파이프라인은 아래와 같다.
Developer
↓
Git Push
↓
CI
- Build
- Unit Test
- Code Quality
↓
Artifact 생성 (Docker Image 등)
↓
CD
- Staging Deploy
- Production Deploy
이와 같이, 개발자가 push한 코드를 CI를 통해 배포가능한 상태로 적용하여 빌드하고, CD를 통해 이러한 배포 가능한 상태의 프로젝트를 운용 환경에 무중단 배포, 적용한다.
이때 CI/CD환경에 Docker를 적용할건데,
1 Git Push
2 CI 실행
3 Maven/Gradle Build
4 Test
5 Docker Image Build
6 Docker Registry Push
7 Kubernetes / Server 배포
이 경우 위와 같이, Gradle Build를 통해 배포 가능한 상태로 만들면, 해당 jar 파일을 docker 이미지로 만들고, Registry에 push하여 Cloud 환경에 배포하는 파이프라인을 고려해볼 수 있겠다.
이러한 Docker 이미지 기반의 CI/CD 파이프라인 구축 시, 단순히 흔히 알고 있는 Devops의 핵심 프로세스의 과정이라는 점을 넘어,
위와 같이, 수동 빌드, 배포로 이어지는 번거로운 작업, 이때 발생하는 human error의 요소를 상당 부분 제거할 수 있고 이에 따라 관리에 소모되는 비용을 감축할 수 있게 된다.
Git Push
→ Jenkins
→ Docker Build
→ Docker Hub Push
→ Server Deploy
좀 더 파이프라인의 체계를 확장한다면, Jenkins를 통해 빌드한 프로젝트를 docker 측에서 이미지로 만들고, 이를 hub에 push하여 서버에 배포하는 과정으로 말할 수 있을 것이다.
이에 대한 CI/CD Pipeline 구축을 위해 Docker 이미지를 Registry에 등록하는 과정부터 시작하여, 찬찬히 살펴보고자 한다.
Cloud Natvie를 지향하는 application architecture는 고객의 빠른 요구사항을 반영하기 위해, watetfall(1 phase)에 기반한 일괄적인 개발 진행 및 배포가 아닌, Agile을 통한 점진적, 단계적 개발과 요구사항 적용을 도입하기 시작하였다.
나아가, 이러한 Agile 기반의 프로젝트 관리를 위해 각 도메인 별로 분류한 마이크로 서비스에 대한 개별적 운영 및 배포 방법이 도입되었으며(MSA), 각 서비스 별로 빌드와 배포를 유연하게 하여 변경사항에 대해 즉각적으로 대응할 수 있도록 CI/CD의 개념까지 도래하게 되었다.
나아가, CI/CD의 개념이 도입되면서, 개발과 운영을 하나로 통합하여 유연하게 운용하는 Devops의 체계까지 신규 도입하게 되었다.
이처럼, Cloud Native - CI/CD는 고객의 요구사항에 발빠르고 유연하게 대응하기 위해 도입된 기술이며, 이러한 대응을 위해 적절한 환경을 갖추기 위해 Devops의 개념까지 유래하게 된 것이다.
기존의 Monolithic 개발 시 개발과 운영은 서로 다른 체계이며 별도의 책임이었으나, MSA, CI/CD 등 점차 고객의 요구사항에 발빠르게 대처하기 위해 개발 및 운영의 경계가 허물어져, 2010년부터는 Devops라는 체계를 도입하여 개발과 운영의 경계없이 하나의 통합적 운영을 지향하는 시스템이 발생하게 되었다.
Devops의 의미는 결국 개발자와 운영부서 간의 소통이 아닌, 고객과의 소통과 끊임없는 interaction, 서비스 개선의 일련의 과정인 것이다.

그리고, 이제는 이러한 개발과 운영의 경계없이 유연한 배포와 적용을 위해, Jenkins 등 하나의 Pipeline 구축에 도움을 주는 여러 도구들을 통해 유연한 시스템을 갖출 수 있게 되었다(개별 요소들을 pipeline화하여 이에 대한 workflow를 제어).
이러한 CI/CD, Devops에 대한 체득을 위해 단계적으로 관련 내용들을 분석해보도록 하자.
첫번째 단계는 Docker를 통한 build 과정에 대해 알아보자.
기본적으로 환경에 구애받지 않은 빌드와 배포, Runtime 운영을 가능하게 한다는 점에서 Docker는 빠질 수 없는 주요 항목이다.
이를 위해선 먼저, root project를 언어에 맞게 빌드하여 실행가능한 jar파일(java의 경우)을 구성해야 한다.
./gradlew build
위와 같이 실행가능한 jar파일을 빌드한 이후에, 이 파일을 도커빌드 시 COPY할 수 있도록 해야 한다.
java -jar build/libs/cloud-native-msa-init-1.jar
참고로, 반드시 해당 파일을 실행하여 단독으로, 정상적으로 실행이 가능한 파일인지 확인하도록 한다.
COPY build/libs/cloud-native-msa-init-1.jar app.jar
이에 유의하면서 Dockerfile을 구성하는 것 부터 시작하여 본격적인 빌드 작업을 진행하면 되겠다.
참고로, Dockerfile은 확장자 없는, 도커 빌드를 위한 파일이다.
project-root/
├─ Dockerfile
├─ build.gradle
├─ settings.gradle
└─ src/
위와 같은 공통적인 구성 사항을 완료한 후, 각 MSA 서버에 적용해보도록 하자.
FROM eclipse-temurin:21-jre-alpine
기본적으로 해당 프로젝트를 실행할 OS 및 언어의 기반을 설정한다. 말 그대로, 해당 이미지를 실행하기 위한 java runtime 환경 이미지를 설정해주어야 한다.
| 타입 | 포함 |
| --- | -------- |
| JDK | 컴파일 + 실행 |
| JRE | 실행만 |
먼저, 이미 빌드 및 실행점검까지 완료한 jar 파일에 대해 컴파일보다는 실행만 하면 되기에, jre를 사용하는 것이 좋을 것이다.
리눅스배포판인 alpine은 이미지 크기가 작지만 native dependency와 같은 라이브러리 오류가 간혹 발생한다는 것이 단점이다. 일단, 이미지 용량이 작은 alpine 으로 환경을 구성하며, 이것이 안될 경우 21버전의-jre 파일로 변경해주면 되겠다.
그리고, 빌드한 외부의 jar파일을 컨테이너 실행 시, 컨테이너 내부로 실행가능한 파일을 복사하도록 지정해준다.
build/libs/cloud-native-msa-init-1.jar cloud-native-msa-init.jar
이 경우, 도커 실행을 통한 컨테이너 생성 시
container
└ /cloud-native-msa-init.jar
위와 같이 구성하도록 한다. 빌드 후 버전이 변경될 경우 수동으로 변경점을 적용할 필요없이 build/libs/*.jar 파일의 와일드카드 형태로 지정해주기도 하는데, 일단 명확한 파일을 지정해주기위해 위와 같이 빌드 파일을 특정해주도록 한다.
컨테이너 내부에서, 명령을 위한 모든 기준점을 지정해주기 위해 Work Directory로 설정하도록 한다.
예를 들어,
WORKDIR /app
위와 같이, 컨테이너 내부에 app이라는 working 전용 디렉토리를 구성해주도록 한다.
이렇게 하면,
/
├ bin
├ usr
├ tmp
└ app
└ cloud-native-msa-init.jar
컨테이너 내부의 디렉토리는 위와 같이 구성되어, 모든 명령을 실행할때
java -jar cloud-native-msa-init.jar
즉, 이러한 명령을 실행하기 위해
cd /app
위와 같이 먼저 해당 작업 디렉토리로 이동한다.
작업디렉토리로 모든 실행 파일을 구성하도록 하고, 이에 따라 절대경로가 아닌 상대경로를 작성하여 도커파일의 가독성을 늘리기 위함이다.
이후, 해당 도커 실행 파일의 Volume을 저장하기 위한 디렉토리를 설정한다.
VOLUME /tmp
관례적인 부분인데, Spring boot가 해당 도커 이미지 실행 시 컨테이너 내부에서 임시 디렉토리인 /tmp을 활용하여 실행 환경을 구성한다.
/tmp
/tmp/tomcat
/tmp/undertow
/tmp/hsperfdata
Embedded Tomcat이 이러한 환경을 만드는데(jar), 도커 이미지의 볼륨파일을 이 /tmp 디렉토리에 같이 넣어 보관하겠다는 의미이며, 앞서 기술한 바와 같이 관례적인 부분이 크다.
참고로, 도커 실행시 내부적으로 도커 볼륨 파일을
/var/lib/docker/volumes/xxxx/_data
위와 같이 생성하며, 이에 대한 볼륨파일과 연결하기 위해 컨테이너 측에서,
container:/tmp
이렇게 볼륨을 바라보는 경로를 연결하게 된다.
마지막으로 도커 실행 시, 내부적으로 실행 경로를 지정해주기 위해
ENTRYPOINT ["java","-jar","cloud-native-msa-init.jar"]
위와 같이 entrypoint를 지정해주도록 한다. 참고로, work directory를 이미 구성한 상황이기에 해당 실행 대상 파일명만 정확히 기입해주도록 하자.
포트번호는 docker run 시점에서 결정하기에 따로 작성하지 않아도 된다.
최종적으로 완성된 Dockerfile은 아래와 같다.
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY build/libs/cloud-native-msa-init-1.jar cloud-native-msa-init-1.jar
VOLUME /tmp
ENTRYPOINT ["java","-jar","cloud-native-msa-init.jar"]
이제, 개인 Docker hub Registry에 도커이미지를 빌드하고 push해보도록 하겠다.

최초, 도커이미지를 빌드하기 위해 개인 dockerhubId를 추출한다(leehyokyun).
그리고, 도커 빌드를 수행한다.
docker build -t leehyokyun/cloud-native-msa-init:1.0 .
참고로 현재 루트 디렉토리에서 도커이미지를 탐색하도록 하기 위해, cli 말미에 반드시 온점(".")을 추가해야 한다.

빌드에 성공하면 위와 같이, 방금 구성한 도커 이미지파일이 로컬에서 확인이 가능하다.
마지막으로, 이 이미지 파일을 docker hub의 개인 registry에 push하자.
docker push leehyokyun/cloud-native-msa-init:1.0
이후 개인 레지스트리에 도커 이미지가 정상적으로 push 되었는지 확인하도록 한다.

그리고, 도커이미지가 잘 실행되는지 로컬에서 실행해보자.
참고로,
docker run -p 8761:8761 --name leehyokyun-cloud-native-msa-init leehyokyun/cloud-native-msa-init:1.0
위와 같이 기존 서버 실행 포트와 일치시켜주는 것이 관리 측면에서 용이하다.

도커 실행 후, 별도의 과정없이 바로 로컬에서 실행을 테스트해보자. 이 과정까지 되었을때 도커이미지 파일의 빌드 및 배포 과정을 end to end로 완료한 것이다.
이 과정을 그대로, 다른 MSA 서버에도 적용해보도록 하자.
도커 빌드 및 registry에 push하는 과정은 동일하다.
다만, 기존 로컬 파일 시스템 기반의 native에서, 컨테이너 환경에서 config 파일들을 읽을 수 있도록 github 링크 기반의 설정으로 변경해주도록 한다.
spring:
cloud:
config:
server:
# native: # 탐색 대상
# search-locations:
# - C:\study\cloud-native-msa-config
# - C:\study\cloud-native-msa-config\ecommerce
git:
uri: https://github.com/LEEHyokyun/cloud-native-MSA-config
search-paths:
- . # global
- ecommerce # ecommerce
위와 같이 기존 native 기반의 설정을 git 기반의 설정으로 변경해주도록 한다.
위와 같은 과정으로 build 및 registry에 등록한다.
굳이 Customized할 필요가 없기에, 공식 사이트에서 제공하는 이미지를 사용하여 환경을 구성할 것이다.

이후 이미지가 정상적으로 생성되었는지 확인한다.
최종적으로 이렇게 생성된 이미지를 바탕으로 로컬에서 docker-compose를 통해 bridge 네트워크를 기반으로, 내부적으로 동일한 네트워크 주소를 물려받아 서로 컨테이너 기반의 통신이 가능하도록 구성해주도록 한다.
가령, gateway의 application.yaml 설정 파일을 docker 환경으로 변경하고자 할 때,
spring:
application:
name: gateway
config:
import: optional:configserver:http://config-server:8888
cloud:
config:
uri: http://config-server:8888
# name: ecommerce
profiles:
active: dev
위와 같이, hostOS에서 접속하는 것이 아닌 컨테이너 내부에서 네트워크 주소를 물려받아 통신하는 별도의 환경이기에, 해당 네트워크 주소를 사용하도록(각 컨테이너 환경 별로 ip 주소가 다름) DNS를 통한 접속이 이루어지도록 해야 한다.
다만 Eureka 서버에 등록되는 이름은 spring application name이므로, 이 부분을 누락하지 않도록 유의한다.
eureka:
instance:
hostname: order-service # "실제 호출 시 " -> prefer ip
더불어, 위와 같이 유레카 측에서 "실제 호출"할때의 host를 localhost가 아닌 해당 서비스 이름(도커 환경에서)으로 호출하도록 하며,
services:
eureka:
image: leehyokyun/cloud-native-msa-init:1.0
container_name: eureka
ports:
- "8761:8761"
networks:
- eureka-only-user-bydocker-network
config-server:
image: leehyokyun/cloud-native-msa-config-server:1.0
container_name: config-server
depends_on:
- eureka
ports:
- "8888:8888"
environment:
- SPRING_PROFILES_ACTIVE=dev
networks:
- eureka-only-user-bydocker-network
gateway:
image: leehyokyun/cloud-native-msa-gateway:1.0
container_name: gateway
restart: unless-stopped
depends_on:
- config-server
- eureka
ports:
- "8000:8000"
environment:
- SPRING_PROFILES_ACTIVE=dev
networks:
- eureka-only-user-bydocker-network
user-service:
restart: unless-stopped
image: leehyokyun/cloud-native-msa-user:1.0
container_name: user-service
depends_on:
- mysql-user
- config-server
- eureka
environment:
- SPRING_PROFILES_ACTIVE=dev
networks:
- eureka-only-user-bydocker-network
order-service-1:
restart: unless-stopped
image: leehyokyun/cloud-native-msa-order:1.0
container_name: order-service-1
depends_on:
- mysql-order
- config-server
- eureka
environment:
- SPRING_PROFILES_ACTIVE=dev
- SERVER_PORT=60010
networks:
- eureka-only-user-bydocker-network
order-service-2:
restart: unless-stopped
image: leehyokyun/cloud-native-msa-order2:1.0
container_name: order-service-2
depends_on:
- mysql-order
- config-server
- eureka
environment:
- SPRING_PROFILES_ACTIVE=dev
- SERVER_PORT=60011
networks:
- eureka-only-user-bydocker-network
product-service:
restart: unless-stopped
image: leehyokyun/cloud-native-msa-product:1.0
container_name: product-service
depends_on:
- mysql-product
- config-server
- eureka
environment:
- SPRING_PROFILES_ACTIVE=dev
networks:
- eureka-only-user-bydocker-network
mysql-user:
image: mysql:8.0.42
container_name: mysql-user
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: users
ports:
- "3306:3306"
networks:
- eureka-only-user-bydocker-network
mysql-order:
image: mysql:8.0.42
container_name: mysql-order
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: orders
ports:
- "3307:3306"
networks:
- eureka-only-user-bydocker-network
mysql-product:
image: mysql:8.0.42
container_name: mysql-product
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: products
ports:
- "3308:3306"
networks:
- eureka-only-user-bydocker-network
rabbitmq:
image: rabbitmq:3-management
container_name: rabbitmq
ports:
- "5672:5672"
- "15672:15672"
networks:
- eureka-only-user-bydocker-network
networks:
eureka-only-user-bydocker-network:
driver: bridge
최종적으로 이러한 프로젝트 환경을 실행하기 위해, 각 컨테이너들을 연결할 네트워크와 실행 순서 등에 대해 (depends on) 명확히 기입해주는 것을 권장한다.
depends on은 컨테이너의 "실행" 시점을 보장할 뿐, 완벽한 실행 이후의 의존 환경을 실행하는 것을 보장해주지는 않기에,
spring:
application:
name: gateway
config:
import: configserver:http://config-server:8888
# import: optional:configserver:http://config-server:8888
이와 같이, 내부적인 MSA 서버 설정을 통해 import를 optinal 옵션을 제거하나, 서버 기동 실패 시 재시작 규칙을 지정해주는 등의 별도 옵션 들을 추가적으로 마련해주어야 한다.
이후, docker 환경으로 실행한 application을 실행해보자.

이를 실행하면 cli에서 서버실행로그가 매우 바쁘게 돌아가는 것을 확인할 수 있다.

그리고 Eureka 서버에 각 MSA가 정상적으로 구성이 되었음을 확인할 수 있다.

라우팅도 잘 이루어지는지 확인하자.
이러한 과정을 통해, 도커 이미지를 실행함으로써, Docker 환경에서 OS 환경에 구애받지 않고 프로젝트를 실행할 수 있는 것이다.
로컬 개발 환경과 도커, 혹은 이에 준하여 작동 환경이 달라질 경우 유의해야 하는 부분은 크게 두가지이다.
거두절미하고 바로 살펴보도록 하겠다.
Application 실행 환경 규칙
위에서 잠깐 기술하였는데, 개발 환경에서는 특정 서버를 실행하고, 그 이후에 환경적으로 의존적인 서버를 실행할 수 있다.
즉, 개발자가 선별적으로, 순차적으로 서버를 실행할 수 있는데 Docker Compose 등의 환경은 그렇지 못하다.
이런 환경을 고려하여, 서버의 실행 순서를 최대한 구체적으로 정립하고 기재해주는 것이 좋고, 그렇지 못할 경우 재시도 규칙을 지정해주는 것이 좋다.
직접 해보면 알겠지만, 개발환경에서 다른 환경으로 넘어갈 경우 application 실행부터 상당한 난관에 봉착할 것이다.
Spring cloud 서버의 경우, 반드시 init > Config > gateway > MSA 순으로 서버가 실행되어야 하는데 개발 환경에서는 이게 가능하였으나 도커 환경, 특히 compose 환경에서는 이게 불가능하다.
물론 굳이 하나의 프로젝트 파일, 혹은 Compose 환경으로 관리해야 하느냐에 대한 의문도 있을 수 있겠으나, 이러한 변수까지 생각하여 환경을 구성하는 방법을 알아야 하는 것이 당연하고, 무엇보다 실행 환경은 1개면 충분하다는 전제로 진행해야 한다.
spring:
application:
name: gateway
config:
import: configserver:http://config-server:8888
# import: optional:configserver:http://config-server:8888
단일 프로젝트 환경에서 원활한 실행을 위해, 위와 같이 config에 대한 import 설정에서 optional을 제거하여 실행 환경의 순서를 최대한 보장해주도록 한다.
그리고, compose와 같은 실행 환경에서는 최대한 순차보장을 위해 depends on과 같은 설정을 지정해주도록 한다. 다른 운용 환경에서 역시 이에 준하는 순차성 보장 장치를 두는 것이 좋을 것이다.
config-server:
image: leehyokyun/cloud-native-msa-config-server:1.0
container_name: config-server
depends_on:
- eureka
gateway:
image: leehyokyun/cloud-native-msa-gateway:1.0
container_name: gateway
restart: unless-stopped
depends_on:
- config-server
- eureka
특히, Docker 환경의 경우 최초 서버 실행 시 이러한 순차보장 실행이 안되거나, RabbitMq와 같은 커넥션 설정이 있는데 이에 대한 연결이 거부되었을 경우 재시도를 하지 않고 서버 실행을 즉시 종료해버린다.
depends on이 순차 실행은 보장하더라도 순차 "완성" 후 실행은 보장하지 못하기 때문인데, 서버가 예상치 못한 오류로 종료되었을때 서버 재시작을 시도할 수 있도록 restart 정책을 기재하는 것 또한 하나의 전략일 수 있겠다.
통신
그리고, 가장 중요한 부분이다.

본 프로젝트와 같이 Eureka 서버에 MSA 환경을 구축한다고 가정하면, Eureka 서버에 등록되는, 즉, 서비스 레지스트리에 등록되는 application 목록은 MSA 내부적으로 등록한 application name으로 등록이 된다.

문제는 내부적으로 이루어지는, hostOS 상에서 각 컨테이너 혹은, 컨테이너와 컨테이너끼리 통신하는 환경에 대한 구성이다.
기본 : 라우팅 흐름
일단 기본적으로 알아야 하는 사항은 gateway 측에서 최초 라우팅을 할때, 어떤 서비스로 보내야 할 것인지는 서비스 레지스트리에 등록된 application name/id 값을 찾은 다음에, 호출을 진행한다는 점이다.
가령,
gateway:
server:
webmvc:
routes:
- id: user-service
uri: lb://USER-SERVICE #endpoint -> loadbalancing
predicates:
- Path=/user/** #prefix
filters:
- RewritePath=/user/(?<segment>.*), /$\{segment} #rewrite
현재의 gateway 라우팅 구조가 위와 같이 되어있는데, gateway로 전달된 요청은 uri 패턴에 따라, 해당 패턴에 부합하는 서비스 ID를 서비스 레지스트리에서 찾는다.
즉, 여기서는 USER-SERVICE로 등록된 서비스를 찾게 되는 것이고, 서비스 레지스트리에 "USER-SERVICE"로 등록된 ID를 찾아, 해당 ID에 등록된 호출 정보를 그대로 내부 MSA와 통신을 시도하게 되는 것이다.
이 정보는 spring application name으로 이루어지기에, 이 부분을 조심하면 된다.
문제는 그 이후인데, 개별적인 컨테이너 실행 환경에서 네트워크로 묶여진 상황이 아닌 hostOS 상에서 실행하는 환경일 경우, hostname이 localhost이어도 정상적인 통신이 가능하다.

하지만, Docker Compose 환경에서 hostOS 네트워크가 아닌 별도의 네트워크를 부여된 상황에서는 localhost로의 접근은 불가능하다.
localhost는 더이상 공유되는 "손님"이 아니기에, MSA 입장에서는 받아들일 수 없는 손님인 것이다(보내는 측 그 자체 = localhost).
따라서, 이 경우에는 서로가 서로를 알아볼 수 있는 hostname을 반드시 사용해야 한다.
그 설정이 바로,
eureka:
instance:
hostname: user-service # "실제 호출 시 " -> prefer ip
위와 같이, eureka instance의 hostname 정보이다. 이 경우, 호출 시 localhost가 아닌 user-service의 hostname으로 되어,
gateway 측에서 localhost:60000/ 이 아닌, 즉 자기자신을 바라보는 호출이 아닌, user-service:60000/과 같이 해당 네트워크에서 인식가능한 형태로 도메인 간 통신이 가능하게 되는 것이다.
더불어,
prefer-ip-address: true
위와 같이 부여받은 ip 주소 자체를 호출하도록 설정해줄 수도 있다.
중요한 것은 통신 환경이 달라진다면 그에 맞게 통신 host 등이 적절하게 변경되어야 한다는 점이다.
기본적으로 Compose와 같이, 특정 네트워크 상태에서 통신을 시도한다면 DNS에 따른 통신 환경이 구성되어야 한다는 것을 잊지 말자.
이제 본격적으로 Jenkins를 활용한 CI/CD 환경을 구축해보도록 하자.
사실 Jenkins는 CI(Code/Build/Test/Delivery)를 중점적으로 사용하는 도구이지만, 최대한 CD Pipeline까지 구성하여 활용하도록 하겠다.
docker 컨테이너 환경에서 실행하는 Jenkins 실행 명령어는 다음의 링크를 참조하도록 하자.
https://hub.docker.com/r/jenkins/jenkins
https://github.com/jenkinsci/docker/blob/master/README.md
중요한 것은 컨테이너 내부에 Jenkins을 실행할 Jdk 버전을 명시해야 한다는 점이다. 내부 컨테이너 환경에 jdk를 등록해야, 기본적으로 Jenkins라는 "프로그램"을 JVM 환경에서 실행할 수 있다.
참고로, jenkins 이미지를 실행하면
java -jar jenkins.war
의 cli가 실행되면서 컨테이너 환경의 jenkins가 만들어지고, Runtime으로 전환되는 것이다.
더불어 기존의 환경에서는 JAVA_HOME 환경변수를 등록하여,
sh './gradlew build'
의 shell script 실행 시, 현재 실행 환경의 java (PATH / JAVA_HOME)를 활용하여 Jar 파일을 실행하는 과정으로 application jar파일을 빌드하였으나, 최근의 Jenkins는 별도의 Agent가 특정 포트를 통해 Master Jenkins에 붙여져 job을 수행하는 형태로 진행된다.
이때, gradle build하기 전에 내부적으로 jdk 경로를 확인하기 위한 shell script를 먼저 실행한다.
실제로 pipeline code를 살펴보면,
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'java -version'
sh './gradlew build'
}
}
}
}
이와 같이, Jenkins 환경에서 사용하는 jdk의 설정 경로를 따라가서 해당 jdk를 기반으로 gradle build하는데 그대로 활용한다.
이러한 전반적인 내부 흐름과 동작을 바탕으로, Jenkins는 프로젝트 빌드에 요구되는 최소한의 작업 단위인 project(Item)를 필요로 한다.
해당 프로젝트 역시, 내부적으로 workspace 경로에 생성되며,

이는 Jenkins 빌드 테스트 시 로그에 출력되는 경로를 통해 알 수 있다.

실제로 내부 jenkins 환경에 접속하여, workspace에 생성된 프로젝트 경로를 확인하여 이후 CI/CD 시 확인할 수 있는 여러 로그 및 파일들을 확인할 수 있도록 준비해두자.
Jenkins에서 프로젝트를 build, deploy하는 과정은 "작업"이라 정의하며, 이러한 작업을 진행하는 방법은 통용적으로는 두가지가 존재한다.
온프레미스 환경 혹은 레거시 환경에서 빌드 및 배포 준비가 되었을때, jenkins UI를 통해 직접 버튼을 클릭하여 쉘 명령어(build/deploy)를 실행하는 방식의 FreeStyle 방식이 존재한다.
더불어, gradlew(Wrapper)를 통한 빌드 혹은 내부적으로 pipe line shell script를 작성하고, 이를 jenkinsfile에 정의한다. 이 jenkinsfile이 중요한데,
[GitHub Repository]
├── src/
├── build.gradle
└── Jenkinsfile ← 이게 핵심
깃허브 레포지토리를 살펴보면, application의 코드와 빌드 과정을 jenkinsfile를 통해 통합적으로 관리가 가능하다.
구체적으로는,
1. GitHub에서 코드 clone
2. Jenkinsfile 찾음
3. Jenkinsfile 읽고 그대로 실행
위와 같이, 코드와 빌드 설정은 jenkins 파일을 통해 관리하는데, 이를 Pipeline as a code라 하며, 말 그대로 Jenkins 서버 내부에서 파이프라인을 관리하는 것이 아니라 git에 위치하여, 버전관리를 손쉽고 유연하게 할 수 있다(Devops).
나아가 이러한 job을 실행하기 위한 트리거 설정은 git hook(코드 push를 통한 jenkins 자동 실행) 혹은 jenkins 측에서 주기적으로 polling하여 변화를 감지하고 github에 build를 진행하는 방식으로 구성할 수 있다.
빌드 과정은 아래와 같이 진행하고,
Checkout → Build → Test → Deploy
이때 각 stage 별로 빌드 과정을 세분화하여 오류 발생 시 그 위치를 정확히 파악하고, 특히 checkout sum을 통해 jenkins가 단순히 코드만 사용하여 설정 정보를 연동하는 것이 아닌, git의 소스코드와 jenkins 설정을 연결하여 사용할 수 있도록 checkout stage를 Checkout 스크립트에 포함시키도록 한다.
더불어,
-Dorg.gradle.daemon=false
gradle을 실행하기 위한 데몬 프로세스를 계속 백그라운드에서 동작시킬 것인지에 대한 설정을 false로 하여, 이전의 빌드 및 클래스, 환경 등을 그대로 사용하는 JVM 프로세스 설정을 사용하지 않는 것이다.
환경변수나 시스템 설정 등을 빌드 할때마다 새롭게 구성하여 사용할 수 있도록 하여, 빌드 간 설정 꼬임 및 이전의 빌드 설정을 재활용하여 사용하지 않도록 한다.
최종적으로, Jenkins에서 참고할 jenkinsFile을 생성하여 프로젝트에 등록하고, 이를 github에 반영한다.
pipeline {
agent any
environment {
GRADLE_OPTS = "-Dorg.gradle.daemon=false"
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build') {
steps {
sh './gradlew clean build -x test'
}
}
stage('Test') {
steps {
sh './gradlew test'
}
}
}
post {
always {
archiveArtifacts artifacts: '**/build/libs/*.jar', fingerprint: true
}
success {
echo '[Success] Build Success'
}
failure {
echo '[Failed] Build Failed'
}
}
}
이 Jenkinsfile에서 중요한 것은 build stage와 test stage를 분리하여, 각 빌드에 대한 빠른 진행 및 테스트 실패에 대한 확인을 명확하게 진행할 수 있도록 유도하는 것이다.
stage('Build') {
steps {
sh './gradlew clean build -x test'
}
}
위와 같이, 먼저 build stage에 대하여 shell script를 실행하도록 하되, -x (exclude) 옵션을 통해 test 진행은 배제한다.
stage('Test') {
steps {
sh './gradlew test'
}
}
빌드와 test 진행을 분리하여, test 실패에 대한 내역은 test stage에서 별도로 내역을 저장하고 관리할 수 있도록 하는 것이 목적이다.
이후, github의 git hook을 설정하여, 변경사항이 발생할때마다 자동으로 integration 과정을 거칠 수 있도록 trigger 설정을 해주도록 한다.
git hook을 감지할 위치와 이에 대한 페이로드 경로를 입력하며(개발환경의 경우 ngrok 등을 통해 임시적으로 조치), 당연히 jenkins에서 git hook을 바라보아야 하므로, git hub - jenkins의 경로가 정확히 이어져야 한다.
https://ablutionary-krista-unprized.ngrok-free.dev/github-webhook/
해당 경로를 통해 webhook을 전달하여 빌드를 진행하도록 설정, 빌드한 jar 파일은 항상 보관하도록 설정하여, 빌드 산출물을 관리하도록 한다(기본적으로 빌드 산출물을 저장하여, 해당 빌드 결과를 확인하고 어느 지점에 문제가 있는지 파악할 수 있는 장치).
추가적으로 github의 코드를 fetch 및 checkout해오는 과정은 private 레포지토리가 아니라면 별도 credential이 필요없지만, 내부적으로 gradlew를 통해 build하는 과정은 반드시 linux의 쓰기 권한을 부여해주어야만 가능하다.
> git checkout -f 6d565e8004be576df974284445be106bafa43c5c # timeout=10
Commit message: "webhook test #1"
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Build)
[Pipeline] sh
+ ./gradlew clean build -x test
/var/jenkins_home/workspace/cloud-native-cicd-jenkins-tobuild@tmp/durable-090cfb60/script.sh.copy: 1: ./gradlew: Permission denied
Jenkins workspace 측에서 권한을 부여하지 않은 상태로 build 시도 시 위와 같이 permission denied 오류가 발생하게 되어 이후의 stage를 진행할 수 없게 되므로 유의하자.
(*권한부여는 Jenkins 측에 임시로 설정하는 것이 아닌, github 측에 영구적으로 설정 정보를 저장하도록 하자)
이제 github의 레포지토리 변경점을 Jenkins pipeline을 통해 빌드해보도록 하자.
1. 코드 수정
2. git push
3. GitHub webhook 발생
4. Jenkins 자동 빌드
5. gradlew build 실행
6. build/libs/*.jar 생성
7. Jenkins UI에서 확인 or 다운로드
위 과정을 pipeline을 통해 구축하였기에, Integration 작업을 이제는 수동이 아닌 자동으로 진행할 수 있게 된다.
먼저, git hook을 통한 변경점 감지와 이를 통한 build가 이루어지기 위해서는 jenkins의 워크 디렉토리를 생성해야 하며, 최초 init 빌드가 필요하다.

init 빌드에 성공하였을 경우 위와 같은 로그가 나타나고, 특히 post의 경우 모든 build 과정을 완료한 이후에 후처리 로그로 나타나도록 jenkinsfile을 구성하였으므로 위와 같이 나타나게 된다.

그리고, 빌드한 jar 파일이 위와 같이 생기게 된다.
이 workspace가 생긴 이후에는 webhook을 통한 자동 build를 진행할 수 있다.
변경사항이 있는 git 변경내역을 push한다면,

위와 같이 jenkins 측에서 자동으로 webhook을 보내고, 해당 이벤트 페이로드를 받아 jenkinsfile에 기재되어있는 명세대로 staging 별 동작을 실시한다.

실제로 git hook에 의한 실행인지, git log를 통해 변경 내역이 온전하게 반영이 되었는지 확인하도록 하자.
일단 webhook에 의한 build까지는 진행하여, Continuous Integration을 할 준비는 되었다.
Jenkins를 통한 CI/CD는 git hook을 통해 변경점을 적용하여 build하고, 이를 최종적으로 배포 서버에 deploy하는 과정까지 진행해야 그 빛을 발할 수 있다.
먼저, 외부 톰캣 서버를 설치하였다고 가정하고, build뿐만이 아닌 deploy까지 진행하는 상황을 가정해보자.
일단 Continuous Integration을 위해 구성한 파이프라인에 대한 Jenkins workspace를 생성한다.

그리고, 외부 Tomcat Container에 배포하고자 할 때, tomcat 내부적으로 webapps 레포지토리에 배포하도록 pipeline 경로를 구성해주는 것이 필요하다.
WAR 파일 → /usr/local/tomcat/webapps/ 에 deploy
그리고, tomcat 인스턴스 환경에서 구동할 경우 jar파일이 아닌 tomcat 내장이 되어있지 않은 war 파일로 되어있기에, 이러한 빌드 후처리를 고려하면서 jenkinsfile script를 구성하도록 한다.
stage('Deploy to Tomcat') {
steps {
sh '''
# WAR 파일 찾기
WAR_FILE=$(ls build/libs/*.war | head -n 1)
echo "Deploying $WAR_FILE"
# Tomcat 컨테이너로 복사
docker cp $WAR_FILE $TOMCAT_CONTAINER:/usr/local/tomcat/webapps/ROOT.war
'''
}
}
위와 같이 tomcat에 war를 배포한다는 것은 내부적으로 "war파일을 복사하세요"와 동일한 의미이기에, 이에 적절한 추가적인 동작을 진행하도록 한다. 참고로, hostOS를 거쳐도 container ID 기반의 통신이 이루어질 수 있도록 container id를 지정해주었다.
이때 내부적으로 docker cli를 실행하고, 이에 대한 Daemon Docker와의 통신(cli)이 가능하도록 docker.sock 인터페이스 파일을 추가해주도록 한다. 이에 대한 내용은 후술한다.
더불어, war 파일을 기반으로 한 복사/배포가 진행되어야 하기에
id 'war'
}
위와 같이 플러그인을 war 파일 기준으로 기재해주어야 정상적인 빌드가 가능해진다.

jenkins 빌드 및 배포까지 진행되는지 확인해본다.
(container 환경에서 빌드 및 배포가 안된다면, root 계정 및 build 경로에서의 war파일 존재 여부 등을 확인하도록 한다.)


이후 build와 deploy가 정상적으로 되었는지 실제 bash 환경에서 확인도 해보고, application 실행을 통해 확인해보도록 한다.

tomcat의 UI를 통해 직접 실행 여부를 확인할 수 있고, 참고로 tomcat UI의 경우 관리자 권한을 xml파일에 추가하고, 이를 위해 패키지 다운로드 등 꽤 과정이 복잡하기에 위와 같이 커맨드 라인 상으로 서버 실행 여부를 확인하는 것을 추천한다.
Jenkins 내부에서 사용자 명령어를 입력하는 커맨드 라인 프로그램, 즉 docker cli는 내부적으로 docker 명령어를 단순히 입력하고 실행하는 것이 아닌, dockered라는 host docker daemon 프로세스를 통해 통신이 이루어진다.
즉, 내부 환경이 아닌, 반드시 해당 컨테이너를 관리하는 host 프로그램의 docker daemon 환경에서 UNIX 도메인 소켓(Unix Domain Socket, UDS)을 기반으로 통신이 이루어진다.
| 구분 | TCP/UDP 소켓 | UNIX 도메인 소켓 |
|---|---|---|
| 위치 | 네트워크(IP) | 파일 시스템 (/var/run/docker.sock) |
| 통신 대상 | 다른 호스트 가능 | 같은 호스트 프로세스끼리만 |
| 사용 방법 | socket(AF_INET, ...) | socket(AF_UNIX, ...) |
| 속도 | 네트워크 속도 | 커널 내부 IPC → 훨씬 빠름 |
여기서 이루어지는 데이터 전달은 외부 네트워크가 아닌, 내부 컨테이너 환경에서 IPC 기반의 전송이기에 Unix Domain Socket이라는 특별한 전송 인터페이스를 사용한다.
쉽게 말하면 외부 네트워크에서의 전송 계층은 TCP/IP이지만, 내부 컨테이너 IPC에서는 docker.sock 이라는 파일 전달하고자 하는 데이터를 연결하는 "통로"로 사용하며, 최종적으로 http 기반의 docker.sock(Socket 인터페이스), 이를 메모리 버퍼에 올려두고 서로 통신하면서 데이터의 전달이 이루어지게 된다.
특히, Socket이 외부 네트워크 통신 시 사용하던 인터페이스가 아닌, 내부 컨테이너에서 IPC간 통신에 사용하는 하나의 인터페이스이자 전송 계층으로 활용되어 데이터 송수신이 이루어진다는 것에 유의하자.
Devops라는 것은 단순히 개발과 배포, 운영의 경계를 완화하고 이를 통합한다는 개념이 아닌, 자원 접근 및 할당의 제어, 관리 체계 안에서 특정 노드의 문제가 발생하였을때 서비스를 중단하지 않거나, 오류의 지속시간을 최소화하는 등 관리의 효율화를 같이 의미하기도 한다.
이런 의미에서, 최근 관리 자동화(Orchestration)이 가능한 쿠버네티스가 부동의 1위 자리를 지키고 있지만, 그에 못지 않게 IaaC(Infrastructure as a Code) 형태로 비교적 간단하고 용이하게 Script(Code)를 구성하여 Devops를 가능하게 하는 중요 도구 중 하나이다.
Spring Cloud부터 강조하는 내용이지만, 단순히 많이 사용하는 도구를 사용하고 구현하는 것보다는, 그 도구를 사용하는 이유와 목적을 체득하기 위해 차선의 방안까지 선택의 폭을 넓히면서 분석을 해보는 것도 좋다.
지금까지 이러한 차선에 해당하는 도구를 살펴보면서 알 수 있었듯이, 대부분의 도구들은 결국 그 원리나 목적의 방향이 같고 비슷한 점이 꽤나 많다.
이러한 생각을 가지면서, Jenkins 뿐만 아니라 자원 및 계정 등의 프로비저닝까지 가능하게 해주는 Ansible에 대해 분석해보도록 하자.
Ansible은 시스템, 하드웨어, 오류 발생 시 노드 관리 및 무중단 서비스 동작을 위한 규칙 정립 등 인프라 체계에 대한 정보를, 특정 스크립트(코드)로 관리하도록 도움을 주는 도구이다.
다만 Ansible 구축 시 CD 서버를 별도로 구축해야 한다는 번거로운 부분이 존재하지만, 일전 Spring Cloud의 Config Server처럼 설정정보 및 명령을 내리는 컨트롤 타워로써 관리 도메인을 명확하게 구성할 수 있다는 장점으로 작용할 수 있다.
참고로 이외, Terraform과 같은 인프라를 직접 구축할 수 있는 도구도 존재하며, Ansible은 이미 구축된 인프라에 대해 다양한 규칙을 제정하여 자원 할당 및 서비스 동작이 가능하게 하는 또 하나의 인프라 체계인 것이다.

위와 같이 설정정보를 변경하였을때 이를 Ansible에 반영하고 push하면, Spring Cloud처럼 설정 정보의 변경이 전 자원에 반영될 수 있도록 push한다.
이러한 push 기반의 설정정보 변경을 통해 단일 중앙 매니징 서버에서 빠르고 효율적으로 대응이 가능하며, 특히 서버에서 문제가 발생할 경우 수동으로 서버를 다운하고 가용한 서버를 대체 운용하는 것이 아닌 미리 설계된 Script(Code)에 의해 무중단 오류 대응 및 서버 대체 등을 가능하게 한다.
Ansible은 또한 Agent의 별도 운용이 필요없다. 그 자체로 직접 서버 자원들과 통신하면서 인프라 체계에 직접적인 영향을 준다.
git과 같은 version control, docker container와 연결하여 컨테이너 환경에서의 설정 정보 변경, build & test(Junit) 등을 모두 연결하고 지원하기에, 말마따나 자원을 통제하고 관리하는 프로비저닝 체계로 여러 형태의 인프라 설계 및 운용 시 매우 유용하게 사용할 수 있는 도구인 것이다.
하나의 온프레미스 네트워크 환경을 가정하여, 이를 도커 네트워크로 구현하고 내부에 각 개별적인 컨테이너가 실행되는 환경을 구성한다.

Jenkins 컨테이너는 CI, Ansibe 컨테이너는 CD의 역할을 하며, 이때 중요한 점은 Ansible 컨테이너는 어디에 배포할지 결정하고 명령을 내리는 컨트롤 타워의 역할을 한다는 점이다.
명령만 내리는 서버이기에, 서비스를 배포하거나 실행하는데 직접적인 연관을 가지지는 않는다.
배포 대상의 Java application 서버는 10022 포트를 통해 접근이 가능하며, Jenkins의 CI 대상이 되기도 한다.
이러한 Ansible 구조를 통해 CI, CD를 분리하여 구성하되 설정 정보의 변경을 단일 도메인을 통해 일괄 관리할 수 있고, 이에 대한 변경점을 전역적으로 전파할 수 있다는 점에서 그 의미를 찾을 수 있다.
이 System 상에서 개발자의 git push는 push의 연쇄전파를 통한 배포로 이어질 수 있다.
1. 개발자가 Git push
2. Jenkins가 코드 빌드
3. Jenkins가 Ansible 실행
4. Ansible이 Java 서버에 접속 (SSH)
5. Java 서버에 배포
위와 같은 과정으로, 특히 Ansible 자체가 SSH를 응용계층으로 사용하여 포트 22번 상에서 TCP 전송계층 기반으로 통신한다.
예를 들어,
ssh root@server
위와 같은 클라이언트 통신이 요청된다면, 이를 받는 수신자 측에서는
/usr/sbin/sshd
서버의 인터페이스와 같은 경로를 통해 데이터를 주고받을 수 있다. Http와 달리 세션이 유지가 되며, SSH 서버를 기반으로 Shell 실행의 결과를 주고받는 형태로 통신이 이루어진다.
따라서, Ansible 내부적으로 SSH 기반의 통신으로 명령을 전달하며,
1. SSH 접속
2. 명령 전달
3. 결과 수집
이러한 Shell 명령 전달의 결과를 수집하면서, 하나의 명령 지급 담당자와 같은 역할을 하는 것이다.

이를 위해 system diagram대로, jenkins-ansible-java apps가 서로 같은 docker network로 연결되어있는지 확인하자.
이후, 전체적인 통합 및 배포의 과정이 현재의 응용계층이 SSH 기반으로 동작이 되므로, 외부 host에서 내부 네트워크 접근 시 SSH기반의 접근이 가능한지 확인을 하도록 하자.
[Jenkins] ──SSH──> [Ansible] ──SSH──> [Java App]
이 과정에서 jenkins, ansible, java app 간의 SSH 통신이 가능한 것을 확인하였으니 이 네트워크를 기반으로 ansible playbook의 통합/배포 체계가 잘 이루어지는지 확인해보자.
참고로, 이 SSH 기반의 통신은 서로 비밀번호를 요구하지 않은 상태에서 진행되어야 하는데, 이를 위해선 SSH 공개키 기반 인증이 필요하다.
이를 위해, origin server에서 target server 방향으로 public SSH Key를 생성하고, 이 공개키를 target server에 저장해주어야 한다.
ssh-keygen -t rsa -b 4096
ssh-copy-id root@cloud-native-cicd-ansible-server

위와 같이 공개키를 등록하여,

SSH 통신이 이루어지도록 사전 환경 구성을 해주어야 한다. Ansible이 이러한 환경구축이 힘들고 번거로운데, 최초 구성 과정에서의 번거로움을 이겨내면 그 이후의 관리는 Cloud Native 환경의 config server처럼 꽤 효율적으로, 편하게 진행할 수 있을 것이다.

Ansible → SSH → Docker Host → docker exec
본 환경 구축의 경우, ansible을 target으로 할 경우 ssh, java apps에게 배포할 경우 docker API기반의 HTTP/TCP/IP으로 통신하도록 구축한다. ansible은 본래 VM 환경에서 SSH 기반의 TCP/IP 통신을 기본으로 하지만, java apps에 ssh 환경이 설치되어있지 않은 상황을 가정하고 다양한 상황에 대해 적용해보는 목적으로 진행해보도록 한다(본 환경의 경우 배포 대상이 로컬 환경 그 자체이므로, Ansible이 나의 로컬 컴퓨터에서 바로 도커 빌드 등을 수행하게 된다).
이 다이어그램에 기반한 동작에 근거하여, ansible과 docker를 활용한 CI/CD 환경을 구축해보도록 한다.
참고로, 로컬에서 변경점을 git push한 내역이 최종적으로 배포서버까지 자동으로 적용되는 과정에서, 핵심은 그 도중에 어떠한 human association이 존재하지 않도록 하는 것이기에, ansible 측에서 배포 대상 서버에 접속하여 pull, restart하는 과정을 파이프라인 구성에 포함시켰다.

참고로, jenkins 계정에서 docker registry에 이미지를 push하기 위해서는 UserName, UserPassword를 Credentials에 등록하여 사용하는 등의 과정이 필요하다.

더불어, jenkins에서 내부적으로 쉘 스크립트를 실행 시 사용하는 환경은 root 계정 기반이 아닌, jenkins 계정 기반이기에 명령어 실행을 위한 도커 그룹 생성 및 jenkins 계정 등록 등의 과정 등이 충분히 진행되었는지 확인하도록 한다.

그 후, 도커 빌드 및 이미지 push가 되는지 확인하여 기본적으로 playbook을 실행할 준비가 되었는지, 기본적인 CI 파이프라인이 구축되었는지 확인한다.
이제, ansible playbook을 통해 대상 서버에서 어떠한 명령을 실행할 것인지 구성하도록 한다.
CD 파이프라인의 경우, 위에서 기술하였듯이 컨테이너가 아닌 host OS 측에서 도커 이미지를 pull받고 해당 이미지를 실행하는 방식으로 구축하며, 이때 Ansible의 Playbook은 host 환경에서 접속하고, 해당 환경에서 도커이미지를 pull받아 실행하는 과정에 대해 스크립트화하는 방식으로 구성한다.
ansible-playbook -i /inventory.ini /deploy.yml
jenkinsfile에서는 ansible playbook을 실행하되, 어떠한 환경(서버)에서 어떠한 명령을 실행할 것인지 지정하도록 한다.
즉,
"inventory에 정의된 서버들에게 deploy.yml에 적힌 작업을 실행해라"
와 같은 의미로 playbook은 대상 서버에서 실행할 스크립트(deploy.yml)이자 하나의 패키지(라이브러리)이기도 하다.
inventory.ini의 경우, 서버 접속에 대한 설정을 기재하도록 한다.
[hyokyun]
host.docker.internal ansible_user=root ansible_port=22
hyokyun 그룹에 속한 서버(= 네 컴퓨터)에 접속해서 playbook 명령을 실행할 수 있도록 그룹화하며, 내부적으로 ansible-server가 도커 컨테이너에서 실행하는 형태이기에, 컨테이너 내부에서 host 컴퓨터에 접속하는 경로로 입력한다(root / SSH 기반의 22포트로 접속).
이는
ssh root@host.docker.internal -p 22
와 같은 command line 실행 효과를 지니게 된다.
이와 같은 과정을 통해 대상 서버에 접속을 하였다면, 해당 서버에서 어떠한 배포명령을 실행할 것인지 deploy.yml(playbook.yml) 스크립트를 저장한다.
- name: Deploy Java Apps by Ansible Playbook (8090)
hosts: hyokyun
tasks:
- name: Pull latest image
command: docker pull leehyokyun/cicd-jenkins-ansible:latest
- name: Stop existing container
ignore_errors: yes
command: docker stop cloud-native-cicd-java-apps
- name: Remove existing container
ignore_errors: yes
command: docker rm cloud-native-cicd-java-apps
- name: Run new container
command: >
docker run -d
--name cloud-native-cicd-java-apps
-p 8090:8090
leehyokyun/cicd-jenkins-ansible:latest
이후 해당 host 그룹에서, playbook.yml을 실행하기 위한 task들을 정의한다. 지금의 경우 docker pull을 받은 후에, 변경점이 반영된 새로운 도커 이미지를 그대로 컨테이너 띄워 서버를 실행하는 방식으로 작동되도록 파이프라인이 구성되어있다.

실제로 젠킨스에서 빌드를 시도하면, 위와 같이 배포 대상 서버에서 deploy 스크립트를 "PLAY"하는 것을 볼 수 있다. playbook을 통해 대신 "실행"(play)할 수 있도록 구성하였기에, playbook인 것이다.
ssh 인증은 접속하는 측을 기준으로, 접속을 하는 서버에 대해 공개키를 배포하고 해당 공개키를 기반으로 비밀번호 인증없이 통신이 이루어지는 상황이 만들어져야 한다.
steps {
sh """
ssh root@cloud-native-cicd-ansible-server \
"ansible-playbook -i /inventory.ini /playbook.yml -u ansible_test"
"""
}
만약 인증처리가 안된다면, 대상 서버에서 별도의 ssh 계정을 만들어, playbook을 실행할 계정을 -u 옵션을 통해 아예 명시해주는 것도 방법이다.
-u ansible_test
위와 같이, playbook을 실행할 주체를 별도로 지정해주는 것이다. 혹은 inventory.ini에서
host.docker.internal ansible_user=ansible_test ansible_password=*** ansible_port=22
로 ssh 인증 계정을 직접 기재해줄 수도 있다(*이 경우 yum install -y sshpass 를 통해 반드시 sshpass 플러그인 설치 반드시 필요).
혹은,
host.docker.internal ansible_user=ansible_test ansible_password=*** ansible_port=22 ansible_connection=paramiko
의 형태로 paramiko 방식의 인증방식을 사용하고,
pip install "ansible[ssh]"
를 통해 ansible ssh를 해당 인증방식 기반을 사용할 수 있도록 패키지 업데이트를 하면 이 역시 인증이 가능한 방식이기도 하다.
CI/CD의 핵심은 결국 Continuous이다.
개발자가 하는 일은 로컬 프로젝트의 변경점을 적용하고, push하는 것이 끝이다.
배포까지 이어지는 환경에 대한 인프라 혹은 파이프라인을 구축하여, 최초 설계비용만 신경쓰면 되고 그 이후의 배포 관리에는 최소한의 비용만 소모한다.
개발과 배포 관리에 대한 경계는 허물어지는 Devops가 바로 이러한 편의성, 효율성에 기반하여 탄생하였다.
항상 느끼는 것이지만 개발자는 기본적으로 게을러야 한다.
이번 CI/CD는 인프라, 네트워킹에 대해 매우 많은 것을 배우고 근간에 대해 이해할 수 있었던 매우 중요한 과정이었다.
좀 더 자신감을 가지고, 실무에 적용할 수 있는 매우 뜻깊은 시간이 될 수 있을 것으로 보인다.
프로비저닝은 특정 환경에서의 운용과 규칙에 맞게 자원 및 계정 등을 사전에 준비해두고, 사용자 요청 혹은 필요가 있을때 적절하게 할당하고 배포하여 원활한 서비스 이용 및 환경 구성을 할 수 있도록 만들어둔 하나의 인프라 체계이다.
말 그대로 미리 준비해둔 자원의 개념이기에, 시스템 하드웨어(서버의 CPU나 메모리)가 될 수 있고, 스토리지, 계정, 나아가 관리 그 자체(규칙)이 될 수 있다.
프로비저닝의 중요한 점은 그것 자체로 하나의 Infra 체계이며, 시스템 하드웨어 등 자원에 대한 구성 및 관리를 수동으로 진행하기도 하고, Script를 통해 자동화하여 관리하기도 한다는 점이다.
이 자동화 개념이 관리 효율화로 이어지고, Ansible이나 K8S와 같은 오케스트레이션까지 이어져 널리 사용되고 있다.
프로비저닝 - https://blog.naver.com/gojump0713/140110601767
IaaC - https://www.altexsoft.com/blog/infrastructure-as-code/