AWS ECS(with Fargate), GitHub, DockerHub, CircleCI를 활용해 서비스 자동 배포 하기(3)

modolee
Day34 Inc.
Published in
20 min readDec 4, 2019

기존 서비스 배포 시 프로세스의 불편함 점을 파악하고 개선하기 위해 도입한 과정을 총 3편에 걸쳐서 소개하려고 한다.

  1. 문제점 파악 및 개선방안
  2. AWS ECS 설정
  3. Circle CI 설정 및 자동 배포 적용

Circle CI 설정

사전 조건

회원가입

  • 회원가입 : Sign Up 버튼 클릭
    - Sign Up With GitHub 버튼 클릭
    - 모든 저장소를 다 연동할 것인지, 공개 저장소 만 연동할 것인지 선택
  • GitHub 권한 승인

GitHub 프로젝트 연동

- 계정 선택, 무료 플랜 선택 후 설치

- 설치 확인

  • CircleCI에서 관리 할 GitHub 저장소 설정
    - Add Projects 탭에서 Follow 하고자 하는 프로젝트를 선택해서 Set Up Project 버튼 클릭

Config 파일 작성

  • 설정을 완료 했으면 어떤 식으로 CI/CD를 진행할 지에 대한 정의가 필요하다. 프로젝트 루트에 .circleci 폴더를 만들고 config.yml 파일을 추가한다.
  • config.yml 기본 구조
    - version : CircleCI 설정 버전 (2019.12.02 현재 2.1)
    - jobs : 실행 될 작업을 정의 (job 이름으로 정의 됨. ex: test, build_and_push_dev)
    - workflows : 정의 된 job을 조합해서 어떤 조건에서 어떤 순서로 동작할 지 정의 한다.
  • jobs
    - docker : 작업을 docker 위에서 수행할 것이고, 어떤 이미지 위에서 수행할 것인지 지정할 수 있다.
    - steps : 작업을 정의하는 곳
    - checkout : 연동 된 GitHub 저장소에서 소스코드를 가져온다.
    - setup_remote_docker : docker 기반으로 동작하도록 설정 한다.
    - run : 실질적인 작업을 정의한다. (name : 이름 / command : 명령어)
  • workflows
    - version : workflow 정의 버전 (2019.12.02 현재 2)
    - workflow 이름으로 정의
    - jobs : 수행 할 job 목록
    - requires : 선행되어야 할 작업 목록
    - filter : 실행되기 위한 조건 (ex: master branch 일 경우)
  • day34/boilerplate-nodejs 의 config.yml 예제
    - 기본적으로 test / build_and_push / deploy 3개의 job으로 구성되어 있음
    - 브랜치에 따라서 dev / prod를 구분
    - test : docker, git 명령을 실행할 수 있는 이미지 기반에서 실행. local에서 동작하는 환경과 동일하게 컨테이너를 생성하고 테스트를 진행
    - build_and_push : docker, git 명령을 실행할 수 있는 이미지 기반에서 실행. dev / prod 환경에 맞춰 이미지를 빌드하고 docker hub에 각각 develop/master 태그를 달아서 PUSH 함
    - deploy : aws cli, jq 명령을 실행할 수 있는 이미지 기반에서 실행. 미리 생성 된 dev / prod ECS 클러스터에 새롭게 PUSH 된 docker 이미지를 가지고 컨테이너를 생성하여 기존의 컨테이너를 교체 함
version: 2.1
jobs:
test:
docker:
- image: docker:stable-git # the primary container, where your job's commands are run
steps:
- checkout # check out the code in the project directory
- setup_remote_docker
- run:
name: Build docker image to Test
command: docker build -t "$DOCKER_REPOSITORY" -f ./Dockerfile.local .
- run:
name: Run Test
command: docker run --env-file=.env.example "$DOCKER_REPOSITORY" npm run test
build_and_push_dev:
docker:
- image: docker:stable-git # the primary container, where your job's commands are run
steps:
- checkout # check out the code in the project directory
- setup_remote_docker
- run:
name: Build docker image and Push to DockerHub with develop tag
command: |
chmod +x ./.circleci/push.sh
sh ./.circleci/push.sh dev
deploy_dev:
docker:
- image: mikesir87/aws-cli
steps:
- checkout # check out the code in the project directory
- run:
name: Deploy to Dev server
command: |
chmod +x ./.circleci/deploy.sh
sh ./.circleci/deploy.sh dev
build_and_push_prod:
docker:
- image: docker:stable-git # the primary container, where your job's commands are run
steps:
- checkout # check out the code in the project directory
- setup_remote_docker
- run:
name: Build docker image to Push to DockerHub with master tag
command: |
chmod +x ./.circleci/push.sh
sh ./.circleci/push.sh prod
deploy_prod:
docker:
- image: mikesir87/aws-cli
steps:
- checkout # check out the code in the project directory
- run:
name: Deploy to Prod server
command: |
chmod +x ./.circleci/deploy.sh
sh ./.circleci/deploy.sh prod
workflows:
version: 2
test-build-push-deploy:
jobs:
- test
- build_and_push_dev:
requires:
- test
filters:
branches:
only: develop
- deploy_dev:
requires:
- build_and_push_dev
filters:
branches:
only: develop
- build_and_push_prod:
requires:
- test
filters:
branches:
only: master
- deploy_prod:
requires:
- build_and_push_prod
filters:
branches:
only: master

스크립트 설명

  • .circleci/push.sh
    - DockerHub에 로그인
    - dev, prod 환경에 맞게 Build 및 Push 진행
    - dev 는 Dockerfile.dev에 정의 된 대로 Build 후 develop 태그를 달아서 Push
    - prod는 Dockerfile에 정의 된 대로 Build 후 $CIRCLE_SHA1, master, latest 태그를 달아서 Push
    - aws cli 사용 설정
#!/bin/bashecho "$DOCKER_PASSWORD" | docker login -u "$DOCKER_ID" --password-stdin# arg 체크
if [ $# -eq 1 ]; then
if [ "$1" != "dev" ] && [ "$1" != "prod" ]; then
echo "Argument는 dev, prod 만 가능합니다."
exit 0
elif [ "$1" == "dev" ]; then
echo "-----DEV 이미지 BUILD AND PUSH-----"
docker build -t "$DOCKER_REPOSITORY":develop -f ./Dockerfile.dev .
docker push "$DOCKER_REPOSITORY":develop
elif [ "$1" == "prod" ]; then echo "-----PROD 이미지 BUILD AND PUSH-----"
docker build \
-t "$DOCKER_REPOSITORY":"$CIRCLE_SHA1" \
-t "$DOCKER_REPOSITORY":master \
-t "$DOCKER_REPOSITORY":latest .
docker push "$DOCKER_REPOSITORY":"$CIRCLE_SHA1"
docker push "$DOCKER_REPOSITORY":master
docker push "$DOCKER_REPOSITORY":latest
fi
else
echo "올바른 입력이 아닙니다."
exit 0
fi
  • .circleci/deploy.sh
    - dev, prod 환경에 따른 환경변수 설정
    - dev는 cluster 이름 뒤에 -dev가 붙고, develop 태그가 붙은 이미지를 사용
    - prod는 cluster 이름 뒤에 아무 것도 붙지 않고, $CIRCLE_SHA1 태그가 붙은 이미지를 사용
    - ECS에 배포할 컨테이너 정의 생성
    - 기타 작업 정의 파라미터와 함께 작업 정의 등록
    - 기존 revision의 컨테이너가 사라질 때 까지 기다림 (정상 동작 하지 않는 경우가 많은)
    - 기다리는 동안 기존 revision이 사라지지 않는다면 수동으로 확인해 보라는 메세지를 남기고 완료 함
#!/bin/bash
set -eo pipefail
deploy_cluster() {
make_container_definition
register_definition
desired_count=$(aws ecs describe-services --cluster "$CLUSTER_NAME" --services "$SERVICE_NAME" | $JQ '.services[0].desiredCount')
echo "Desired count: $desired_count"
if [[ $(aws ecs update-service --cluster "$CLUSTER_NAME" --service "$SERVICE_NAME" --desired-count "$desired_count" --task-definition "$revision" | \
$JQ '.service.taskDefinition') != "$revision" ]]; then
echo "Error updating service."
return 1
else
echo "Success updating service."
fi
# wait for older revisions to disappear
# not really necessary, but nice for demos
for attempt in {1..30}; do
if stale=$(aws ecs describe-services --cluster "$CLUSTER_NAME" --services "$SERVICE_NAME" | \
$JQ ".services[0].deployments | .[] | select(.taskDefinition != \"$revision\") | .taskDefinition"); then
echo "Waiting for stale deployment(s):"
echo "$stale"
sleep 30
else
echo "Deployed!"
return 0
fi
done
echo "Service update took too long - please check the status of the deployment on the AWS ECS console"
return 0
}
make_container_definition() {
container_definition="[
{
\"name\": \"$TASK_NAME\",
\"image\": \"$REPOSITORY_URL\",
\"portMappings\": [
{
\"containerPort\": 80,
\"hostPort\": 80,
\"protocol\": \"tcp\"
}
],
\"logConfiguration\": {
\"logDriver\": \"awslogs\",
\"options\": {
\"awslogs-group\": \"/ecs/$PROJECT\",
\"awslogs-region\": \"$AWS_DEFAULT_REGION\",
\"awslogs-stream-prefix\": \"ecs\"
}
},
\"essential\": true,
\"environment\": [
{
\"name\": \"SERVER_HOST\",
\"value\": \"$SERVER_HOST\"
},
{
\"name\": \"SERVER_PORT\",
\"value\": \"$SERVER_PORT\"
},
{
\"name\": \"API_END_POINT\",
\"value\": \"$API_END_POINT\"
}
]
}
]"
print_container_definition
}
print_container_definition() {
echo "$container_definition"
}
register_definition() {
if revision=$(aws ecs register-task-definition \
--container-definitions "$container_definition" \
--family "$FAMILY_NAME" \
--execution-role-arn "arn:aws:iam::$AWS_ACCOUNT_ID:role/ecsTaskExecutionRole" \
--network-mode "awsvpc" \
--requires-compatibilities "FARGATE" \
--cpu "$AWS_ECS_CONTAINER_CPU" \
--memory "$AWS_ECS_CONTAINER_MEMORY" | $JQ '.taskDefinition.taskDefinitionArn'); then
echo "Revision: ${revision}"
else
echo "Failed to register task definition"
return 1
fi
}
set_env_variables() {
# arg 체크
if [ $# -eq 1 ]; then
if [ "$1" != "dev" ] && [ "$1" != "prod" ]; then
echo "Argument는 dev, prod 만 가능합니다."
exit 0
elif [ "$1" == "dev" ]; then
echo "DEV 환경 변수 생성"
CLUSTER_NAME="$AWS_ECS_CLUSTER_NAME-dev"
IMAGE_TAG="develop"
elif [ "$1" == "prod" ]; then
echo "PROD 환경 변수 생성"
CLUSTER_NAME="$AWS_ECS_CLUSTER_NAME"
IMAGE_TAG="$CIRCLE_SHA1"
fi
else
echo "올바른 입력이 아닙니다."
exit 0
fi
FAMILY_NAME="$PROJECT"
SERVICE_NAME="$PROJECT"
TASK_NAME="$PROJECT"
REPOSITORY_URL="$DOCKER_REPOSITORY:$IMAGE_TAG"
JQ="jq --raw-output --exit-status" print_env_variables
}
print_env_variables() {
echo "$CLUSTER_NAME"
echo "$IMAGE_TAG"
echo "$FAMILY_NAME"
echo "$SERVICE_NAME"
echo "$TASK_NAME"
echo "$REPOSITORY_URL"
}
configure_aws_cli() {
aws --version
aws configure set default.region "$AWS_DEFAULT_REGION"
aws configure set default.output json
}
set_env_variables "$@"
configure_aws_cli
deploy_cluster

환경 변수 설정

  • 환경 설정으로 이동
    - GitHub과 연동 한 프로젝트의 오른쪽에 있는 톱니 바퀴를 눌러 설정 페이지로 이동한다.
  • 환경 변수 설정 페이지로 이동
    - Build Settings > Environment Variables 메뉴를 선택한다.
  • 환경 변수 추가
    - AWS_ACCESS_KEY_ID : IAM > 사용자 > 액세스 키 ID
    - AWS_SECRET_ACCESS_KEY : IAM > 사용자 > 비밀 액세스 키
    - AWS_ACCOUNT_ID : IAM > AWS Account 옆 괄호 안에 숫자
    - AWS_DEFAULT_REGION : 기본 Region. 아시아(서울) 인 경우 ap-northeast-2
    - AWS_ECS_CLUSTER_NAME : ECS Cluster 생성 시 입력 한 이름 boilerplate
    - AWS_ECS_CONTAINER_CPU : 컨테이너가 할당 할 vCPU. 기본 설정 256
    - AWS_ECS_CONTAINER_MEMORY : 컨테이터에게 할당 할 MEMORY. 기본 설정 512
    - DOCKER_ID : DockerHub ID. DockerHub에 가입하세요.
    - DOCKER_PASSWORD : DockerHub 패스워드. DockerHub에 가입하세요.
    - DOCKER_REPOSITORY : DockerHub에 이미지 저장소 URL. DockerHub에 가입한 ID 또는 조직 이름/프로젝트이름 을 입력한다. (예 : day34/boilerplate-nodejs)
    - PROJECT : 프로젝트 이름. boilerplate
    - API_END_POINT : boilerplate-nodejs에서 내부적으로 사용하는 환경변수. API 호출 주소. http://localhost
    - SERVER_HOST : boilerplate-nodejs에서 내부적으로 사용하는 환경변수. 서버 주소. 0.0.0.0
    - SERVER_PORT : boilerplate-nodejs에서 내부적으로 사용하는 환경변수. 서버 포트. 80

자동 배포 적용

소스 수정 및 GitHub에 Push

  • 테스트를 위해서는 2가지 방법이 있다.
    - .circleci/config.yml 파일을 수정해서 배포를 실행할 branch를 변경하는 방법
    - 그냥 master 브랜치에 바로 push 하는 방법
      - build_and_push_prod:
requires:
- test
filters:
branches:
only: <PUSH 하려는 브랜치 이름>
- deploy_prod:
requires:
- build_and_push_prod
filters:
branches:
only: <PUSH 하려는 브랜치 이름>

Circle CI 대시보드에서 확인

  • workflows에서 Running 되고 있는 것을 클릭해서 상세 페이지를 보면 job이 나열되어 있는 것을 확인할 수 있다.
  • 아래 스크린샷은 성공적으로 배포까지 완료 된 상태이다.

Amazon ECS에서 확인

  • AWS > 서비스 > Amazon ECS > 클러스터 > 클러스터 이름 > 서비스 이름
  • 이벤트 탭에서 발생하는 이벤트 확인
    - 새로운 태스크가 실행되고, Load Balancer의 대상 그룹에 등록 된 후 기존의 태스크가 제거되고 안정적인 상태가 되는 것은 확인할 수 있다.
    - 이렇게 배포 후 안정까지 약 7분 가량이 소요 되었다.
  • 안정 상태가 된 후에 맨 처음 확인 했던 ELB DNS/API 주소로 들어가서 정상적으로 API가 동작하는 지 확인하면 된다.
    - 여기에서는 DNS/api/sample/item 으로 접속해서 API 테스트 함
    - 아래와 같이 나오면 정상적으로 컨테이너 교체가 된 것
    - 조금 더 명확하게 하려면 API 리턴 값을 변경하여 확인할 수 있다.

GitHub에서 PR로 브랜치 병합 시 (PR을 사용하는 경우에만 참고)

  • 아래 처럼 CircleCI가 연동되어 테스트를 수행하고 있다는 메세지를 볼 수 있다.

참고

--

--