【Elastic Beanstalk】1つの環境のみで完全なBlue-Greenデプロイを実現する方法【PHP】

# 目的

- `/var/www/html` からアプリケーションのソースを読み取る
- `/var/app/current -> /var/www/html` にシンボリックリンクを貼って現在のバージョンを動かしている
- `/var/app/ondeck` で次のバージョンのビルドを行う
- 最終的に `/var/app/ondeck` で `/var/app/current` を上書きしたい

Elastic Beanstalk において，なんとか1つの環境だけで Blue-Green デプロイができないかな，と試行錯誤した記録です。

# 手順

#### 前提

- `/var/app/ondeck` で既にビルドが終わっている。
- `cp`および`mv`がGNU版である。

#### コマンド

```bash
ln -sf /var/app/ondeck /var/www/new_html　&& mv -Tf /var/www/new_html /var/www/html
sleep 5 && rm -rf /var/app/current
cp -al /var/app/ondeck /var/app/current
ln -sf /var/app/current /var/www/new_html && mv -Tf /var/www/new_html /var/www/html
sleep 5 && rm -rf /var/app/ondeck
```

#### ポイント

- ディレクトリのシンボリックリンクを上書きできる`ln -snf`は**アトミックな操作ではない**ので，**`ln -sf`**と**`mv -Tf`**を組み合わせる必要がある。
  - 参考: [How to change symlinks atomically - Tom Moertel’s Blog](http://blog.moertel.com/posts/2005-08-22-how-to-change-symlinks-atomically.html)
- 新しいパスにコピーするときには，ハードリンクを再帰的に作成する**`cp -al`**を使うと，アプリケーションによるディスク使用領域が一時的に2倍に膨れ上がる無駄も避けられる。
  - 参考: [recursive - How to copy a directory recursively using hardlinks for each file - Unix & Linux Stack Exchange](https://unix.stackexchange.com/questions/202430/how-to-copy-a-directory-recursively-using-hardlinks-for-each-file)
- 読もうとするファイルが既に削除されていることを防ぐために，1リクエストの処理にかかりそうな時間ぶんあるいはその数倍だけ，`rm`の前にスリープを入れておくと安全。ここでは5秒と見なす。
- オートローディングのベースになる`__DIR__`や`__FILE__`は**シンボリックリンク解決後のパス**になるため，古いバージョンから新しいバージョンを部分的に読み込んでしまう不整合は発生しない。直接 `/var/www/html/src/foo/bar/Klass.php` などと，シンボリックリンクを含む絶対パスをハードコーディングしている場合はアウト。

# 検証

macOS上で実験しているので，`mv`の代わりに`gmv`，`cp`の代わりに`gcp`を使っています。

```bash:確認用シェルスクリプト
#!/bin/sh

# 準備
mkdir -p /tmp/example/current /tmp/example/ondeck
echo aaaa > /tmp/example/current/data.txt
echo bbbb > /tmp/example/ondeck/data.txt
ln -s /tmp/example/current /tmp/example/html

# 監視を開始
perl -e 'while (1) { open(my $file, "/tmp/example/html/data.txt") or die("ERROR!"); print <$file> }' &
sleep 1

# 置き換えを開始
ln -sf /tmp/example/ondeck /tmp/example/new_html && gmv -Tf /tmp/example/new_html /tmp/example/html
sleep 5 && rm -rf /tmp/example/current
gcp -al /tmp/example/ondeck /tmp/example/current
ln -sf /tmp/example/current /tmp/example/new_html && gmv -Tf /tmp/example/new_html /tmp/example/html
sleep 5 && rm -rf /tmp/example/ondeck

# 後始末
kill %%
rm -rf /tmp/example
```

ゼロダウンタイムになっているかどうかを確認します。

<img width="620" alt="スクリーンショット 2017-07-12 14.19.23.png" src="https://qiita-image-store.s3.amazonaws.com/0/25060/784384b9-7c2f-08de-9b63-ae568e165a25.png">

できました！

# Elastic Beanstalk への適用

`/opt/elasticbeanstalk/hooks/appdeploy/enact/01_flip.sh`に以下の変更を加えます。

```bash:【変更前】&#32;/opt/elasticbeanstalk/hooks/appdeploy/enact/01_flip.sh
#!/usr/bin/env bash
#==============================================================================
# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Amazon Software License (the "License"). You may not use
# this file except in compliance with the License. A copy of the License is
# located at
#
#       https://aws.amazon.com/asl/
#
# or in the "license" file accompanying this file. This file is distributed on
# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or
# implied. See the License for the specific language governing permissions
# and limitations under the License.
#==============================================================================

set -xe

EB_APP_STAGING_DIR=$(/opt/elasticbeanstalk/bin/get-config  container -k app_staging_dir)
EB_APP_DEPLOY_DIR=$(/opt/elasticbeanstalk/bin/get-config  container -k app_deploy_dir)

if [ -d $EB_APP_DEPLOY_DIR ]; then
  mv $EB_APP_DEPLOY_DIR $EB_APP_DEPLOY_DIR.old
fi

mv $EB_APP_STAGING_DIR $EB_APP_DEPLOY_DIR

nohup rm -rf $EB_APP_DEPLOY_DIR.old >/dev/null 2>&1 &
```

```bash:【変更後】&#32;/opt/elasticbeanstalk/hooks/appdeploy/enact/01_flip.sh
#!/usr/bin/env bash
#==============================================================================
# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Amazon Software License (the "License"). You may not use
# this file except in compliance with the License. A copy of the License is
# located at
#
#       https://aws.amazon.com/asl/
#
# or in the "license" file accompanying this file. This file is distributed on
# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or
# implied. See the License for the specific language governing permissions
# and limitations under the License.
#==============================================================================

set -xe

EB_APP_STAGING_DIR=$(/opt/elasticbeanstalk/bin/get-config  container -k app_staging_dir)
EB_APP_DEPLOY_DIR=$(/opt/elasticbeanstalk/bin/get-config  container -k app_deploy_dir)

ln -sf $EB_APP_STAGING_DIR /var/www/new_html && mv -Tf /var/www/new_html /var/www/html
if [ -d $EB_APP_DEPLOY_DIR ]; then
  sleep 5 && mv $EB_APP_DEPLOY_DIR $EB_APP_DEPLOY_DIR.old
fi

cp -al $EB_APP_STAGING_DIR $EB_APP_DEPLOY_DIR

ln -sf $EB_APP_DEPLOY_DIR /var/www/new_html && mv -Tf /var/www/new_html /var/www/html
sleep 5 && mv $EB_APP_STAGING_DIR $EB_APP_STAGING_DIR.old

nohup rm -rf $EB_APP_STAGING_DIR.old $EB_APP_DEPLOY_DIR.old >/dev/null 2>&1 &
```

オートスケールで反映させるためには， `.ebextensions` 配下にこの定義を書く必要があります。

```yaml:.ebextensions/01-customize-flip.config
files:
  "/opt/elasticbeanstalk/hooks/appdeploy/enact/01_flip.sh":
    owner: root
    group: root
    mode: "000755"
    content: |
      #!/usr/bin/env bash
      #==============================================================================
      # Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved.
      #
      # Licensed under the Amazon Software License (the "License"). You may not use
      # this file except in compliance with the License. A copy of the License is
      # located at
      #
      #       https://aws.amazon.com/asl/
      #
      # or in the "license" file accompanying this file. This file is distributed on
      # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or
      # implied. See the License for the specific language governing permissions
      # and limitations under the License.
      #==============================================================================

      set -xe

      EB_APP_STAGING_DIR=$(/opt/elasticbeanstalk/bin/get-config  container -k app_staging_dir)
      EB_APP_DEPLOY_DIR=$(/opt/elasticbeanstalk/bin/get-config  container -k app_deploy_dir)

      ln -sf $EB_APP_STAGING_DIR /var/www/new_html && mv -Tf /var/www/new_html /var/www/html
      if [ -d $EB_APP_DEPLOY_DIR ]; then
        sleep 5 && mv $EB_APP_DEPLOY_DIR $EB_APP_DEPLOY_DIR.old
      fi

      cp -al $EB_APP_STAGING_DIR $EB_APP_DEPLOY_DIR

      ln -sf $EB_APP_DEPLOY_DIR /var/www/new_html && mv -Tf /var/www/new_html /var/www/html
      sleep 5 && mv $EB_APP_STAGING_DIR $EB_APP_STAGING_DIR.old

      nohup rm -rf $EB_APP_STAGING_DIR.old $EB_APP_DEPLOY_DIR.old >/dev/null 2>&1 &
```

これをリポジトリに含めた上で，デプロイ実行中に `ll /var/www/html` を連打して確認してみました。

<img width="656" alt="スクリーンショット 2017-07-12 15.13.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/25060/8b7fa074-3261-e5e7-163e-89ea80ae33f2.png">

うまくいきました！

# 注意事項

PHPのようにソースコードを設置するだけでデプロイが完了するアプリケーションに関してはゼロダウンタイムですが，**Railsのようにアプリケーションサーバとして永続させるタイプのものの場合，スタートアップにかかる時間が存在し，古いファイルパスの情報を保持し続ける**ため，依然として

- ロードバランサのスワップ
- CNAMEスワップ

といった処理は必要です。また，データベースのスキーマ変更が発生する場合もこの方法では不可能です。

…とはいえども，スキーマ変更の伴わないPHPアプリケーションの変更に関しては，非常に簡単に対応できるのは美味しいところだと思います。また1つPHPの長所が増えましたね（笑