CloudFormation을 이용해서 IaC 입문해보기

본 포스트는 IaC에 입문하시는 분들을 위해 작성되었습니다. 또한 금일 예정된 Developers.IO Korea Online에서 다룰 내용에 관해 작성하였습니다. Infrastructure as code가 무엇인지, CloudFormation을 활용하여 코드를 작성하고 CLI를 통해 AWS에 리소스를 생성하는 것까지 실습합니다!
2020.07.01

들어가며

안녕하세요! 클래스메서드 신입 엔지니어 임홍기입니다. 입사한지 벌써 3달이 되었네요.
오늘은 지금까지 공부해온 인프라 지식을 코드로 구성해보는 IaC에 대해서 알아보겠습니다.

해당 내용은 7월 1일 19시에 예정되어있는 Developers.IO Korea Online에서도 다룰 예정이니 관심 있으시면 참여 부탁드립니다!

목차

  • IaC란?
  • CloudFormation이란?
  • CloudFormation을 이용해서 구성하는 방법

목차는 크게 3가지이며 왼쪽에 있는 목차화면을 통해 이동하는 것도 가능합니다!

IaC란?

IaC란 Infrastructure as Code의 약자이며, AWS의 환경이나 OS등의 인프라를 구성할 때
코드 기반으로 작성하여 관리하거나 갱신하는 방식입니다.

IaC의 배경

최근, 클라우드 기술의 발전에 의해 인프라 지식이 전무한 사람이라도
EC2 인스턴스 하나를 만드는 것쯤은 몇 번의 클릭만으로 해결할 수 있는 시대가 왔습니다! (저도 이 매력에 AWS에 빠지게 되었습니다 ㅎㅎ)

goodnews

하지만, 구성해야 할 인프라 구조가 점점 복잡해지거나, 협업해야 할 때, 이전 구성에 대해 잊어버렸을 때는 어떻게 해야 할까요?

but

IaC는 이러한 문제의 해결을 위해 등장했습니다.

IaC의 이점

  1. 코드로 관리하기 때문에, 한눈에 모든 설정을 확인할 수 있습니다. (손으로 구성할 때 보다 실수가 적어짐)
  2. Github등의 버전 관리 서비스를 이용하면, 다른 사람과 협업하거나, 커밋로그로 변경을 관리할 수 있습니다.
  3. 템플릿으로 관리함으로써, 어떤 환경에서도 같은 구성으로 테스트할 수 있습니다.
  4. 템플릿으로 배포된 리소스들은 추적되며, 템플릿을 삭제하는 것만으로 생성된 모든 리소스를 완벽하게 삭제할 수 있습니다.

IaC의 단점

  1. 구성에 따라 다양한 툴들을 사용할 수 있어야 하여 러닝코스트가 높습니다. (사람 의존적)
  2. GUI 환경에서 클릭해서 설정하는 것이 더 빠를 수도 있습니다.
  3. IaC로 생성된 리소스는 기본적으로 IaC로 관리해야 하며, GUI에서 건드리면 안 됩니다. (스택 업데이트시 코드와 환경이 다르면 롤백이 발생)

CloudFormation이란?

코드(JSON/YAML)로 정의한 AWS의 리소스를 자동으로 프로비저닝 해주는 AWS의 툴이자 서비스입니다.
코드로 작성한 리소스 파일을 템플릿이라고 하며, 생성된 리소스를 스택이라고 부릅니다.

템플릿에 리소스를 정의해두면, 서로 연계되도록 구성되며, 삭제할때도 스택을 삭제하는 것으로 모든 리소스를 제거할 수 있습니다.

무엇보다, CloudFormation의 이용요금은 무료입니다.
CloudFormation으로 생성한 리소스의 사용량만큼 과금되는 것이 특징입니다. (매니지먼트 콘솔에서 생성한 리소스의 과금 방식과 동일)

CloudFormation의 동작 원리

CFn1

  1. 코드(JSON/YAML)로 템플릿을 작성합니다.
  2. 정의한 템플릿을 S3에 업로드 합니다.
  3. 정의한 코드를 CloudFormation이 읽고
  4. 실행하는 것으로 정의한 환경이 AWS상에 구성됩니다.

CloudFormation 템플릿 구조

AWSTemplateFormatVersion: "version date"
Description:
  String
Metadata:
  template metadata
Parameters:
  set of parameters
Mappings:
  set of mappings
Conditions:
  set of conditions
Resources:
  set of resources
Outputs:
  set of outputs

resource1

  • AWSTemplateFormatVersion : 템플릿이 따르는 버전, 현재 2010-09-09 버전이 유일
  • Description : 템플릿의 설정을 기술하는 부분, 항상 템플릿 버전 다음에 정의
  • Parameters : 스택 생성 및 업데이트시 템플릿에 전달하는 값

resource2

  • Mappings : 조건부 파라미터값을 지정하는 데 사용할 수 있는 Key-Value 값의 매핑 리전별로 리소스를 달리할 시 사용

resource3

  • Resources : AWS 리소스 및 해당 리소스의 속성을 지정

resource4

  • Outputs : 리소스 생성 후 받을 결과에 대해 정의
  • Metadata : 템플릿에 대한 추가 정보
  • Conditions : 스택 생성 또는 업데이트 시 특정 리소스 속성에 값이 할당되는지의 여부를 제어, 스택 환경이 prod인지 test인지에 따라 달라지는 리소스를 조건부로 생성할 때 사용

참고 : AWS CloudFormation 템플릿 구조

CloudFormation을 이용해서 구성하는 방법

2가지 패턴의 구성방법을 이용하여 실제로 구성해보도록 하겠습니다.

샘플 템플릿 이용

먼저 샘플 템플릿을 이용하여 빠르게 구성하는 방법을 알아보겠습니다.

PT1

매니지먼트 콘솔창에서 CloudFormation으로 이동한 뒤 Create stack에 With new resources를 선택합니다.

PT1-2

Use a sample template에서 구성해보고자하는 스택을 선택합니다.
여기선 LAMP Stack을 선택해서 구성해보겠습니다.

PT1-3

Stack name과 Parameters에 요구되는 값을 넣어주고 상세 설정을 한 뒤, Review 화면에서 설정한 템플릿을 확인하고 다음으로 넘어가는 것으로 샘플 템플릿을 이용한 구성은 끝이 납니다.

PT1-4

짧으면 수십초에서 수십분 기다리면 리소스들이 순서대로 생성됩니다.
정의한 스택 이름의 Status가 CREATE_COMPLETE가 되면 성공적으로 리소스들이 생성되었다는 의미입니다.

PT1-5

생성한 리소스들은 매니지먼트 콘솔창에서도 확인할 수 있습니다.

직접 템플릿 작성 및 업로드

이번에는 직접 템플릿을 작성하고, 업로드 하는 방법을 알아보겠습니다.
처음부터 작성하는 것도 나쁘지 않지만, 보통은 필요한 구성의 템플릿을 찾아서 수정하는 방법으로 작성을 많이 한다고 합니다!
이번에 작성한 예제는 다중 가용영역 LAMP스택 템플릿에 S3와 RDS를 추가로 정의해서 작성했습니다.

참고 템플릿

  1. 샘플 템플릿
  2. 템플릿 조각

등을 이용해서 구성시 필요한 요소들을 적재적소로 사용하면 처음부터 작성하는 것 보다 편하게 작업하실 수 있습니다.

구성 환경 아키텍처

Architecture

위의 구성대로 코드를 작성해 보았습니다.
작성한 코드는 코드량이 많은 관계로 가장 아래에 기재해 두었습니다.

CLI를 통한 업로드

작성한 yaml파일은 매니지먼트 콘솔에서 샘플 리소스를 선택한 것처럼 파일을 업로드해서 등록할 수 있지만, 이번엔 CLI 환경에서 템플릿을 업로드 해보겠습니다.

CLI 명령어

템플릿을 CLI를 통해 업로드 하기 위해서는, 매니지먼트 콘솔에서 파라미터값을 입력하듯이, 파라미터값을 넘겨줘야 합니다.

cliparameter

작성한 템플릿으로 스택을 생성하는 명령어

cli-command

  • --stack-name : 스택이름을 정의합니다.
  • --template-body : 작성한 템플릿 파일명
  • --parameters : 템플릿 내에 정의한 파라미터 값
  • --capabilities : AWS 계정의 권한에 영향이 있는 리소스 생성시 사용(여기선 policy와 role을 작성하기 위해 정의)

쉘 스크립트 작성

예제로 작성한 코드는 위의 CLI명령으로 실행할 수 있으며
간단한 쉘 스크립트를 작성하여 실행되게 만들 수도 있습니다.

shell

용도와 파라미터값에 따라 쉘 스크립트를 작성해서, 보다 빠르게 실행할 수 있습니다!

편의상 이번에는 스트링 값으로 입력했지만, DB Password의 설정은 Secrets Manager를 사용해서 arn을 넣어주는게 안전합니다.

생성된 리소스 확인

created-stack

지금까지 위의 아키텍처대로 구성하고 실행했습니다.
약 20분에 걸쳐 정의한 리소스들과 정책이 생성된 것을 확인할 수 있습니다.
생성된 모든 리소스들은 매니지먼트 콘솔에서도 확인할 수 있습니다.

스택 삭제

생성된 스택은 아래의 명령어를 이용하여 생성하는 것과 같이 간단하게 삭제할 수 있습니다.

명령어

$ aws cloudformation delete-stack --stack-name [stackName]

delete-stack

CloudFormation에서 사용가능한 명령어는 CLI cloudformation reference를 참고해 주세요!

ha-lamp-stack.yml

# Template version
AWSTemplateFormatVersion: 2010-09-09
Description: Create a HA, scalable LAMP stack with RDS, S3
# passed to parameter when stack is generated
Parameters:
  VpcId:
    Type: "AWS::EC2::VPC::Id"
  Subnets:
    Type: "List<AWS::EC2::Subnet::Id>"
  KeyName:
    Type: "AWS::EC2::KeyPair::KeyName"
  BucketName:
    Type: String
  # Database parameter
  DBName:
    Default: myDatabase
    Type: String
    MinLength: "1"
    MaxLength: "64"
    AllowedPattern: "[a-zA-Z][a-zA-Z0-9]*"
  DBUser:
    NoEcho: "true"
    Description: Username for MySQL database access
    Type: String
    MinLength: "1"
    MaxLength: "16"
    AllowedPattern: "[a-zA-Z][a-zA-Z0-9]*"
  DBPassword:
    NoEcho: "true"
    Description: Password for MySQL database access
    Type: String
    MinLength: "8"
    MaxLength: "41"
    AllowedPattern: "[a-zA-Z0-9]*"
  DBAllocatedStorage:
    Default: "5"
    Type: Number
    MinValue: "5"
    MaxValue: "1024"
  DBInstanceClass:
    Type: String
    Default: db.t2.micro
    # AllowedValues:
    #   - db.t1.micro, db.t2.micro, db.t2.small, db.t2.medium, db.t2.large ...
  MultiAZDatabase:
    Default: "true"
    Type: String
    # AllowedValues:
    #   - "true", "false"

  # AutoScaling EC2 size Default = 3
  WebServerCapacity:
    Default: "3"
    Type: Number
    MinValue: "3"
    MaxValue: "10"
  InstanceType:
    Type: String
    Default: t2.micro
    # AllowedValues:
    #   - t1.micro, t2.nano, t2.micro, t2.small, t2.medium, t2.large ...
  SSHLocation:
    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})'
Mappings:
  AWSInstanceType2Arch:
    t1.micro:
      Arch: HVM64
    t2.nano:
      Arch: HVM64
    t2.micro:
      Arch: HVM64
    t2.small:
      Arch: HVM64
    t2.medium:
      Arch: HVM64
    t2.large:
      Arch: HVM64
  AWSInstanceType2NATArch:
    t1.micro:
      Arch: NATHVM64
    t2.nano:
      Arch: NATHVM64
    t2.micro:
      Arch: NATHVM64
    t2.small:
      Arch: NATHVM64
    t2.medium:
      Arch: NATHVM64
    t2.large:
      Arch: NATHVM64
  AWSRegionArch2AMI:
    ap-northeast-1:
      HVM64: ami-00a5245b4816c38e6
      HVMG2: ami-09d0e0e099ecabba2
    ap-northeast-2:
      HVM64: ami-00dc207f8ba6dc919
      HVMG2: NOT_SUPPORTED
    ap-northeast-3:
      HVM64: ami-0b65f69a5c11f3522
      HVMG2: NOT_SUPPORTED
Resources:
  # S3 Bucket
  S3Bucket:
    Type: AWS::S3::Bucket
    # DeletionPolicy: Retain 스택 삭제시 버킷 유지
    Properties:
      BucketName: !Ref BucketName
  S3BucketRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: ["sts:AssumeRole"]
            Effect: Allow
            Principal:
              Service: [s3.amazonaws.com]
  BucketBackupPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Action: ["s3:GetReplicationConfiguration", "s3:ListBucket"]
            Effect: Allow
            Resource:
              - !Join ["", ["arn:aws:s3:::", !Ref "S3Bucket"]]
          - Action: ["s3:GetObjectVersion", "s3:GetObjectVersionAcl"]
            Effect: Allow
            Resource:
              - !Join ["", ["arn:aws:s3:::", !Ref "S3Bucket", /*]]
          - Action: ["s3:ReplicateObject", "s3:ReplicateDelete"]
            Effect: Allow
            Resource:
              - !Join [
                  "",
                  [
                    "arn:aws:s3:::",
                    !Join [
                      "-",
                      [
                        !Ref "AWS::Region",
                        !Ref "AWS::StackName",
                        replicationbucket,
                      ],
                    ],
                    /*,
                  ],
                ]
      PolicyName: BucketBackupPolicy
      Roles: [!Ref "S3BucketRole"]
  ApplicationLoadBalancer:
    Type: "AWS::ElasticLoadBalancingV2::LoadBalancer"
    Properties:
      Subnets: !Ref Subnets
  ALBListener:
    Type: "AWS::ElasticLoadBalancingV2::Listener"
    Properties:
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref ALBTargetGroup
      LoadBalancerArn: !Ref ApplicationLoadBalancer
      Port: "80"
      Protocol: HTTP
  ALBTargetGroup:
    Type: "AWS::ElasticLoadBalancingV2::TargetGroup"
    Properties:
      HealthCheckIntervalSeconds: 10
      HealthCheckTimeoutSeconds: 5
      HealthyThresholdCount: 2
      Port: 80
      Protocol: HTTP
      UnhealthyThresholdCount: 5
      VpcId: !Ref VpcId
      TargetGroupAttributes:
        - Key: stickiness.enabled
          Value: "true"
        - Key: stickiness.type
          Value: lb_cookie
        - Key: stickiness.lb_cookie.duration_seconds
          Value: "30"
  WebServerGroup:
    Type: "AWS::AutoScaling::AutoScalingGroup"
    Properties:
      VPCZoneIdentifier: !Ref Subnets
      LaunchConfigurationName: !Ref LaunchConfig
      DesiredCapacity: !Ref WebServerCapacity
      TargetGroupARNs:
        - !Ref ALBTargetGroup
    CreationPolicy:
      ResourceSignal:
        Timeout: PT5M
        Count: !Ref WebServerCapacity
    UpdatePolicy:
      AutoScalingRollingUpdate:
        MinInstancesInService: "1"
        MaxBatchSize: "1"
        PauseTime: PT15M
        WaitOnResourceSignals: "true"
  # EC2 Auto Scaling 시작 구성
  LaunchConfig:
    Type: "AWS::AutoScaling::LaunchConfiguration"
    Metadata:
      Comment1: Configure the bootstrap helpers to install the Apache Web Server and PHP
      Comment2: >-
        The website content is downloaded from the CloudFormationPHPSample.zip
        file
      "AWS::CloudFormation::Init":
        config:
          packages:
            yum:
              httpd: []
              php: []
              php-mysql: []
          files:
            /var/www/html/index.php:
              content: !Join
                - ""
                - - |
                    <html>
                  - |2
                      <head>
                  - |2
                        <title>AWS CloudFormation PHP Sample</title>
                  - |2
                        <meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1">
                  - |2
                      </head>
                  - |2
                      <body>
                  - |2
                        <h1>Welcome to the AWS CloudFormation PHP Sample</h1>
                  - |2
                        <p/>
                  - |2
                        <?php
                  - |2
                          // Print out the current data and tie
                  - |2
                          print "The Current Date and Time is: <br/>";
                  - |2
                          print date("g:i A l, F j Y.");
                  - |2
                        ?>
                  - |2
                        <p/>
                  - |2
                        <?php
                  - |2
                          // Setup a handle for CURL
                  - |2
                          $curl_handle=curl_init();
                  - |2
                          curl_setopt($curl_handle,CURLOPT_CONNECTTIMEOUT,2);
                  - |2
                          curl_setopt($curl_handle,CURLOPT_RETURNTRANSFER,1);
                  - |2
                          // Get the hostname of the intance from the instance metadata
                  - |2
                          curl_setopt($curl_handle,CURLOPT_URL,'http://169.254.169.254/latest/meta-data/public-hostname');
                  - |2
                          $hostname = curl_exec($curl_handle);
                  - |2
                          if (empty($hostname))
                  - |2
                          {
                  - |2
                            print "Sorry, for some reason, we got no hostname back <br />";
                  - |2
                          }
                  - |2
                          else
                  - |2
                          {
                  - |2
                            print "Server = " . $hostname . "<br />";
                  - |2
                          }
                  - |2
                          // Get the instance-id of the intance from the instance metadata
                  - |2
                          curl_setopt($curl_handle,CURLOPT_URL,'http://169.254.169.254/latest/meta-data/instance-id');
                  - |2
                          $instanceid = curl_exec($curl_handle);
                  - |2
                          if (empty($instanceid))
                  - |2
                          {
                  - |2
                            print "Sorry, for some reason, we got no instance id back <br />";
                  - |2
                          }
                  - |2
                          else
                  - |2
                          {
                  - |2
                            print "EC2 instance-id = " . $instanceid . "<br />";
                  - |2
                          }
                  - '      $Database   = "'
                  - !GetAtt
                    - MySQLDatabase
                    - Endpoint.Address
                  - |
                    ";
                  - '      $DBUser     = "'
                  - !Ref DBUser
                  - |
                    ";
                  - '      $DBPassword = "'
                  - !Ref DBPassword
                  - |
                    ";
                  - |2
                          print "Database = " . $Database . "<br />";
                  - |2
                          $dbconnection = mysql_connect($Database, $DBUser, $DBPassword)
                  - |2
                                          or die("Could not connect: " . mysql_error());
                  - |2
                          print ("Connected to $Database successfully");
                  - |2
                          mysql_close($dbconnection);
                  - |2
                        ?>
                  - |2
                        <h2>PHP Information</h2>
                  - |2
                        <p/>
                  - |2
                        <?php
                  - |2
                          phpinfo();
                  - |2
                        ?>
                  - |2
                      </body>
                  - |
                    </html>
              mode: "000600"
              owner: apache
              group: apache
            /etc/cfn/cfn-hup.conf:
              content: !Join
                - ""
                - - |
                    [main]
                  - stack=
                  - !Ref "AWS::StackId"
                  - |+

                  - region=
                  - !Ref "AWS::Region"
                  - |+

              mode: "000400"
              owner: root
              group: root
            /etc/cfn/hooks.d/cfn-auto-reloader.conf:
              content: !Join
                - ""
                - - |
                    [cfn-auto-reloader-hook]
                  - |
                    triggers=post.update
                  - >
                    path=Resources.LaunchConfig.Metadata.AWS::CloudFormation::Init
                  - "action=/opt/aws/bin/cfn-init -v "
                  - "         --stack "
                  - !Ref "AWS::StackName"
                  - "         --resource LaunchConfig "
                  - "         --region "
                  - !Ref "AWS::Region"
                  - |+

                  - |
                    runas=root
              mode: "000400"
              owner: root
              group: root
          services:
            sysvinit:
              httpd:
                enabled: "true"
                ensureRunning: "true"
              cfn-hup:
                enabled: "true"
                ensureRunning: "true"
                files:
                  - /etc/cfn/cfn-hup.conf
                  - /etc/cfn/hooks.d/cfn-auto-reloader.conf
    Properties:
      ImageId: !FindInMap
        - AWSRegionArch2AMI
        - !Ref "AWS::Region"
        - !FindInMap
          - AWSInstanceType2Arch
          - !Ref InstanceType
          - Arch
      InstanceType: !Ref InstanceType
      SecurityGroups:
        - !Ref WebServerSecurityGroup
      KeyName: !Ref KeyName
      UserData: !Base64
        "Fn::Join":
          - ""
          - - |
              #!/bin/bash -xe
            - |
              yum update -y aws-cfn-bootstrap
            - |
              # Install the files and packages from the metadata
            - "/opt/aws/bin/cfn-init -v "
            - "         --stack "
            - !Ref "AWS::StackName"
            - "         --resource LaunchConfig "
            - "         --region "
            - !Ref "AWS::Region"
            - |+

            - |
              # Signal the status from cfn-init
            - "/opt/aws/bin/cfn-signal -e $? "
            - "         --stack "
            - !Ref "AWS::StackName"
            - "         --resource WebServerGroup "
            - "         --region "
            - !Ref "AWS::Region"
            - |+

  # SecurityGroup
  WebServerSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupDescription: Enable HTTP access via port 80 locked down to the ELB and SSH access
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: "80"
          ToPort: "80"
          SourceSecurityGroupId: !Select
            - 0
            - !GetAtt
              - ApplicationLoadBalancer
              - SecurityGroups
        - IpProtocol: tcp
          FromPort: "22"
          ToPort: "22"
          CidrIp: !Ref SSHLocation
      VpcId: !Ref VpcId
  DBEC2SecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupDescription: Open database for access
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: "3306"
          ToPort: "3306"
          SourceSecurityGroupId: !Ref WebServerSecurityGroup
      VpcId: !Ref VpcId
  # Database
  MySQLDatabase:
    Type: "AWS::RDS::DBInstance"
    Properties:
      Engine: MySQL
      DBName: !Ref DBName
      MultiAZ: !Ref MultiAZDatabase
      MasterUsername: !Ref DBUser
      MasterUserPassword: !Ref DBPassword
      DBInstanceClass: !Ref DBInstanceClass
      AllocatedStorage: !Ref DBAllocatedStorage
      VPCSecurityGroups:
        - !GetAtt
          - DBEC2SecurityGroup
          - GroupId
Outputs:
  WebsiteURL:
    Description: URL for newly created LAMP stack
    Value: !Join
      - ""
      - - "http://"
        - !GetAtt
          - ApplicationLoadBalancer
          - DNSName

참고

Troubleshooting
CloudFormation create-stack reference
CloudFormation CLI Command Reference
스택 리소스 삭제 및 업데이트 방지
스택 삭제시 리소스 유지