CI/CD パイプラインを通じてアプリケーションを実行し、すべてのテストにパスしたのに、アプリケーションをターゲット環境にデプロイすると期待どおりに動作しない、という問題にぶつかったことはありませんか。本番環境でのアプリケーションの挙動を正確に予測できないという事態は、実際に多々発生しており、チームの悩みの種となっています。スモークテストは、このようなエラーを早期に発見するために、アプリケーションの重要なコンポーネントや機能だけを大まかに検証するテストです。デプロイ後にアプリケーションが期待どおりに機能することも確認できます。通常、スモークテストはすべてのアプリケーション ビルドで実行されます。より時間のかかる広範なテストに踏み切る前に、必要不可欠な基本機能に問題がないことを検証するわけです。スモークテストを実装すれば、高速なフィードバック ループの構築が促進されるため、ソフトウェア開発ライフ サイクルにおいて大きなメリットが期待できます。

この記事では、CI/CD パイプラインのデプロイ ステージにスモークテストを追加して、デプロイ後のアプリケーションの基本機能をテストする方法について紹介します。

使用するテクノロジー

この記事では次のテクノロジーについて言及します。

前提条件

今回は、以前の記事「Infrastructure as Code を使用したパイプラインからのリリースの自動化」で使用した構成とコードを取り扱います。ソースコードの全文はこちらのリポジトリでご確認ください。

スモークテスト

スモークテストは、予期せぬビルド エラーや接続エラーを検出し、新しいリリースをターゲット環境にデプロイした後にサーバーから期待どおりのレスポンスがあるかどうかを検証するのに効果的です。シンプルなスモークテストでは、たとえばアプリケーションがアクセス可能で、特定の応答コード (OK 200300301404 など) を返すかどうかをチェックできます。この記事で紹介するスモークテストでは、デプロイ後のアプリがサーバー コード OK 200 を返すこと、デフォルトのページ コンテンツとして期待どおりのテキストが表示されることを確認します。

スモークテストが追加されていない CI/CD パイプライン

まず、サンプルのパイプライン構成を見てみましょう。単体テストの実行と、Docker イメージのビルドおよび Docker Hub へのプッシュを行うパイプライン構成です。Infrastructure as Code (Pulumi) を使用して、新しい Google Kubernetes Engine (GKE) クラスタをプロビジョニングし、このリリースをクラスタにデプロイします。このサンプル パイプライン構成は、スモークテストを実装していません。このサンプル パイプラインを実行すると、新しい GKE クラスタが作成され、手動で pulumi destroy コマンド (作成されたすべてのインフラストラクチャを終了) を実行するまで有効になります。

注意: インフラストラクチャを終了しないと、予想外のコストがかかることがあります。

version: 2.1
orbs:
  pulumi: pulumi/pulumi@1.0.1
jobs:
  build_test:
    docker:
      - image: circleci/python:3.7.2
        environment:
          PIPENV_VENV_IN_PROJECT: 'true'
    steps:
      - checkout
      - run:
          name: Install Python Dependencies
          command: |
            pipenv install --skip-lock
      - run:
          name: Run Tests
          command: |
            pipenv run pytest
  build_push_image:
    docker:
      - image: circleci/python:3.7.2
    steps:
      - checkout
      - setup_remote_docker:
          docker_layer_caching: false
      - run:
          name: Build and push Docker image
          command: |       
            pipenv install --skip-lock
            pipenv run pip install --upgrade 'setuptools<45.0.0'
            pipenv run pyinstaller -F hello_world.py
            echo 'export TAG=${CIRCLE_SHA1}' >> $BASH_ENV
            echo 'export IMAGE_NAME=orb-pulumi-gcp' >> $BASH_ENV
            source $BASH_ENV
            docker build -t $DOCKER_LOGIN/$IMAGE_NAME -t $DOCKER_LOGIN/$IMAGE_NAME:$TAG .
            echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
            docker push $DOCKER_LOGIN/$IMAGE_NAME
  deploy_to_gcp: 
    docker:
      - image: circleci/python:3.7.2
        environment:
          CLOUDSDK_PYTHON: '/usr/bin/python2.7'
          GOOGLE_SDK_PATH: '~/google-cloud-sdk/'
    steps:
      - checkout
      - pulumi/login:
          access-token: ${PULUMI_ACCESS_TOKEN}
      - run:
          name: Install dependencies
          command: |
            cd ~/
            sudo pip install --upgrade pip==18.0 && pip install --user -r project/reqs.txt
            curl -o gcp-cli.tar.gz https://dl.google.com/dl/cloudsdk/channels/rapid/google-cloud-sdk.tar.gz
            tar -xzvf gcp-cli.tar.gz
            echo ${GOOGLE_CLOUD_KEYS} | base64 --decode --ignore-garbage > ${HOME}/project/pulumi/gcp/gke/cicd_demo_gcp_creds.json
            ./google-cloud-sdk/install.sh  --quiet
            echo 'export PATH=$PATH:~/google-cloud-sdk/bin' >> $BASH_ENV
            source $BASH_ENV
            gcloud auth activate-service-account --key-file ${HOME}/project/pulumi/gcp/gke/cicd_demo_gcp_creds.json
      - pulumi/update:
          stack: k8s
          working_directory: ${HOME}/project/pulumi/gcp/gke/
workflows:
  build_test_deploy:
    jobs:
      - build_test
      - build_push_image:
          requires:
            - build_test
      - deploy_to_gcp:
          requires:
          - build_push_image

このパイプラインは新しい GKE クラスタに新しいアプリ リリースをデプロイしますが、この自動化が完了した後、アプリケーションが実際に稼働しているかどうかを知る術はありません。アプリケーションのデプロイが完了し、新しい GKE クラスタ内で正しく機能しているかどうかをすばやく確認するにはどうしたらよいでしょうか。ここで CI/CD パイプラインにスモーク テストを実装すると、デプロイ後のアプリケーションのステータスをすばやく簡単に確認することができます。

スモークテストの作成方法

スモークテストを作成するにはまず、アプリケーションの機能の検証に必要なステップを定義するテストケースを開発します。テストケースの開発は、検証したい機能を特定し、そのテスト シナリオを作成するプロセスの一部になっています。今回のチュートリアルでは、意図的にテスト範囲を非常に狭くしました。この場合、デプロイ後のアプリケーションにアクセスできるかどうか、デフォルトのページに期待どおりの静的テキストが表示されるかどうかの検証が最重要事項です。

以下では、このスモークテスト用のテストケースをどのようにして開発したかについて説明します。個人的には、自分の開発スタイルに沿って、テスト対象のアイテムの概要をまとめ、一覧を作成するようにしています。概要には、そのアプリ向けにスモークテストを開発したときに考慮した要素を記載します。

  • 言語/テストのフレームワーク
    • Bash
    • smoke.sh
  • テストの実行タイミング
    • GKE クラスタの作成後
  • テスト対象
    • テスト: デプロイ後のアプリケーションにアクセスできるか
      • 期待される結果: サーバーがコード 200 を返す
    • テスト: デフォルト ページに「Welcome to CI/CD」と表示されるか
      • 期待される結果: TRUE
    • テスト: デフォルト ページに「Version Number:」というテキストが表示されるか
      • 期待される結果: TRUE
  • テスト後のアクション (テストにパスしたかどうかにかかわらず実行)
    • 標準出力にテスト結果を出力
    • GKE クラスタと関連インフラストラクチャを破棄
      • pulumi destroy を実行

このチュートリアルのテストケースの概要は完全で、テストしたい項目に関する情報が明白に記載されています。これは「テスト スクリプト」とも呼ばれます。今回は、bash ベースのオープンソースのスモークテスト フレームワーク smoke.sh(asm89 作成) を使用してスモークテストを作成しますが、お好みの言語またはフレームワークで作成してかまいません。ここで smoke.sh を選択したのは、実装しやすく、オープンソースのフレームワークだからです。では、smoke.sh フレームワークを使用して、このテスト スクリプトを作成する方法について見ていきましょう。

smoke.sh を使用してスモークテストを作成する

smoke.sh フレームワークのドキュメントに、フレームワークの使い方が記載されています。以下には、サンプル コードのリポジトリtest/ ディレクトリにある smoke_test ファイルを使用する場合のコード例を示します。

#!/bin/bash

. tests/smoke.sh

TIME_OUT=300
TIME_OUT_COUNT=0
PULUMI_STACK="k8s"
PULUMI_CWD="pulumi/gcp/gke/"
SMOKE_IP=$(pulumi stack --stack $PULUMI_STACK --cwd $PULUMI_CWD output app_endpoint_ip)
SMOKE_URL="http://$SMOKE_IP"

while true
do
  STATUS=$(curl -s -o /dev/null -w '%{http_code}' $SMOKE_URL)
  if [ $STATUS -eq 200 ]; then
    smoke_url_ok $SMOKE_URL
    smoke_assert_body "Welcome to CI/CD"
    smoke_assert_body "Version Number:"
    smoke_report
    echo "\n\n"
    echo 'Smoke Tests Successfully Completed.'
    echo 'Terminating the Kubernetes Cluster in 300 second...'
    sleep 300
    pulumi destroy --stack $PULUMI_STACK --cwd $PULUMI_CWD --yes
    break
  elif [[ $TIME_OUT_COUNT -gt $TIME_OUT ]]; then
    echo "Process has Timed out! Elapsed Timeout Count.. $TIME_OUT_COUNT"
    pulumi destroy --stack $PULUMI_STACK --cwd $PULUMI_CWD --yes
    exit 1
  else
    echo "Checking Status on host $SMOKE... $TIME_OUT_COUNT seconds elapsed"
    TIME_OUT_COUNT=$((TIME_OUT_COUNT+10))
  fi
  sleep 10
done

次に、smoke_test ファイルで何が行われているかを説明します。

smoke_test ファイルの解説

ファイルの先頭から見ていきましょう。

#!/bin/bash

. tests/smoke.sh

上のスニペットでは、Bash バイナリを使用するよう指定しています。また、smoke_test スクリプトにインポートまたは追加する、コアの smoke.sh フレームワークのファイル パスを指定しています。

TIME_OUT=300
TIME_OUT_COUNT=0
PULUMI_STACK="k8s"
PULUMI_CWD="pulumi/gcp/gke/"
SMOKE_IP=$(pulumi stack --stack $PULUMI_STACK --cwd $PULUMI_CWD output app_endpoint_ip)
SMOKE_URL="http://$SMOKE_IP"

上のスニペットは、smoke_test スクリプトで使用する環境変数を定義しています。以下に、環境変数の説明を一覧します。

  • PULUMI_STACK="k8s" - pulumi が pulumi アプリ スタックを指定するために使用します。
  • PULUMI_CWD="pulumi/gcp/gke/" - pulumi インフラストラクチャ コードのパス。
  • SMOKE_IP=$(pulumi stack --stack $PULUMI_STACK --cwd $PULUMI_CWD output app_endpoint_ip) - GKE クラスタ上のアプリケーションのパブリック IP アドレスを取得する Pulumi コマンド。この変数はスクリプト全体で使用します。
  • SMOKE_URL="http://$SMOKE_IP" - GKE クラスタ上のアプリケーションの url エンドポイントを指定します。
while true
do
  STATUS=$(curl -s -o /dev/null -w '%{http_code}' $SMOKE_URL)
  if [ $STATUS -eq 200 ]; then
    smoke_url_ok $SMOKE_URL
    smoke_assert_body "Welcome to CI/CD"
    smoke_assert_body "Version Number:"
    smoke_report
    echo "\n\n"
    echo 'Smoke Tests Successfully Completed.'
    echo 'Terminating the Kubernetes Cluster in 300 second...'
    sleep 300
    pulumi destroy --stack $PULUMI_STACK --cwd $PULUMI_CWD --yes
    break
  elif [[ $TIME_OUT_COUNT -gt $TIME_OUT ]]; then
    echo "Process has Timed out! Elapsed Timeout Count.. $TIME_OUT_COUNT"
    pulumi destroy --stack $PULUMI_STACK --cwd $PULUMI_CWD --yes
    exit 1
  else
    echo "Checking Status on host $SMOKE... $TIME_OUT_COUNT seconds elapsed"
    TIME_OUT_COUNT=$((TIME_OUT_COUNT+10))
  fi
  sleep 10
done

上のスニペットがすべての魔法の源となる部分です。while ループは、条件が true になるかスクリプトが終了するまで実行されます。この場合、ループは curl コマンドを使ってアプリケーションが OK 200 応答コードを返すかどうかをテストします。このパイプラインは新しい GKE クラスタをゼロから作成するので、Google Cloud Platform でトランザクションが発生します。スモークテストを始める前に、この処理が完了するのを待ちましょう。まず GKE クラスタとアプリケーション サービスを稼働させる必要があります。$STATUS 変数には、curl リクエストの結果が入ります。次に、値が 200 かどうかのテストが行われます。それ以外の場合、ループは $TIME_OUT_COUNT 変数を 10 秒インクリメントして、10 秒待ってから curl リクエストをもう一度実行します。これはアプリケーションが応答するまで繰り返されます。クラスタとアプリケーションが稼働していて、応答を返す場合、STATUS 変数が 200 応答コードを生成し、テストの残りの部分が実行されます。

smoke_assert_body "Welcome to CI/CD" および smoke_assert_body "Version Number: " ステートメントで、Web ページに Welcome と Version number のテキストが表示されるかどうかをテストします。結果が false なら、テストは失敗し、パイプラインのエラーになります。結果が true なら、アプリケーションは 200 応答コードを返し、テキスト テストの結果が TRUE になります。するとスモークテストがパスして、pulumi destroy コマンドが実行され、このテストケースのために作成されたすべてのインフラストラクチャが終了します。このクラスタはもう必要ないので、テストで作成されたすべてのインフラストラクチャが終了します。

このループには、アプリケーションが $TIME_OUT 値を超過したかどうかを確認する elif (else if) ステートメントも含まれています。elif ステートメントは、例外処理の一例で、予期しない結果が発生した場合の制御に使用します。$TIME_OUT_COUNT 値が TIME_OUT 値を超過した場合、pulumi destroy コマンドが実行され、新しく作成されたインフラストラクチャが終了します。さらに exit 1 コマンドにより、パイプライン ビルド プロセスが失敗します。テスト以外の目的にはこのインフラストラクチャは必要ないため、テスト結果にかかわらず、GKE クラスタは終了します。

パイプラインにスモークテストを追加する

ここまでの手順では、サンプルのスモークテストと、テストケースの開発プロセスについて説明してきました。ここでは、上記の CI/CD パイプライン構成にスモークテストを統合します。deploy_to_gcp ジョブの pulumi/update ステップの下に、新しい run ステップを追加します。

      ...
      - run:
          name: Run Smoke Test against GKE
          command: |
            echo 'Initializing Smoke Tests on the GKE Cluster'
            ./tests/smoke_test
            echo "GKE Cluster Tested & Destroyed"
      ...

上のスニペットは、既存の CI/CD パイプラインに smoke_test スクリプトを統合し、実行する方法を示しています。パイプラインに新しい run ブロックを追加すると、各パイプライン ビルドが実行中の GKE クラスタ上のアプリケーションをテストし、アプリケーションがすべてのテストケースをパスしたという検証結果を返すようになります。特定のリリースをテスト済みのターゲット環境 (この例の場合は Google Kubernetes クラスタ) にデプロイすると、名目上実行されます。

まとめ

この記事では、CI/CD パイプラインでスモークテストと Infrastructure as Code を利用して、ターゲット デプロイ環境でビルドをテストする利点について詳しく紹介しました。ターゲット環境でアプリケーションをテストすると、同じターゲット環境にデプロイしたときのアプリケーションの挙動について貴重なインサイトが得られます。CI/CD パイプラインにスモークテストを実装することで、アプリケーション ビルドの信頼性がいっそう高まります。

ご質問、ご意見、ご感想がございましたら、Twitter で @punkdata 宛にメンションしてください。

最後までお読みいただき、ありがとうございました。