みなさんは 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 から削除
各段階でロールバックしても、旧スキーマと旧コードの組み合わせが常に成立します。ただしこれは運用ルールへの依存なので、仕組みで担保したいところです。