Для передачи данных сервером на php клиенту можно использовать следующий алгоритм:


  1. Сервер php публикует данные в канал redis.
  2. Сервер node подписывается на события в соответствующем канале redis и при
    наступлении события поступления данных публикует эти данные уже в
    socket.io
  3. Клиент подписывается на сообщения socket.io и обрабатывает их при поступлении

Исходный код проекта можно найти на github


Здесь я буду двигаться очень маленькими шагами.
В проекте будет использоваться связка nginx и php-fpm и начну я с настройки
nginx.


Настройка nginx


Начнем создавать docker-compose.yml в корневой папке нашего проекта.


# docker-compose.yml
version: '3'
services:
  nginx:
    image: nginx
    ports:
      - 4400:80

Откроем в браузере: http://localhost:4400 и увидим стандартное приветствие
nginx.
Теперь настроим, чтобы nginx отдавал статическое содержимое папки
./www/public.
Сначала создадим папки


mkdir -pv www/public

Создадим файл ./www/pulbic/index.html


<!-- www/public/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title></title>
  </head>
  <body>
    <h1>Hello World!</h1>
  </body>
</html>

Создадим файл конфигурации nginx — nginx/conf/custom.conf. Для начала
скопируем стандартный /etc/nginx/conf.d/default.conf.
Изменим docker-compose.yml


 services:
   nginx:
     image: nginx
+    volumes:
+      - ./nginx/conf/custom.conf:/etc/nginx/conf.d/default.conf
     ports:
       - 4400:80

Пересоздадим контейнер nginx


docker-compose up -d

И вновь наблюдаем по в браузере по адресу http://localhost:4400 стандартное
приветствие nginx.
Внесем изменения
docker-compose.yml


     image: nginx
     volumes:
       - ./nginx/conf/custom.conf:/etc/nginx/conf.d/default.conf
+      - ./www:/www
     ports:
       - 4400:80

nginx/conf/custom.conf


 #access_log  /var/log/nginx/host.access.log  main;

   location / {
-    root   /usr/share/nginx/html;
+    root   /www/public;
     index  index.html index.htm;
   }

Теперь по адресу http://localhost:4400 отображается 'Hello World!' из файла
www/public/index.html.
Прорывом это назвать сложно, но мы определенно двигаемся в нужном направлении.


Настройка php


Начнем с создания папок для хранения файлов настроек контейнера.


mkdir -pv php/conf

Далее создадим php/Dockerfile


FROM php:7-fpm

RUN apt-get -qq update && apt-get -qq install   curl   > /dev/null

ENV PHPREDIS_VERSION 3.0.0

RUN mkdir -p /usr/src/php/ext/redis     && curl -L https://github.com/phpredis/phpredis/archive/$PHPREDIS_VERSION.tar.gz | tar xvz -C /usr/src/php/ext/redis --strip 1     && echo 'redis' >> /usr/src/php-available-exts     && docker-php-ext-install redis

И внесем изменения в docker-compose.yml


       - ./www:/www
     ports:
       - 4400:80
+  php:
+    build: ./php
+    volumes:
+      - ./www:/www
+    environment:
+      - REDIS_PASSWORD=${REDIS_PASSWORD}

Также нам нужно внести изменения в настройки nginx, чтобы файлы с расширением
.php обрабатывались php-fpm.
Изменим файл nginx/conf/custom.conf следующим образом


 # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
 #
-#location ~ \.php$ {
-#    root           html;
-#    fastcgi_pass   127.0.0.1:9000;
-#    fastcgi_index  index.php;
-#    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
-#    include        fastcgi_params;
-#}
+location ~ \.php$ {
+    root          /www;
+    fastcgi_pass   php:9000;
+    fastcgi_index  index.php;
+    fastcgi_param REQUEST_METHOD  $request_method;
+    fastcgi_param CONTENT_TYPE    $content_type;
+    fastcgi_param CONTENT_LENGTH  $content_length;
+    fastcgi_param  SCRIPT_FILENAME  /www/public/$fastcgi_script_name;
+    include        fastcgi_params;
+}

Осталось создать файл www/public/info.php со следующим кодом


<?php
phpinfo();

Перезапустим наш контейнер


docker-compose restart

И теперь по адресу http://localhost:4400/info.php отображается информация о
настройках php.
Еще немного поэкспериментируем и создадим файл www/Test.php


<?php
class Test
{
  public function prn()
  {
    echo 'Success';
  }
}

А содержимое файла www/public/info.php заменим на следующее:


<?php
require_once(
  implode(
    DIRECTORY_SEPARATOR,
    [
      dirname(__DIR__),
      'Test.php'
    ]
  )
);

$test = new Test();
$test->prn();

Теперь по адресу http://localhost:4400/info.php отображается 'success', а это
означает, что php-fpm доступны скрипты расположенные в папке www, а через
браузер они недоступны. Т.е. мы продолжаем двигаться в нужном направлении.


Настройка redis


Это, пожалуй, самая короткая часть.
Redis в этом проекте не будет доступен из внешней сети, но защиту паролем
настроим.
Для этого создадим файл .env


REDIS_PASSWORD=eustatos

Внесем изменения в docker-compose.yml


     build: ./php
     volumes:
       - ./www:/www
+  redis:
+    image: redis
+    command: ["sh", "-c", "exec redis-server --requirepass \"${REDIS_PASSWORD}\""]

Чтобы протестировать подключение к redis изменим файл www/public/info.php


<?php
$redis = new Redis();
// подключаемся к серверу redis
$redis->connect(
  'redis',
  6379
);
// авторизуемся. 'eustatos' - пароль, который мы задали в файле `.env`
$redis->auth($_ENV['REDIS_PASSWORD']);
// публикуем сообщение в канале 'eustatos'
$redis->publish(
  'eustatos',
  json_encode([
    'test' => 'success'
  ])
);
// закрываем соединение
$redis->close();

Рестартуем контейнер


docker-compose restart

Теперь подключимся к серверу redis


docker-compose exec redis bash

Перейдем к командной строке. 'eustatos' — пароль, который мы ранее задали в
файле .env


# redis-cli -a eustatos

Подпишемся на канал 'eustatos' (название произвольное, чтобы все работало,
долно совпадать с названием канала, которое мы определили в файле
www/public/info.php)


> subscribe eustatos

После всех этих приготовлений, переходим в браузере по адресу
http://localhost:4400/info.php и наблюдаем, как в терминале, где мы
подключались к redis появляются примерно следующие строки:


1) "message"
2) "eustatos"
3) "{\"test\":\"success\"}"

Значит мы стали еще ближе к нашей цели.


Настройка socket.io


Созадим папку, где будут лежать файлы нашего socket.io сервера


mkdir socket

Внесем изменения в docker-compose.yml


   redis:
     image: redis
     command: ["sh", "-c", "exec redis-server --requirepass \"${REDIS_PASSWORD}\""]
+  socket:
+    image: node
+    user: "node"
+    volumes:
+      - ./socket:/home/node/app
+    ports:
+      - 5000:5000
+    working_dir: /home/node/app
+    command: "npm start"

Перейдем в папку socket


cd socket

Установим необходимые пакеты


npm init -y
npm i -S socket.io redis express

После этого добавим в файл socket/package.json строки


{
  "name": "socket-php-example",
  "version": "1.0.0",
  "main": "index.js",
  "author": "eustatos <astashkinav@gmail.com>",
  "license": "MIT",
+  "scripts": {
+    "start": "node index.js"
+  },
  "dependencies": {
    "express": "^4.16.3",
    "redis": "^2.8.0",
    "socket.io": "^2.1.0"
  }
}

Создадим файл socket/index.js


const express = require('express');
const app = express();
const http = require('http').Server(app);

const port = process.env.PORT || 5000;

app.get(
  '/',
  function(req, res, next) {
    res.send('success');
  }
);

http.listen(
  port,
  function() {
    console.log('Listen at ' + port);
  }
);

Перезапустим наш контейнер


docker-compose restart

После этого в браузере по адресу http://localhost:5000 отображается "success".
Значит мы еще чуть ближе к нашей цели. Осталось совсем немного.
Изменим файл socket/index.js


const express = require('express');
const app = express();
const http = require('http').Server(app);
const io = require('socket.io')(http);

// подключаемся к redis
const subscriber = require('redis').createClient({
  host: 'redis',
  port: 6379,
  password: 'eustatos'
});

// подписываемся на изменения в каналах redis
subscriber.on('message', function(channel, message) {
  // пересылаем сообщение из канала redis в комнату socket.io
  io.emit('eustatosRoom', message);
});

// открываем соединение socket.io
io.on('connection', function(socket){
  // подписываемся на канал redis 'eustatos' в callback
  subscriber.subscribe('eustatos');
});

const port = process.env.PORT || 5000;

http.listen(
  port,
  function() {
    console.log('Listen at ' + port);
  }
);

На этом настройка контейнера socket.io завершена.


Настройка клиентского приложения


Клиентское приложение можно развернуть в любом из наших контейнеров, но
для чистоты эксперимента развернем его в отдельном контейнере.
Файлы клиентского приложения разместим в папке client


mkdir client

Создадим файл client/index.html


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title></title>
  </head>
  <body>
      <script
         src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/1.7.3/socket.io.min.js"></script>
      <script>
const socket = io(
  window.location.protocol + '//' + window.location.hostname + ':5000'
);
        socket.on(
          'eustatosRoom',
          function(message) {
            console.log(JSON.parse(message));
          }
        );
      </script>
  </body>
</html>

Изменим docker-compose.yml


     ports:
       - 5000:5000
     command: "npm start"
+  client:
+    image: nginx
+    volumes:
+      - ./client:/usr/share/nginx/html
+    ports:
+      - 8000:80

Перезапустим наш контейнер


docker-compose restart

Откроем сначала в браузере http://localhost:8000. Для демонстрации результата
наших трудов нужно открыть панель разработчика.
Пока ничего не отображается.
Откроем в другой вкладке или окне адрес http://localhost:4400/info.php и посмотри на консоль панели разработчика нашего клиента. Мы должны увидеть:


{test: "success"}

А это значит, что наш сервер благополучно передал клиентскому приложению данные.

Комментарии (24)


  1. Dreyk
    10.04.2018 22:04

    пароль от редиски надо пробрасывать во все контейнеры через ENV


    1. eustatos Автор
      11.04.2018 00:02

      Спасибо за предложение.
      Внес изменения


      1. VolCh
        11.04.2018 07:01

        В сокете остался.


    1. Fesor
      11.04.2018 16:08

      а еще лучше через secret.


  1. xakepmega
    10.04.2018 22:52

    зачем здесь пхп?


    1. eustatos Автор
      10.04.2018 22:59

      Существует бэкенд на php, есть задача уведомить клиентское приложение о наступлении некоего события. Можно делать много запросов из клиента на бэк. А можно сделать, как в данном примере. Это не образец передового архитектурного решения, это выход из сложившейся ситуации


      1. Fortop
        11.04.2018 00:30

        Обычный Ratchet не устраивает?


        Ну или уж https://github.com/walkor/phpsocket.io


        Зачем создавать винегрет?


        1. Fesor
          11.04.2018 16:09

          Действительно, надо было взять centrifugo и не париться.


          p.s. Ratchet еще живой?


          1. Fortop
            11.04.2018 20:46

            1. Fesor
              13.04.2018 09:55

              Как-то пульс слабый.


              p.s. 2 года назад я делал проект с активным использованием Ratchet. И делать больше так не буду. Ваш же довод про винегрет для меня лично кажется странным.


  1. apapacy
    10.04.2018 23:10

    По конфигам есть два существенных замечания. 1) Порты все кроме nginx не следует открывать. Внутри контейнеров Вы можете обращаться к ним напрямую напр. redis:port… 2) В документации nginx есть несколько примеров для fastcgi и там рекомендуется стандартные параметры включать из файла include fastcgi.conf; Если только не требуется что-то нестандартное.


    1. eustatos Автор
      10.04.2018 23:16

      Спасибо за замечание. Порты наружу открыл в демонстрационных целях. Собственно, сервер и клиент открыты через nginx. Socket.io тоже получается нужно открывать наружу, иначе клиентский браузер к нему не достучится.
      Про fastcgi согласен. Конфигурация у nginx далеко не образцовая. Цель была продемонстрировать возможность


      1. apapacy
        10.04.2018 23:31

        По портам в основном касалось redis. Но раз Вы упомянули о socket.io то он как правило слушает тот же порт что и приложение основное (в Вашем случае php) поэтому полюбому придется как-то делать проксирование через nginx то есть socket.io тоже наружу не придетс открывать.


  1. ZurgInq
    11.04.2018 07:33

    Зачем здесь node.js? На php существует уже больше одной реализации event loop (event driven), позволяющий запускать долгоживущие процессы php.


    1. eustatos Автор
      11.04.2018 08:49

      Возможно, мы по разному трактуем термины. Было бы проще, если были бы конкретные примеры этих решений. Эти решения можно использовать в связке с SPA, например на Angular?


      1. ZurgInq
        11.04.2018 11:19

        Хотя бы reactphp.org Выше дали ещё ссылку на phpsocket.io. А сокеты, SPA и angular друг другу ортогональны


  1. VolCh
    11.04.2018 07:40

    Слабенький пример докеризации. Большими жирными буквами надо написать "чисто для ознакомительных целей, не делать так в продакшене".


    Навскидку:


    • конфиги типа nginx если и монтировать, то в только дев-режиме, вообще они должны быть включены в образ.
    • зависимости npm должны устанавливаться при билде образа
    • исходные файлы (php, js, html) должны копироваться в образ


    1. eustatos Автор
      11.04.2018 08:34

      Тестовость данного проекта очевидна. Все контейнеры сложены в один только в демонстрационных целях. Чтобы можно было одной командой развернуть и попробовать.
      А с замечанием полностью согласен.


      1. VolCh
        11.04.2018 12:20

        Это тем очевидно, кто начинал по таким туториалам внедрять докер в продакшен, ничего о нём не зная, как я )


    1. L0NGMAN
      12.04.2018 00:20

      Почему исходные файлы должны копироватся в образ?


      1. VolCh
        12.04.2018 13:47
        +1

        Чтобы образ можно было запустить на любой машине, например на продакшен сервере.


        1. Sadykh
          12.04.2018 20:20

          Не считаю истинно правильным копировать исходные файлы (php, html, js, etc) в образ. Часто использую docker контейнер для одинакового окружения (настроенная версия php, nginx, mysql), а в отдельном репозитории с git'ом хранить команды для запуска сборка образа (как правило docker-compose) и весь исходный код. Все доступы в .env файле, которые не выкладываются нигде.


          Тут почему никто не говорит, что если вы сохраните данные в образе, то при выполнении команды down для контейнеров, образ вернется на стадию "упакования".


          В общем, по моему скромному мнению:


          • в docker хранится образ окружения (несколько контейнеров, которые выполняют свою роль)
          • исходный код (ваши php файлы, html, css, js) хранятся в отдельном репозитории и подключаются монтированием к образу (подробнее прочитайте про volume)
          • база данных (к примеру) — подключается также монтированием папки с базой

          Я не понимаю, зачем вам хранить данные в образе. Если вы запустите образ с данными на другой машине, то там данные будут не актуальны. Куда правильнее использовать репликацию и так далее, но это уже совсем другая история. Разве что видел интересный кейс c копированием верстки на react/angular в образ, но там смысл есть.


          1. VolCh
            13.04.2018 10:53
            +1

            Тут почему никто не говорит, что если вы сохраните данные в образе, то при выполнении команды down для контейнеров, образ вернется на стадию "упакования".

            Это подразумевается по умолчанию, предполагается, что люди читали документацию по докеру: "When a container is removed, any changes to its state that are not stored in persistent storage disappear."


            Я не понимаю, зачем вам хранить данные в образе.

            Кто говорит про данные? Речь про код и конфиги. Данные, состояние хранится либо в volume, либо вообще вне контейнера или даже вне докера.


            Как вы будете деплоить свое приложение на продакшен сервер? Особенно, если всё, что на нём есть — это ядро ОС и сам docker. Вот реально больше ничего, ни ssh, ни ftp, ни даже хоть какой-то консоли физической. Докер не умеет заливать файлы на удаленную машину, только в контейнер. Да, вы можете в образе или при удаленном запуске контейнера сделать volume (хотя не обязательно), а потом с локальной машины или деплой-сервера через docker cp скопировать файлы приложения на удаленный сервер. Можете даже разделить запуск на создание и старт: создать конейнер, скопировать в него код приложения, запустить, чтобы не получить ситуацию когда сервер работает, но кода нет.


            Можете, но зачем, если их можно сразу включить в образ, сразу одной командой docker run с любой машины, включая те, где исходников нет, хоть с телефона, получить работающий сервер на удаленной машине, где ничего кроме ядра и докера нет.


            Докер предназначен для упаковки и запуска приложений в контейнерах. Ваш сценарий — это сценарий упаковки и запуска приложений nginx, php-fpml и т. д., которые как данные получают ваш код. Но обычно цель не запустить эти приложения, скормив им какие-то данные с локальной машины, а запустить ваше приложение, которое где-то под капотом будет использовать эти приложения.


            В целом volume, примонтированный на локальный каталог с исходниками или результатами билда — это хак, для упрощения локальной разработки, чтобы не билдить образ на каждый чих и не перезапускать. Ни докер в целом, ни его фича volume для этого не создавались. Докер создавался, чтобы всё приложение упаковать в образ, который можно без проблем запускать на разных машинах, в том числе удаленно, лишь бы там был запущен сервер докера. Функциональность volume создавалась, чтобы разделять данные (не код) между контейнерами, прежде всего между контейнерами из одного образа. Монтирование volume на конкретный каталог в локальной ФС — в целом противоречит идеологии докера, хотя без него в некоторых ситуациях сложно обойтись.


      1. eustatos Автор
        12.04.2018 16:30

        Если данные не скопировать в образ и попытаться запустить его например на AWS, то некрасиво получится, так как они (данные) в этом случае будут недоступны.