Automate Rootless Docker Updates with Ansible

Rootless Docker is a significant leap forward for container security, effectively mitigating the risks of privilege escalation by running the Docker daemon and containers within a user’s namespace. However, this security advantage introduces operational complexity. Standard, system-wide automation tools like Ansible, which are accustomed to managing privileged system services, must be adapted to this user-centric model. Manually SSH-ing into servers to run apt upgrade as a specific user is not a scalable or secure solution.

This guide provides a production-ready Ansible playbook and the expert-level context required to automate rootless Docker updates. We will bypass the common pitfalls of environment variables and systemd --user services, creating a reliable, idempotent automation workflow fit for production.

Why Automate Rootless Docker Updates?

While “rootless” significantly reduces the attack surface, the Docker daemon itself is still a complex piece of software. Security vulnerabilities can and do exist. Automating updates ensures:

  • Rapid Security Patching: C-V-E-s affecting the Docker daemon or its components can be patched across your fleet without manual intervention.
  • Consistency and Compliance: Ensures all environments are running the same, approved version of Docker, simplifying compliance audits.
  • Reduced Toil: Frees SREs and DevOps engineers from the repetitive, error-prone task of manual updates, especially in environments with many hosts.

The Core Challenge: Rootless vs. Traditional Automation

With traditional (root-full) Docker, Ansible’s job is simple. It connects as root (or uses become) and manages the docker service via system-wide systemd. With rootless, Ansible faces three key challenges:

1. User-Space Context

The rootless Docker daemon doesn’t run as PID 1‘s systemd. It runs as a systemd --user service under the specific, unprivileged user account. Ansible must be instructed to operate within this user’s context.

2. Environment Variables (DOCKER_HOST)

The Docker CLI (and Docker Compose) relies on environment variables like DOCKER_HOST and XDG_RUNTIME_DIR to find the user-space daemon socket. While our automation will primarily interact with the systemd service, tasks that validate the daemon’s health must be aware of this.

3. Service Lifecycle and Lingering

systemd --user services, by default, are tied to the user’s login session. If the user logs out, their systemd instance and the rootless Docker daemon are terminated. For a server process, this is unacceptable. The user must be configured for “lingering” to allow their services to run at boot without a login session.

Building the Ansible Playbook to Automate Rootless Docker Updates

Let’s build the playbook step-by-step. Our goal is a single, idempotent playbook that can be run repeatedly. This playbook assumes you have already installed rootless Docker for a specific user.

We will define our target user in an Ansible variable, docker_rootless_user.

Step 1: Variables and Scoping

We must target the host and define the user who owns the rootless Docker installation. We also need to explicitly tell Ansible to use privilege escalation (become: yes) not to become root, but to become the target user.

---
- name: Update Rootless Docker
  hosts: docker_hosts
  become: yes
  vars:
    docker_rootless_user: "docker-user"

  tasks:
    # ... tasks will go here ...

πŸ’‘ Advanced Concept: become_user vs. remote_user

Your remote_user (in ansible.cfg or -u flag) is the user Ansible SSHes into the machine as (e.g., ansible, ec2-user). This user typically has passwordless sudo. We use become: yes and become_user: {{ docker_rootless_user }} to switch from the ansible user to the docker-user to run our tasks. This is crucial.

Step 2: Ensure User Lingering is Enabled

This is the most common failure point. Without “lingering,” the systemd --user instance won’t start on boot. This task runs as root (default become) to execute loginctl.

    - name: Enable lingering for {{ docker_rootless_user }}
      command: "loginctl enable-linger {{ docker_rootless_user }}"
      args:
        creates: "/var/lib/systemd/linger/{{ docker_rootless_user }}"
      become_user: root # This task must run as root
      become: yes

We use the creates argument to make this task idempotent. It will only run if the linger file doesn’t already exist.

Step 3: Update the Docker Package

This task updates the docker-ce (or relevant) package. This task also needs to run with root privileges, as it’s installing system-wide binaries.

    - name: Update Docker CE package
      ansible.builtin.package:
        name: docker-ce
        state: latest
      become_user: root # Package management requires root
      become: yes
      notify: Restart rootless docker service

Note the notify keyword. We are separating the package update from the service restart. This is a core Ansible best practice.

Step 4: Manage the Rootless systemd Service

This is the core of the automation. We define a handler that will be triggered by the update task. This handler *must* run as the docker_rootless_user and use the scope: user setting in the ansible.builtin.systemd module.

First, we need to gather the user’s XDG_RUNTIME_DIR, as systemd --user needs it.

    - name: Get user XDG_RUNTIME_DIR
      ansible.builtin.command: "printenv XDG_RUNTIME_DIR"
      args:
        chdir: "/home/{{ docker_rootless_user }}"
      changed_when: false
      become: yes
      become_user: "{{ docker_rootless_user }}"
      register: xdg_dir

    - name: Set DOCKER_HOST fact
      ansible.builtin.set_fact:
        user_xdg_runtime_dir: "{{ xdg_dir.stdout }}"
        user_docker_host: "unix://{{ xdg_dir.stdout }}/docker.sock"

  handlers:
    - name: Restart rootless docker service
      ansible.builtin.systemd:
        name: docker
        state: restarted
        scope: user
      become: yes
      become_user: "{{ docker_rootless_user }}"
      environment:
        XDG_RUNTIME_DIR: "{{ user_xdg_runtime_dir }}"

By using scope: user, we tell Ansible to talk to the user’s systemd bus, not the system-wide one. Passing the XDG_RUNTIME_DIR in the environment ensures the systemd command can find the user’s runtime environment.

The Complete, Production-Ready Ansible Playbook

Here is the complete playbook, combining all elements with handlers and correct user context switching.

---
- name: Automate Rootless Docker Updates
  hosts: docker_hosts
  become: yes
  vars:
    docker_rootless_user: "docker-user" # Change this to your user

  tasks:
    - name: Ensure lingering is enabled for {{ docker_rootless_user }}
      ansible.builtin.command: "loginctl enable-linger {{ docker_rootless_user }}"
      args:
        creates: "/var/lib/systemd/linger/{{ docker_rootless_user }}"
      become_user: root # Must run as root
      changed_when: false # This command's output isn't useful for change status

    - name: Update Docker packages (CE, CLI, Buildx)
      ansible.builtin.package:
        name:
          - docker-ce
          - docker-ce-cli
          - containerd.io
          - docker-buildx-plugin
          - docker-compose-plugin
        state: latest
      become_user: root # Package management requires root
      notify: Get user environment and restart rootless docker

  handlers:
    - name: Get user environment and restart rootless docker
      block:
        - name: Get user XDG_RUNTIME_DIR
          ansible.builtin.command: "printenv XDG_RUNTIME_DIR"
          args:
            chdir: "/home/{{ docker_rootless_user }}"
          changed_when: false
          register: xdg_dir

        - name: Fail if XDG_RUNTIME_DIR is not set
          ansible.builtin.fail:
            msg: "XDG_RUNTIME_DIR is not set for {{ docker_rootless_user }}. Is the user logged in or lingering enabled?"
          when: xdg_dir.stdout | length == 0

        - name: Set user_xdg_runtime_dir fact
          ansible.builtin.set_fact:
            user_xdg_runtime_dir: "{{ xdg_dir.stdout }}"

        - name: Force daemon-reload for user systemd
          ansible.builtin.systemd:
            daemon_reload: yes
            scope: user
          environment:
            XDG_RUNTIME_DIR: "{{ user_xdg_runtime_dir }}"

        - name: Restart rootless docker service
          ansible.builtin.systemd:
            name: docker
            state: restarted
            scope: user
          environment:
            XDG_RUNTIME_DIR: "{{ user_xdg_runtime_dir }}"
            
      # This entire block runs as the target user
      become: yes
      become_user: "{{ docker_rootless_user }}"
      listen: "Get user environment and restart rootless docker"

πŸ’‘ Pro-Tip: Validating the Update

To verify the update, you can add a final task that runs docker version *as the rootless user*. This confirms both the package update and the service health.

  post_tasks:
    - name: Verify rootless Docker version
      ansible.builtin.command: "docker version"
      become: yes
      become_user: "{{ docker_rootless_user }}"
      environment:
        DOCKER_HOST: "unix://{{ user_xdg_runtime_dir }}/docker.sock"
      register: docker_version
      changed_when: false

    - name: Display new Docker version
      ansible.builtin.debug:
        msg: "{{ docker_version.stdout }}"

Frequently Asked Questions (FAQ)

How do I run Ansible tasks as a non-root user for rootless Docker?

You use become: yes combined with become_user: your-user-name. This tells Ansible to use its privilege escalation method (like sudo) to switch to that user account, rather than to root.

What is `loginctl enable-linger` and why is it mandatory?

Linger instructs systemd-logind to keep a user’s session active even after they log out. This allows the systemd --user instance to start at boot and run services (like docker.service) persistently. Without it, the rootless Docker daemon would stop the moment your Ansible session (or any SSH session) closes.

How does this playbook handle the `DOCKER_HOST` variable?

This playbook correctly avoids relying on a pre-set DOCKER_HOST. Instead, it interacts with the systemd --user service directly. For the validation task, it explicitly sets the DOCKER_HOST environment variable using the XDG_RUNTIME_DIR fact it discovers, ensuring the docker CLI can find the correct socket.

Automate Rootless Docker Updates with Ansible

Conclusion

Automating rootless Docker is not as simple as its root-full counterpart, but it’s far from impossible. By understanding that rootless Docker is a user-space application managed by systemd --user, we can adapt our automation tools.

This Ansible playbook provides a reliable, idempotent, and production-safe method to automate rootless Docker updates. It respects the user-space context, correctly handles the systemd user service, and ensures the critical “lingering” prerequisite is met. By adopting this approach, you can maintain the high-security posture of rootless Docker without sacrificing the operational efficiency of automated fleet management. Thank you for reading theΒ DevopsRolesΒ page!

,

About HuuPV

My name is Huu. I love technology, especially Devops Skill such as Docker, vagrant, git, and so forth. I like open-sources, so I created DevopsRoles.com to share the knowledge I have acquired. My Job: IT system administrator. Hobbies: summoners war game, gossip.
View all posts by HuuPV →

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.