Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.

Flask 공식 튜토리얼 따라하기 #2

Flask 공식 튜토리얼 따라하기 #1에 이어서 Flask 튜토리얼을 따라해 보고 있다.(2편이 너무 늦었지만....)



3 단계: flaskr를 패키지로 설치하기

앞에서 계속 이해 못 하고 있던 flaskr을 패키지로 설명하는 부분이 나왔다. Flask에는 Click 패키지가 포함되어 있어서 Flask 커맨드라인 도구를 제공한다고 한다. Click을 보면 CLI 프로그램을 쉽게 만들게 도와주는 패키지인 것으로 보이는데 flask 커맨드 라인은 튜토리얼 뒤에서 더 나온다고 하므로 일단 그냥 넘어갔다. Click은 나중에 CLI를 만들 때 보면 될 것 같다.

Flask 애플리케이션을 관리할 때 Python 패키지 가이드를 따르는 게 좋다고 한다. 일단 이건 뒤에서 더 보기로 하고 일단 프로젝트 루트에 setup.pyMANIFEST.in 파일을 만든다.

from setuptools import setup

setup(
    name='flaskr',
    packages=['flaskr'],
    include_package_data=True,
    install_requires=[
        'flask',
    ],
)

setup.py의 내용이다. Flask 애플리케이션 개발 환경 구성에서도 찾아봤지만, 프로젝트 정보 및 의존성 등을 명시하고 있는 파일이다. name은 이 패키지의 이름이고 packagesflaskr라고 지정했으므로 모듈을 찾기 위해 flaskr/__init__.py를 찾게 된다. include_package_dataTrue라고 했으므로 MANIFEST.in에서 명시한 패키지 디렉터리의 데이터 파일을 포함하게 되고 여기서는 flask가 필요하므로 install_requires에 지정했다.

graft flaskr/templates
graft flaskr/static
include flaskr/schema.sql

위는 MANIFEST.in 파일의 내용이다. setuptools를 사용할 때 패키지에 포함하기 위한 특수 파일들을 여기에 지정해야 한다.

from .flaskr import app

flaskr/ 아래 __init__.py 파일을 위 내용으로 만든다. 이 import 문을 쓰면 Falskr 애플리케이션 인스턴스는 패키지의 최상위로 가져올 수 있다고 한다. Flask 개발 서버가 이 인스턴스의 위치를 알아야 하는데 이 import 문이 없으면 FLASK_APP=flaskr.flaskr같은 환경변수를 지정해 주어야 한다.

이제 pip로 패키지를 설치한다.

$ pip install --editable .
Obtaining file:///Users/outsider/temp/flaskr
Collecting flask (from flaskr==0.0.0)
  Using cached Flask-0.12.2-py2.py3-none-any.whl
Collecting Werkzeug>=0.7 (from flask->flaskr==0.0.0)
  Using cached Werkzeug-0.12.2-py2.py3-none-any.whl
Collecting Jinja2>=2.4 (from flask->flaskr==0.0.0)
  Using cached Jinja2-2.9.6-py2.py3-none-any.whl
Collecting itsdangerous>=0.21 (from flask->flaskr==0.0.0)
Collecting click>=2.0 (from flask->flaskr==0.0.0)
  Using cached click-6.7-py2.py3-none-any.whl
Collecting MarkupSafe>=0.23 (from Jinja2>=2.4->flask->flaskr==0.0.0)
Installing collected packages: Werkzeug, MarkupSafe, Jinja2, itsdangerous, click, flask, flaskr
  Running setup.py develop for flaskr
Successfully installed Jinja2-2.9.6 MarkupSafe-1.0 Werkzeug-0.12.2 click-6.7 flask-0.12.2 flaskr itsdangerous-0.24

이제 실행하면 서버가 잘 뜬다. 물론 아직 라우트 설정이 없으므로 404 밖에 나오지 않는다.

$ FLASK_APP=flaskr FLASK_DEBUG=true flask run
 * Serving Flask app "flaskr"
 * Forcing debug mode on
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 351-985-958

여기까지 따라 했는데도 궁금한 게 많이 생겼다.

MANIFEST.in

MANIFEST.in에 패키지에 필요한 폴더를 지정한 건 이해했는데 정확한 동작은 알지 못했다. 소스배포판(sdist)를 만드는 방법을 살펴보면 sdist 명령어가 다음 파일들을 기본적으로 패키지에 포함한다.

  • py_modulespackages 옵션이 암시하는 모든 Python 소스 파일.
  • ext_moduleslibraries에서 지정한 모든 C 소스 파일.
  • scripts 옵션으로 구분할 수 있는 스크립트.
  • test/test*.py 처럼 테스트 스크립트로 보이는 모든 것.(현재는 소스배포판에서 포함하는 것 외에 Distutils가 아무 일도 하지 않지만, 차후에 Python 모듈 배포판을 테스트하는 것이 표준이 될 것이다.)
  • README.txt(또는 README), setup.py, setup.cfg.
  • package_data 메타데이터와 일치하는 모든 파일.
  • data_files 메타데이터와 일치하는 모든 파일.

이 외에 파일을 포함하는 방법이 MANIFEST.in라는 매니페스트 템플릿을 작성하는 것이다. 이 파일에서 소스 배포판에 포함할 파일목록을 작성하면 sdist 명령어가 이 템플릿 파일에 나온 대로 처리해서 파일을 찾는다.

graft flaskr/templates
graft flaskr/static
include flaskr/schema.sql

그래서 여기서 보면 Flask의 정적 파일과 뷰파일 및 SQL 파일을 배포판에 포함하기 위한 설정임을 알 수 있다. MANIFEST.in에 나온 각 명령어는 sdist의 명령어인데 graft dir는 디렉터리 밑의 모든 파일을 포함하라는 의미이고 include pattern1 pattern2 ...는 패턴과 일치하는 모든 파일을 포함하라는 의미이다.

__init__.py

__init__.py를 쓰면 패키지로 인식한다는데 정확히 어떤 역할을 하는 파일인지가 궁금했다. Python 문서를 보면 다음과 같이 나와 있다.

Python이 디렉터리가 패키지를 담고 있는 것처럼 다루려면 __init__.py 파일이 필요하다. string같은 일반적인 이름으로 된 폴더가 유효한 모듈인데 의도치 않게 모듈 검색 경로에서 의도치 않게 숨겨지는 일을 막는 데 필요하다. 가장 간단한 상황에서는 아무 내용도 없는 __init__.py를 만들어도 되지만 이 파일에서 패키지의 초기화 코드를 실행하거나 __all__ 변수를 설정할 수 있다.

일반 디렉터리와 Python이 패키지로 다룰 폴더의 구분을 위해 필요하다고 이해했고 초기화 코드를 실행한다는 걸 보니 패키지를 import 할 때 실행되는 것으로 보인다.

그래서 string/__init__.py라는 파일을 만들고

print('This is a string package')

루트 디렉터리에서 REPL로 테스트를 해보았다.

$ python
Python 3.6.3 (default, Oct  7 2017, 13:45:53)
[GCC 4.2.1 Compatible Apple LLVM 9.0.0 (clang-900.0.37)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import string
This is a string package
>>> import string

string이라는 모듈을 잘 가져오고 __init__.py가 실행되는 것을 볼 수 있다. 당연히 임포트 할 때마다 실행되는 것은 아니므로 내부에서 캐싱하는 것으로 보인다.

여기까지 해보자 Python 모듈과 import와 관련해서 어떻게 동작하는지 좀 더 궁금해져서 다음과 같이 테스트를 위한 파일과 폴더를 생성했다.

├── foo.py
├── bar.py
├── baz
│   └── bar.py
├── qux
│   ├── __init__.py
│   └── bar.py
└── corge
    ├── __init__.py
    └── bar.py

foo.pybar.py는 파일을 임포트 할 때는 테스트해보기 위함이고 baz__init__.py가 없는 폴더, qux는 빈 __init__.py를 가진 폴더, corge는 내용이 있는 __init__.py가 있는 폴더다. 각 파일의 내용은 아래 한꺼번에 적어두었다.

# foo.py
print('This is a foo.py')

# bar.py
def hello():
  print('Hello World')

REPL로 다시 테스트를 해봤다.

$ python
Python 3.6.3 (default, Oct  7 2017, 13:45:53)
[GCC 4.2.1 Compatible Apple LLVM 9.0.0 (clang-900.0.37)] on darwin
Type "help", "copyright", "credits" or "license" for more information.

>>> import foo
This is a foo.py
>>> foo
<module 'foo' from '/Users/outsider/python-test/foo.py'>

그냥 파일인데 잘 임포트가 된다.

>>> import bar
>>> bar
<module 'bar' from '/Users/outsider/python-test/bar.py'>
>>> bar.hello()
Hello World

함수가 있는 파일을 임포트하면 모듈에서 함수를 실행할 수 있다.

>>> import baz
>>> baz
<module 'baz' (namespace)>
>>> baz.bar
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: module 'baz' has no attribute 'bar'
>>> baz.hello()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: module 'baz' has no attribute 'hello'
>>> import baz.bar
>>> baz.bar.hello()
Hello World

__init__.py가 없는 폴더도 임포트는 할 수 있는데 따로 그 안에 내용에 접근할 수는 없는 것 같다. 대신 파일을 불러오듯이 폴더 구조에 따라 import baz.bar처럼 하는 건 가능하다.

>>> import qux
>>> qux
<module 'qux' from '/Users/outsider/python-test/qux/__init__.py'>
>>> qux.bar
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: module 'qux' has no attribute 'bar'
>>> qux.hello()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: module 'qux' has no attribute 'hello'
>>> import qux.bar
>>> qux.bar.hello()
Hello World

__init__.py가 있는 경우도 위와 같이 동작한다. 다면 임포트한 모듈을 출력했을 때 앞에서는 <module 'baz' (namespace)>처럼 나왔는데 이번에는 <module 'qux' from '/Users/outsider/python-test/qux/__init__.py'>로 나왔다. Python 임포트 문서를 보면 Python에는 regular packages와 namespace packages 두 가지 타입이 있다고 한다. regular packages는 Python 3.2 이전부터 존재하던 전통적인 패키지로 __init__.py가 있는 폴더를 의미하고 임포트했을 때 __init__.py를 자동으로 실행하고 그 객체를 패키지 네임스페이스에 바인딩한다.

namespace packages는 완전히 이해는 못 했는데 __init__.py가 없는 패키지를 의미하고 그 안의 파일들은 꼭 파일 시스템에 있어야 하는 것은 아니고 네트워크나 zip 파일 등에 있을 수도 있다고 한다. 좀 더 동적으로 다양한 경우를 해결할 수 있도록 새로 추가된 패키지 타입이 아닌가 싶다. 조만간 쓸 일은 없어 보여서 그냥 넘어갔다.

# corge/__init__.py
from .bar import hello

corge/__init__.py는 위의 내용으로 작성했다. 이렇게 작성하고 임포트하자 아래처럼 바로 hello 변수를 사용할 수 있었다.

>>> import corge
>>> corge
<module 'corge' from '/Users/outsider/python-test/corge/__init__.py'>
>>> corge.hello()
Hello World

실제 모듈을 작성해 봐야 하겠지만 대충의 동작은 이해한 것 같다.

길게 돌아왔지만, 다시 튜토리얼 코드를 보면 flaskr/__init__.pyfrom .flaskr import app로 작성했으므로 flaskr/flaskr.py에 있는 app을 모듈로 외부에 노출해서 import flaskr을 했을 때 flaskr.app으로 바로 사용하게 하려는 의도임을 추측할 수 있었다. 실제로 튜토리얼에서는 이 코드가 있으면 export FLASK_APP=flaskr.flaskr로 할 필요가 없다고 나오는데 실제 실행은 export FLASK_APP=flaskr로 지정해서 하고 있다. 조금 전 테스트를 통해서 flaskr.flaskr로 시작 파일을 찾지 않고 왜 flaskr만으로 찾을 수 있는지 알 수 있게 되었다.

Python 패키지 가이드

Python에서 아직 가장 이해가 안 되는 부분 중 하나인데 Flask 애플리케이션을 관리할 때 Python 패키지 가이드를 따르는 게 좋다고 한다. 실제로 튜토리얼에서도 예제를 작성한 뒤에 pip로 설치해서 실행하고 있다. PyPI에 배포하고 다른 프로젝트에서 가져다 쓰는 모듈의 경우에는 패키징과 설치가 필요하다는 것을 이해하는데 Flask같은 웹 애플리케이션 같은 경우에도 왜 패키징과 설치가 필요한지 이해할 수가 없었다. 보통 이런 코드는 서버에 배포해서 바로 실행을 하거나(Flask 홈페이지에 나온 것처럼) 소스를 배포해서 어떤 환경에서 실행하도록 하는 것이지 다른 프로젝트가 가져다 쓰는 목적이 아니라고 생각하기 때문이다.

이해가 안 되지만 일단 저 가이드 문서를 따르라고 하니까 가이드 문서를 읽어봐야 했다. 이 문서에서는 패키지를 설치하는 방법패키징해서 배포하는 방법을 설명하고 있는데 여기서는 후자가 필요하다고 생각해서 패키징해서 배포하는 방법을 살펴봤다.

패키징과 배포의 요구사항

패키지 설치 환경을 구성하고 pip install twinetwine을 설치한다. twine는 PyPI에 배포판을 업로드하는 데 필요한데 여기서는 PyPI에 배포할 것이 아니므로 일단 넘어갔다.

프로젝트 구성

여기서는 프로젝트 구성에 필요한 초기 파일에 관한 설명이 나와있다.

setup.py

setup.py는 프로젝트 디렉터리의 루트에 존재해야 하는 가장 중요한 파일이다. 이 파일을 2가지 주요 기능이 있는데 첫째, 전역 setup() 함수를 가지고 있고 이 함수에 전달하는 인자는 프로젝트 정의에 대한 상세 내용을 담고 있고 둘째, 패키지 테스트와 관련된 여러 가지 명령어를 실행하는 CLI를 제공한다. 이 명령어의 목록을 보려면 python setup.py --help-commands를 실행하면 된다.

setup.cfg

setup.cfgsetup.py 명령어의 기본 옵션을 담고 있는 ini 파일이다.

README.rst

README.rst는 프로젝트의 목적을 다루는 readme 파일이다. 가장 일반적인 형식이 rst 확장자를 가지는 reStructuredText이지만 필수는 아니다.

MANIFEST.in

MANIFEST.in는 소스 배포판에 자동으로 포함되지 않는 추가 파일을 패키징 해야 할 때 필요하다. MANIFEST.in가 필요한 이유는 앞에서 살펴봐서 쉽게 이해하고 넘어갔다. 추가로 MANIFEST.in은 wheels처럼 바이너리 배포판에는 영향을 주지 않는다고 하는 걸 알게 되었다.

LICENSE.txt

모든 패키지는 배포판에 대한 라이센스 파일을 포함해야 한다.

필수는 아니지만, 대부분은 프로젝트와 같은(혹은 아주 유사한) 이름의 최상위 패키지 아래 Python 모듈과 패키지를 포함하고 있다. 여기서 flaskr라는 프로젝트 에서 flaskr라는 폴더를 가지는 것을 의미하는 것이다.

setup() 인자

setup.py의 주요 기능이 전역 setup() 함수를 가지는 것이므로 이 함수에 전달하는 인자로 프로젝트 정의에 대한 자세한 내용을 명시한다.

  • name: 프로젝트 이름으로 PyPi 목록에 표시되는 이름이다. PEP 508에 따르면 유효한 프로젝트 이름은 ASCII 문자, 숫자, 언더스코어(_), 하이픈(-), 마침표(.)로만 이뤄져야 하고 ASCII 문자나 숫자로 시작하고 끝난다. name='sample',처럼 지정한다.

    • 프로젝트 이름을 비교할 때는 대소문자를 구별하지 않고 언더스코어, 하이픈, 마침표가 여러 개 이어지더라도 같은 것으로 처리하므로 프로젝트 이름이 cool-stuff일 때 사용자가 의존성 선언이나 다운로드를 할 때 Cool-Stuff, cool.stuff, COOL_STUFF, CoOl__-.-__sTuFF를 모두 같은 것으로 처리한다.
  • version: 프로젝트의 현재 버전으로 사용자가 최신버전 대신 특정 버전을 사용할 수 있도록 한다. version='1.2.0',처럼 지정한다.
  • url: 프로젝트의 홈페이지 URL로 url='https://github.com/pypa/sampleproject', 처럼 지정한다.
  • author: 프로젝트의 작성자 정보다. author='The Python Packaging Authority',author_email='pypa-dev@googlegroups.com', 처럼 지정한다.
  • license: 사용하는 라이센스로 license='MIT',처럼 지정한다.
  • classifiers: 프로젝트를 카테고리화 하는 classifier 목록이다. 프로젝트가 지원하는 Python 버전을 정의하기도 하지만 이는 PyPI에서 검색하는 용도로만 사용해야 하고 설치에서는 사용하면 안 된다. 프로젝트 설치에 사용하는 파이썬 버전 제약은 python_requires를 사용해야 한다.
classifiers=[
    # How mature is this project? Common values are
    #   3 - Alpha
    #   4 - Beta
    #   5 - Production/Stable
    'Development Status :: 3 - Alpha',

    # Indicate who your project is intended for
    'Intended Audience :: Developers',
    'Topic :: Software Development :: Build Tools',

    # Pick your license as you wish (should match "license" above)
     'License :: OSI Approved :: MIT License',

    # Specify the Python versions you support here. In particular, ensure
    # that you indicate whether you support Python 2, Python 3 or both.
    'Programming Language :: Python :: 2',
    'Programming Language :: Python :: 2.6',
    'Programming Language :: Python :: 2.7',
    'Programming Language :: Python :: 3',
    'Programming Language :: Python :: 3.2',
    'Programming Language :: Python :: 3.3',
    'Programming Language :: Python :: 3.4',
],
  • keywords: 프로젝트를 설명하는 키워드 목록으로 keywords='sample setuptools development',처럼 지정한다.
  • packages: 프로젝트에 포함되어야 하는 패키지 목록이다. 수동으로 지정할 수도 있지만 setuptools.find_packages가 자동으로 찾아준다. exclude 키워드는 릴리스나 설치 시에 생략할 패키지를 지정할 때 사용한다. packages=find_packages(exclude=['contrib', 'docs', 'tests*']),처럼 지정한다.
  • install_requires: 프로젝트를 실행할 때 최소한으로 필요한 의존성을 지정할 때 사용한다. pip로 설치했다면 의존성으로 설치하는 것이 명세이다. install_requires=['peppercorn'],처럼 지정한다.
  • python_requires: 프로젝트를 특정 파이썬 버전에서만 실행해야 한다면 PEP 440 버전 지정자 문자열로 pip가 다른 파이썬 버전에서는 설치하지 못하도록 할 수 있다. 이 기능은 최신 기능이므로 프로젝트의 소스 배포판과 wheels를 setupttols 24.2.0 이상으로 빌드해야 적절한 메타데이터가 생성되고 pip 9.0.0 이상에서만 이 메타데이터를 인식한다.

    • python_requires='>=3',는 Python 3+을 지원함을 의미한다.
    • python_requires='~=3.3',는 Python 3.3 이상은 지원하지만, Python 4는 지원하지 않음을 의미한다.
    • python_requires='>=2.6, !=3.0.*, !=3.1.*, !=3.2.*, <4',는 Python 2.6, 2.7과 3.3 이상의 모든 Python 3를 지원함을 의미한다.
  • package_data: 패키지에 설치되어야 하는 추가 파일이 있을 때 사용한다. 이 파일들은 주로 패키지 구현체와 관련된 데이터나 패키지를 사용하는 프로그래머가 관심 있을 문서를 포함한 텍스트 파일이다. 이러한 파일을 "package data"라고 부른다. 이 값은 패키지 이름에서 상대 경로의 목록으로 패키지에 복사되어야 한다. package_data={'sample': ['package_data.dat'], },처럼 지정한다.
  • data_files: 대부분은 package_data로 충분하지만, 일부의 경우 패키지 외부의 데이터 파일을 두어야 하는 경우가 있다. data_files는 이때 사용한다. data_files=[('my_data', ['data/data_file'])],처럼 지정한다.
  • scripts: 설치해야 하는 미리 만들어 놓은 스크립트를 지정할 때 사용하지만 크로스 플랫폼 호환성을 위해 console_scripts를 사용하기를 권장한다.
  • py_modules: 프로젝트에 포함되어야 하는 모듈의 목록으로 py_modules=["six"],처럼 지정한다.
  • entry_points: 프로젝트 등에서 의존해서 정의하고 있는 엔트리포인트를 위해 프로젝트가 제공하는 플러그인을 지정한다. entry_points={ ... },처럼 지정한다.
  • console_scripts: 스크립트 인터페이스를 등록할 때 사용한다. 다음과 같이 지정할 수 있다.
entry_points={
    'console_scripts': [
        'sample=sample:main',
    ],
},

여기까지 문서를 읽었지만 주로 패키지를 설정하는 내용이고 "개발 모드"로 작업하기를 보면 필수는 아닌데 작업하는 동안 프로젝트를 "editable"이나 "develop" 모드로 로컬에 설치하는 게 일반적이라고 한다. 이렇게 하면 프로젝트를 설치한 상태로 수정하면서 작업할 수 있다. 이 모드는 pip install -e .를 실행하면 되고 -e 대신 --editable을 사용해도 된다. 이렇게 실행했을 때 install_requires에 선언한 의존성과 console_scripts에 선언한 스크립트를 모두 설치한다.

사실 여전히 왜 모듈도 아니고 웹 애플리케이션 같은 걸 작성할 때도 패키지를 설치하듯이 하는지 잘 모르겠지만 Python의 역사 때문에 남은 관례가 아닌가 추측만 해본다.

2018/01/22 22:13 2018/01/22 22:13

Terraform의 provisioner 사용하기

Terraform은 Infrastructure as Code 도구라서 AWS를 사용하는 경우 VPC나 EC2 인스턴스, 시큐리티 그룹 등을 Terraform으로 관리할 수 있다. 이런 요소들은 직접 웹 콘솔에서 만들거나(API를 써도 되지만...) Terraform 도구를 사용하면 되지만 서버의 환경 설정 등의 프로비저닝 작업을 모두 Terraform으로 관리하는 것은 고민되는 접근이다.

여기서 환경 설정이란 것은 Python이나 Node.js를 설치한다거나 apt-get 업데이트를 한다거나 nginx를 설치한다거나 하는 등의 일이다. 사실 이런 작업은 Ansible이나 Chef 등이 더 적합하다고 보지만 어디까지를 Terraform으로 하고 어디까지를 Ansible이나 Chef로 할지는 각 조직에서 결정할 일이다.

이전에 Terraform을 사용할 때는 AWS에서 주로 썼기에 Packer로 프로비저닝한 머신 이미지를 AMI로 만들어서 Terraform에서 사용했기에 Terraform에서는 프로비저닝을 직접 사용하지는 않았고 잘 살펴보지도 않았다. Terraform에서도 Provisioner를 제공하고 있어서 여기서 직접 프로비저닝하거나 다른 도구와 연결해서 사용할 수 있다. 얼마 전에 Terraform으로 Digital Ocean의 Droplet 생성하면서 provisioner를 처음 사용해 보고 사용방법을 좀 더 정리해 보게 되었다. 여기서 사용한 Terraform 버전은 v0.11.2다.

Provisioner

Provisioner는 Terraform으로 리소스를 생성하거나 제거할 때 로컬이나 원격에서 스크립트를 실행할 수 있는 기능으로 0.8부터 추가되었다. 기본적으로 provisioner는 생성할 때만 실행되고 그 뒤에 업데이트되거나 하진 않는다. 그래서 provisioner가 실패하면 리소스가 잘못되었다고 판단하고 다음 terraform apply 할 때 제거하거나 다시 생성한다. provisioner에서 when = "destroy"를 지정하면 해당 프로비저너는 리소스를 제거하기 전에 실행되고 프로비저너가 실패한다면 다음 terraform apply 할 때 다시 실행하게 된다. 문서에 따르면 이 때문에 제거 프로비저너는 여러 번 실행해도 괜찮도록 작성해야 한다고 한다.

local-exec 프로비저너

local-exec 프로비저너는 이름 그대로 로컬에서 실행하는 프로비저너다. 여기서 로컬은 terraform apply를 실행하는 현재 머신을 의미한다.

resource "null_resource" "demo" {
  provisioner "local-exec" {
    command = "echo hello world"
  }
}

위처럼 .tf 파일을 작성했다. null_resource는 말 그대로 빈 리소스를 의미한다. 그래서 하나의 리소스와 묶이지 않은 프로비저닝 등을 사용하거나 할 때 사용할 수 있는데 여기서는 데모용으로 사용했다. 다른 리소스와 크게 다른 부분은 없고 provisioner "local-exec"를 지정해서 echo hello world를 실행하도록 했다.

$ terraform apply

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  + null_resource.demo
      id: <computed>


Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

null_resource.demo: Creating...
null_resource.demo: Provisioning with 'local-exec'...
null_resource.demo (local-exec): Executing: ["/bin/sh" "-c" "echo hello world"]
null_resource.demo (local-exec): hello world
null_resource.demo: Creation complete after 0s (ID: 822915894771826369)

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

terraform apply를 실행하면 리소스를 생성하고 나서 프로비저너를 실행하면서 hello world가 출력된 것을 볼 수 있다.

resource "null_resource" "cluster" {
  provisioner "local-exec" {
    command = "echo hello world"
  }

  provisioner "local-exec" {
    command = "python --version"
  }
}

provisioner는 한 리소스 안에서 여러 번 정의해도 상관없고 작성한 순서대로 실행된다.

$ terraform apply

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  + null_resource.cluster
      id: <computed>


Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

null_resource.cluster: Creating...
null_resource.cluster: Provisioning with 'local-exec'...
null_resource.cluster (local-exec): Executing: ["/bin/sh" "-c" "echo hello world"]
null_resource.cluster (local-exec): hello world
null_resource.cluster: Provisioning with 'local-exec'...
null_resource.cluster (local-exec): Executing: ["/bin/sh" "-c" "python --version"]
null_resource.cluster (local-exec): Python 3.6.3
null_resource.cluster: Creation complete after 0s (ID: 6241181854377804821)

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

이를 실행하면 아까처럼 hello world가 출력되고 내 로컬 머신의 Python 버전이 출력된 것을 볼 수 있다. 이 결과는 앞에 생성한 리소스를 다 지우고 다시 실행한 것이다. 생성 시에만 프로비저너가 실행되므로 이미 생성한 리소스에 프로비저너를 추가하더라도 실행되지 않는다. 더 정확히는 리소스가 변경된 것이 아니므로 이미 최신 상태라고 나오므로 실행이 되지 않는다.

아직 사용 사례는 다 모르지만 local-exec는 주로 로컬에 스크립트나 프로그램을 실행하거나 클라우드에 실행한 IP나 ID 같은 정보를 로컬 파일로 만들 때 사용하는 것으로 보인다.

remote-exec 프로비저너

remote-exec 프로비저너Terraform으로 Digital Ocean의 Droplet 생성하기에서 사용해서 실제 사용방법은 이전 글을 보아도 되긴 한다. local-exec와는 달리 remote-exec는 리소스를 생성한 후 타겟 머신에서 실행된다.

remote-execinline, script, scripts가 있는데 셋 중 한 번에 하나만 사용해야 한다. inline을 살펴보기 위해 아래처럼 AWS에 EC2 인스턴스를 실행하는 tf 파일을 작성해보자.

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

resource "aws_instance" "demo" {
  ami             = "ami-d39a02b5" # ubuntu 16.04
  instance_type   = "t2.micro"
  subnet_id       = "subnet-xxxxxxxx"
  security_groups = ["sg-xxxxxxxx"]
  key_name        = "your-key-pair"

  connection {
    user        = "ubuntu"
    type        = "ssh"
    private_key = "${file("~/.ssh/id_rsa")}"
    timeout     = "2m"
  }

  provisioner "remote-exec" {
    inline = [
      "hostname",
      "lsb_release -a",
    ]
  }
}

원격에 접속해서 실행하려면 어떻게 접속할지를 알아야 하므로 connection을 지정해야 한다. 지금은 sshwinrm를 지원한다는데 winrm은 안 써봐서 모르겠고 보통은 ssh를 쓸 것으로 생각한다. connectionresource 아래 선언할 수도 있고 provisioner 아래 선언할 수도 있는데 resource 아래 선언하면 해당 리소스의 모든 프로비저너가 선언한 커넥션을 사용하게 된다. 여기서는 inline으로 호스트 명과 Ubuntu의 버전 정보를 출력했다.

$ terraform apply


aws_instance.demo: Creating...
  ami:                          "" => "ami-d39a02b5"

aws_instance.demo: Still creating... (10s elapsed)
aws_instance.demo: Still creating... (20s elapsed)
aws_instance.demo: Provisioning with 'remote-exec'...
aws_instance.demo (remote-exec): Connecting to remote host via SSH...
aws_instance.demo (remote-exec):   Host: 13.113.162.115
aws_instance.demo (remote-exec):   User: ubuntu
aws_instance.demo (remote-exec):   Password: false
aws_instance.demo (remote-exec):   Private key: true
aws_instance.demo (remote-exec):   SSH Agent: true
aws_instance.demo (remote-exec): Connected!
aws_instance.demo (remote-exec): ip-10-10-1-47
aws_instance.demo (remote-exec): No LSB modules are available.
aws_instance.demo (remote-exec): Distributor ID:  Ubuntu
aws_instance.demo (remote-exec): Description: Ubuntu 16.04.3 LTS
aws_instance.demo (remote-exec): Release: 16.04
aws_instance.demo (remote-exec): Codename:  xenial
aws_instance.demo: Creation complete after 34s (ID: i-0f8e4ce616fee9e52)

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

EC2 인스턴스를 생성하면 SSH 접속에 성공한 뒤에 호스트 명과 Ubuntu 버전을 출력한 것을 볼 수 있다. inline 대신 script를 사용하면 로컬에 있는 파일을 원격 서버에 복사한 뒤에 실행한다. 복잡한 스크립트의 경우 유용하고 scripts는 여러 파일을 실행해야 할 때 사용한다.

resource "aws_instance" "demo" {
  # 중략

  connection {
    user        = "ubuntu"
    type        = "ssh"
    private_key = "${file("~/.ssh/id_rsa")}"
    timeout     = "2m"
  }

  provisioner "remote-exec" {
    script = "./bootstrap.sh"
  }
}

앞에서 본 파일을 위처럼 수정한 뒤 bootstrap.sh 파일에 똑같은 명령어를 입력한 뒤에 실행하면 아까와 똑같은 결과가 나오는 것을 확인할 수 있다.

그 밖의 프로비저너

위에서 본 프로비저너가 가장 간단하게 사용할 수 있는 프로비저너고 프로비저너 문서를 보면 다양한 프로비저너가 있는 걸 볼 수 있다. 단순히 파일을 복사하거나 Chef, Salt와 연결할 수 있는 프로비저너도 존재한다.

앞에서도 얘기했듯이 어디까지를 테라폼의 영역으로 가져가고 어디서부터 프로비저닝 도구를 사용할지는 상황에 맞게 고민해 보아야 할 문제이다.

2018/01/19 18:57 2018/01/19 18:57