[Terraform@CloudNet] Terraform Runner: Atlantis

2024. 7. 13. 21:13EKS@Terraform

- CloudNet에서 주관하는 Terraform 스터디 내용입니다

- 내용은 위 책 테라폼으로 시작하는 IaC 를 기준으로 정리하였습니다.

- 실습은 M1 macbook air 에서 진행했습니다.

- 매번 좋은 스터디를 진행해 주시는 CloudNet 팀 감사드립니다

- 잘못된 점, 업데이트된 지식, 다른 코멘트 언제나 환영입니다!

 

 

Runner란?

Terraform 에 GitOps 를 적용하기 위한 도구이다. (Terraform 자체도 Devops를 위한 툴인데, 데봅스를 위한 데봅스...?)

스터디장 가시다님은 Workflow 랑 비슷한데 기능이 아직 좀 모자란 정도로 설명하셨다.

 

Terraform 을 이용한 리소스 관리를 여러 사람이 하게 되면 한 레포에 여러사람이 작업할때 고려해야할 점이 많아지듯 형상관리와 backend lock 등이 필요해진다. 이 기능을 지원하는 것이 Runner 이고, Runner의 한 종류인 Atlantis 는 plan 과 apply 를 PR 코멘트에서 하도록 지원한다. 

 

국내기업의 Atlantis 도입 후기는 펫프렌즈의 사례를 참고해도 좋겠다.

 

 

 

Atlantis 배포

atlantis 환경을 구축하고 여기서 GitOps 를 실습해보자

 

 

우선 atlantis의 서버 역할을 하는 EC2 인스턴스를 하나 띄워준다. 

EC2 는 cloudformation file 로 구성되어 있다. 아래 토글해놓은 t101-atlantis-ec2.yaml 파일을 저장하고, aws cli 로 cloudformation 을 배포할 것이다. 실습에서 사용할 git, terraform 등의 버전을 통일해서 Gasida 님이 cloudformation 파일을 구성해놓으셨다. 감사합니다!

 

t101-atlantis-ec2.yaml

더보기
AWSTemplateFormatVersion: '2010-09-09'

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "<<<<< Deploy EC2 >>>>>"
        Parameters:
          - KeyName
          - SgIngressSshCidr
          - MyInstanceType
          - LatestAmiId

      - Label:
          default: "<<<<< Region AZ >>>>>"
        Parameters:
          - TargetRegion
          - AvailabilityZone1
          - AvailabilityZone2

      - Label:
          default: "<<<<< VPC Subnet >>>>>"
        Parameters:
          - VpcBlock
          - PublicSubnet1Block
          - PublicSubnet2Block

Parameters:
  KeyName:
    Description: Name of an existing EC2 KeyPair to enable SSH access to the instances.
    Type: AWS::EC2::KeyPair::KeyName
    ConstraintDescription: must be the name of an existing EC2 KeyPair.
  SgIngressSshCidr:
    Description: The IP address range that can be used to communicate to the EC2 instances.
    Type: String
    MinLength: '9'
    MaxLength: '18'
    Default: 0.0.0.0/0
    AllowedPattern: (\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/(\d{1,2})
    ConstraintDescription: must be a valid IP CIDR range of the form x.x.x.x/x.
  MyInstanceType:
    Description: Enter EC2 Type(Spec) Ex) t3.micro.
    Type: String
    Default: t3.medium
  LatestAmiId:
    Description: (DO NOT CHANGE)
    Type: 'AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>'
    Default: '/aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id'
    AllowedValues:
      - /aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id

  TargetRegion:
    Type: String
    Default: ap-northeast-2
  AvailabilityZone1:
    Type: String
    Default: ap-northeast-2a
  AvailabilityZone2:
    Type: String
    Default: ap-northeast-2c

  VpcBlock:
    Type: String
    Default: 10.10.0.0/16
  PublicSubnet1Block:
    Type: String
    Default: 10.10.1.0/24
  PublicSubnet2Block:
    Type: String
    Default: 10.10.2.0/24

Resources:
# VPC
  TerraformVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcBlock
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: Terraform-VPC

# PublicSubnets
  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Ref AvailabilityZone1
      CidrBlock: !Ref PublicSubnet1Block
      VpcId: !Ref TerraformVPC
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: Terraform-PublicSubnet1
  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Ref AvailabilityZone2
      CidrBlock: !Ref PublicSubnet2Block
      VpcId: !Ref TerraformVPC
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: Terraform-PublicSubnet2

  InternetGateway:
    Type: AWS::EC2::InternetGateway
  VPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref TerraformVPC

  PublicSubnetRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref TerraformVPC
      Tags:
        - Key: Name
          Value: Terraform-PublicSubnetRouteTable
  PublicSubnetRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PublicSubnetRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
  PublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicSubnetRouteTable
  PublicSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet2
      RouteTableId: !Ref PublicSubnetRouteTable


# EC2 Hosts
  EC2SG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Terraform EC2 Security Group
      VpcId: !Ref TerraformVPC
      Tags:
        - Key: Name
          Value: Terraform-SG
      SecurityGroupIngress:
      - IpProtocol: '-1'
        CidrIp: !Ref SgIngressSshCidr
      - IpProtocol: tcp
        FromPort: 4141
        ToPort: 4141
        CidrIp: 0.0.0.0/0

  EC21:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref MyInstanceType
      ImageId: !Ref LatestAmiId
      KeyName: !Ref KeyName
      Tags:
        - Key: Name
          Value: Atlantis
      NetworkInterfaces:
        - DeviceIndex: 0
          SubnetId: !Ref PublicSubnet1
          GroupSet:
          - !Ref EC2SG
          AssociatePublicIpAddress: true
          PrivateIpAddress: 10.10.1.10
      BlockDeviceMappings:
        - DeviceName: /dev/sda1
          Ebs:
            VolumeType: gp3
            VolumeSize: 30
            DeleteOnTermination: true
      UserData:
        Fn::Base64:
          !Sub |
            #!/bin/bash
            hostnamectl --static set-hostname Atlantis

            # Config convenience
            echo 'alias vi=vim' >> /etc/profile
            echo "sudo su -" >> /home/ubuntu/.bashrc

            # Install Packages & Terraform
            wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
            echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
            apt update -qq && apt install tree jq unzip zip terraform -y

            # Install aws cli version2
            curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
            unzip awscliv2.zip
            sudo ./aws/install

            # Install atlantis
            wget https://github.com/runatlantis/atlantis/releases/download/v0.28.3/atlantis_linux_amd64.zip -P /root
            unzip /root/atlantis_linux_amd64.zip -d /root && rm -rf /root/atlantis_linux_amd64.zip

Outputs:
  eksctlhost:
    Value: !GetAtt EC21.PublicIp

 

그리고 aws ec2 권한이 있는 key-pair가 필요하다. 없다면 링크 를 참고해서 키를 생성해준다. 나는 만들어둔 키가 있어서 사용했다

 

 

이런 식으로 MYKEYNAME 을 환경변수로 등록하고 aws cli의 cloudformation으로 배포하면 배포가 수행된다

(AWS GUI 콘솔을 사용할 수도 있다)

## cloudformation 배포
aws cloudformation deploy \
--template-file t101-atlantis-ec2.yaml \
--stack-name t101 \
--parameter-overrides KeyName=$MYKEYNAME \
SgIngressSshCidr=$(curl -s ipinfo.io/ip)/32 \
--region ap-northeast-2

## pub IP 확인
aws cloudformation describe-stacks \
--stack-name t101 \
--query 'Stacks[*].Outputs[0].OutputValue' \
--output text

배포가 바로 되지는 않기 때문에 (약 3분) 배포를 모니터링하는 코드를 다른 터미널에서 띄워서 보고 있을 수도 있다

while true; do 
  date
  AWS_PAGER="" aws cloudformation list-stacks \
    --stack-status-filter CREATE_IN_PROGRESS CREATE_COMPLETE CREATE_FAILED DELETE_IN_PROGRESS DELETE_FAILED \
    --query "StackSummaries[*].{StackName:StackName, StackStatus:StackStatus}" \
    --output table
  sleep 1
done

배포 종료 후 공인 IP 로 접속할 것이므로 주소를 확인해둔다

 

배포된 EC2로 접속하는데, 공인IP를 알지 않아도 cloudformation 쿼리로 접근할 수 있다는 점이 인상적이었다.

ssh -i ~/.ssh/sshpark.pem ubuntu@$(aws cloudformation describe-stacks \
--stack-name t101 \
--query 'Stacks[*].Outputs[0].OutputValue' \
--output text)

 

root 계정으로 접근에 성공했다!

 

이후 사용을 위해 aws configure로 자격증명을 설정한다

여담으로 key 확인이 너무 오랜만이라 secret key를 저장해놓은 위치를 잊어서 조금 헤멨다. 키 관리를 잘합시다!

 

그리고 현재IP로 접근을 쉽게 하기위해서 환경변수에 현재 IP:4141 포트를 등록해 놓는다

URL="http://$(curl -s ipinfo.io/ip):4141"
echo $URL

 

 

이제 테라폼 코드를 관리할 Github Repository 를 생성한다

 

 

그리고 나서 이 레포에서 사용할 토큰을 새로 만들어준다 (실습용이므로 7일짜리로 만들었다)

새로 생성한 토큰을 터미널에 변수로 등록한다.

등록할 때 SECRET 변수에 아무 문장을 만들어서 추가로 등록한다

그리고 생성한 repo에 웹훅을 추가한다. 웹훅이란 웹훅이란 데이터가 변경되었을 때 실시간으로 알림을 받을 수 있는 기능이다.

URL 에는 터미널 변수로 등록한 URL/events 를 넣어주고, 알람 받는 이벤트로는 아래 4개를 체크하고 생성한다

- Issue comments

- Pull request reviews

- Pushes

- Pull Requests

생성이 잘 되었다

 

 

그리고 repo명과 username 을 참고해서 USERNAME, REPO_ALLOWLIST 변수도 등록해준다

 

마지막으로 지금까지 등록한 변수들을 한번 확인하고 Atlantis 서버를 실행한다. 

## 변수 설정 확인
echo $URL $USERNAME $TOKEN $SECRET $REPO_ALLOWLIST

## Atlantis 서버 실행
./atlantis server \
--atlantis-url="$URL" \
--gh-user="$USERNAME" \
--gh-token="$TOKEN" \
--gh-webhook-secret="$SECRET" \
--repo-allowlist="$REPO_ALLOWLIST"

 

실행이 성공한 것을 확인하고는 새 터미널을 켜서 ec2에 접속한 다음 atlantis가 4141 포트를 점유하고 있는지 확인한다

ss -tnlp

정상적으로 실행되고 있다면 등록한 URL을 크롬에 복붙해서 접속했을 때 아래와 같은 아틀란티스 화면이 뜬다

이쯤에서 아까만든 웹훅으로 돌아와보자. 웹훅을 생성하던 시점에는 Atlantis가 배포되지 않았으므로 502 에러가 떴었지만 이제 Atlantis 가 동작하고 있으므로 redeliver 은 성공적으로 수행된다

 

 

 

 

Atlantis GitOps 실습

이제 Atlantis는 준비되었으니 해당 repo에 테라폼 코드를 올려보자. (클론하는 레포는 위에서 생성한 내 레포여야 한다)

# git clone
git clone https://github.com/HitHereX/t101-cicd && cd t101-cicd && tree

# feature branch 생성
git branch test && git checkout test && git branch

# main.tf 파일 작성
echo 'resource "null_resource" "example" {}' > main.tf

# add commit push
git add main.tf && git commit -m "add main.tf" && git push origin test

 

++DEBUG: fatal: not a valid object name: 'main'

main 브랜치에 아무것도 없어서 발생하는 에러로 initial commit 을 해주면 해결된다고 했으나 커밋할 게 없었기 때문에 readme.md를 만들어 main에 commit 하고 나서 test branch 를 만들어 주는 것으로 해결했다

null resource 지만 어쨌든 브랜치에 커밋을 했으니 PR 시에 자동으로 배포되는지를 확인해보자.

main ← test 브랜치로 PR을 생성한다 (내용은 필요없음)

 

 

생성하면 자동으로 plan이 실행되고 lock이 걸린다

github comment로 atlantis apply 를 주면 apply 가 실행되어 변경된 테라폼 코드대로 리소스가 배포된다

(의외로) merge 여부와는 상관이 없었다.

 

 

 

왼쪽은 PR 후 apply 전, 오른쪽은 apply 후의 atlantis 화면이다.

PR과 함께 plan 이 자동으로 실행되고 tfstate 가 잠긴다(lock)

comment로 apply 하면 이 내용이 반영되는 것을 볼 수 있다

 

 

apply 를 눌러보면 이렇게 콘솔에서 plan, apply 한 내용이 나온다

 

 

다른 리소스들도 배포하는 데 유용하게 사용할 수 있을 것 같다

apply 후 merge 를 별도로 수행해야 한다는 점은 아쉽지만 팀 내 룰으로 해결할 수 있는 부분일 것 같다.

 

 

 

리소스 삭제

아래 코드는 cloudformation으로 배포한 atlantis 리소스를 삭제한다. 깃헙 레포랑 키도 잘 지우도록 하자

aws cloudformation delete-stack --stack-name t101

 

혼자라면 아마 절대 못 했을 내용인데 CloudNet 팀의 스터디로 많은 내용을 빠르게 받아들일 수 있었다!

항상 진심으로 감사드립니다