Lại một bài hướng dẫn Heroku nữa??
Hầu hết các hướng dẫn deploy Laravel lên Heroku đều sử dụng Apache để làm web server. Mặc định Laravel đi kèm với 1 file .htaccess
được dùng bởi Apache để rewrite tất cả dynamic url về file public/index.php
để xử lý routing bằng Laravel. Apache trên Heroku cũng hỗ trợ override config bằng file .htaccess
nên việc setup bằng Apache rất dễ dàng.
https://devcenter.heroku.com/articles/custom-php-settings#apache-defaults
Apache uses a Virtual Host that responds to all hostnames. The document root is set up as a <Directory> reachable without access limitations and
AllowOverride All
set to enable the use of.htaccess
files. Any request to a URL ending on.php
will be rewritten to PHP-FPM using a proxy endpoint namedfcgi://heroku-fcgi
viamod_proxy_fcgi
. The DirectoryIndex directive is set toindex.php index.html index.html
.
Nhưng nếu trường hợp bạn muốn sử dụng Nginx thì sao? Và một ứng dụng Laravel không chỉ chạy PHP đơn thuần mà thường có cả queue, schedule, database, redis, socketio… vậy xử lý thế nào đây?
Trong bài này mình sẽ đi qua một số khái niệm liên quan đến Heroku và hướng dẫn thực hành deploy Laravel, Socket.IO và Nginx.
Để thực hành bạn cần một tài khoản Heroku Free và cài đặt heroku-cli
ở local.
Deploy Laravel App
Tạo project Laravel với composer
:
1 2 3 4 5 | composer create-project --prefer-dist laravel/laravel:6 heroku-laravel-nginx-socketio <span class="token function">git</span> init <span class="token function">git</span> add <span class="token keyword">.</span> <span class="token function">git</span> commit -m <span class="token string">'Init Laravel 6'</span> |
Tạo heroku app:
1 2 3 | cd heroku-laravel-nginx-socketio heroku apps:create heroku-laravel-nginx-socketio |
Khi tạo xong app, heroku cli sẽ tự động add thêm 1 git remote repository, bạn có thể kiểm tra bằng lệnh:
1 2 3 4 | $ <span class="token function">git</span> remote -v heroku https://git.heroku.com/heroku-laravel-nginx-socketio.git <span class="token punctuation">(</span>fetch<span class="token punctuation">)</span> heroku https://git.heroku.com/heroku-laravel-nginx-socketio.git <span class="token punctuation">(</span>push<span class="token punctuation">)</span> |
Nếu không có heroku
remote thì bạn tự thêm vào bằng lệnh git remote add heroku <heroku git url>
Ok, thử push lên heroku repo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | $ <span class="token function">git</span> push heroku master Counting objects: 113, done. Delta compression using up to 4 threads. Compressing objects: 100% <span class="token punctuation">(</span>95/95<span class="token punctuation">)</span>, done. Writing objects: 100% <span class="token punctuation">(</span>113/113<span class="token punctuation">)</span>, 56.69 KiB <span class="token operator">|</span> 3.54 MiB/s, done. Total 113 <span class="token punctuation">(</span>delta 10<span class="token punctuation">)</span>, reused 0 <span class="token punctuation">(</span>delta 0<span class="token punctuation">)</span> remote: Compressing <span class="token function">source</span> files<span class="token punctuation">..</span>. done. remote: Building source: remote: remote: <span class="token operator">!</span> Warning: Multiple default buildpacks reported the ability to handle this app. The first buildpack <span class="token keyword">in</span> the list below will be used. remote: Detected buildpacks: PHP,Node.js remote: See https://devcenter.heroku.com/articles/buildpacks<span class="token comment">#buildpack-detect-order</span> remote: -----<span class="token operator">></span> PHP app detected <span class="token punctuation">..</span>. |
Khác với git push thông thường như khi push lên Github chẳng hạn, thì sau khi push lên heroku, Heroku sẽ thực hiện build và deploy.
Buildpacks
Do Khi chưa thêm buildpacks cho heroku app, Heroku sẽ tự động detect buildpack phù hợp để build (có lẽ Heroku dựa vào các file composer.json
và package.json
để detect buidpack?).
Buildpack là gì? => Hiểu nôm na, buildpacks là tập hợp các scripts thường dùng để build, compile ứng dụng tùy thuộc vào ngôn ngữ lập trình của từng ứng dụng. Ví dụ ở đây, app của chúng ta là PHP Laravel thì sẽ có các bước:
composer install
để cài các packages,npm install
để cài JS packages,npm run dev
để compile và generate assets như Sass, CSS, Javascript…Các buildpacks được support chính chủ bởi Heroku là: Ruby, Node.js, Clojure, Python, Java, Gradle, JVM, Grails 3.x, Scala, Play 2.x, PHP, Go
Ở đây chúng ta cần 2 buildpacks là heroku-buildpack-php để chạy composer
và heroku-buildpack-nodejs để chạy npm
. Add vào Heroku App bằng lệnh sau:
1 2 3 | heroku buildpacks:add heroku/php heroku buildpacks:add heroku/nodejs |
Heroku sẽ chạy lần lượt các buildpack theo thứ tự được thêm.
Push lại và xem kết quả:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | $ <span class="token function">git</span> commit --amend --no-edit $ <span class="token function">git</span> push heroku master -f <span class="token punctuation">..</span>. remote: Building source: remote: remote: -----<span class="token operator">></span> Node.js app detected remote: remote: -----<span class="token operator">></span> Creating runtime environment remote: remote: NPM_CONFIG_LOGLEVEL<span class="token operator">=</span>error remote: NODE_ENV<span class="token operator">=</span>production remote: NODE_MODULES_CACHE<span class="token operator">=</span>true remote: NODE_VERBOSE<span class="token operator">=</span>false remote: remote: -----<span class="token operator">></span> Installing binaries remote: engines.node <span class="token punctuation">(</span>package.json<span class="token punctuation">)</span>: unspecified remote: engines.npm <span class="token punctuation">(</span>package.json<span class="token punctuation">)</span>: unspecified <span class="token punctuation">(</span>use default<span class="token punctuation">)</span> remote: remote: Resolving node version 10.x<span class="token punctuation">..</span>. remote: Downloading and installing node 10.16.3<span class="token punctuation">..</span>. remote: Using default <span class="token function">npm</span> version: 6.9.0 remote: remote: -----<span class="token operator">></span> Installing dependencies remote: Installing node modules <span class="token punctuation">(</span>package.json<span class="token punctuation">)</span> remote: added 1005 packages from 481 contributors <span class="token keyword">in</span> 31.347s remote: remote: -----<span class="token operator">></span> Build <span class="token punctuation">..</span>. |
Bây giờ thì command npm install
đã được chạy bằng Nodejs Buildpack với NODE_ENV
là production. Nhưng chúng ta vẫn còn thiếu bước chạy npm run dev
hoặc npm run prod
, vậy làm sao để chạy?
Theo tài liệu https://devcenter.heroku.com/articles/nodejs-support#customizing-the-build-process thì chúng ta cần định nghĩa thêm 1 npm scripts để hướng dẫn Heroku run build, khai báo trong file package.json
hoặc là build
hoặc heroku-postbuild
, trong đó heroku-postbuild
sẽ được ưu tiên hơn.
1 2 3 4 | remote: -----> Build remote: Detected both "build" and "heroku-postbuild" scripts remote: Running heroku-postbuild |
Vậy file package.json sẽ như thế này:
1 2 3 4 5 6 | <span class="token property">"scripts"</span><span class="token operator">:</span> <span class="token punctuation">{</span> <span class="token property">"prod"</span><span class="token operator">:</span> <span class="token string">"npm run production"</span><span class="token punctuation">,</span> ... <span class="token property">"heroku-postbuild"</span><span class="token operator">:</span> <span class="token string">"npm run prod"</span> <span class="token punctuation">}</span> |
Procfile
Mở app bằng lệnh: heroku open
, một tab trình duyệt mới được mở: https://heroku-laravel-nginx-socketio.herokuapp.com/ và bạn sẽ thấy lỗi 403 Forbidden
??
Trong log khi push lên heroku có đoạn:
1 2 3 4 5 6 | remote: -----<span class="token operator">></span> Preparing runtime environment<span class="token punctuation">..</span>. remote: NOTICE: No Procfile, using <span class="token string">'web: heroku-php-apache2'</span><span class="token keyword">.</span> remote: -----<span class="token operator">></span> Checking <span class="token keyword">for</span> additional extensions to install<span class="token punctuation">..</span>. remote: -----<span class="token operator">></span> Discovering process types remote: Procfile declares types -<span class="token operator">></span> web |
=> NOTICE: No Procfile, using 'web: heroku-php-apache2'
Procfile? => Mỗi app sẽ có file file có tên
Procfile
để khai báo các command được chạy khi app khởi động, ví dụ:
- Chạy web server
- Chạy queue worker
- …
Cú pháp file Procfile
:
1 2 | <process type>: <command> |
Trong đó:
<process type>
là tên của command hay còn gọi là Process Type, ví dụweb
,worker
…<command>
là lệnh được chạy khi app start, ví dụheroku-php-apache2
,php artisan queue:work
…
Có 2 process type đặc biệt, trong đó web
là process type duy nhật có thể handle HTTP requests. Nếu app của bạn cần web server thì bạn cần khai báo lệnh chạy web server ở process type này.
Mỗi dòng sẽ là một process type, mỗi process type được chạy trên một dyno hoàn toàn độc lập.
Một trong những ưu điểm của Heroku là nó có thể scale dễ dàng bằng cách tăng thêm số lượng dyno ở mỗi process type. Nhưng việc này sẽ bàn sau khi chúng ta có tiền và có nhu cầu chạy 1 app production trên Heroku vì với Free plan Heroku chỉ cho phép chúng ta tạo 1 web process và 1 worker process, tối đa 1 dyno cho mỗi process.
Quay trở lại app Laravel của chúng ta, nếu sử dụng Apache chúng ta sẽ có file Procfile như sau:
1 2 | web: vendor/bin/heroku-php-apache2 public/ |
Mặc định heroku-php-apache2
sử dụng folder hiện tại làm document root nhưng với Laravel thì cần set document root là public
để điều hướng đến file public/index.php
.
Muốn sử dụng Nginx thay cho Apache chúng ta sẽ sử dụng command vendor/bin/heroku-php-nginx
thay cho vendor/bin/heroku-php-apache2
và cần phải custom Nginx config, do Nginx không hỗ trợ file .htaccess
như Apache, theo hướng dẫn:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | location / { # try to serve file directly, fallback to rewrite try_files $uri @rewriteapp; } location @rewriteapp { # rewrite all to index.php rewrite ^(.*)$ /index.php/$1 last; } location ~ ^/index.php(/|$) { try_files @heroku-fcgi @heroku-fcgi; # ensure that /index.php isn't accessible directly, but only through a rewrite internal; } |
1 2 | web: vendor/bin/heroku-php-nginx -C heroku-nginx.conf public/ |
Deploy lại và mở lại xem sao và lần này đã ra đúng trang Laravel nhưng lại là trang 500 error?? À tất tiên là do chưa có file .env
.
Environment variable
Do filesystem trên Heroku đặc biệt ở chỗ là các thay đổi trên filesystem (không thông qua git) sẽ chỉ được giữ lại cho đến khi dyno shutdown hoặc khởi động lại, hay mỗi lần deploy hoặc restart tất cả các file thay đổi hay thêm mới trong quá trình chạy (ví dụ file laravel.log) sẽ bị xóa, chỉ có những file có trên git được keep lại.
Nên ở đây chúng ta sẽ không dùng file .env
vì file này thường không được add vào git. Thay vào đó các biến môi trường sẽ được set trong setting của app trên heroku (gọi là Config Vars) hoặc có thể thông qua heroku cli.
Biến môi trường quan trọng nhất với Laravel đó là APP_KEY
, chúng ta sẽ generate key và dùng heroku cli để set biến môi trường cho app:
Generate key bằng artisan command:
1 2 | php artisan key:generate --show |
Sau đó set biến môi trường cho heroku app bằng heroku cli:
1 2 | heroku config:set APP_KEY<span class="token operator">=</span>key_generated_above |
Ngoài ra ta cần config lại log để có thể xem log qua lệnh heroku logs
do nếu dùng log vào file thì file log sẽ bị xóa đi sau mỗi lần restart => https://devcenter.heroku.com/articles/getting-started-with-laravel#changing-the-log-destination-for-production
1 2 | heroku config:set LOG_CHANNEL=errorlog |
Deploy Socket.io
Để bắt đầu chúng ta sẽ tham khảo code socketio đơn giản ở repo: https://github.com/heroku-examples/node-socket.io, có chức năng hiển thị server time realtime thông qua socketio:
1 2 | <span class="token function">mkdir</span> socketio-server |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <span class="token punctuation">{</span> <span class="token property">"name"</span><span class="token operator">:</span> <span class="token string">"socketio-server"</span><span class="token punctuation">,</span> <span class="token property">"version"</span><span class="token operator">:</span> <span class="token string">"1.0.0"</span><span class="token punctuation">,</span> <span class="token property">"description"</span><span class="token operator">:</span> <span class="token string">""</span><span class="token punctuation">,</span> <span class="token property">"main"</span><span class="token operator">:</span> <span class="token string">"index.js"</span><span class="token punctuation">,</span> <span class="token property">"scripts"</span><span class="token operator">:</span> <span class="token punctuation">{</span> <span class="token property">"test"</span><span class="token operator">:</span> <span class="token string">"echo "Error: no test specified" && exit 1"</span> <span class="token punctuation">}</span><span class="token punctuation">,</span> <span class="token property">"keywords"</span><span class="token operator">:</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">,</span> <span class="token property">"author"</span><span class="token operator">:</span> <span class="token string">""</span><span class="token punctuation">,</span> <span class="token property">"license"</span><span class="token operator">:</span> <span class="token string">"ISC"</span><span class="token punctuation">,</span> <span class="token property">"dependencies"</span><span class="token operator">:</span> <span class="token punctuation">{</span> <span class="token property">"express"</span><span class="token operator">:</span> <span class="token string">"^4.17.1"</span><span class="token punctuation">,</span> <span class="token property">"socket.io"</span><span class="token operator">:</span> <span class="token string">"^2.3.0"</span> <span class="token punctuation">}</span> <span class="token punctuation">}</span> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <span class="token keyword">const</span> express <span class="token operator">=</span> <span class="token function">require</span><span class="token punctuation">(</span><span class="token string">'express'</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token keyword">const</span> socketIO <span class="token operator">=</span> <span class="token function">require</span><span class="token punctuation">(</span><span class="token string">'socket.io'</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token keyword">const</span> <span class="token constant">PORT</span> <span class="token operator">=</span> <span class="token number">3000</span><span class="token punctuation">;</span> <span class="token keyword">const</span> server <span class="token operator">=</span> <span class="token function">express</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">.</span><span class="token function">listen</span><span class="token punctuation">(</span><span class="token constant">PORT</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token template-string"><span class="token string">`Listening on </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span> <span class="token constant">PORT</span> <span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">`</span></span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token keyword">const</span> io <span class="token operator">=</span> <span class="token function">socketIO</span><span class="token punctuation">(</span>server<span class="token punctuation">)</span><span class="token punctuation">;</span> io<span class="token punctuation">.</span><span class="token function">on</span><span class="token punctuation">(</span><span class="token string">'connection'</span><span class="token punctuation">,</span> <span class="token punctuation">(</span>socket<span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span> console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token string">'Client connected'</span><span class="token punctuation">)</span><span class="token punctuation">;</span> socket<span class="token punctuation">.</span><span class="token function">on</span><span class="token punctuation">(</span><span class="token string">'disconnect'</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token string">'Client disconnected'</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token function">setInterval</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> io<span class="token punctuation">.</span><span class="token function">emit</span><span class="token punctuation">(</span><span class="token string">'time'</span><span class="token punctuation">,</span> <span class="token keyword">new</span> <span class="token class-name">Date</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">toTimeString</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token number">1000</span><span class="token punctuation">)</span><span class="token punctuation">;</span> |
Bây giờ làm sao để chạy cả Laravel và Node socketio ở cổng 3000. Ta có các hướng giải quyết:
- Thêm 1 process type mới => Không khả thi vì process type chạy trên dyno độc lập nên không thể kết nối giữa Laravel (web) với Socket.IO server
- Thêm 1 app mới chỉ để chạy Socket.IO => Tốn thêm app (Free plan chỉ tạo được tối đa 5 App), hoặc nếu plan trả phí thì sẽ mất thêm $
- Chạy trên cùng dyno với Laravel (
web
processs type) => Có vẻ ổn cho demo, nhưng cấu hình của dyno chỉ là 512MB nên nếu ứng dụng lớn hơn chút sẽ không ổn. Vì mục đích là free demo nên chúng ta sẽ tìm cách chạy trên cùng dyno vóiweb
Thật may mắn là Heroku cũng có 1 chủ đề về vấn đề này => https://help.heroku.com/CTFS2TJK/how-do-i-run-multiple-processes-on-a-dyno. Cách làm là sử dụng background jobs của Shell để chạy nhiều command cùng lúc bằng cách thêm ký tự &
ở cuối mỗi câu lệnh.
1 2 | web: vendor/bin/heroku-php-nginx -C heroku-nginx.conf public/ <span class="token operator">&</span> <span class="token punctuation">(</span>cd public/socketio-server <span class="token operator">&&</span> node server.js<span class="token punctuation">)</span> <span class="token operator">&</span> <span class="token function">wait</span> -n |
wait -n
là command của Shell, nó sẽ exit khi có ít nhất 1 command exit và do đó sẽ trigger restart lại dyno.
Cần sửa lại build step trong package.json, để instal dependency trong thư mục socketio-server
:
1 2 3 4 5 6 | <span class="token punctuation">{</span> <span class="token property">"scripts"</span><span class="token operator">:</span> <span class="token punctuation">{</span> <span class="token property">"heroku-postbuild"</span><span class="token operator">:</span> <span class="token string">"(cd socketio-server && npm install) && npm run prod"</span> <span class="token punctuation">}</span> <span class="token punctuation">}</span> |
Client (Browser) cần kết nối đến socketio server, nhưng do Heroku chỉ open 1 cổng duy nhất cho web process nên không thể connect đến cổng 3000 trên client. Vì vậy chúng ta cần tạo 1 reverse proxy bằng nginx để proxy request đến localhost:3000, thêm vào file config nginx:
1 2 3 4 5 6 7 | location /socket.io/ { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; } |
Khi có request đến url /socketio/
thì Nginx sẽ request đến http://localhost:3000/socket.io/
.
Sử dụng socketio trên client:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <a href="https://vapor.laravel.com">Vapor</a> <a href="https://github.com/laravel/laravel">GitHub</a> </div> <span class="token inserted">+ <p id="server-time"></p></span> </div> </div> <span class="token inserted">+ <script src="/socket.io/socket.io.js"></script></span> <span class="token inserted">+ <script></span> <span class="token inserted">+ var socket = io();</span> <span class="token inserted">+ var el = document.getElementById('server-time');</span> <span class="token inserted">+ socket.on('time', function(timeString) {</span> <span class="token inserted">+ el.innerHTML = 'Server time: ' + timeString;</span> <span class="token inserted">+ });</span> <span class="token inserted">+ </script></span> |
Deploy và demo đã thành công =))