diff --git a/etc/nixos/configuration.nix b/etc/nixos/configuration.nix index 81fa855..c556c39 100644 --- a/etc/nixos/configuration.nix +++ b/etc/nixos/configuration.nix @@ -5,6 +5,8 @@ ./hardware-configuration.nix ./linode.nix + + ./modules/networked-docker-containers.nix ]; networking = { diff --git a/etc/nixos/modules/networked-docker-containers.nix b/etc/nixos/modules/networked-docker-containers.nix new file mode 100644 index 0000000..2762c8e --- /dev/null +++ b/etc/nixos/modules/networked-docker-containers.nix @@ -0,0 +1,306 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.networked-docker-containers; + + networkedDockerContainer = + { ... }: { + + options = { + + image = mkOption { + type = with types; str; + description = "Docker image to run."; + example = "library/hello-world"; + }; + + imageFile = mkOption { + type = with types; nullOr package; + default = null; + description = '' + Path to an image file to load instead of pulling from a registry. + If defined, do not pull from registry. + + You still need to set the image attribute, as it + will be used as the image name for docker to start a container. + ''; + example = literalExample "pkgs.dockerTools.buildDockerImage {...};"; + }; + + cmd = mkOption { + type = with types; listOf str; + default = []; + description = "Commandline arguments to pass to the image's entrypoint."; + example = literalExample '' + ["--port=9000"] + ''; + }; + + entrypoint = mkOption { + type = with types; nullOr str; + description = "Overwrite the default entrypoint of the image."; + default = null; + example = "/bin/my-app"; + }; + + environment = mkOption { + type = with types; attrsOf str; + default = {}; + description = "Environment variables to set for this container."; + example = literalExample '' + { + DATABASE_HOST = "db.example.com"; + DATABASE_PORT = "3306"; + } + ''; + }; + + log-driver = mkOption { + type = types.str; + default = "none"; + description = '' + Logging driver for the container. The default of + "none" means that the container's logs will be + handled as part of the systemd unit. Setting this to + "journald" will result in duplicate logging, but + the container's logs will be visible to the docker + logs command. + + For more details and a full list of logging drivers, refer to the + + Docker engine documentation + ''; + }; + + networks = mkOption { + type = with types; listOf str; + default = []; + description = '' + Docker networks to create and connect this container to. + + The first network in this list will be connected with + --network=, others after container + creation with docker network connect. + + Any networks will be created if they do not exist before + the container is started. + ''; + }; + + ports = mkOption { + type = with types; listOf str; + default = []; + description = '' + Network ports to publish from the container to the outer host. + + Valid formats: + + + + + <ip>:<hostPort>:<containerPort> + + + + + <ip>::<containerPort> + + + + + <hostPort>:<containerPort> + + + + + <containerPort> + + + + + Both hostPort and + containerPort can be specified as a range of + ports. When specifying ranges for both, the number of container + ports in the range must match the number of host ports in the + range. Example: 1234-1236:1234-1236/tcp + + When specifying a range for hostPort only, the + containerPort must not be a + range. In this case, the container port is published somewhere + within the specified hostPort range. Example: + 1234-1236:1234/tcp + + Refer to the + + Docker engine documentation for full details. + ''; + example = literalExample '' + [ + "8080:9000" + ] + ''; + }; + + user = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Override the username or UID (and optionally groupname or GID) used + in the container. + ''; + example = "nobody:nogroup"; + }; + + volumes = mkOption { + type = with types; listOf str; + default = []; + description = '' + List of volumes to attach to this container. + + Note that this is a list of "src:dst" strings to + allow for src to refer to + /nix/store paths, which would difficult with an + attribute set. There are also a variety of mount options available + as a third field; please refer to the + + docker engine documentation for details. + ''; + example = literalExample '' + [ + "volume_name:/path/inside/container" + "/path/on/host:/path/inside/container" + ] + ''; + }; + + workdir = mkOption { + type = with types; nullOr str; + default = null; + description = "Override the default working directory for the container."; + example = "/var/lib/hello_world"; + }; + + dependsOn = mkOption { + type = with types; listOf str; + default = []; + description = '' + Define which other containers this one depends on. They will be added to both After and Requires for the unit. + + Use the same name as the attribute under services.docker-containers. + ''; + example = literalExample '' + services.docker-containers = { + node1 = {}; + node2 = { + dependsOn = [ "node1" ]; + } + } + ''; + }; + + extraDockerOptions = mkOption { + type = with types; listOf str; + default = []; + description = "Extra options for docker run."; + example = literalExample '' + ["--network=host"] + ''; + }; + }; + }; + + mkService = name: container: let + mkAfter = map (x: "docker-${x}.service") container.dependsOn; + in + rec { + wantedBy = [ "multi-user.target" ]; + after = [ "docker.service" "docker.socket" "docker-networks.service" ] ++ mkAfter; + requires = after; + + serviceConfig = { + ExecStart = [ "${pkgs.docker}/bin/docker start -a ${name}" ]; + + ExecStartPre = [ + "-${pkgs.docker}/bin/docker rm -f ${name}" + "-${pkgs.docker}/bin/docker image prune -f" + ] ++ ( + optional (container.imageFile != null) + [ "${pkgs.docker}/bin/docker load -i ${container.imageFile}" ] + ) ++ [ + ( + concatStringsSep " \\\n " ( + [ + "${pkgs.docker}/bin/docker create" + "--rm" + "--name=${name}" + "--log-driver=${container.log-driver}" + ] ++ optional (container.entrypoint != null) + "--entrypoint=${escapeShellArg container.entrypoint}" + ++ (mapAttrsToList (k: v: "-e ${escapeShellArg k}=${escapeShellArg v}") container.environment) + ++ map (p: "-p ${escapeShellArg p}") container.ports + ++ optional (container.user != null) "-u ${escapeShellArg container.user}" + ++ map (v: "-v ${escapeShellArg v}") container.volumes + ++ optional (container.workdir != null) "-w ${escapeShellArg container.workdir}" + ++ optional (container.networks != []) "--network=${escapeShellArg (builtins.head container.networks)}" + ++ map escapeShellArg container.extraDockerOptions + ++ [ container.image ] + ++ map escapeShellArg container.cmd + ) + ) + ] ++ map (n: "${pkgs.docker}/bin/docker network connect ${escapeShellArg n} ${name}") (drop 1 container.networks); + + ExecStop = ''${pkgs.bash}/bin/sh -c "[ $SERVICE_RESULT = success ] || ${pkgs.docker}/bin/docker stop ${name}"''; + ExecStopPost = "-${pkgs.docker}/bin/docker rm -f ${name}"; + + ### There is no generalized way of supporting `reload` for docker + ### containers. Some containers may respond well to SIGHUP sent to their + ### init process, but it is not guaranteed; some apps have other reload + ### mechanisms, some don't have a reload signal at all, and some docker + ### images just have broken signal handling. The best compromise in this + ### case is probably to leave ExecReload undefined, so `systemctl reload` + ### will at least result in an error instead of potentially undefined + ### behaviour. + ### + ### Advanced users can still override this part of the unit to implement + ### a custom reload handler, since the result of all this is a normal + ### systemd service from the perspective of the NixOS module system. + ### + # ExecReload = ...; + ### + + TimeoutStartSec = 0; + TimeoutStopSec = 120; + Restart = "no"; + }; + }; + +in +{ + + options.networked-docker-containers = mkOption { + default = {}; + type = types.attrsOf (types.submodule networkedDockerContainer); + description = "Docker containers to run as systemd services."; + }; + + config = mkIf (cfg != {}) { + + systemd.services = mapAttrs' (n: v: nameValuePair "docker-${n}" (mkService n v)) cfg // { + "docker-networks" = rec { + after = [ "docker.service" "docker.socket" ]; + requires = after; + + serviceConfig = { + Type = "oneshot"; + ExecStart = map ( + n: ''${pkgs.bash}/bin/sh -c "${pkgs.docker}/bin/docker network inspect ${escapeShellArg n} > /dev/null || \ + ${pkgs.docker}/bin/docker network create ${escapeShellArg n}"'' + ) (unique (flatten (mapAttrsToList (_: c: c.networks) cfg))); + }; + }; + }; + + virtualisation.docker.enable = true; + }; +}