안녕하세요. Twodragon 입니다.
가시다 님의 온라인 A101(Ansible 101 study) 강의에 대한 3번째 주차이며 시스템 구축 및 환경 설정 자동화를 위한 도전기 입니다!
Ansible을 사용한 사용자 계정 생성
Ansible을 사용하여 리모트 서버에 사용자 계정을 생성하고 암호를 설정하는 플레이북을 작성해보겠습니다. 이때 계정과 패스워드 정보는 Ansible Vault를 이용하여 암호화됩니다.
사전 분석
- 사용자 계정과 패스워드 정보는 Ansible Vault를 이용하여 암호화합니다.
- 사용자 계정 생성은 ansible.builtin.user 모듈을 사용합니다.
플레이북 설계
- 프로젝트 디렉터리 생성 및 ansible.cfg, inventory 파일 작성
- 사용자 계정 정보가 정의된 변수 파일을 생성
- 사용자 계정을 생성하는 플레이북 작성
플레이북 개발
1. 프로젝트 디렉터리 및 파일 생성
bashCopy code
# 프로젝트 디렉터리 생성
mkdir ~/my-ansible/chapter_09.1
cd ~/my-ansible/chapter_09.1
# ansible.cfg, inventory 파일 작성
cat <<EOT > ansible.cfg
[defaults]
inventory = ./inventory
remote_user = ubuntu
ask_pass = false
[privilege_escalation]
become = true
become_method = sudo
become_user = root
become_ask_pass = false
EOT
cat <<EOT> inventory
tnode1
tnode2
tnode3
EOT
2. 사용자 계정 정보가 정의된 변수 파일 생성
bashCopy code
# vault 암호는 편하게 입력
ansible-vault create vars/secret.yml
New Vault password: qwe123
Confirm New Vault password: qwe123
# 에디터 창으로 전환 : user_info 변수에 userid와 userpw가 같이 있는 사전형 변수를 정의
---
user_info:
- userid: "ansible"
userpw: "ansiblePw1"
- userid: "stack"
userpw: "stackPw1"
:wq
# 변수 파일 확인
ls -l vars/secret.yml
cat vars/secret.yml
3. 사용자 계정 생성하는 플레이북 작성
yamlCopy code
---
- hosts: all
# vault로 사용자 계정 관련 변수가 정의된 파일을 임포트하여 사용
vars_files:
- vars/secret.yml
tasks:
# loop 문을 사용하여 user_info의 userid와 userpw 사용
- name: Create user
ansible.builtin.user:
name: "{{ item.userid }}"
password: "{{ item.userpw | password_hash('sha512', 'mysecret') }}"
state: present
shell: /bin/bash
loop: "{{ user_info }}"
4. 플레이북 실행
bashCopy code
# 문법 체크
ansible-playbook --syntax-check create_user.yml
# 실행 : 외부 변수(-e)로 userid 정의하고 전달 실행
ansible-playbook -e userid=ansible create_user.yml --ask-vault-pass
# 계정 생성 확인
ansible -m shell -a "tail -n 3 /etc/passwd" all
이렇게 하면 Ansible을 사용하여 여러 호스트에 사용자 계정을 생성하는 플레이북을 구성할 수 있습니다. 이 플레이북은 Vault를 사용하여 보안성을 강화하며, 각 호스트에 동일한 계정을 생성합니다.
도전과제 1 - 실습 시 Vault에 AWS SecretManager를 활용하기
도전과제 1에서는 Ansible Vault를 사용하여 AWS SecretManager에서 비밀 정보를 가져와 사용하는 방법을 적용해보겠습니다.
- AWS SecretManager에 시크릿 생성
- 먼저 AWS SecretManager에서 Ansible이 사용할 시크릿을 생성합니다. 여기서는 ansible_secrets라는 이름의 시크릿을 만들어 userid와 userpw를 저장합니다.
- 시크릿 정보 가져오기
- Ansible Vault에서 시크릿 정보를 가져올 때는 lookup 함수를 사용합니다. 시크릿을 가져오는 플레이북에서 lookup 함수를 사용하여 AWS SecretManager에서 값을 가져오고, 해당 값을 변수에 할당합니다.
- Vault 암호화된 변수 파일 수정
- 앞서 가져온 SecretManager의 정보를 이용하여 Vault 암호화된 변수 파일을 수정합니다.
- 플레이북 실행
- 최종적으로 수정된 Vault 암호화된 변수 파일을 사용하여 플레이북을 실행합니다.
도전과제를 해결하기 위한 플레이북 예시는 다음과 같습니다:
yamlCopy code
---
- hosts: all
# AWS SecretManager에서 시크릿 정보를 가져와 변수에 할당
vars:
secret_info: "{{ lookup('aws_secret', 'ansible_secrets') }}"
# 가져온 정보를 변수에 할당하고 계정 생성
tasks:
- name: Assign secret values
set_fact:
user_info: "{{ secret_info | from_json }}"
- name: Create user
ansible.builtin.user:
name: "{{ item.userid }}"
password: "{{ item.userpw | password_hash('sha512', 'mysecret') }}"
state: present
shell: /bin/bash
loop: "{{ user_info }}"
위의 플레이북에서 lookup('aws_secret', 'ansible_secrets') 부분은 AWS SecretManager에서 ansible_secrets 시크릿을 가져오는 부분입니다. 이를 통해 Vault에 저장된 정보를 가져와 계정을 생성할 수 있습니다.
9.2 SSH Key 생성 및 복사하기
사전 분석
- 사용자 아이디는 외부 변수로 받는다.
- Ansible 서버에서 Ansible 계정을 만들고 SSH 키를 생성한다.
- Ansible 서버에 생성된 SSH 공개 키를 각 tnode에 복사한다.
- 계정을 생성할 때는 ansible.builtin.user 모듈을, SSH 공개 키를 복사할 때는 ansible.posix.authorized_key 모듈을 이용한다.
플레이북 설계
- Ansible 공식 문서의 콘텐츠 컬렉션에서 SSH 키 생성 및 복사에 필요한 모듈을 찾았다.
- 플레이북명은 **create_sshkey.yml**로 설정하고, ‘Create SSH key’ 태스크와 ‘Copy SSH Pub Key’라는 2개의 태스크를 갖는다.
- ‘Create SSH key’ 태스크는 localhost에서 실행하고, ‘Copy SSH Pub Key’ 태스크는 tnode에서 실행한다.
- 인벤토리에는 tnode라는 그룹을 만들고, 모든 관리 노드를 tnode 그룹으로 정의한다.
플레이북 개발
1. 프로젝트 디렉터리 생성 및 inventory 파일 작성
bashCopy code
mkdir ~/my-ansible/chapter_09.2
cd ~/my-ansible/chapter_09.2
cp ~/my-ansible/ansible.cfg ./
cat <<EOT> inventory
[tnode]
tnode1
tnode2
tnode3
EOT
ansible-doc -l -t lookup
2. SSH 키 생성 및 복사 플레이북 작성 (create_sshkey.yml)
bashCopy code
---
- hosts: localhost
tasks:
- name: Create SSH key
ansible.builtin.user:
name: "{{ userid }}"
generate_ssh_key: true
ssh_key_bits: 2048
ssh_key_file: /home/{{ userid }}/.ssh/id_rsa
shell: /bin/bash
- hosts: tnode
tasks:
- name: Copy SSH Pub key
ansible.posix.authorized_key:
user: "{{ userid }}"
state: present
key: "{{ lookup('file', '/home/{{ userid }}/.ssh/id_rsa.pub') }}"
Sources
도전과제 2: Lookups 플러그인을 사용한 플레이북 작성
Lookups 플러그인을 사용하여 간단한 플레이북을 작성해보겠습니다. 이 예제에서는 파일에서 데이터를 조회하고, 조회한 데이터를 사용하여 사용자를 생성하는 작업을 수행하는 플레이북입니다.
lookup_example.yml
yamlCopy code
---
- hosts: localhost
tasks:
- name: Read user data from file
set_fact:
user_data: "{{ lookup('file', '/path/to/user_data.txt') }}"
- name: Create user from lookup data
ansible.builtin.user:
name: "{{ user_data.split(':')[0] }}"
uid: "{{ user_data.split(':')[1] }}"
state: present
이 플레이북은 localhost에서 실행되며, 먼저 파일에서 데이터를 읽어와서 변수 user_data에 저장한 후, 이 데이터를 이용하여 사용자를 생성합니다.
참조 : Lookups Plugin 문서
9.3 NTP 서버 설치 및 설정하기 - Lookups 플러그인을 활용한 플레이북
사전 분석
- NTP 서버 주소는 메인 플레이북에서 정의한다.
- 운영체제가 Ubuntu면 apt 모듈을 사용하여 chrony를 설치한다.
- 운영체제가 CentOS/레드햇이면 dnf 모듈을 사용하여 chrony를 설치한다.
- Jinja2 템플릿 방식의 chrony.conf 파일을 대상 호스트로 복사한다.
- 설정 파일이 복사되면 chrony 서비스를 재시작한다.
- 롤을 이용하여 설계하고 작성한다.
플레이북 설계
- 이번 예제에서는 chrony를 서로 다른 운영체제에서 각각의 모듈을 이용하여 설치할 것이므로 롤을 생성하고 호출하는 방식으로 작성한다.
롤 구성에 필요한 플레이북
- 롤의 기본 구조를 생성하기 위해 ansible-galaxy 명령어를 사용한다.
bashCopy code
ansible-galaxy role init --init-path ./roles myrole.chrony
롤 디렉터리 구조
luaCopy code
roles/
|-- myrole.chrony/
| |-- defaults/
| |-- files/
| |-- handlers/
| |-- meta/
| |-- tasks/
| | |-- main.yml
| |-- templates/
| | |-- chrony.conf.j2
| |-- tests/
| | |-- inventory
| | |-- test.yml
| |-- vars/
| | |-- main.yml
변수 및 템플릿 설정
- vars/main.yml 파일을 통해 패키지 이름, 서비스 이름 및 Fedora 계열의 운영체제 정보를 설정한다.
yamlCopy code
# vars file for myrole.chrony
package_name : chrony
service_name : chronyd
fedora_os:
- RedHat
- CentOS
- templates/chrony.conf.j2 파일을 통해 외부에서 받은 ntp_server 변수를 사용하여 템플릿을 작성한다.
bashCopy code
# templates/chrony.conf.j2
pool {{ ntp_server }}
driftfile /var/lib/chrony/drift
makestep 1.0 3
rtcsync
allow 10.10.0.0/16
local stratum 10
keyfile /etc/chrony.keys
leapsectz right/UTC
logdir /var/log/chrony
핸들러 설정
- handlers/main.yml 파일에는 chrony 서비스를 재시작하는 핸들러를 설정한다.
yamlCopy code
# handlers file for myrole.chrony
- name: Restart chrony
ansible.builtin.service:
name: "{{ service_name }}"
state: restarted
운영체제별 태스크
- tasks/main.yml 파일에서는 운영체제별로 설치할 패키지를 정의하고 템플릿을 복사한 후 핸들러를 호출한다.
yamlCopy code
# tasks file for myrole.chrony
- name: Import playbook
ansible.builtin.include_tasks:
file: "{{ ansible_facts.distribution }}.yml"
- name: Copy chrony config file when Ubuntu
ansible.builtin.template:
src: chrony.conf.j2
dest: /etc/chrony/chrony.conf
notify: "Restart chrony"
when: ansible_facts.distribution == "Ubuntu"
- name: Copy chrony config file when Other OS
ansible.builtin.template:
src: chrony.conf.j2
dest: /etc/chrony.conf
notify: "Restart chrony"
when: ansible_facts.distribution in fedora_os
운영체제별 패키지 설치
- 각 운영체제별로 패키지를 설치하는 tasks/{distribution}.yml 파일을 작성한다.
yamlCopy code
# tasks/RedHat.yml
- name: Install chrony using dnf
ansible.builtin.dnf:
name: "{{ package_name }}"
state: latest
yamlCopy code
# tasks/CentOS.yml
- name: Install chrony using dnf
ansible.builtin.dnf:
name: "{{ package_name }}"
state: latest
yamlCopy code
# tasks/Ubuntu.yml
- name: Install chrony using apt
ansible.builtin.apt:
name: "{{ package_name }}"
state: latest
메인 플레이북
- 최종적으로 install_ntp.yml 파일에서 롤을 추가하고 ntp_server 변수를 함께 선언한다.
yamlCopy code
# install_ntp.yml
- hosts: tnode
roles:
- role: myrole.chrony
ntp_server: 0.kr.pool.ntp.org
플레이북 실행
bashCopy code
# 플레이북 실행
ansible-playbook install_ntp.yml
- 결과 확인 및 검증
bashCopy code
# 설정 파일 확인
ansible -m shell -a "cat /etc/chrony/chrony.conf" tnode1
도전 과제 3: Apache HTTP 서버 설치 플레이북
사전 분석:
- Ubuntu 및 CentOS에서 Apache HTTP 서버를 설치하는 플레이북을 작성할 것입니다.
- Roles 및 Templates을 활용하여 효율적인 구성을 할 예정입니다.
플레이북 설계:
- 앤서블 공식 문서 및 참고 자료에서 각 운영체제별로 Apache를 설치하는 모듈을 찾습니다.
- 설치된 Apache 서비스의 구성을 변경할 수 있도록 템플릿을 활용하여 설정 파일을 관리합니다.
- Ubuntu 및 CentOS에서 동일한 플레이북을 사용할 수 있도록 Roles를 구성합니다.
검색 결과 활용:
- Stack Overflow : Ubuntu에 Nginx를 설치하는 예제를 참고하여 Apache 설치 모듈을 찾습니다.
- Ansible Documentation - apt_module : Ansible의 apt 모듈을 통해 Ubuntu에 패키지를 설치하는 방법을 확인합니다.
- Middleware Inventory : Ansible을 사용하여 다양한 작업을 수행하는 블로그 글에서 핸들러를 사용하여 서비스를 관리하는 방법을 참고합니다.
플레이북 개발:
- 아래는 플레이북의 일부분으로, Roles 및 Templates을 사용하여 Apache HTTP 서버를 Ubuntu 및 CentOS에 설치하는 내용을 포함합니다.
yamlCopy code
---
- name: Install Apache HTTP Server
hosts: webservers
become: true
vars:
apache_pkg:
Ubuntu: apache2
CentOS: httpd
tasks:
- name: Install Apache on Ubuntu
ansible.builtin.apt:
name: "{{ apache_pkg.Ubuntu }}"
state: present
when: ansible_facts['distribution'] == 'Ubuntu'
- name: Install Apache on CentOS
ansible.builtin.yum:
name: "{{ apache_pkg.CentOS }}"
state: present
when: ansible_facts['distribution'] == 'CentOS'
- name: Copy Apache configuration template
ansible.builtin.template:
src: apache.conf.j2
dest: /etc/apache2/apache2.conf
when: ansible_facts['distribution'] == 'Ubuntu'
- name: Copy Apache configuration template
ansible.builtin.template:
src: apache.conf.j2
dest: /etc/httpd/conf/httpd.conf
when: ansible_facts['distribution'] == 'CentOS'
- name: Start Apache service
ansible.builtin.service:
name: "{{ apache_pkg[ansible_facts['distribution']] }}"
state: started
enabled: yes
플레이북 실행:
bashCopy code
ansible-playbook install_apache.yml
이 플레이북은 Ubuntu 및 CentOS에 Apache HTTP 서버를 설치하고, 각각의 설정 파일을 관리합니다. Roles 및 Templates를 사용하여 모듈화된 구조로 설계되었습니다.
10.1 환경 설정 자동화 챕터의 네트워크 IP 설정하기
사전 분석
- 우분투 OS에서는 netplan 파일을 사용하여 IP를 설정합니다.
- netplan은 파일 형태이므로 먼저 파일 구조를 확인하고 jinja2 템플릿을 이용하여 작성합니다.
- CentOS/레드햇 OS에서는 nmcli 모듈을 사용하여 IP를 설정합니다. (community.general.nmcli 모듈 참조)
- 예제에서는 ethernet 타입의 네트워크 IP를 설정합니다.
- IP 설정과 관련된 정보는 메인 플레이북에서 변수로 정의하며, 해당 인터페이스가 호스트에 존재하는지 앤서블 팩트를 통해 확인합니다.
플레이북 설계
- 우분투와 CentOS/레드햇 각각을 대상으로 하는 롤을 구성합니다.
- OS 종류에 따라 해당 롤을 호출하는 메인 플레이북을 설계합니다.
- ansible.builtin.template 모듈: docs.ansible.com
플레이북 개발 및 실행
1. 프로젝트 디렉터리와 ansible.cfg, inventory 파일 생성
bashCopy code
# 프로젝트 디렉터리 생성 및 ansible.cfg, inventory 파일 작성
mkdir ~/ansible-project/chapter_10.1
cd ~/ansible-project/chapter_10.1
# ansible.cfg, inventory 파일 작성
cat <<EOT> ansible.cfg
[defaults]
inventory = ./inventory
remote_user = ansible
ask_pass = false
inject_facts_as_vars = false
roles_path = ./roles
[privilege_escalation]
become = true
become_method = sudo
become_user = root
become_ask_pass = false
EOT
cat <<EOT> inventory
[tnode]
tnode1
tnode2
tnode3
EOT
2. 롤 생성: myrole.nmcli, myrole.netplan
bashCopy code
# 롤 생성
ansible-galaxy role init --init-path ./roles myrole.nmcli
ansible-galaxy role init --init-path ./roles myrole.netplan
# 확인
ansible-galaxy role list
tree roles -L 2
3. myrole.nmcli에 태스크 파일 작성
yamlCopy code
# tasks file for myrole.nmcli
- name: Setup nic ip
community.general.nmcli:
type: ethernet
conn_name: "{{ item.con_name }}"
ip4: "{{ item.ip_addr }}"
gw4: "{{ item.ip_gw }}"
dns4: "{{ item.ip_dns }}"
state: present
loop: "{{ net_info }}"
when: net_info[0].con_name in ansible_facts.interfaces
- nmcli 모듈을 사용하여 외부에서 받은 변수로 네트워크 IP를 설정합니다.
- 변수는 배열 형식으로 받기 때문에 loop를 사용하고, 외부에서 받은 인터페이스가 앤서블 팩트에 존재하는지 확인하는 when 키워드를 사용합니다.
myrole.netplan에 Jinja2 템플릿 파일 작성
bashCopy code
# Jinja2 템플릿 파일 생성
touch ~/ansible-project/chapter_10.1/roles/myrole.netplan/templates/01-netplan-ansible.yaml.j2
01-netplan-ansible.yaml:
yamlCopy code
# This is the network config written by 'ansible'
network:
version: 2
ethernets:
{% for item in net_info %}
{{ item.con_name }}:
dhcp4: no
dhcp6: no
addresses: [{{ item.ip_addr }}]
gateway4: {{ item.ip_gw }}
nameservers:
addresses: [{{ item.ip_dns }}]
{% endfor %}
- Jinja2 템플릿을 사용하여 외부에서 받은 배열 형식의 변수를 for 문으로 처리합니다.
myrole.netplan에 태스크 파일 작성
yamlCopy code
# tasks file for myrole.netplan
- name: Copy netplan file
ansible.builtin.template:
src: 01-netplan-ansible.yaml.j2
dest: /etc/netplan/01-netplan-ansible.yaml
when: net_info[0].con_name in ansible_facts.interfaces
notify: Netplan apply
- template 모듈을 사용하여 템플릿 파일을 호스트에 복사합니다.
- 외부에서 받은 인터페이스가 앤서블 팩트에 존재하는지 확인하고, 템플릿 복사가 성공하면 notify 키워드를 사용하여 핸들러를 호출합니다.
myrole.netplan에 핸들러 파일 작성: 핸들러는 command 모듈을 사용하여 netplan apply 명령어를 실행합니다.
yamlCopy code
# handlers file for myrole.netplan
- name: Netplan apply
ansible.builtin.command: netplan apply
마지막으로 롤을 호출할 메인 플레이북을 작성합니다.
- 메인 플레이북에서는 롤에 전달할 변수를 vars 섹션에 선언하고 tasks 섹션에 롤을 추가합니다.
- ansible.builtin.include_role 모듈을 사용하여 롤을 호출하면서 when 구문을 함께 사용하여 OS 버전에 따라 해당 롤을 호출합니다.
yamlCopy code
# set_ip.yml
---
- hosts: tnode1
vars:
fedora_os:
- CentOS
- RedHat
net_info:
- con_name: ens5
ip_addr: 10.10.1.11/24
ip_gw: 10.10.1.1
ip_dns: 127.0.0.53
tasks:
- name: Include role in CentOS and RedHat
ansible.builtin.include_role:
name: myrole.nmcli
when: ansible_facts.distribution in fedora_os
- name: Include role in Ubuntu
ansible.builtin.include_role:
name: myrole.netplan
when: ansible_facts.distribution == "Ubuntu"
- hosts: tnode2
vars:
fedora_os:
- CentOS
- RedHat
net_info:
- con_name: ens7
ip_addr: 10.10.1.12/24
ip_gw: 10.10.1.1
ip_dns: 127.0.0.53
tasks:
- name: Include role in CentOS and RedHat
ansible.builtin.include_role:
name: myrole.nmcli
when: ansible_facts.distribution in fedora_os
- name: Include role in Ubuntu
ansible.builtin.include_role:
name: myrole.netplan
when: ansible_facts.distribution == "Ubuntu"
플레이북 실행
- 실행 전 tnode1의 네트워크 정보 확인
bashCopy code
# 실행 전 tnode1 정보 확인
ssh tnode1 ls /etc/netplan
ssh tnode1 cat /etc/netplan/50-cloud-init.yaml
ssh tnode1 ip -br -c addr
ssh tnode1 ip -c route
ssh tnode1 nslookup blog.cloudneta.net
ansible -m shell -a "cat /var/log/syslog | grep -i dhcp" tnode1
ssh tnode1 sudo dhclient -v ens5
bashCopy code
# 문법 체크
ansible-playbook --syntax-check set_ip.yml
# 플레이북 실행
ansible-playbook set_ip.yml
# 실행 후 tnode1 정보 확인
ssh tnode1 ls /etc/netplan
ssh tnode1 cat /etc/netplan/01-netplan-ansible.yaml
ssh tnode1 ip -br -c addr
ssh tnode1 ip -c route
ssh tnode1 nslookup blog.cloudneta.net
🌐 Sources
- ansible.builtin.template - Ansible Documentation
- community.general.nmcli - Ansible Documentation
- Jinja2 Template Engine - Introduction
도전과제4 Jinja2 템플릿을 활용한 예시 playbook를 구글링하여 실습 환경에 맞게 구성 후 실습해보세요
먼저, Jinja2 템플릿을 사용한 예시 플레이북을 구글에서 찾아보겠습니다. 그런 다음 실습 환경에 맞게 조정하고 전체적인 요약을 제공하겠습니다.
- Google에서 "Jinja2 template Ansible playbook example"을 검색합니다.
- 검색 결과 중에서 Jinja2 템플릿을 사용한 예시 플레이북을 찾습니다.
예시 플레이북을 찾았다면, 해당 플레이북을 기반으로 실습 환경에 맞게 조정합니다. 예시 플레이북의 구조와 역할에 따라 필요한 수정을 수행합니다.
아래는 가상의 예시일 수 있으며, 특정 예시 플레이북이나 템플릿을 기반으로 작성한 것이 아닙니다.
예시 플레이북 (example_playbook.yml):
yamlCopy code
---
- name: Configure Network on tnode1
hosts: tnode1
tasks:
- name: Copy network configuration template
template:
src: templates/network.j2
dest: /etc/netplan/01-netplan-ansible.yaml
notify: Apply network configuration
handlers:
- name: Apply network configuration
command: netplan apply
Jinja2 템플릿 (templates/network.j2):
yamlCopy code
network:
version: 2
ethernets:
ens5:
addresses: {{ ip_address }}/24
gateway4: {{ gateway }}
nameservers:
addresses: [{{ nameserver }}]
위의 예시에서 {{ ip_address }}, {{ gateway }}, {{ nameserver }} 등의 변수는 실제 값으로 대체되어야 합니다. 네트워크 인터페이스 이름(ens5 등)도 실제 환경에 맞게 수정이 필요합니다.
실습 단계:
- 위에서 구성한 플레이북과 템플릿을 저장합니다.
- 필요한 변수 값을 실제 환경에 맞게 설정합니다.
- Ansible을 사용하여 플레이북을 실행합니다.
'* Twodragon' 카테고리의 다른 글
Ansible 101: 조건문과 반복문 활용하여 인프라 관리하기 (0) | 2024.01.20 |
---|---|
Ansible 101: 손쉬운 인프라 관리를 위한 앤서블 시작하기 (0) | 2024.01.10 |
[성장스토리] 고시원에서 원룸까지, 대학생의 눈물과 웃음의 여정 (0) | 2023.12.26 |
2021년 회고록 그리고, 2022년 계획 (2) | 2021.12.25 |