はじめに
昨今のアプリケーション開発にはCI/CDを導入した効率的な運用が求められる。
CI/CDといえばCircleCI、Jenkinsなどのサービスを利用するのが一般的かと思う。
しかし私自身は正直な話、上記サービスはアカウントこそあれど使ったことがほとんどないレベルである。
なので今から書く内容が本当にCI/CDなのかというツッコミは大いに歓迎するが、少なくとも私はこの手法を実務レベルで使っている。
環境
本記事はPython製のアプリを対象としている。
Fabric3を用いているためPythonの実行環境は必要だが、CI/CDの管理対象としてはPython以外のアプリでも利用可能と思う。
- Linux Mint 19.1 Tessa
- GNU Make 4.1-9.1ubuntu1
- OpenSSH 1:7.6p1-4ubuntu0.5
- rsync 3.1.2-2.1ubuntu1.1
- Python-3.9.6 (anyenv+pyenv+condaで導入)
- Fabric3-1.14.post1
- python-dotenv-0.19.0
- pytest-6.2.4 (unittestやnoseなどでもよい)
最初のアプリ
話を簡単にするため今回のアプリケーションは"Hello ***"と表示するだけのものとする。
構成
最初の構成はこんな感じ
.
├── app
│ └── __main__.py
└── tests
├── __init__.py
└── test_main.py
app/__main__.py
import os
def greetings(name=None):
if not name:
name = os.getenv('NAME', 'John')
return f'Hello {name}'
if __name__ == "__main__":
print(greetings())
tests/test_main.py
import pytest
from app.__main__ import greetings
def test_app():
name = 'Doe'
result = greetings(name)
assert name in result
実行
アプリケーションをコマンドラインから実行する。
# 引数(環境変数)無しで実行
python -m app
Hello John
# 環境変数NAMEを与えて実行
NAME=Foo python -m app
Hello Foo
テスト
py.test -v
============================= test session starts ==============================
platform linux -- Python 3.9.6, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
collected 1 item
tests/test_main.py::test_app PASSED [100%]
============================== 1 passed in 0.01s ===============================
ここまでのソース → Release v1.0 · higebobo/simple-cicd
Fabric3によるデプロイ
上記アプリケーションをtesting、staging、productionの3つの環境にデプロイする。
インストール
まずFabric3をインストールする。
pip install Fabric3
またデプロイ先はssh接続するのでパスフレーズ無しに設定しておく(毎回パスワード入力したくないよね普通)。
試運転
Fabric3をインストールするとfab
というコマンドが実行可能になる。
そしてこのfab
はデフォルトでfabfile.py
を実行するのでここに処理内容を記述する。
まずは簡単な設定ファイルを実行してみる。
fabfile.py
from fabric.api import (env, run, task)
env.hosts = ['localhost']
@task
def mock():
run("echo 'hello'")
@task
デコレータにより関数mock
が引数として呼び出し可能となる。
fab mock
事項結果
[localhost] Executing task 'mock'
[localhost] run: echo 'hello'
[localhost] out: hello
[localhost] out:
Done.
Disconnecting from localhost... done.
少しわかりにくいがout: hello
のところが実行結果である。
見ておわかりの通りSHELLコマンドのechoを実行しているだけなのでFablic3の実行内容はPythonに依存していない。
続いて実装していく。
環境変数の準備
環境変数はdotenv
を用いた運用とする。
ファイル名は通常.env
を使うが作成したアプリでもdotenv
を利用する場合があるため(今回もそう)Fabric3用には.env.fabric
を使うことにする。
まずライブラリのインストール
pip install python-dotenv
Fabric3で読み込むデプロイ用の環境変数
デプロイ対象はtesting、staging、productionの環境とする。
そのためそれぞれに以下の変数を設定する。
-
HOST_<環境名>
: デプロイ先のホスト -
USER_<環境名>
: デプロイ先のユーザ -
DIR_<環境名>
: デプロイ先のディレクトリ -
ENV_<環境名>
: デプロイ先の.envファイル -
CMD__<環境名>
: デプロイ先の実行コマンド(今回の例ではPythonだがpyenvなどで仮想環境を利用する場合は特定しておく)
今回はテストなのでローカルホストに対して3つの環境を想定してこんな感じにしてみた(ディレクトリは事前に作成)。
.env.fabric
## testing environment
HOST_TEST=localhost
USER_TEST=foo
DIR_TEST=/var/tmp/test
ENV_TEST=env/env.test
CMD_TEST=/usr/bin/python3
## staging environment
HOST_STAGE=localhost
USER_STAGE=foo
DIR_STAGE=/var/tmp/stage
ENV_STAGE=env/env.stage
CMD_STAGE=/usr/bin/python3
## production environment
HOST_PROD=localhost
USER_PROD=foo
DIR_PROD=/var/tmp/prod
ENV_PROD=env/env.prod
CMD_PROD=/usr/bin/python3
デプロイしたアプリ用の環境変数
アプリケーション自体も.env
を利用するように変更する。
app/__main__.py
import os
from dotenv import load_dotenv # 追加
load_dotenv() # 追加
# 以下略
そしてデプロイ先の.env
のコピー元となる以下を準備する
env
├── env.prod
├── env.stage
└── env.test
変数はNAME
のみ定義している。
例えばenv/env.prod
はこんな感じ。
NAME="Production"
fabfile.py実装
環境変数の準備ができたら実装する。
以下が完成版。
# -*- mode: python -*- -*- coding: utf-8 -*-
import os
from dotenv import load_dotenv
from fabric.api import (env, run, task, put, cd)
from fabric.contrib.project import rsync_project
path = os.path.join(os.path.dirname(__file__), '.env.fabric')
load_dotenv(path, verbose=True)
env.hosts = ['localhost']
@task
def mock():
"""動作確認用"""
run("echo 'hello'")
def env_base(env_type):
"""環境変数を動的に生成する関数"""
env.hosts = [os.getenv(f'HOST_{env_type}', 'localhost')]
env.user = os.getenv(f'USER_{env_type}', 'user')
env.dir = os.getenv(f'DIR_{env_type}', '/var/tmp')
env.cmd = os.getenv(f'CMD_{env_type}', 'python')
env.file = os.getenv(f'ENV_{env_type}', '.env')
@task
def test():
"""テスティング用の環境変数を取得"""
env_base('TEST')
@task
def stage():
"""ステージング用の環境変数を取得"""
env_base('STAGE')
@task
def prod():
"""プロダクション用の環境変数を取得"""
env_base('PROD')
@task
def deploy():
"""デプロイ"""
app_name = os.path.basename(os.path.dirname(os.path.realpath(__file__)))
local_dir = os.path.dirname(os.path.abspath(__file__))
rsync_project(
local_dir=local_dir,
remote_dir=env.dir,
# デプロイ対象から除外したいファイル(今回は存在しないファイルも含めてある)
exclude=['*.orig', '*.bak', '*~', '.git*', '*.pyc', '__pycache__',
'README.md', 'env', '.env*', '.pytest_cache', '.coverage',
'fabfile.py', 'requirements.txt', 'log/*.*'],
delete=True)
if env.file:
# env/env.***を.envにコピー
put(env.file, f'{env.dir}/{app_name}/.env')
# ここからはデプロイ後の操作なのであってもなくてもよい記述
with cd(os.path.join(env.dir, app_name)):
# コマンド実行
run(f'{env.cmd} -m app')
デプロイ
では実際にデプロイする。
テスティング環境を対象に行うときは以下のコマンド。
fab test deploy
まずtest
関数で環境変数が動的に準備されてdeploy
関数でデプロイする流れとなる。
結果はこんな感じ。
[localhost] Executing task 'test'
[localhost] Executing task 'deploy'
...略
[localhost] out: Hello Testing # ← テスティング用の環境変数を読み込んでアプリを実行している
...略
Makefileの準備
そして仕上げのMakefile(抜粋)
test: test-quiet
test-quiet:
@py.test -s
test-verbose:
@py.test -s --verbose
deploy: test deploy-all
deploy-all: deploy-test deploy-stage deploy-prod
deploy-mock:
@fab mock
deploy-test:
@fab test deploy
deploy-stage:
@fab stage deploy
deploy-prod:
@fab prod deploy
いよいよなんちゃってCI/CDの実行。
make deploy
結果
collected 1 item # 最初はテストを実行
...略
[localhost] out: Hello Testing # テスティングにデプロイしてコマンド実行
...略
[localhost] out: Hello Production # ステージングにデプロイしてコマンド実行
...略
[localhost] out: Hello Production # プロダクションにデプロイしてコマンド実行
...略
Done.
Disconnecting from localhost... done.
makeのタスクでdeployを実行しているのだがそのdeployはtestを実行してからdeploy-allを実行している。
つまり
「テスト」→「テスティング環境へのデプロイ」→「ステージング環境へのデプロイ」→「プロダクション環境へのデプロイ」
となり途中のタスクでコケると終了する。
試しにステージングのところでわざとエラーを仕込んでみると(環境変数のHOSTのところを存在しないものにしてみた)こんな感じ
...略
Aborting.
Makefile:35: recipe for target 'deploy-stage' failed
make: *** [deploy-stage] Error 1
今回はテストをデプロイ前に開発環境で実行しただけだが、本来は各デプロイ先で事前にテストを実行するのが正解かと思われる。
その場合は今回は言及しないがfabfile.pyで以下をうまく処理するように記述すればよいかと思う。
「テスト」→「テスティング環境への仮デプロイ」→「テスティング環境でのテスト」→「テスティング環境への本デプロイ」(以下略)
あと、これまた端折るがFabric3の関数は引数を渡せる。
例えば上記deploy
関数は
@task
def deploy(backup=False, before_test=True):
if backup:
# 処理
if before_test:
# 処理
みたいに引数を設定できるので呼び出すときは
fab test deploy:backup=True,before_test=False
のようにすればよい。
まとめ
冒頭に述べたように本格的に使える代物では無いことは認めるが、私の場合ちょっとしたアプリ(特にPython製)ならそれなりに便利に使えている。
今回のソース → Simple CI/CD sample with GNU Make and Python
免責事項
紹介記事の内容は私の環境では十分検証して実行確認しているが、利用環境によりどのような現象が発生するかはわからない。
そのため実際に試すときは設定ファイルの記述ミスによりデプロイ先の意図せぬファイルを上書き・削除してしまうようなことが無いように自己責任の範疇でお願いしたい。