Hello everyone! Welcome back to the backend master class!
In the last lecture, we've learned how to use Docker
network to connect 2 stand-alone Docker containers by names. Today I'm gonna
show you how to use docker-compose
to automatically setup all services in
the same Docker network and launch them all at once with a single command.
OK, let's start!
First, I'm gonna create a new file: docker-compose.yaml
at the root of our
project. There are a lot of things you can config with a docker-compose
file. You can read all about them in the documentation by going to
docs.docker.com, open Reference
, Compose file reference
, version 3
. On the right-hand side menu you can see a list of
different syntaxes.
In this video, I will show you some of the most important one that we
usually use. Here in the center if the page, we can see an example compose
file.
Basically you define the docker-compose
version, then a list of services
that you want to launch together. In this example, there's a redis
service, a postgres
DB service and some other web services for voting and
computing the result.
Now I'm just gonna copy the first 2 lines of this example, and paste them
to our docker-compose.yaml
file. Note that a yaml
file is very sensitive
to indentation.
version: "3.9"
services:
At the moment, the file is using 4 spaces indentation, but I just want to use 2 spaces instead. So I'm gonna click on this button
select Indent using spaces
,
and choose 2 as the tab size.
OK, now if we press Enter
, we can see that it is nicely indented with 2
spaces. Next we have to declare a list of services we want to launch. The
first service should be postgres
db. For this service, we've gonna use a
prebuild Docker image. Hence, the image
keyword, followed by the name
and tag
of the image, which is postgres:12-alpine
in this case. Now
we will use the environment
keyword to specify some environment
variables for username, password and db name just like what we did
in the GitHub CI workflow.
postgres:
image: postgres:12-alpine
environment:
First let's copy the POSTGRES_USER
variable. The syntax is a bit
different, we have to use the equal operator to assign the value.
Similarly, I gonna copy the POSTGRES_PASSWORD variable and finally the
POSTGRES_DB variable.
environment:
- POSTGRES_USER=root
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=simple_bank
Alright, next step, we will declare the api
service, which will serve all
API requests to our simple bank. For this service, we should build its
image from the golang source, so under the build
keyword, we specify
the context
to build the image:
api:
build:
context: .
This dot means the current root folder. Then we use dockerfile keyword to
tell docker-compose
where to find the Docker file to build the image. In
this case, it's just the Dockerfile at the root of the project. Next, we
should publish the port 8080
to the host machine so that we can call the
simple bank API from outside of the container.
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
One of the most important thing we must do is to tell the api
service how
to connect to the postgres
service. In order to do that, we will set 1
environment variable: DB_SOURCE
. As we've seen in the previous lecture,
setting this environment variable will override the value we declare in the
app.env
file and since all services in this docker-compose
file will
run on the same network, they can communicate with each other via name.
Therefore, here, in this URL DB_SOURCE=postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable
,
instead of localhost
, we will use the name of the postgres
service.
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- DB_SOURCE=postgresql://root:secret@postgres:5432/simple_bank?sslmode=disable
And that's basically it! The docker-compose file is completed. Let's try to run it!
If you have the latest Docker CLI
on your machine, all you have to do is
to run docker-compose up
docker-compose up
Then Docker-compose will automatically search for the docker-compose.yaml
file in the current folder and run it for you. As you can see here, before
running the service, it has to build the Docker image for our simple
bank API service first. Then after the image is successfully built,
docker-compose
will start both the postgres
and the api
service at
once.
In the logs, we can see whether it comes from which service by looking at
the prefix of the line. It is either postgres_1
or api_1
in this case.
Now if in another terminal tab, we run
docker images
we can see a new simplebank_api
image.
Basically, the image name is prefixed with simplebank
, which is the name
of the folder containing our docker file. And its suffix is the name of
the service itself, which is api
.
Now let's run
docker ps
to see all running services. There are 2 services: simple_bank_postgres_1
and simple_bank_api_1
. Both of them have the simple_bank
prefix
followed by the name of the service.
Now if we go back to the docker-compose
tab, and scroll up to the top, we
can see clearly here that a new network simplebank_default
is created
before the 2 service containers are created.
Now if we try to inspect the simplebank_default
network, we can see that
the 2 service containers are actually running on this same network.
That's why they can discover each other by names.
Alright, now it's time to send some real API requests to see if the service is working well or not. As the new database is completely empty, I'm just gonna send the create user API.
Oops, we've got 500 Internal Server Error
, and the reason is: "relation
users does not exist". Do you know why?
Well, that's because we haven't run db migration to create the db schema yet. To fix this, we have to update our Docker image to run db migration before starting the API server. It would be done in a similar fashion as what we've done in the GitHub CI workflow: we will have to download golang-migrate binary to the Docker image and use it to run the migrations.
So I'm gonna copy these 2 instructions and paste them to the Dockerfile
in
the builder stage.
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.14.1/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate.linux-amd64 /usr/bin/migrate
First we have to run this curl
command
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.14.1/migrate.linux-amd64.tar.gz | tar xvz
to download and extract the migrate
binary.
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.14.1/migrate.linux-amd64.tar.gz | tar xvz
Then I'm gonna move this second command to the run stage
sudo mv migrate.linux-amd64 /usr/bin/migrate
where we will copy from builder
the downloaded migrate
binary to the
final image. We have to change the path of original migrate
file to /app
,
because that's the working directory in the builder stage when we download
and extract the file.
COPY --from=builder /app/migrate.linux-amd64 /usr/bin/migrate
Then I'm gonna put this file in the same WORKDIR
folder in the final
run stage image, which is also /app
.
COPY --from=builder /app/migrate.linux-amd64 ./migrate
Next, we also have to copy all migration SQL files from the db/migration
folder to the image. So copy db/migration
. I'm gonna put it in the
migration
folder under the current working directory.
COPY db/migration ./migration
Now, one thing we must do is, to install curl
in the builder stage image
because by default, the base Alpine image doesn't have curl
preinstalled.
To do that, we just have to add a RUN apk add curl
instruction here.
Finally, we have to change the way we start the app. So that, it can run
the db migration before running the main
binary. I'm gonna create a new
file: start.sh
at the top of our project. Then let's change mod this
file to make it executable.
chmod +x start.sh
This file will be run by /bin/sh
because we're using Alpine image, so
the bash
shell is not available. We use set -e
instruction to make sure
that the script will exit immediately if a command returns a non-zero
status. First step, we will run db migration. So we call the /app/migrate
binary, pass in the path to the folder containing all migration SQL files,
which is /app/migration
. Then the database URL, which we will take
from the DB_SOURCE
environment variable. So here, I just use $DB_SOURCE
to get its value. Let's also use -versbose
option to print out all
details when the migration is run. Finally the up
argument is used to
run migrate up all migrations.
set -e
echo "run db migration"
/app/migrate -path /app/migration -database "$DB_SOURCE" -verbose up
After running migrate up
, we will start the app. All we have to do in
this step is to call exec "$@"
.
echo "start the app"
exec "$@"
It basically means: takes all parameters passed to the script and run it.
In this case, we expect it to be /app/main
as specified in this CMD
instruction.
CMD ["/app/main"]
To make it work, we will use the ENTRYPOINT
instruction and specify the
/app/start.sh
file as the main entry point of the Docker image.
ENTRYPOINT ["/app/start.sh"]
Keep in mind that when the CMD
instruction is used together with
ENTRYPOINT
, it will act as just the additional parameters that will be
passed into the entry point script. So basically, it will be similar
to running "/app/starts.sh" with "/app/main" as the second argument. But
by separating the command from the entrypoint we have more flexibility to
replace it with other command at run time whenever we want. You can read
more about this in the Docker documentation page, in the CMD
instruction
section.
# Builds stage
FROM golang:1.16-alpine3.13 AS builder
WORKDIR /app
COPY . .
RUN go build -o main main.go
RUN apk add curl
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.14.1/migrate.linux-amd64.tar.gz | tar xvz
# Run stage
FROM alpine3.13
WORKDIR /app
COPY --from=builder /app/main .
COPY --from=builder /app/migrate.linux-amd64 /usr/bin/migrate
COPY app.env .
COPY db/migration ./migration
EXPOSE 8080
CMD ["/app/main"]
ENTRYPOINT ["/app/start.sh"]
OK, now the Docker file is updated, let's try to run docker-compose
again. But first, we need to run docker-compose down
to remove all
existing containers and networks. And we should also remove the
simplebank_api
image, because we want to rebuild the image with the
new db migration scripts.
docker rmi simplebank_api
OK, everything is cleaned up. Let's run docker-compose up
again!
It will take a while to rebuild the image.
Oops, we've got an error: "start.sh: no such file or directory".
Oh, that's because I forgot to copy the start.sh
file into the docker
image. So let's do that now!
Here in the run stage, we just need to add 1 more instruction:
COPY start.sh .
And that's it! We should be good now. Let's run docker-compose down
to
remove all containers. Run
docker rmi simplebank_api
to remove the old simplebank_api
image and finally run
docker-compose up
to rebuild and relaunch everything! This time, looks
like the db migration script was run, however, it was still not successful.
The service exited with code 1. And in the log, we can see the error:
"connection refused". The app cannot connect to the database to run db
migration. That's because it takes some time for Postgres server to be
ready, but the app tried to run db migration immediately when it starts.
At that time the Postgres server was not ready to accept connection yet.
So to fix this, we have to tell the app to wait for Postgres to be ready
before trying to run the db migration script and start the API server.
Now although in the docker-compose.yaml
file, we can use the
depends_on
instructions to tell docker-compose
that the api
service
depends on the postgres
service, this only makes sure that the postgres
will be started before the api
service. It doesn't ensure that the
postgres
is in ready state before starting the api
service.
You can read more about this behavior in the docker-compose
documentation page. Just search for depends_on
on https://docs.docker.com/compose/compose-file/compose-file-v3/
page. Then you will see there, they listed several things to be aware of
when using the depends_on
instruction. If we want to wait for a
service to be ready, we should follow this link to know how to control
the start up order. There are several tools that has been written to
solve this problem, but the one we should use in our case is the
sh-compatible wait-for
script, because we're using an Alpine-based
image.
OK, so here is its Github page.
Wait-for
is designed to synchronize services like Docker containers.
And its usage is pretty simple. We just need to run wait-for
, and pass
in the host:port
URL we want to wait.
Here's an example,
$ ./wait-for www.eficode.com:80 -- echo "Eficode site is up"
Eficode site is up
It's basically waiting for the web page eficode.com
port 80 to be
ready before executing the echo
statement. So what I'm gonna do is
open its latest release version, and click on this link to download the
script.
The file will be downloaded to your Downloads
folder. So I'm gonna open
the terminal and copy that file to the root of our simple bank project.
I will rename it to wait-for.sh
to make it cleaner about the file type.
mv ~/Downloads/wait-for ./wait-for.sh
Note that this file is not executable yet, so we should run change mod +x to make it executable.
chmod +x wait-for.sh
Alright, now back to our Dockerfile
. I'm gonna add 1 more instruction
to copy the wait-for.sh
file into the final Docker image.
COPY wait-for.sh .
Then, in the docker-compose
file, we should override the default entry
point and command. So that, it will wait for the postgres
service to be
ready before trying to start the api
service. Let's add entrypoint
here
and its content should be: /app/wait-for.sh
. Then the host and port to
wait for should be postgres:5432
, followed by 2 dashes, then finally
the start.sh
script to run after the postgres
server is ready.
version: "3.9"
services:
postgres:
image: postgres:12-alpine
environment:
- POSTGRES_USER=root
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=simple_bank
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- DB_SOURCE=postgresql://root:secret@postgres:5432/simple_bank?sslmode=disable
depends_on:
- postgres
entrypoint: ["/app/wait.sh", "postgres:5432", "--", "/app/start.sh"]
Now you should know that when the ENTRYPOINT
is overridden like this,
it will also clear out any default command on the image, meaning that the
CMD
instruction we wrote in the Dockerfile
will be ignored. So in this
case, we have to explicitly specify the command we want to run in the
docker-compose.yaml
file as well. We use the command
keyword for that
purpose. And the command we want to run is simply: /app/main
.
entrypoint: [ "/app/wait.sh", "postgres:5432", "--", "/app/start.sh" ]
command: [ "/app/main" ]
And that will be it! I think now we have everything we need for the app
to launch successfully. Let's try it one more time! First let's run
docker-compose down
to remove all existing containers. Run docker rmi
to remove the simplebank_api
image.
docker rmi simplebank_api
And finally run
docker-compose up
to rebuild and relaunch the services. This time, you can see that the services are started in order.
First the postgres
service will run, and only when it is ready to listen
for connections, then the api
service will start running the db
migration. Therefore, the migrations were run successfully, first the
init_schema
migration, then the add_user
migration. Finally, the API
server was started successfully after the db migrations. We can see the Gin
logs saying that the server is listening and serving HTTP requests on
port 8080.
So let's open Postman to try it! I'm gonna send this same create user request as before.
But this time, the request is successful. A new user is created! Awesome!
And with this I'm gonna conclude today's lecture about docker-compose
.
Now you know how to use this powerful tool to launch multiple services at once, with specific startup orders.
Thanks a lot for watching, happy coding, and see you in the next lecture!