5 Common Pitfalls to Avoid When Deploying Services in Bulk with Ansible

In the field of operations automation, Ansible has become a powerful tool for bulk service deployment due to its agentless architecture, declarative syntax, and rich modules. However, even experienced operations personnel often stumble over seemingly simple details during actual operations. This article, based on over 200 production environment deployment experiences in the past three years, dissects five frequently encountered pitfalls, each accompanied by real-world scenario restoration, error code analysis, and practical solutions validated through experience.

1. Template Variable Conflicts: The Culprit Behind Sudden “Garbage” in Configuration Files

Scenario Restoration: A certain e-commerce team used Ansible to deploy a payment gateway, where everything worked fine in the test environment, but the production environment frequently had the database password in the configuration file replaced with the plaintext <span>{{ db_pass }}</span>. Upon investigation, it was found that developers used both Jinja2 variables (<span>{{ }}</span>) and Nginx configuration variables (like <span>${host}</span>), leading to parsing confusion during rendering.

Root Cause of the Problem:

The Jinja2 template engine in Ansible uses <span>{{ }}</span> as the default variable delimiter. If there are variable syntaxes from other tools (like Shell scripts’ <span>$var</span> or Python’s <span>{{ }}</span>), it can trigger unexpected variable replacements. This is especially problematic when the template contains multiple nested variables (like <span>{{ app_config.{{ env }}.port }}</span>), as the parser will throw a <span>TemplateSyntaxError</span> directly.

Solution:

  1. 1. Escape Variable Delimiters Use <span>{% raw %}</span> and <span>{% endraw %}</span> to wrap non-Ansible variables, for example, the reverse proxy address in Nginx configuration:
location / {
    {% raw %}
    proxy_pass http://
    ${upstream_server};
    {% endraw %}
}
  1. 1. Custom Variable Delimiters Modify the delimiters in the playbook using <span>variable_start_string</span> and <span>variable_end_string</span> to avoid conflicts:
- name: Deploy configuration with special variables
  template:
    src: app.conf.j2
    dest: /etc/app.conf
    variable_start_string: '[['
    variable_end_string: ']]'
  1. 1. Use<span>|default</span> to Avoid Empty Variables Add default values for potentially undefined variables to prevent empty values during rendering:
db_password: {{ db_pass | default('default_password') }}

Pitfall Avoidance Tip: Execute <span>ansible-playbook --syntax-check</span> before submitting the template to check syntax, and use <span>ansible -m template -a "src=test.j2 dest=/tmp/test"</span> for single-node rendering tests to detect variable conflicts in advance.

2. Asynchronous Task Timeout: Deployment Stuck in “Running” for 30 Minutes

Scenario Restoration: A gaming company used Ansible to deploy server patches in bulk, and out of 100 nodes, 30 always showed <span>async task running</span>, which were marked as failed after timing out. However, upon logging into the nodes, it was found that the patches had actually been installed. The issue was due to the <span>poll</span> parameter of the asynchronous task not being set correctly, causing Ansible to terminate checks prematurely.

Root Cause of the Problem:

The <span>async</span> parameter in Ansible defines the maximum runtime of a task (in seconds), while the <span>poll</span> parameter defines the check interval. By default, if <span>poll: 0</span> is not set, Ansible will block and wait for the task to complete. If the task takes longer than the <span>async</span> value, it will be deemed a failure even if it actually succeeded. A common misuse is:

- name: Install large package
  yum:
    name: bigpackage
    state: present
    async: 3600  # Maximum runtime of 1 hour
    # Missing poll parameter, defaults to checking every 15 seconds, which may misjudge timeout due to network latency

Solution:

  1. 1. Non-blocking Asynchronous Mode For long-running tasks (like database migrations or large file transfers), set <span>poll: 0</span> and use <span>async_status</span> to poll results:
- name: Asynchronously execute database migration
  command: /opt/db/migrate.sh
  async: 7200  # Maximum runtime of 2 hours
  poll: 0
  register: migrate_task

- name: Poll migration results
  async_status:
    jid: "{{ migrate_task.ansible_job_id }}"
  register: job_result
  until: job_result.finished
  retries: 30  # Maximum retries of 30
  delay: 60    # Check every 60 seconds
  1. 1. Adjust Timeout by Task Type Different tasks require differentiated <span>async</span> values:
  • • Package installation: 300-900 seconds (depending on package size)
  • • System upgrades: over 3600 seconds
  • • Configuration file generation: usually does not require async (time < 10 seconds)
  1. 1. Capture Asynchronous Task Output The standard output of asynchronous tasks is not automatically saved; it must be captured using <span>register</span> along with the <span>stdout</span> parameter:
- name: Asynchronously execute script and capture output
  script: long_running_script.sh
  async: 1800
  poll: 0
  register: script_result

- name: Get output
  async_status:
    jid: "{{ script_result.ansible_job_id }}"
  register: final_result
  until: final_result.finished

Pitfall Avoidance Tip: Check the default timeout configuration using <span>ansible-config dump | grep DEFAULT_TIMEOUT</span>. It is recommended to set <span>timeout = 60</span> (default is 10 seconds) in the <span>ansible.cfg</span> for production environments to avoid connection timeouts due to network fluctuations.

3. Permission Inheritance Trap: Using Become but Still Getting Permission Denied

Scenario Restoration: A certain team deployed Elasticsearch and set <span>become: yes</span> in the playbook, but still encountered <span>/usr/share/elasticsearch/data: Permission denied</span>. Upon checking, it was found that when using the <span>copy</span> module to copy configuration files, even though administrator privileges were used, the file owner was incorrectly set to <span>root</span>, while Elasticsearch required the data directory to be owned by the <span>elasticsearch</span> user.

Root Cause of the Problem:

The <span>become</span> mechanism in Ansible only elevates task execution privileges and does not automatically handle file permission inheritance. Common misconceptions include:

  • • Assuming <span>become: yes</span> will make all operations default to root privileges (it actually needs to be combined with <span>become_user</span>)
  • • Ignoring the <span>owner</span> / <span>group</span> parameters of the <span>copy</span> / <span>template</span> modules, leading to file permissions not matching the service running user
  • • Executing <span>su - user</span> in the <span>shell</span> module without enabling a login shell via <span>args: executable: /bin/bash</span>, resulting in lost environment variables

Solution:

  1. 1. Refined Permission Control Clearly define the <span>become</span> hierarchy and handle tasks requiring user-level permissions separately:
- name: Create data directory (root privileges)
  file:
    path: /var/lib/appdata
    state: directory
    mode: '0755'
    become: yes

- name: Deploy application configuration (application user privileges)
  template:
    src: app.conf.j2
    dest: /home/appuser/app.conf
    owner: appuser
    group: appuser
    become: yes
    become_user: appuser  # Switch to application user for execution
  1. 1. <span>shell</span> Module Permission Specification When needing to execute user-level commands in <span>shell</span>, use <span>become_user</span> + <span>args</span> to ensure the environment is correct:
- name: Execute initialization script as application user
  shell: |
    source ~/.bashrc
    ./init.sh --env prod
  args:
    chdir: /home/appuser
    executable: /bin/bash  # Enable login shell
    become: yes
    become_user: appuser
  1. 1. Permission Check Tasks Add permission verification after critical steps, such as checking directory permissions after deployment:
- name: Verify data directory permissions
  stat:
    path: /var/lib/appdata
  register: dir_stat

- name: Assert correct directory owner
  assert:
    that:
      - dir_stat.stat.pw_name == 'appuser'
    fail_msg: "Data directory owner is incorrect, should be appuser"

Pitfall Avoidance Tip: Use <span>ansible-doc <module></span> to check if the module supports <span>become</span> (all core modules do, while third-party modules need additional testing for permission inheritance).

4. Module Idempotence Loss: Repeated Execution Causes Service Anomalies

Scenario Restoration: A financial institution used Ansible to deploy an API gateway, and due to a network interruption, after retrying the playbook, multiple Systemd services with the same name appeared on the server. Investigation revealed that the deployment task used <span>shell: cp gateway.service /etc/systemd/system/</span> instead of the <span>copy</span> module, and did not check if the service already existed, leading to conflicts from repeated copies.

Root Cause of the Problem:

The core advantage of Ansible is idempotence (consistent results on repeated execution), but the following situations can break this feature:

  • • Misusing <span>shell</span> / <span>command</span> modules to perform non-idempotent operations (like <span>cp</span>, <span>mv</span>, <span>rm</span>)
  • • Not using <span>creates</span> / <span>removes</span> parameters to prevent repeated execution
  • • Using <span>when</span> conditions with <span>changed_when: false</span> to mask state changes
  • • Executing <span>INSERT</span> on databases instead of <span>INSERT IGNORE</span> (without primary key checks)

Solution:

  1. 1. Prioritize Using Core Modules Replace <span>shell</span> commands with idempotent modules, for example:
  • • Replace <span>shell: echo "max_open_files=1024" >> /etc/security/limits.conf</span><span>lineinfile: path=/etc/security/limits.conf line="* hard nofile 1024"</span>
  • • Replace <span>shell: systemctl start nginx</span><span>service: name=nginx state=started</span> (will not repeat execution if already started)
  1. 1. <span>shell</span> Module Idempotence Transformation If using <span>shell</span> is necessary, control execution conditions using <span>creates</span> / <span>removes</span>:
- name: Initialize database (only execute first time)
  shell: /opt/db/init_db.sh
  args:
    creates: /var/lib/db/initialized  # Do not execute if this file exists
    become: yes
  1. 1. State Judgment Optimization Use <span>register</span> and <span>when</span> to implement conditional execution, avoiding repeated operations:
- name: Check if service is already deployed
  stat:
    path: /usr/bin/appservice
  register: service_exists

- name: Install only if service is not deployed
  yum:
    name: appservice
    state: present
    when: not service_exists.stat.exists

Pitfall Avoidance Tip: Add the <span>--check</span> parameter before executing the playbook for a dry run, observing whether the <span>changed</span> state meets expectations. For critical tasks, it is recommended to implement a “change only if there is a change” mechanism in the <span>handler</span> using the <span>listen</span> mechanism (e.g., restart services after configuration file updates).

5. Inventory Dynamic Refresh Failure: New Nodes Always “Not Found”

Scenario Restoration: A cloud-native team managed AWS instances through Ansible dynamic inventory (<span>ec2.py</span>), and after expanding new nodes, executing <span>ansible all -m ping</span> always failed to recognize the new nodes. Investigation revealed that the dynamic inventory cache was not refreshed in time, and Ansible was still using the node list from an hour ago.

Root Cause of the Problem:

Ansible’s inventory caches host information by default (default cache time is 300 seconds). When using dynamic inventory (like interfacing with cloud vendor APIs or CMDB systems), if the cache is not cleared, it can lead to new nodes not being discovered or already offline nodes still being managed. Common errors include:

  • • Not setting <span>inventory_cache_expiration</span>, leading to long-term cache validity
  • • Dynamic inventory scripts lacking permissions to obtain the latest node information
  • • Switching between multi-environment inventories (like <span>prod</span>/<span>test</span>) without specifying the <span>-i</span> parameter, mistakenly using the default inventory

Solution:

  1. 1. Cache Strategy Configuration Set reasonable cache parameters in <span>ansible.cfg</span>:
[inventory]
cache_enabled = True
cache_type = jsonfile
cache_dir = ~/.ansible/inventory_cache
cache_expiration = 60  # Cache validity period of 60 seconds (set to 0 for high-frequency change environments)
  1. 1. Force Refresh Inventory Add the <span>--flush-cache</span> parameter when executing commands to clear the cache:
ansible-playbook -i ec2.py deploy.yml --flush-cache
  1. 1. Multi-environment Inventory Management Organize inventory using directory structures and specify environments through <span>-i</span>:
inventory/
├── prod/
│   ├── hosts
│   └── group_vars/
└── test/
    ├── hosts
    └── group_vars/

Clearly specify the environment during deployment:

ansible-playbook -i inventory/prod deploy_prod.yml
  1. 1. Dynamic Inventory Health Check Add scheduled tasks to verify the validity of dynamic inventory:
- name: Check dynamic inventory availability
  command: ./ec2.py --list
  args:
    chdir: /etc/ansible/inventory
  register: inventory_check
  failed_when: "'error' in inventory_check.stderr"

Pitfall Avoidance Tip: Use <span>ansible-inventory --list</span> to view the currently loaded host list, comparing it with <span>ansible-inventory --graph</span> for topology structure to confirm that groups and variables are correctly applied. For cloud environments, it is recommended to combine dynamic inventory with the <span>wait_for</span> module to ensure new nodes are ready for SSH services before executing deployment.

Summary: Three Principles for Building a Reliable Ansible Deployment System

  1. 1. Layered Verification Principle: Embed <span>assert</span>, <span>failed_when</span>, and other assertions in the playbook to achieve full-link verification of “environment check before deployment → step validation during deployment → status confirmation after deployment”.
  2. 2. Least Privilege Principle: Avoid using <span>become: yes</span> globally; allocate permissions based on actual task needs. For sensitive operations (like database changes), it is recommended to add <span>tags: risky</span> for extra confirmation during execution.
  3. 3. Documentation as Code Principle: For complex playbooks, retain original comments using <span>!unsafe</span> tags, and combine with <span>ansible-doc</span> to generate module descriptions, ensuring team members can quickly understand task logic.

Many of the pitfalls in Ansible stem from neglecting the differences between “declarative syntax” and “actual execution logic”. Remember: good Ansible code should resemble an executable operations manual—clear and readable, while also resilient against the side effects of repeated execution.

Finally, I recommend two practical tools: <span>ansible-lint</span> (static code analysis) and <span>molecule</span> (automated testing framework), which can help intercept 80% of common issues before deployment, making bulk deployment truly a “worry-free task”.

Since you’ve read this far, if you found it helpful, please give it a like, share, and follow. If you want to receive updates promptly, you can also star me. Thank you for reading my article, and see you next time.

Leave a Comment