[Terraform] 설치형 Grafana 인스턴스 1대 띄우기

컴퓨터공학/IaC

2023. 07. 15.

설치형 Grafana 인스턴스 1대 띄우기

Terraform 카테고리는 CloudNet 가시다 님 기획으로 진행되는

<Terraform101 스터디 2기>

공부 내용과 과제 수행을 기록하기 위해 마련되었습니다.

스터디 일람

주차 주제 과제 내용 깃허브
1주차 (23.07.03 ~ 09) 테라폼 기본 사용 1/3 - 링크
⛳ 2주차 (23.07.10 ~ 16) 테라폼 기본 사용 2/3
data, locals, variable, for loops
커스텀 VPC에 EC2 띄어보기 링크

 

개요

이번 주는 커스텀 VPC에 EC2를 띄어보는 과제를 수행하게 되었습니다.

 

저는 요즘들어 Grafana 대시보드에 텔레메트리 시각화를 구성하는 데 매진하고 있는데요. 그래서인지 단순하게 EC2를 띄우기 보단, 설치형 Grafana 인스턴스를 실행해 보는 것으로 과제를 변형하여 진행하고 싶었습니다~

목표

  1. VPC와 퍼블릭 서브넷을 생성하고, 그 위에 설치형 Grafana 인스턴스를 실행합니다.
  2. IaC 템플릿에 정적인 데이터를 등장시키기 보다는, 변경의 유연성을 위해 가능한 동적으로 데이터를 주입합니다.
  3. 테라폼의 data, variable, local 그리고 반복문 문법을 사용하여 참조할 각종 데이터를 구성합니다.

세부 목표

  • 인스턴스 타입 필터링
    • EC2 인스턴스 타입을 입력받고, validation을 사용해 특정 타입만 필터링 해보기.
  • 가용 영역의 선정
    • 지정한 인스턴스 타입을 쓸 수 없는 AZ에 서브넷을 만드는 실수를 방지하기.
  • 서브넷 및 라우팅 반복 설정
    • 반복문을 사용해 퍼블릭 서브넷들과, 라우트 테이블 연결을 명세하기.
  • 보안 그룹
    • 서울 리전의 Instance Connect CIDR 주소만 SSH(22번)에 접속 가능함.
    • 나의 공인IP만 Grafana 서비스(3000번)에 접속 가능함.
  • EC2 UserData 파일
    • userdata.sh 파일을 읽어들여 EC2 UserData를 설정하기.

디렉토리 구성

tree - 템플릿 파일들
tree - 템플릿 이외

템플릿 내용

provider.tf
terraform {
  required_providers {
    jq = {
      source  = "massdriver-cloud/jq"
      version = "0.2.0"
    }
  }
}

provider "jq" {}

provider "aws" {
  region = "ap-northeast-2"
}

이번 과제에는 AWS 말고도 특이한 프로바이더 jq가 사용됩니다.

AWS Instance Connect 서비스의 IP 주소록에서 서울 리전(ap-northeast-2) 부분만 골라내기 위해 jq 신택스를 이용할 예정입니다.

sub.tf
variable "instance_type" {
  description = "Grafana EC2 instance type."
  type        = string

  validation {
    condition     = can(regex("^[t|m].+\\.(\\bmicro\\b|\\bsmall\\b|\\bmedium\\b|\\blarge\\b)$", var.instance_type))
    error_message = "Available families are t or m, and sizes are micro, small, medium or large."
  }
}

data "aws_region" "current" {}

data "aws_ec2_instance_type_offerings" "azs" {
  location_type = "availability-zone"
  filter {
    name   = "instance-type"
    values = [var.instance_type]
  }
}

data "aws_ami" "latest_amzn2" {
  owners      = ["amazon"]
  most_recent = true

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm*"]
  }
}

data "local_file" "userdata" {
  filename = "${path.module}/userdata.sh"
}

data "http" "public_ip" {
  url = "http://icanhazip.com"
}

data "http" "instance_connect_ip_ranges" {
  url = "https://raw.githubusercontent.com/joetek/aws-ip-ranges-json/master/ip-ranges-ec2-instance-connect.json"
}

data "jq_query" "instance_connect_regional_ip_range" {
    data = data.http.instance_connect_ip_ranges.response_body
    query = ".prefixes[] | select(.region==\"${data.aws_region.current.name}\")  | .ip_prefix"
}

locals {
  subnets = {
    for i, az in data.aws_ec2_instance_type_offerings.azs.locations : "pubsub_${i}" => {
      az   = az
      cidr = "10.254.${i}.0/24"
    }
  }

  lastest_amazon_linux2 = data.aws_ami.latest_amzn2.id
  userdata_content = data.local_file.userdata.content

  my_public_ip = "${chomp(data.http.public_ip.response_body)}/32"
  instance_connect_ip = trim(data.jq_query.instance_connect_regional_ip_range.result, "\"")
}

output "subnets" {
  value = local.subnets
}

output "userdata" {
  value = data.local_file.userdata.content
}

리소스들이 참조할 데이터를 단일 위치에 모아두기 위해 sub.tf 를 작성했습니다. 이곳에 변수, 데이터소스, 로컬 값들을 선언해 놓았습니다.

=======

  • 변수 instance_type: Grafana 인스턴스 타입을 입력 받습니다.

=======

  • 데이터소스 aws_region: 현재 AWS 리전 이름을 제공합니다.
  • 데이터소스 aws_ec2_instance_type_offerings: 특정 인스턴스 타입을 지원하는 가용 영역 정보를 제공합니다.
  • 데이터소스 aws_ami : 최신 아마존 리눅스2 AMI 정보를 제공합니다.
  • 데이터소스 local_file: EC2 UserData 스크립트 파일을 제공합니다.
  • 데이터소스 http 2개: 각각 나의 공인IP와, Instance Connect IP 주소록을 제공합니다.
  • 데이터소스 jq_query: Instance Connect IP 주소록에서 서울(ap-northeat-2) 주소만을 선택해 제공합니다.

=======

  • 로컬 subnets: 지정한 인스턴스 타입을 지원하는 서브넷을 생성할 때 쓸 명세 객체입니다.
  • 로컬 lastest_amazon_linux: 최신 아마존 리눅스2 AMI ID 입니다.
  • 로컬 userdata_content: EC2 UserData 스크립트 내용입니다.
  • 로컬 my_public_ip: 나의 공인IP입니다.
  • 로컬 instance_connect_ip: 서울 리전의 Instance Connect 서비스의 CIDR입니다.

=======

vpc.tf
resource "aws_vpc" "grafana" {
  cidr_block = "10.254.0.0/16"

  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "vpc-demo-grafana"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.grafana.id

  tags = {
    Name = "igw-demo-grafana"
  }
}

resource "aws_subnet" "pubsub" {
  for_each   = local.subnets
  vpc_id     = aws_vpc.grafana.id
  
  availability_zone = each.value.az
  cidr_block = each.value.cidr

  map_public_ip_on_launch = true

  tags = {
    Name = "sbn-${each.key}"
  }
}

resource "aws_route_table" "route" {
  vpc_id = aws_vpc.grafana.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }

  tags = {
    Name = "rt-demo-grafana"
  }
}

resource "aws_route_table_association" "route_asso" {
  for_each       = aws_subnet.pubsub
  subnet_id      = each.value.id
  route_table_id = aws_route_table.route.id
}

vpc.tf는 VPC, 서브넷, 라우트 테이블 등 네트워크 자원을 정의합니다.

sg.tf
resource "aws_security_group" "sg" {
  name        = "seg-demo-grafana"
  description = "seg-demo-grafana"
  vpc_id      = aws_vpc.grafana.id
}

resource "aws_security_group_rule" "ssh_rule" {
  type              = "ingress"
  description       = "SSH"
  from_port         = 22
  to_port           = 22
  protocol          = "tcp"
  cidr_blocks       = [local.instance_connect_ip]
  security_group_id = aws_security_group.sg.id
}

resource "aws_security_group_rule" "grafana_rule" {
  type              = "ingress"
  description       = "Grafana from MZC"
  from_port         = 3000
  to_port           = 3000
  protocol          = "tcp"
  cidr_blocks       = [local.my_public_ip]
  security_group_id = aws_security_group.sg.id
}

resource "aws_security_group_rule" "outbound_rule" {
  type              = "egress"
  description       = "All traffic"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.sg.id
}

sg.tf에 보안 그룹, 인바운드 규칙 2개, 아웃바운드 규칙 1개를 작성했습니다. 3000번 Grafana 포트는 나의 공인IP만 접근 가능하며, 22번 SSH 포트는 서울 리전 Instance Connect 프락시만 접근 가능합니다.

ec2.tf
resource "aws_instance" "grafana" {
  depends_on = [aws_internet_gateway.igw]

  subnet_id     = aws_subnet.pubsub["pubsub_0"].id
  ami           = local.lastest_amazon_linux2
  instance_type = var.instance_type

  vpc_security_group_ids      = [aws_security_group.sg.id]
  user_data                   = local.userdata_content
  user_data_replace_on_change = true

  tags = {
    Name = "ec2-demo-grafana"
  }
}

output "public_ip" {
  value = format("http://%s:3000", aws_instance.grafana.public_ip)
}

ec2.tf는 Grafana 인스턴스를 명세합니다. Grafana 서비스는 :3000번 포트를 리슨하게 되므로 이에 연계하는 엔드포인트 주소를 Output으로 산출하도록 했습니다.

userdata.sh
#!/bin/bash
yum update -y
yum install -y https://dl.grafana.com/oss/release/grafana-10.0.2-1.x86_64.rpm

systemctl daemon-reload
systemctl start grafana-server
systemctl enable grafana-server.service

이 스크립트는 Grafana 인스턴스를 초기화 해주는 EC2 UserData 스크립트입니다. grafana-10.0.2 버전을 설치하고 grafana-server 서비스를 등록하는 간단한 절차입니다.

이상으로 모든 템플릿 내용을 게시했습니다.

인스턴스 타입 필터링

입력 변수 유효성 검사(Input variable validation)

 

2주차 스터디에서는 변수(variable) 선언에 validation 블록을 추가해 변수값 검증이 가능하다 배웠습니다.

덕분에 인스턴스 타입 유효성 검사를 커스터마이징 해보려 합니다.

  • validation 블록
    • condition 속성 - 입력 변수의 값을 검증하는 규칙입니다. true 또는 false를 반환해야 합니다. (필수)
    • error_message 속성 - condition 결과가 false일 때 출력할 오류 메시지입니다. (필수)
  • 같이 보기
    • regex() 함수 - 정규표현식으로 문자열을 검사하고 매치 스트링을 반환합니다. can 함수를 곁들여서 정규식에 일치하지 않는 경우의 오류를 내뱉게 할 수 있습니다.
    • can() 함수 - 입력된 표현식을 평가하였을 때 오류 없이 수행되면 true, 그렇지 않으면 false를 반환합니다.

저는 Grafana 인스턴스의 타입이 t 또는 m 패밀리micro, small, medium, large 사이즈에만 국한되었으면 했습니다.

이를 정규표현식으로 구현합니다. can() 내에 regex() 를 집어넣고 인스턴스 타입이 식에 맞아 떨어지는지 확인합니다.

 

(sub.tf)

variable "instance_type" {
  description = "Grafana EC2 instance type."
  type        = string

  validation {
    condition     = can(regex("^[t|m].+\\.(\\bmicro\\b|\\bsmall\\b|\\bmedium\\b|\\blarge\\b)$", var.instance_type))
    error_message = "Available families are t or m, and sizes are micro, small, medium or large."
  }
}

가용 영역의 선정

특정 인스턴스 타입은 일부 가용 영역(Availabilty Zone)에서 선택 불가합니다. 대표적인 타입이 t2.micro 입니다. 프리티어 계정에서 월 750hr 무료로 제공되나, 서울 리전은 ap-northeast-2a, 2c 가용 영역에서만 선택 가능합니다.

 

이 제약을 명심하지 않으면 쓸모없는 영역에 서브넷을 생성할 수 있습니다. EC2 핸즈온을 해볼 때 번번히 겪어보던 실수이지요.

가용 영역의 인스턴스 타입 지원여부 알아보기

[AWS CLI/Console] describe-instance-type-offerings

ec2의 describe-instance-type-offerings CLI로 t2.micro 타입이 지원되는 가용 영역을 알아봅시다. 다음처럼 수행합니다.

aws ec2 describe-instance-type-offerings --filters 'Name=instance-type,Values=t2.micro' --query 'InstanceTypeOfferings[].Location' --location-type availability-zone

수행 결과로 ap-northeast-2a ,2c 영역이 반환되는 걸 볼 수 있습니다.

[
    "ap-northeast-2a",
    "ap-northeast-2c"
]

이 내용은 EC2 콘솔> Instance Types 페이지에 방문해서도 확인 가능합니다.

t2.micro는 ap-northeast-2a, 2c 영역에서만 쓸 수 있어요

[Terraform] aws_ec2_instance_type_offerings

테라폼에서는 describe-instance-type-offerings CLI에 대응하는 aws_ec2_instance_type_offerings 데이터소스를 사용해 볼 수 있겠습니다.

 

(sub.tf)

data "aws_ec2_instance_type_offerings" "azs" {
  location_type = "availability-zone"
  filter {
    name   = "instance-type"
    values = "t2.micro"
  }
}

locals {
  subnets = {
    for i, az in data.aws_ec2_instance_type_offerings.azs.locations : "pubsub_${i}" => {
      az   = az
      cidr = "10.254.${i}.0/24"
    }
  }
}

이렇게 subnets 객체를 만들어서 출력해 본 모양은 어떨까요?

❯ echo local.subnets | terraform console

(⬇️)

{
    "pubsub_0" = {
        "az" = "ap-northeast-2c"
        "cidr" = "10.254.0.0/24"
    }
    "pubsub_1" = {
        "az" = "ap-northeast-2a"
        "cidr" = "10.254.1.0/24"
  }
}

서브넷 및 라우팅 반복 설정

로컬에 선언한 subnets 객체를 for_each로 훑으면 ap-northeat-2a, 2c 위에 서브넷을 정의할 수 있습니다. 

 

(vpc.tf)

resource "aws_subnet" "pubsub" {
  for_each   = local.subnets
  vpc_id     = aws_vpc.grafana.id
  cidr_block = each.value.cidr
  
  availability_zone = each.value.az
  map_public_ip_on_launch = true

  tags = {
    Name = "sbn-${each.key}"
  }
}

pubsub 내용대로 실제 생성된 서브넷을 VPC 콘솔에서 확인해 볼 수 있었습니다.

서브넷 2개가 제대로 생성되었네요~

pubsub 서브넷들과 퍼블릭 라우트 테이블을 연결할 때도 for_each 구문은 구성을 편하게 도와줍니다.

resource "aws_subnet" "pubsub" {
  for_each   = local.subnets
  vpc_id     = aws_vpc.grafana.id
  cidr_block = each.value.cidr

  availability_zone = each.value.az
  map_public_ip_on_launch = true

  tags = {
    Name = "sbn-${each.key}"
  }
}

resource "aws_route_table" "route" {
  vpc_id = aws_vpc.grafana.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }

  tags = {
    Name = "rt-demo-grafana"
  }
}

resource "aws_route_table_association" "route_asso" {
  for_each       = aws_subnet.pubsub
  subnet_id      = each.value.id
  route_table_id = aws_route_table.route.id
}

Instance Connect 허용 보안그룹 구성하기

이번 과제에서는 EC2 Keypair를 생성하는 과정을 일부러 빼 보았습니다.

 

인스턴스에 퍼블릭 키가 보관되어 있지 않기에 Key 기반 SSH 인증이 불가하며, 따라서 제 컴퓨터에서 SSH 접속은 불가합니다. 하지만 AWS는 다행히도 EC2 인스턴스 접속 방식을 2가지 더 지원합니다.

  • Systems Manager - Session Manager
  • EC2 Instance Connect

Session Manager 방식은 EC2에서 SSM 서비스로 아웃바운드 트래픽을 개시하여 쉘 연결을 실현합니다. 따라서 보안 그룹의 아웃바운드 규칙은 관련 SSM 서비스 엔드포인트 3곳을 막으면 안 됩니다.

 

뿐만 아니라, EC2가 SSM의 관리 노드가 되려면 특정 권한을 인가받아야 합니다. 일반적으로 인스턴스 프로필에 IAM Role을 붙여서 인가해 주게 됩니다.

 

EC2 Instance Connect 방식은 Amazon EC2 콘솔 화면에서 즉시 나의 EC2로 손쉽게 액세스를 제공합니다. 이 경우 AWS 프락시가 쉘 접속을 개시하므로, EC2 보안 그룹은 해당 프락시 CIDR의 인바운드 접속을 허용해 주어야 합니다.

공인IP가 없는 프라이빗 인스턴스에게 배스천 터널링 없이 직접 접근할 때 세션 매니저가 주로 거론되지만,

EC2 Instance Connect Endpoint가 출시되며 후자의 방식도 이용 가능해졌습니다. 다만 관리 용도로 써야하지, 데이터 업로드 용으로 쓰기엔 대역폭 제한이 있어 부적절합니다.

 

EC2 Instance Connect Endpoint는 AWS VPC 콘솔> Endpoints 메뉴에서 생성 가능합니다.

[Shell/JQ] 서울 리전의 Instance Connect 주소 범위 알아내기

위쪽 링크에 첨부한 Instance Connect 주소록은 모든 리전의 정보가 표시되어 있습니다. 서울 리전(ap-northeast-2)의 주소만 건져내기 위해 jq 쿼리를 날려봅시다.

> curl https://raw.githubusercontent.com/joetek/aws-ip-ranges-json/master/ip-ranges-ec2-instance-connect.json | jq -r '.prefixes[] | select(.region=="ap-northeast-2")  | .ip_prefix'

13.209.1.56/29

정답은 13.209.1.56/29로 군요.

[Terraform] jq 프로바이더와 jq_query 데이터소스 사용해 보기

이번 과제는 Massdriver, Inc.가 기여한 jq 프로바이더를 참조합니다. provider.tf에서 확인 가능한데요.

 

이 프로바이더가 제공하는 jq_query 데이터소스는 쉘에서 쓰던 jq를 HCL에서도 흉내내고자 채택했습니다. 

 

(sub.tf)

data "http" "instance_connect_ip_ranges" {
  url = "https://raw.githubusercontent.com/joetek/aws-ip-ranges-json/master/ip-ranges-ec2-instance-connect.json"
}

data "jq_query" "instance_connect_regional_ip_range" {
  data  = data.http.instance_connect_ip_ranges.response_body
  query = ".prefixes[] | select(.region==\"${data.aws_region.current.name}\")  | .ip_prefix"
}

locals {
  instance_connect_ip = trim(data.jq_query.instance_connect_regional_ip_range.result, "\"")
}
  • Instance Connect 주소록을 http 데이터소스로 긁어오고
  • jq_query 데이터소스로 서울 리전 주소만 뽑아냅니다.
  • result 속성을 참조하면 쿼리 결과를 얻을 수 있습니다.

 

👍 쉘에서는 jq -r 옵션을 주어 JSON 입력을 raw 데이터로 정제했지만, jq_query 데이터소스에는 관련 옵션이 없습니다. 결과를 쌍따옴표가 감싸고있으니 trim() 함수로 제거해줍니다.

로컬에 선언한 instance_connect_ip 값을 참조해서 보안그룹을 뚫어 주려면 아래처럼 작성합니다.

 

(sg.tf)

resource "aws_security_group" "sg" {
  name        = "seg-demo-grafana"
  description = "seg-demo-grafana"
  vpc_id      = aws_vpc.grafana.id
}

resource "aws_security_group_rule" "ssh_rule" {
  type              = "ingress"
  description       = "SSH"
  from_port         = 22
  to_port           = 22
  protocol          = "tcp"
  cidr_blocks       = [local.instance_connect_ip]
  security_group_id = aws_security_group.sg.id
}

나의 공인IP 허용하는 보안그룹 구성하기

이미 작성한 컨텐츠와 중복되므로 참조 부탁드립니다.

결과 보고

Grafana 접속 화면

EC2 인스턴스의 공인IP:3000에 접속하여 성공적으로 Grafana 서버 실행을 확인할 수 있었습니다.

설치형 대시보드를 손쉽게 구성해 주는 IaC 템플릿을 제 인벤토리에 갖추게 되어 기쁩니다.

Grafana 초기 계정은 ID: admin \ PW: admin 입니다.
Grafana 로그인 후 메인화면 입니다.

Instance Connect 접속 화면

서울 리전의 Amazon EC2 콘솔에서 Connect 버튼클릭해 쉘 접속이 정상적으로 가능합니다.

Amazon EC2 콘솔> Connect 버튼 클릭!
Instance Connect 접속 성공 화면

이상으로 CloudNet Terraform Study 2기 - 2주차 과제를 완료했습니다. 

데이터 소스, 로컬, 변수, 반복문을 배우고 충분히 활용해 볼 수 있던 기회였습니다!

'컴퓨터공학 > IaC' 카테고리의 다른 글

[Terraform] Provisioner  (0) 2023.07.23
[AWS][CloudFormation] CFN 스택 자원 보호하기  (0) 2022.09.23