0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EC-CUBEのリリース方法を git pull から symlink アトミックデプロイに変えた話

0
Posted at

みなさんは EC-CUBE をどんな構成で動かして、どうやってリリースしていますか。

EC-CUBE は Symfony ベースですが、プラグインの入れ方や user_data の扱いなど独自の事情が多く、「Symfony のデプロイのベストプラクティス」がそのまま当てはまらない部分があります。日本語の実践記事も少なく、自分の場合はかなり手探りで進めました。

この記事では、git pull ベースのリリースから、ビルド成果物を symlink で切り替える方式に移行した話を書きます。EC-CUBE 固有の判断と、実際に踏んだ落とし穴が中心です。

環境

EC-CUBE 4.2
PHP 8.1
Web サーバー Apache 2.4 + php-fpm
インフラ VPS 1台
CI GitHub Actions

困っていたこと

リリース手順はこうでした。

cd /var/www/myapp/html
git pull
composer install          # ライブラリ更新を伴うときだけ
bin/console cache:clear --env=prod

/var/www/myapp/html は git clone したワークツリーそのもので、Apache の DocumentRoot がここを向いています。つまり git pull した瞬間に本番が変わります。

普段はこれで回るのですが、composer install を打つ日が怖い。

何が問題か

外部依存にリリース成否が握られる

composer install は実行のたびに Packagist や GitHub へ到達しに行きます。レート制限、ネットワークの一時的な不調、パッケージ側の事情。自分でコントロールできない要因でリリースが止まります。STG では通ったのに本番だけ失敗する、という再現しにくい事故も起きます。

途中で落ちると壊れた状態が残る

パッケージを1つずつ展開していく処理なので、途中で落ちると vendor/ が新旧混在のまま止まります。「動いていたコード」でも「動くはずの新しいコード」でもない状態で、切り戻しが git checkout では済みません。もう一度 composer install を回して収束させる必要があり、復旧に時間がかかります。

不整合なまま公開され続ける

git pull した瞬間からコードは新しくなりますが、vendor/ はまだ古いままです。install が完了するまでの数十秒〜数分、この不整合な状態が本番に露出します。パッケージ数が多い日やネットワークが遅い日は、その時間がそのまま伸びます。

要するに、失敗しうる処理を、公開中の環境で、外部依存ありで実行している構造そのものが問題でした。

どう変えたか

Before: 本番に git pull → ライブラリ更新時は本番で composer install
After:  CI でビルド → tar を転送 → releases/ に展開 → current を張り替え

本番から composer とレジストリ到達性の依存が消え、ロールバックは symlink の張り替えだけで数秒になりました。

検討した選択肢

案1: vendor/ を Git にコミットする

本番で composer を動かす必要がなくなります。導入コストは最小ですが、リポジトリが肥大化し composer.lock との整合性管理が煩雑になります。そして根本問題(pull した瞬間から本番が変わる)は解決しません。

案2: Docker 化してイメージ単位でデプロイ

本来はこちらが筋の良い解です。ただし EC-CUBE 4系をコンテナで本番運用するには、セッション・アップロードファイル・プラグインインストールの永続化を全部設計し直す必要があります。今回は「既存構成を変えずに、まず本番から外部依存を切り離す」ことを優先しました。中期的にはこちらに移行したいと考えています。

案3: ビルド成果物 + symlink アトミックデプロイ(採用)

  • 本番から composer とレジストリ到達性の依存が消える
  • 展開・検証と公開が分離され、失敗しても current を切り替えなければ無傷
  • 過去の release がそのままロールバック在庫になる
  • 既存構成(Apache + php-fpm on VPS)を変えずに済む

この方式自体は目新しいものではなく、Capistrano(2005年〜)が広めた定番の構成です。releases/ shared/ current というディレクトリ名まで踏襲しています。PHP なら Deployer が同じことをやってくれますし、Ansible 派なら Ansistrano があります。

今回あえて自作したのは、EC-CUBE 固有の処理(eccube:generate:proxies、eccube:composer:install、var/cache を共有しない判断など)が結局は自分で書く必要があり、ツールが肩代わりしてくれるのは symlink の張り替えと世代管理くらいだったためです。シェルスクリプト100行程度に収まる見込みが立ったので、中で何が起きているか全部見える方を取りました。

規模が大きくなったり、デプロイ先が増えたりしたら Deployer への移行を検討すると思います。

設計

releases / shared / current

/var/www/myapp/
├── releases/
│   ├── 20260901_120000/     ← 使い捨て。過去5世代を保持=ロールバック在庫
│   ├── 20260910_143000/
│   └── 20260915_091500/
├── shared/
│   ├── .env
│   ├── var/log/
│   ├── var/sessions/
│   └── app/PluginData/
└── current -> releases/20260915_091500

releases/ は「あるコミット時点の完成品」を使い捨てで並べたもの。一度展開したら書き換えません。書き換えないからこそ、過去の世代がそのままロールバック在庫になります。

shared/ は全リリースが同じ実体を参照し続ける場所。展開時に symlink を張ります。

current は実体を持たないシンボリックリンク。DocumentRoot をここに固定しておけば、リンクの向き先を変えるだけで公開が切り替わります。

判断基準はひとつです。

リリースで作り直せるか、跨いで生き残る必要があるか

何をどちらに置くか

ここで EC-CUBE の構成を把握する必要が出てきます。

プロジェクトルート/
├── index.php            ← エントリポイント(Symfony の public/index.php 相当)
├── .htaccess            ← rewrite ルール
├── composer.json
├── vendor/
├── bin/console
├── src/Eccube/          ← 本体コード(触らない)
├── app/
│   ├── Customize/       ← 独自実装
│   ├── Plugin/          ← プラグイン本体
│   ├── PluginData/      ← プラグインが実行時に書き込む領域
│   ├── template/
│   │   └── user_data/   ← 管理画面で作るページ用の Twig
│   ├── proxy/entity/    ← Entity 拡張プロキシ(自動生成)
│   └── config/
├── html/                ← 公開アセット置き場
│   ├── bundle/
│   ├── template/
│   ├── upload/          ← 管理画面からアップロードされる画像
│   └── user_data/
└── var/
    ├── cache/
    ├── log/
    └── sessions/

これを踏まえた割り当てです。

項目 置き場 理由
app/Plugin/ release プラグイン本体=コード
app/PluginData/ shared 実行時に書き込まれる状態
app/template/user_data/ release Git管理しているため(後述)
html/user_data/ release 同上
html/upload/ shared 管理画面からアップロードされる
var/log, var/sessions shared 実行時に書き込まれる
var/cache release 絶対パスが焼き込まれるため shared 不可
.env shared 環境固有

迷ったのが2箇所あります。

var/cache を shared にしてはいけない

var/ をまるごと shared にしたくなりますが、Symfony のコンパイル済みコンテナには絶対パスが焼き込まれます。リリースディレクトリが変わると破綻するので、release 側に持たせてデプロイ時に再生成します。

user_data は運用次第で答えが変わる

EC-CUBE の想定は「管理画面のページ管理機能で Twig を直接編集・生成する」です。つまり実行時に書き込まれる領域なので、一般的な解説では shared 側に置かれます。

ただし私たちは Twig をローカルで書いて Git 管理し、pull で配布していました。管理画面から直接編集はしません。つまり実質的にコードです。

これを shared に置くと、ロールバックしたときに「その世代のコード」と「現在の shared の中身」が食い違い、世代管理の意味がなくなります。また shared 上のファイルが壊れた場合、Git に正があるのに復旧経路がありません。

公式の想定ではなく、自分たちが実際にどう運用しているかで決める必要がありました。

実装

deploy.sh

CI が tar を置いた後に呼ばれるサーバー側スクリプトです。

#!/bin/bash
set -euo pipefail
shopt -s nullglob

BASE="$(realpath "$1")"      # /var/www/myapp
TARBALL="$2"                 # /tmp/release-prod.tar.gz
ENV="$3"                     # prod
RELEASE="${BASE}/releases/$(date +%Y%m%d%H%M%S)"
KEEP_RELEASES=5
CURRENT_SWITCHED=0

# --- 失敗時のフェイルセーフ ---
cleanup_on_failure() {
    local exit_code=$?
    [ "$exit_code" -eq 0 ] && return
    if [ "$CURRENT_SWITCHED" -eq 1 ]; then
        echo "!!! current 切替後のエラー。release は残します: $RELEASE !!!"
        return
    fi
    if [ -d "$RELEASE" ]; then
        echo "=== 失敗のため release を削除: $RELEASE ==="
        echo "!!! current は切り替わっていません。稼働環境への影響はありません !!!"
        sudo rm -rf "$RELEASE"
    fi
}
trap cleanup_on_failure EXIT

[ -f "$TARBALL" ] || { echo "リリース物がありません: $TARBALL"; exit 1; }
[ -d "${BASE}/shared" ] || { echo "shared がありません"; exit 1; }

# --- 展開 ---
mkdir -p "$RELEASE"
tar xzf "$TARBALL" -C "$RELEASE"

# --- shared への symlink ---
rm -rf "${RELEASE}/.env" "${RELEASE}/var/log" "${RELEASE}/var/sessions" \
       "${RELEASE}/app/PluginData" "${RELEASE}/html/upload"
mkdir -p "${RELEASE}/var" "${RELEASE}/app" "${RELEASE}/html"
ln -sfn "${BASE}/shared/.env"            "${RELEASE}/.env"
ln -sfn "${BASE}/shared/var/log"         "${RELEASE}/var/log"
ln -sfn "${BASE}/shared/var/sessions"    "${RELEASE}/var/sessions"
ln -sfn "${BASE}/shared/app/PluginData"  "${RELEASE}/app/PluginData"
ln -sfn "${BASE}/shared/html/upload"     "${RELEASE}/html/upload"

# --- EC-CUBE 固有の処理 ---
rm -rf "${RELEASE}/var/cache"
php "${RELEASE}/bin/console" eccube:composer:install
php "${RELEASE}/bin/console" doctrine:migrations:migrate --no-interaction
php "${RELEASE}/bin/console" eccube:generate:proxies --env="$ENV"
php "${RELEASE}/bin/console" cache:warmup --env="$ENV"

# --- 切替前チェック。ここで落ちれば current は無傷 ---
php "${RELEASE}/bin/console" --version || { echo "console起動失敗。切替中止"; exit 1; }

sudo chown -R www-data:www-data "$RELEASE"

# --- アトミック切替 ---
ln -sfn "$RELEASE" "${BASE}/current"
CURRENT_SWITCHED=1

sudo systemctl reload php8.1-fpm

# --- 古い世代の整理 ---
ls -1dt "${BASE}"/releases/*/ | tail -n +$((KEEP_RELEASES + 1)) | xargs -r sudo rm -rf
rm -f "$TARBALL"

echo "current -> $(readlink "${BASE}/current")"

肝は trap cleanup_on_failure EXIT です。current を切り替える前に落ちれば release ごと削除して稼働環境は無傷、切り替えた後なら調査用に残すという分岐にしています。

eccube:generate:proxies を入れているのが EC-CUBE 固有の部分です。プラグインやカスタマイズが Entity を拡張している場合、プロキシクラスを再生成しないと動きません。

GitHub Actions

環境 トリガー ブランチ
STG develop への push で自動 develop 固定
本番 workflow_dispatch で手動 master 固定

マージとリリースを切り離すのが要点です。master にマージしても何も起きず、Actions から手動実行して初めて本番に出ます。他の作業との順序調整が効きます。

name: Deploy to STG
on:
  push:
    branches: [develop]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    env:
      STG_BASE: ${{ vars.STG_BASE }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.1'

      - name: Build
        run: |
          cp .env.local.example .env
          composer install --prefer-dist --no-progress --no-plugins --no-scripts \
                           --no-dev --optimize-autoloader --no-interaction
          php bin/console assets:install --symlink --relative html --env=stg
          echo "branch=develop"                 >  .deploy-info
          echo "commit=${GITHUB_SHA}"           >> .deploy-info
          echo "deployed_at=$(date -Iseconds)"  >> .deploy-info
          tar czf /tmp/release-stg.tar.gz \
              --exclude=.git --exclude=var/log --exclude=./var/cache --exclude=./.env .

      - name: Set up SSH key
        run: |
          mkdir -p ~/.ssh && umask 077
          printf '%s\n' "${{ secrets.STG_SSH_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -H "${{ secrets.STG_HOST }}" >> ~/.ssh/known_hosts

      - name: Ship & deploy
        run: |
          scp -i ~/.ssh/deploy_key /tmp/release-stg.tar.gz deploy@${{ secrets.STG_HOST }}:/tmp/
          scp -i ~/.ssh/deploy_key deploy.sh rollback.sh deploy@${{ secrets.STG_HOST }}:~/
          ssh -i ~/.ssh/deploy_key deploy@${{ secrets.STG_HOST }} \
            "bash ~/deploy.sh \"$STG_BASE\" /tmp/release-stg.tar.gz stg"

      - name: Clean up SSH key
        if: always()
        run: rm -f ~/.ssh/deploy_key

deploy.sh を毎回リポジトリから転送しているのは、スクリプトの変更がすぐ反映されるようにするためです。releases の中に同梱したままだと、変更が1周遅れます。

本番側は workflow_dispatch で、UI からブランチを選べてしまうので明示的に弾いています。

name: Deploy to Production
on:
  workflow_dispatch:
    inputs:
      prod_base:
        description: 'デプロイ先のBASEパス(例: /var/www/myapp)'
        required: false
        type: string

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    environment: production          # Required reviewers で承認ゲート
    env:
      PROD_BASE: ${{ inputs.prod_base || vars.PROD_BASE }}
    steps:
      - name: ブランチとデプロイ先の検証
        run: |
          if [ "${GITHUB_REF}" != "refs/heads/master" ]; then
              echo "本番デプロイは master からのみ(現在: ${GITHUB_REF})"
              exit 1
          fi
          [ -n "$PROD_BASE" ] || { echo "PROD_BASE が未設定"; exit 1; }

      - uses: actions/checkout@v4
      # 以降は STG と同じ(--env=prod、secrets.PROD_* に差し替え)

Apache

<VirtualHost *:443>
    ServerName example.com
    DocumentRoot /var/www/myapp/current

    <Directory /var/www/myapp/current>
        Options FollowSymLinks      # ← current が symlink なので必須
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

Options FollowSymLinks がないと current を辿れず全リクエストが 403 になります。

sudoers

deploy.sh が使う3つのコマンドだけ NOPASSWD にします。

deployuser ALL=(root) NOPASSWD: /usr/bin/chown -R www-data\:www-data /var/www/myapp/releases/*
deployuser ALL=(root) NOPASSWD: /usr/bin/rm -rf /var/www/myapp/releases/*
deployuser ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.1-fpm

コロンは sudoers 内で特殊文字なので \: でエスケープします。

移行の進め方

本番を直接いじらず、コピーを別ディレクトリに作ってリハーサルしました。

# 1. コピーを作る(本番は無傷)
sudo rsync -a --exclude=.git /var/www/myapp/html/ /var/www/myapp_migration/

# 2. コピー側で移行スクリプトを実行
sudo /var/www/myapp_migration/init-deploy.sh /var/www/myapp_migration

# 3. 別ポートで検証用 vhost を立てて動作確認
#    (DBは共有なので書き込み系の操作は避ける)

# 4. 本番 conf の DocumentRoot を切り替え
sudo cp /etc/apache2/sites-available/myapp-le-ssl.conf \
        /etc/apache2/sites-available/myapp-le-ssl.conf.$(date +%Y%m%d_%H%M%S).bak
sudo vi /etc/apache2/sites-available/myapp-le-ssl.conf
sudo apache2ctl configtest && sudo systemctl reload apache2

切り戻しは .bak を戻して reload するだけです。

ハマったところ

DocumentRoot を current/html にしたらサイトが落ちた

「Symfony なら public/、EC-CUBE なら html/」という一般論で設定したら 404 の嵐になりました。前述の通り、このプロジェクトでは index.php がルート直下にあり、html/ はアセット置き場です。公開起点は current そのものでした。

ls して index.php の場所を確認してから決めましょう。

tar の出力先をアーカイブ対象の中に置かない

tar czf release.tar.gz ... .        # NG: file changed as we read it
tar czf /tmp/release.tar.gz ... .   # OK

tar が走査している最中に出力ファイル自身が肥大化していき、「読み取り中にファイルが変化した」と判定されて落ちます。

移行後は機密ファイルに実際に curl する

curl -I https://example.com/composer.json   # 403 を期待
curl -I https://example.com/.env            # 404 を期待

DocumentRoot がプロジェクトルートになる構成では、理屈上 composer.json や vendor/ も公開範囲に入ります。実際には .htaccess で防がれていましたが、「.htaccess が効いているから安全」と「設計上安全」は別物です。叩いて確認したところ、拒否リストから漏れていた拡張子が 200 OK を返していました。

sudo -n true は信用できない

事前確認では「パスワード不要」と出たのに、CI からは止まりました。

sudo: a terminal is required to read the password

sudoers には NOPASSWD がなく %sudo ALL=(ALL:ALL) ALL だけ。これは「何でも実行できる」であって「パスワードなしで」ではありません。対話セッションで直前に sudo を使っていたため、タイムスタンプキャッシュで「不要」に見えていただけでした。CI は非対話実行なのでキャッシュが効きません。

sudo -k                              # これを挟まないと確認にならない
sudo -n true && echo OK || echo NG

移行後に git status が全ファイル deleted になるのは正常

直下のファイルを releases/ へ mv しただけです。CI 経由に移行したならサーバー上の .git はもう不要なので削除で構いません。

今後やりたいこと

Docker 化

今回は見送りましたが、本来はこちらに寄せたいところです。CI でビルドする部分はそのまま流用できるので、移行の土台はできました。セッション・アップロードファイル・プラグインの永続化設計が宿題です。

DB マイグレーションのロールバック

コードは current を張り替えれば戻りますが、DB は共有資源なので戻せません。破壊的変更は2段階に分ける運用でカバーしています。

Release A: 新カラム追加 + 書き込み開始(旧カラムは残す)
Release B: 旧カラムの参照を削除
Release C: 旧カラムを DB から削除

各段階でロールバックしても、旧スキーマと旧コードの組み合わせが常に成立します。ただしこれは運用ルールへの依存なので、仕組みで担保したいところです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?