2

Now that Apple is dropping certain services in Server.app (postfix, dovecot, DNS, to name a few) it is important to find solutions to keep these running. Apple suggests moving to open source versions, but their document describing the migration is far from complete (e.g. mail services are not documented yet).

I have been thinking in adding these services using containers. It is possible to run Docker on macOS. I've been able to install and use Docker through homebrew, using Virtualbox as the hypervisor-provider.

However, I've not been able to start a docker machine at boot time, before anyone is logged in. Such a startup would be necessary for a macOS Server to retain its services under Docker

A LaunchDaemon should do the trick. Homebrew even can manage a launchd .plist or you can create one by hand.

But while I can start the VM manually, I cannot start it via launchctl. What seemed to happen at one point is that macOS (High Sierra in my case) balked at the fact that what I am trying to start had not been code signed. Which is weird, because I also run Duplicati on some systems, nginx, minio and these just run. I could get passed this hurdle with codesign -s - /usr/local/opt/docker-machine/bin/docker-machine. But it still will not launch the service.

[UPDATE: codesign is a red herring. Even with the programs signed and errors going away (codesign -s - <binary>) it still is not possible to launch docker from launchd, let alone at boot.]

Is there any way I can have a docker machine (with some services) start at boot time on macOS?

gctwnl
  • 131
  • 5
  • For several years Apple has pretty much abandoned any thought of supporting macOS as a server. You should expect this trend to continue, and begin moving your workloads off macOS. – Michael Hampton Jan 09 '19 at 16:04

1 Answers1

1

Yes, it is possible. The essential problem was that VirtualBox kexts had not been loaded when the docker-machine command was run by launchd. Launchd has no good dependency system. So, I created a script that checks and retries the check after intervals (until a max time) and only starts docker-machine when VirtualBox is present. It is driven by a JSON file that contains information on the machine(s) to start.

Currently still under development (I need to complete a few things) but here is an example of a plist:

<?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>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin</string>
    </dict>
    <key>Label</key>            <string>nl.rna.docker-machines.manage</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/gerben/RNAManageDockerMachines.py</string>
        <string>/Users/gerben/RNAManagedDockerMachines.json</string>
        <string>--maxwait</string>
        <string>60</string>
        <string>-vvvv</string>
        <string>start</string>
    </array>
    <key>Disabled</key>         <false/>
    <key>LaunchOnlyOnce</key>   <true/>
    <key>RunAtLoad</key>        <true/>
    <key>KeepAlive</key>        <false/>
    <key>StandardOutPath</key>
        <string>/Library/Logs/rnamanagedocker_out.log</string>
    <key>StandardErrorPath></key>
        <string>/Library/Logs/rnamanagedocker_err.log</string>
  </dict>
</plist>

The plist is LaunchOnlyOnce (a startupitem of sorts). A configuration JSON:

{
  "sysbh-default": {
    "displayname": "Sysbh's default docker machine",
    "vmservice": "virtualbox",
    "user": "sysbh",
    "workingdir": "/Users/sysbh",
    "machinename": "default",
    "enabled": true
  },
  "gerben-lunaservices": {
    "displayname": "Gerben's lunaservices docker machine",
    "vmservice": "vmware",
    "user": "gerben",
    "workingdir": "/Users/gerben",
    "machinename": "lunaservices",
    "enabled": false
    }
}

As you can see the JSONs can hold several definitions.

And the script. I'm using a homebrew-installed python 3.7. The script can start and stop de docker machines.

#!/usr/local/bin/python3

import sys
import os
import pwd
import subprocess
import argparse
import textwrap # Required for 3.7
import json
import time

DOCKERMACHINECOMMAND='/usr/local/opt/docker-machine/bin/docker-machine'
VERSION="1.0beta1"
AUTHOR="Gerben Wierda (with lots of help/copy from stackexchange etc.)"
LICENSE="Free under BSD License (look it up)"
STANDARDRETRY=15

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 for 3.7 adapted from the StackExchange version to use textwrap module
      rebroken = [textwrap.wrap(tpar, width) for tpar in paragraphs]
      # 2.7: rebroken = [argparse._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|Start Docker VMs with docker-machine at macOS boot. This program reads one or\n"
"more JSON files that define docker machines, including which VM provider to\n"
"use (currently only VirtualBox is supported), as what user the machine must be\n"
"started, the working directory to go to before starting or stopping a machine,\n"
"and the name of the docker machine. Example:\n") +
"""
{
  \"john-default\": {
    \"displayname\": \"John's default docker machine\",
    \"vmservice\": \"virtualbox\",   # VM provider to use
    \"user": \"sysbh\",             # User to run as
    \"workingdir\": \"/Users/john\", # Dir to cd to before running docker-machine
    \"machinename\": \"default\",    # Docker machine name
    \"enabled\": true              # Set to false to ignore entry
  },
  \"gerben-lunaservices\": {
    \"displayname\": \"Gerben's lunaservices docker machine\",
    \"vmservice\": \"vmware\",       # Not implemented in this version
    \"user\": \"gerben\",
    \"workingdir\": \"/Users/gerben\",
    \"machinename\": \"lunaservices\",
    \"enabled\": false
  }
}\n
""" +
"This script was written by: " + AUTHOR +
"\nThis is version: " + VERSION +
"\n" + LICENSE +
"\nThe command used is: " + DOCKERMACHINECOMMAND)
parser.add_argument( "-v", "--verbosity", action="count", default=0,
                     help="Increase output verbosity (5 is maximum effect)")
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 20 seconds until the required VM provider"
                     " becomes available or the maximum wait time is met. Note that this is  implemented"
                     " per VM provider so in the worst case the program will try for number of"
                     " providers times the maximum wait time. This argument is ignored"
                     " when the action is not 'start'."))
parser.add_argument( "--only", nargs="*", dest="VMDeclarations_Machines_Subset",
                     metavar="machine",
                     help="Restrict actions to these machine names only. Not yet implemented.")
parser.add_argument( "VMDeclarations_files", metavar="JSON_file", nargs="+",
                     help=("JSON file(s) with Docker Machine launch definitions."
                     " See description above."))
parser.add_argument( "action", choices=['start','stop'], nargs=1,
                     help=("Action that is taken. Either start or stop the machine(s)."))
scriptargs = parser.parse_args()

PROGNAME=sys.argv[0]
VERBOSITY=scriptargs.verbosity

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

def log( message):
    print( "[" + PROGNAME + " " + time.asctime() + "] " + message)

def CheckVMProvider( vmservice):
    if vmservice == 'virtualbox':
        if vmservices['virtualbox']:
            return True
        waited=0
        while waited <= scriptargs.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:
                vmservices['virtualbox'] = True
                return True
            waited = waited + STANDARDRETRY
            if waited < scriptargs.maxwait:
                if VERBOSITY > 1: log( "Virtual machine provider " + vmservice + " is not (yet) available. Sleeping " + str(STANDARDRETRY) + "sec and retrying...")
                time.sleep( STANDARDRETRY)
            else:
                if VERBOSITY > 1: log( "Virtual machine provider " + vmservice + " is not available. Giving up.")
    else:
        if VERBOSITY > 1: log( "Virtual machine provider " + vmservice + " 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():
        report_ids( 'starting demotion')
        os.setgid( user_gid)
        os.setuid( user_uid)
        report_ids( 'finished demotion')
    return result

def manageDockerMachine( entryname, definition):
    displayname = definition['displayname']
    enabled     = definition['enabled']
    user        = definition['user']
    workingdir  = definition['workingdir']
    vmservice   = definition['vmservice']
    machinename = definition['machinename']
    pw_record = pwd.getpwnam( user)
    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['HOME']    = homedir
    env['USER']    = username
    env['PWD']     = workingdir
    env['LOGNAME'] = username
    dmargs = [DOCKERMACHINECOMMAND, scriptargs.action[0], machinename]
    if enabled:
        if VERBOSITY > 2: log( "Starting " + vmservice + " docker machine " + machinename + " for user " + username)
        if not CheckVMProvider( vmservice):
            log( "Virtual machine provider " + vmservice + " 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))
    else:
        if VERBOSITY > 3: log( "Ignoring disabled " + vmservice + " docker machine " + machinename + " of user " + user)
    return True

for file in scriptargs.VMDeclarations_files:
    if VERBOSITY > 1: 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 machinedefinitionname in list( machinedefinitions):
        manageDockerMachine( machinedefinitionname, machinedefinitions[machinedefinitionname])

A couple of things need to be done yet. E.g. the --only flag has not been implemented yet. Layout. The script is ready for more VM providers than VirtualBox (just add it and add the test to see if it has been loaded).

gctwnl
  • 131
  • 5