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

はじめに

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

  • (ほぼ)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と比較して飛び抜けて優れている点があるかといわれると特にないかもしれませんが、シンプルで拡張しやすく作られており、個人的にはとても好印象でした。


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

Symfony Advent Calendar 2016 22日目の記事です。

はじめに

Doctrineは多機能で私も開発においてはたいへん便利に使っています。反面、多機能がゆえにどの機能が何を提供してくれるのか把握できていないために苦労することもしばしばです。

ここでは私がいつもどれがなにを示すのかわからなくて毎回ドキュメントを見ながら使っていたcascade={"remove"}onDelete="CASCADE"orphanRemoval=trueの記述がもたらす各振舞についてこの場を借りて整理してみたいと思います。

前提

  • エンティティとしてUserとProfileがあり、UserがProfileを1つ所有しているとする
  • 以下のapp.phpテストスクリプト(注目箇所のみ抜粋)にて、振舞を検証する
<?php
// app.php

//...

// データ登録
$user = new User();
$profile = new Profile();
$user->setProfile($profile);
$profile->setUser($user);

$em->persist($profile);
$em->persist($user);
$em->flush();

// データ削除
$em->remove($user);
$em->flush();

これ以降、エンティティの定義方法を変更することでテストスクリプトの振舞にどう影響するのかを確認してみます。

1. プレーンな状態

まずは考えうるもっともプレーンな状態でエンティティを定義してみます。

<?php

class User
{
    //...
    
    /**
     * @OneToOne(targetEntity="Profile", mappedBy="user")
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
    
    //...
}

エンティティ定義をもとにスキーマを作成後、テストスクリプトを実行するとエラーが送出され、完遂しません。

# スキーマを作成
$ ./vendor/bin/doctrine orm:schema-tool:create

# テストスクリプトを実行
$ php app.php
 :
PHP Fatal error:  Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 1451 Cannot delete or update a parent row: a foreign key constraint fails (`2016_12_doctrine_association_remove_test`.`profile`, CONSTRAINT `FK_4EEA9393A76ED395` FOREIGN KEY (`user_id`) REFERENCES `User` (`id`)) in /path/to/2016-12-doctrine/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOStatement.php:91

実行時に走ったクエリは以下です。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM User WHERE id = 1;

テストスクリプトのコードではUserエンティティだけを削除しようとしています。しかし、DBにおいてはProfile.user_idからUser.idに外部キーが貼られているため、紐付いた親にあたるUserエンティティが存在しなくなる矛盾からエラーになっていますね。

2. cascade={“remove”}

そこでcascade={"remove"}を追記してみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user")
+    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

スキーマを更新後テストスクリプトを実行してみます。エラーになることなく完遂しました。 ちなみに走ったクエリは以下です。DELETE FROM Profile WHERE id = 1;はさきほどは見られなかったクエリですね。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM Profile WHERE id = 1;
DELETE FROM User WHERE id = 1;

これはテストスクリプトが以下のように書き換えられたのと同様の動きといえます。

// app.php

//..

// データ削除
+ $em->remove($profile);
$em->remove($user);
$em->flush();

つまりcascade={"remove"}におけるremoveEntityManagerに生えているremoveメソッドを指す識別子だったことがわかります。remove以外にもpersistmergeなどが定義できますし複数の定義も可能です。ちなみに各々のメソッドがどのような振舞をするかについては 7. Working with Objects / Doctrine 2 ORM 2 documentation に詳しいです。

3. onDelete=”CASCADE”

removedeleteは同義ですしcascadeという文言もありますし、cascade={"remove"}とやりたいことは似ているように感じます。 実際に動きを見てみましょう。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
+    * @OneToOne(targetEntity="Profile", mappedBy="user")
     */
    private $profile;
    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
-    * @JoinColumn(name="user_id", referencedColumnName="id")
+    * @JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE"))
     */
    private $user;
}

上記のように書き換えた後、ここではひとまずスキーマの更新を行わずテストスクリプトを実行してみます。さきほどと同じように外部キーエラーが送出され完遂しません。

# テストスクリプトを実行
$ php app.php
 :
PHP Fatal error:  Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 1451 Cannot delete or update a parent row: a foreign key constraint fails (`2016_12_doctrine_association_remove_test`.`profile`, CONSTRAINT `FK_4EEA9393A76ED395` FOREIGN KEY (`user_id`) REFERENCES `User` (`id`)) in /path/to/2016-12-doctrine/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOStatement.php:91

ちなみに走ったクエリは以下です。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM User WHERE id = 1;

1. プレーンな状態のときと同様ですね。onDelete="CASCADE"は何もしてくれないのでしょうか?そんなはずはありません。以下のようにスキーマを更新してみます。

$ ./vendor/bin/doctrine orm:schema-tool:update --force
 :
ALTER TABLE Profile DROP FOREIGN KEY FK_4EEA9393A76ED395
ALTER TABLE Profile ADD CONSTRAINT FK_4EEA9393A76ED395 FOREIGN KEY (user_id) REFERENCES User (id) ON DELETE CASCADE

再度テストスクリプトを実行すると無事に完遂しました。ちなみに走ったクエリは以下です。エラーになったさきほどと変わりありません。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM User WHERE id = 1;

つまりonDelete="CASCADE"はDoctrineがORMとしてよしなに何かをしてくれるためではなく、DBのレイヤで対応するようにDDLを生成するための記述でしかないということですね。

4. orphanRemoval=true

次はOrphan Removalです。とりあえず追記して使ってみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user")
+    * @OneToOne(targetEntity="Profile", mappedBy="user", orphanRemoval=true)
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
-    * @JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE"))
+    * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

スキーマを更新してからテストスクリプトを実行してみると以下のクエリが走り、所望の振舞となりました。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
DELETE FROM Profile WHERE id = 1;
DELETE FROM User WHERE id = 1;

cascade={"remove"}と同様の動きのように見えますね。ではどちらのように書いても同じなのでしょうか?

cascade={“remove”}とorphanRemoval=trueの違い

テストスクリプトの仕様を以下のコードのように「ユーザに新たなプロフィールをセットする」というように変更してみます。

<?php
// app.php

//...

- // データ削除
- $em->remove($user);
+ // 新たなプロフィールをセット
+ $profile2 = new Profile();
+ $user->setProfile($profile2);
+ $em->persist($profile2);
$em->flush();

まずは以下のようにcascade={"remove"}を追記してみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user", orphanRemoval=true)
+    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

さっそくテストスクリプトを実行してみます。走ったクエリは以下です。DELETE文は発行されていませんのでこれは所望の振舞ではありません。

INSERT INTO User (id) VALUES (null)
INSERT INTO Profile (user_id) VALUES (1)
INSERT INTO Profile (user_id) VALUES (NULL)

そこでorphanRemoval=trueに戻してみます。

<?php

class User
{
    //...
    
    /**
-    * @OneToOne(targetEntity="Profile", mappedBy="user", cascade={"remove"})
+    * @OneToOne(targetEntity="Profile", mappedBy="user", orphanRemoval=true)
     */
    private $profile;

    //...
}

class Profile
{
    //...

    /**
     * @OneToOne(targetEntity="User", inversedBy="profile")
     * @JoinColumn(name="user_id", referencedColumnName="id")
     */
    private $user;
}

テストスクリプトを実行してみると以下のクエリが走ります。これで所望の振舞となりました。

INSERT INTO User (id) VALUES (null);
INSERT INTO Profile (user_id) VALUES (1);
INSERT INTO Profile (user_id) VALUES (NULL)
DELETE FROM Profile WHERE id = 1;

以上から、cascade={"remove"}が親が削除されることでそれに伴い子も削除されるのに対して、orphanRemoval=trueは親そのものが存在しても子との関係が断たれた状態になった時点で子が削除されるようですね。

まとめ

cascade={"remove"}onDelete="CASCADE"orphanRemoval=true それぞれの記述のもたらす振舞をそれぞれ確認してみました。目的が同様に見えても状態によっては別の振舞を行うことが明確になりました。 DBに近い振舞はパフォーマンスへの影響も小さくないこともあり手法の選定は慎重になりたいですね。

参考