このエントリーをはてなブックマークに追加

はじめに

最近はプライベートではGoのコードを書いていることが多いのですが、GoではTable Driven Testというやり方が知られています。
このテストコードの書き方が個人的にとても分かりやすく気に入っているので、PHPでテスト書く時も参考にしています。
Table Driven Testを意識してテストコードを書くようにしてから、コードレビュー時に「テストが分かりやすくなった」と言ってもらえたりすることもあったので、ご紹介したいと思います。

Table Driven Testとは

Goの公式Githubリポジトリで紹介されているテスト手法の一つです。
一般的には「Data Driven Test」や「Parameterized Test」とも呼ばれているみたいで、特に真新しいしいやり方ではないようです。
PHPのBDDテストフレームワークであるBehatでも、テーブル形式のテスト方法が用意されていたりもします。

Goでのサンプルコード

GoのTable Driven Testで掲載されているサンプルコードは若干見づらいのですが、他のテストに関するページで分かりやすいコードが紹介されていたので、そちらを紹介します。

package stringutil

import "testing"

func TestReverse(t *testing.T) {
	cases := []struct {
		in, want string
	}{
		{"Hello, world", "dlrow ,olleH"},
		{"Hello, 世界", "界世 ,olleH"},
		{"", ""},
	}
	for _, c := range cases {
		got := Reverse(c.in)
		if got != c.want {
			t.Errorf("Reverse(%q) == %q, want %q", c.in, got, c.want)
		}
	}
}

ちょっとGoのコードに慣れていない人には読みづらいかもしれませんが、「入力した文字列の順番を逆に並び替える」という処理になります。
注目して欲しい点は以下の通りです。

  • テスト対象の関数の入力と出力を構造を定義している
  • 構造の定義の直後に実データも定義している

データ構造の定義と実データのセットがとても近い場所が行われているので、テストデータの内容がとても分かりやすいです。
また、入力と出力というシンプルなデータ構造をテストデータとすることにより、この関数がどういった機能を持っているのかというが一目瞭然だと思います。

PHPでやってみる

上のGoのコードを見て、「これってPHPUnitのDataProviderの機能に似ているな」と感じました。
実際に書いてみようと思います。

プロダクションコード

<?php
/**
 * This file is part of the MyVendor.MyPackage
 *
 * @license http://opensource.org/licenses/MIT MIT
 */
namespace MyVendor\MyPackage;

class MyPackage
{
    public function reserve($string){
        preg_match_all('/./us', $string, $array);

        return join('', array_reverse($array[0]));
    }
}

マルチバイト文字を含む文字列の並び替えというのが、PHPの標準関数に存在しなかったので簡単に実装してみました。
UTF8限定です。

テストコード

<?php

namespace MyVendor\MyPackage;

class MyPackageTest extends \PHPUnit_Framework_TestCase
{
    /**
     * @var MyPackage
     */
    protected $skeleton;

    protected function setUp()
    {
        parent::setUp();
        $this->skeleton = new MyPackage;
    }

    /**
     * @param $in
     * @param $expected
     * @dataProvider providerReserveTestData
     */
    public function testReverse($in, $expected)
    {
        $this->assertEquals($expected, $this->skeleton->reserve($in));
    }

    public function providerReserveTestData()
    {
        return [
            '半角英数字のみ' => [
                'in' => 'Hello, world',
                'expected' => 'dlrow ,olleH'
            ],
            'マルチバイト文字含む' => [
                'in' => 'Hello, 世界',
                'expected' => '界世 ,olleH'
            ],
            '空' => [
                'in' => '',
                'expected' => ''
            ],
        ];
    }
}

PHPではデータ構造の定義とデータの初期化を同時に行うことは出来ないので、こんな感じに連想配列にしてテストデータを渡すようにしてみました。
これでどういった値を入力すると、どんな結果が得られるかが分かりやすくなっていて良い感じじゃないでしょうか。

テストには関係ないのですが、連想配列のキーにどういったテストデータなのかの説明を書くようにもしています。
そうすることで、テスト時にどのデータでエラーになったかが分かりやすくなります。

$ ./vendor/bin/phpunit
PHPUnit 5.7.19 by Sebastian Bergmann and contributors.

...F                                                                4 / 4 (100%)

Time: 202 ms, Memory: 6.00MB

There was 1 failure:

1) MyVendor\MyPackage\MyPackageTest::testReverse with data set "マルチバイト文字含む" ('Hello, 世界', '界世 ,OlleH')
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'界世 ,OlleH'
+'界世 ,olleH'

/path/to/tests/MyPackageTest.php:31

FAILURES!
Tests: 4, Assertions: 4, Failures: 1.

Table Driven Testでテストを書きやすいようにプロダクションコードを設計する

プロダクションコードがTable Driven Testでテストしやすい形になっているということは、以下のことが満たされているコードではないかと思っています。

  1. 入力及び出力が必要最低限に抑えられている
  2. 出力に変化を与える要素が全て入力に含まれている
  3. 出力が何かしら存在する
  4. 依存する対象がインターフェイスになっている

入力及び出力が必要最低限に抑えられている

これは単純に入出力が多いとテストデータを作るのが大変になるからです。
入出力が多い場合は、テスト対象が担う役割が多過ぎる可能性があるのかなと疑い、機能分割出来ないか考えてみることにしています。

出力に変化を与える要素が全て入力に含まれている

入力に明示的に現れていない、暗黙的な入力が出力結果に影響を及ぼしているとテストしづらいことが多いです。

暗黙的な入力の具体例としては、

  • インスタンスのプロパティ
  • 環境変数
  • グローバル変数
  • 現在の時間

といったところでしょうか。

こういった暗黙的な入力が出力結果に影響を与えてしまう場合は、引数に含められないか検討してみます。
また、これらの依存の排除がよりコードを複雑にしてしまったりして必ずしもいい結果を及ぼさないこともあるかと思いますので、出来るだけ検討してみるという感じで進めています。

出力が何かしら存在する

これは単純に出力結果が無いと検証しづらいからですね。

出力結果が無い場合の代表例が

  • ファイル作成
  • 標準出力への出力

といった外部へ出力するケースかと思います。
こういうケースは FileWriterStdoutWriter のようなものを用意して、それらのオブジェクトに出力させるように実装して、テストの時にはモック化するというのが一般的かと思います。

ちなみにGoの場合は io.Writer というインターフェイスが公式で用意されているので、それを利用することが一般的です。

依存する対象がインターフェイスになっている

インターフェイスに依存しているということは、モックを作りやすい=テストしやすいということです。
PHPUnitのモックは具象クラスも上手くモックしてくれることも多いですが、極力インターフェイスに依存するほうが望ましいかなと思います。

具体的には、

  • ライブラリを利用する時にインターフェイスが用意されていない
  • 外部のWeb APIを直接利用

といった場合でしょうか。
このケースも出来るだけインターフェイスに依存できないか検討してみるようにしています。

おわりに

如何でしたでしょうか?

Table Driven Testでテストしづらいコードは、良くないコードである

とまでは一概にはいえないかもしれませんが、設計を見直すべきサインになっている場合も結構あるというのが個人的な実感です。
実際の開発現場では色々な要因があるのでなんでもかんでもTable Driven Testでやるべきだとは思いませんし、テストにおいてもYAGNI精神とのバランスは大事かなとも思います。

Table Driven Testを上手く利用することで、よりよいコードを書く手助けになると思いますので、今後も上手に使っていけたらなと思います。

あと、このように他の言語で学んだことをPHPなどで流用して実践出来たりすると楽しいなと思いました。
今後も他の言語をどんどん触って、いろんなエッセンスを日々のプログラミングに取り入れていけたらなと思います!


このエントリーをはてなブックマークに追加

はじめに

本エントリーのゴールは以下のような状態です。

  • (ほぼ)Dockerだけしかインストールされていないサーバーが1つある
  • そのサーバーのDockerで複数のWebアプリがホストされている
  • 各WebアプリはDocker Composeで構成管理されていて、アプリごとに docker-compose up するだけで動く
  • docker-compose up した各Webアプリには、指定したサブドメインが自動で割り当てられて、Let’s Encrypt で取得したSSL証明書が適用される

この状態になるまでに必要な作業をできるだけ丁寧に解説していきます。やや長くなりますが、ぜひ最後までお付き合いください。

目次

  1. Ubuntu 16.04のサーバーを用意する
  2. サーバーにDockerをインストールする
    1. 一般ユーザーを作る
    2. Dockerをインストール
    3. Docker Composeをインストール
  3. リバースプロキシを構築
  4. サーバーへのDockerのインストールからリバースプロキシの構築までをAnsibleで自動化
  5. Webアプリを作る
  6. Webアプリを実際にデプロイしてみる

1. Ubuntu 16.04のサーバーを用意する

まずサーバーを1つ用意しましょう。 僕は DigitalOcean の$5/moプランでUbuntu 16.04をよく借りて使うので、ここではUbuntu 16.04を前提に話を進めます。

2. サーバーにDockerをインストールする

サーバーが用意できたら、DockerおよびDocker Composeをインストールします。 しかしその前に、DigitalOceanの場合、インスタンス(Droplet)作成直後はrootしかユーザーがいないので、まずは一般ユーザーを作成しましょう。

2-1. 一般ユーザーを作成する

sudoできる一般ユーザーを作ります。ここでは例としてユーザー名を ubuntu とします。

% adduser ubuntu
% adduser ubuntu sudo

さらに、ローカルのssh公開鍵を /home/ubuntu/.ssh/authorized_keys に追加してsshログインできるようにしておきましょう。 ssh-copy-id コマンドを使うと楽です。

Macなら

$ brew install ssh-copy-id
$ ssh-copy-id -i ~/.ssh/id_rsa.pub ubuntu@<host>

これ以降はサーバー上の作業は ubuntu ユーザーで行う前提で解説していきます。

2-2. Dockerをインストール

公式のドキュメント のとおりに作業すれば簡単にインストールできます。

実際に打つコマンド

$ sudo apt-get remove docker docker-engine

$ sudo apt-get update

$ sudo apt-get install \
    linux-image-extra-$(uname -r) \
    linux-image-extra-virtual

$ sudo apt-get install \
    apt-transport-https \
    ca-certificates \
    curl \
    software-properties-common

$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

$ sudo apt-key fingerprint 0EBFCD88
# Key fingerprint = 9DC8 5822 9FC7 DD38 854A  E2D8 8D81 803C 0EBF CD88 が出力されることを目視で確認

$ sudo add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
   $(lsb_release -cs) \
   stable"

$ sudo apt-get update

$ sudo apt-get install docker-ce

$ sudo docker run hello-world
# ちゃんと動作することを確認

sudo不要にする&ブート時にdockerサービスを自動起動する

このままだと sudo でしか docker コマンドが使えなくて不便なので、公式ドキュメント(続き) を参考に、ubuntu ユーザーを docker グループに追加しましょう。

$ sudo groupadd docker
$ sudo usermod -aG docker $USER

これだけで、sudo しなくても docker コマンドが使えるようになります。グループを反映させるために、一度 ubuntu からログアウトして再度ログインし直してください。

$ docker run hello-world

して使えることを確認してみましょう。

また、サーバーのブート時にdockerサービスが自動で起動するようにしておきたいので、こちらも 公式ドキュメント(続き) を参考に、

$ sudo systemctl enable docker

これだけやっておきましょう。

2-3. Docker Composeをインストール

同じく 公式のドキュメント のとおりに作業すれば簡単にインストールできます。

実際に打つコマンド

$ sudo curl -L "https://github.com/docker/compose/releases/download/1.12.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
# 1.12.0は記事公開時点での最新バージョン

$ sudo chmod +x /usr/local/bin/docker-compose

$ docker-compose --version
# ちゃんと動作することを確認

3. リバースプロキシを構築

無事にDockerのインストールが完了したので、続いてDockerコンテナ上で動作するリバースプロキシを構築しましょう。 このリバースプロキシが、後から追加でデプロイする各Webアプリにサブドメインを割り当ててSSL証明書を適用してくれます。

ここでは以下の素晴らしいDockerイメージを活用します。

jwilder/nginx-proxy は、同じDocker Network内で VIRTUAL_HOST 環境変数を持ったコンテナが作成されると自動でリバースプロキシしてくれるというもので、jrcs/letsencrypt-nginx-proxy-companion/jwilder/nginx-proxy と協調してプロキシされる各コンテナのバーチャルホストに対して自動でLet’s EncryptのSSL証明書を取得して適用してくれるというものです。

この2つを立ち上げておくだけで、ほとんど何も考えなくてもSSL対応のサブドメイン運用がとても簡単に実現できてしまいます。

Docker Composeを使ってこの2つのコンテナを使ったリバースプロキシを構築しましょう。 例として ~/nginx-proxy というディレクトリに構築することにします。(Dockerコンテナとして使うだけなので物理パスはどこでも大丈夫です)

$ mkdir -p ~/nginx-proxy/certs
$ vi ~/nginx-proxy/docker-compose.yml
# ~/nginx-proxy/docker-compose.yml
version: '2'
services:
  nginx-proxy:
    image: jwilder/nginx-proxy
    container_name: nginx-proxy
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./certs:/etc/nginx/certs:ro
      - /etc/nginx/vhost.d
      - /usr/share/nginx/html
      - /var/run/docker.sock:/tmp/docker.sock:ro
    restart: always
  letsencrypt-nginx-proxy-companion:
    image: jrcs/letsencrypt-nginx-proxy-companion
    container_name: nginx-letsencrypt
    volumes:
      - ./certs:/etc/nginx/certs
      - /var/run/docker.sock:/var/run/docker.sock:ro
    volumes_from:
      - nginx-proxy
    restart: always

このような docker-compose.yml を作って

$ cd ~/nginx-proxy
$ docker-compose up -d

とすればリバースプロキシの構築は完了です。

docker-compose.yml の内容について

基本的にはREADME(jwilder/nginx-proxy#readme / jrcs/letsencrypt-nginx-proxy-companion#readme)で説明されているとおりに書き起こしただけで特別なことはしていません。

ここにあるとおりnginx-letsencrypt コンテナから nginx-proxy コンテナの

  • /etc/nginx/certs
  • /et/nginx/vhost.d
  • /usr/share/nginx/html

の3つのボリュームに対してRead/Writeできる必要があるということなので、

  1. nginx-proxy コンテナでボリュームを作成
  2. volumes_fromnginx-proxy コンテナのボリュームを nginx-letsencrypt コンテナにすべてマウント

しています。

/etc/nginx/certs に関しては、両方のコンテナでホストの ~/nginx-proxy/certs ディレクトリをマウントして、nginx-letsencrypt コンテナにのみWriteを許可しています。

jwilder/nginx-proxy は同じDocker Network内で新しいコンテナが立ち上がったときにそれを自動検知してバーチャルホストを割り当ててくれるわけですが、ホストの /var/run/docker.sock をコンテナにマウントすることでこの自動検知が実現されているようです。

なお、マウントする先は nginx-proxy コンテナと nginx-letsencrypt コンテナで微妙に違っているので注意が必要です。

  • /var/run/docker.sock:/tmp/docker.sock:ro
  • /var/run/docker.sock:/var/run/docker.sock:ro

また、両サービスとも restart: always を設定して、Docker再起動時に自動的にコンテナが起動するようにしています。 すでにマシンブート時にdockerサービスが自動起動するようには設定したので、これでホストマシン自体の再起動後もDockerコンテナが自動で起動してくれます。

4. サーバーへのDockerのインストールからリバースプロキシの構築までをAnsibleで自動化

実はここまでの作業はすべてAnsibleで自動化しました。

https://github.com/ttskch/ansible-docker

これを使えば上記の作業をコマンド一発で終わらせられます。

$ ANSIBLE_USER_PASSWORD=<some password> ansible-playbook -i hosts -u root playbook.yml

なお、このAnsible Playbookでは、上記で説明した手順に加えて swap領域を設定する というのをやっています。 DigitalOceanの$5/moプランだとメモリが512MBしかなくてたまにメモリ不足になるので。

swap領域の設定の仕方は以下の記事が参考になりました。

How To Add Swap on Ubuntu 14.04 | DigitalOcean

docker-machineでプロビジョニング

ちなみに、Dockerホストのプロビジョニングは docker-machine コマンドを使って行うという手もあります。(こちらの方が簡単かもしれません)

詳細は以下のドキュメントをご参照ください。

5. Webアプリを作る

ではいよいよ、ここまでに作ったサーバーでホストするWebアプリを作りましょう。 僕はPHPerなので今回はNginx+PHP-FPMという構成で作ります。

例として以下のようなファイル構成で作ってみましょう。

$ tree .
.
├── docker
│   ├── nginx
│   │   ├── Dockerfile
│   │   └── default.conf
│   └── php
│       ├── Dockerfile
│       └── php.ini
├── docker-compose.yml
└── index.php

3 directories, 6 files

アプリのソース

アプリ自体の内容は今回はどうでもいいので以下のとおりですw

<?php
// ./index.php
phpinfo();

docker-compose.yml

問題はDockerの構成のほうですが、原則に従って1コンテナ1プロセスにしたいので、アプリの構成にもDocker Composeを使うことにします。

# ./docker-compose.yml
version: '2'
services:
  nginx:
    build: ./docker/nginx
    container_name: myapp-nginx
    depends_on:
      - php
    volumes:
      - .:/var/www
    environment:
      VIRTUAL_HOST: myapp.mydomain.com
      LETSENCRYPT_HOST: myapp.mydomain.com
      LETSENCRYPT_EMAIL: your.real@email.com
    restart: always
  php:
    build: ./docker/php
    container_name: myapp-php
    volumes:
      - .:/var/www
    restart: always
networks:
  default:
    external:
      name: nginxproxy_default

こんな感じで、nginxphp というサービスをそれぞれ myapp-nginx myapp-php というコンテナ名で作成し、各コンテナはそれぞれ docker/nginx/Dockerfile docker/php/Dockerfile をビルドして作ることにします。

nginx サービスは php サービスに依存していて、nginx php ともホストのカレントディレクトリをコンテナの /var/www にマウントしています。マウント先のパスは同じである必要はないですが、分かりやすさのためにこうしています。

そして、nginx サービスに対して以下の3つの環境変数をセットしています。

  • VIRTUAL_HOST: myapp.mydomain.com
  • LETSENCRYPT_HOST: myapp.mydomain.com
  • LETSENCRYPT_EMAIL: your.real@email.com

1つ目の VIRTUAL_HOST は、冒頭でも触れたように、nginx-proxy によってリバースプロキシしてもらうために必要なものです。 2つ目と3つ目の LETSENCRYPT_HOST LETSENCRYPT_EMAIL は、READMEにあるように Let’s Encryptの証明書を取得するのに必要な情報です。

当然ながら、myapp.mydomain.com のAレコードにはサーバーのIPアドレスがセットされている必要がありますので、実際にお持ちのドメインで事前にサブドメインを設定しておいてください。

また、特筆すべきは最後のこれです。

networks:
  default:
    external:
      name: nginxproxy_default

nginx-proxy にリバースプロキシしてもらうためには、プロキシされるコンテナが nginx-proxy と同じDocker Network内にある必要があることは前述のとおりです。

READMEにある例のように nginx-proxy とプロキシされるコンテナを同じDocker Composeで構成する場合は問題ないのですが、今回のように nginx-proxy とプロキシされるコンテナをそれぞれ別のDocker Composeで構成する場合は、そのままだとそれぞれのDocker Composeごとに別々のデフォルトネットワークが作成されてしまうため、明示的に同じネットワークに所属させる必要があります。

この件については、以下の記事がとても参考になりました。

docker-compose で別の docker-compose.yml で作ったコンテナとリンクする (ネットワークを繋げる)

記事で紹介されているように共通リンク用に新しくネットワークを作成して nginx-proxy とプロキシされるサーバーの両方をそのネットワークに所属させる、という方法でもよかったのですが、今回はちょっと手抜きして、~/nginx-proxy/docker-compose.yml によって作成されるデフォルトネットワーク nginxproxy_default に所属させる形にしました。

Dockerfile

最後に、それぞれのコンテナの構成をDockerfileに書きましょう。

Nginx

# ./docker/nginx/Dockerfile
FROM nginx

COPY default.conf /etc/nginx/conf.d/

RUN mkdir -p /var/www

WORKDIR /var/www
# ./docker/nginx/default.conf
server {
    listen 80;
    server_name localhost;
    root /var/www;
    index index.php;

    location ~ \.php$ {
        fastcgi_pass myapp-php:9000;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

ほぼ公式イメージをそのまま使っています。

        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

ここでPHPスクリプトのパスの指定にNginxの $document_root を使っている点に注意です。 今回は nginx php 両サービスとも .:/var/www でマウントしているのでこれで問題ないですが、もし nginxphp でマウント先のパスが違っている場合は、

        fastcgi_param SCRIPT_FILENAME /マウント先のパス/fastcgi_script_name;

といった具合に指定する必要があります。

また、

        fastcgi_pass myapp-php:9000;

ここでPHP-FPMのホスト名として、サービス名である php ではなくコンテナ名である myapp-php を使っている点にも注意してください。

これは、後々同じDocker Network内に複数のWebアプリをデプロイしていったときに、同じ php というサービス名を使っているコンテナがあるとどっちに接続されるか確定的でなくなってしまうためです。(実際にやってみたところ、リロードするたびに表示されるアプリが変わるという変態仕様になってしまいましたw)

もちろんコンテナ名も他のアプリと被ってしまえば同じことが起こりますので、アプリ一意な名前を付けるようにしましょう。

PHP-FPM

# ./docker/php/Dockerfile
FROM php:7-fpm

RUN apt-get update \
    && apt-get install -y libicu-dev libmcrypt-dev libxml2-dev \
    && docker-php-ext-install -j$(nproc) intl mcrypt xml mbstring opcache \
    && mkdir -p /var/www

COPY php.ini /usr/local/etc/php/

WORKDIR /var/www
# ./docker/php/php.ini
date.timezone = "Asia/Tokyo"
mbstring.language = "Japanese"
mbstring.internal_encoding = "UTF-8"
memory_limit = 128M

こちらもほとんど公式イメージのままで、いくつかextensionを追加インストールして(今回のアプリでは全く不要ですがw) php.ini を配置したのみです。

6. Webアプリを実際にデプロイしてみる

さて、ここまでですべての準備が整いました。あとは作ったソース一式をサーバーにデプロイして docker-compose up すればWebアプリが期待どおりにホスティングできるはずです。

一応、ここまでで書いたコードをGitHubに上げておきました。

https://github.com/ttskch/docker-nginx-proxy-sample

前述した、Docker環境一式をプロビジョンしてくれる ttskch/ansible-docker と合わせて使っていただければ、今すぐにでも動作確認ができると思います。もしよろしければここだけでも一緒に手を動かしてみてください :muscle:

では実際に試してみましょう。

6-1. 実際にDigitalOceanでDropletを作る

$5/moプランなら1時間使っても0.8円ぐらいなので、気軽に作っちゃいましょうw

image

6-2. Ansibleでプロビジョニングする

まずサーバー側にpythonをインストールして、

image

ローカル側からプロビジョニングを実行します。

image

そしてしばらく待ちます。

6-3. サーバーにログインしてリバースプロキシが起動していることを確認

完了したら再度(今度は ubuntu ユーザーで)ログインして、docker ps でリバースプロキシのコンテナが起動していることを確認してみましょう。

image

OKですね。

6-4. アプリをデプロイ&ビルド

ここでは先ほど作ったGitHubリポジトリをcloneする形でデプロイしてみます。

image

Dockerfileのビルドに初回は多少時間がかかるので、ここでもしばらく待ちます。ビルドが完了したら docker ps でコンテナの起動を確認してみましょう。

image

OKですね。

6-5. 本物のサブドメインを設定

このままだとアプリのホスト名が myapp.mydomain.com という嘘のドメインになっていてSSL証明書の取得ができないので、手持ちのドメインでサブドメインを発行して実際に確認してみましょう。

今回は myapp.ttskch.com というサブドメインのAレコードをサーバーのアドレス 128.199.131.35 に設定しました。

docker-compose.ymlmyapp.mydomain.commyapp.ttskch.com に変更して、コンテナを再起動しましょう。

image

しばらくすると、~/nginx-proxy/certs に証明書のファイルがダウンロードされていることが確認できると思います。

image

ドメインを持っていない人は、SSL対応の確認はできませんが、/etc/hosts を書き換えることでとりあえずWebアプリの動作だけは確認できるのでやってみましょう。 サーバーの /etc/hosts127.0.0.1 myapp.mydomain.com を、ローカルの /etc/hosts128.199.131.35 myapp.mydomain.com をそれぞれ追記すれば、ローカルのブラウザから myapp.domain.com にアクセスすれば表示が確認できます。

image

6-6. ブラウザで確認

image

デデーン!バッチリ動いてますね!SSLも有効になっています。

6-7. DigitalOceanのDroplotを削除

今回はただの動作確認なので、不要になったDropletは忘れずに削除しましょう。(もし忘れても$5/moなのでダメージは少ないですが)

おわりに

タイトルに「複数のWebアプリを」とあったのに本文では1アプリしか作りませんでしたが、この環境にアプリを追加するのがとても簡単だということはお分かりいただけると思います。

リバースプロキシは常に起動しっぱなしにしておいて、今回作った myapp のような単独のWebアプリをどんどん作って(同じDocker Network内で)Dockerコンテナとして起動すれば、ホストするWebアプリはポンポン増やせます。

まだまだ業務でDockerを使う機会はないという方も多いかもしれませんが、試しに趣味プロジェクトをこんな感じで簡易にDocker化してみるのも面白いのではないでしょうか。

以上、長文にお付き合いいただきありがとうござました :pray:


このエントリーをはてなブックマークに追加

新年明けましておめでとうございます、永井です。
本年も「カルテットコミュニケーションズ技術ブログ」共々頑張っていきたいと思いますので、宜しくお願い致します。

はじめに

カルテット社内におけるデプロイツールのデファクトスタンダードは、Capistranoです。
機能的には特に不満はないのですが、たまに手の込んだことをやろうとすると途端に躓いてしまった経験が何度かあり、出来ればアプリケーションで利用しているプログラミング言語で良いツールがないかなと思っていました。
そこで今回はDeployerを試してみました。

今回のゴール

  • シンプルなPHPのプロジェクト(Silexベース)をDeployerでデプロイする
  • CircleCIを利用して、 特定ブランチが更新された時に自動的にデプロイする

Deployerについて

deployer

DeployerはPHP製のとてもシンプルなデプロイツールです。
公式サイトを見る限り、

  • ロールバック対応
  • 並列処理対応
  • 独自の処理を拡張可能

などなど、デプロイツールに求められるような機能は一通り揃っているように思えます。

デプロイする為のSilexプロジェクトを作成

今回はSilexのプロジェクトをデプロイしてみたいと思います。
deployer-sampleというプロジェクトを作成します。

$ composer create-project fabpot/silex-skeleton deployer-sample "~2.0"

ディレクトリ構成はこんな感じです。

$ tree -L 1
.
├── LICENSE
├── README.rst
├── bin
├── composer.json
├── composer.lock
├── config
├── deploy.php
├── phpunit.xml.dist
├── src
├── templates
├── tests
├── var
├── vendor
└── web

インストール

公式サイトにはいくつかインストール方法が紹介されていますが、今回は直接pharファイルをダウンロードする方法でいきます。
プロジェクト内の bin ディレクトリへインストールします。

# path/to/deployer-sample

$ curl -LO https://deployer.org/deployer.phar
$ mv deployer.phar ./bin/dep
$ chmod +x ./bin/dep

設定ファイルの生成

コマンドで自動生成出来ます。
今回はSilexプロジェクトなので、Commonを選択。
コマンド実行したディレクトリに生成されるので、任意の場所に移動しています。
デプロイ用のファイルはプロジェクト内に置くべきかどうかは、ケースバイケースだと思いますが、今回は後々楽をする為にプロジェクト配下の config ディレクトリ以下に配置します。

# path/to/deployer-sample

$ ./bin/dep init

Please select your project type (defaults to common):
  [0] Common
  [1] Laravel
  [2] Symfony
  [3] Yii
  [4] Zend Framework
  [5] CakePHP
  [6] CodeIgniter
  [7] Drupal
 > 0
Successfully created: /path/to/deployer-sample/deploy.php

$ mv deploy.php ./config/

設定ファイルの設定

見たら分かる感じですね。
主な修正点は

  • 各種パス及びホスト名
  • PHP-FPMを使っていないので削除
  • writable_mode デフォルトでは acl というのが指定されており sudo 必須の為、今回はシンプルに chmod を設定

といったところです。

# config/deploy.php

 // Configuration

-set('repository', 'git@domain.com:username/repository.git');
+set('repository', 'git@github.com:example/deployer-sample.git');
 set('shared_files', []);
-set('shared_dirs', []);
-set('writable_dirs', []);
+set('shared_dirs', ['var/logs']);
+set('writable_dirs', ['var/cache']);
+set('writable_mode', 'chmod');

 // Servers

-server('production', 'domain.com')
-    ->user('username')
+server('production', 'example.com')
+    ->user('user')
     ->identityFile()
-    ->set('deploy_path', '/var/www/domain.com');
+    ->set('deploy_path', '/var/www/example.com');


 // Tasks

-desc('Restart PHP-FPM service');
-task('php-fpm:restart', function () {
-    // The user must have rights for restart service
-    // /etc/sudoers: username ALL=NOPASSWD:/bin/systemctl restart php-fpm.service
-    run('sudo systemctl restart php-fpm.service');
-});
-after('deploy:symlink', 'php-fpm:restart');
-
 desc('Deploy your project');
 task('deploy', [
     'deploy:prepare',
...

ローカルからの実行

ローカルから実行してみます。

前提条件

  • 公開鍵方式でデプロイ先サーバへSSH接続が出来る
  • 秘密鍵はデフォルトの~/.ssh/id_rsaを利用する
# path/to/deployer-sample

$ ./bin/dep -f=./config/deploy.php deploy production

デプロイ先はこんな感じになりました。

$ tree -a -L 2
.
├── .dep
│   └── releases
├── current -> /path/to/deployer-sample/releases/1
├── releases
│   └── 1
└── shared
│   └── var

少し変わっているのは、.dep/releases というファイルでしょうか。
中身は以下のように、リリースバージョン番号とタイムスタンプの一覧が保存されています。

20161206130959,1
20161206135544,2

CircleCIから自動的にデプロイ出来るようにする

SSHの秘密鍵を設定

Project Setting > SSH Permissions にてSSHの秘密鍵をセットします。
今回はホスト名をセットせずに登録しました。

circleci

デプロイ先のサーバにも公開鍵をセットします。

circle.ymlを用意

master ブランチが更新されたらデプロイをするように設定しました。

# path/to/deployer-sample/circle.yml

machine:
  timezone: Asia/Tokyo
  php:
    version: 5.6.22

dependencies:
  pre:
    - echo 'date.timezone = "Asia/Tokyo"' > /opt/circleci/php/$(phpenv global)/etc/conf.d/date_timezone.ini

deployment:
  production:
    branch: master
    commands:
      - |
        curl -LO https://deployer.org/deployer.phar
        mv deployer.phar /home/ubuntu/bin/dep
        chmod +x /home/ubuntu/bin/dep
      - dep -f=./config/deploy.php deploy production

deploy.phpの修正

このままだと、デプロイ時にデプロイ先サーバとの接続に失敗してしまいます。
これは、CircleCIのSSH Permissionsでセットした秘密鍵が~/.ssh/id_[HASH]のようなファイルで保存され~/.ssh/configにて以下のように解決していることが原因です。

Host !github.com *
IdentitiesOnly no
IdentityFile /home/ubuntu/.ssh/id_[HASH]

Deployerのデフォルトで利用されているSSHクライアントは、秘密鍵のパスがデフォルトで ~/.ssh/id_rsa にセットされるようになっているため、うまく認証ができません。
いくつか回避方法はありますが、今回はPHPのSSHクライアントではなく、サーバにインストールされているSSHクライアントを利用するようにDeployerの設定を変更して対応しました。

# path/to/deployer-sample

 set('writable_dirs', ['var/cache']);
 set('writable_mode', 'chmod');
+set('ssh_type', 'native');

これで、自動でデプロイされるようになります!
(実はここが一番ハマりました)

おわりに

PHP製アプリケーションにPHP製デプロイツールを利用することで、学習コストや環境構築コストなど低く抑えることが出来るというのは、ひとつのメリットだと思います。
Capistranoと比較して飛び抜けて優れている点があるかといわれると特にないかもしれませんが、シンプルで拡張しやすく作られており、個人的にはとても好印象でした。