Possible to run containers at boot? And a networking question

I’m in an all-Mac environment and I want to start using Docker (finally also with a swarm with Docker processes on multiple Macs). I’m a newbie and I’ve been reading documentation and I’m left with a few questions for which I haven’t found answers yet. I’d be obliged if someone can answer them for me.

  1. I want containers to run at launch of the machine and not just when a user is logged in. From the documentation this looks to be incompatible with the Docker for Mac app, where all the documentation talks about ~/Library, etc. and everything seems to be about only running stuff when you’re logged in and have started the Docker for Mac app. Does this mean I will have to run Docker using the brew install? Or is it possible to have a Docker for Mac install and still use Docker containers without anyone being logged in?
  2. I want containers to be active on a local subnet. I’ve read that you cannot connect to containers from the macOS host Docker is running on. Is this final, or is it just a matter of the right routing table command and such? What is needed for containers on a Mac to be first class citizens on my local LAN?

Nobody? I’ve installed Docker through homebrew and have found out that apparently code signing stops launchd from starting a docker-machine at boot. It would be really nice if I could run a docker machine at boot and also start some containers at boot.

If you want your containers running all the time after a reboot then I would look at docker-compose this appears to work fine with a mac, not that I have any experience with macs.

Create a common network 'docker network create [some_network_name] ’ and then, instead of using docker run to start containers you would instead be using docker-compose.yml files which use a structured file to define paramaters such as volume mounts and environment variables. Also, the network to be used and any aliases.

Example: -

version: "3"
services:
  nginx:
    image: "jwilder/nginx-proxy:alpine" 
    container_name: nginx
    restart: always 
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ./nginx/log:/var/log/nginx/
      - ./nginx/certs:/etc/nginx/certs
      - ./nginx/proxy.conf:/etc/nginx/proxy.conf:ro
    environment:
      - SSL_POLICY=Mozilla-Modern
    ports:
      - '80:80'
      - '443:443'
    networks:
      some_network:
        aliases:
          - nginx.local

    networks:
      some_network:
        external:
          name: some_network

As far as I’m aware, assuming docker starts up when your machine boots, the assuming you started the docker containers using ‘docker-compose up -d’, in the same directory as the docker-compose.yml file, then these containers will spin up on boot. There’s also a restart: always option incase of crashes.

That is the second part. The first part is getting a docker-machine run at boot of the Mac, the “assuming docker starts up when your machine boots” part. That’s the one I haven’t been able to get running.

The codesign thing turned out to be a red herring, by the way. It’s just not possible to get a docker machine start running at boot time via launchd.

Actually, it is possible. The problem is that launchd has no mechanism for an ordering of what to launch at boot, no dependencies. And docker-machine launches are dependent on Virtualbox kernel extensions having loaded.

So, I wrote a script to handle docker-machine at launch (and in general) and now I can launch docker-machines at boot time of a mac. I’m still expanding on the script to handle more issues and when it’s done I’ll publish in full.

Hi - did you ever complete the scripts to run docker-machine at launch?
I also want to run docker at boot on a Mac, so I can get a docker VPN server container running at boot.

I was sidetracked by many other issues, so I never finished it, but no problem to share it now as is. I’ll get back to this later.

The script to start a machine is:

#!/usr/local/bin/python3

import sys
import os
import pwd
import subprocess
import argparse
import textwrap
import json
import time
import pathlib

DOCKERMACHINECOMMAND='/usr/local/opt/docker-machine/bin/docker-machine'
VERSION="1.1"
COPYRIGHT="(C) Gerben Wierda 2019 (with lots of help/copy from\nstackexchange etc.)"
LICENSE="Free under BSD License (look it up)"
STANDARDRETRY=15
LAUNCHDAEMON='/Library/LaunchDaemons/nl.rna.docker-machines.autostart.plist'
DEFAULTJSONLOCATION='/usr/local/etc/managed-docker-machines'
DEFAULTJSONFILENAME='default.json'
DEFAULTJSONFILE=DEFAULTJSONLOCATION + '/' + DEFAULTJSONFILENAME
DEFAULTJSONFILECONTENT="""[
    {
      "entryname" : "generic",
      "machinename" : "default"
    }
]
"""
# Override the default json so we can run without a json argument
# Global vars. A bit jukky but OK for this small thing
DEFAULTJSONFILEOVERRIDDENMSG = "\n(can be overridden by the MANAGEDOCKERMACHINESDEFAULT environment variable)\n"
env = os.environ.copy()
if env.get('MANAGEDOCKERMACHINESDEFAULT'):
    DEFAULTJSONFILE = env.get('MANAGEDOCKERMACHINESDEFAULT')
    DEFAULTJSONFILEOVERRIDDENMSG = "\n(currently overridden by the MANAGEDOCKERMACHINESDEFAULT environment variable)\n"

# Launchd definition plist template
LAUNCHDPLISTTEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
        <string>nl.rna.docker-machines.autostart</string>
    <key>ProgramArguments</key>
        <array>
            STARTATBOOTCMDSTRINGS        </array>
    <key>RunAtLoad</key>        <true/>
    <key>LaunchOnlyOnce</key>   <true/>
    <key>KeepAlive</key>        <false/>
    <key>StandardErrorPath</key>
        <string>/var/log/autostart-docker-machines-rna-nl.err</string>
    <key>StandardOutPath</key>
        <string>/var/log/autostart-docker-machines-rna-nl.out</string>
</dict>
</plist>'''

from argparse import RawDescriptionHelpFormatter

class SmartDescriptionFormatter(argparse.RawDescriptionHelpFormatter):
  #def _split_lines(self, text, width): # RawTextHelpFormatter, although function name might change depending on Python
  def _fill_text(self, text, width, indent): # RawDescriptionHelpFormatter, although function name might change depending on Python
    if text.startswith('R|'):
      paragraphs = text[2:].splitlines()
      # Next line adapted from the StackExchange version to use textwrap module
      rebroken = [textwrap.wrap(tpar, width) for tpar in paragraphs]
      rebrokenstr = []
      for tlinearr in rebroken:
        if (len(tlinearr) == 0):
          rebrokenstr.append("")
        else:
          for tlinepiece in tlinearr:
            rebrokenstr.append(tlinepiece)
      #print(rebrokenstr)
      return '\n'.join(rebrokenstr) #(argparse._textwrap.wrap(text[2:], width))
    # this is the RawTextHelpFormatter._split_lines
    #return argparse.HelpFormatter._split_lines(self, text, width)
    return argparse.RawDescriptionHelpFormatter._fill_text(self, text, width, indent)

parser = argparse.ArgumentParser( formatter_class=SmartDescriptionFormatter,
                    description=(
'''R|BASICS

Start Docker VMs with docker-machine at macOS boot. This program reads one
or more JSON files that define docker machines, including which VM provider to
use (currently only VirtualBox can be detected), as what user the machine must
be started, the working directory to go to before starting or stopping a
machine, and the name of the docker machine. Example:
  [
    {
    "entryname": "john-default", # required, used for --only flag
    "displayname": "John's default docker machine",
                                 # Default: entryname
    "vmdriver": "virtualbox",    # VM driver to use
                                 # Default: virtualbox
    "user": "gerben",            # User to run as
                                 # Default: the user that runs the script
                                 # (in which case no sudo/root is needed)
    "workingdir": "/private/tmp",# Dir to cd to before running docker-machine
                                 # Default: environment value CWD or else
                                 # /private/tmp
    "machinename": "default",    # Docker machine name
                                 # Default: entryname
    "createoptions": ["--virtualbox-hostonly-cidr","192.168.98.1/24"],
                                 # Example. Sets network for DHCP on machine
                                 # and thus reliable IP address (workaround)
                                 # Default: nothing
    "enabled": true              # Set to false to ignore entry
                                 # Default: true
    },
    {
      "entryname": "gerben-lunaservices",
      "displayname": "Gerben's lunaservices docker machine",
      "vmdriver": "vmware",
      "user": "gerben",
      "workingdir": "/Users/gerben",
      "machinename": "lunaservices",
      "enabled": false
    },
    {
      "entryname" : "default"
    }
  ]
(Note: vmware support is not implemented) (Also Note: comments are not allowed
in JSON files, they're only here for explanation.)

Preferred install of this script:
    sudo cp macos-manage-docker-machines /usr/local/bin
    sudo chmod +x /usr/local/bin/macos-manage-docker-machines

The default json file used is:
''' + '    ' + DEFAULTJSONFILE + DEFAULTJSONFILEOVERRIDDENMSG +
'''
The script can install a barebones default configuration for you which
contains a definition that can be shared by all users:
[
    {
      "entryname" : "generic",
      "machinename" : "default"
    }
]
This default machine definitions json lets this script act on the 'default'
docker-machine for any user, start the machine in the current directory
and uses virtualbox. It doesn't require root/sudo. It also doesn't manage
anything when the script is run as root, because root machines require
explicit
    "user": "root"
in the entry

Actually, it could have been simpler still (but potentially confusing):
[
    {
      "entryname" : "default"
    }
]
as the entryname is used also as machinename if no machinename is given.

So, after running
    sudo macos-manage-docker-machines installdefault
You can run
    macos-manage-docker-machines create
and get a 'default' docker machine.

START DOCKER MACHINES AT BOOT

To install a basic auto-launch configuration:
    sudo macos-manage-docker-machines startatboot
Note that this will not start any docker-machine at boot if the default json
is used, as the launchd runs as root and the default configuration doesn't
have an entry that works for root. So to make your personal machine
'mymachine' start at boot you could add this entry to
    /usr/local/etc/managed-docker-machines/myown.json
(change 'me' to your own user name, twice)
[
  {
    "entryname":     "mymachine",
    "displayname":   "My beautiful docker machine",
    "vmdriver":      "virtualbox",
    "user":          "me",
    "workingdir":    "/Users/me",
    "machinename":   "mymachine",
    "createoptions": ["--virtualbox-hostonly-cidr","192.168.98.1/24"],
    "enabled":       true
  }
]

Note: the create-options is a workaround to make sure your machine gets the
same IP address every time. For other machines, you change the third number of
the IP address, e.g.
    "createoptions": ["--virtualbox-hostonly-cidr","192.168.97.1/24"],
That way you can make sure that each machine has a reliable, repatable IP
address.

And then you could run
    macos-manage-docker-machines create \\
        /usr/local/etc/managed-docker-machines/myown.json
to create your docker machine 'mymachine'

then the way to get these to run at macOS start is
    sudo macos-manage-docker-machines startatboot \
        /usr/local/etc/managed-docker-machines/myown.json
or, for instance if you have multiple files with definitions:
    sudo macos-manage-docker-machines --maxwait 120 startatboot \
        /usr/local/etc/managed-docker-machines/*

OVERRIDES

If the combination of machinename and user have already been managed
in a run of this script, a next entry will be ignored. Thus, you can create
definitions that override the later definitions. This is particularly useful
for getting reliable IP addresses on reboot/restart (see example above).
''' +
"\nCopyright: " + COPYRIGHT +
"\nThis is version: " + VERSION +
"\n" + LICENSE +
"\nThe docker-machine command used is: " + DOCKERMACHINECOMMAND))

parser.add_argument( "-v", "--verbosity", action="count", default=0,
                     help="Increase output verbosity (5 gets the maximum effect)")
parser.add_argument( "-d", "--dry-run", dest="dryrun", action='store_const', const=True,
                     help="Do everything except actually running subprocesses or commands that change the system.")
parser.add_argument( "--force", dest="force", action='store_const', const=True,
                     help="Overwrite files on install actions.")
parser.add_argument( "--maxwait", type=int, choices=range(0, 601), default=0,
                     metavar="[0-600]",
                     help=("Maximum wait time in seconds for VM provider to become available (if missing)."
                     " The program will retry every" + str(STANDARDRETRY) + "until the required VM provider"
                     " becomes available or the maximum wait time is met. Note that this wait is implemented"
                     " per VM provider so in the worst case the program will try for number of"
                     " providers times the set maximum retry time."))
parser.add_argument( "--only", nargs="*", dest="VMDeclarations_Machines_Subset",
                     metavar="machine",
                     help="Restrict actions to these entrynames only.")
parser.add_argument( "action", choices=['start','stop','create','rm','restart','ls','installdefault','startatboot','nostartatboot'], nargs=1,
                     help=("Action that is taken. All but installdefault, startatboot and nostartatboot are docker-machine actions."
                     " The 'ls' action is only executed once for each user, regardless of the number of machine"
                     " definitions that apply to that user. installdefault installs a default JSON file " +
                     DEFAULTJSONFILE + " with a basic generic entry."
                     " startatboot uses the current command invocation to create a launchd plist as " +
                     LAUNCHDAEMON + "nostartatboot uninstalls the launchd plist."))
parser.add_argument( "VMDeclarations_files", metavar="JSON_file(s)", nargs="*",
                     help=("JSON file(s) with Docker Machine launch definitions."
                     " See description above."))
scriptargs = parser.parse_intermixed_args()

# Global vars. A bit jukky but OK for this small thing
PROGNAME=sys.argv[0]
VERBOSITY=scriptargs.verbosity
DRYRUN=scriptargs.dryrun
FORCE=scriptargs.force
ACTION=scriptargs.action[0]
JSONFILES=scriptargs.VMDeclarations_files
if not JSONFILES: JSONFILES = [DEFAULTJSONFILE]
ONLYMACHINES=scriptargs.VMDeclarations_Machines_Subset
MAXWAIT=scriptargs.maxwait
WHOAMI=pwd.getpwuid(os.geteuid()).pw_name
if not WHOAMI:
    print( "Fatal error: cannot get user information.")
    exit( 1)

# Add VM providers here
vmdrivers = {'virtualbox':False}

def log( message):
    if DRYRUN:
        logmsg="[" + PROGNAME + " " + time.asctime() + " (DRYRUN)] " + message
    else:
        logmsg="[" + PROGNAME + " " + time.asctime() + "] " + message
    print( logmsg)

def CheckVMProvider( vmdriver):
    if vmdriver == 'virtualbox':
        if vmdrivers['virtualbox']:
            return True
        waited=0
        while waited <= MAXWAIT:
            p1 = subprocess.Popen( ["kextstat"], stdout=subprocess.PIPE)
            p2 = subprocess.Popen( ["grep", "org.virtualbox.kext.VBoxNetAdp"], stdin=p1.stdout, stdout=subprocess.PIPE)
            p1.stdout.close()  # Allow p1 to receive a SIGPIPE if p2 exits.
            if p2.wait() == 0:
                vmdrivers['virtualbox'] = True
                return True
            waited = waited + STANDARDRETRY
            if waited < MAXWAIT:
                if VERBOSITY > 1: log( "Virtual machine provider " + vmdriver + " is not (yet) available. Sleeping " + str(STANDARDRETRY) + "sec and retrying...")
                time.sleep( STANDARDRETRY)
            else:
                if VERBOSITY > 1: log( "Virtual machine provider " + vmdriver + " is not available. Giving up.")
    else:
        if VERBOSITY > 1: log( "Virtual machine provider " + vmdriver + " is not supported.")
        return False

def report_ids( msg):
    if VERBOSITY > 4: print( "[" + PROGNAME + "] " + 'uid, gid = %d, %d; %s' % (os.getuid(), os.getgid(), msg))

def demote( user_uid, user_gid):
    def result():
        if not (DRYRUN or user_uid == os.geteuid()) or WHOAMI == "root":
            report_ids( 'starting demotion')
            os.setgid( user_gid)
            os.setuid( user_uid)
            report_ids( 'finished demotion')
    return result

processedmachines = {}
processedusers={}
def manageDockerMachine( definition):
    entryname     = definition.get( 'entryname')
    machinename   = definition.get( 'machinename') or entryname
    displayname   = definition.get( 'displayname') or entryname
    enabled       = definition.get( 'enabled') or True
    targetuser    = definition.get( 'user') or '<any>'
    workingdir    = definition.get( 'workingdir') or os.getenv( "CWD") or '/private/tmp'
    vmdriver      = definition.get( 'vmdriver') or 'virtualbox'
    createoptions = definition.get( 'createoptions')
    machineidentifier = targetuser + '|' + machinename
    if targetuser == '<any>':
        # If no user has been defined, it is run for the current user, unless
        # the current user is root
        if WHOAMI == 'root' and ACTION != 'ls':
            if VERBOSITY > 0: log( "Skipping machine " + machinename + " for user " + targetuser + " as we are running as root.")
            return True
        else:
            targetuser = WHOAMI
    if machineidentifier in processedmachines:
        # if we encounter a later definition for the same combination of machine
        # and user it is ignored (thus a more specific definition can override
        # a more generic definition)
        if VERBOSITY > 0: log( "Skipping machine " + machinename + " for user " + targetuser + " as it has already been managed by an earlier definition.")
        return True
    if WHOAMI in ['root', targetuser]:
        # We can execute if we are the target user for this definition or if we
        # are root
        pw_record = pwd.getpwnam( targetuser)
        username  = pw_record.pw_name
        homedir   = pw_record.pw_dir
        uid       = pw_record.pw_uid
        gid       = pw_record.pw_gid
        env = os.environ.copy()
        env['PATH']      = '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/usr/local/sbin'
        env['HOME']      = homedir
        env['USER']      = username
        env['SUDO_USER'] = username
        env['PWD']       = workingdir
        env['LOGNAME']   = username
        dmargs = [DOCKERMACHINECOMMAND, ACTION, machinename]
        if ACTION == 'create' and createoptions:
            # Add optional options for create
            dmargs[2:2] = createoptions
        if ACTION == 'ls':
            # When the action is 'ls', it is only executed once for each user and only When
            # when we are running as root will we get an extra header so it is clear for
            # which user each output is
            if username in processedusers:
                if VERBOSITY > 4: log( "Already processed ls for user " + username + ". Skipping.")
                return True
            if WHOAMI == 'root':
                print( 'docker-machine ls output for user ' + username + ':')
        if DRYRUN:
            dmargs = ['/bin/echo', "[" + PROGNAME + " " + time.asctime() + " (DRYRUN)] " + 'Would have executed (as user ' + targetuser + ') command:'] + dmargs
        if enabled:
            if VERBOSITY > 2: log( "Executing " + ACTION + " on " + vmdriver + " docker machine " + machinename + " for user " + targetuser + '".')
            if not CheckVMProvider( vmdriver):
                if VERBOSITY > 0: log( "Virtual machine provider " + vmdriver + " not found. Ignoring machine definition " + '"' + machinename + '".')
                return False
            report_ids('starting ' + str( dmargs))
            process = subprocess.Popen( dmargs, preexec_fn=demote(uid, gid),
                                        cwd=workingdir,env=env)
            result = process.wait()
            report_ids( 'finished ' + str(dmargs))
            processedmachines[machineidentifier] = True
            processedusers[username] = True
        else:
            if VERBOSITY > 3: log( "Ignoring disabled " + vmdriver + " docker machine " + machinename + " of user " + targetuser + '".')
    else:
        if VERBOSITY > 0: log( "Skipping machine of " + targetuser + " for which we (" + WHOAMI + ") do not have rights. Run as root to fix.")
    return True

def startatboot():
    # The current command invocation is used to decide what JSON file arguments
    # to give for the automatic start at boot time.
    # If --maxwait > 60, it is used instead of the default 60
    if WHOAMI == 'root':
        if ACTION == 'startatboot':
            if FORCE or not os.path.exists( LAUNCHDAEMON):
                maxwait = 60
                if MAXWAIT>60: maxwait = MAXWAIT
                args = ('            <string>-v</string>' + "\n" +
                       '            <string>--maxwait</string>' + "\n" +
                       '            <string>' + str(maxwait) + '</string>' + "\n" +
                       '            <string>start</string>' + "\n")
                cmd = ''
                if VERBOSITY > 0: log( "Installing autostart of docker machines at macOS boot time.")
                cmd = cmd + '<string>' + os.path.abspath( PROGNAME) + '</string>' + "\n"
                for file in JSONFILES:
                    args = args + '            <string>' + os.path.abspath( file) + '</string>' + "\n"
                launchdplistfilled = LAUNCHDPLISTTEMPLATE.replace( 'STARTATBOOTCMDSTRINGS', cmd + args)
                if not DRYRUN:
                    if VERBOSITY > 0: log( 'Installing ' + LAUNCHDAEMON)
                    elif VERBOSITY > 3:
                        log( 'Installing the following content as ' + LAUNCHDAEMON + ':')
                        print( launchdplistfilled)
                    print( launchdplistfilled, file=open( LAUNCHDAEMON, 'w'))
                else:
                    log( "(DRYRUN) " + LAUNCHDAEMON + " not created.")
            else:
                log( LAUNCHDAEMON + " already exists and will not be overwritten. Uninstall first.")
        elif ACTION == 'nostartatboot':
            if VERBOSITY > -1:
                log( 'Uninstalling ' + LAUNCHDAEMON)
            log( 'TODO uninstall')
        elif ACTION == 'installdefault':
            if FORCE or not os.path.exists( DEFAULTJSONFILE):
                if not DRYRUN:
                    pathlib.Path( DEFAULTJSONLOCATION).mkdir(parents=True, exist_ok=True)
                    if os.path.isdir( DEFAULTJSONLOCATION):
                        if VERBOSITY > 0: log( DEFAULTJSONFILE + " created.")
                        print( DEFAULTJSONFILECONTENT, file=open( DEFAULTJSONFILE, 'w'))
                    else:
                        log( "Failure to create " + DEFAULTJSONLOCATION + " (is there a file with that name?)")
                else:
                    log( "(DRYRUN) " + DEFAULTJSONFILE + " not created.")
            else:
                log( DEFAULTJSONFILE + " already exists and will not be overwritten. Remove first.")
        else:
            exit( 2) # Programmer error
    else:
        log( "Nothing (un)installed. Installing or uninstalling the startup item or default configuration requires running this script as root.")
    return

def executeDockerMachineOn():
    for file in JSONFILES:
        if VERBOSITY > 0: log( "Processing VM declaration file: " + file)
        filedescriptor = open( file, 'r')
        machinedefinitions = json.load( filedescriptor)
        if VERBOSITY > 4: print( json.dumps( machinedefinitions, sort_keys=True, indent=4))
        for machinedefinition in machinedefinitions:
            entryname = machinedefinition['entryname']
            if not entryname:
                log( "Skipping VM declaration as it has no required 'entryname' key.")
            else:
                if ONLYMACHINES and entryname not in ONLYMACHINES:
                    if VERBOSITY > 1: log( "Skipping VM declaration: " + entryname + " (excluded by --only).")
                else:
                    manageDockerMachine( machinedefinition)

if DRYRUN and VERBOSITY > 4: log( "Running dry: no system-changing actions will be taken.")
if ACTION in ['installdefault','startatboot','nostartatboot']:
    startatboot()
else:
    executeDockerMachineOn()

Save as /usr/local/bin/macos-manage-docker-machines with execute permissions:

    sudo cp macos-manage-docker-machines /usr/local/bin
    sudo chmod +x /usr/local/bin/macos-manage-docker-machines

then run macos-manage-docker-machines --help for the documentation. It is still in the state it was two years ago. There were some things I wanted to add, but I forget what they were :slight_smile:

I would be interested to hear from your experiences, as I will return to this later, especially regarding networking as I did not yet look at getting the docker machine providing a service to the rest of the LAN.

Hi gctwnl,

Thanks for posting this up, it’s going to take me a little while to digest b/c I’m new to Docker and some things like a JSON file are not familiar to me yet.

I’m having a little bit of confusion understanding some things:
How many scripts are there in the post? I see the python script, the launchd script and then some other stuff.

Or is what you posted all a large python script, which you intend to be named macos-manage-docker-machines?

Another question:
Do you have a recommendation for how to get/install the docker-machine and the correct VirtualBox?

thanks in advance, I’m excited to understand this, try it out and get it working.

All of it is the script. What you see in the script is some default payload, e.g. the script contains default json and plist configurations that it can install, as well as documentation. It contains everything to set you up.

The only thing you do need is python3 and to change the reference to it in the first line.

Virtualbox was a d/l from Oracle

I installed python3 and docker-machine via homebrew but I have moved to MacPorts for a year now.

Yes.