EMD Blog

Spring Boot와 Jenkins를 이용한 ECR Push(1) 본문

CICD

Spring Boot와 Jenkins를 이용한 ECR Push(1)

EmaDam 2021. 5. 31. 23:03

 회사 프로젝트 중 크롤링 Batch 서버를 구축해야할 일이 생겼다. 새롭게 들어온 팀원이 맡아서 진행하기로 했는데 기왕 하는 거 Docker Image push 까지 자동화 구축 후 진행을 하면 좀 더 편하게 개발을 하지 않을까 싶어 구축 과정을 간략하게 기록하려한다. 사내 서비스 운영은 인력 부족 및 금액 적인 부분 때문에 AWS에 많이 의존하고 있다. 현재 구축하려는 서비스도 MongoDB를 활용하려다가 데이터가 쌓이면 관리하기 힘들어질 것 같아 DynamoDB를 활용하기로 했다. 구축하고자 하는 개발 프로세스는 다음과 같다.

 먼저 로컬에서 github repository를 remote하여 개발하다가 이슈 단위의 작업이 종료되면 Github의 Task branch에 Push 후 develop branch에 pull request한다.  pull request시 jenkins에 webhook을 보내고 Jenkins에서는 pull request하고자 하는 애플리케이션의 통합테스트를 진행하고 진행결과를 Github에 반환해 merge 가능 여부를 체크한다. 통합테스트가 통과해 squash commit이 가능하면 내부적으로 코드 리뷰 후 squash merge를 진행한다. 그러면 다시 github으로 webhook를 보내 jenkins에서는 코드를 불러와 통합테스트를 진행하고 빌드 후 이미지화해서 ECR에 Push한다. 

 

개발환경은 다음과 같다

 - JDK 1.8

 - Springboot 2.5.0

 - Gradle 

 

 

위 처럼 세팅 했다면 이제 DynamoDB와 연동해보도록 하자 

 

Amazon DynamoDB 란? - Amazon DynamoDB

이 페이지에 작업이 필요하다는 점을 알려 주셔서 감사합니다. 실망시켜 드려 죄송합니다. 잠깐 시간을 내어 설명서를 향상시킬 수 있는 방법에 대해 말씀해 주십시오.

docs.aws.amazon.com

DynamoDB에 대한 설명과 사용법은 AWS개발자 안내서에 상세하게 설명되어있으니 참고하도록 하자

 

 DynamoDB는 기본 적으로 사용량에 따른 요금을 부과하게 되어있다. 하지만 테스트용으로 dynamodb-local을 지원하니 개발환경에서는 DynamoDB-local을 사용하고 테스트 환경에서는 실제 dynamodb를 사용하기로 한다. 그럼 docker-compose.yml을 먼저 작성하자

version: "3.7"
services:
  dynamodb:
    container_name: dynamodb
    image: amazon/dynamodb-local:latest
    ports:
      - 8000:8000
    restart: always
    command: ["-jar", "DynamoDBLocal.jar", "-sharedDb"]

dynamodb-local을 실행시켜주는 간단한 compose 파일이다. -sharedDb 옵션을 줘서 dynamodb가 종료되더라도 데이터가 사라지지 않도록 했으며 -sharedDb 대신 -inMemory옵션을 주게 되면 dynamodb 종료시 데이터가 삭제된다. 그리고 터미널에서 쉽게 조작할 수 있도록 aws cli를 설치하도록 하자 

 

 여기까지 진행했으면 dynamoDB를 실행해보자 

$ docker-compose up -d
 Creating dynamodb ... done

dynamodb-local이 실행되었으면 aws cli 명령어를 통해 테이블을 생성해보도록 하자. 

근데 테이블을 생성하기 전에 간단하게 스키마를 구성해보도록 하자. 임시로 강아지라는 테이블을 만들기로 하고 id, 이름, 나이, 종류, 생성일 정도로 구성되어있다고 하자. 그리고 그 중 PK는 id, 검색용 보조 index로 종류, 생성일을 인덱싱하자.  

$ aws dynamodb create-table \
    --table-name Dog \
    --attribute-definitions \
        AttributeName=id,AttributeType=S \
        AttributeName=dogType,AttributeType=S \
        AttributeName=createdAt,AttributeType=S \
    --key-schema \
        AttributeName=id,KeyType=HASH \
    --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1 \
    --global-secondary-indexes \
"[ { \"IndexName\": \"byDogType\", \"KeySchema\": [ {\"AttributeName\": \"dogType\",\"KeyType\":\"HASH\"}, {\"AttributeName\": \"createdAt\",\"KeyType\":\"RANGE\"} ], \"Projection\": { \"ProjectionType\": \"ALL\" }, \"ProvisionedThroughput\": { \"ReadCapacityUnits\":1, \"WriteCapacityUnits\":1 } } ]" \
    --endpoint-url http://localhost:8000


{
    "TableDescription": {
        "AttributeDefinitions": [
            {
                "AttributeName": "id",
                "AttributeType": "S"
            },
            {
                "AttributeName": "dogType",
                "AttributeType": "S"
            },
            {
                "AttributeName": "createdAt",
                "AttributeType": "S"
            }
        ],
        "TableName": "Dog",
        "KeySchema": [
            {
                "AttributeName": "id",
                "KeyType": "HASH"
            }
        ],
        "TableStatus": "ACTIVE",
        "CreationDateTime": "2021-05-29T23:37:41.479000+09:00",
        "ProvisionedThroughput": {
        ...
      

 위 명령어를 통해 table을 생성할 수 있다. 보면은 이름과 나이에 대한 스키마를 명시하지 않았다. Nosql이기 때문에 동적 스키마를 사용하므로 컬럼 종류가 늘어나면 자동으로 수평확장 된다. 그래서 id와 dogType(종류), createdAt만 명시해 놓은 것이다. AttributeType의 경우 컬럼 타입으로 S면 문자열, N이면 숫자형이다. 그리고 --key-schema option을 통해 id를 키본키로 설정했으며 기본키는 꼭 한개가 아니여도 된다. 마지막으로 --global-secondary-indexes 옵션을 통해 보조 인덱스를 설정했다. dogType과 createdAt을 묶어 byDogType이라는 이름으로 설정 했으며 dogType은 검색(HASH), createdAt은 정렬(RANGE)에 사용된다.  

 

 위 명령어 옵션 중에 --provisioned-throughput이 있는데 dynamodb는 프로비저닝 모드와 온디멘드 모드로 나뉘며 프로비저닝 모드의 경우 처리량에 대한 용량을 선택해야한다. 처리량에 대한 Auto Scaling은 제공하지만 초기설정 값은 지정해줘야한다. 하지만 local의 경우 이 값에 상관없이 작동해 그냥 최소값인 1로 설정하면 된다. 이 값에 대한 자세한 설명은 홈페이지 설명을 참고해보자.

 

이렇게 테이블을 생성했다면 잘 생성되었는지 조회해보자

 $ aws dynamodb list-tables --endpoint-url http://localhost:8000

{
    "TableNames": [
        "Dog"
    ]
}

테이블이 잘 조회되는 것을 확인할 수 있다. 참고로 테이블 생성이나 조회시 endpoint를 설정해주지 않으면 local이 아닌 AWS에 생성되니 localhost로 명시해주자. 

 

 이렇게 dynamodb의 테이블 생성과 조회를 해보았다. aws에 대한 다른 dynamodb 사용 방법은 아래 경로를 참고하자.

 

dynamodb — AWS CLI 1.19.84 Command Reference

Note: You are viewing the documentation for an older major version of the AWS CLI (version 1). AWS CLI version 2, the latest major version of AWS CLI, is now stable and recommended for general use. To view this page for the AWS CLI version 2, click here. F

docs.aws.amazon.com

 

이제 dynamodb의 작동을 확인 했으니 spring boot와 연동해보도록 하자. 먼저 build gradle에 의존성을 몇개 추가하자.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation group: 'com.amazonaws', name: 'aws-java-sdk-dynamodb', version: '1.11.1000'
    implementation group: 'com.github.derjust', name: 'spring-data-dynamodb', version: '5.1.0'
    implementation group: 'org.assertj', name: 'assertj-core', version: '3.8.0'
    implementation 'junit:junit:4.12'
}

추가된 것은 aws-java-sdk-dynamodb와 spring-data-dynamodb, assertj-core이다. aws-java-sdk-dynamodb는 dynamodb를 사용하기 위한 것이고 spring-data-dynamodb는 쿼리메서드를 사용하기 위해, assertj-core는 Test코드를 위해 추가했다. 

 

그럼 /src/main/resource/application.properties를 application.yml로 변경 후 어플리케이션에 대한 설정 정보를 입력해주자

server:
  port: 8080
amazon:
  dynamodb:
    endpoint: "http://localhost:8000"
    region: "ap-northeast-2"
  aws:
    accesskey: "key"
    secretkey: "key2"

 서버 Port를 8080로 설정하고 amazon dynamodb의 endpoint를 local, region을 서울로 설정했다. accesskey와 secretkey는 local이기 때문에 따로 값을 설정하지 않아도 된다. 

 

 쿼리메서드 사용을 위해 com.emadam.study에 config package를 생성하고 DynamoDBConfig.java를 생성하자.

 

DynamoDBConfig.java

package com.emadam.study.config;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.client.builder.AwsClientBuilder;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTypeConverter;
import org.socialsignin.spring.data.dynamodb.repository.config.EnableDynamoDBRepositories;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.TimeZone;

@Configuration
@EnableDynamoDBRepositories(basePackages = "com.emadam.study.domain")
public class DynamoDBConfig {
    @Value("${amazon.dynamodb.endpoint}")
    private String amazonDynamoDBEndpoint;

    @Value("${amazon.dynamodb.region}")
    private String amazonDynamoDbRegion;

    @Value("${amazon.aws.accesskey}")
    private String amazonAwsAccessKey;

    @Value("${amazon.aws.secretkey}")
    private String amazonAwsSecretKey;

    @Bean(name = "amazonDynamoDB")
    public AmazonDynamoDB amazonDynamoDB() {
        AWSStaticCredentialsProvider credentialsProvider = new AWSStaticCredentialsProvider(new BasicAWSCredentials(amazonAwsAccessKey, amazonAwsSecretKey));
        AwsClientBuilder.EndpointConfiguration endpointConfiguration = new AwsClientBuilder.EndpointConfiguration(amazonDynamoDBEndpoint, amazonDynamoDbRegion);

        return AmazonDynamoDBClientBuilder.standard()
                .withCredentials(credentialsProvider)
                .withEndpointConfiguration(endpointConfiguration).build();
    }

    public static class LocalDateTimeConverter implements DynamoDBTypeConverter<Date, LocalDateTime> {
        @Override
        public Date convert(LocalDateTime localDateTime) {
            return Date.from(localDateTime.toInstant(ZoneOffset.UTC));
        }

        @Override
        public LocalDateTime unconvert(Date date) {
            return date.toInstant().atZone(TimeZone.getDefault().toZoneId()).toLocalDateTime();
        }
    }
}

위에서 부터 amazonDynamoDBEndpoint, amazonDynamoDBRegion, amazonAwsAccessKey, amazonAwsSecretKey에 application.yml의 값을 매핑하고 이 설정값을 들을 통해 amazonDynamoDB 메서드에서 dynamodb client객체를 생성 후 amazonDynamoDB라는 이름의 Bean으로 만든다. 마지막으로 LocalDateTimeConverter는 dynamodb sdk가 기본 Date 객체만 지원하기 때문에 저장할때는 Date, 가져올때는 LocalDateTime으로 변환되도록 하는 Converter이다. 

 

그럼 이제 앞서 구상한 테이블 스키마에 대한 Entity 객체를 생성하자.

Dog.java

package com.emadam.study.domain;

import com.amazonaws.services.dynamodbv2.datamodeling.*;
import com.emadam.study.config.DynamoDBConfig;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDateTime;

@Getter
@Setter
@NoArgsConstructor
@DynamoDBTable(tableName = "Dog")
public class Dog {
    @DynamoDBHashKey(attributeName = "id")
    @DynamoDBAutoGeneratedKey
    private String id;

    @DynamoDBAttribute
    private String name;

    @DynamoDBAttribute
    private Integer age;

    @DynamoDBAttribute
    @DynamoDBIndexHashKey(globalSecondaryIndexName = "byDogType")
    private String dogType;

    @DynamoDBAttribute
    @DynamoDBIndexRangeKey(globalSecondaryIndexName = "byDogType")
    @DynamoDBTypeConverted(converter = DynamoDBConfig.LocalDateTimeConverter.class)
    private LocalDateTime createdAt;

    @Builder
    public Dog(
            String id,
            String name,
            Integer age,
            String dogType
    ) {
        this.id = id;
        this.name = name;
        this.age = age;
        this.dogType = dogType;
        this.createdAt = LocalDateTime.now();
    }
}

lombok을 통해 getter와 setter를 생성해주고 기본 생성자를 생성해준다. 그다음 @DynamoDBTable 어노테이션을 통해 table을 지정해주고 각 항목 별로 어노테이션을 추가해준다. 

@DynamoDBHashKey : keyType이 Hash인 기본키

@DynamoDBAutoGeneratedKey : 값을 자동으로 생성

@DynamoDBAttribute: DynamoDB table의 항목임을 명시

@DynamoDBIndexHashKey: keyType이 Hash인 second index 키

@DynamoDBIndexRangeKey: keyType이 Range인 second index 키

@DynamoDBTypeConverted: 사용할 컨버터 class를 명시

 

그 다음 Repository 객체를 생성해 실제 쿼리 메서드를 생성해보자.

 

DogRepository.java

package com.emadam.study.domain;

import org.springframework.data.repository.CrudRepository;

import java.util.List;

public interface DogRepository extends CrudRepository<Dog, String> {
    List<Dog> findAllByDogTypeOOrderByCreatedAtDesc(String dogType);
}

 CrudRepository를 상속받았으며 CrudRepository를 상속받는 다른 Repository객체들이 많이 존재하므로 자신한테 맞는 객체를 찾아 상속받아서 사용해도 된다. 그 다음 item중 dogType에 해당하는 item을 찾아 생성일 기준으로 정렬하는 쿼리 메서드를 생성한다. 쿼리 메서드의 경우 자바 ORM 표준 JPA 프로그래밍 책을 참고하거나 이쪽을 참고하여 상황에 맞게 작성하자.

 

 그 다음 작성한 쿼리 메서드를 테스트할 Test case를 작성한다.

DogRepositoryTests.java

package com.emadam.study.domain;

import com.emadam.study.config.DynamoDBConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.junit.Assert;

@SpringBootTest(classes = {DynamoDBConfig.class})
public class DogRepositoryTests {
    private @Autowired DogRepository dogRepository;
    private final String[] DOG_TYPE = {"말티즈", "푸들", "리트리버", "치와와", "불독"};

    @Test
    void create() {
        Dog dog = dogRepository.save(Dog.builder()
            .name("우디")
            .age(3)
            .dogType(DOG_TYPE[0])
            .build()
        );

        Assert.assertEquals(dog.getName(), "우디");
        Assert.assertEquals(dog.getAge(), Integer.valueOf(3));
        Assert.assertEquals(dog.getDogType(), "말티즈");
    }
}

 

테스트에 성공했다면 나머지 수정, 삭제, 조회에 관한 테스트도 작성하자

조회

@Test
    void findCreated(){
        String id = dogRepository.save(Dog.builder()
            .name("모냐")
            .age(10)
            .dogType(DOG_TYPE[1])
            .build()
        ).getId();

        Dog dog = dogRepository.findById(id)
                .orElseThrow(() -> new DogNotFoundException(id));

        Assert.assertEquals(dog.getName(), "모냐");
        Assert.assertEquals(dog.getAge(), Integer.valueOf(10));
        Assert.assertEquals(dog.getDogType(), "푸들");
    }

 

 

 

수정

@Test
    void update(){
        String id = dogRepository.save(Dog.builder()
            .name("폴리")
            .age(7)
            .dogType(DOG_TYPE[2])
            .build()
        ).getId();

        Dog dog = dogRepository.findById(id)
                .orElseThrow(() -> new DogNotFoundException(id));
        dog.setName("마루");
        dog.setAge(17);
        Dog modifiedDog = dogRepository.save(dog);

        Assert.assertEquals(modifiedDog.getName(), "마루");
        Assert.assertEquals(modifiedDog.getAge(), Integer.valueOf(17));
        Assert.assertEquals(modifiedDog.getDogType(), "리트리버");
    }

 

 

삭제

@Test
    void deleteCreated() {
        Dog dog = dogRepository.save(Dog.builder()
            .name("앵그리")
            .age(5)
            .dogType(DOG_TYPE[3])
            .build()
        );

        dogRepository.delete(dog);

        BDDAssertions.thenThrownBy(() -> dogRepository.findById(dog.getId())
                .orElseThrow(() -> new DogNotFoundException(dog.getId())))
                .isExactlyInstanceOf(DogNotFoundException.class);
    }

 

여러 Item 조회

@Test
    void findDogs() {
        int size = 8;
        IntStream.range(0, size).forEach(i -> dogRepository.save(Dog.builder()
                .name("강쥐" + i)
                .age(3 + 2 * i)
                .dogType(DOG_TYPE[4])
                .build()
        ));

        List<Dog> dogs = dogRepository
                .findAllByDogTypeOrderByCreatedAtDesc(DOG_TYPE[4]);

        BDDAssertions.then(dogs.size()).isEqualTo(size);
        IntStream.range(1, size).forEach(i -> {
            Dog prev = dogs.get(i - 1);
            Dog next = dogs.get(i);
            BDDAssertions.then(prev.getCreatedAt().isAfter(next.getCreatedAt())).isTrue();
        });
    }

 

이렇게 CRUD에 대한 테스트 코드가 작성되었다. 하지만 이대로만 진행하면 테스트를 여러번 했을 때 findDogs 테스트에서 실패하게 되는데 이유는 DOG_TYPE[4]에 대한 item수가  미리 설정한 size를 넘어가게 되기 때문이다. 그렇기 때문에 테스트가 종료되면 DynamoDB의 모든 item을 삭제하는 로직을 추가한다.

 

DogRepository.java

package com.emadam.study.domain;

import org.springframework.data.repository.CrudRepository;

import java.util.List;

public interface DogRepository extends CrudRepository<Dog, String> {
    List<Dog> findAllByDogTypeOrderByCreatedAtDesc(String dogType);
    void deleteAllByDogType(String dogType);
}

 

DogRepositoryTests.java

@SpringBootTest(classes = {DynamoDBConfig.class})
public class DogRepositoryTests {
    private @Autowired DogRepository dogRepository;
    private final String[] DOG_TYPE = {"말티즈", "푸들", "리트리버", "치와와", "불독"};

    @AfterEach
    void deleteAll() {
        for(String dogType : DOG_TYPE) {
            dogRepository.deleteAllByDogType(dogType);
        }
    }

    @Test
    void create() {
        ...
    }

    ...
}

dogType을 기준으로 item을 삭제하는 쿼리 메서드를 생성했다. 그리고 AfterEach를 통해 테스트가 종료될때마다 Dog table의 모든 item을 삭제하도록 했다. 만약에 테스트를 시작하기 전에 작업을 하고 싶다면 BeforeEach 어노테이션을 사용하면 된다.

 

 이제 테스트르 작성했으니 Docker image생성을 위한 Dockerfile과 gradle.build를 작성해보자

 

DogRepositoryTests.java

FROM openjdk:8-jre-alpine

WORKDIR /root

ARG buildDir=build/unpack

COPY ${buildDir}/lib BOOT-INF/lib
COPY ${buildDir}/app .

CMD java org.springframework.boot.loader.JarLauncher

DogRepositoryTests.java

...

task moveLib {
    doLast {
        def unpackDir = "$buildDir/unpack"
        ant.move(file: "${unpackDir}/app/BOOT-INF/lib", toFile: "${unpackDir}/lib")
    }
}

task unpackJar(type: Copy) {
    def unpackDir = "$buildDir/unpack"

    delete unpackDir
    from zipTree(jar.getArchiveFile())
    into "$unpackDir/app"

    finalizedBy moveLib
}

build {
    finalizedBy unpackJar
}

 

Dockerfile과 build.gradle에 Dockerfile 최적화 과정은 양권성님의 블로그를 참고하였으니 궁금하면 확인해보자.

 

(Docker) Spring Boot Application Image 최적화하기

들어가기에 앞서이 글에서 Docker와 Spring Boot, Gradle에 대한 기본적인 지식은 있다고 판단하고 설명한다.프로젝트는 spring-boot-docker-demo 저장소에서 단계별로 브랜치를 확인해보면 된다.이해를 돕기

perfectacle.github.io

그럼 docker image가 잘 생성되는지 확인해보자

$ ./gradlew check
$ ./gradlew build
$ docker build -t dog:1.0 .
$ docker images

 

docker image가 잘 생성된 것을 볼 수 있다.

그럼 이제 github, jenkins를 사용해서 빌드, 테스트, Push해보도록 하자.

 

먼저 서버에 Jenkins를 설치해주자. 서버에는 docker와 docker compose가 설치되어 있어야 하며, jenkins는 docker-compose를 통해 설치하자.

 

Dockerfile

FROM jenkins/jenkins:latest
USER $USER
RUN curl -s https://get.docker.com/ | sh
RUN curl -L "https://github.com/docker/compose/releases/download/1.28.5/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose && \
    chmod +x /usr/local/bin/docker-compose && \
    ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
RUN curl -sL bit.ly/ralf_dcs -o ./dcs && \
    chmod 755 dcs && \
    mv dcs /usr/local/bin/dcs
RUN usermod -aG docker jenkins

 

docker-compose.yml

version: "3.9"
services:
  jenkins:
    container_name: jenkins
    privileged: true
    build:
      context: ./
    ports:
      - "8080:8080"
      - "50000:50000"
    volumes:
      - /var/jenkins_home:/var/jenkins_home
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - TZ=Asia/Seoul

 

Dockerfile과 docker-compose 파일을 home에 생성했다. 우리가 할 것은 Jenkins를 통해 docker를 실행해야하기 때문에 별도의 Dockerfile을 만들어 jenkins와 docker가 한 container내에 같이 설치되도록 했다. 그리고 host에 설치된 docker.sock과 mount하여 container내에서 실행하는 docker 명령어가 container외부에서도 실행되도록 설정했다.  그럼 docker-compose를 실행하자

 

$ docker-compose up -d

 

조금 기다린 후 jenkins가 시작되면 관리자 계정정보를 설정하고 기본 플러그인만 설치하자.

다음 포스팅에는 본격적으로 Github과 연동하도록하겠다.