My Docker Image including Wine designed to run on AWS ECS
In my quest to stand up an old BBS game that was written for an early version of Windows I ended up creating a Docker Image of Ubuntu Linux that incorporates Wine and VNC that is configured to run in an instance of AWS ECS (Elastic Container Service - a.k.a. Docker in Amazon), using AWS EFS (Elastic File System - a.k.a. NFS in Amazon) for persistence.
This document will outline what I needed to build, how I got this to work, what challenges I had to overcome and how I handled them, and where you can download my code to build this Docker image and what you will need to modify to get it working for yourself.
All of my code used to create this image is in a public github repository I have set up here: ubuntu-1604-wine
Requirements
First step to any good deployment is knowing your requirements.
In my case I had several that needed to be met by this image.
Here are a list of my requirements:
- A Linux base that I know well (I prefer Debian flavors, and Ubuntu over others)
- Secure CLI remote access to the image (SSH)
- Graphical remote access to the image that is secure or can be tunneled (VNC)
- Ability to run a 1990's era Windows application (Wine)
- Works with network storage (NFS / AWS EFS)
- Works in an AWS ECS environment so I don't have to dedicate server resource to it.
- Supports a scripted installation without user interaction.
Testing outside of Docker
To make sure all the components I needed to incorporate were going to working together and to limit my troubleshooting of Docker issues versus application issues, I started testing with dedicated server resource VMs.
The first test I did was on a full desktop version of Ubuntu 16.04LTS. On this I was able to configure SSH and VNC and WINE and NFS and tested running my old application. I used the configurations and tweaks from this test to move into the next test.
The next test I did was on a headless server version of Ubuntu 16.04LTS. On this I was able to get the minimum X server components installed to allow all the other components from my desktop test to function properly.
With all these know working configurations in hand I was now ready to move on to the creation of a Docker image.
Building the Docker Image
My goal was to build the Docker image from a script that required no user input and to have that image once created be able to launch with no user input. This required the use of variables in the image build scripts and the use of environment variables. I'll step through each of these scripts and variables, what they do, and the order that they are executed.
Base Image
This is the base Docker image I used in my Dockerfile:
####################
# Base Image
####################
ubuntu:16.04
This image is similar to Ubuntu server but with many things stripped out. Some of these things I need and will need to add back in.
Basic Environment
Here is the basic environment I set in my Dockerfile before running any scripts:
####################
# Basic Environment
####################
ENV HOME=/root
WORKDIR $HOME
# Set Non-Interactive for image build
ENV DEBIAN_FRONTEND=noninteractive
# PASSWORD FOR SSH AND VNC IS SET HERE
ENV MYPSD=password
# X/VNC Environment Vars
# VNC port:5901 noVNC webport via http://IP:6901/?password=vncpassword
ENV TERM=xterm \
VNC_COL_DEPTH=24 \
VNC_RESOLUTION=1280x1024 \
VNC_PW=$MYPASSWD \
VNC_VIEW_ONLY=false \
DISPLAY=:1 \
VNC_PORT=5901 \
NO_VNC_PORT=6901
As you can see from the comments in the code, many of these environment variables are used by scripts and applications that have yet to be installed into the image.
One setting of note is the DEBIAN_FRONTEND=noninteractive setting as this will suppress any interactive prompts during the installs that follow.
Add scripts and update image
Here is the code I used to add my scripts and update the base image in my Dockerfile:
####################
# Add some scripts
####################
ADD ./bin/*.sh /usr/local/bin/
RUN chmod a+x /usr/local/bin/*
####################
# Update Apt Repos
####################
RUN apt-get update -y
Run Script to Install Tools and SSHD
As I said above many of the basic tools I use in the Ubuntu server image are missing from the Ubuntu Docker image. So, I created a script to install these tools as well as some tools used by other packages I'll be installing later. I also need to set the LANG environment variable here after the tools are installed.
Here is the code I used to do this in my Dockerfile:
####################
# Install Basic Tools
####################
RUN install-tools.sh
ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8'
#
####################
# Install SSHD
####################
RUN install-sshd.sh
Here is the code in the script file install-tools.sh:
#!/usr/bin/env bash
### Exit script on error
set -e
echo "********* Install basic tools **********"
apt-get install -y vim wget net-tools locales bzip2 iputils-ping traceroute \
python-numpy #python-numpy used for websockify/novnc
echo "Generate locales"
locale-gen en_US.UTF-8
Here is the code in the script file install-sshd.sh:
#!/usr/bin/env bash
### Exit script on error
set -e
echo "********** Install SSHD **********"
apt-get install -y openssh-server
mkdir /var/run/sshd
# Change root password below
#echo 'root:password' | chpasswd
echo root:$MYPSD | chpasswd
sed -i 's/PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config
# SSH login fix. Otherwise user is kicked off after login
sed 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' -i /etc/pam.d/sshd
You will notice that the root password is set to the MYPSD environment password that I set up at the beginning of the Dockerfile. You could modify this to be any user you like instead of root. But I prefer to use the root user. This has security implications. So consider yourself warned.
Run Script to Install X and VNC
I created scripts to install XFCE, VNC, noVNC, and copy some X configuration files into the image.
Here is the code I used to do this in my Dockerfile:
####################
# Install TigerVNC
####################
RUN install-tigervnc.sh
#
####################
# Install noVNC
####################
RUN install-no_vnc.sh
#
####################
# Install Xfce
####################
RUN install-xfce.sh
ADD ./bin/xfce/ $HOME/
Here is the code in the script file install-tigervnc.sh:
#!/usr/bin/env bash
### Exit script on error
set -e
echo "Download TigerVNC server binary"
wget -qO- https://dl.bintray.com/tigervnc/stable/tigervnc-1.8.0.x86_64.tar.gz | tar xz --strip 1 -C /
echo "Set the TigerVNC Password"
PASSWD_PATH="$HOME/.vnc/passwd"
mkdir $HOME/.vnc
echo "$MYPSD" | vncpasswd -f >> $PASSWD_PATH
chmod 600 $PASSWD_PATH
This script downloads the VNC binary, unpacks it, and sets the VNC password to be the MYPSD environment variable we set earlier. Since VNC has its own password file I had to make sure this was created and then used the vncpasswd command to set the password.
Here is the code in the script file install-no_vnc.sh:
#!/usr/bin/env bash
### Exit script on error
set -e
echo "Download noVNC (HTML based VNC) server binary"
mkdir -p $HOME/noVNC/utils/websockify
wget -qO- https://github.com/novnc/noVNC/archive/v0.6.2.tar.gz | tar xz --strip 1 -C $HOME/noVNC
wget -qO- https://github.com/novnc/websockify/archive/v0.6.1.tar.gz | tar xz --strip 1 -C $HOME/noVNC/utils/websockify
chmod +x -v $HOME/noVNC/utils/*.sh
## create index.html to forward automatically to `vnc_auto.html`
ln -s $HOME/noVNC/vnc_auto.html $HOME/noVNC/index.html
This script downloads the noVNC and websockify binaries, unpacks them, and sets the index file. Note that I am not setting a password here. This uses the VNC password that we already set.
Here is the code in the script file install-xfce.sh:
#!/usr/bin/env bash
### Exit script on error
set -e
echo "Install Xfce4"
apt-get install -y xfce4 xfce4-terminal
#xfce4-goodies <-nice but not req and add lots of size
apt-get purge -y pm-utils xscreensaver*
This script installs XFCE and some X applications including a terminal and screensaver. Initially I played with installing all the xfce-goodies. But this added a lot of bloat to the image and wasn't really needed.
After the installation of XFCE the Dockerfile will copy over several xml configuration files that XFCE uses. These are normally created automatically when launching XFCE for the first time. But the created versions seem to not like being in Docker and are not the best. Instead these files were created in a VM version of Ubuntu and copied here. I'll not be detailing all these files now, but they are all available in my github repo.
Run Script to Install a Browser
When working in a graphical environment there will always be a need to look up something on a web site. So a web browser was a must for me. Initially I tried to get Chrome working but had many issues with it. I ended up using Firefox. I still left my Chrome scripts in my github repository although they are not being called in the Dockerfile. This way I can easily come back to it to work on the issues later. For now Firefox is a good alternative.
Here is the code I used to do this in my Dockerfile:
####################
# Install Browser
####################
RUN install-firefox.sh
# Lots of issues with chrome but if you want to try it uncomment this:
#RUN install-chrome.sh
Here is the code in the script file install-firefox.sh:
#!/usr/bin/env bash
set -e
echo "Install Firefox"
function disableUpdate(){
ff_def="$1/browser/defaults/profile"
mkdir -p $ff_def
echo <<EOF_FF
user_pref("app.update.auto", false);
user_pref("app.update.enabled", false);
user_pref("app.update.lastUpdateTime.addon-background-update-timer", 1182011519);
user_pref("app.update.lastUpdateTime.background-update-timer", 1182011519);
user_pref("app.update.lastUpdateTime.blocklist-background-update-timer", 1182010203);
user_pref("app.update.lastUpdateTime.microsummary-generator-update-timer", 1222586145);
user_pref("app.update.lastUpdateTime.search-engine-update-timer", 1182010203);
EOF_FF
> $ff_def/user.js
}
#copy from org/sakuli/common/bin/installer_scripts/linux/install_firefox_portable.sh
function instFF() {
if [ ! "${1:0:1}" == "" ]; then
FF_VERS=$1
if [ ! "${2:0:1}" == "" ]; then
FF_INST=$2
echo "download Firefox $FF_VERS and install it to '$FF_INST'."
mkdir -p "$FF_INST"
FF_URL=http://releases.mozilla.org/pub/firefox/releases/$FF_VERS/linux-x86_64/en-US/firefox-$FF_VERS.tar.bz2
echo "FF_URL: $FF_URL"
wget -qO- $FF_URL | tar xvj --strip 1 -C $FF_INST/
ln -s "$FF_INST/firefox" /usr/bin/firefox
disableUpdate $FF_INST
exit $?
fi
fi
echo "function parameter are not set correctly please call it like 'instFF [version] [install path]'"
exit -1
}
instFF '45.9.0esr' '/usr/lib/firefox'
This script installs a specific version of Firefox that is known to work well with Docker and Xfce. It also prevents automatic updates by setting all the required user preferences in a default profile.
Run Script to Install WINE
This next install was a bit tricky as I wanted to install the latest winehq.org build of WINE
and configure it to work in both 32 and 64 bit modes defaulting to 32 bit.
This required several dependencies be met and the winehq apt repositories be added.
In addition, I had to install some msi packages manually using the msiexec CLI since they normally
require user interaction in a graphical environment upon first wine launch.
Here is the code I used to do this in my Dockerfile:
####################
# Install Wine
####################
RUN install-wine.sh
Here is the code in the script file install-wine.sh:
#!/usr/bin/env bash
### Exit script on error
set -e
echo "Install WINE !!!"
# Allow 32bit
echo "# Allow 32bit"
echo "**** dpkg --add-architecture i386"
dpkg --add-architecture i386
# Need to add this package to add repositories
echo "# Need to add this package to add repositories"
echo "**** apt-get install -y software-properties-common"
apt-get install -y software-properties-common
# Need to add this packge to use https repositories
echo "# Need to add this packge to use https repositories"
echo "**** apt-get install -y apt-transport-https"
apt-get install -y apt-transport-https
# Add the wine repo
echo "# Add the wine repo"
echo "**** wget -nc https://dl.winehq.org/wine-builds/Release.key"
wget -nc -nv https://dl.winehq.org/wine-builds/Release.key
echo "**** apt-key add Release.key"
apt-key add Release.key
echo "**** apt-add-repository https://dl.winehq.org/wine-builds/ubuntu/"
apt-add-repository https://dl.winehq.org/wine-builds/ubuntu/
echo "**** apt-get update -y"
apt-get update -y
# Install wine-stable
echo "# Install wine-stable"
echo "**** apt-get install -y wine-stable winehq-stable"
apt-get install -y wine-stable winehq-stable
# In a real desktop I would run this: WINEARCH=win32 winecfg to install gecko and mono
# with a GUI launch that allows other configuration as well... instead we will:
# Install gecko and mono via wget and msiexec
echo "# Install gecko and mono via wget and msiexec"
mkdir -p $HOME/.cache/wine
cd $HOME/.cache/wine
echo "**** wget -nv http://dl.winehq.org/wine/wine-mono/4.7.1/wine-mono-4.7.1.msi"
wget -nv http://dl.winehq.org/wine/wine-mono/4.7.1/wine-mono-4.7.1.msi
echo "**** wget -nv http://dl.winehq.org/wine/wine-gecko/2.47/wine_gecko-2.47-x86.msi"
wget -nv http://dl.winehq.org/wine/wine-gecko/2.47/wine_gecko-2.47-x86.msi
echo "**** WINEARCH=win32 msiexec /i wine-mono-4.7.1.msi /q /l* wine-mono-4.7.1.log"
WINEARCH=win32 msiexec /i wine-mono-4.7.1.msi /q /l* wine-mono-4.7.1.log
echo "**** WINEARCH=win32 msiexec /i wine_gecko-2.47-x86.msi /q /l* wine_gecko-2.47-x86.log"
WINEARCH=win32 msiexec /i wine_gecko-2.47-x86.msi /q /l* wine_gecko-2.47-x86.log
cd $HOME
# Install dotnet tools with winetricks
echo "# Install dotnet tools with winetricks"
echo "**** apt-get install -y winetricks"
apt-get install -y winetricks
This script starts by adding the 32 bit architecture to the image. Next it adds the utilities necessary to add new apt repositories and adds the winehq repositories and installs wine. Next it gets the mono and gecko msi files and installs them using the msiexec CLI. This prevents the requirement of an interactive graphical environment. Lastly it installs the winetricks package which adds some windows network functions.
Cleanup
With everything installed all that is left is some cleanup tasks.
Here is the code I used to do this in my Dockerfile:
####################
# Install Cleanup
####################
RUN apt-get clean -y
# We don't want Non-Interactive set after install
ENV DEBIAN_FRONTEND=readline
# We don't want the passwd here after install
ENV MYPSD=redacted
This cleans up all the cached apt files and sets DEBIAN_FRONTEND=readline so that future installs with be interactive again, and redacts the MYPSD variable.
Running Configs
The last thing that we do with the Dockerfile is to set the running configs:
# Expose Ports
EXPOSE 22/tcp $VNC_PORT $NO_VNC_PORT
# Run the startme script which starts SSHD and VNC and checks for other run line commands
ENTRYPOINT ["startme.sh"]
CMD ["--tail-log"]
Here is the code in the script file startme.sh:
#!/bin/bash
### Exit script on error
set -e
# Start SSHD server
/usr/sbin/sshd -D &
# Need to set the bash environment up for vnc to inherit
source $HOME/.bashrc
# Start NOVNC Server
echo "Starting noVNC Server..."
echo "$HOME/noVNC/utils/launch.sh --vnc localhost:$VNC_PORT --listen $NO_VNC_PORT "
$HOME/noVNC/utils/launch.sh --vnc localhost:$VNC_PORT --listen $NO_VNC_PORT &
# Start VNC Server
vncserver -kill $DISPLAY || rm -rfv /tmp/.X*-lock /tmp/.X11-unix || echo "remove old vnc locks to be a reattachable container"
echo "Starting VNC Server..."
echo "vncserver $DISPLAY -depth $VNC_COL_DEPTH -geometry $VNC_RESOLUTION "
vncserver $DISPLAY -depth $VNC_COL_DEPTH -geometry $VNC_RESOLUTION
## either tail the vnc log or pass through a different command
echo -e "\n\n------------------ VNC environment started ------------------"
echo -e "\nVNCSERVER started on DISPLAY= $DISPLAY \n\t=> connect via VNC viewer with ThisContainerIP:$VNC_PORT"
echo -e "\nnoVNC HTML client started:\n\t=> connect via http://ThisContainerIP:$NO_VNC_PORT/?password=...\n"
if [ -z "$1" ] || [[ $1 =~ -t|--tail-log ]]; then
# if option `-t` or `--tail-log` block the execution and tail the VNC log
echo -e "\n------------------ $HOME/.vnc/*$DISPLAY.log ------------------"
tail -f $HOME/.vnc/*$DISPLAY.log
else
# unknown option ==> call command
echo -e "\n\n------------------ EXECUTE COMMAND ------------------"
echo "Executing command: '$@'"
exec "$@"
fi
This script is launched when running the Docker image. It launches the SSH server and starts the VNC and noVNC servers then sets up a way to execute commands sent to it or tails the vnc logs if no commands are sent.
AWS ECS
I will not be detailing all the steps necessary to set up an AWS ECS cluster with its image repositories and AWS EFS storage (as AWS provides very good documentation on all this). Instead, I will simply explain that AWS ECS is Docker sitting on AWS EC2 servers using its own repository system and that AWS EFS is and NFS file server that can be used for persistence in your containers. The last important note is that AWS provides a separate CLI for working with ECS called the ecs-cli. This ecs-cli can be used to orchestrate your container deployments in several ways including ecs-cli compose which is similar to Docker compose but with some slightly different options.
After building the Docker image which I named u16winevnc using the steps above,
I set up my AWS ECS cluster with and EFS mount point and a repository which
I named alt_bier which also matches my local repository name for simplicity.
You can name the image and repository anything you like. But these
are the names I will reference in my code below.
Here is how I pushed my Docker image into the AWS ECS repository:
ecs-cli push alt_bier/u16winevnc
When you push into your repository it will provide you will the full repository image file name which starts with your account information. Copy this down for later.
Since I was familiar with Docker compose I decided to use the ecs-cli compose function to run my container in ECS. To do this I created a yml file named u16winevnc.yml with the following:
version: '2'
services:
u16winevnc:
# Image refers to the AWS ECS repository image to be used, change to yours
image: 000000000000.dkr.ecr.us-west-2.amazonaws.com/alt_bier/u16winevnc:latest
# Privileged mode is only required if you want to use network tools
privileged: true
# 1024 cpu_shares = 1 vCPU (256 = 0.25 vCPU)
cpu_shares: 256
# mem_limit is an integer indicating bytes in binary (1G=2^30=1073741824, 512M=2^29=536870912)
mem_limit: 536870912
# Ports to be exposed if format host:container
ports:
- "522:22/tcp"
- "5901:5901/tcp"
- "6901:6901/tcp"
# This mounts an AWS EFS file system
volumes:
- "/efs:/efs"
This file will determine the settings used for our container once we launch it. Comments in the code explain all of the settings. One note is that even though this file exposes ports from the container to the host does not mean these ports are exposed beyond the host. You must use AWS EC2 security groups to grant access inbound to these ports.
This is how I launch my new container:
ecs-cli compose --file u16winevnc.yml --project-name u16winevnc up
Using this method I can bring instances of this container up or down easily.
I have written logic into the Windows app that I run in this container to save its state to the EFS mount so that this container can be torn down and brought back up without data loss. But that is beyond the scope of this blog.
Conclusion
While what I have presented here may seem like a lot of work, it was actually all quite easy and fairly quick to complete once all the research was done.
The final product not only met my original goal of running this old Windows code without using a dedicated server, but I have found several other uses after I launched it including external network and security testing of my systems.
This was a lot of fun and very rewarding that it worked so well.
I hope you enjoy it.