Desarrollando con Docker

¿Alguna vez habéis utilizado Docker para desarrollar aplicaciones? Como apasionado de Docker he leído muchos artículos hablando de cómo desplegar aplicaciones a producción, cómo escalar utilizando Docker Swarm, y temáticas similares, pero he encontrado pocos artículos dedicados a usar Docker para desarrollarlas.

Pues bien, en el departamento de desarrollo de Irontec hace tiempo que venimos utilizando Docker para desarrollar aplicaciones web (sobre todo backends en php) y nos gustaría compartir con vosotros algo de lo aprendido por el camino.

En este artículo vamos a montar un entorno de desarrollo basado en Docker para un ‘Hola mundo’ en php corriendo sobre php-fmp, apache2 y mysql.

No vamos a entrar en la instalación de Docker ni Docker Compose ni vamos a explicar qué son. Damos por hecho que sabéis lo que es o, por lo menos, os suena. Si queréis saber más podéis entrar en los siguientes enlaces:

El camino hacia Docker

La vida en la gestión de proyectos de desarrollo

En Irontec siempre hemos intentado huir de la frase «en mi equipo funciona». En el ciclo de vida de los proyectos, una de las cosas que más tiempo consume son los pases a producción. La diferencia entre las versiones utilizadas para desarrollar y las versiones instaladas en producción nos han dado más de un dolor de cabeza. Es por eso que ya antes de Docker utilizábamos Vagrant con Ansible y Virtualbox para disponer de entornos de desarrollo uniformes. Esto nos permitía desarrollar utilizando las versiones concretas de las aplicaciones (apache, php, mysql…) que íbamos a tener en producción, evitando así los típicos problemas relacionados con las versiones de software.

En la gestión de proyectos de desarrollo es muy importante tener la capacidad de que cualquier desarrollador pueda unirse a un desarrollo sin tener que perder el tiempo en preparar el entorno. Para esto Vagrant nos resultaba muy útil, ya que con un simple «vagrant up» teníamos, en cuestión de minutos, todo el entorno listo para desarrollar.

El problema que teníamos con este sistema es que consumía muchos recursos y no podías trabajar en dos proyectos a la vez porque se ralentizaba mucho el equipo.

Cuando apareció Docker nos gustó mucho. Venía a solucionar de un plumazo los problemas con las versiones ya que, en teoría, lo que corre en desarrollo es lo mismo que corre en preproducción y producción: las imágenes sobre las que corren los contenedores son idénticas en todos los entornos.

Pero el cambio de paradigma era demasiado grande para hacerlo de golpe, así que empezamos a utilizar Vagrant con Docker como provider. Lo hicimos a nuestra manera, ya que no seguíamos la filosofía de Docker de un servicio – un contenedor, sino que utilizábamos los contenedores como si fueran máquinas virtuales, pero mucho más ligeras. Podéis ver una presentación que preparé a modo de curso para el departamento de desarrollo pinchando aquí.

Según se extendía el uso de Docker nos empapamos de su filosofía pero nos surgían ciertas dudas:

  • ¿Tenemos que levantar un contenedor por cada servicio que corra nuestra aplicación?
  • ¿Tenemos que ejecutar un comando de 3 líneas por cada contenedor para que compartan volúmenes y se vean entre ellos dentro de su red?
  • ¿No podemos automatizar todo esto?

Como utilizábamos Vagrant por aquel entonces, empezamos a automatizar la creación de los contenedores en el vagrantfile. Sin embargo, se hacía bastante complejo dejarlo bien definido.

Entonces llegó «el pulpo» con sus tentáculos para hacernos la vida más fácil. Estoy hablando, como no, de Docker ComposePor fin teníamos una forma fácil de definir todas las necesidades de cada uno de los contenedores y de lanzarlos todos con un solo comando: docker-compose up .

Nos dio mucha pena pero desde entonces abandonamos nuestro querido Vagrant y empezamos a definir nuestros entornos de desarrollo utilizando únicamente Docker y Docker Compose.

 

Al grano

El «grano» es un hola mundo que se ejecuta sobre php-fpm y que consume datos de una base de datos en mysql. Como frontal hemos elegido apache2 porque lo conocemos muy bien, aunque podríamos haber elegido nginx perfectamente.

Lo primero que tenemos que hacer para montar el entorno de desarrollo es pensar cómo se va a estructurar nuestra aplicación para ver cuántos servicios vamos a necesitar que ejecute nuestra aplicación.

Desde el punto de vista tradicional de sistemas esta aplicación necesitaría:

  • Una máquina con php-fmp instalado, configurado y corriendo.
  • Una máquina (podría ser la misma) con apache2 instalado, configurado y corriendo.
  • Una máquina (podría ser la misma) con mysql instalado, configurado y corriendo.

Habría que configurar apache para que las peticiones de archivos *.php sean entregadas a la máquina donde está corriendo php-fpm y que ésta le devuelva la página ya renderizada a la máquina de apache para servirla.

Además, habría que configurar nuestra aplicación para que las llamadas a la base de datos en mysql se hagan a la máquina donde está mysql.

Una vez que tenemos claro el esquema de nuestra aplicación, hay que trasladarlo a servicios en el archivo docker-compose.yaml.

En este caso nuestra aplicación tendrá tres servicios, uno por cada máquina de nuestro esquema tradicional: un servicio para apache, otro para php y otro para mysql.

 

docker-compose.yaml

Este es el archivo donde vamos a  definir la estructura de nuestra aplicación y el que va a utilizar Docker Compose para lanzar todos los contenedores que necesitemos con los comandos adecuados según su configuración. Este archivo puede estar en cualquier carpeta, tanto dentro como fuera de nuestro proyecto. No obstante, por cuestiones prácticas, a la hora de montar volúmenes lo mejor es que esté en la raíz de nuestro proyecto.

Lo primero que tenemos que saber de este archivo es que existen dos versiones, la 1 y la 2. En la versión 1 cada clave principal era un servicio, pero en la versión 2 se introdujeron los volúmenes y las redes, por lo que cambió la estructura del archivo y los servicios pasaron a definirse dentro de la clave principal «services». Para indicar que queremos utilizar la versión 2, dado que tiene más opciones, lo primero que hay que hacer es poner, en la primera línea, version: «2» .

A parte de la clave de versión, este archivo puede tener otras tres claves principales:

  • services: dentro de esta clave se definen los servicios que va a tener nuestra aplicación.
  • volumes: dentro de esta clave se definen los volúmenes que va a tener nuestra aplicación.
  • networks: dentro de esta clave se definen las redes que se van a crear y se van a usar por la aplicación.

Los volúmenes se pueden definir también de manera dinámica dentro de los servicios, por lo que para este ejemplo no vamos a utilizarlos.

Docker Compose crea una red por defecto para cada grupo de servicios que forman la aplicación si no se define la clave principal «networks». Todos los contenedores que forman la aplicación estarán en la misma red y serán accesibles a través de su IP. Por este motivo en este ejemplo no la vamos a utilizar.

services

Según lo comentado anteriormente nuestra aplicación tendrá tres servicios, uno para apache, otro para php y otro para mysql. Para definirlos ponemos en nuestro docker-compose.yaml lo siguiente:

version: "2"
services:
  mysql:
  
  php:

  apache:

Los nombres de los servicios pueden ser los que se quieran, aunque si se usa algo lógico mejor. En este caso no nos hemos comido la cabeza y hemos llamado a cada servicio por su nombre.

Ya tenemos los servicios creados, pero ahora hay que configurarlos. Por cada servicio se creará un contenedor, y cada contenedor se tiene que basar en una imagen. Por lo general, a mi me gusta utilizar imágenes oficiales de dockerhub y si no encuentro una que me sirva para mi objetivo lo que hago es basarme en una oficial, modificarla utilizando el Dockerfile y comitearlo a nuestro repositorio para luego utilizarlo. Pero esto ya lo explicaremos en otro artículo. Ahora vamos a configurar nuestros servicios uno a uno utilizando la imágen que más nos convenga para cada caso.

MySQL

Para este servicio, después de hacer una búsqueda en dockerhub, hemos elegido la imágen oficial «mysql«. Cada versión de mysql es un tag de la imagen en dockerhub. Nosotros hemos elegido la 5.6.35 por lo que el servicio quedaría definido de la siguiente manera:

version: "2"
services:
  mysql:
    image: 'mysql:5.6.35'

Según la documentación de MySQL en dockerhub, necesitamos definir un par de variables de entorno para que el servicio de mysql se configure correctamente al arrancar. Nosotros hemos elegido la siguiente configuración:

  • MYSQL_ROOT_PASSWORD:  Define la contraseña para el usuario root.
  • MYSQL_DATABASE: Crea la base de datos que le indiquemos al arrancar.

Para que un contenedor tenga las variables de entorno definidas al arrancar hay que indicarlo en el docker-compose.yaml de la siguiente manera:

version: "2"
services:
  mysql:
    image: 'mysql:5.6.35'
    environment:
      MYSQL_ROOT_PASSWORD: "password"
      MYSQL_DATABASE: docker

 

Con esto ya tenemos nuestro servicio de mysql configurado. Por supuesto que se pueden añadir más opciones, pero de momento sigamos con el siguiente servicio.

php

Para este servicio vamos a utilizar la imagen oficial de php 7.0-fpm, por lo que nuestra configuración quedaría de la siguiente forma:

version: "2"
services:
  mysql:
    image: 'mysql:5.6.35'
    environment:
      MYSQL_ROOT_PASSWORD: "password"
      MYSQL_DATABASE: docker
  php: image: php:7.0-fpm

Sin embargo, en este caso no nos basta con esto. Como hemos dicho al principio la estructura de nuestra aplicación requiere que el contenedor de php sepa conectarse con el de mysql, pero no sabemos que IP le va a asignar docker a cada contenedor, por lo que no podemos usarla en la configuración de nuestra aplicación.

Para solucionar este problema tenemos la opción «links». Con esta opción le decimos a docker que añada una línea en el /etc/hosts del contenedor desde el que queremos conectar con la IP del contenedor al que queremos conectar y con el nombre que le especifiquemos. De esta manera, cuando hagamos un docker-compose up  docker detectará que necesita la IP del contenedor al que queremos conectar y levantará primero éste para asignarle una IP y después meter en el /etc/hosts la línea con la IP y nombre correspondientes.

Para llegar a este punto, la configuración quedaría de la siguiente forma:

version: "2"
services:
  mysql:
    image: 'mysql:5.6.35'
    environment:
      MYSQL_ROOT_PASSWORD: "password"
      MYSQL_DATABASE: docker
  php: image: php:7.0-fpm
    links: 
      - mysql:mysqldb

Con esta línea le estamos diciendo que meta en el archivo /etc/hosts del contenedor de php una línea con la IP del contenedor de mysql y con el nombre «mysqldb». De esta manera podemos utilizar en el código de nuestra aplicación el nombre de mysqldb que sabrá resolver la IP correspondiente.

Pero esta configuración tampoco es suficiente para desarrollar, ya que php necesita procesar nuestros archivos *.php. El problema es que estos archivos no están dentro del contenedor, por lo que le resulta imposible. Para solucionar este problema lo que necesitamos es que nuestro código esté disponible dentro del contenedor. Para ello utilizamos los volúmenes, con los que le decimos a Docker que una ruta concreta de nuestro equipo tiene que estar disponible en una ruta concreta del contenedor.

La configuración quedaría así:

version: "2"
services:
  mysql:
    image: 'mysql:5.6.35'
    environment:
      MYSQL_ROOT_PASSWORD: "password"
      MYSQL_DATABASE: docker
  php: image: php:7.0-fpm
    links: 
      - mysql:mysqldb
    volumes: 
      - ./:/var/www/html

En este caso lo que conseguimos es que la raíz de nuestro proyecto (recordad que el docker-compose.yaml se encuentra en la raíz de nuestro proyecto) esté disponible dentro del contenedor en la ruta /var/www/html. Hemos elegido esta ruta por mantener la estructura de directorios que se crearía en un servidor web por defecto.

Ahora ya sí podemos pasar a la configuración del servicio apache.

apache

En este caso, apache necesita conectarse con el contenedor php y necesita también tener acceso al código de nuestra aplicación para servir los archivos que no sean *.php. La configuración sería la siguiente:

version: "2"
services:
  mysql:
    image: 'mysql:5.6.35'
    environment:
      MYSQL_ROOT_PASSWORD: "password"
      MYSQL_DATABASE: docker
  php: image: php:7.0-fpm
    links: 
      - mysql:mysqldb
    volumes: 
      - ./:/var/www/html
  apache: 
    build: docker/dockerfiles/apache/ 
    links: 
      - php:phpfpm 
    volumes: 
      - ./:/var/www/html

Aquí vemos otra vez la opción «links» para que apache sepa como conectarse con php, y la opción «volumes» con la que hacemos que nuestro código esté disponible dentro del contenedor de apache.

Vemos además que no está la opción «image» y sin embargo sí otra opción llamada «build». Esto se debe a que para este servicio no hemos encontrado una imagen oficial de apache que utilizar. Lo que hemos hecho ha sido crear una. Para ello necesitamos utilizar el Dockerfile, y con esta opción le estamos diciendo a Docker que utilice el archivo Dockerfile que está en la ruta docker/dockerfiles/apache/ . A mi me gusta organizar todos los archivos necesarios para que Docker monte la aplicación dentro de una carpeta llamada docker en la raíz de la aplicación, pero cada uno puede hacerlo como quiera.

 

Dockerfile

El archivo Dockerfile es el archivo que utilizamos para automatizar la creación de imágenes. En este archivo vamos indicando los pasos a realizar para crear la imagen. No voy a entrar en definiciones ni explicaciones, ya que no entra dentro del objetivo de este artículo. Si queréis saber más podéis visitar la documentación oficial de docker aquí.

Para instalar apache2 en una imagen no basta con hacer apt-get install apache2-common, ya que si lo haces así e intentas levantar un contenedor sobre esa imagen verás que aparece una serie de errores relativos a definición de variables de entorno. Para no volvernos locos, lo que hemos hecho ha sido fijarnos en alguien que ya lo haya hecho y coger la parte que nos interesa. En este caso nos hemos basado en el Dockerfile de php:7.0.14-apache ya que parece que lo instalan y lo configuran correctamente. El Dockerfile resultante es el siguiente:

#Based on php:7.0.14-apache

FROM debian:jessie

RUN apt-get update && apt-get install -y apache2-bin apache2.2-common --no-install-recommends && rm -rf /var/lib/apt/lists/*

ENV APACHE_CONFDIR /etc/apache2
ENV APACHE_ENVVARS $APACHE_CONFDIR/envvars

RUN set -ex \
	\
# generically convert lines like
#   export APACHE_RUN_USER=www-data
# into
#   : ${APACHE_RUN_USER:=www-data}
#   export APACHE_RUN_USER
# so that they can be overridden at runtime ("-e APACHE_RUN_USER=...")
    && sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS" \
    \
# setup directories and permissions
    && . "$APACHE_ENVVARS" \
    && for dir in \
        "$APACHE_LOCK_DIR" \
        "$APACHE_RUN_DIR" \
        "$APACHE_LOG_DIR" \
        /var/www/html \
    ; do \
        rm -rvf "$dir" \
        && mkdir -p "$dir" \
        && chown -R "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$dir"; \
    done

# logs should go to stdout / stderr
RUN set -ex \
	&& . "$APACHE_ENVVARS" \
	&& ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log" \
	&& ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log" \
	&& ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"

COPY apache2-foreground /usr/local/bin/
RUN chmod +x /usr/local/bin/apache2-foreground

COPY 000-default.conf /etc/apache2/sites-enabled/000-default.conf
RUN a2enmod proxy proxy_fcgi

WORKDIR /var/www/html

EXPOSE 80

CMD ["apache2-foreground"]

Desde la línea 3 hasta la 41 está todo copiado del Dockerfile de php.  Tienen comentarios que explican lo que hace cada bloque, por lo que solo voy a explicar brevemente cada uno de los comandos que aparecen:

1. FROM debian:jessie

Le estamos diciendo a Docker que se descargue la imagen de debian:jessie y que el resto de pasos los haga sobre esta imagen.

2. RUN apt-get update && apt-get install -y apache2-bin apache2.2-common –no-install-recommends && rm -rf /var/lib/apt/lists/*

El comando RUN se encargar de ejecutar en una shell lo que se le indique. En este caso instala los paquetes necesarios para tener apache.

3. ENV APACHE_CONFDIR /etc/apache2

El comando ENV define variables de entorno que estarán disponibles dentro del contenedor que se ejecute sobre la imagen resultante.

4. COPY 000-default.conf /etc/apache2/sites-enabled/000-default.conf

El comando COPY copia un archivo de tu equipo a la ruta especificada dentro de la imagen.

En este caso lo que hacemos es copiar la configuración del host virtual que definimos nosotros para reemplazar la que viene por defecto. De hecho, necesitamos hacerlo para poder configurar la comunicación de apache con phpfpm.

Este comando solo puede acceder a archivos que estén en la misma carpeta que el Dockerfile o subcarpetas. Las rutas son relativas desde la carpeta donde está el archivo Dockerfile.

5. RUN a2enmod proxy proxy_fcgi

Este comando ya lo hemos visto, pero vuelvo a referirlo porque lo que hace es habilitar los módulos de apache «proxy» y «proxy_fcgi» necesarios para la comunicación entre apache y phpfpm

5. WORKDIR /var/www/html

El comando WORKDIR hace que cualquier comando ejecutado desde su definición, ya sea dentro del Dockerfile o dentro de los contenedores que se creen a partir de la imagen resultante, se ejecutarán desde el directorio indicado.

6. EXPOSE 80

El comando EXPOSE indica que los contenedores levantados sobre esta imagen tendrán expuesto el puerto 80. Este puerto es necesario para que apache acepte conexiones.

7. CMD [«apache2-foreground»]

El comando CMD indica que, cuando se levante un contenedor sobre esta imagen sin especificar un comando a ejecutar, el que se ejecute será el definido aquí.

El «comando» apache2-foreground es un archivo que tenemos que tener en la carpeta del archivo Dockerfile y que hemos copiado de aquí.

 

El archivo del host virtual que se indica en el punto 4 tiene la siguiente configuración:

<VirtualHost *:80>
        # The ServerName directive sets the request scheme, hostname and port that
        # the server uses to identify itself. This is used when creating
        # redirection URLs. In the context of virtual hosts, the ServerName
        # specifies what hostname must appear in the request's Host: header to
        # match this virtual host. For the default virtual host (this file) this
        # value is not decisive as it is used as a last resort host regardless.
        # However, you must set it for any further virtual host explicitly.
        #ServerName www.example.com
        ServerAdmin webmaster@localhost
        DocumentRoot /var/www/html/public
        # Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
        # error, crit, alert, emerg.
        # It is also possible to configure the loglevel for particular
        # modules, e.g.
        #LogLevel info ssl:warn
        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined
        # For most configuration files from conf-available/, which are
        # enabled or disabled at a global level, it is possible to
        # include a line for only one particular virtual host. For example the
        # following line enables the CGI configuration for this host only
        # after it has been globally disabled with "a2disconf".
        #Include conf-available/serve-cgi-bin.conf
        #<FilesMatch \.php$>
        #        SetHandler proxy:fcgi://phpfpm:9000/var/www/html
        #</FilesMatch>
        <Directory /var/www/html/public>
            # enable the .htaccess rewrites
            Options Indexes FollowSymLinks MultiViews
            AllowOverride All
            Order allow,deny
            allow from all
            require all granted
        </Directory>
        ProxyPassMatch ^/(.*\.php(/.*)?)$ fcgi://phpfpm:9000/var/www/html/public/$1
</VirtualHost>

Como podemos ver este archivo es el que crea apache por defecto. Lo que hemos hecho ha sido cambiar un par de cosas:

1. DocumentRoot /var/www/html/public

Aquí le estamos indicando a apache cual es la raíz del host virtual, es decir, la carpeta «public».

2. <Directory /var/www/html/public>

Aquí estamos definiendo las opciones de la carpeta public. Estas son las opciones por defecto de apache.

3. ProxyPassMatch ^/(.*\.php(/.*)?)$ fcgi://phpfpm:9000/var/www/html/public$1

Esta es la línea que hace que se comunique con phpfpn. Lo que hace es que cualquier archivo que le pidan que tenga como extensión .php hace un proxy pass al contenedor donde está corriendo el php-fpm. Como recordaréis, php-fpm en el docker-compose.yaml está definido en el servicio php, y éste está enlazado con el servicio apache con el nombre de «phpfpm». Es por esto que en la ruta fcgi aparece el host «phpfpm». El puerto que aparece es el que utiliza por defecto la imagen de php-fpm y, por lo tanto, nuestro contenedor que corre sobre esta imagen.

La ruta que hay después del puerto es la ruta a nuestra aplicación para que php-fmp pueda encontrar el archivo en cuestión y procesarlo.

 

En marcha

Ya tenemos todo listo, así que vamos a lanzar los contenedores. ¿Cómo lo hacemos? Con un simple docker-compose up  desde la raíz del proyecto o desde alguna subcarpeta.

Veamos que pasa:

/home/user/workspace/desarrollandocondocker$ docker-compose up
Creating network "desarrollandocondocker_default" with the default driver
Building apache
Step 1 : FROM debian:jessie
...
Step 2 : RUN apt-get update && apt-get install -y apache2-bin apache2.2-common --no-install-recommends && rm -rf /var/lib/apt/lists/*
...
Step 3 : ENV APACHE_CONFDIR /etc/apache2
...
Step 4 : ENV APACHE_ENVVARS $APACHE_CONFDIR/envvars
...
Step 5 : RUN set -ex && sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS" && . "$APACHE_ENVVARS" && for dir in "$APACHE_LOCK_DIR" "$APACHE_RUN_DIR" "$APACHE_LOG_DIR" /var/www/html ; do rm -rvf "$dir" && mkdir -p "$dir" && chown -R "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$dir"; done
...
Step 6 : RUN set -ex && . "$APACHE_ENVVARS" && ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log" && ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log" && ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"
...
Step 7 : RUN a2enmod proxy proxy_fcgi
...
Step 8 : COPY apache2-foreground /usr/local/bin/
...
Step 9 : RUN chmod +x /usr/local/bin/apache2-foreground
...
Step 10 : COPY 000-default.conf /etc/apache2/sites-enabled/000-default.conf
...
Step 11 : WORKDIR /var/www/html
...
Step 12 : EXPOSE 80
...
Step 13 : CMD apache2-foreground
...
Successfully built 1ac9884c8584
Creating desarrollandocondocker_mysql_1
Creating desarrollandocondocker_php_1
Creating desarrollandocondocker_apache_1
Attaching to desarrollandocondocker_mysql_1, desarrollandocondocker_php_1, desarrollandocondocker_apache_1
php_1 | [22-Dec-2016 15:24:00] NOTICE: fpm is running, pid 1
php_1 | [22-Dec-2016 15:24:00] NOTICE: ready to handle connections
apache_1 | AH00112: Warning: DocumentRoot [/var/www/html/public] does not exist
apache_1 | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.22.0.4. Set the 'ServerName' directive globally to suppress this message
apache_1 | AH00112: Warning: DocumentRoot [/var/www/html/public] does not exist
apache_1 | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.22.0.4. Set the 'ServerName' directive globally to suppress this message
apache_1 | [Thu Dec 22 15:24:01.171425 2016] [mpm_event:notice] [pid 1:tid 140428340189056] AH00489: Apache/2.4.10 (Debian) configured -- resuming normal operations
apache_1 | [Thu Dec 22 15:24:01.171479 2016] [core:notice] [pid 1:tid 140428340189056] AH00094: Command line: 'apache2 -D FOREGROUND'
mysql_1 | 2016-12-30 12:09:58 0 [Note] /usr/sbin/mysqld (mysqld 5.6.35) starting as process 58 ...
mysql_1 | Database initialized
mysql_1 | MySQL init process in progress...
mysql_1 | 2016-12-30 12:10:00 0 [Note] mysqld (mysqld 5.6.35) starting as process 81 ...
mysql_1 | 
mysql_1 | MySQL init process done. Ready for start up.
mysql_1 | 
mysql_1 | 2016-12-30 12:10:04 0 [Note] mysqld (mysqld 5.6.35) starting as process 1 ...
mysql_1 | 2016-12-30 12:10:04 1 [Note] Plugin 'FEDERATED' is disabled.
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Using atomics to ref count buffer pool pages
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: The InnoDB memory heap is disabled
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Mutexes and rw_locks use GCC atomic builtins
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Memory barrier is not used
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Compressed tables use zlib 1.2.8
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Using Linux native AIO
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Using CPU crc32 instructions
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Initializing buffer pool, size = 128.0M
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Completed initialization of buffer pool
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Highest supported file format is Barracuda.
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: 128 rollback segment(s) are active.
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Waiting for purge to start
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: 5.6.35 started; log sequence number 1625997
mysql_1 | 2016-12-30 12:10:04 1 [Note] Server hostname (bind-address): '*'; port: 3306
mysql_1 | 2016-12-30 12:10:04 1 [Note] IPv6 is available.
mysql_1 | 2016-12-30 12:10:04 1 [Note] - '::' resolves to '::';
mysql_1 | 2016-12-30 12:10:04 1 [Note] Server socket created on IP: '::'.
mysql_1 | 2016-12-30 12:10:04 1 [Warning] 'proxies_priv' entry '@ root@ce71f123ec57' ignored in --skip-name-resolve mode.
mysql_1 | 2016-12-30 12:10:04 1 [Note] Event Scheduler: Loaded 0 events
mysql_1 | 2016-12-30 12:10:04 1 [Note] mysqld: ready for connections.
mysql_1 | Version: '5.6.35' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server (GPL)


Vemos que lo primero que hace es crear una red para utilizar con nuestra aplicación utilizando el driver «default». Para más información sobre redes y drivers consultar la documentación oficial aquí.

Lo siguiente que hace es crear la imagen apache. Vemos cómo hace todos los pasos indicados en el Dockerfile y da información sobre el resultado de cada paso. Parece que todo ha ido bien y se ha creado la imagen correctamente.

Lo siguiente que vemos es que se crean los tres contenedores que necesitamos: desarrollandocondocker_mysql_1, desarrollandocondocker_php_1 y desarrollandocondocker_apache_1.

Hay que tener en cuenta que, si los contenedores tienen que correr sobre imágenes que no tenemos descargadas, Docker las descargará automáticamente y veréis el progreso de descarga.

A continuación, lo que hace Docker es «conectarse» con la salida estándar de cada uno de los contenedores y mostrar toda la información que van soltando cada uno de ellos.

A los logs de cada uno de los contenedores se les añade el prefijo del nombre del servicio y un número. En nuestro caso las líneas que empiezan por «mysql_1 |» son los logs de nuestro mysql, las que empiezan por «php_1 |» son los logs de nuestros php y las que empiezan por «apache_1 |» son los logs de nuestro apache.

Si agrupamos los logs por cada servicio vemos lo siguiente:

MySQL

mysql_1 | 2016-12-30 12:09:58 0 [Note] /usr/sbin/mysqld (mysqld 5.6.35) starting as process 58 ...
mysql_1 | Database initialized
mysql_1 | MySQL init process in progress...
mysql_1 | 2016-12-30 12:10:00 0 [Note] mysqld (mysqld 5.6.35) starting as process 81 ...
mysql_1 | 
mysql_1 | MySQL init process done. Ready for start up.
mysql_1 | 
mysql_1 | 2016-12-30 12:10:04 0 [Note] mysqld (mysqld 5.6.35) starting as process 1 ...
mysql_1 | 2016-12-30 12:10:04 1 [Note] Plugin 'FEDERATED' is disabled.
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Using atomics to ref count buffer pool pages
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: The InnoDB memory heap is disabled
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Mutexes and rw_locks use GCC atomic builtins
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Memory barrier is not used
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Compressed tables use zlib 1.2.8
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Using Linux native AIO
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Using CPU crc32 instructions
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Initializing buffer pool, size = 128.0M
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Completed initialization of buffer pool
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Highest supported file format is Barracuda.
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: 128 rollback segment(s) are active.
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: Waiting for purge to start
mysql_1 | 2016-12-30 12:10:04 1 [Note] InnoDB: 5.6.35 started; log sequence number 1625997
mysql_1 | 2016-12-30 12:10:04 1 [Note] Server hostname (bind-address): '*'; port: 3306
mysql_1 | 2016-12-30 12:10:04 1 [Note] IPv6 is available.
mysql_1 | 2016-12-30 12:10:04 1 [Note] - '::' resolves to '::';
mysql_1 | 2016-12-30 12:10:04 1 [Note] Server socket created on IP: '::'.
mysql_1 | 2016-12-30 12:10:04 1 [Warning] 'proxies_priv' entry '@ root@ce71f123ec57' ignored in --skip-name-resolve mode.
mysql_1 | 2016-12-30 12:10:04 1 [Note] Event Scheduler: Loaded 0 events
mysql_1 | 2016-12-30 12:10:04 1 [Note] mysqld: ready for connections.
mysql_1 | Version: '5.6.35' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server (GPL)

Vemos como arranca mysql y vemos que ha arrancado bien ya que en la anteúltima línea pone que está preparado para recibir conexiones y da información sobre el servidor en la última.

php

php_1 | [22-Dec-2016 15:24:00] NOTICE: fpm is running, pid 1
php_1 | [22-Dec-2016 15:24:00] NOTICE: ready to handle connections

Aquí también vemos que php está listo para manejar conexiones.

apache

apache_1 | AH00112: Warning: DocumentRoot [/var/www/html/public] does not exist
apache_1 | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.22.0.4. Set the 'ServerName' directive globally to suppress this message
apache_1 | AH00112: Warning: DocumentRoot [/var/www/html/public] does not exist
apache_1 | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.22.0.4. Set the 'ServerName' directive globally to suppress this message
apache_1 | [Thu Dec 22 15:24:01.171425 2016] [mpm_event:notice] [pid 1:tid 140428340189056] AH00489: Apache/2.4.10 (Debian) configured -- resuming normal operations
apache_1 | [Thu Dec 22 15:24:01.171479 2016] [core:notice] [pid 1:tid 140428340189056] AH00094: Command line: 'apache2 -D FOREGROUND'

 

En el caso de apache parece que las cosas no han ido tan bien. Nos han salido dos Warnings diciendo que no existe el DocumentRoot /var/www/html/public.

En el archivo del virtual host de apache hemos puesto que nuestro document root iba a estar en /var/www/html/public. Como recordaréis, esta ruta forma parte del volumen que definimos en el docker-compose.yaml, es decir, /var/www/html/ es la raíz de nuestra aplicación, por lo que si creamos la carpeta public ahí estará automáticamente disponible en /var/www/html/public.

 

Reiniciando contenedores

Una vez creada la carpeta public vamos a reiniciar los contenedores para ver que nos cuenta esta vez apache. Para hacerlo, lo primero que hay que hacer es recuperar el prompt de la terminal ya que como hemos lanzado el comando docker-compose up  sin el parámetro  -d  los contenedores está corriendo en primer plano.

Ctrl+c
^CGracefully stopping... (press Ctrl+C again to force)
Stopping desarrollandocondocker_apache_1 ... done
Stopping desarrollandocondocker_php_1 ... done
Stopping desarrollandocondocker_mysql_1 ... done
/home/user/workspace/desarrollandocondocker$ docker-compose up -d
Starting desarrollandocondocker_mysql_1
Starting desarrollandocondocker_php_1
Starting desarrollandocondocker_apache_1
/home/user/workspace/desarrollandocondocker$ docker-compose logs -f --tail=20

Al hacer crtl+c se paran todos los contenedores.

Después ejecutamos docker-compose up -d . Esta vez lo hacemos con la opción  -d  para que los contenedores se ejecuten en background y nos devuelva el prompt. De esta manera no vemos ninguna información de los contenedores, por lo que si queremos ver los logs debemos decírselo a docker-compose mediante docker-compose logs -f –tail=20 .

El comando docker-compose logs muestra los logs de todos los contenedores. Al añadir el parámetro  -f  lo que hacemos es que, una vez llegado al final del archivo se mantenga observando para que cuando lleguen más logs los muestre. El parámetro  –tail=20  lo que hace es decirle que no lea todo el archivo sino que lea solo las últimas 20 líneas. Esto es útil para contenedores que llevan mucho tiempo en marcha y tienen muchos logs.

Una vez ejecutado el comando, si nos fijamos solo en los logs de apache vemos lo siguiente:

apache_1  | [Thu Dec 22 16:06:54.385138 2016] [core:notice] [pid 1:tid 140283519170432] AH00094: Command line: 'apache2 -D FOREGROUND'
apache_1  | [Thu Dec 22 16:09:27.661333 2016] [mpm_event:notice] [pid 1:tid 140283519170432] AH00491: caught SIGTERM, shutting down
apache_1  | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.22.0.4. Set the 'ServerName' directive globally to suppress this message
apache_1  | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.22.0.4. Set the 'ServerName' directive globally to suppress this message
apache_1  | [Thu Dec 22 16:09:43.074193 2016] [mpm_event:notice] [pid 1:tid 140333996349312] AH00489: Apache/2.4.10 (Debian) configured -- resuming normal operations
apache_1  | [Thu Dec 22 16:09:43.074304 2016] [core:notice] [pid 1:tid 140333996349312] AH00094: Command line: 'apache2 -D FOREGROUND'

Ya no nos da ningún warning y parece que todo va bien, así que… ¡¡¡¡Ya podemos ver nuestra web!!!!

 

Accediendo a contenedores

Pero… ¿Cómo vemos la web? ¿Dónde está?

Como ya hemos comentado, Docker crea una interfaz de red para cada aplicación y le da a cada servicio una IP, solo que no sabemos cuál. Para averiguarlo, hay que inspeccionar nuestro contenedor, que hace de frontal de la aplicación. En nuestro caso, el que tiene corriendo Apache.

Para hacerlo necesitamos saber el nombre de nuestro contenedor. Esto lo podemos averiguar de dos maneras:

  1. docker ps
  2. docker-compose ps

La primera muestra la lista de todos los contenedores que tenemos corriendo en nuestra máquina, mientras que la segunda muestra los contenedores que tenemos corriendo solo para nuestra aplicación. Yo personalmente prefiero utilizar la segunda porque tengo bastantes contenedores corriendo en mi máquina y me cuesta más encontrar el que busco con la primera opción. Sin embargo, con la segunda opción, lo que obtengo es lo siguiente:

docker@docker:~/workspace/blog/desarrollandoConDocker$ docker-compose ps
             Name                           Command             State    Ports   
--------------------------------------------------------------------------------
desarrollandocondocker_apache_1   apache2-foreground            Up      80/tcp   
desarrollandocondocker_mysql_1    docker-entrypoint.sh mysqld   Up      3306/tcp 
desarrollandocondocker_php_1      php-fpm                       Up      9000/tcp 
docker@docker:~/workspace/blog/desarrollandoConDocker$

En este caso el nombre de nuestro contenedor es desarrollandocondocker_apache_1 .

Ahora que ya lo tenemos, vamos a obtener la IP. Docker Compose no tiene la opción de «inspeccionar» contenedores, por lo que lo que tenemos que hacer es:

docker inspect desarrollandocondocker_apache_1

Esto nos devuelve toda la información del contenedor. No voy a entrar en detalles, solo voy a fijarme en lo que me interesa en este momento, que es:

[
    {
        "Id": "ef7e51df0c14926757c0f6e76952df5728942a8a351ba863b481bb7d50cfb841",
        "Created": "2016-12-30T09:00:23.8548314Z",
        "Path": "apache2-foreground",
        "Args": [],
        ...
        "NetworkSettings": {
            "Bridge": "",
            ...
            "Networks": {
                "desarrollandocondocker_default": {
                    ...
                    "Gateway": "172.22.0.1",
                    "IPAddress": "172.22.0.4",
                    "IPPrefixLen": 16,
                    ...
                }
            }
        }
    }
]

Como vemos en la salida del comando, la IP de nuestro contenedor es:  172.22.0.4 , por lo que si abrimos un navegador y escribimos  http://172.22.0.4/  veremos un mensaje que dice «file not found». Esto es normal, porque hemos creado la carpeta public pero nuestro proyecto no tiene código todavía.

 

ATENCIÓN

Esto no funciona en macOS. Podéis ver más información aquí.

Para acceder a los contenedores en macOS lo que tenemos que hacer es mapear puertos. En linux también se puede hacer. Sin embargo, yo no soy partidario de ello, ya que cuando trabajamos con varias aplicaciones a la vez hay que identificar qué puertos tenemos ocupados. De otra forma,  la aplicación no se levanta.

Para mapear puertos habría que configurarlo de la siguietne manera:

version: "2"
services:
  mysql:
    image: 'mysql:5.6.35'
    environment:
      MYSQL_ROOT_PASSWORD: "password"
      MYSQL_DATABASE: docker
  php: image: php:7.0-fpm
    links: 
      - mysql:mysqldb
    volumes: 
      - ./:/var/www/html
  apache: 
    build: docker/dockerfiles/apache/ 
    links: 
      - php:phpfpm 
    volumes: 
      - ./:/var/www/html
    ports:
      - 9000:80

Mediante la opción «ports» podemos mapear todos los puertos que queramos. En este caso estamos mapeando el puerto 9000 de nuestro equipo local con el puerto 80 del contenedor con nuestro apache. De esta manera podemos acceder a la aplicación mediante la ruta:  http://localhost:9000 .

 

Desarrollando nuestra aplicación

Ahora que está todo listo, ya podemos desarrollar nuestra aplicación. Como ya hemos comentado, nuestra aplicación va a consistir en un simple hola mundo.

Vamos a crear un archivo index.html en public con el siguiente contenido:

<h1>Hola html</h1>

Si recargamos la página vemos ya nuestro hola mundo.

Si ejecutamos  docker-compose logs -f –tail=20  y recargamos la página vemos que quien responde es apache. Esto es porque estamos pidiendo una página en html y puede servirla apache directamente, no necesita de php.

apache_1  | 172.22.0.1 - - [30/Dec/2016:10:39:36 +0000] "GET / HTTP/1.1" 200 302 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.89 Safari/537.36"

 

Ahora cambiemos nuestro index.html por index.php y pongamos lo siguiente:

 

<?php
echo "<h1>Hola php</h1>";

Si recargamos la página viendo los logs veremos cómo apache le ha pasado la petición al contenedor de php, este le ha contestado con un 200 y apache nos ha servido la página que le ha pasado php ya renderizada.

php_1     | 172.22.0.4 -  30/Dec/2016:10:36:24 +0000 "GET /index.php" 200
apache_1  | 172.22.0.1 - - [30/Dec/2016:10:36:24 +0000] "GET / HTTP/1.1" 200 312 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.89 Safari/537.36"

 

Ahora vamos a complicarlo un poquito más y vamos a conectarnos a la base de datos en el contenedor de mysql. Para esto modificamos nuestro index.php y lo dejamos así:

<?php
echo "<h1>Hola php</h1>";

$connection = mysqli_connect("mysqldb", "root", "password", "docker");

if (!$connection) {
    echo "Error conectando a la base de datos";
    exit;
}

echo "Conecatdo a la base de datos correctamente.";

En este código lo único que hacemos es intentar conectarnos con la base de datos en mysql. Si nos fijamos, el host es mysqldb, que es el nombre que le hemos dado al enlace entre nuestro php y nuestro mysql.

Si recargamos la página vemos cómo nos da un error. Esto se debe a que la imagen de php que utilizamos no tiene la extensión de mysql instalada.

 

Instalando extensiones en las imágenes oficiales de php

Dado que estamos utilizando la imagen oficial de php, lo primero que tenemos que hacer es leernos su documentación. En el apartado «How to install more PHP extensions» nos explica de forma muy clara cómo tenemos que hacerlo.

Así que vamos a crear un Dockerfile para crear la imagen en php con las extensiones que queremos, en nuestro caso «mysqli». Para esto tenemos que modificar el archivo docker-compose.yaml de tal manera que use un Dockerfile para crear la imágen.

version: "2"
services:
  mysql:
    image: 'mysql:5.6.35'
    environment:
      MYSQL_ROOT_PASSWORD: "password"
      MYSQL_DATABASE: docker
  php:
    build: docker/dockerfiles/php/
    links:
      - mysql:mysqldb
    volumes:
      - ./:/var/www/html
  apache:
    build: docker/dockerfiles/apache/
    links:
      - php:phpfpm
    volumes:
      - ./:/var/www/html

Ahora le hemos dicho que lea el Dockerfile que está en «docker/dockerfiles/php/», así que vamos a crearlo de la siguiente manera:

FROM php:7.0-fpm
RUN docker-php-ext-install mysqli

Para rehacer la imagen de php lo que hay que hacer es primer eliminar los contenedores que están levantados ahora mismo y después levantarlos otra vez indicándole a Docker que queremos que se rehaga la imagen:

docker-compose down
docker-compose up -d --build

Si vemos los logs vemos que en nuestro contenedor de php se ha instalado la extensión de mysqli.

Al recargar la página ahora vemos que se ha conectado correctamente a la base de datos.

 

Todavía más

Esto es solo un pequeño ejemplo muy muy básico de cómo desarrollar con Docker. Por supuesto que una aplicación real supone muchas más cosas, como un framework (Zend, Symfony,…), muchas extensiones de php necesarias, la instalación de librerías mediante composer, etc.

En una aplicación real todo se complica mucho, y más si tenemos en cuenta que uno de los objetivos de desarrollar con Docker (quedándonos solo con el punto de vista de desarrollo) es que cualquier persona que quiera incorporarse al proyecto no tenga que preocuparse de instalar las extensiones, dependencias, etc… Se trata de que solo tenga que hacer un docker-compose up para poder ponerse a programar en cuestión de pocos minutos.

Desde el punto de vista de gestión de proyectos es muy importante que cualquier desarrollador pueda incorporarse a cualquier proyecto sin tener que pasar unas cuantas horas configurando el entorno.

 

Próximamente

En un próximo artículo intentaré explicar como montar el entorno de desarrollo para una aplicación compleja, con Symfony, MongoDb y Apache y automatizar la instalación de dependencias, limpieza de cache, etc. ¡No os lo perdáis!

 

 

 



¿Te gusta este post? Es solo un ejemplo de cómo podemos ayudar a tu empresa...
Sobre Luis Felipe García

Departamento de desarrollo de Irontec. Apasionado de la informática, GNU/Linux, Software Libre y de mi familia.

1 Comentario

¿Por qué no comentas tú también?


  • Excelente! me quedo jugando con esto hasta que salga como componer un entorno con symfony!
    No me quedo claro lo siguiente… entiendo que luego de bajar cada servicio, se vuelve a 0 la imagen. Donde se están guardando los datos de la imagen con servicio de mysql ? No veo que se monte ninguna carpeta de la maquina host como para mantener el estado.

    Saludos!

    Rodrigo Hace 7 años Responde


    • Gracias.

      En este ejemplo no persisten los datos de mysql. Esto pensaba explicarlo en el siguiente post pero, como adelanto, si quieres persistir los datos (nosotros en desarrollo lo solemos hacer aunque luego en producción suele ir contra una base de datos externa) bastaría con añadir al docker-compose.yaml, en el servicio mysql, el volumen adecuado.

      Según la documentación de la imagen oficial de mysql (en la sección «Where to Store Data») hay que montar un volumen local mapeado a la ruta /var/lib/mysql. Nosotros solemos meter los volúmenes que vamos a usar dentro de la carpeta docker/volumes/[nombre_de_servicio] dentro de la carpeta raíz del proyecto, aunque esto es a gusto del consumidor.

      El docker-compose.yaml quedaría así:


      version: "2"
      services:
        mysql:
          image: 'mysql:5.6.35'
          environment:
            MYSQL_ROOT_PASSWORD: "password"
            MYSQL_DATABASE: docker
          volumes:
            - ./docker/volumes/mysql:/var/lib/mysql

      Un saludo.

      Luis Felipe García Hace 7 años Responde


  • Muchas gracias por compartir toda esta informacion. En este momento estoy trabajando sobre un proyecto utilizando cakePhp, pero no tengo idea de como hacerlo. te agradezco si me puedes colaborar con los pasos a seguir para hacerlo.

    Carlos Hace 7 años Responde


    • La verdad es que no he utilizado nunca cackePhp por lo que no sabría decirte. En cualquier caso, nuestro equipo de desarrollo tiene una amplia experiencia desarrollando en PHP por lo que si te pones en contacto con nuestro departamento comercial podríamos ver como podemos ayudarte.

      Luis Felipe García Hace 6 años Responde


  • Luis Felipe para cuando la segunda parte?

    bumiga Hace 6 años Responde


    • El día a día ha hecho que me retrase bastante en la segunda entrega, pero intentaré retomarlo y publicarlo lo antes posible.

      Luis Felipe García Hace 6 años Responde


  • Muy bueno! Me gustaría saber cómo hacen debug de la aplicación servida luego de levantar los servicios. 🙂

    Jonathan Hace 6 años Responde


    • Para debugear usamos XDebug instalado en el contenedor de php y configurando el IDE para que apunte a la IP o el puerto mapeado del contenedor.
      En el siguiente artículo intentaré poner un ejemplo.

      Luis Felipe García Hace 6 años Responde


  • ¿Hay fecha sobre el artículo anunciado al final de éste relacionado sobre una configuración de Docker con Symphony, MongoDB, Apache y otras opciones automatizadas? ¿Acaso ya se ha realizado? Si es así, ¿me pasas el enlace? Gracias, saludos.

    Yo Hace 6 años Responde


    • Todavía no hay fecha para el siguiente artículo, pero intentaré hacerlo lo antes posible. Estate atento…!!!

      Luis Felipe García Hace 6 años Responde


  • Impresionante, excelente, maravilloso, etc…

    Me veo en la obligación de implementar Docker en un proyecto la diferencia es que solo necesito de PHP ya que los demas servicios ya existen en el servidor, vamos a ver que pasa.

    Muchas gracias por este post.

    Cristhian Alexis Galeano Ruiz Hace 6 años Responde


Queremos tu opinión :)