In this part of the tutorial series on developing PHP on Docker we will
deploy our dockerized PHP application to a production environment on GCP using multiple VMs
and run it via "plain" docker
(without compose
).
You'll learn how to orchestrate the deployment of multiple VMs, ensure their connectivity amongst each other and deploy a dockerized PHP application by building and pushing the images locally to pull and start them from the VMs.
The deployment process will be reflected in a single
make
target called deploy
.
All code samples are publicly available in my
Docker PHP Tutorial repository on Github.
You find the branch with the final result of this tutorial at
part-11-deploy-dockerized-php-app-production.
All published parts of the Docker PHP Tutorial are collected under a dedicated page at
Docker PHP Tutorial. The previous part was
Create a production infrastructure for dockerized PHP Apps on GCP.
and the following one is
Use the gcloud
cli docker image instead of installing it locally.
If you want to follow along, please subscribe to the RSS feed or via email to get automatic notifications when the next part comes out :)
Table of contents
- Introduction
- Running containers with
docker
instead ofdocker compose
- Changes in the deployment process
- Wrapping up
Introduction
The general deployment process is essentially the same as outlined in the previous tutorial at Deploy dockerized PHP Apps to production on GCP via docker compose as a POC: Deployment workflow.
For a single VM it looks like shown in the following video:
This process now needs to be done for every Compute Instance VM that runs a dockerized service (i.e. everything labeled "GCP VM" in the following image)
We are still able to perform all necessary steps via the deploy
target defined in
.make/05-00-deployment.mk
:
.PHONY: deploy
deploy: # Build all images and deploy them to GCP
@printf "$(GREEN)Cleaning up old 'deployment-settings.env' file$(NO_COLOR)\n"
@"$(MAKE)" make-remove-deployment-settings
@printf "$(GREEN)Starting docker setup locally$(NO_COLOR)\n"
@"$(MAKE)" docker-compose-up
@printf "$(GREEN)Verifying that there are no changes in the secrets$(NO_COLOR)\n"
@"$(MAKE)" gpg-init
@"$(MAKE)" deployment-guard-secret-changes
@printf "$(GREEN)Verifying that there are no uncommitted changes in the codebase$(NO_COLOR)\n"
@"$(MAKE)" deployment-guard-uncommitted-changes
@printf "$(GREEN)Initializing gcloud deployment service account$(NO_COLOR)\n"
@"$(MAKE)" gcp-init-deployment-account
@printf "$(GREEN)Switching to 'prod' environment ('deployment-settings.env' file)$(NO_COLOR)\n"
@"$(MAKE)" make-init-deployment-settings ENVS="ENV=prod TAG=latest"
@printf "$(GREEN)Creating build information file$(NO_COLOR)\n"
@"$(MAKE)" deployment-create-build-info-file
@printf "$(GREEN)Building docker images$(NO_COLOR)\n"
@"$(MAKE)" docker-compose-build
@printf "$(GREEN)Pushing images to the registry$(NO_COLOR)\n"
@"$(MAKE)" docker-compose-push
@printf "$(GREEN)Creating the service-ips file$(NO_COLOR)\n"
@"$(MAKE)" deployment-create-service-ip-file
@printf "$(GREEN)Creating the deployment archive$(NO_COLOR)\n"
@"$(MAKE)" deployment-create-tar
@printf "$(GREEN)Copying the deployment archive to the VMs and run the deployment$(NO_COLOR)\n"
@"$(MAKE)" deployment-run-on-vms
@printf "$(GREEN)Clearing deployment archive$(NO_COLOR)\n"
@"$(MAKE)" deployment-clear-tar
@printf "$(GREEN)Removing 'deployment-settings.env' file$(NO_COLOR)\n"
@"$(MAKE)" make-remove-deployment-settings
FYI: The individual make
targets are explained in detail at the
deploy
target section of the previous docker compose
based approach
There are some changes in this tutorial to accommodate for the switch from docker compose
to
docker
and the usage of individual VMs per service over a single one.
Run the code yourself
CAUTION: The following steps assume, that the necessary infrastructure on GCP was already created and that you have the key files for the two service accounts
gcp-master-service-account-key.json
(created manually upfront)gcp-service-account-key.json
(created automatically as part of the setup script)
If that's not the case, please go through the setup steps outlined in the previous tutorial Create a production infrastructure for dockerized PHP Apps on GCP: Run the code yourself first. If you need to go through the steps, please keep in mind that some targets have been renamed in this part, especially
docker-build => docker-compose-build
docker-up => docker-compose-up
# Prepare the codebase
git clone https://github.com/paslandau/docker-php-tutorial.git && cd docker-php-tutorial
git checkout part-11-deploy-dockerized-php-app-production
# Ensure that the service accounts key files exist
ls -l ./gcp-master-service-account-key.json ./gcp-service-account-key.json
# Should NOT fail with
# ls: cannot access './gcp-master-service-account-key.json': No such file or directory
# ls: cannot access './gcp-service-account-key.json': No such file or directory
# Run the initialization
make dev-init
# Update the variables `DOCKER_REGISTRY` and `GCP_PROJECT_ID` in `.make/variables.env`
projectId="SET YOUR GCP_PROJECT_ID HERE"
# CAUTION: Mac users might need to use `sed -i '' -e` instead of `sed -i`! @see https://stackoverflow.com/a/19457213/413531
sed -i "s/DOCKER_REGISTRY=.*/DOCKER_REGISTRY=gcr.io\/${projectId}/" .make/variables.env
sed -i "s/GCP_PROJECT_ID=.*/GCP_PROJECT_ID=${projectId}/" .make/variables.env
# Ensure that the infrastructure is up and running
make gcp-init-master-account
make infrastructure-info
make docker-compose-build
make docker-compose-up
make gpg-init
make secret-decrypt
# Retrieve the AUTH string of the redis instance and set it in the `.secrets/prod/app.env` file
auth_string=$(make -s gcp-get-redis-auth)
# CAUTION: Mac users might need to use `sed -i '' -e` instead of `sed -i`! @see https://stackoverflow.com/a/19457213/413531
sed -i "s/REDIS_PASSWORD=.*/REDIS_PASSWORD=${auth_string}/" .secrets/prod/app.env
# Encrypt the secrets and commit the changes
make secret-encrypt
git add . && git commit -m "Update the REDIS_PASSWORD and re-encrypt the secrets"
# Run the deployment
make deploy
# Migrate the database
make deployment-setup-db-on-vm
# Verify the deployment
make deployment-info
Should show something like this
$ make deployment-info
application:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f7c8079d07ad gcr.io/ay-mit-mct-atmo-gcp-temp2-c/dofroscra/application-prod:latest "/decrypt-secrets.sh…" 23 seconds ago Up 21 seconds application
BUILD INFO
==========
User : Pascal
Date : 2022-09-13 10:39:12+02:00
Branch: part-11-deploy-dockerized-php-app-production
Commit
------
commit 79438866cc7e699bdb1aab30ec32269d67044366
Author: Pascal Landau <[email protected]>
Date: Tue Sep 13 10:38:50 2022 +0200
Update the REDIS_PASSWORD and re-encrypt the secrets
php-fpm:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4150fed55c05 gcr.io/ay-mit-mct-atmo-gcp-temp2-c/dofroscra/php-fpm-prod:latest "/decrypt-secrets.sh…" 43 seconds ago Up 41 seconds 0.0.0.0:9000->9000/tcp, :::9000->9000/tcp php-fpm
BUILD INFO
==========
User : Pascal
Date : 2022-09-13 10:39:12+02:00
Branch: part-11-deploy-dockerized-php-app-production
Commit
------
commit 79438866cc7e699bdb1aab30ec32269d67044366
Author: Pascal Landau <[email protected]>
Date: Tue Sep 13 10:38:50 2022 +0200
Update the REDIS_PASSWORD and re-encrypt the secrets
php-worker:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4654abb45e1a gcr.io/ay-mit-mct-atmo-gcp-temp2-c/dofroscra/php-worker-prod:latest "/decrypt-secrets.sh…" 51 seconds ago Up 50 seconds 9001/tcp php-worker
BUILD INFO
==========
User : Pascal
Date : 2022-09-13 10:39:12+02:00
Branch: part-11-deploy-dockerized-php-app-production
Commit
------
commit 79438866cc7e699bdb1aab30ec32269d67044366
Author: Pascal Landau <[email protected]>
Date: Tue Sep 13 10:38:50 2022 +0200
Update the REDIS_PASSWORD and re-encrypt the secrets
nginx:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
2f815040499a gcr.io/ay-mit-mct-atmo-gcp-temp2-c/dofroscra/nginx-prod:latest "/docker-entrypoint.…" About a minute ago Up About a minute 0.0.0.0:80->80/tcp, :::80->80/tcp, 0.0.0.0:443->443/tcp, :::443->443/tcp nginx
Visit the UI at: http://34.134.120.87/
The last line prints the public IP address of the VM of the nginx
service that can be
accessed from the internet:
$ curl -s http://34.134.120.87
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<ul>
<li><a href="?dispatch=foo">Dispatch job 'foo' to the queue.</a></li>
<li><a href="?queue">Show the queue.</a></li>
<li><a href="?db">Show the DB.</a></li>
<li><a href="/info">Show the build info.</a></li>
</ul>
</body>
</html>
The following video shows the full process (excluding most of the waiting time)
Running containers with docker
instead of docker compose
First, we will still use docker compose
to build the production images, but we won't use it
any longer to run the containers. The good news: There are no changes whatsoever in the
Dockerfiles
. So the task comes down to modifying the docker compose
config files to
remove all run settings and provide the equivalent commands via make
targets.
Removing docker compose
run settings for prod
We can inspect the full compose
config for prod
via make docker-compose-config ENV=prod
:
$ make docker-compose-config ENV=prod
name: dofroscra_prod
services:
application:
build:
context: C:\codebase\test-deploy\docker-php-tutorial\.docker
dockerfile: ./images/php/application/Dockerfile
args:
BASE_IMAGE: gcr.io/dofroscra-part-10/dofroscra/php-base-prod:latest
ENV: prod
target: prod
image: gcr.io/dofroscra-part-10/dofroscra/application-prod:latest
networks:
default: null
nginx:
build:
context: C:\codebase\test-deploy\docker-php-tutorial\.docker
dockerfile: ./images/nginx/Dockerfile
args:
APP_CODE_PATH: /var/www/app
NGINX_VERSION: 1.21.5-alpine
target: prod
image: gcr.io/dofroscra-part-10/dofroscra/nginx-prod:latest
networks:
default: null
php-fpm:
build:
context: C:\codebase\test-deploy\docker-php-tutorial\.docker
dockerfile: ./images/php/fpm/Dockerfile
args:
BASE_IMAGE: gcr.io/dofroscra-part-10/dofroscra/php-base-prod:latest
TARGET_PHP_VERSION: "8.1"
target: prod
image: gcr.io/dofroscra-part-10/dofroscra/php-fpm-prod:latest
networks:
default: null
php-worker:
build:
context: C:\codebase\test-deploy\docker-php-tutorial\.docker
dockerfile: ./images/php/worker/Dockerfile
args:
BASE_IMAGE: gcr.io/dofroscra-part-10/dofroscra/php-base-prod:latest
PHP_WORKER_PROCESS_NUMBER: "4"
target: prod
image: gcr.io/dofroscra-part-10/dofroscra/php-worker-prod:latest
networks:
default: null
networks:
default:
name: dofroscra_prod_default
It consists of the files
.docker/docker-compose/docker-compose.local.ci.prod.yml
.docker/docker-compose/docker-compose.local.prod.yml
and the
ENV based docker compose
config
was adjusted accordingly to reflect the file changes in .make/02-00-docker.mk
# We need to "assemble" the correct combination of docker-compose.yml config files
DOCKER_COMPOSE_FILES:=
ifeq ($(ENV),prod)
DOCKER_COMPOSE_FILES:=-f $(DOCKER_COMPOSE_FILE_LOCAL_CI_PROD) -f $(DOCKER_COMPOSE_FILE_LOCAL_PROD)
else ifeq ($(ENV),ci)
DOCKER_COMPOSE_FILES:=-f $(DOCKER_COMPOSE_FILE_LOCAL_CI_PROD) -f $(DOCKER_COMPOSE_FILE_LOCAL_CI) -f $(DOCKER_COMPOSE_FILE_CI)
else ifeq ($(ENV),local)
DOCKER_COMPOSE_FILES:=-f $(DOCKER_COMPOSE_FILE_LOCAL_CI_PROD) -f $(DOCKER_COMPOSE_FILE_LOCAL_CI) -f $(DOCKER_COMPOSE_FILE_LOCAL_PROD) -f $(DOCKER_COMPOSE_FILE_LOCAL)
endif
The changes to the previous tutorial are described subsequently in more detail.
The file
.docker/docker-compose/docker-compose.local.ci.prod.yml
will be stripped down to only contain the build
instructions for the application
image:
services:
application:
image: ${DOCKER_REGISTRY?}/${DOCKER_NAMESPACE?}/application-${ENV?}:${TAG?}
build:
context: ../
dockerfile: ./images/php/application/Dockerfile
target: ${ENV?}
args:
- BASE_IMAGE=${DOCKER_REGISTRY?}/${DOCKER_NAMESPACE?}/php-base-${ENV?}:${TAG?}
- ENV=${ENV?}
All the "rest" gets moved to the new file .docker/docker-compose/docker-compose.local.ci.yml
because it's still relevant for the local
and ci
environments.
The same is true for the
.docker/docker-compose/docker-compose.local.prod.yml
file.
It only keeps the build
instructions for the services php-fpm
php-worker
and nginx
:
services:
php-fpm:
image: ${DOCKER_REGISTRY?}/${DOCKER_NAMESPACE?}/php-fpm-${ENV?}:${TAG?}
build:
context: ../
dockerfile: ./images/php/fpm/Dockerfile
target: ${ENV?}
args:
- BASE_IMAGE=${DOCKER_REGISTRY?}/${DOCKER_NAMESPACE?}/php-base-${ENV?}:${TAG?}
- TARGET_PHP_VERSION=${PHP_VERSION?}
php-worker:
image: ${DOCKER_REGISTRY?}/${DOCKER_NAMESPACE?}/php-worker-${ENV?}:${TAG?}
build:
context: ../
dockerfile: ./images/php/worker/Dockerfile
target: ${ENV?}
args:
- BASE_IMAGE=${DOCKER_REGISTRY?}/${DOCKER_NAMESPACE?}/php-base-${ENV?}:${TAG?}
- PHP_WORKER_PROCESS_NUMBER=${PHP_WORKER_PROCESS_NUMBER:-4}
nginx:
image: ${DOCKER_REGISTRY?}/${DOCKER_NAMESPACE?}/nginx-${ENV?}:${TAG?}
build:
context: ../
dockerfile: ./images/nginx/Dockerfile
target: ${ENV?}
args:
- NGINX_VERSION=${NGINX_VERSION?}
- APP_CODE_PATH=${APP_CODE_PATH_CONTAINER?}
The "rest" goes into file .docker/docker-compose/docker-compose.local.yml
.
File
.docker/docker-compose/docker-compose.local.prod.yml
file
is simply removed completely.
make
targets for running containers via "plain" docker
The make
targets for docker
(and docker compose
) are located in file .make/02-00-docker.mk
.
Since building and pushing the images is still done via compose
, we only need to add
targets for pulling images and managing containers via docker
(i.e. start, stop and remove).
Note: To have clear distinction between targets for docker
and those for docker compose
, I
have renamed the existing docker compose
targets to include compose
in the target name e.g.
docker-up => docker-compose-up
docker-push => docker-compose-push
Pulling
Pulling is straight forwardly done via
docker pull
:
.PHONY: docker-pull
docker-pull: validate-docker-variables ## Pull a single docker images from the remote repository
@$(if $(DOCKER_SERVICE_NAME),,$(error "DOCKER_SERVICE_NAME is undefined"))
docker pull $(DOCKER_REGISTRY)/$(DOCKER_NAMESPACE)/$(DOCKER_SERVICE_NAME)-$(ENV):$(TAG)
The image name $(DOCKER_REGISTRY)/$(DOCKER_NAMESPACE)/$(DOCKER_SERVICE_NAME)-$(ENV):$(TAG)
follows the convention outlined in
Docker from scratch for PHP 8.1 Applications in 2022: Image naming convention.
All variables apart from the $(DOCKER_SERVICE_NAME)
should be defined as
shared variables
(i.e. they should be always present) and are checked in the validate-docker-variables
target
.PHONY: validate-docker-variables
validate-docker-variables:
@$(if $(TAG),,$(error TAG is undefined - Did you run 'make make-init'?))
@$(if $(ENV),,$(error ENV is undefined - Did you run 'make make-init'?))
@$(if $(DOCKER_REGISTRY),,$(error DOCKER_REGISTRY is undefined))
@$(if $(DOCKER_NAMESPACE),,$(error DOCKER_NAMESPACE is undefined))
@$(if $(APP_CODE_PATH_CONTAINER),,$(error APP_CODE_PATH_CONTAINER is undefined))
Running
Running a container is done via
docker run
.PHONY: docker-run
docker-run: validate-docker-variables ## Start a single docker container
@$(if $(DOCKER_SERVICE_NAME),,$(error "DOCKER_SERVICE_NAME is undefined"))
@$(if $(HOST_STRING),,$(error "HOST_STRING is undefined"))
docker run --name $(DOCKER_SERVICE_NAME) \
-d \
-it \
--env-file compose-secrets.env \
--mount type=bind,source="$$(pwd)"/secret.gpg,target=$(APP_CODE_PATH_CONTAINER)/secret.gpg,readonly \
$(HOST_STRING) \
$(DOCKER_SERVICE_OPTIONS) \
$(DOCKER_REGISTRY)/$(DOCKER_NAMESPACE)/$(DOCKER_SERVICE_NAME)-$(ENV):$(TAG)
Though I believe the docker-run
target requires some explanation:
--name $(DOCKER_SERVICE_NAME)
When starting a container, we will retain the same
service name
that we use in the docker compose
setup. E.g. the service application
will be started with
the container name application
via --name application
. This way, we can
streamline the same container management across docker
and docker compose
, because the
$(DOCKER_SERVICE_NAME)
works for both cases.
Example: The application
container will always have the name application
and we will
use that information in the Stopping and Removing targets later.
--env-file compose-secrets.env
The environment variables are provided via --env-file compose-secrets.env
. This file
is created during the deployment process on the VM
and contains the secret gpg
password (GPG_PASSWORD
). It
was previously referenced in the (now deleted) docker-compose.local.prod.yml
file.
--mount type=bind,source="$$(pwd)"/secret.gpg,target=$(APP_CODE_PATH_CONTAINER)/secret.gpg,readonly
Mounts the secret gpg
key. $$(pwd)
resolves to the current working directory and
$(APP_CODE_PATH_CONTAINER)
to the directory of the codebase in the container. This was
previously done in the docker-compose.local.prod.yml
file.
The variable was previously defined directly in the .docker/.env
file but was moved to the
shared variables in file .make/variables.env
, so that it becomes available for make
as well.
Its value is
APP_CODE_PATH_CONTAINER=/var/www/app
It was added accordingly to the
definition of the DOCKER_COMPOSE_COMMAND
in .make/02-00-docker.mk
:
DOCKER_COMPOSE_COMMAND:= \
MSYS_NO_PATHCONV=1 \
ENV=$(ENV) \
TAG=$(TAG) \
DOCKER_REGISTRY=$(DOCKER_REGISTRY) \
DOCKER_NAMESPACE=$(DOCKER_NAMESPACE) \
APP_USER_ID=$(APP_USER_ID) \
APP_GROUP_ID=$(APP_GROUP_ID) \
APP_USER_NAME=$(APP_USER_NAME) \
APP_CODE_PATH_CONTAINER=$(APP_CODE_PATH_CONTAINER) \
docker compose -p $(DOCKER_COMPOSE_PROJECT_NAME) --env-file $(DOCKER_ENV_FILE)
The MSYS_NO_PATHCONV=1
variables was added as well due to the
handling of leading slashes of MinGW on Windows.
Note: secret gpg
key and password are required for
decrypting the encrypted secret files when the container starts.
$(HOST_STRING)
We need to somehow "substitute" the docker compose
DNS magic, that allows us to
communicate with a service by simply using the service name instead if its IP address. This is done
with the $(HOST_STRING)
variable that contains a list of --add-host
options. See section
Poor man's DNS via --add-host
for a more in-depth explanation
about this topic.
$(DOCKER_SERVICE_OPTIONS)
Each service might require some additional docker run
options (e.g. forwarded ports) and
those can be passed via the $(DOCKER_SERVICE_OPTIONS)
variable. Since these options don't
change, I have created a corresponding make
target for each service:
.PHONY: docker-run-nginx
docker-run-nginx: ## Start the nginx container
"$(MAKE)" docker-run DOCKER_SERVICE_NAME="$(VM_NAME_NGINX)" DOCKER_SERVICE_OPTIONS="-p 80:80 -p 443:443"
.PHONY: docker-run-php-fpm
docker-run-php-fpm: ## Start the php-fpm container
"$(MAKE)" docker-run DOCKER_SERVICE_NAME="$(VM_NAME_PHP_FPM)" DOCKER_SERVICE_OPTIONS="-p 9000:9000"
.PHONY: docker-run-application
docker-run-application: ## Start the application container
"$(MAKE)" docker-run DOCKER_SERVICE_NAME="$(VM_NAME_APPLICATION)" DOCKER_SERVICE_OPTIONS=""
.PHONY: docker-run-php-worker
docker-run-php-worker: ## Start the php-worker container
"$(MAKE)" docker-run DOCKER_SERVICE_NAME="$(VM_NAME_PHP_WORKER)" DOCKER_SERVICE_OPTIONS=""
Stopping
Uses docker stop
.PHONY: docker-stop
docker-stop: validate-docker-variables ## Stop a single docker container
@$(if $(DOCKER_SERVICE_NAME),,$(error "DOCKER_SERVICE_NAME is undefined"))
docker stop $(DOCKER_SERVICE_NAME)
Removing
Uses docker rm
.PHONY: docker-rm
docker-rm: validate-docker-variables ## Remove a single docker container
@$(if $(DOCKER_SERVICE_NAME),,$(error "DOCKER_SERVICE_NAME is undefined"))
docker rm $(DOCKER_SERVICE_NAME)
Changes in the deployment process
Most of the steps in the
deploy
target of the previous docker compose
based approach
don't change, because we are still using docker compose
to build the images. The main
differences are, that
- we will be using a temporary deployment
.env
file - the deployment archive becomes simpler
- we need to ensure that the services can still communicate via their service names
- the deployment script needs to be executed for every service on its VM
Use a temporary deployment .env
file
The main Makefile
will now include a third file containing variables located at
.make/deployment-settings.env
. The file is ignored by git
and is created only temporary as
part of the deployment process via
.PHONY: deploy
deploy: ## Build all images and deploy them to GCP
# ...
@printf "$(GREEN)Switching to 'prod' environment (via 'deployment-settings.env' file)$(NO_COLOR)\n"
@"$(MAKE)" make-init-deployment-settings ENVS="ENV=prod TAG=latest"
# ...
It is a copy of the .make/variables.env
file and takes precedence over .make/variables.env
and
.make/.env
. This is important, because the .make/.env
file might hold variables that are
specific to the local dev environment, e.g. a custom APP_USER_ID
and APP_GROUP_ID
(required for Linux users to avoid
permission issues in the containers).
But when the prod
containers are built, we don't want to use any local specific settings.
Thus, the new .make/deployment-settings.env
is created before the containers are build and
deleted at the end of the deployment process via
.PHONY: deploy
deploy: # Build all images and deploy them to GCP
# ...
@printf "$(GREEN)Removing 'deployment-settings.env' file$(NO_COLOR)\n"
@"$(MAKE)" make-remove-deployment-settings
See also the targets make-init-deployment-settings
and make-remove-deployment-settings
defined in the main Makefile
:
.PHONY: make-init-deployment-settings
make-init-deployment-settings: ## Create a `deployment-settings.env` file to ensure that no local-only variables are affecting the deployment. Use via ENVS="KEY_1=value1 KEY_2=value2"
@cp .make/variables.env .make/deployment-settings.env
@for variable in $(ENVS); do \
echo $$variable | tee -a .make/deployment-settings.env > /dev/null 2>&1; \
done
.PHONY: make-remove-deployment-settings
make-remove-deployment-settings: ## Remove the `deployment-settings.env` file
@rm -f .make/deployment-settings.env
Plus, sometimes stuff "goes wrong" in the deployment, and then it's nice to be able to run
make make-remove-deployment-settings
and "clean up" the deployment environment
settings nicely.
So in total we now have the following three .env
files for the make
setup:
.make/.env
:- originally introduced in
Docker from scratch for PHP 8.1 Applications in 2022: Shared variables:
.make/.env
and refined in Create a CI pipeline for dockerized PHP Apps: Initialize the shared variables: Defines the sharedENV
variable so that allmake
targets use the correct infrastructure environment as well as variables that are specific to the local dev environment
- originally introduced in
Docker from scratch for PHP 8.1 Applications in 2022: Shared variables:
.make/variables.env
:introduced in Create a CI pipeline for dockerized PHP Apps: Initialize the shared variables: Holds the "default" variables and is not ignored by
git
. The variables are neither "secret" nor are they likely to be changed for dev specific environment adjustments - though they could be overridden in the.make/.env
file, e.g. forAPP_USER_ID APP_GROUP_ID
.make/deployment-settings.env
:- as described above: A temporary copy of
.make/variables.env
for the deployment process
- as described above: A temporary copy of
They are included in the main Makefile
in the order
include .make/variables.env
-include .make/.env
-include .make/deployment-settings.env
i.e. .make/deployment-settings.env
takes precedence over .make/.env
that in turn takes
precedence over .make/variables.env
.
A simpler deployment archive
This section refers to the previous tutorial
(step Create the deployment archive)
that uses the make
target deployment-create-tar
.
Since we don't use docker compose
any longer, we don't need any docker compose
configuration
files in the deployment archive. The .env
file is also no longer necessary, so we can get rid of
the .secrets/docker.env
file
entirely.
The new deployment-create-tar
target now looks like this:
.PHONY: deployment-create-tar
deployment-create-tar:
# create the build directory
rm -rf .build/deployment
mkdir -p .build/deployment
# copy the necessary files
cp -r .make .build/deployment/
find .build/deployment -name '*.env' -delete
cp .make/variables.env .build/deployment/.make/variables.env
cp Makefile .build/deployment/
cp .infrastructure/scripts/deploy.sh .build/deployment/
# move the ip services file
mv .build/service-ips .build/deployment/service-ips
# create the archive
tar -czvf .build/deployment.tar.gz -C .build/deployment/ ./
The make
setup is still required, and we are even adding a new file called .build/service-ips
,
that is created to enable communication via service names and is explained in the next section
Poor man's DNS via --add-host
.
Poor man's DNS via --add-host
So far, we could make use of the
DNS magic provided by docker compose
that
enabled all our containers to talk to each other via their service name.
We use this e.g. to
- define
php-fpm
as an upstream host in thenginx
configuration - use
mysql
as hostname forDB_HOST
andredis
as hostname forREDIS_HOST
in the.env
file for Laravel
This will stop working once we drop docker compose
and run the containers on individual machines
Since all services will now live on different VMs, we need to identify the private IP address of every VM and make sure that the service name of the container running on that VM resolves to that IP address.
The latter part can be achieved quite easily by using the
--add-host
option of docker run
Your container will have lines in
/etc/hosts
which define the hostname of the container itself as well as localhost and a few other common things. The--add-host
flag can be used to add additional lines to/etc/hosts
.
But how to we find the IP addresses?
Retrieving IP addresses
Fortunately the gcloud
cli has us covered here:
for MySQL:
gcloud sql instances describe
$ gcloud sql instances describe mysql-vm --format="get(ipAddresses[0].ipAddress)" --project=$projectId 10.111.0.5
for Redis:
gcloud redis instances describe
$ gcloud redis instances describe redis-vm --format="get(host)" --project=$(GCP_PROJECT_ID) --region=$region 10.111.0.50
for Compute Instance VMs:
gcloud compute instances describe
(see also Locating IP addresses for an instance)$ gcloud compute instances describe php-fpm-vm --format="get(networkInterfaces[0].networkIP)" --project=$projectId --zone=$zone 10.128.0.2
I have added corresponding make
targets in .make/03-00-gcp.mk
.PHONY: gcp-get-private-ip-vm
gcp-get-private-ip-vm: ## Get the private ip of a VM
@$(if $(GCP_PROJECT_ID),,$(error "GCP_PROJECT_ID is undefined"))
@$(if $(VM_NAME),,$(error "VM_NAME is undefined"))
gcloud compute instances describe $(VM_NAME) --format="get(networkInterfaces[0].networkIP)" --project=$(GCP_PROJECT_ID) --zone=$(GCP_ZONE)
.PHONY: gcp-get-private-ip-mysql
gcp-get-private-ip-mysql: ## Get the private IP address of the SQL service
gcloud sql instances describe $(VM_NAME_MYSQL) --format="get(ipAddresses[0].ipAddress)" --project=$(GCP_PROJECT_ID)
.PHONY: gcp-get-private-ip-redis
gcp-get-private-ip-redis: ## Get the private IP address of the Redis service
gcloud redis instances describe $(VM_NAME_REDIS) --format="get(host)" --project=$(GCP_PROJECT_ID) --region=$(GCP_REGION)
The service-ips
file: Map service names to IPs
Now that we can "get" the IP addresses of all our services, we can store them in a simple text file as part of the deployment process and transmit this file to all the VMs.
Of course there a is make
target for the retrieval (in .make/03-00-gcp.mk
)
.PHONY: gcp-get-ips
gcp-get-ips: ## Get the IP addresses for all services
@printf "$(DOCKER_SERVICE_NAME_MYSQL):"
@"$(MAKE)" -s gcp-get-private-ip-mysql
@printf "$(DOCKER_SERVICE_NAME_REDIS):"
@"$(MAKE)" -s gcp-get-private-ip-redis
@for vm_name_service_name in $(ALL_VM_SERVICE_NAMES); do \
vm_name=`echo $$vm_name_service_name | cut -d ":" -f 1`; \
service_name=`echo $$vm_name_service_name | cut -d ":" -f 2`; \
printf "$$service_name:"; \
make -s gcp-get-private-ip-vm VM_NAME=$$vm_name; \
done;
The gcp-get-ips
target uses the targets of the previous section and prints them in the format
$serviceName:$ipAddress
Note: Please refer to section
Mapping service names to VM names
regarding the variable $(ALL_VM_SERVICE_NAMES)
that contains a mapping of the vm names to the
service names in the form
$vmName:$serviceName
A full result might look like this:
$ make gcp-get-ips
mysql:10.111.0.5
redis:10.111.0.50
php-fpm:10.128.0.2
application:10.128.0.3
nginx:10.128.0.4
php-worker:10.128.0.5
Storing them in a file (.build/service-ips
) is done as part of the deploy
target via the target
deployment-create-service-ip-file
defined in file .make/05-00-deployment.mk
.
.PHONY: deployment-create-service-ip-file
deployment-create-service-ip-file: ## Create a file containing the IPs of all services
@make -s gcp-get-ips > ".build/service-ips"
@sed -i "s/\r//g" ".build/service-ips"
Note: The sed -i "s/\r//g" ".build/service-ips"
part was necessary, because on Windows the
line endings in the gcp-get-ips
are \r\n
instead of just \n
and the sed
command ensures
that no \r
(carriage return) are left over.
The application deployment script
On a conceptual level, the deployment "on a VM" in the previous tutorial was done with a
script (located at .infrastructure/scripts/deploy.sh
)
that is transmitted to the VM first and then executed there. This script was slightly updated:
#!/usr/bin/env bash
set -e
usage="Usage: deploy.sh docker_service_name"
[ -z "$1" ] && echo "No docker_service_name given! $usage" && exit 1
docker_service_name=$1
echo "Initializing the codebase"
make make-init ENVS="ENV=prod TAG=latest"
echo "Retrieving secrets"
make gcp-secret-get SECRET_NAME=GPG_KEY > secret.gpg
GPG_PASSWORD=$(make gcp-secret-get SECRET_NAME=GPG_PASSWORD)
echo "Creating compose-secrets.env file"
echo "GPG_PASSWORD=$GPG_PASSWORD" > compose-secrets.env
echo "Pulling image for '${docker_service_name}' on the VM from the registry"
make docker-pull DOCKER_SERVICE_NAME="${docker_service_name}"
echo "Stop the '${docker_service_name}' container on the VM"
make docker-stop DOCKER_SERVICE_NAME="${docker_service_name}" || true
make docker-rm DOCKER_SERVICE_NAME="${docker_service_name}" || true
echo "Preparing service IPs as --add-host options"
service_ips=""
while read -r line;
do
service_ips=$service_ips" --add-host $line"
done < service-ips
echo "Start the container for '${docker_service_name}' on the VM"
make docker-run-"${docker_service_name}" HOST_STRING="$service_ips"
It now expects the service name as a mandatory argument and uses the
make
targets for running containers via "plain" docker
:
make docker-pull DOCKER_SERVICE_NAME="${docker_service_name}"
make docker-stop DOCKER_SERVICE_NAME="${docker_service_name}" || true
make docker-rm DOCKER_SERVICE_NAME="${docker_service_name}" || true
It also utilizes the .build/service-ips
file
from the previous section to
build the HOST_STRING
for running the container:
service_ips=""
while read -r line;
do
service_ips=$service_ips" --add-host $line"
done < service-ips
make docker-run-"${docker_service_name}" HOST_STRING="$service_ips"
Run the deployment script for each service
The application deployment script of the previous section
is invoked as part of the deployment-run-on-vm
target that we already know from the previous
tutorial, see
Deployment commands on the VM.
.PHONY: deployment-run-on-vm
deployment-run-on-vm: ## Run the deployment script on the VM specified by VM_NAME for the service specified by DOCKER_SERVICE_NAME
@$(if $(DOCKER_SERVICE_NAME),,$(error "DOCKER_SERVICE_NAME is undefined"))
"$(MAKE)" -s gcp-scp-command SOURCE=".build/deployment.tar.gz" DESTINATION="deployment.tar.gz"
"$(MAKE)" -s gcp-ssh-command COMMAND="sudo rm -rf $(CODEBASE_DIRECTORY) && sudo mkdir -p $(CODEBASE_DIRECTORY) && sudo tar -xzvf deployment.tar.gz -C $(CODEBASE_DIRECTORY) && cd $(CODEBASE_DIRECTORY) && sudo bash deploy.sh $(DOCKER_SERVICE_NAME)"
This target has to be invoked for every VM, and we use the same technique as in section
Setup the VMs that run docker
containers,
i.e.
create a
make
target for every service, e.g..PHONY: deployment-run-on-vm-application deployment-run-on-vm-application: "$(MAKE)" --no-print-directory deployment-run-on-vm VM_NAME=$(VM_NAME_APPLICATION) DOCKER_SERVICE_NAME=$(DOCKER_SERVICE_NAME_APPLICATION)
create a target to run them all in parallel via
make
.PHONY: deployment-run-on-vms deployment-run-on-vms: ## Run the deployment script on all VMs "$(MAKE)" -j --output-sync=target deployment-run-on-vm-application \ deployment-run-on-vm-php-fpm \ deployment-run-on-vm-php-worker \ deployment-run-on-vm-nginx
Wrapping up
Congratulations, you made it! If some things are not completely clear by now, don't hesitate to
leave a comment. You should now be ready to deploy a dockerized PHP application "to
production" on GCP using multiple VMs via docker
- without compose
.
In the next part of this tutorial, we will
replace the locally installed gcloud
cli with the official docker image
to get rid of the dependency.
Please subscribe to the RSS feed or via email to get automatic notifications when this next part comes out :)
Wanna stay in touch?
Since you ended up on this blog, chances are pretty high that you're into Software Development (probably PHP, Laravel, Docker or Google Big Query) and I'm a big fan of feedback and networking.
So - if you'd like to stay in touch, feel free to shoot me an email with a couple of words about yourself and/or connect with me on LinkedIn or Twitter or simply subscribe to my RSS feed or go the crazy route and subscribe via mail and don't forget to leave a comment :)