Lab 10 - DevOps¶
This week's topic is a small introduction to Docker and DevOps union deployments wise. We will be looking into how to deploy Docker applications so that running them does not require you to change configuration files every time.
Please make sure you did the previous lab, especially editing the /etc/docker/daemon.json
file.
This lab is composed of the following topics:
- Debugging containers
- Linking containers
- Properly publishing a service running in a container using a dynamic proxy
- Pushing a self built container to a registry
Debugging containers¶
Often enough, things do not work as you want them to. Either requests do not return what you would expect, the container itself seems to be missing data, some dependency you did not account for, etc.
One must know how to find debug information from containers for these reasons.
Logs¶
The problem is that even though the docker daemon writes logs:
journalctl -r -u docker
You'll notice that these logs are only about the docker service itself. You care more about the container logs. These can be checked doing:
docker logs <container ID|name>
So, for an example, doing docker logs <docker_lab_container_name>
should return you this:
192.168.251.1 - - [11/May/2020:03:47:16 +0000] "GET / HTTP/1.1" 200 7234 "-" "curl/7.66.0" "-"
192.168.251.1 - - [11/May/2020:03:48:36 +0000] "GET / HTTP/1.1" 200 7234 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:75.0) Gecko/20100101 Firefox/75.0" "-"
Which are the same as the access logs for Apache. All the container errors get put here as well if the software and dockerfile is set up properly. You can also read the logs information from stopped (not deleted) containers, which is especially handy for finding why they stopped.
Internals¶
Logs are not always helpful. Sometimes you want to debug something inside the container or test network connectivity.
For this reason, you can (sometimes, when the creator of the Dockerfile has left appropriate binaries in) open a shell inside a container and use it as your own VM.
Try doing this: docker exec -ti docker_lab sh
exec
means to execute a command inside a container-t
means to open a tty (shell), and-i
makes it interactive so that you can type theredocker_lab
is the container name of the container we built an image for.sh
is the command to be executed
If this works, it should drop you into a new shell. If you check the files and IP address, it is an entirely different machine. You can check the configuration used to run the container (cat /etc/passwd
), install software (apk update && apk add vim
), and ping the network to test networking.
Some things to remember:
- If you make changes to this container from the inside, then (unless the changes are made in a mounted directory) it will be there only for the lifetime of this particular container. The moment you delete this one, it's gone. For this reason, it's never a good practice to make changes inside the container, but always the Dockerfile.
localhost
now means the container itself.- If you do
curl localhost:5000
, now you see the same information on port 5000 as you did on port 5005 before.- You may need to first install curl (
apk add curl
)
- You may need to first install curl (
- Port 5005 gives an error because nothing is listening inside the container on port 5005.
- If you want to access your VM host, not the container, you need to use an IP address. If you check your container's IP address, it should be something like 192.168.67.X. You can access your VM on 192.168.67.1 (e.g.
curl 192.168.67.1:5000
).
Security scanning¶
One of the more significant issues with packaging software like containers is that it's challenging to keep track of what versions of software/operating system/tools you are running in each container.
It's even worse when trying to find container images. How can you make sure the image you want to use is safe?
Thankfully, nowadays, we have tools called Security Scanners. There's quite a few of them, but we will be using Trivy, which does not need any other dependencies to run.
To install Trivy on your VM, run the required commands from here: Trivy getting started
Scanning with Trivy is a relatively simple ordeal. All you need to do, is give it the name of either a local image, like:
trivy image registry.hpc.ut.ee/mirror/library/alpine
Or scan an image in a remote registry:
trivy image registry.hpc.ut.ee/mirror/library/python:3.4-alpine
As you can see, the Alpine image has no outstanding security issues, which is excellent. On the other hand, Python image is old enough to have quite a few.
these issues can be solved by either recreating the entire image or updating it when using it as a base image for projects.
Container optimization¶
In the last lab we touched on writing dockerfiles and how to build images from them. You can easily write a detailed dockerfile and publish it via docker build
. All of the commands that you wrote will be turned into layers for the image and stored, increasing the image size. Managing container size and optimization is an important factor of DevOps. Well optimized containers do exactly what you need them to do and not a bit more. Optimization also potentially decreases the image size and complexity, making scaling, storing and readability a lot easier.
The dockerfile lines themselves give a good indication on what is being run, however tools can be used to see what is actually happening under the hood. The tool we are going to use today is called dive
. You can install it on your system but it is not required to complete the lab as we will be using the Docker installation. This means that the executable command will be a little bit longer as well
Using dive
is simple, just run: docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock registry.hpc.ut.ee/mirror/wagoodman/dive:latest <image name>
. We will inspect the image that we created in the last lab, docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock registry.hpc.ut.ee/mirror/wagoodman/dive:latest docker_lab
.
Complete
The interface is pretty confusing at first glance. Note that all of the commands on the bottom row are just ctrl+<letter>
. One of the first things to do is use tab
to move to the Current layer Contents
window and hit ctrl+U
to disable seeing unmodified files. Go back to the Layers
tab and move up and down to see everything that is being done to the image. The Image Details
window gives an overview on how much disk space is being used and how it could be made better.
You can check other images that you have on your machine (docker images
) to see what is going on in them. You can also modify the dockerfile made in the last lab to see how it changes in the dive output.
Linking containers¶
Sometimes, you want to hide your running service from the outside world for security reasons, but still allow it to be accessible by some containers.
A good example is a database. A database does not need to be accessible from the outside world, as it contains important information that is very easy to access - you only need to know the right username and password.
This is why Docker uses networks internally, which is one of the most complicated aspects of Docker. We will now build a new container analogous to the docker_lab
container but with updated code. We will also deploy a mariadb
container to store the access logs and motd messages of the applications.
As the centos
user, create a directory to contain your files with which to build your container, and cd
to that directory¶
mkdir -p ~/devops_lab/container
cd ~/devops_lab/container
Dockerfile
and server.py
files and populate them with the following content: Dockerfile:
FROM registry.hpc.ut.ee/mirror/library/alpine
RUN apk add --no-cache python3 py3-flask py3-sqlalchemy py3-sqlalchemy-utils
RUN apk add --no-cache py3-mysqlclient
COPY server.py /opt/server.py
EXPOSE 5000
CMD python3 /opt/server.py
server.py:
#!/bin/env python3
from flask import Flask, request, jsonify, redirect
import socket
from traceback import format_exc
import os
import time
from sqlalchemy import Table, Column, String, MetaData, Engine, Integer, create_engine
from sqlalchemy_utils import create_database, database_exists
from logging import Logger, getLogger
class MOTDStore():
engine: Engine
metadata: MetaData
oauth_states: Table
@classmethod
def build_motd_store_table(cls, metadata: MetaData) -> Table:
return Table(
"motd",
metadata,
metadata,
Column("id", Integer, primary_key=True), # Auto-increment should be default
Column("date", String(20)),
Column("message", String(200))
)
@classmethod
def build_last_access_store_table(cls, metadata: MetaData) -> Table:
return Table(
"access_times",
metadata,
metadata,
Column("id", Integer, primary_key=True), # Auto-increment should be default
Column("date", String(20)),
Column("ip", String(16))
)
def __init__(
self,
logger: Logger = getLogger(__name__)
):
self._logger = logger
db_password = os.environ.get("DATABASE_ROOT_PASSWORD")
url = "mysql+mysqldb://root:%s@devops_lab_db:3306/motd_database"%(db_password)
self.engine = create_engine(url)
if not database_exists(url):
create_database(url)
self.metadata = MetaData()
self.motd_store = self.build_motd_store_table(self.metadata)
self.last_access_store = self.build_last_access_store_table(self.metadata)
self.create_tables()
def create_tables(self):
self.metadata.create_all(self.engine)
@property
def logger(self) -> Logger:
if self._logger is None:
self._logger = logging.getLogger(__name__)
return self._logger
def add_motd(self, date: str, message: str) -> None:
with self.engine.begin() as conn:
conn.execute(
self.motd_store.insert(),
{"date": date, "message": message},
)
def log_access(self, date: str, ip: str) -> None:
with self.engine.begin() as conn:
conn.execute(
self.last_access_store.insert(),
{"date": date, "ip": ip},
)
def get_motd(self) -> str:
with self.engine.begin() as conn:
c = self.motd_store.c
query = self.motd_store.select().order_by(c.id.desc())
result = conn.execute(query).first() #Select only the last entry
if result:
return result[2]
return "No message has been added yet. <a href='/new-motd'>Visit here</a> to add a new message of the day."
#https://stackoverflow.com/questions/4060221/how-to-reliably-open-a-file-in-the-same-directory-as-the-currently-running-scrip
__location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__)))
app = Flask(__name__)
motd_store = MOTDStore()
def create_tables(self):
self.metadata.create_all(self.engine)
@property
def logger(self) -> Logger:
if self._logger is None:
self._logger = logging.getLogger(__name__)
return self._logger
def add_motd(self, date: str, message: str) -> None:
with self.engine.begin() as conn:
conn.execute(
self.motd_store.insert(),
{"date": date, "message": message},
)
def log_access(self, date: str, ip: str) -> None:
with self.engine.begin() as conn:
conn.execute(
self.last_access_store.insert(),
{"date": date, "ip": ip},
)
def get_motd(self) -> str:
with self.engine.begin() as conn:
c = self.motd_store.c
query = self.motd_store.select().order_by(c.id.desc())
result = conn.execute(query).first() #Select only the last entry
if result:
return result[2]
return "No message has been added yet."
#https://stackoverflow.com/questions/4060221/how-to-reliably-open-a-file-in-the-same-directory-as-the-currently-running-scrip
__location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__)))
app = Flask(__name__)
motd_store = MOTDStore()
def generate_time_string():
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
def log_access_to_db(ip):
try:
motd_store.log_access(date=generate_time_string(), ip=ip)
except FileNotFoundError as e:
app.logger.error(format_exc())
@app.route("/")
def hello():
response = "Client IP: " + request.remote_addr + "\nHostname: " + socket.gethostname() + "\n"
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
@app.route("/motd")
def message_of_the_day():
log_access_to_db(request.remote_addr)
response = "<h1>The message of the day is:</h1><br>"
try:
response += motd_store.get_motd()
except FileNotFoundError as e:
response += "No message today"
app.logger.error(format_exc())
response += "<br><br><br><a href='/new-motd'>Visit here</a> to add a new message of the day."
return response, 200
@app.route("/add-motd")
def add_motd():
motd = request.form.get("motd") or request.args.get("motd")
if motd:
app.logger.info("Setting new motd:\n%s"%(motd))
motd_store.add_motd(date=generate_time_string(), message=motd)
return redirect("/motd", code=302)
@app.route("/new-motd")
def new_motd():
form = """
<form action="/add-motd">
<label for="motd">Set new message of the day:</label><br>
<input type="text" id="motd" name="motd"><br>
<input type="submit" value="Submit">
</form>
"""
return form, 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Finally, build your container and name it devops_lab
:
docker build -t devops_lab .
Run a mariadb
container¶
But first, you need to create a directory you can mount to the container to keep your databases.
cd ~/devops_lab
mkdir db_data
Now you are ready to run your mariadb
container:
docker run -d --name devops_lab_db -e MYSQL_ROOT_PASSWORD=<SOME PASSWORD FOR DB ROOT> -v $(pwd)/db_data:/var/lib/mysql registry.hpc.ut.ee/mirror/mariadb
Run the devops_lab
container¶
docker run -d --name devops_lab -e DATABASE_ROOT_PASSWORD=<THE ROOT PASSWORD YOU SET WHEN RUNNING THE MARIADB CONTAINER> devops_lab
Task 1 - Build and run the devops_lab
and devops_lab_db
containers
- Build the
devops_lab
container - Create a directory for
devops_lab_db
container persistent files - Run the
devops_lab_db
container with the persistent files directory mounted and an appropriate password for the databaseroot
user - Run the
devops_lab
container
Warning
Now running docker ps
you will discover that the devops_lab
container is not up. Running docker ps -a
you'll discover that the container has exited. The next logical thing to do is to check the container logs using docker logs devops_lab
In the output you will see the following: sqlalchemy.exc.OperationalError: (MySQLdb.OperationalError) (2005, "Unknown server host 'devops_lab_db' (-2)")
. The reason you get this error is that the two containers, devops_lab
and devops_lab_db
need to be in the same docker
network to talk to each other which they currently are not. We will fix this problem next.
Create a docker
network and make the mariadb
container available only from this internal docker
network.¶
First create a network for the containers of this lab and verify that the network was created
docker network create devops_lab --subnet 192.168.150.0/24
docker network ls
Then connect both the devops_lab
and devops_lab_db
containers to the network
docker network connect devops_lab devops_lab
docker network connect devops_lab devops_lab_db
Now try starting the devops_lab
container again. This time it should start, verify it with docker ps
docker start devops_lab
docker ps
Task 2 Create a docker network
- Create a docker network called
devops_lab
- Connect
devops_lab
anddevops_lab_db
containers to the created network - Start the
devops_lab
container and verify that it is able to run
There are tools to make this easier for you, for an example, docker-compose
, but we find that you cannot use tools properly unless you know what actually happens, when you do use those tools.
Properly publishing a container¶
Even though using the ports method for publishing things to the internet would work.. technically, then there are huge problems with that approach:
- You need to remember which service is on which port.
- You cannot scale services, as you cannot put multiple of them listening on the same port.
- If you use a service provider, then it is very often that only some ports from the internet are allowed. (e.g. in the public internet, only ports 80 and 443)
- Firewall and security configuration becomes complicated.
- You have no overview about how often or how your service is used unless the software inside your container provides that information. (it usually does not)
One of the solutions would be to do a localhost proxy like we did in the web lab or last lab. The problem with this is, that it would solve only points 2, 3 and 5. Thankfully, there are thought out solutions out there capable of proxying without using any docker ports.
One of these services is called Traefik. We will be setting up a Traefik proxy to a container without dabbling with the fancy buttons and dials Traefik has (automatic encryption, metrics, logging, complicated routing). This is left as homework, if interested.
First, create a directory and a file for traefik
config
sudo mkdir /etc/traefik
sudo touch /etc/traefik/traefik.toml
After that populate the config file with the following:
[global]
checkNewVersion = true
sendAnonymousUsage = true
[entryPoints]
[entryPoints.web]
address = ":80"
[log]
[api]
insecure = true
dashboard = true
[ping]
[providers.docker]
Then run a traefik
container with the config file and /var/run/docker.sock
mounted to the container.
docker run --name traefik -d -p 50080:80 -v /var/run/docker.sock:/var/run/docker.sock:ro -v /etc/traefik/traefik.toml:/traefik.toml:ro traefik:v2.1.8
Finally, create a config file to proxy http connections to traefik
by default. To do that, first create /etc/httpd/conf.d/default.conf
and populate it with
<VirtualHost *:80>
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI}
</VirtualHost>
<VirtualHost *:443>
ErrorLog /var/log/httpd/traefik-proxy-error_log
CustomLog /var/log/httpd/traefik-proxy-access_log common
ForensicLog /var/log/httpd/traefik-proxy-forensic_log
ProxyPreserveHost On
ProxyPass / http://localhost:50080/
ProxyPassReverse / http://localhost:50080/
SSLEngine on
SSLCertificateFile /etc/pki/tls/certs/www_server.crt
SSLCertificateKeyFile /etc/pki/tls/private/www_server.key
SSLCACertificateFile /etc/pki/tls/certs/cacert.crt
</VirtualHost>
Then edit /etc/httpd/conf/httpd.conf
and add the following line BEFORE the IncludeOptional conf.d/*.conf
line
IncludeOptional conf.d/default.conf
Finally, restart the httpd
service. Your apache
web server should now forward all incoming connections to the traefik
container unless there is a virtualhost
configured in /etc/httpd/conf.d
directory for the target host.
Now we have a working proxy, but we have not told it to proxy anything. Let's fix that problem.
Task 3 Run a traefik
proxy container
- Create
/etc/traefik/traefik.toml
config file fortraefik
- Run a
traefik
container with the config file anddocker
socket file mounted - Create config for
apache
to forward traffic to thetraefik
container - Edit
/etc/httpd/conf/httpd.conf
so that the created conf file will be used as default when config for the target host is not found.
Container deployments¶
Running multiple containers in a deployment can be tricky. You would need to make multiple Dockerfiles, create separate images, manage their networks and a hundred potential things more. Luckily we can bundle these tasks together with a tool called docker compose
. From the docker documentation:
Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a YAML file to configure your application’s services. Then, with a single command, you create and start all the services from your configuration.
For our lab, we will run a deployment with compose that consists of the traefik
proxy, devops_lab
and devops_lab_db
containers. We will also add configuration to the file for traefik
to know which address corresponds to which container. In addition, we will expose and password protect traefik
dashboard.
First, stop and remove all containers we have created in this lab.
docker stop devops_lab devops_lab_db traefik
docker rm devops_lab devops_lab_db traefik
Now navigate to yout devops_lab
directory and create a file called docker-compose.yml
. Then populate the file with the following:
networks:
devops_lab:
name: devops_lab
external: true
services:
traefik:
image: "registry.hpc.ut.ee/mirror/traefik:v2.1.8"
container_name: "traefik"
ports:
- "50080:80"
- "58080:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "/etc/traefik/traefik.toml:/traefik.toml:ro"
- "/etc/http.passwd:/etc/http.passwd:ro"
networks:
- devops_lab
labels:
traefik.http.routers.dashboard.rule: Host(`traefik-dashboard.<vm_name>.sa.cs.ut.ee`)
traefik.http.routers.dashboard.service: api@internal
traefik.http.routers.dashboard.middlewares: auth
traefik.http.middlewares.auth.basicauth.usersfile: /etc/http.passwd
restart: always
devops_lab_db:
image: "registry.hpc.ut.ee/mirror/mariadb"
container_name: "devops_lab_db"
networks:
- devops_lab
environment:
MYSQL_ROOT_PASSWORD: "<SOME PASSWORD FOR DB ROOT>"
volumes:
- "/home/centos/devops_lab/db_data:/var/lib/mysql:rw"
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "127.0.0.1", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 1s
timeout: 3s
retries: 30
restart: always
devops_lab:
image: "devops_lab"
container_name: "devops_lab"
networks:
- devops_lab
environment:
DATABASE_ROOT_PASSWORD: "<SOME PASSWORD FOR DB ROOT>"
labels:
traefik.http.routers.devops_lab.rule: Host(`devops-lab.<vm_name>.sa.cs.ut.ee`)
traefik.port: 5000
depends_on:
devops_lab_db:
condition: service_healthy
restart: always
As you can see you are taking the commands that you would assign to docker run
and are assigning them in a yaml file. This deployment runs a traefik
container with an accompanying devops_lab
and devops_lab_db
containers.The traefik labels:
- traefik.http.routers.dashboard.rule: Host(
traefik-dashboard.<vm_name>.sa.cs.ut.ee
) tellstraefik
proxy to forward requests coming totraefik-dashboard.<vm_name>.sa.cs.ut.ee
to the dashboard application running insidetraefik
container - traefik.http.routers.dashboard.service: api@internal Sets the target to forward the request to
- traefik.http.routers.dashboard.middlewares: auth Tells
traefik
to use authentication for this endpoint - traefik.http.middlewares.auth.basicauth.usersfile: /etc/http.passwd Tells
traefik
to use/etc/http.passwd
as the authentication source
For the devops_lab
there are a bit less labels and these should be self explanatory by now.
Now create /etc/http.passwd
and create a user and password to access traefik
dashboard
sudo htpasswd -c /etc/http.passwd <A FREELY CHOSEN USERNAME>
Finally run docker compose up -d
. In the same directory where your docker-compose.yml
file is. You should see output about your services starting.
Now, before we can actually access the services from the browser, we also need to add traefik-dashboard.<vm_name>.sa.cs.ut.ee
and devops-lab.<vm_name>.sa.cs.ut.ee
to our DNS condiguration:
traefik-dashboard IN CNAME <vm_name>.sa.cs.ut.ee.
devops-lab IN CNAME <vm_name>.sa.cs.ut.ee.
Task 4 Use docker-compose
to start your services
- Stop and remove containers created before during this lab
- Create a
docker-compose.yml
file - Create
/etc/http.passwd
- Start your containers with
docker compose up -d
- Configure
traefik-dashboard.<vm_name>.sa.cs.ut.ee
anddevops-lab.<vm_name>.sa.cs.ut.ee
in your DNS server
Verify
- if you go to page
devops-lab.<vm_name>.sa.cs.ut.ee/motd
, you should see the newmotd
application running. - if you go to page
traefik-dashboard.<vm_name>.sa.cs.ut.ee
,traefik
should prompt you to authenticate. Use the credentials you created before withhtpasswd
- you can check the logs and see how it works, also there should be information about your containers on the traefik dashboard.
Pushing a container to a registry¶
Now we have built a container but that container is only available on the machine it was built on. Sometimes we need to make the containers we build publicly available. You can take the mariadb
or traefik
containers as examples - we did not build them, we pulled them from a public registry.
To prepare for the next lab, we need to make the devops_lab
container publicly available. For this, we have prepared a container registry
for you. Navigate to https://registry.sa.cs.ut.ee/ and create a new account there. As the username use your university username. You can use any password you like.
Then, after logging in, create a new Private
project with your <vm_name>
as the project name. Finally, click on the created project and using the hints in the PUSH COMMAND
drop-down menu, push your devops_lab
container to the registry (use latest
as the TAG
).
Task 5 Push the built container to a registry
- Create an account at https://registry.sa.cs.ut.ee/
- Create a project at https://registry.sa.cs.ut.ee/
docker login registry.sa.cs.ut.ee
- Push your built container to the registry