Ansible을 활용하여 클라우드 서버 배포하기
Ansible은 서버 설정을 자동화하고, 인프라를 구성하는데 주로 사용되는 오픈소스이다. Redhat에서 Ansible을 활용한 Automation 자격증과 교육을 진행할 만큼 활용도가 높다. 필자는 DevOps Engineer로 일을 하면서 Ansible을 처음 접했고, 지금까지도 사용하고 있다. 아직 숙련도가 높지 않지만, 운영 환경에서 서버를 안정적으로 배포할 수 있을 만큼 유용하게 사용하고 있다.
Ansible을 사용하는 것 자체는 간단하다. 하지만 Ansible Role을 통해서 효율적으로 애플리케이션 설정 구조를 설계하는 건 다소 경험이 필요할 수 있다. 도구를 활용하는데 정답은 없다. 회사마다 자체적으로 최선의 구조를 설계하면 그게 바로 Best Practice이다.
이번 포스트에서는 클라우드 환경에서 서버를 설정할 때 Ansible을 어떻게 활용할 수 있는지에 대한 필자의 경험을 소개하고자 한다.
보통 서버를 띄우면 기본적으로 OS만 설치되어 있다. OS에 따라 기본적으로 설치되는 패키지도 있지만, 자체 서비스를 제공하기 위한 도구들은 추가로 설치해야 한다. 외부 패키지들은 보통 YUM, APT와 같은 패키지 매니저를 통해 설치한다. 만약 매니저에 등록되어 있지 않다면, cURL/wget 등을 활용하여 필요한 도구를 설치할 수 있다.
공들여서 만든 서비스를 외부로 공개한다고 가정해 보자.
서버를 1대 임대받는다. AWS라면 EC2 인스턴스 1대를 생성한다. 앞서 설명했듯이 서버에는 OS와 기본 패키지만 설치되어 있다. 해당 도구만으로는 서비스를 제공할 수가 없다. 이제 우리는 SSH를 통해 서버에 접근해서 패키지를 설치한다.
예컨대, 아래와 같이 설치할 수 있다.
1. nginx를 설치하기 위해 apt 패키지 매니저를 사용한다.
2. 실제 서비스 코드가 들어있는 코드 저장소에서 코드를 다운로드한다.
3. Python 명령어를 통해 서비스를 실행한다.
위와 같은 과정을 거치면 서버에서 우리가 만든 서비스를 제공할 수 있다.
위 방법을 통해 서버가 늘어날 때마다 1대씩 들어가서 설정하면 문제없이 서버를 증설할 수 있다.
하지만, 갑자기 100대가 필요하다고 하면 어떻게 될까?
쉽게 생각하면, 위 작업을 100번 반복하면 100대에 어렵지 않게 서비스를 올릴 수 있다. 하지만 서버가 늘어나는 이유를 생각해 보면 이렇게 단순한 방법이 효과적이지 못함을 깨달을 수 있다.
서버가 여러 대 필요한 이유는 보통 트래픽이 증가하기 때문이다. 서버는 한정된 자원으로 코드를 수행한다. 10명이 서비스를 사용할 때는 자원이 충분하지만, 1000만 명을 지원하기에는 부족할 수 있다. 그러면 자원을 충분하게 늘려줘야 한다. 그 방법으로는 Scale up과 Scale Out이 있다.
Scale up은 서버의 크기를 키우는 방식이고, Scale out은 서버의 양을 늘리는 방식이다. 피자로 따지면 Small을 Large로 늘리면 Scale up이고, Small 피자를 2개 사면 Scale out이다. Large가 Small의 두 배라고 가정하면, 두 경우 모두 늘어난 자원의 양은 동일하다.
두 방식 모두 장단점이 있다.
Scale up은 단일 서버에서 대용량을 처리해야 하는 애플리케이션에 적합하다. 예컨대, MySQL과 같은 데이터베이스는 쓰기 노드 사양을 늘릴 때 Scale up을 한다. MySQL은 scale out을 하게 되면 저장소가 분산되기 때문에 관리가 어려워진다. 하지만, 단일 서버가 처리하는 양이 많아지는 만큼 서버가 내려갔을 때의 영향도도 증가한다.
Scale out은 Stateless 한 애플리케이션에 적합하다. 일반적으로 API 서버는 Stateless 하다. 특정 서버에서 상태값을 가지고 있지 않기 때문에 어느 API 서버로 들어오나 동일한 응답을 반환하다. Scale out전략에서는 서버를 늘려서 사용하다가 필요 없을 때 다시 줄이기 용이하다. 서버 1대가 서비스에 미치는 영향도도 적다. 하지만, 서버 1대가 처리해야 하는 작업이 무거워지는 경우에는 Scale out으로 문제 해결이 불가능하다.
비용효율적으로 서버를 증설하려면 Scale out 전략을 활용하는 편이 좋다. 트래픽이 늘어나면 자연스럽게 서버의 대수를 늘리고, 줄어들면 다시 내리면 된다. 클라우드 환경이라면 서버를 간편하게 늘릴 수 있기 때문에 Scale out 전략을 손쉽게 구현할 수 있다.
다시, 100대에 애플리케이션을 설치해야 하는 상황으로 돌아가보자.
앞서 서버가 늘어나는 상황을 이해했다면 100대에 들어가서 설치하겠다는 말은 안하리라 믿는다. 매번 설치하기 싫으면, 서버가 OS를 구동하고 나서 자동으로 설치되도록 만들어야 한다.
클라우드 제공사에서는 cloud-init 도구를 사용한다. cloud-init은 가상 머신(서버)을 띄울 때 첫 설정을 도와주는 오픈소스 도구이다. Cloud-init는 클라우드 제공사 별로 정의된 Metadata를 읽어서 서버를 설정한다. 그 후, 사용자가 지정한 User Data 스크립트를 실행한다. 우리는 여기서 애플리케이션 서비스 설치를 진행할 수 있다.
아래와 같이 User Data 스크립트를 설정하면 서버가 뜰 때마다 자동으로 애플리케이션을 설치할 수 있다.
이제 더 이상 서버에 들어가서 일일이 설치할 필요가 없다!
만약 새로운 패키지가 필요하다면 User Data 스크립트를 수정해서 다시 배포하면 된다.
하지만 여기서는 또 다른 문제가 발생한다.
서비스의 종류가 늘어나면서 그에 필요한 User Data 스크립트를 매번 만들어줘야 한다. 서비스마다 요구하는 패키지가 다를 수 있다. 설령 같은 패키지라고 해도, 설정이 다를 수도 있다. 설상가상으로 서버의 하드웨어 타입이 달라지면, 또 별도의 User Data 스크립트를 생성해야 할 수 있다.
이렇게 외부 조건에 따라 User Data 스크립트를 매번 다르게 만들어야 한다면, User Data 스크립트를 관리하기 부담스러워진다.
이 문제를 간단하게 해결할 수 있는 도구가 바로 Ansible이다.
Ansible은 서버의 설정과 인프라 프로비저닝 등을 자동화할 수 있는 오픈소스 도구이다. Ansible은 별도의 Agent 설치 없이 SSH를 통한 스크립트 실행을 지원한다. 또한 Ansible은 선언적으로 서버 설정을 정의할 수 있다. 단계별로 작업을 구분할 수 있고, 서비스 및 환경에 따라 변수를 동적으로 적용할 수 있다.
Ansible은 멱등성을 지원한다. 멱등성이란 연산을 여러 번 적용해도 동일한 결과가 나오는 것을 의미한다. Ansible은 한 번 실행하나, 여러 번 실행하나 동일한 결과를 보장한다. 만약 중간에 network 문제로 설치에 실패하더라도, 다시 실행하면 동일하게 설정할 수 있다. 서버를 재시작해도 Ansible 스크립트가 잘 실행되었다면 서버가 정상적으로 설정되었음을 보장할 수 있다.
Ansible은 로컬 서버 설정뿐만 아니라 원격에서 서버를 설정하는 방식도 지원한다. 사용자는 그룹별로 host 정보를 관리할 수 있다. 특정 그룹을 지정하면 해당 그룹에 속한 서버에 원격으로 접속하여 서버 설정을 진행한다. 이러한 기능 덕분에, On Premise에서 서버를 관리할 때 Ansible을 요긴하게 활용하곤 한다.
Ansible은 기본적으로 playbook.yml 파일을 통해 작업을 정의한다. playbook.yml 파일은 실제 실행할 작업을 담고 있다. 아래의 playbook 은 원격에서 nginx 서버를 설치할 때 사용할 수 있다.
위의 Playbook은 다음과 같은 작업을 수행한다.
hosts: webserver는 webserver 그룹에 속한 원격 서버에서 작업을 수행한다는 의미이다.
become: true는 관리자 권한으로 실행되도록 설정한다.
tasks 섹션에서 apt 모듈을 사용하여 nginx를 설치한다.
template 모듈을 사용하여 nginx.conf.j2 템플릿 파일을 /etc/nginx/nginx.conf 경로에 복사한다.
notify를 통해 Restart Nginx 핸들러를 호출하여 nginx 서비스를 재시작한다.
handlers 섹션에서 service 모듈을 사용하여 nginx 서비스를 재시작한다.
Inventory는 서버를 그룹별로 분리하기 위한 파일이다. Inventory를 통해 동일한 설정이 필요한 서버를 그룹별로 묶어 효율적으로 관리할 수 있다. 또한 부모/자식 구조를 설계하여, 부모를 지정하면 자식까지도 적용될 수 있도록 체계화가 가능하다.
아래의 예시를 보자
app_servers는 자식으로 webservers와 databases를 갖는다. 각 webservers와 databases는 서로 다른 서버군을 포함한다. ansible 실행 시 app_servers를 타깃으로 지정하면, webservers와 databases 그룹의 모든 호스트가 대상이 된다.
호스트뿐만 아니라, 서버 설정에 필요한 변수들도 정의할 수 있다. 똑같이 부모와 자식 구조를 가진다. 자식은 부모의 설정값을 덮어쓴다. 즉, 자식의 설정값이 부모보다 우선순위가 높다.
위 예시를 보면, webservers의 server_port는 80이다. 하지만 app_servers의 server_port는 8080이다. 만약 app_servers를 대상으로 실행하면, databases는 8080 포트를 사용하지만 webservers는 80 포트를 사용한다.
inventory 파일에 직접 변수를 정의하지 않고, group_vars 폴더를 통해 변수를 관리할 수 있다. group_vars 폴더는 inventory에 정의한 그룹별로 별도의 폴더를 포함한다. 그룹별 폴더 내에는 해당 그룹에서 사용할 변수들이 정의된 YAML 파일이 있다. 이렇게 파일을 분리하면 그룹에 적용된 변수만 따로 정의하기 때문에 변수를 손쉽게 관리할 수 있다. 모든 그룹의 변수를 모아서 보지 않아도 되니 내용도 훨씬 깔끔해진다.
아래는 필자가 사용하고 있는 방식의 예시이다.
여기서 ansible_connection=local의 의미는 외부 서버에 접근하지 않고 로컬에서 스크립트를 실행하겠다는 의미이다. 그 밑에는 platform_aws부터 app_apnortheast2까지 그룹 간 부모/자식 관계가 설정되어 있다. 하지만 그룹만 명시하고 변수는 보이지 않는다. 이 경우에는 group_vars 폴더를 통해서 변수를 확인할 수 있다.
이제 group_vars 폴더를 보자.
다음과 같이 각 그룹의 이름별로 폴더를 생성해서 변수를 정의한다.
각 폴더에는 service.yml 파일이 있다. 해당 파일에는 그룹에서 사용할 변숫값이 명시되어 있다. 이전에도 설명했듯이 자식 그룹에 중복된 변수가 있으면 자식 그룹의 값이 우선 적용된다. 이 구조를 잘 활용하면 복잡한 듯 보이지만 관리하기 쉬운 구조로 inventory를 구성할 수 있다.
위 예시에서 ansible이 작동하는 순서는 다음과 같다.
1. ansible-playbook을 실행할 때 어떤 inventory 파일을 사용할지 지정한다.
2. Inventory에 적힌 부모/자식 구조에 따라 부모부터 group_vars 폴더를 확인한다.
3. 예시에서는 platform_aws -> platform_preprod -> platform_apnortheast2 -> app_apnortheast2 순서로 정의된 변숫값이 적용된다.
4. 만약 자식 그룹에 속한 변수가 부모와 겹친다면, 자식이 부모를 덮어쓴다.
5. 최종적으로 모아진 변수들을 가지고 스크립트를 실행한다.
이전에 보았던 nginx 샘플 playbook.yml 파일로 다시 돌아가보자. YAML로 정의된 playbook.yml 파일 하나면 서버에서 nginx을 간편하게 설치할 수 있다. 여기에 inventory와 group_vars를 활용하면 사용자 입맛에 맞게 그룹을 나누고, 동적으로 변수를 적용하여 nginx를 설정할 수 있다. OS가 달라지는 부분도 inventory 그룹을 통해 분리할 수 있다.
inventory와 group_vars를 통해 환경별/OS별로 스크립트 파일을 분리해야 하는 부담은 줄었지만 여전히 비효율적인 부분이 남아있다.
만약 A 패키지를 설치하는 서비스의 종류가 100개 있다고 가정해 보자. 이전에는 하나의 서비스가 100개의 서버를 가진 경우였지만, 이번에는 그 종류가 100개이다.
서비스별로 필요한 패키지가 모두 동일하다면 inventory를 통해 나눌 수 있다. 하지만 서비스별로 요구하는 패키지가 천차만별이라면, playbook을 별도로 정의해야 한다. 즉, playbook 파일 분리하고, 각 파일에 A 패키지를 설치하는 동일한 스크립트를 추가해야 한다. 최악의 경우 100개가 모두 별도의 playbook 파일이 존재할 수 있는데, 그러면 100개에 모두 똑같은 코드가 들어가는 셈이다.
이 상태에서 만약 A 패키지의 버전을 올린다고 가정해 보자. 그러면 100개의 playbook에 정의된 코드를 수정해야 한다. 혹여나 버전을 올리다가 잘못되기라도 하면 다시 100개 코드를 다시 rollback 해야 한다.
이러한 불편함을 해소하기 위해서는 불필요한 코드 중복을 없애야 한다. 코드가 같다면, 한 곳에서 작업을 정의해 놓고 해당 코드를 가져다가 쓰는 편이 효율적이다. Ansible은 이러한 비효율을 제거하기 위해 Ansible Role이라는 기능을 지원한다.
Ansible Role은 하나의 역할을 가진 작업을 모아놓은 패키지이다. Nginx, MySQL, Redis 설치와 같이 고유한 작업을 패키지 형태로 정의할 수 있다. 서비스를 사용하는 playbook.yml 에서는 이미 정의해 놓은 Role을 조합해서 사용한다.
예컨대, A 서비스의 playbook.yml에는 MySQL, Nginx가 필요하고, B 서비스의 playbook.yml에는 Nginx와 Redis가 필요하다고 가정하자. 그러면 A는 MySQL, Nginx role을, B는 Nginx와 Redis role을 사용하면 된다. 여기서 A와 B에 정의한 Nginx role은 같은 Role이다. 만약 변경이 필요하면 Nginx role이 정의된 코드만 변경한다. 그러면 해당 Nginx role을 사용하는 모든 서비스에 변경사항을 바로 반영할 수 있다.
Ansible role을 잘 활용하면, 마치 레고 블록을 조합해서 모형을 만드는 것처럼 role을 조합해서 서비스를 구축할 수 있다. 새로운 서비스를 위한 서버를 설정할 때도 기존에 정의한 Role을 재사용하여 간편하고 빠르게 playbook을 생성할 수 있다.
Ansible Role은 기본적으로 아래와 같은 구조를 갖는다. 중요한 것만 설명하면 다음과 같다.
tasks/: 역할의 작업(Task) 정의 파일들을 포함한다.
handlers/: 역할의 핸들러(Handler) 정의 파일들을 포함한다. 핸들러는 특정 이벤트에 대한 작업을 수행하는 데 사용된다.
files/: 역할에 필요한 정적 파일들을 포함한다. 이 디렉터리에 있는 파일들은 원격 시스템에 복사된다.
templates/: 역할에 사용되는 템플릿 파일들을 포함한다. 템플릿 파일은 동적으로 생성되는 파일에 사용된다.
vars/: 역할에 사용되는 변수(Variables) 정의 파일들을 포함한다.
defaults/: 역할의 기본 변수 정의 파일들을 포함한다. 이 파일에는 변수의 기본값이 정의된다.
meta/: 역할의 메타데이터(Metadata) 정의 파일을 포함한다. 메타데이터는 역할의 종속성, 저자, 라이선스 등의 정보를 의미한다.
위 디렉터리 구조에 맞춰서 작업을 정의하면 하나의 role이 완성된다. Playbook에서는 role의 경로만 지정하면 된다.
Playbook에서 role을 사용하는 방법은 아래와 같다.
필자는 현재 서버 설정을 자동화하는데 Ansible을 사용하고 있다. 설치해야 하는 애플리케이션 별로 Ansible Role을 만들어 놓았다. 실제 서비스에서는 만들어놓은 Role을 취사선택해서 사용한다.
Demo 용으로 hello 애플리케이션 배포를 위해 아래와 같이 playbook을 구성할 수 있다.
내용을 이해하기 위해 roles-remote와 roles의 차이에 대해 알아보자.
Role을 실행하기 위해서는 로컬에 스크립트를 가지고 있어야 한다. 앞서 언급했던 것처럼 애플리케이션에 따라 별도의 소스 저장소로 role을 만들어 놓았다. 즉, 모든 role은 외부 저장소에 있다. 따라서 playbook을 실행하기 전에 외부 저장소에 있는 role 스크립트를 로컬로 복사해야 한다. 이를 위해서 별도의 공간인 roles-remote를 만들었다.
roles-remote에 다운로드할 role의 목록은 YAML 파일로 관리한다. requirements.yaml 파일에 다운로드가 필요한 Role의 저장소 정보를 입력한다. 아래 예시를 보면, src에 role이 저장된 코드 저장소 위치가 보인다. 참고로, name은 다운로드할 때 어떤 이름을 사용할지를 의미한다.
이렇게 정리한 Role들은 ansible-galaxy install 명령어를 통해 로컬에 다운로드할 수 있다.
스크립트를 보면 requirements.yaml 에 있는 내용 중 일부를 변경해서 requirement-parsed.yaml로 다시 저장하는 부분이 있다. 외부 저장소가 private인 경우 토큰값이 없으면 접근할 수 없다. 하지만 토큰을 코드 내에 저장하는 건 매우 위험한 일이다. 따라서 서버를 설정할 때 토큰 값을 주입하고, 스크립트를 다운로드한다. 그 후 토큰이 저장된 파일을 로컬에서 제거한다. 원본은 보존하고, 토큰 값은 노출하지 않도록 만들기 위해서 requirement-parsed.yaml 파일로 따로 저장하는 것이다.
이제 필요한 스크립트는 모두 다운로드하였다. 이제는 Ansible Playbook을 실행하기만 하면 된다. Ansible Playbook을 실행할 때는 inventory를 지정해야 한다. inventory에 따라 어느 서버에 접근할지, 혹은 어떤 변숫값을 사용할지 달라진다.
ansible-playbook 명령어를 실행할 때 -i 옵션으로 inventory를 지정할 수 있다. 필자는 user-data에서 ansible-playbook을 실행하도록 설정했다. 이때 inventory 마다 user-data에 사용할 스크립트를 나눠야 하는 불편함이 존재한다.
따라서, 서버의 태그값을 통해 ANSIBLE_GROUP 변수를 받아오고, 해당 변수를 통해 inventory 파일을 지정한다. 그러면 EC2 태그값만 변경하면 동일한 user-data 스크립트를 사용하고도 inventory를 변경할 수 있다.
Ansible은 On Premise, Cloud 가리지 않고 서버를 설정하는데 자주 사용된다. 인터넷을 찾아보면 필자가 사용하는 방식보다 훨씬 더 효율적으로 사용하는 사례도 많다. 다만, Ansible을 사용할 때 늘 염두해야 하는 건, 중복을 없애고 동적으로 설정을 변경할 수 있도록 구성해야 한다는 점이다. 그래야 적은 노력으로 버그를 수정하거나 설정을 변경할 수 있다.
만약, User Data 스크립트만을 사용해서 애플리케이션을 설치하고 있다면, 이번 기회에 Ansible을 도입해 보면 좋을 듯하다. Ansible 자체적으로 내장된 기능도 풍부하기에 추후에 다양한 용도로 활용할 수 있다. 기회가 된다면 서버 설정 용도 외에 다른 목적으로 Ansible 활용 사례를 정리해보고자 한다.
https://galaxy.ansible.com/docs/using/installing.html
https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_intro.html