Makefileで簡易CI/CDを実装する


はじめに

昨今のアプリケーション開発には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

免責事項

紹介記事の内容は私の環境では十分検証して実行確認しているが、利用環境によりどのような現象が発生するかはわからない。
そのため実際に試すときは設定ファイルの記述ミスによりデプロイ先の意図せぬファイルを上書き・削除してしまうようなことが無いように自己責任の範疇でお願いしたい。