핸들러는 이벤트 기반으로 동작하는 Task. 예를 들어, 지금까지는 Tasks 내부에 여러 Task를 정의하고 순차적으로 실행이 됐는데, 만약 어떤 Task가 다른 Task에 의존성이 있어야 하는 경우 그 의존성을 만족하여 실행하게 하는 게 쉽지 않다. 이를 해결하는 방법 중 하나가 Handler라고 생각하면 된다.
예시를 들어보자. 만약, 내가 Nginx 서버 설정을 변경했으면 서버 설정이 변경됐으니 Nginx를 재실행해야 변경 사항이 적용되는데 서버 설정을 변경하고 Ansible이 변경을 감지했을 때 재실행하게 하고 싶을 때 Handler를 사용하면 된다.
실습
파일 구조가 다음과 같이 되어 있다.
여기서 default 파일이 Nginx 서버 설정 파일인데 이 파일을 보자.
default
해당 파일을 보면 index에 blue.html 파일이 가장 먼저 있다. 이러면 Nginx는 세 파일 중 blue.html을 뿌려주게 된다.
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index blue.html index.htm index.nginx-debian.html;
server_name _;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
}
Playbook을 실행한 후 확인해 보자.
example.yaml
Ubuntu 대상으로만 진행하는 YAML 파일이고 이 파일을 보면 하단에 "Copy nginx configuration file" Task가 있다. 이때 서버 설정 파일을 복사해서 리모트 호스트에 Nginx 설정 파일이 위치해야 하는 곳에 복사하게 된다.
그럼 Ubuntu의 Public IP로 접속해 보면 다음과 같이 파란 화면으로 보일 것이다. 합리적이다.
그럼 내가 이 상태로 Nginx 설정 파일을 다음과 같이 blue.html이 아닌 red.html로 바꾸고 다시 실행하면 어떻게 될까?
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index red.html index.htm index.nginx-debian.html;
server_name _;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
}
아무런 변화가 일어나지 않을 것이다. 왜냐하면 설정 파일이 변경되어도 Nginx가 재실행되지 않으면 변경 사항을 적용하지 않을 테니까. 그러면 이럴 때 핸들러를 사용하면 된다. 이런 변화가 있을 때 핸들러가 실행되도록 말이다. 그게 바로 YAML 파일에 이 부분이다.
이처럼 Nginx 설정 파일을 복사하는 Task에 notify라는 키를 추가해서 핸들러의 이름을 넣어주면, 이 Task를 작업할 때 Changed라는 상태가 나타나면 핸들러가 트리거 된다. 실행해 보자. 다음과 같이 해당 Task에서 Changed가 발생했고 그에 따라 핸들러를 트리거한다. 그럼 모든 Task가 끝나고 Handler가 실행된다.
이제 다시 들어가서 확인해 보자. 다음과 같이 빨간 화면이 보인다. 이게 핸들러다.
핸들러를 사용할 때 알고 있어야 하는 점
Playbook 내에서 같은 이벤트를 여러번 호출하더라도 동일한 핸들러는 한번만 실행된다.
모든 핸들러는 Playbook 내에 모든 작업이 완료된 후에 실행된다.
핸들러는 이벤트 호출 순서에 따라 실행되는 것이 아니라 핸들러 정의 순서에 따라 실행된다.
나머지는 다 말 그대로이고, 마지막 문장인 핸들러 정의 순서에 따라 실행된다는 것은 무슨말이냐면 핸들러 정의를 다음과 같이했다고 가정하자.
두 개의 핸들러가 이러한 순서대로 정의됐을 때, 내가 만약에 Stop Nginx를 먼저 notify로 호출하고 가장 마지막 Task에서 Restart Nginx 핸들러를 호출했다고 해도 Restart Nginx -> Stop Nginx 순으로 핸들러가 실행된다는 의미이다. 그러니까 이 부분을 주의해야한다.
---
# This is Ansible Playbook
# Playbook: YAML로 정의. 순서대로 정렬된 플레이(작업 목록: Play) 절차.
# Play: 작업 목록(Tasks). 특정 호스트 목록에 대하여 수행
# Task: Ansible의 수행 단위. 애드혹 명령어는 한번에 단일 작업 수행.
# Module: Ansible이 실행하는 코드 단위. 작업에서 모듈을 호출함.
# Collection: 모듈의 집합.
- name: Play 1
hosts: ubuntu
tasks:
- name: "Task 1: Execute command"
command: uptime
- name: "Task 2: Execute script"
script: task2.sh
- name: "Task 3: Install package"
apt:
name: nginx
state: present
update_cache: true
- name: "Task 4: Start nginx service"
service:
name: nginx
state: started
- name: Play 2
hosts: localhost
tasks:
- name: "Task 1: Execute command"
command: whoami
- name: "Task 2: Execute script"
script: task2.sh
우선, Playbook은 YAML 파일 자체라고 보면 된다. 그래서 순서대로 정의한 Play 절차를 의미하고 이 Play는 코드에서 Tasks라고 생각하면 된다. Play는 특정 호스트 목록(그룹)에 대하여 Tasks를 수행한다. Task 하나 하나는 Ansible의 수행 단위이다. Adhoc을 사용했을 땐 한번에 하나씩 수행했지만 여기선, 여러개를 tasks라는 키에 정의하여 사용가능하다. Module은 Ansible이 실행하는 코드 단위이다. 저번 포스팅에서 배운 command, ping 이런것들도 다 모듈이다.
install-nginx.yaml
이 파일이 실제로 Playbook을 사용해보는 첫 YAML 파일이 될 것이다. 각 호스트 그룹 Ubuntu와 Amazon에 대해서 Nginx를 설치하는 작업을 한다. 그래서 글로벌로 패키지를 설치하려면 루트 권한이 필요하기 때문에 become: true 값이 있음을 알 수 있고, Ubuntu에 대해서는 Nginx를 설치하는 Task 하나와 설치한 Nginx를 서비스로 실행하는 Task 하나가 있다. Amazon도 같은 Task가 있지만 그 전에 패키지 매니저인 amazon-linux-extras를 먼저 Enable하는 Task가 추가로 있음을 확인할 수 있다.
---
- name: Install Nginx on Ubuntu
hosts: ubuntu
become: true
tasks:
- name: "Install Nginx"
apt:
name: nginx
state: present
update_cache: true
- name: "Ensure nginx service started"
service:
name: nginx
state: started
- name: Install Nginx on Amazon Linux
hosts: amazon
become: true
tasks:
- name: "Enable Nginx repository provided by Amazon"
command: "amazon-linux-extras enable nginx1"
- name: "Install Nginx"
yum:
name: nginx
state: present
- name: "Ensure nginx service started"
service:
name: nginx
state: started
uninstall-nginx.yaml
이번엔 Uninstall이다. 그러다보니 먼저 서비스를 중지하는 Task가 먼저 나온다. 그리고 absent라는 state를 주어 해당 패키지를 삭제한다. Amazon은 거기에 더불어 Repository를 Disable하는 작업도 있다.
이번엔 ansible-playbook이라는 명령어를 사용한다. 그래서 인벤토리를 지정하고 Playbook을 지정하면 인벤토리에 존재하는 호스트들에 대해 install-nginx.yaml 파일을 수행하라는 의미가 된다.
결과는 다음과 같다. (아래 사진과 백퍼센트 동일하지 않을 수 있다. ok, changed에 대해서)
위에서 부터 하나씩 살펴보자. 우선 순서대로 실행된다고 했기 때문에 먼저 작성한 Ubuntu에 대해서 실행을 먼저 한 모습이다.
Gathering Facts는 기본으로 실행되는 작업이다. Facts는 상세라고 표현하는데 리모트 호스트에 대한 정보를 수집하는 과정이라고 생각하면 된다.
그리고 Install Nginx를 실행한다. 여기서 어떤것은 ok고 어떤것은 changed라고 되어 있다. 이게 어떤 차이냐면, ok는 설치가 되어 있는 경우에 다시 설치할 필요가 없고 그 말은 변경 사항이 없다는 것을 의미하므로 OK로 표현한다. 그러나 Changed는 없던 패키지가 설치가 됐기 때문에 변경사항이 이 리모트 호스트에 있다라고 말해주기 위해 Changed로 표현한다. 그래서 같은 Playbook을 연속해서 실행하더라도 불필요하게 패키지를 계속 설치하지 않게 Ansible이 이미 이 Task를 충족하는지 확인한다. 이를 '멱등성'이라고 표현하는데 Ansible은 멱등성을 보장한다. 그리고 Service 모듈을 통해 Nginx가 실행됐는지 확인 후 실행하지 않았다면 실행하는데, Ubuntu에서는 Nginx를 설치하면 기본으로 실행을 한다. 그래서 OK로 표현한다. Amazon 부분도 같은 맥락으로 보면 될 것 같다.
이제 요약부분이 마지막에 나온다. 전부 문제 없이 원하는 작업을 수행했고, 그렇지 않았다면 unreachable, failed, skipped, ignored 중 하나가 표시된다. 여기서 amazon1에 대해서만 살펴보면 OK가 총 4개고 그 중 Changed가 1개가 있다라고 생각하면 된다. OK 4개에 Changed 1개가 아니라.
그리고 위에서 멱등성을 보장한다고 했다. 진짜 그런지 확인해보려면 다시 한번 실행해보면 된다. 다시 실행해보면 전부 다 OK로 나올것이다.
전부 다 OK로 나올것을 예상했지만 딱 한군데, Amazon에서 Enable Repository 작업이 Changed가 됐다. 이는 왜 그러냐면 기본적으로 Command라는 모듈은 멱등성을 보장하지 않는다. 그도 그럴것이 커맨드를 실행하지 않은 상태에서 실행을 하는데 멱등성이란 게 있을 수가 없다. 그래서 저 부분만 Changed고 설치나 설치 후 실행 작업은 모두 이미 충족된 상태임을 알려주는 OK가 표시됐다.
Adhoc으로 Playbook 정상 수행 확인
Nginx를 설치했으면 잘 설치가 됐는지 확인해보자. 다음 명령어로 간단하게 확인이 가능하다.
ansible -i inventory amazon -m command -a "curl localhost"
인벤토리 파일에 작성된 호스트들 중 Amazon이라는 그룹에 대해서 Command 모듈을 수행하는데 localhost:80에 요청을 날리는 작업을 수행하는 것이다. Nginx가 설치됐다면 기본으로 80 포트에 Nginx 서버가 호스팅된다. 실행해보면 Nginx 기본 페이지가 보여짐을 알 수 있다.
Playbook 실행 (Uninstall Nginx)
이제 Nginx를 Uninstall 해보자. 미리 만들어둔 uninstall-nginx.yaml 파일로 Playbook을 실행하면 된다. 잘 수행됐다. 예상대로 OK와 Changed가 나올곳에 딱딱 나왔다.
이제 실제로 잘 삭제됐는지 확인하기 위해 같은 CURL을 수행해보자.
ansible -i inventory [amazon|ubuntu] -m command -a "curl localhost"
참고로 위 사용 방법과 순서가 뒤죽박죽으로 되어 있다. 즉, 순서에 영향을 받지 않는다는 의미이다. 지금은 인벤토리 파일이 먼저 왔고 모듈이 그 다음에 왔고 호스트 패턴(그룹)이 제일 마지막에 왔음을 확인할 수 있다.
아무튼 이 명령어를 수행하면 다음과 같은 에러를 마주한다. 이전 포스팅에서 설명했지만, Ansible은 SSH로 접속할 수 있어야 하기 때문에 접속 정보가 필요한데, Amazon EC2의 유저 정보는 ec2-user이다. 그러나, 우리가 amazon.inv 파일에서는 별칭이나 ansible_user를 정의하지 않았기 때문에 현재 로컬 사용자로 접속하려고 시도한다. 당연히 없을것이다 해당 유저는. 그래서 문제가 발생한다.
그럼 amazon.inv 파일을 그대로 사용할 경우 유저 정보를 기입해줘야 하는데 다음과 같이 사용가능하다.
ansible -i amazon.inv -m ping all -u ec2-user
-u 옵션을 통해서 유저 정보를 줄 수 있다. 이것을 어떻게 알아냈냐면 그냥 -h 옵션으로 사용가능한 옵션을 확인해보면 된다. 이렇게 수행하면 다음과 같이 정상 결과를 돌려 받는다.
근데, 여기서 정상 결과를 돌려 받은 이유는 나는 ssh-agent라는 것을 사용해서 내 .pem 파일을 SSH 키 보관 장소에 보관했기 때문에 해당 EC2에 접근 가능한 Key pair 정보를 알려주지 않고도 접속이 가능한 것이다. ssh-agent를 사용하지 않는다면 --private-key라는 옵션을 주어서 .pem 파일 경로를 지정해줘야한다. ssh-agent를 사용한다면 .pem 파일을 등록해서 사용하면 굳이 SSH로 접속할 때마다 키 정보를 알려주지 않아도 되기 때문에 편리하다. 다음이 SSH-Agent가 키를 보관하는 저장소에 추가하는 방법이다.
ssh-add -K /path/to/.pem/
참고로 여기서 사용한 ping 모듈은 우리가 흔히 아는 ping이랑 다르다. 여기서 ping 모듈은 하는 작업이 2개다. 1. 대상 호스트에 연결2. 파이썬 사용가능 여부 확인. 이렇게 두 가지를 해주는 모듈이다. 실제로 저 성공 결과 이미지를 보면 discovered_interpreter_python이라는 키에 파이썬 경로가 보여지는것을 알 수 있다. 이 작업을 하는 이유는 'Ansible'을 이용하게 되면 Control Node, Managed Node 이렇게 두 가지가 있다.
Control Node는 지금 이 명령어를 수행하는 로컬 PC를 의미한다. 반면, Managed Node는 Ansible이 작업하는 서버인 AWS EC2를 의미한다. 이 예제에선 Amazon EC2를 말한다고 보면 된다. 당연히 Control Node에는 Ansible이 설치되어 있어야 하고 Managed Node는 파이썬이 설치가 되어 있어야 한다. 그래서 ping 모듈이 파이썬이 설치됐는지 확인한다.
command 모듈
이번엔 command라는 모듈을 사용해보자. 이는 Managed Node 내부에서 주어진 커맨드를 실행하는 모듈이다.
ansible -i vars.inv -m command -a "uptime" ubuntu
이번엔 vars.inv 파일을 사용해서 ansible_user도 작성이 된 파일을 사용하자. 따로 -u 옵션을 사용하기 귀찮으니까.
그리고 command 모듈을 사용해서 "uptime" 이라는 명령어를 ubuntu 그룹에만 수행하자.
잘 수행됐음을 알 수 있다.
apt 모듈
이번엔 Ubuntu의 패키지 매니저인 apt-get을 사용할 수 있는 apt 모듈을 사용해서 패키지를 내려받아보자.
ansible -i vars.inv -m apt -a "name=git state=latest update_cache=yes" ubuntu
저렇게 하면 다음과 같은 에러가 발생할 것이다. 이는 무슨 에러냐면 호스트에 글로벌로 패키지를 설치하려면 기본적으로 root 권한이 있는 사용자가 설치해야 한다. 그러나 지금 vars.inv 파일에 기입된 유저는 ubuntu이므로 아래와 같은 권한 에러가 발생하는 것.
이제 서버 형상 관리 도구인 "Ansible"을 사용해보자. Ansible을 사용하기에 있어 가장 먼저 이해해야 하는 개념은 Inventory이다.
Inventory
인벤토리란 Ansible을 사용하는 것은 특정 서버에 대해 형상 관리를 하기 위함인데 그렇다는 것은 특정 서버에 대한 정보가 필요하다. 그 정보를 관리하는 파일을 'Ansible'에서는 Inventory라고 한다.
이 Inventory는 그룹 기능을 지원하는데 예를 들어 내가 Linux 운영체제의 배포판 중 하나인 Ubuntu 서버를 3개를 관리하고 있다고 하면 Ubuntu라는 그룹을 만들어서 해당 그룹에 3개의 서버를 모두 할당할 수 있다. 그럼 그룹으로 실행해야 하는 명령어나 상태 체크같은 것들이 가능해지니 효율적인 관리가 될 수 있겠다.
이 인벤토리는 크게 두 가지 종류가 있다.
Static Inventory
Dynamic Inventory
Static inventory는 말 그대로 정적 인벤토리고 변경되지 않는 서버에 대한 관리를 할 때 사용한다고 보면 된다. 가장 기본이 되는 방식이고, Dynamic inventory는 AWS와 같은 클라우드 기반 서버의 IP는 수시로 변경될 수 있고 Auto Scaling과 같은 기능을 사용해서 없던 서버가 새로 생기는 경우도 비일비재하다. 이럴 때 동적으로 관리가 가능하게 해주는 방법이 Dynamic inventory라고 보면 되겠다.
Inventory 소스 작성과 이해
그러면 이제 실제로 AWS에 EC2 인스턴스 총 4개를 가지고 연습을 해보자.
우선 이전에 Terraform을 배웠을 때 Network, EC2를 생성하는 코드를 통해 총 4개의 EC2를 만들것이다.
이건 이전에 테라폼에 대한 포스팅에 올라와있으니 참고하면 되고 이미 생성됐다는 가정하에 진행하도록 하겠다.
4개 EC2를 테라폼을 통해 만들고 생성한 Outputs 결과는 다음과 같다.
이 정보들을 가지고 Inventory 파일들을 만들어보자. 우선 내 디렉토리 구조는 다음과 같이 생겼다. 참고로 인벤토리 파일의 확장자는 필요하지 않다. 그저 구분짓기 위해 작성했다고 보면 된다.
amazon.inv
이 파일은 amazon instance의 Public IP가 기록되어 있다.
3.38.149.218
52.78.112.88
ubuntu.inv
이 파일은 ubuntu instance의 Public DNS가 기록되어 있다. 이렇게 IP와 DNS를 사용하는 경우 둘 다 가능하다는 것을 보여주기 위해 작성했다.
이 파일은 IP나 DNS값을 기억하기가 까다롭기 때문에 별칭을 사용해서 해당 값을 치환해 놓은 파일이다. 여기서 별칭은 (amazon1, amazon2, ubuntu1, ubuntu2)가 되고 이 별칭들은 당연히 Unique 값이어야 한다. 그리고 이 별칭에 대해서 ansible_host라는 변수에는 IP나 DNS값이 들어가면 된다. 그럼 이렇게 만들어 놓으면 추후에 3.38.149.218을 지칭하는 대신에 amazon1이라는 별칭으로 표현해주기 때문에 가시성에서 효율적일 수 있다.
이 파일은 변수를 추가하는 파일이다. 그래서 보면 없던 값인 ansible_user라는 값이 있다. 이는 무엇이냐면 Ansible은 Agentless하기 때문에 SSH나 WinRM을 통해 원격으로 명령을 수행하고 특정 작업을 진행한다. 그러려면 접속 유저 정보가 필요하다. Amazon EC2는 기본 사용자가 'ec2-user'이고 Ubuntu는 'ubuntu'이다. 그래서 이런 사용자를 지정하지 않고 만약 Ansible이 어떤 명령을 수행하기 위해 SSH접속을 할 때 로컬 PC의 현재 사용자와 동일한 사용자로 접속하고는 한다. 그러면 문제가 발생하기 때문에 이렇게 ansible_user라는 유저가 필요한 것이다.
Packer로 빌드를 하면 보통은 산출물이 나오게 된다. 이를 Artifact라고 하는데, 이 Artifact를 가지고 후처리기가 또 다른 산출물을 만들게 된다. 그럼 Checksum은 어떤것이냐면, 해시함수를 이용해서 파일의 무결성을 검증하는 용도라고 생각하면 된다. 그래서 데이터 파일이 주어지면 해당 파일을 가지고 md5, sha256 같은 checksum type을 통해 해시값을 구하고 이 파일이 변조된 상태인지 아닌지를 판단할 수 있게 해준다.
Compress
빌드 결과물을 압축해주는 후처리기이다.
Manifest
Packer가 빌드를 하면 빌드 결과에 대한 메타데이터를 가지는 파일이 있는데 이 파일을 만들어주는 후처리기이다.
Local Shell
사용자가 원하는 후처리기가 없을 때 커스텀하여 만드는 후처리기이다. 그래서 로컬 머신에서 원하는 명령어를 수행할 수 있게 된다.
build {
name = "cwchoiit-packer"
source "amazon-ebs.ubuntu" {
name = "nginx"
ami_name = "cwchoiit-packer"
}
post-processor "manifest" {} # 첫번째 post-processor는 빌드 산출물을 다이렉트로 입력으로 받게 된다.
post-processors {
# post-processors의 첫번째 post-processor 역시 빌드 산출물을 다이렉트로 입력으로 받는다.
post-processor "shell-local" {
inline = ["echo Hello World ! > artifact.txt"]
}
# post-processors의 첫번째가 아닌 post-processor들은 첫번째 단계의 산출물을 입력으로 가져올 수 있게 된다.
# 여기 같은 경우는 첫번째 post-processor가 shell-local이라 딱히 산출물이 없는데 파일(artifact.txt)을 만들어내는 스크립트가 있다.
# 그리고 그 파일을 post-processor의 산출물로 만들고 싶으면 이 "artifice" 라는 후처리기를 사용하면 된다.
# 그러면 이 files에 지정한 파일들을 다음 post-processor에게 전달하게 된다.
post-processor "artifice" {
files = ["artifact.txt"]
}
post-processor "compress" {}
}
post-processors {
# post-processors의 첫번째 post-processor 역시 빌드 산출물을 다이렉트로 입력으로 받는다.
post-processor "shell-local" {
inline = ["echo Finished!"]
}
}
}
이 파일에 후처리기가 존재한다. 후처리기는 post-processor, post-processors 두 개의 블록으로 만들어 낼 수 있고 문자 그대로 복수개나 단일개냐의 차이가 있다. 그리고 후처리기를 사용하면서 알아야 할 것이 있는데 빌드가 끝난 후 최초의 post-processor와 post-processors의 첫번째 post-processor는 모두 빌드 산출물을 다이렉트로 입력으로 받게 된다. 그럼 post-processors의 두번째 세번째는 어떻게 동작하냐면 post-processors의 첫번째 post-processor의 산출물을 입력으로 가져올 수 있게 된다. 근데 저 예시에서 보면 첫번째 후처리기가 shell-local이고 이 녀석같은 경우 별다른 산출물을 만들어 내지 않는데 명령어로 artifact.txt라는 파일을 만들었다. 이 파일을 산출물로 만들고 싶으면 사용할 수 있는 다음 후처리기가 artifice라는 후처리기이다. 그 후처리기한테 files 리스트에 원하는 산출물을 넣으면 그 파일이 다음 후처리기에게 산출물로 적용된다.
이제, 이것을 실행해보자.
packer build .
아래 보면, Running post-processor 부분이 있다. 순서대로 manifest, shell-local, artifice, compress, shell-local 순으로 진행되는 모습을 볼 수 있다.
그리고 이렇게 빌드가 끝나면 실행한 경로에서 다음과 같이 새로운 파일들이 생겨났음을 볼 수 있다.
하나는 manifest 후처리기를 통해 만들어진 manifest 파일이고, 하나는 compress 후처리기를 통해 만들어진 압축파일이다. 압축파일을 풀어보자. 우선 어떤 형태의 파일인지 알아보기 위해 다음 명령어를 수행해보자.
AWS에서 제공하는 Secrets Manager 서비스에 대한 Data Source이다. Sensitive한 데이터를 저장하고 보관할 때 이 서비스를 활용할 수 있겠다. name 속성은 Secret Manager의 Secret name을 나타낸다. key는 해당 데이터에 들어가 있는 Key/Value의 Key를 나타낸다.
저 값을 가져오는 Data Source라고 생각하면 된다. 그리고 그 아래는 전에 봤던 build 블록이다. 그리고 provisioner에서 보면 Secret의 value값을 찍는 명령어가 보인다. 한번 build 해보자.
packer build .
이런식으로 Terraform과 정확히 동일하게 같은 개념으로 Data Source를 이해할 수 있었다.
Provisioner는 머신 이미지 내부에 필요한 설정이나 소프트웨어를 설치할 때 사용한다. 예를 들면 필요 패키지 설치, 사용자 생성 등 이런 머신 이미지 내 필요한 작업을 Provisioner를 통해서 할 수 있다. 그리고 이 Provisioner의 종류가 굉장히 많다. 문서를 참조하면 더 많은 정보를 알 수 있다.
저번 시간에도 source가 바로 어떻게 빌드할 것인지에 대한 내용이라고 했는데, 이처럼 null은 뭐가 거의 없다. communicator = "none"이 의미하는 건 이 빌더랑 Packer가 통신하는 방법(SSH, WinRM)은 필요 없다는 의미이다.
main.pkr.hcl
# Without name
build {
sources = [
"source.null.one",
"source.null.two",
]
}
# With name
build {
name = "cwchoiit-packer"
sources = [
"source.null.one",
"source.null.two",
]
}
# Fill-in (기존 source를 확장하는 기능, 근데 원래 있던 것을 변경하는 것은 불가능하다. 예를 들어, 기존에 null.one이 가지고 있던 communicator를 여기서 변경하는 행위)
build {
name = "cwchoiit-packer-fill-in"
source "null.one" {
name = "terraform"
}
source "null.two" {
name = "vault"
}
}
이 부분을 보면 세 개의 build 블록으로 나뉘어져 있다. 빌드의 이름이 없는 경우, 빌드의 이름이 있는 경우, 빌드의 이름이 있고 source를 expand하는 경우 총 세가지다. 이런 각각의 방법으로 build를 할 수가 있다는 것을 보여준다.
Terraform을 공부한 상태다보니 이해가 안가는 부분은 없다. 여기서 subnet_id를 명시한 이유는 나 같은 경우 Default VPC가 없기 때문에 반드시 Subnet을 명시해줘야 한다. 그리고 associate_public_ip_address도 true로 설정하지 않으면 AMI를 만들기 위해 새로 생성되는 EC2의 Public IP가 존재하지 않기 때문에 SSH 접속을 Packer가 할 수 없다. 그래서 'true'로 설정해줘야 한다.
source_ami_filter는 기반이 되는 AMI를 지정하는 부분이다. ubuntu 이미지 가장 최신 버전을 사용하기로 한다.
마지막 부분인 build 부분이다. 이 부분은 빌드의 이름과 어떤 것을 빌드할 것인지에 대한 정보를 기입한다.
우선 init 명령어를 통해 필요한 plugin을 내려받아야 한다. 이 명령어가 잘 수행되면 다음 명령어를 입력한다.
packer build .
이제 빌드를 시작한다. 빌드를 시작하면 다음과 같은 화면이 보일것이다.
과정을 보면 알겠지만 일시적으로 SSH에 접속하기 위해 Key pair와 Security group을 생성하는것이 보인다. 이 부분도 명시해서 가져다가 사용하게 할 수 있지만 이 예제에선 Packer가 직접 만들도록 했다. 그리고 인스턴스가 생성된 후 SSH로 인스턴스에 접속한다. 그 이유는 AMI는 인스턴스에서 필요한 모든 패키지나 기본 설정을 다 한 상태에서의 이미지이기 때문에 그런것들이 있다면 해당 인스턴스에서 설치 작업을 수행해야 하기 때문에 SSH에 접속하는 것이다. 그러나 이 예제에는 따로 설치할 것은 없다. 내가 정의하지 않았으니까. 이렇게 인스턴스가 만들어지고 AMI가 만들어지면 일시적으로 만든 Security group, Key pair, Instance가 삭제된다.
module "security_group" {
source = "tedilabs/network/aws//modules/security-group"
version = "0.24.0"
name = "${local.vpc_name}-provisioner-userdata"
description = "Security Group for SSH."
vpc_id = "vpc-04a888e12b489eb12"
ingress_rules = [
{
id = "ssh"
protocol = "tcp"
from_port = 22
to_port = 22
cidr_blocks = ["0.0.0.0/0"]
description = "Allow SSH from anywhere."
},
{
id = "http"
protocol = "tcp"
from_port = 80
to_port = 80
cidr_blocks = ["0.0.0.0/0"]
description = "Allow HTTP from anywhere."
}
]
egress_rules = [
{
id = "all/all"
description = "Allow to communicate to the Internet."
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
]
tags = local.common_tags
}
이제 Userdata를 이용해서 AWS EC2 인스턴스를 만드는 부분이다. 기본적으로 ami, instance_type, subnet_id, associate_public_ip_address는 할당해주었다. 여기서 새롭게 보이는 key_name은 EC2에 SSH로 접속할 때 사용했던 Key pair의 Name이다. 그리고 이제 핵심 부분인 user_data가 있다. 보면 EOT로 시작해서 EOT로 끝나는데 그냥 Multiple 문자열을 받기 위해 저렇게 사용한다. 그리고 첫 부팅시에만 적용될 sudo apt-get update와 nginx를 설치하는 작업을 한다. 그 다음 위에서 설명한 security_group을 지정했다.
이제 이 부분이 핵심이다. null_resource는 이 역시 Terraform에서 지원하는 Provider 중 하나인데 이 녀석은 딱 한가지 Attribute를 가지고 있다. triggers 라는 Attribute인데 이 triggers는 그 안에 지정한 것들 중 하나라도 변경 사항이 생기면 이 null_resource라는 전체 리소스를 재실행한다. 즉 리소스를 다시 만들어낸다. 그리고 이것을 사용해서 실제 provisioner를 사용했다.
triggers에 3개가 있다. instance_id, script, index_file. 우선 instance_id는 위에서 만든 provisioner라는 이름의 리소스를 가진 리소스의 ID가 변경되거나, script, index_file은 files 폴더에 있는 파일의 해시값이 변경된다(파일 내용이 변경됨)면 이 리소스가 재실행된다는 의미이다.
여기에 local-exec는 로컬 PC에서 명령어를 수행하는 부분이다. 의미없지만 잘 수행되는지 Hello World를 echo로 실행한다.
file은 로컬에서 리모트로 파일을 전송한다. 그래서 source랑 destination 속성이 있다. 저 부분은 뭐가 뭔지 한 눈에 알 수 있을것이고 connection 부분이 중요하다. 이 부분이 어떻게 전송할지에 대한 내용이기 때문이다. 우선 SSH로 보내고 유저 이름은 "ubuntu"이다. Host는 위에서 provisioner라는 리소스 이름의 인스턴스 Public IP가 된다. 그리고 private_key가 이제 EC2에 SSH로 접속할 때 사용하는 .pem 파일이다. 이 .pem 파일은 AWS Console에서 EC2 인스턴스 만들 때 Key pair로 사용하는 그 .pem 파일이다.
그리고 remote-exec는 리모트 머신에서 명령어를 수행한다. 그 수행하는 명령어가 바로 script = "실행할 스크립트 파일의 경로" 또는 inline = ["명령어", "명령어"]이다. 이 명령어를 작성하는 방법은 세가지가 있다. script, scripts, inline. script와 inline은 보았고, scripts는 스크립트 파일 한개가 아니라 여러개를 실행할 때 사용한다.
그래서 이 main.tf에 대해 Apply를 하면 기존에 있는 VPC를 사용해서(저 위에 vpc-id가 기존에 있는 내 VPC ID를 붙인 것) 해당 VPC의 Subnet역시 기존에 있는 Subnet에 EC2 두개가 만들어진다. (Userdata로, Provisioner로) 그리고 각 EC2는 Nginx를 설치하는데 Userdata는 설치하는 것까지가 끝이다. 그리고 그것은 딱 한번 최초 부팅시에만 실행될 것이다. 그게 Userdata의 특징이니까. 그러나 provisioner로 만든 EC2는 null_resource를 사용해서 감시하는 3개의 트리거가 발동될 때마다 새롭게 null_resource에 작성한 작업들을 수행하게 된다. 왜냐하면 null_resource에서 작업하기로 하는 HOST가 전부 provisioner로 만든 EC2의 Public IP를 가르키기 때문.
그래서 이 것을 Apply해보자. 다음과 같이 정상적으로 Apply가 됐고 EC2 인스턴스 두개가 생겼다.
AWS Console에 들어가보면 만든 EC2 두개가 보인다.
그리고 각 EC2의 Security도 잘 적용된 상태다.
이 상태에서 저 Outputs에서 알려준 Public IP를 접근해보자. Nginx를 두 EC2 모두 설치했기 때문에 80으로 접속 시 Nginx 화면이 보여야한다.
Userdata로 만든 EC2
Provisioner로 만든 EC2
Provisioner로 만든 EC2는 왜 다를까? null_resource에서 index.html 파일을 Nginx가 기본으로 뿌려주는 경로에 위치시켰기 때문에 내가 만든 이 index.html 파일이 보이는 것이다.
그럼 여기서 한 발 더 나아가서 이 파일(index.html)이 수정되면? => 트리거가 발동 => provisioner로 만든 EC2가 Replace 될 것
files/index.html
다음과 같이 수정해보자.
<h1>Hello Chyoni!</h1>
그 후 Apply 진행. 변경된다는 Plan이 보인다.
작업이 진행되고 다시 provisioner로 띄운 EC2로 들어가보면 다음과 같이 변경 사항이 적용됐음을 확인 가능하다.