シンボリックリンクを活用した無停止デプロイとファイル削除を実装しました

こんにちは、インフラチームテックリードの櫻井です。

今回は prtimes.jp のデプロイ改善の一環としてシンボリックリンクを使った無停止デプロイと rsync --delete によるファイル削除とデプロイスクリプトの速度改善を行ったので紹介します。

目次

シンボリックリンクを使った無停止デプロイ

まず初めにシンボリックリンクを使った無停止デプロイについて紹介します。

今まで prtimes.jp のデプロイは実際に稼働しているアプリケーションのディレクトリにデプロイサーバーから直接 rsync コマンドを実行し、ファイルを更新していました。

しかしこの方法だとリリースによる変更量が大きい場合にダウンタイムが発生してしまう可能性があります。

例えば新しいライブラリをインストールしてそれを呼び出すコードを実装した場合、ライブラリよりも呼び出し元のコードを先に更新してしまうとライブラリが見つからずエラーになってしまいます。

これを防止するために、シンボリックリンクを使ってディレクトリを切り替えることで無停止でデプロイできる仕組みを実装しました。

準備

具体的にはまず以下のような準備を行います。

  1. ソースコードのルートディレクトリを向き先にしたシンボリックリンクを作成する
  2. ログファイルなどデプロイの前後で共有したいファイルを別ディレクトリに移動し、シンボリックリンク経由で参照する
  3. ソースコードのルートディレクトリ以下を全てコピーしたディレクトリを作成する

上記の準備を行うと例えば以下のようなディレクトリ構造になります。

/var/www
    ├── app -> /var/www/app1
    ├── app1
    │   ├── index.php
    │   └── log -> /var/www/app_shared/log
    ├── app2
    │   ├── index.php
    │   └── log -> /var/www/app_shared/log
    └── app_shared
        └── log

デプロイ

次にデプロイスクリプトを以下のような手順で実行するように変更します。

  1. シンボリックリンク/var/www/appの向き先(/var/www/app1)を取得する
  2. /var/www/appの向き先でない方のディレクトリ(/var/www/app2)に対してrsyncを実行する
  3. /var/www/appの向き先をrsyncを実行したディレクトリ(/var/www/app2)に変更する

切り替え先のディレクトリを2つのみにしているのは、なるべくrsync時の差分が大きくならないようにすることで高速にrsyncを実行できるようにするためです。

このように先にrsyncを完了してからシンボリックリンクの向き先を変更することで、ダウンタイムが発生することなくデプロイを行うことができるようになります。

このようなシンボリックリンクの切り替えを使ったデプロイの仕組みはCapistranoやDeployerなどのデプロイツールでも実際に使用されています。

今回のデプロイスクリプトはシンプルな実装で十分なので、オーバーヘッドが少なく高速に実行できるシェルスクリプトでデプロイスクリプトを実装しました。

シェルスクリプトでの実装例を載せておくので、自分で実装してみたいという方は参考にしてみてください。

#!/bin/bash

# 実行コマンドを出力する(x)、エラー時に終了する(e)、変数が未定義のときにエラーにする(u)
set -xeu

# TODO: デプロイ先のサーバーやディレクトリは各自の環境に合わせて変更してください
DEST_SERVERS=("deploy@192.168.0.1" "deploy@192.168.0.2" "deploy@192.168.0.3")
SYMLINK_PATH=/var/www/app
DEST_DIR_1=/var/www/app1
DEST_DIR_2=/var/www/app2
SRC_DIR=/home/deploy/src

# デプロイ実行
for DEST_SERVER in "${DEST_SERVERS[@]}" ; do
  # 現在シンボリックリンクの向いていない方のディレクトリにデプロイする
  SYMLINK_TO=$(ssh ${DEST_SERVER} "readlink ${SYMLINK_PATH}")
  if [ ${SYMLINK_TO} == ${DEST_DIR_1} ]; then
    DEST_DIR=${DEST_DIR_2}
  else
    DEST_DIR=${DEST_DIR_1}
  fi

    # ソースコード転送
  rsync -azKv --no-o --no-g ${SRC_DIR}/ ${DEST_SERVER}:${DEST_DIR}/

  # シンボリックリンクの向き先を変更
  ssh ${DEST_SERVER} "ln -s ${DEST_DIR} ${SYMLINK_PATH}.org && mv -Tf ${SYMLINK_PATH}.org ${SYMLINK_PATH}"
done

注意点

シンボリックリンクを使った無停止デプロイを行う場合、注意しなければならない点がいくつかあったので紹介します。

シンボリックリンクを別名で作ってから上書きする

先ほどの実装例にも書きましたが、シンボリックリンクの向き先を変更する際に直接上書きするのではなく別名で作成してからmvコマンドで上書きをする必要があります。

これはシンボリックリンクを直接上書きしてしまうと上書きのタイミングでダウンタイムが発生してしまう可能性があるためです。

# 良い例 : 先に別名でシンボリックリンクを作成してからmvコマンドで上書きする
$ ln -s ${DEST_DIR} ${SYMLINK_PATH}.org && mv -Tf ${SYMLINK_PATH}.org ${SYMLINK_PATH}

# 悪い例 : 直接シンボリックリンクを上書きするとダウンタイムが発生する可能性がある
$ ln -snf ${DEST_DIR} ${SYMLINK_PATH}

realpath_cache_sizeを0にする

PHPにはシンボリックリンクを解決した結果の絶対パスをキャッシュする機能が備わっています。

この機能は毎回シンボリックリンクや相対パスを解決する必要がなくなるためサーバーの負荷を軽減することができて便利ですが、シンボリックリンクを切り替える前のキャッシュが使われてデプロイ前の古いコードを読み込んでしまう可能性があります。

これを解決するためにphp.inirealpath_cache_size = 0を指定する必要があります。

PHPのrealpath_cacheについては以下の記事で詳しく解説されているので参考にしてみてください。

https://www.klab.com/jp/blog/tech/2016/1062120304.html

アプリケーション内でrealpath()を使用している箇所がないか確認する

先ほどrealpath_cacheの注意点について書きましたが、アプリケーション内でもrealpath()関数を使用している箇所がないか確認する必要があります。

realpath()関数は引数として与えられたパスのシンボリックリンクや相対パスを解決して絶対パスに変換します。
https://www.php.net/manual/ja/function.realpath.php

実際にprtimes.jpでもrealpath()を使って取得した絶対パスのハッシュ値をキャッシュファイルのパスの一部に使用している箇所があったため、シンボリックリンクを切り替えたことで過去に生成したキャッシュを見つけることができなくなりパフォーマンスが大きく低下してしまうという事故がありました。

絶対パスを直接記述している箇所を変更する

例えばSERVER_ROOT = '/var/www/app1' のように直接絶対パスを記述している箇所はSERVER_ROOT = '/var/www/app'のようにシンボリックリンクのパスに書き換える必要があります。

これは容易に想像できるので大丈夫だと思いますが、これで満足してrealpath()の確認を怠ると痛い目を見るかもしれません(体験談)。

rsync --delete を使ってファイルを削除する

次にrsync --deleteを使ってデプロイ時にファイルを削除できるようにしたことについて紹介します。

rsyncコマンドは --delete オプションをつけることで転送元で削除されたファイルを転送先でも削除することができます。

しかし今までprtimes.jpのデプロイスクリプトではrsync時に --delete オプションが付与されておらず、Git上で削除しても実際のサーバー上からは削除されないという状態でした。

この対策のために過去にファイル削除用のスクリプトを作成してデプロイ後に実行するということもしていましたが、リファクタリングデーの実施によりファイルを削除する機会が増え、rsync -–delete を使ってデプロイ時にファイルを削除したいと考えるようになりました。

あわせて読みたい
PR TIMESにおけるリファクタリングデー
PR TIMESにおけるリファクタリングデーこんにちは、業務委託でPR TIMESにJOINしているuzulla (”うずら”, twitter, GitHub)です。本エントリではPR TIMESで行っているリファクタリングデーについてお話したい...

準備

rsync の --delete オプションを有効化するために、まずどのファイルが削除されるのか調べる必要があります。

rsync 実行時に--dry-run (-n) オプションと--verbose (-v)オプションを有効化することで更新されるファイルの一覧を調べることができるので、grep コマンドと awk コマンドを使って絞り込むことで削除されるファイルパスの一覧を取得することができます。

$ rsync -azK --dry-run --verbose ${SRC_DIR}/ ${DEST_SERVER}:${DEST_DIR}/ | grep deleting | awk '{print $2}'

こうして取得した削除予定のファイルパスの一覧には削除して良いもの(過去にGit上から削除されたファイル等)と削除してはいけないもの(シンボリックリンクやログファイル等)が含まれています。

削除してはいけないものが削除されないようにするために、以下のような対応を行います。

  • シンボリックリンクの場合 : あらかじめデプロイサーバー側でデプロイ先サーバーのシンボリックリンクと同じ向き先のシンボリックリンクを作成しておく、または rsync の --exclude オプションで指定して除外する。
  • ディレクトリまたはファイルの場合 : 別ディレクトリに移動してシンボリックリンク経由で参照するように変更する、または rsync の --exclude オプションで指定して除外する。

実行

削除してはいけないファイルなどの対応が全て完了したら rsync に --delete オプションをつけて実行することでファイルを削除することができます。

ただし、もし削除してはいけないファイルを見落として削除してしまった場合は切り戻しが必要になるので、初回は直接 rsync --delete を実行するよりも削除予定のファイルを一時ディレクトリなど別の場所に退避させておくことをおすすめします。

削除予定のファイルパス一覧(delete_files.txt)を入力として受け取ってファイルを移動するPHPスクリプトを載せておくので参考にしてみてください。

<?php

// サーバー上のアプリケーションのルートディレクトリ
$root_dir = '/var/www/app1/';
// 削除予定のファイルの移動先ディレクトリ
$tmp_dir = '/var/tmp/deleted_files/app1/';
// 削除予定のファイルパス一覧
$fp = fopen('delete_files.txt', 'r');
$dir_permission = 0777;

while (($filepath = fgets($fp)) !== false) {
    $filepath = trim($filepath);
    if (empty($filepath)) {
        echo 'filepath is empty' . $filepath ."\n";
        continue;
    }

    if (is_link($root_dir . $filepath)) {
        echo $root_dir . $filepath . " is symlink to " . realpath($root_dir . $filepath) . ".\n";
        continue;
    }
    if (is_dir($root_dir . $filepath)) {
        echo $root_dir . $filepath . " is directory.\n";
        echo 'rmdir ' . $root_dir . $filepath . "\n";
        rmdir($root_dir . $filepath);
        continue;
    }

    $dirname = dirname($tmp_dir . $filepath);
    echo 'mkdir ' . $dirname . "\n";
    mkdir($dirname, $dir_permission, true);

    echo 'rename ' . $root_dir . $filepath . ' to ' . $tmp_dir . $filepath . "\n";
    rename($root_dir . $filepath, $tmp_dir . $filepath);
}

fclose($fp);

削除予定のファイルを全て移動または削除したらもう一度以下のコマンドを実行して差分を確認します。

$ rsync -azK --dry-run --verbose ${SRC_DIR}/ ${DEST_SERVER}:${DEST_DIR}/ | grep deleting | awk '{print $2}'

差分から削除予定のファイルがなくなったら完了です。

デプロイスクリプトのrsync に --delete オプションをつけて毎回デプロイ時に自動的に削除されるようにしましょう。

デプロイ速度の改善

上記の改善を行うにあたって既存のデプロイスクリプトは古くなっていて変更しづらくなっていたため、デプロイスクリプトを既存のRubyで書かれたものから新たにシェルスクリプトで書き直しました。

既存のデプロイスクリプトは1回のコマンドで1台のサーバーしかデプロイすることができず、複数台のサーバーにデプロイするために毎回コマンドを実行してビルド処理を行わなければならないため非常に時間がかかっていました。

新しいデプロイスクリプトではビルド後に全てのサーバーにデプロイを行うためコマンド1回でデプロイを完了することができるようになり、デプロイ時間は約6分30秒から2分0秒と3倍以上の速さになりました。

実際に開発メンバーからもデプロイが高速化されたことで喜びの声が上がっています笑

まとめ

今回は prtimes.jp のデプロイスクリプトにシンボリックリンクを使った無停止デプロイとrsync --deleteを使ったファイル削除とデプロイスクリプトの速度改善について紹介しました。

本記事が同じようなことを試そうとしている方の参考になれば幸いです。

この記事を書いた人

2018年に京都大学を卒業後、PR TIMESに新卒入社。
現在は開発チームのサーバーサイドエンジニア兼インフラチームのテックリードを担当しています。

目次
閉じる