├── .editorconfig ├── .gitignore ├── LICENSE.txt ├── README.md ├── composer.json └── src ├── .ebignore ├── .platform ├── confighooks │ └── postdeploy │ │ └── 01setup-envvars.sh └── hooks │ └── postdeploy │ ├── 01setup-envvars.sh │ └── 02scheduler.sh ├── Console ├── AWS │ └── ConfigureLeaderCommand.php └── System │ ├── SetupLeaderSelectionCRONCommand.php │ └── SetupSchedulerCommand.php ├── ElasticBeanstalkCronProvider.php └── elasticbeanstalkcron.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | indent_style = space 9 | indent_size = 4 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Laravel template 3 | vendor/ 4 | node_modules/ 5 | 6 | # Laravel 4 specific 7 | bootstrap/compiled.php 8 | app/storage/ 9 | 10 | # Laravel 5 & Lumen specific 11 | bootstrap/cache/ 12 | storage/ 13 | .env.*.php 14 | .env.php 15 | .env 16 | 17 | # Rocketeer PHP task runner and deployment package. https://github.com/rocketeers/rocketeer 18 | .rocketeer/ 19 | ### JetBrains template 20 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 21 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 22 | 23 | /.idea 24 | /laravel-elasticbeanstalk-corn.iml 25 | # User-specific stuff: 26 | .idea/workspace.xml 27 | .idea/tasks.xml 28 | .idea/dictionaries 29 | .idea/vcs.xml 30 | .idea/jsLibraryMappings.xml 31 | 32 | # Sensitive or high-churn files: 33 | .idea/dataSources.ids 34 | .idea/dataSources.xml 35 | .idea/dataSources.local.xml 36 | .idea/sqlDataSources.xml 37 | .idea/dynamic.xml 38 | .idea/uiDesigner.xml 39 | 40 | # Gradle: 41 | .idea/gradle.xml 42 | .idea/libraries 43 | 44 | # Mongo Explorer plugin: 45 | .idea/mongoSettings.xml 46 | 47 | ## File-based project format: 48 | *.iws 49 | 50 | ## Plugin-specific files: 51 | 52 | # IntelliJ 53 | /out/ 54 | 55 | # mpeltonen/sbt-idea plugin 56 | .idea_modules/ 57 | 58 | # JIRA plugin 59 | atlassian-ide-plugin.xml 60 | 61 | # Crashlytics plugin (for Android Studio and IntelliJ) 62 | com_crashlytics_export_strings.xml 63 | crashlytics.properties 64 | crashlytics-build.properties 65 | fabric.properties 66 | ### Composer template 67 | composer.phar 68 | /vendor/ 69 | 70 | # Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file 71 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 72 | composer.lock 73 | 74 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Patrick Brouwers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel 6 - 12.x Task Scheduler with Elastic Beanstalk 2 | 3 | *Ensure one instance in an Elastic Beanstalk environment is running Laravel's Scheduler* 4 | 5 | A common [problem](https://stackoverflow.com/questions/14077095/aws-elastic-beanstalk-running-a-cronjob) [many](http://culttt.com/2016/02/08/setting-up-and-using-cron-jobs-with-laravel-and-aws-elastic-beanstalk/) [people](https://medium.com/@joelennon/running-cron-jobs-on-amazon-web-services-aws-elastic-beanstalk-a41d91d1c571#.i53d41sci) have encountered with Amazon's [Elastic Beanstalk](https://aws.amazon.com/elasticbeanstalk/) is maintaining a single instance in an environment that runs Laravel's Task Scheduler. Difficulties arise because auto-scaling does not guarantee any instance is run indefinitely and there are no "master-slave" relationships within an environment to differentiate one instance from the rest. 6 | 7 | Although Amazon has provided a [solution](http://stackoverflow.com/a/28719447/1469797) it involves setting up a worker tier and then, potentially, creating new routes/methods for implementing the tasks that need to be run. Yuck! 8 | 9 | **This package provides a simple, zero-setup solution for maintaining one instance within an Elastic Beanstalk environment that runs the Task Scheduler.** 10 | 11 | ## Amazon Linux 1 is deprecated and Amazon Linux 2023 is recommended 12 | 13 | Amazon Linux 1 (AL1) is already retired, even Amazon Linux 2 (AL2) will soon going to be unsupported too, so it's recommended to migrate to use Amazon Linux 2023 (AL2023) 14 | https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/using-features.migration-al.html 15 | Starts from this release will only support AL2023, please use previous releases for use in AL2, with this release will also drop support for Laravel 5 (PHP 7) since EB only support starting from PHP 8.1 16 | 17 | ## How Does It Work? 18 | 19 | Glad you asked! The below process **is completely automated** and only requires that you publish the `.platform` folder to the root of your application. 20 | 21 | ### 1. Use Elastic Beanstalk's Advanced Configuration to run CRON setup commands 22 | 23 | EB applications since AL2 can contain [platform hooks](https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/platforms-linux-extend.html) that provides advanced configuration for an EB environment, called `.platform`. 24 | 25 | This package provides a configuration file that runs two commands on deployment (every instance initialization) that setup the conditions needed to run the Task Scheduler on one instance: 26 | 27 | ### 2. Run `system:start:leaderselection` 28 | 29 | This is the first command that is run on deployment. It configures the instance's Cron to run **Leader Selection** at a configured interval (default = 5 minutes) 30 | 31 | ### 3. Run **Leader Selection** `aws:configure:leader` 32 | 33 | This is the **Leader Selection** command. It does the following: 34 | 35 | * Get the Id of the Instance this deployment is running on 36 | * Get the `EnvironmentName` of this Instance. (When running in an EB environment all EC2 instances have the same `EnvironmentName`) 37 | * Get all running EC2 instances with that `EnvironmentName` 38 | * Find the **earliest launched instance** 39 | 40 | If this instance is the earliest launched then it is deemed the **Leader** and runs `system:start:cron` 41 | 42 | ### 4. Run `system:start:cron` 43 | 44 | This command is run **only if the current instance running Leader Selection is the Leader**. It inserts another entry in the instance's Cron to run [Laravel's Scheduler](https://laravel.com/docs/12.x/scheduling). 45 | 46 | ### That's it! 47 | 48 | Now only one instance, the earliest launched, will have the scheduler inserted into its Cron. If that instance is terminated by auto-scaling a new Leader will be chosen within 5 minutes (or the configured interval) from the remaining running instances. 49 | 50 | ## Installation 51 | 52 | Require this package 53 | 54 | ```bash 55 | composer require "foxxmd/laravel-elasticbeanstalk-cron" 56 | ``` 57 | 58 | Then, publish the **.platform** folder and configuration file 59 | 60 | ```bash 61 | php artisan vendor:publish --tag=ebcron 62 | ``` 63 | 64 | Don't forget to add +x permission to the EB Platform Hooks scripts ([no longer required for Amazon Linux platform that released on or after April 29, 2022](https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/platforms-linux-extend.hooks.html#platforms-linux-extend.hooks.more)) 65 | 66 | ```bash 67 | find .platform -type f -iname "*.sh" -exec chmod +x {} + 68 | ``` 69 | 70 | ## Configuration 71 | 72 | In order for Leader Selection to run a few environmental variables must be present: 73 | 74 | * **USE_CRON** = true -- Must be set in order for Leader Selection to occur. (This can be used to prevent Selection from occurring on undesired environments IE Workers, etc.) 75 | * **AWS_ACCESS_KEY_ID** -- Needed for read-only access to EC2 client 76 | * **AWS_SECRET_ACCESS_KEY** -- Needed for read-only access to EC2 client 77 | * **AWS_REGION** -- Sets which AWS region when looking using the EC2 client, defaults to `us-east-1` if not set. 78 | 79 | These can be included in your **.env** or, for EB, in the environment's configuration section. 80 | 81 | ## Contributing 82 | 83 | Make a PR for some extra functionality and I will happily accept it :) 84 | 85 | ## License 86 | 87 | This package is licensed under the [MIT license](https://github.com/FoxxMD/laravel-elasticbeanstalk-cron/blob/master/LICENSE.txt). 88 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foxxmd/laravel-elasticbeanstalk-cron", 3 | "description": "Ensure only one Laravel instance is running CRON jobs in an EB environment", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "FoxxMD", 9 | "email": "FoxxMD@users.noreply.github.com" 10 | } 11 | ], 12 | "require": { 13 | "illuminate/support": "6 - 11", 14 | "illuminate/console": "6 - 11", 15 | "aws/aws-sdk-php": "^3.26", 16 | "lstrojny/functional-php": "^1.17" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "FoxxMD\\LaravelElasticBeanstalkCron\\": "src/" 21 | } 22 | }, 23 | "extra": { 24 | "laravel": { 25 | "providers": [ 26 | "FoxxMD\\LaravelElasticBeanstalkCron\\ElasticBeanstalkCronProvider" 27 | ] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/.ebignore: -------------------------------------------------------------------------------- 1 | .platform 2 | -------------------------------------------------------------------------------- /src/.platform/confighooks/postdeploy/01setup-envvars.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Prepare bash importable env file 3 | sed -r -n 's/.+=/export &"/p' /opt/elasticbeanstalk/deployment/env | sed -r -n 's/.+/&"/p' > /opt/elasticbeanstalk/deployment/envvars 4 | -------------------------------------------------------------------------------- /src/.platform/hooks/postdeploy/01setup-envvars.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Prepare bash importable env file 3 | sed -r -n 's/.+=/export &"/p' /opt/elasticbeanstalk/deployment/env | sed -r -n 's/.+/&"/p' > /opt/elasticbeanstalk/deployment/envvars 4 | -------------------------------------------------------------------------------- /src/.platform/hooks/postdeploy/02scheduler.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Crontab will place cron tasks as root if the user doesn't have a home directory. 3 | mkdir -p /home/webapp ; chown -R webapp:webapp /home/webapp 4 | 5 | # Adds a cron entry that checks for leader selection every 5 minutes 6 | sudo -u webapp bash -c ". /opt/elasticbeanstalk/deployment/envvars ; /usr/bin/php artisan system:start:leaderselection" 7 | 8 | # Does an initial leader selection check 9 | sudo -u webapp bash -c ". /opt/elasticbeanstalk/deployment/envvars ; /usr/bin/php artisan aws:configure:leader" 10 | -------------------------------------------------------------------------------- /src/Console/AWS/ConfigureLeaderCommand.php: -------------------------------------------------------------------------------- 1 | config = $config; 36 | } 37 | 38 | public function handle() 39 | { 40 | 41 | $client = new Ec2Client([ 42 | 'credentials' => [ 43 | 'key' => $this->config->get('elasticbeanstalkcron.key', ''), 44 | 'secret' => $this->config->get('elasticbeanstalkcron.secret', ''), 45 | ], 46 | 'region' => $this->config->get('elasticbeanstalkcron.region', 'us-east-1'), 47 | 'version' => 'latest', 48 | ]); 49 | 50 | $this->info('Initializing Leader Selection...'); 51 | 52 | // Only do cron setup if environment is configured to use it (This way we don't accidentally run on workers) 53 | if ((bool) $this->config->get('elasticbeanstalkcron.enable', false)) { 54 | // AL2 is using IMDSv2 which use session token 55 | // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html 56 | 57 | // get token first, check to see if we are in an instance 58 | $ch = curl_init('http://169.254.169.254/latest/api/token'); //magic ip from AWS 59 | curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1); 60 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 61 | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); 62 | curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-aws-ec2-metadata-token-ttl-seconds: 21600']); 63 | 64 | if ($token = curl_exec($ch)) { 65 | $ch = curl_init('http://169.254.169.254/latest/meta-data/instance-id'); 66 | curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1); 67 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 68 | curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-aws-ec2-metadata-token: ' . $token]); 69 | $instanceId = curl_exec($ch); 70 | $this->info('Instance ID: ' . $instanceId); 71 | 72 | // Get this instance metadata so we can find the environment it's running in 73 | $tags = $info = $client->describeInstances([ 74 | 'Filters' => [ 75 | [ 76 | 'Name' => 'instance-id', 77 | 'Values' => [$instanceId], 78 | ], 79 | ], 80 | ])->get('Reservations')[0]['Instances'][0]['Tags']; 81 | 82 | // Get environment name 83 | $environmentName = F\first($tags, function ($tagArray) { 84 | return $tagArray['Key'] == 'elasticbeanstalk:environment-name'; 85 | })['Value']; 86 | $this->info('Environment: ' . $environmentName); 87 | 88 | $this->info('Getting Instances with Environment: ' . $environmentName); 89 | 90 | // Get instances that have this environment tagged 91 | $info = $client->describeInstances([ 92 | 'Filters' => [ 93 | [ 94 | 'Name' => 'tag-value', 95 | 'Values' => [$environmentName], 96 | ], 97 | ], 98 | ]); 99 | $instances = F\map($info->get('Reservations'), function ($i) { 100 | return current($i['Instances']); 101 | }); 102 | $this->info('Getting potential instances...'); 103 | 104 | // Only want instances that are running 105 | $candidateInstances = F\select($instances, function ($instanceMeta) { 106 | return $instanceMeta['State']['Code'] == 16; 107 | }); 108 | 109 | $leader = false; 110 | 111 | if (!empty($candidateInstances)) { //there are instances running 112 | if (count($candidateInstances) > 1) { 113 | // if there is more than one we sort by launch time and get the oldest 114 | $this->info('More than one instance running, finding the oldest...'); 115 | $oldestInstance = F\sort($candidateInstances, function ($left, $right) { 116 | return $left['LaunchTime'] <=> $right['LaunchTime']; 117 | })[0]; 118 | } else { 119 | $this->info('Only one instance running...'); 120 | $oldestInstance = reset($candidateInstances); 121 | } 122 | if ($oldestInstance['InstanceId'] == $instanceId) { 123 | // if this instance is the oldest instance it's the leader 124 | $leader = true; 125 | } 126 | } else { 127 | $this->info('No candidate instances found. \'O Brave New World!'); 128 | $leader = true; 129 | } 130 | 131 | // No leader is running so we'll setup this one as the leader 132 | // and create a cron entry to run the scheduler 133 | if ($leader) { 134 | $this->info('We are the Leader! Initiating Cron Setup'); 135 | $this->call('system:start:cron'); 136 | } else { 137 | // Instance was found, don't do any cron stuff 138 | $this->info('We are not a leader instance :( Maybe next time...'); 139 | $this->info('Leader should be running on Instance ' . $oldestInstance['InstanceId']); 140 | } 141 | 142 | $this->info('Leader Selection Done!'); 143 | } else { 144 | // Probably be run from your local machine 145 | $this->error('Did not detect an ec2 environment. Exiting.'); 146 | } 147 | } else { 148 | $this->info('USE_CRON env var not set. Exiting.'); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Console/System/SetupLeaderSelectionCRONCommand.php: -------------------------------------------------------------------------------- 1 | config = $config; 40 | } 41 | 42 | public function handle() 43 | { 44 | $this->info('Initializing CRON Leader Setup...'); 45 | 46 | $overwrite = $this->option('overwrite'); 47 | 48 | if (!$overwrite) { 49 | $output = shell_exec('crontab -l 2> /dev/null || true'); 50 | } else { 51 | $this->info('Overwriting previous CRON contents...'); 52 | $output = null; 53 | } 54 | 55 | if (!empty($output) && strpos($output, 'aws:configure:leader') !== false) { 56 | $this->info('Already found Leader Selection entry! Not adding.'); 57 | } else { 58 | $interval = $this->config->get('elasticbeanstalkcron.interval', 5); 59 | $path = $this->config->get('elasticbeanstalkcron.path', '/var/app/current/artisan'); 60 | 61 | // using opt..envvars makes sure that environmental variables are loaded before we run artisan 62 | // http://georgebohnisch.com/laravel-task-scheduling-working-aws-elastic-beanstalk-cron/ 63 | // (this link is for AL1, AL2 need a workaround to get the same envvars file, see .platform folder) 64 | file_put_contents( 65 | '/tmp/crontab.txt', 66 | $output . "*/$interval * * * * . /opt/elasticbeanstalk/deployment/envvars &&" . 67 | " /usr/bin/php $path aws:configure:leader >> /dev/null 2>&1" . PHP_EOL 68 | ); 69 | 70 | echo exec('crontab /tmp/crontab.txt'); 71 | } 72 | 73 | $this->info('Leader Selection CRON Done!'); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Console/System/SetupSchedulerCommand.php: -------------------------------------------------------------------------------- 1 | config = $config; 35 | } 36 | 37 | public function handle() 38 | { 39 | $this->info('Initializing CRON Setup...'); 40 | 41 | $overwrite = $this->option('overwrite'); 42 | 43 | if (!$overwrite) { 44 | $output = shell_exec('crontab -l 2> /dev/null || true'); 45 | } else { 46 | $this->info('Overwriting previous CRON contents...'); 47 | $output = null; 48 | } 49 | 50 | if (!empty($output) && strpos($output, 'schedule:run') !== false) { 51 | $this->info('Already found Scheduler entry! Not adding.'); 52 | } else { 53 | $path = $this->config->get('elasticbeanstalkcron.path', '/var/app/current/artisan'); 54 | // using opt..envvars makes sure that environmental variables are loaded before we run artisan 55 | // http://georgebohnisch.com/laravel-task-scheduling-working-aws-elastic-beanstalk-cron/ 56 | // (this link is for AL1, AL2 need a workaround to get the same envvars file, see .platform folder) 57 | file_put_contents('/tmp/crontab.txt', $output . '* * * * * . /opt/elasticbeanstalk/deployment/envvars && /usr/bin/php ' . $path . ' schedule:run >> /dev/null 2>&1' . PHP_EOL); 58 | echo exec('crontab /tmp/crontab.txt'); 59 | } 60 | 61 | $this->info('Schedule Cron Done!'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ElasticBeanstalkCronProvider.php: -------------------------------------------------------------------------------- 1 | registerConsoleCommands(); 21 | } 22 | 23 | public function boot() 24 | { 25 | $this->publishes([ 26 | __DIR__ . '/.platform' => base_path('/.platform'), 27 | __DIR__ . '/elasticbeanstalkcron.php' => config_path('elasticbeanstalkcron.php') 28 | ], 'ebcron'); 29 | } 30 | 31 | protected function registerConsoleCommands() 32 | { 33 | $this->commands([ 34 | ConfigureLeaderCommand::class, 35 | SetupLeaderSelectionCRONCommand::class, 36 | SetupSchedulerCommand::class 37 | ]); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/elasticbeanstalkcron.php: -------------------------------------------------------------------------------- 1 | env('USE_CRON', false), 15 | 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | INTERVAL 20 | |-------------------------------------------------------------------------- 21 | | 22 | | The interval, in minutes, that a Leader Selection check should be 23 | | run by the CRON 24 | | 25 | */ 26 | 'interval' => 5, 27 | 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | PATH 32 | |-------------------------------------------------------------------------- 33 | | 34 | | Path to your artisan file. Defaults to /var/app/current/artisan 35 | | By default the root of your app is located at /var/app/current 36 | | 37 | */ 38 | 'path' => '/var/app/current/artisan', 39 | 40 | 41 | /* 42 | |-------------------------------------------------------------------------- 43 | | AWS KEY 44 | |-------------------------------------------------------------------------- 45 | | 46 | | AWS Key Needed for read-only access to ec2 client 47 | | 48 | */ 49 | 'key' => env('AWS_ACCESS_KEY_ID', null), 50 | 51 | 52 | /* 53 | |-------------------------------------------------------------------------- 54 | | AWS SECERT 55 | |-------------------------------------------------------------------------- 56 | | 57 | | Needed for read-only access to ec2 client 58 | | 59 | */ 60 | 'secret' => env('AWS_SECRET_ACCESS_KEY', null), 61 | 62 | 63 | /* 64 | |-------------------------------------------------------------------------- 65 | | AWS REGION 66 | |-------------------------------------------------------------------------- 67 | | 68 | | Sets which AWS region when looking using the ec2 client, 69 | | defaults to us-east-1 if not set. 70 | | 71 | */ 72 | 'region' => env('AWS_REGION', 'us-east-1'), 73 | 74 | 75 | ]; 76 | --------------------------------------------------------------------------------