オールアバウトTech Blog

株式会社オールアバウトのエンジニアブログです。

既存batchのPHP、Laravelのアップグレードとcronjob化したお話

f:id:allabout-techblog:20200323115646p:plain オールアバウトの新卒1年目エンジニアが投稿する企画「テックブログ新卒週間2020」を開催します。

本記事はオールアバウトの運営・開発を担うグループに所属している@k_takamatsuからお送りします。 現在、オールアバウトとグループ会社のオールアバウトナビ両方で業務を行なっています。 基本的にどちらもLaravelを使ったWebアプリの開発が主です。

本日はオールアバウトナビで行なった、システムのバージョンアップとCronJob化についてお話しします。

既存batchシステムのPHP、Laravelバージョンアップ

現在オールアバウトナビ(以降ナビ社)は、「カジュアルに知性をアップデート」をコンセプトにした「SNS配信型ウェブメディア」のcitrusを運営しています。 citrus-net.jp

こちらのサービスに関するbatchシステムを改修する際に、PHPのバージョンが古くて使用できないpackageが出てきたため、バージョンアップをする必要がありました。 また、現状batchはGCPVMインスタンスで動いているのですが、この点に関して料金と何よりインスタンス自体の保守・運用などのコストが掛かる、という事からbatchをサーバレス化したいという話が上がりました。 元々ナビ社ではKubernetesでwebアプリを運用していたことから、batchシステムをCronJob化することになりました。

このような経緯から、私はこのbatchシステムのバージョンアップとCronJob化を行う事になりました。 しばらくはPHPとLaravelのバージョンアップについてお話をします、CronJobについては後ほど記載します。

既存システムの構成を把握

実際に作業を始めるにあたり、まずは現状batchシステムがどう動作しているのか知る所から始めました。

調査した結果現状の構成は以下の通りです。

古くからあるシステムという事と、社内の閉ざされた環境で動作していたため長い間アップグレードは行われていない事がわかりました。 また、LaravelもLTSの2世代前と古かった為、PHPだけでなくLaravelもアップグレードしようという事になりました。 PHPは7系に、Laravelは6系に移行する事となります。

既存システムの動きとアップデート後のシステムの動きをどう担保するか

PHPとLaravelのバージョンアップやりますって話になりましたが、実は入社するまでフレームワークを使用した開発経験がありませんでした。 またPHPに限らず、プログラム言語のアップデート対応などの知識・経験もありません。 そのため、バージョンアップ対応に自信がなく最悪バグを残したままリリースしてしまうのではないか?といった不安が当初強かったです。

また、今回はPHPのバージョンアップ、Laravelのバージョンアップ、CronJob化を一気に対応してリリースする方針となり、 それぞれ動作の担保をどのように確保するか、また問題が生じた際の切り分けをどうすればいいかという点で凄く悩みました。

色々考えた結果、動作の担保としてUnitテストを書くのが一番だと思ったのですが、既存のソースを見る限りUnitテストを入れるとなると多少リファクタする必要がありそうでした。 ですが、今回はそこまで時間を掛ける事ができないためUnitテストの導入は断念しました。 その代わり静的チェックのPHPStanを使用する事で最低限の担保を行う事にしました。 ※本当はPHPccを使用したかったのですが、サポートが終了しているためPHPStanを用いる事としました。

また、問題を切り分けるためにローカル環境に現状の動きを再現するDockerコンテナを作成し、そのコンテナ内で言語・フレームワークのアップグレードを段階的に対応する事にしました。 CronJob化については手元でPHP・Laravelの移行が完了した後に対応する事で切り分けました。

既存システムの動作をDockerコンテナで再現

まずは現状と同じ動作を行う環境を作成する必要があります。

各種設定値やcomposerで管理しているpackageなど、それぞれ調査した後にDockerfileを作成します。

ベースイメージは公式のDockerHubからphp:5.5-cliを落としてきて、それぞれ必要なモジュールをインストール、アプリイメージを作成していきます。 一から環境構築など行なった事がなかったため、この環境作りが個人的にかなり苦戦しました。

ネットの情報などを利用して記述していたのですが、次から次に襲いかかってくるエラーに心打たれ・・・それでも何度もtry & errorを繰り返してようやく動作するコンテナが立ち上がりました。 同じようにデータベース用のDockerfileも作成し、テスト用環境のデータベースからデータを取得してデータの準備も完了。 バッチとデータベースのコンテナはdocker-compose.yamlで管理する形にします。

これで無事既存システムの動きの再現を行う事ができました。 知らない事だらけだったので、この時点でかなり勉強になりました。

PHPのバージョンアップ

既存の動作をする壊していい環境を作れたので、ここからは思う存分変更を加えてPHP5.5から7系へのバージョンアップを行います。 ※壊していい環境を自分で作る事で、心理的安全を確保できたのはかなり良かったです

先ほど作成したDockerfileで使用していたベースイメージをphp:5.5-cliから7系に変更。 これでコンテナを立ち上げてPHPのバージョンを確認すると、無事php7に変更されていることが確認出来ました。 これでPHP7への切り替えは終了です。ここから実際に非推奨となった関数であったり、消されてしまった関数だったりの対応を行う必要があります。

ここからは先程述べたとおりPHPStanを用いて修正を行なっていきます。 まずは出来上がっているLaravelの環境にPHPStanを組み込んでいきます。

PHPStanの導入

導入は至って簡単で、composer.jsonに書くだけで入ってくれます。

インストールされたら、実際にチェックコマンドを実行。 /app# vendor/bin/phpstan analyse -l 0 ./app

すると沢山のエラーが出力されました。

119/119 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
------ ------------------------------------------------------------------
  Line   Console/Commands/〇〇〇〇〇〇Command.php
 ------ ------------------------------------------------------------------
  46     Call to static method info() on an unknown class Log.
  56     Call to static method info() on an unknown class Log.
  80     Call to static method beginTransaction() on an unknown class DB.
  93     Call to static method rollback() on an unknown class DB.
  94     Call to static method Error() on an unknown class Log.
  97     Call to static method commit() on an unknown class DB.
  98     Call to static method info() on an unknown class Log.
  102    Call to static method rollback() on an unknown class DB.
  103    Call to static method Error() on an unknown class Log.
  117    Call to static method info() on an unknown class Log.

こんなエラーが延々と出力されて、合計198件のエラーが検出されます。 うわー、これ手に負えないやつじゃね?って思って居たのですが、よくエラーを見てみるとunknown classがたくさんあり、かつそのclassはLaravel独自のclass名である事に気づきました。

ネットで調べてみると、Laravel 5 IDE Helper Generatorなるものがあり、LaravelのIDE補完を可能にすると上記のエラーの類は消えることがわかりました。 なのでLaravel 5 IDE Helper Generatorを導入します。

Laravel 5 IDE Helper Generatorの導入

これも同様にcomposerでインストールを行います。 composer require --dev barryvdh/laravel-ide-helper ~2.0 ※Laravel5以上からは2.0を指定するようです

インストールが完了したらProviderに登録します。

'providers' => array(
    // ↓ 下記を追記 ↓
    'Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider',
),

php artisan ide-helper:generateを実行し、_ide_helper.phpが生成されれば終了です。

この_ide_helper.phpの中に、Laravel独自のクラスが補完されているため、PHPStan実行時に先ほどのファイルを読み込ませると良いようです。

PHPStanで補完のファイルを読み込ませる

PHPStanは実行する際に、設定ファイルを使って実行する事ができます。

そこで、以下のファイルを作成 phpstan.neon

parameters:
    level: 0
    autoload_files:
        - _ide_helper.php
    paths:
        - ../../app

実行時に設定ファイルを指定 vendor/bin/phpstan analyse -c vendor/bin/phpstan.neon これで実行結果が変化するはずです。

実際に5系から7系にあげた後、静的チェックツールで確認

Laravelのide_helperを読み込ませた結果、テストのエラー結果が変わりました。

------ -----------------------------------------------
  Line   Console/Commands/〇〇〇〇Command.php
 ------ -----------------------------------------------
  131    Undefined variable: $hoge
 ------ -----------------------------------------------
 ------ --------------------------------------------------------
  Line   Console/Commands/〇〇〇〇Command.php
 ------ --------------------------------------------------------
  154    Caught class App\Console\Commands\Exception not found.
 ------ --------------------------------------------------------
 ------ --------------------------------------------------------
  Line   Console/Commands/〇〇〇〇Command.php
 ------ --------------------------------------------------------
  160    Caught class App\Console\Commands\Exception not found.
 ------ --------------------------------------------------------
 ------ --------------------------------------------------------
  Line   Console/Commands/〇〇〇〇Command.php
 ------ --------------------------------------------------------
  166    Caught class App\Console\Commands\Exception not found.
 ------ --------------------------------------------------------
 ------ --------------------------------------------------------
  Line   Console/Commands/〇〇〇〇Command.php
 ------ --------------------------------------------------------
  168    Caught class App\Console\Commands\Exception not found.
 ------ --------------------------------------------------------

198件あったのが合計24件まで削除されました。 これでようやく対応できるエラー量となりました。

実際に検知されたエラーとしては、使用されていない変数が残っていたり、非推奨関数が使われているよってエラーだったり、こんな書き方出来ないよってエラーでした。 1つ1つエラーを解消しに行ったら対応する事ができ、最終的に静的チェックでエラーの検知を無くす事ができました。

想像していたよりも、PHP5から7への対応は比較的安易に終了する事ができたので良かったです。 ※一応不安だったので、非推奨になった関数や無くなった関数など検索しましたが、残っていませんでした。

Laravelのバージョンアップ

想定していたよりも、PHP7への移行は比較的スムーズに終わったためLaravelのアップグレードに入ります。

Laravelはアップグレードガイドを公式が出してくれています。

readouble.com

基本的にはこのアップグレードガイドに沿って行えば問題ないはずです。 今回のシステムに関してはLaravel5.1とかなり前のバージョンからのため、過去のアップグレードガイドを跨ぎながら進めました。

とは言え初めてLaravelのバージョンアップを行うため、ネットで手順などを調べてみました。 すると以下のように書いてありました。

  1. composer.jsonを修正して、ライブラリのバージョンを上げる
  2. composer updateしてみる
  3. 「composer.jsonに書いてあるライブラリはこのバージョンだと依存関係を解消できない」みたいなエラーが出る
  4. packagist.orgでバージョンと依存関係を調べつつ1.に戻る

ひとまずこのやり方に沿って進める事とします。

packageの依存関係

composer.jsonで指定しているバージョンを6.0に変更

"require": {
        "laravel/framework": "6.0.0",

実行してみると以下のようにエラーが発生

Updating dependencies (including require-dev)
Your requirements could not be resolved to an installable set of packages.
  Problem 1
    - Installation request for laravel/framework 6.0.0 -> satisfiable by laravel/framework[v6.0.0].
    - hogehoge v2.0.0 requires illuminate/support ~5.0.17|5.1.*|5.2.*|・・・v5.6.9

とあるpackageの2.0.0はLaravel5.0から5.6までしかサポートしてませんよって怒られます。 Packagistで確認しても、そのように記述されてました。

packageによっては、単純にバージョンを上げればインストールできる場合もありますが、稀にLaravel6.0に対応していない or PHP7.3に対応していないpackageがあります。 それらに関しては代用できるpackageを探してインストールする作業が必要でした。

今回は大規模にアップグレードするため、殆どのpackageを更新しなければならず・・・結構骨の折れる作業でした。 もちろんですが、使用するpackageを変更すると既存のソースコードも変更しなければならず。 早々に壁にぶつかって泣きそうでした。

artisan コマンドが使えない

無事にcomposer updateが終わっていざ、aritsanコマンドを使ってみようかと思ったのですが上手くいきませんでした。 これはLaravelアップグレードによる弊害で、アップグレードガイドを元に整えていくと無事実行されます。

その他の原因として、cacheが邪魔して動かないといった事がありました。

発生したエラーの内容を見ると以前の設定のキャッシュが残っているのが原因でした。 そのためキャッシュを削除したら上手くいくだろうと思い、実行することに。 - php artisan config:clear - php artisan cache:clear

共にエラーが発生して削除できません。

仕方ないのでbootstrap/cache配下の4つのファイルを削除して再度実行するとうまくいきました。 - packages.php - services.php - config.php - routes.php

根本の原因はこのファイルなので、このファイルを生成しないように変更します。 composer.jsonで"php artisan optimize"が走っていたのが問題でした。 以下のように削除してcomposer update -> php artisan listを実行すると正常に動作しました。

"scripts": {
        "post-install-cmd": [
            "php artisan clear-compiled",
            "php artisan optimize"
        ],
        "post-update-cmd": [
            "php artisan clear-compiled",
            "php artisan optimize"
        ],

"scripts": {
        "post-install-cmd": [
            "php artisan clear-compiled"
        ],
        "post-update-cmd": [
            "php artisan clear-compiled"
        ],

これでcomposerのupdateもできるようになったしartisanコマンドが使えるようになったので、一旦アップグレード作業を停止します。

Laravel6になったためPHPStanで動作を静的にチェック

静的チェックを行なって、今の段階でエラーがないか確認をすることにしました。

Laravel6になり内部構成も変更されているため改めてide-helperを生成します。 生成後PHPStanを実行結果、見つかったerrorは17件。 内容としては既存のpackageのエラーであったり、アップグレードによるLaravelのエラーでした。 しっかりLaravelでのエラーも捉えれているので安心ですね。

このエラーを解消していけば、Laravel6系へのアップグレード対応が完了します。

PHPStanでエラーを潰す

チェックして検知されたエラーを、Laravelのドキュメントで公開されているアップグレードガイドを元に対応していきます。

途中packageの変更に伴い、処理を書き換える必要も出てきましたが、なんとか無事にPHPStanのエラーを潰す事ができました。

PHPStanでのチェックが通ったので実際に各コマンドを実行してみる

ここまでの対応で、PHP7.3へのアップグレード・Laravel6.0へのアップグレードが一応完了しました。

実際にBatchで実行されているartisanコマンドをそれぞれ実行して動作確認をしていきます。 事前に必要なテストデータをそれぞれ作成、実行した結果と現在VMインスタンスで稼働しているbatchシステムの実行結果を比較していって動作の確認を行っていきます。

この際に現在使われていないコマンドなどが複数残っており、不要な処理とそれに関連するファイルは削除してリポジトリの掃除も行っていきます。 そうやって全部のコマンドが正常に動作するのを確認して、PHPとLaravelのアップグレード対応は無事完了しました。

VMインスタンスで動作しているbatchをGKEのpodで動かす(CronJob化)

ここまで大きな対応として3つあるうちの2つが完了しました。

残りは3つ目のCronJob化となります。 普段業務でDockerであったり、kubernetesであったり使用しているのでGKEに関してはなんとなーくわかっていたのですが。 CronJobに関しては全く知らない状態でした。

なので、まずはCronJobって何なのか調べるところからスタートです。 具体的に参考になったのは以下の記事でした、ありがとうございます。

cloud.google.com

cstoku.dev

実際にCronJobってどんな物か把握できたら、作業に取り掛かり始めます。

CronJobのyamlファイル作成

Googleが提供してくれているCronJobのドキュメントを参考にyamlファイルを作成していきます。

CronJobは基本的に1yamlファイルにつき1jobを実行します。 そのため、batchで動かしているコマンドの数ぶんCronJobのyamlファイルを書く必要がありました。

実際に作成したファイルの一部分です

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: hogehoge-ranking
spec:
  schedule: "0 * * * *" # UTC時間で実行される
  successfulJobsHistoryLimit: 0
  failedJobsHistoryLimit: 0
  jobTemplate:
    spec:
      template:
        metadata:
          labels:
            name:hogehoge-ranking
        spec:
          containers:
          - name: hogehoge-ranking
            resources:
              requests:
                cpu: 〇〇
                memory: 〇〇
              limits:
                memory: 〇〇
            env:
              - name: TZ
                value: "Asia/Tokyo"
            image: GCRにあるImageのパス
            command: ["php", "artisan", "hogehogeRanking"]
          restartPolicy: Never
      backoffLimit: 1

ドキュメントなどを見て各種設定項目を追加していきました。 実行時間がUTC時間なのは気をつけなければいけません。 実行終了したpodを何世代分残すであったり、それぞれのjobを並行実行させていいのかダメなのかなど、結構考慮しなければいけない点が多かったです。 設定項目に関しては、先程紹介した記事に詳細がありますので、気になる方はそちらをご覧ください。

GKE上にデプロイしてみる

こんな感じでyamlファイルを作成したら実際にKubernetes上にデプロイして動作の確認を行います。 弊社ではCIツールとしてCircleCIを採用しています。 ※詳細は以下の記事に載っています

allabout-tech.hatenablog.com

そのためCircleCIのconfig.ymlで作成したCronJobのyamlファイルをapplyするよう修正します。 ここで問題になるのが、今までCircleCIでデプロイしていたのはVMインスタンス上でありGKE上にデプロイするような処理ではないということです。

CircleCIの設定

CronJobの確認を行う前にCircleCIのconfig.ymlを修正する必要がありました。 こちらに関しては詳細は省きますが、それぞれ必要な項目を設定して最終的に以下のようなフローにしました。

f:id:allabout-techblog:20200319110139p:plain

STGとPROに関してはそれぞれworkflowsでマージされたブランチを元に制御しています。

デプロイされた後の動作確認

まずはkubectl get cronjobでCronJobができているか確認

kubectl get cronjob

NAME                      SCHEDULE    SUSPEND   ACTIVE   LAST SCHEDULE   AGE
hogehoge-ranking   0 * * * *             False           0              <none>                    6m59s

できている事を無事に確認できました。 jobが実行された後にjobの確認もできます。

kubectl get job

NAME                      COMPLETIONS   DURATION   AGE
hogehoge-ranking   1/1                        6m11s           6m11s

無事にjobも実行されて完了していることがわかります。 実行結果を確認しに行ったら、問題なく動作している事が確認できました。

Kubernetes Loggingでログを取得できない

CronJobの設定項目でKubernetes Loggingでログを取得できるから実行した後のpodは残さない方針に決めた、と書きました。 しかし、その大事なログが取得できない事がわかり対応する必要がありました。

そもそもDockerの標準出力としてログを確認できない

Kubernetes Loggingで確認できないという話だったので、まずは手元で動かしているDockerコンテナのログとして出力できるか確認してみました。

$ docker-compose logs batch

ログ自体は出力されるのですが、それはコンテナを立ち上げた際のログが出力されるだけで、jobで実行した際に出力しているログが表示されません。 なぜだって頭を悩ませるのですが、そもそもLaravelバージョンアップに伴ってログの設定やってないって事に気付いたのでLaravelをいじる事にしました。

Laravelでログを標準出力するまで

そもそもLaravel5.1とLaravel6.xではログの設定の仕方が異なります。 Laravel5.6からログの設定はconfig\logging.phpで設定する事になっているため、まずはファイルの作成から。

以下参考

github.com

ここでlaravelはデフォルトで標準エラーは用意されているのがわかります。

'stderr' => [
    'driver' => 'monolog',
    'handler' => StreamHandler::class,
    'formatter' => env('LOG_STDERR_FORMATTER'),
    'with' => [
        'stream' => 'php://stderr',
    ],
],

今回出力するログは開始だったり終了だったりがわかるようなログだったため、標準エラーではなく標準出力としてログを吐きたいのです。 そのためstdoutに吐くような設定を追加する必要があります。 それに関してはこちらの記事を参考にしました、ありがとうございます。

qiita.com

そんなこんなでstdoutを作成、デフォルトで標準出力を使用するように設定してLaravelの設定は終了です。

相変わらずDocker logsで表示されない

意気揚々とバッチのコマンドを実行して、Docker logsを実行! それでもログは出力されていません。

laravelの方でログが取得できているか確認しに行くと、ちゃんと今日の日付でログファイルが作成されていました。 その為Laravelでの標準出力はうまくいっている、Dockerの方で出力をキャッチできていない事がわかりました。

これに関しては、php.iniの設定が必要だったりDocker for Mac故の罠なども多々あり色々つまづくのですが今回は割愛します。

Kubernete Loggingでログが表示される!!

それぞれ必要な設定を行った後に実際にCronJobをデプロイして、podが実行されたとにログビューアを見に行くと・・・ ありました!しっかりKubernetesがログを拾ってくれていました。

I 2020-03-06T03:00:28Z [2020-03-06 12:00:28] production.INFO: ranking command start.

I 2020-03-06T03:00:28Z [2020-03-06 12:00:28] production.INFO: ranking command end.

これにて、ようやくKubernete Loggingで標準出力したいって目標は達成できました。

実際にCronJobとして処理が実行されるか確認

一通り作業が終わったら、今度はテスト環境にdeployして動作を確認します。 KubernetesUTC時間のため、CronJob.yamlファイルでの時間指定にだけ気をつけてdeploy。

それぞれ実行時間になった際に正しく動作している事を確認する事ができました。 あとは本番用のCronJob.yamlファイルとCircleCI対応を行ってdeployしたら終わりとなります。

本番のリリースはもう少し後なので、それが終わるまで気を抜かずにやりきりたいと思います。

終わりに

今回の仕事では主に以下の作業を行っています。

PHP・Laravelのバージョンアップ、Dockerアプリイメージの作成、CronJob化、CircleCIのフロー作成など。 今までの業務内容ではやる事の出来なかった内容だったので、かなり満足しています。 それも、新卒1年目で全ての作業を任せてもらえたのは大きな経験となりました。

今回の仕事を通して大事だなって思ったのは、初めての業務内容で不安がある際にその不安点をどれだけ取り除けるか工夫する事です。 私の場合手元の環境で本番と全く同じ動作を再現できた事、静的チェックツールを用いてエラーを検知する事で心理的安全を作りました。 そのおかげで、環境さえ作ってしまえばゴリゴリ変更を加えていけたので本当に大事だと感じました。

また、弊社では開発部とSREと別れていて基本的にアプリイメージの作成等はSREが行っていました。 それを、今回アプリイメージの作成、各種設定などインフラ寄りの作業も行う事ができ、自分の知見が広がった事を実感しています。 WEBアプリケーションエンジニアとしてこの辺りの知識も当然のように求められると思いますので、機会を見つけて今後も学んでいきたいです。