この 1 年、カンファレンスなどのイベントで多くのエンジニアの方々と話をしてきました。 その際、ほんのわずかな作業だけで[継続的インテグレーション][1] & 継続的デプロイメント (CI/CD) パイプラインをコードベースに実装できることを、デモで実演する機会がたびたびありました。 そこで今回は、このデモで実際に使用したサンプル コードと CircleCI 設定ファイルをもとに、 CI/CD パイプイラインをコードベースに実装する方法について順を追って説明します。

この記事の内容は以下のとおりです。

  • Python Flask アプリケーション用のシンプルな単体テスト
  • プロジェクトの CI/CD 設定ファイルで、コードベースに CI/CD パイプラインを実装する
  • Docker イメージをビルドする
  • Docker Hub に Docker イメージをプッシュする
  • デプロイ スクリプトを使って、Docker コンテナ内のアプリケーションを Digital Ocean サーバー上で稼働させる

前提条件

このチュートリアルを始めるには、以下の準備が必要です。

すべての準備が終わったら、次のセクションに進みましょう。

サンプル アプリケーションの概要

この記事では、シンプルな Python Flask アプリケーションを使用します。プロジェクトのソース コード一式をこちらに用意していますので、git clone でローカルに取得してください。 これは、リクエストを受信したら HTML をレンダリングするだけのシンプルな Web アプリケーションです。 このアプリケーションのソースは、以下の hello_world.py ファイルです。

from flask import Flask

app = Flask(__name__)

def wrap_html(message):
    html = """
        <html>
        <body>
            <div style='font-size:120px;'>
            <center>
                <image height="200" width="800" src="https://infosiftr.com/wp-content/uploads/2018/01/unnamed-2.png">
                <br>
                {0}<br>
            </center>
            </div>
        </body>
        </html>""".format(message)
    return html

@app.route('/')
def hello_world():
    message = 'Hello DockerCon 2018!'
    html = wrap_html(message)
    return html

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

上記のコードで注目していただきたいのは、hello_world() 関数内にある message 変数です。 この変数では文字列値を指定しているので、変数の値を単体テストで照合します。

コードのテスト

低品質で不安定なコードが公開されることがないようにするために、コードのテストは必須です。 今回は、Python に用意されている unittest というテスト フレームワークを使用します。 Flask アプリケーションはすでに完成しているので、このアプリケーションが設計どおりに機能するか確かめる単体テストが必要です。 そこで、hello_world.py アプリケーションを単体テストするためのファイルとして、test_hello_world.py を用意しました。 さっそく、コードを見てみましょう。

import hello_world
import unittest

class TestHelloWorld(unittest.TestCase):

    def setUp(self):
        self.app = hello_world.app.test_client()
        self.app.testing = True

    def test_status_code(self):
        response = self.app.get('/')
        self.assertEqual(response.status_code, 200)

    def test_message(self):
        response = self.app.get('/')
        message = hello_world.wrap_html('Hello DockerCon 2018!')
        self.assertEqual(response.data, message)

if __name__ == '__main__':
    unittest.main()
import hello_world
import unittest

まず、テスト ファイルから hello_world.py のコードにアクセスするために、import 文で hello_world アプリケーションをインポートします。 次に、unittest モジュールをインポートして、アプリケーションのテスト カバレッジを定義します。

class TestHelloWorld(unittest.TestCase): では、テストの最小単位となる基底クラス unittest.TestCase から、TestHelloWorld インスタンスを生成しています。 このインスタンスは、一連の入力に対する応答をテストします。 テスト ケースは、unittest フレームワークの基底クラス TestCase を利用して作成します。

def setUp(self):
        self.app = hello_world.app.test_client()
        self.app.testing = True

まず、テスト フィクスチャを用意するために、クラスレベルのメソッド setUp() を呼び出します。 これは、テスト メソッドの直前に呼び出します。 この例では、app という名前の変数を定義して、hello_world.py コードの app.test_client() オブジェクトとしてインスタンス化しています。

def test_status_code(self):
    response = self.app.get('/')
    self.assertEqual(response.status_code, 200)

test_status_code() メソッドでは、コードで実際のテスト ケースを指定しています。 このテスト ケースでは、Flask アプリケーションに対して get リクエストを送信し、アプリケーションからの応答を response 変数に格納します。 そして、self.assertEqual(response.status_code, 200) で、response.status_code の値が、get リクエストの成功を意味する 200 であるかを確認します。 status_code の値が 200 でない場合、テストは失敗します。

def test_message(self):
    response = self.app.get('/')
    message = hello_world.wrap_html('Hello DockerCon 2018!')
    self.assertEqual(response.data, message)

次の test_message() メソッドでは、別のテスト ケースを指定しています。 このテスト ケースでは、message 変数の値を確認します。この変数は、hello_world.py ファイルの hello_world() メソッドで定義しています。 先ほどのテストと同様に、get リクエストをアプリケーションに送信して、戻り値を response 変数に格納します。

message = hello_world.wrap_html('Hello DockerCon 2018!')

次に、message 変数に、hello_world.wrap_html() ヘルパー メソッド (hello_world.py ファイルで定義済み) から返された HTML を代入します。 具体的には、wrap_html() メソッドに文字列 Hello DockerCon 2018 を入力し、この文字列が埋め込まれた HTML を得ています。 つまり、この test_message() では、アプリケーションの message 変数が、テスト ケースで定めた文字列に一致するか確認します。 文字列が一致しない場合、テストは失敗します。

CI/CD の実装

本記事で使用するアプリケーションと単体テストの内容がわかったところで、コードベースに CI/CD パイプラインを実装しましょう。 CI/CD パイプラインは、CircleCI を使用することでとてもシンプルに実装できます。ただし、そのためには次の準備が必要です。

CI/CD パイプラインのセットアップ

CircleCI プラットフォームにプロジェクトを設定すると、コミットをアップストリームにプッシュするたびに CircleCI で検知され、config.yml ファイルで定義したジョブが実行されるようになります。

そのためには、リポジトリのルートに新しいディレクトリを作成し、その中に yaml 形式の設定ファイルを作成します。 これらの新しいアセットの名前は、CircleCI の命名スキーマ (ディレクトリは .circleci/、ファイルは config.yml) に従う必要があります。 基本的に、CircleCI プラットフォームの CI/CD パイプラインと構成は、これらのディレクトリとファイルで定義します。

設定ファイル

CI/CD を実現する鍵は、設定ファイルの config.yml です。 まず、以下のコード ブロックで、チュートリアル用の設定ファイルの中身をご覧ください。この内容をもとに、設定ファイルの構文について説明します。

version: 2
jobs:
  build:
    docker:
      - image: circleci/python:2.7.14
        environment:
          FLASK_CONFIG: testing
    steps:
      - checkout
      - run:
          name: VirtualEnv を設定
          command: |
            echo 'export TAG=0.1.${CIRCLE_BUILD_NUM}' >> $BASH_ENV
            echo 'export IMAGE_NAME=python-circleci-docker' >> $BASH_ENV 
            virtualenv helloworld
            . helloworld/bin/activate
            pip install --no-cache-dir -r requirements.txt
      - run:
          name: テストを実行
          command: |
            . helloworld/bin/activate
            python test_hello_world.py
      - setup_remote_docker:
          docker_layer_caching: true
      - run:
          name: Docker イメージをビルドしてプッシュ
          command: |
            . helloworld/bin/activate
            pyinstaller -F hello_world.py
            docker build -t ariv3ra/$IMAGE_NAME:$TAG .
            echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
            docker push ariv3ra/$IMAGE_NAME:$TAG
      - run:
          name: アプリを Docker 経由で Digital Ocean サーバーにデプロイ
          command: |
            ssh -o StrictHostKeyChecking=no root@hello.dpunks.org "/bin/bash ./deploy_app.sh ariv3ra/$IMAGE_NAME:$TAG"

jobs: キーには、実行するジョブの一覧を指定します。 ジョブとは、実行するアクションをまとめたものです。 実行するジョブが 1 つだけの場合、そのジョブには build: という名前をつける必要があります。 ジョブとビルドについて詳しくは、こちらを参照してください。

build: キーには、いくつかの要素があります。

  • docker:
  • steps:

docker: キーは、CircleCI で Docker Executor を使用するためのものです。つまり、Docker コンテナがビルドの実行環境になります。

image: circleci/python:2.7.14 は、ビルドに使用する Docker イメージを指定しています。

steps:

steps: キーでは、このビルドで実行するコマンドすべてをまとめて指定します。 最初のコマンドである - checkout は、コードを実行環境に Git クローンします。

- run: キーは、ビルド内で実行するコマンドを指定します。 このキーには name: パラメーターがあり、一連のコマンドをグループ化してラベルを付けることができます。 たとえば、name: テストを実行 では、テスト関連のコマンドをグループ化しています。 このようにグループ分けをしておくと、CircleCI ダッシュボードでビルドのデータを整理、確認するときに便利です。

注: run ブロックは、それぞれが別々のシェル/ターミナルに相当します。 そのため、コマンドの設定内容や実行結果が、後続の run ブロックに引き継がれることはありません。 この仕様は、ドキュメントの「CircleCI 2.0 のセットアップに関するヒント」セクションにあるとおり、$BASH_ENV を使うことで回避できます。

- run:
    name: VirtualEnv を設定
    command: |
      echo 'export TAG=0.1.${CIRCLE_BUILD_NUM}' >> $BASH_ENV
      echo 'export IMAGE_NAME=python-circleci-docker' >> $BASH_ENV 
      virtualenv helloworld
      . helloworld/bin/activate
      pip install --no-cache-dir -r requirements.txt

run ブロックの command: キーには、実行するコマンド一式を定義します。 まず、このビルド全体で使用するカスタム環境変数として、$TAGIMAGE_NAME を設定しています。 残りのコマンドでは、Python の Virtualenv を設定し、requirements.txt ファイルで指定した Python 依存関係ファイルをインストールしています。

- run:
    name: テストを実行
    command: |
      . helloworld/bin/activate
      python test_hello_world.py

この run ブロックでは、アプリケーションのテストを実行します。 テストに失敗すると、ビルド全体が失敗します。 この場合、コードを修正して再びコミットする必要があります。

- setup_remote_docker:
    docker_layer_caching: true

この run ブロックでは setup_remote_docker: キーを指定しています。これは、Docker Executor ジョブ内でイメージをビルドおよび実行し、Docker レジストリにプッシュするための機能です。 「docker_layer_caching」を「true」に設定すると、前回のジョブまたはワークフローでビルド済みの Docker イメージ (レイヤー) が再利用されます。 つまり、前のジョブでビルドされたすべてのレイヤーに、リモート環境からアクセスできるということです。 ただし、設定ファイルで「docker_layer_caching: true」と指定している場合でも、ジョブがクリーンな環境で実行される場合があります。

今回は、アプリ用の Docker イメージをビルドして Docker Hub にプッシュするので、setup_remote_docker: は必須です。

- run:
    name: Docker イメージをビルドしてプッシュ
    command: |
      . helloworld/bin/activate
      pyinstaller -F hello_world.py
      docker build -t ariv3ra/$IMAGE_NAME:$TAG .
      echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
      docker push ariv3ra/$IMAGE_NAME:$TAG

この Docker イメージをビルドしてプッシュ run ブロックでは、まず、pyinstaller でアプリケーションをバイナリとしてパッケージ化しています。 その後、以下の Docker イメージのビルド プロセスに移ります。

docker build -t ariv3ra/$IMAGE_NAME:$TAG .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ariv3ra/$IMAGE_NAME:$TAG

最初のコマンドでは、リポジトリにある Dockerfile (以下のコード ブロックを参照) に基づいて、Docker イメージをビルドしています。 Docker イメージのビルド方法について詳しくは、Dockerfile のドキュメント (英語) を参照してください。

FROM python:2.7.14

RUN mkdir /opt/hello_word/
WORKDIR /opt/hello_word/

COPY requirements.txt .
COPY dist/hello_world /opt/hello_word/

EXPOSE 80

CMD [ "./hello_world" ]

echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin コマンドでは、CircleCI ダッシュボードで設定した環境変数 $DOCKER_LOGIN と $DOCKER_PWD を使用して、Docker レジストリにログインします。続くコマンドで、イメージを Docker Hub にプッシュしています。

- run:
    name: アプリを Docker 経由で Digital Ocean サーバーにデプロイ
    command: |
      ssh -o StrictHostKeyChecking=no root@hello.dpunks.org "/bin/bash ./deploy_app.sh ariv3ra/$IMAGE_NAME:$TAG"

最後の run ブロックでは、Digital Ocean プラットフォームで稼働中の本番サーバーに新しいコードをデプロイします。リモート サーバー上にデプロイ スクリプトを配置することを忘れないでください。 ssh コマンドで、リモート サーバーにアクセスし、deploy_app.sh スクリプトを実行します。スクリプトの引数に ariv3ra/$IMAGE_NAME:$TAG を含めることで、Docker Hub からプルしてデプロイするイメージを指定しています。

このジョブが正常に完了すると、config.yml ファイルで指定したターゲット サーバー上で、新しいアプリケーションが稼働します。

まとめ

今回のチュートリアルの目的は、コードに CI/CD パイプラインを実装する方法について説明することでした。 この例では Python を使用しましたが、「ビルド・テスト・デプロイ」というコンセプトは、どのような言語やフレームワークでも簡単に実装できます。 本記事で示したサンプルをもとに、みなさんのケースに合ったパイプラインを構築してみてください。 CircleCI の開発者向けドキュメント ページに、役立つドキュメントが用意されていますので、ぜひご覧ください。 また、行き詰まったときには、日本語 Discuss で CircleCI コミュニティに相談してみることをおすすめします。

ぜひ、以下の記事もご覧ください。


Olususi Oluyemi 氏は、テクニカルの世界に大きな情熱を傾け、プログラミングに没頭し、Web 開発なしには生きていけないほど、最新テクノロジーを取り入れることが大好きです。

さんの他の投稿を読む Olususi Oluyemi