PHP-Buildpackを読み解いて、カスタマイズに挑戦してみよう

## BuildPackって何？
詳細については[こちらのサイト](https://qiita.com/tokida/items/da9233b39505aa117040#%E3%82%82%E3%81%86%E5%B0%91%E3%81%97%E5%85%B7%E4%BD%93%E7%9A%84%E3%81%AA%E5%8B%95%E4%BD%9C)が分かり易いです。
とにかく、Cloud foundryにアプリケーションをデプロイする時には、`BuildPack`を利用すると、環境構築を自動でやってくれるので、非常に便利です。
私はPHPerなので、今回は`PHP-BuildPack`を使います。これには、`PHP`はもちろんのこと、`Apache(httpd)`、や`Composer`まで含まれています。
これらが準備されているサーバを、一から構築するのは非常に面倒ですので、代行してくれるのは非常にありがたいですね。

## BuildPackのソースはどこにあるの？
[cloud foundryのリポジトリ](https://github.com/cloudfoundry)に纏まっています。
今回は[PHP BuildPack](https://github.com/cloudfoundry/php-buildpack)を触っていきます。
修正する時にはこのリポジトリを自分のリポジトリに`fork`すると良いでしょう。

## BuildPackの構成はどうなっているの？
`cf push`をした時に、実際に稼働するのは`bin`の配下のソースです。
BuildPackには3つの機能があり、Detect(実行環境の検査), Compile(環境の構築) ,Release(リリース準備）を行っています。
例として`scripts/Detect`を見てみましょう。

```python
#!/usr/bin/env python

# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from build_pack_utils import Builder
import sys

(Builder()
     .configure()  # noqa
         .default_config()
         .user_config(step='detect')
         .done()
     .detect()
         .find_composer_path()
         .if_found_output('php ' + sys.argv[2])
         .when_not_found_continue()
         .done()
     .detect()
         .ends_with(".php")
         .recursive()
         .if_found_output('php ' + sys.argv[2])
         .when_not_found_continue()
         .done()
     .detect()
        .by_name('{WEBDIR}')
        .if_found_output('php ' + sys.argv[2])
        .done())
```

これを見ると、[ランタイムの説明](https://github.com/IBM-Bluemix-Docs/runtimes/blob/master/php/nl/ja/index.md)に書いてある言葉の意味がよく分かりますね。

> php_buildpack は、以下のいずれかの条件下で使用されます。
アプリケーションに composer.json ファイルが含まれている。
アプリケーションに *.php ファイルが含まれている。
アプリケーションの options.json ファイルで ${WEBDIR} 変数が定義され、その変数がアプリケーション内の既存ディレクトリーに設定されている。

こんな感じのScriptの集合がbuildpackとなります。

## 修正ポイント
### コンフィグのカスタマイズ
多くの場合は、デフォルトの設定を変更する事が多いのではないでしょうか。
適用されるコンフィグファイルについては、`defaults/config`に入っています。これを修正すると、コンフィグの変更を行う事が出来ます。![1.config.PNG](https://qiita-image-store.s3.amazonaws.com/0/154110/45237a5b-a4b7-3b0a-0e73-a7da01bddbc5.png)

### スクリプトのカスタマイズ
デプロイ時の動作を変更する時には、`/scripts/compile.py`から呼び出しているScriptを変更しましょう。`detect.py`や`release.py`はほぼ触る所は無いと思います。
早速、compile.pyを見てみましょう。

```py
#!/usr/bin/env python

# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from datetime import datetime
from build_pack_utils import Builder
from compile_helpers import setup_webdir_if_it_doesnt_exist
from compile_helpers import setup_log_dir


if __name__ == '__main__':
    (Builder()
        .configure()
            .default_config()  # noqa
            .stack_config()
            .user_config()
            .validate()
            .done()
        .execute()
            .method(setup_webdir_if_it_doesnt_exist)
        .execute()
            .method(setup_log_dir)
        .register()
            .extension()
                .from_build_pack('lib/{WEB_SERVER}')
            .extension()
                .from_build_pack('lib/php')
            .extension()
                .from_build_pack('lib/env')
            .extension()
                .from_build_pack('extensions/appdynamics')
            .extension()
                .from_build_pack('extensions/dynatrace')
            .extension()
                .from_build_pack('extensions/geoip')
            .extension()
                .from_build_pack('extensions/newrelic')
            .extension()
                .from_build_pack('extensions/caapm')
            .extension()
                .from_build_pack('extensions/sessions')
            .extension()
                .from_build_pack('extensions/composer')
            .extensions()
                .from_application('.extensions')
            .extension()
                .from_build_pack('lib/additional_commands')
            .done()
        .install()
            .build_pack_utils()
            .extensions()
            .done()
        .copy()
            .under('{BP_DIR}/bin')
            .into('{BUILD_DIR}/.bp/bin')
            .where_name_is('rewrite')
            .where_name_is('start')
            .any_true()
            .done()
        .save()
            .runtime_environment()
            .process_list()
            .done()
        .create_start_script()
            .using_process_manager()
            .write())

    print 'Finished: [%s]' % datetime.now()
```

実際に`composer install`等が動作するのは`.install().build_pack_utils().extensions()`となります。

では、`composer install`が実行される直前に

```
.env.exampleが存在するかつ、.envファイルがない場合は、.env.exampleを.envにリネームする
```
という処理を追加してみましょう。

###### ちなみに、処理を追加するメリット…
`Laravel`を使う際に必須なのが、`.env`ファイルです。これにはデータベースへの接続情報などが格納されており、`Laravel`の動作には必須なのですが、そのファイルの性質上、Gitリポジトリには存在させてはいけないファイルとなります。
そのため、リポジトリからCloneしてデプロイしようとするとファイルが無い為にデプロイが失敗する事があります。
**composer.jsonにスクリプト書けば？**という声が聞こえてきたような気がしますが、ここは`build-pack`のカスタマイズで押し通します。


実体として呼び出されているのは、`extensions/composer/extension.py`です。
190行目近辺を抜粋してみます。

```py
    def _compile(self, install):
        self._builder = install.builder
        self.composer_runner = ComposerCommandRunner(self._ctx, self._builder)
        self.clean_cache_dir()
        self.move_local_vendor_folder()
        self.copy_dotenv()
        self.install()
        self.run()

    def clean_cache_dir(self):
        if not os.path.exists(self._ctx['COMPOSER_CACHE_DIR']):
            self._log.debug("Old style cache directory exists, removing")
            shutil.rmtree(self._ctx['COMPOSER_HOME'], ignore_errors=True)

    def move_local_vendor_folder(self):
        vendor_path = os.path.join(self._ctx['BUILD_DIR'],
                                   self._ctx['WEBDIR'],
                                   'vendor')
        if os.path.exists(vendor_path):
            self._log.debug("Vendor [%s] exists, moving to LIBDIR",
                            vendor_path)
            (self._builder.move()
                .under('{BUILD_DIR}/{WEBDIR}')
                .into('{BUILD_DIR}/{LIBDIR}')
                .where_name_matches('^%s/.*$' % vendor_path)
                .done())
            
    def copy_dotenv(self):
        dot_env_path = os.path.join(self._ctx['BUILD_DIR'],
                                   'htdocs',
                                   '.env')
        dot_env_exapmle_path = os.path.join(self._ctx['BUILD_DIR'],
                                   'htdocs',
                                   '.env.example')
        if not os.path.exists(dot_env_path) and os.path.exists(dot_env_exapmle_path):
            os.rename(dot_env_exapmle_path,dot_env_path)
            
    def install(self):
        self._builder.install().package('PHP').done()
        if self._ctx['COMPOSER_VERSION'] == 'latest':
            dependencies_path = os.path.join(self._ctx['BP_DIR'],
                                             'dependencies')
            if os.path.exists(dependencies_path):
                raise RuntimeError('"COMPOSER_VERSION": "latest" ' \
                    'is not supported in the cached buildpack. Please vendor your preferred version of composer with your app, or use the provided default composer version.')

            self._ctx['COMPOSER_DOWNLOAD_URL'] = \
                'https://getcomposer.org/composer.phar'
            self._builder.install()._installer.install_binary_direct(
                self._ctx['COMPOSER_DOWNLOAD_URL'], None,
                os.path.join(self._ctx['BUILD_DIR'], 'php', 'bin'),
                extract=False)
        else:
            self._builder.install()._installer._install_binary_from_manifest(
                self._ctx['COMPOSER_DOWNLOAD_URL'],
                os.path.join(self._ctx['BUILD_DIR'], 'php', 'bin'),
                extract=False)
```
`_compoile`から呼び出されているself.installが、実際に`composer install`コマンドとなるので、この直前に`self.copy_dotenv()`を仕込んでみました。
最後にforkしたリポジトリに、この修正内容を反映(commit)しておきます。

## 実際に動作確認をしてみましょう。
### まずは失敗パターン
[サンプルリポジトリ](https://github.com/motuo1201/demoLaravelIbmCloud)をローカルにCloneして

```
ibmcloud cf push
```

してみましょう。下記のエラーでこけるはずです。

```
 php htdocs/artisan key:generate
In KeyGenerateCommand.php line 96:

  file_get_contents(/tmp/app/htdocs/.env): failed to open stream: No such fil
  e or directory
```
理由は明白で、`.envが無いからですね。`

では、次に参照する`manifest.yml`を変更しましょう。変更例は以下の通りです。
buildpackの指定を変更しています。これを保存して、もう一度、`cf push`してみましょう。

```yml
applications:
- path: .
  name: testLaravel
  memory: 128M
  buildpack: https://github.com/motuo1201/php-buildpack
env:
    APP_DEBUG: false
    CF_STAGING_TIMEOUT: 15
    CF_STARTUP_TIMEOUT: 15
```

余談ですが、`php-buildpack`ディレクトリ直下の`VERSION`ファイルの内容がbuild時に出力されます。
自作したBuildpackが当たっているかどうかは、この出力で確認しても良いでしょう。

```
Downloaded build artifacts cache (17.3M)
-------> Buildpack version 4.4.00
```

今回は無事にデプロイできました！

こんな感じで自作の`buildpack`を作ると、より開発が捗るかもしれません。
是非、挑戦してみて下さい。