티스토리 뷰

DevOps/Ansible

[A101] Ansible - 핸들러와 롤

전빵수 2024. 1. 20. 02:13
CloudNet@의 가시다님 Ansible 1기 스터디에 참여하게 되어 배운 내용과 책의 내용을 함께 정리합니다.

📚 앤서블로 시작하는 인프라 자동화

 

앤서블로 시작하는 인프라 자동화 | 장현정 - 교보문고

앤서블로 시작하는 인프라 자동화 | 효율적인 IT 자동화를 위한 도구, 앤서블 설계부터 응용까지 단계별로 배우는 인프라 관리클라우드 컴퓨팅을 논할 때 IaC(Infrastructure as Code)를 빼놓을 수 없는

product.kyobobook.co.kr

 

1. 핸들러 및 작업 실패 처리

앤서블 모듈 멱등(idempotent)이 가능하도록 설계되어 있습니다. 즉 플레이북을 여러 번 실행해도 결과는 항상 동일합니다. 또한 플레이 및 해당 작업은 여러 번 실행할 수 있지만, 해당 호스트는 원하는 상태로 만드는 데 필요한 경우에만 변경됩니다.

하지만 한 작업에서 시스템을 변경해야 하는 경우 추가 작업 실행해야 할 수도 있습니다. 예를 들어 서비스의 구성 파일을 변경하려면 변경 내용이 적용되도록 서비스를 다시 로드해야 합니다. 이때 핸들러는 다른 작업에서 트리거한 알림에 응답하는 작업이며, 해당 호스트에서 작업이 변경될 때만 핸들러 통지합니다.

 

1.1. 앤서블 핸들러

앤서블에서 핸들러를 사용하려면 notify 문을 사용하여 명시적으로 호출된 경우에만 사용할 수 있습니다. 또한 핸들러를 정의할 때는 같은 이름으로 여러 개의 핸드러를 정의하기보다는 각각의 고유한 이름을 사용하여 정의하는 것이 좋습니다.

 

rsyslog 재시작 task가 실행되면 notify 키워드를 통해 print msg라는 핸들러를 호출하는 playbook을 생성해봅니다.

~/my-ansible/handler-sample.yml

---
- hosts: tnode2
  tasks:
    - name: restart rsyslog
      ansible.builtin.service:
        name: "rsyslog"
        state: restarted
      notify:
        - print msg

  handlers:
    - name: print msg
      ansible.builtin.debug:
        msg: "rsyslog is restarted"

핸들러가 실행되면 TASK가 아닌 RUNNING HANDLER 가 실행되는 것을 확인할 수 있습니다.

 

1.2. 작업 실패 무시

앤서블은 플레이 시 각 작업의 반환 코드를 평가하여 작업의 성공 여부를 판단합니다. 일반적으로 작업이 실패하면 앤서블은 이후의 모든 작업을 건너뜁니다. 하지만 작업이 실패해도 플레이를 계속 실행할 수 있는 방법이 있는데, ignore_errors 키워드로 구현할 수 있습니다.

 

apache3 패키지를 설치하는 작업을 추가하고 비교해봅니다.

~/my-ansible/ignore-example-1.yml

---
- hosts : tnode1

  tasks:
    - name: Install apache3
      ansible.builtin.apt:
        name: apache3
        state: latest

    - name: Print msg
      ansible.builtin.debug:
        msg: "Before task is ignored"

해당 작업은 apache3라는 패키지가 존재하지 않기 때문에 실패하며 종료되며 다음 작업도 진행되지 않습니다.

작성했던 playbook에 ignore_errors : yes 를 추가해 해당 작업이 실패하더라도 무시하고 다음 작업을 진행할 수 있게 합니다.

~/my-ansible/ignore-example-2.yml

---
- hosts : tnode1

  tasks:
    - name: Install apache3
      ansible.builtin.apt:
        name: apache3
        state: latest
      ignore_errors: yes

    - name: Print msg
      ansible.builtin.debug:
        msg: "Before task is ignored"

결과는 아래와 같이 출력되어 실패한 작업은 무시되고 다음 작업은 정상 진행된 것을 확인 할 수 있으며, ignored 개수도 알려줍니다.

 

1.3. 작업 실패 후 핸들러 실행

앤서블은 일반적으로 작업이 실패하고 해당 호스트에서 플레이가 중단되면 이전 작업에서 알림을 받은 모든 핸들러 실행되지 않습니다.

하지만 플레이북에 force_handlers: yes 키워드를 설정하면 이후 작업이 실패하여 플레이가 중단되어도 알림을 받은 핸들러가 호출됩니다.

 

핸들러를 호출하는 작업과 이후에 실패하는 작업을 실행하는 playbook을 작성해봅니다.

~/my-ansible/force-handler-1.yml

---
- hosts: tnode2

  tasks:
    - name: restart rsyslog
      ansible.builtin.service:
        name: "rsyslog"
        state: restarted
      notify:
        - print msg

    - name: install apache3
      ansible.builtin.apt:
        name: "apache3"
        state: latest

  handlers:
    - name: print msg
      ansible.builtin.debug:
        msg: "rsyslog is restarted"

playbook을 실행해보면 핸드러를 호출한 상태에서 작업이 실패하여 핸들러 호출 작업도 사라져버렸습니다.

hosts 아래 force_handlers: yes를 추가하고, install apache3 작업을 추가한 playbook을 생성해봅니다.

~/my-ansible/force-handler-2.yml

---
- hosts: tnode2

  tasks:
    - name: restart rsyslog
      ansible.builtin.service:
        name: "rsyslog"
        state: restarted
      notify:
        - print msg

    - name: install apache3
      ansible.builtin.apt:
        name: "apache3"
        state: latest

  handlers:
    - name: print msg
      ansible.builtin.debug:
        msg: "rsyslog is restarted"

playbook을 실행해보면 작업이 실패하더라도 호출된 핸들러까지는 실행한 결과를 확인할 수 있습니다.

 

1.4. 작업 실패 조건 지정

앤서블을 사용할 때 셸 스크립트보다는 모듈 사용하는 것이 권장됩니다.

command 계열 모듈 사용 시

  1. 앤서블에서 셸 스크립트를 실행한 뒤 결과로 실패 또는 에러 메시지를 출력해도, 앤서블에서는 작업이 성공했다고 간주합니다.
  2. 어떤 명령이라도 실행된 경우에는 태스크 실행 상태를 항상 changed로 표시됩니다.
  3. 명령을 실행한 경우에 반환 코드가 0이 아니면 failed로 처리합니다.
  4. 이런 경우 failed_when 키워드를 사용하여 작업이 실패했음을 나타내는 조건을 지정할 수 있습니다.

cat ~/my-ansible/Easy-Ansible/chapter_07.3/adduser-script.sh

#!/bin/bash

# 사용자 계정 및 패스워드가 입력되었는지 확인
if [[ -n $1 ]] && [[ -n $2 ]]
then

  UserList=($1)
  Password=($2)

  # for문을 이용하여 사용자 계정 생성
  for (( i=0; i < ${#UserList[@]}; i++ ))
  do
    # if문을 사용하여 사용자 계정이 있는지 확인
    if [[ $(cat /etc/passwd | grep ${UserList[$i]} | wc -l) == 0 ]]
    then
      # 사용자 생성 및 패스워드 설정
      useradd ${UserList[$i]}
      echo ${Password[$i]} | passwd ${UserList[$i]} --stdin
    else
      # 사용자가 있다고 메시지를 보여줌.
      echo "this user ${UserList[$i]} is existing."
    fi
  done

else
  # 사용자 계정과 패스워드를 입력하라는 메시지를 보여줌.
  echo -e 'Please input user id and password.\nUsage: adduser-script.sh "user01 user02" "pw01 pw02"'
fi

해당 파일을 현재 디렉토리에 옮겨 실습을 진행해본다.

cp ~/my-ansible/Easy-Ansible/chapter_07.3/adduser-script.sh adduser-script.sh
chmod +x adduser-script.sh
ls -l adduser-script.sh

해당 스크립트 파일을 셸 스크립트로 실행할 수 있는 상태가 되었습니다.

aws ad-hoc을 사용하여 사용자를 추가하는 스크립트 파일을 tnode1에 복사하고 확인합니다.

ansible -m copy -a 'src=/home/ubuntu/my-ansible/adduser-script.sh dest=/home/ubuntu/adduser-script.sh' tnode1
ssh tnode1 ls -l /home/ubuntu/

실행된 결과가 제대로 적용되었는지 확인합니다.



해당 셸 스크립트가 정상적으로 실행되려면 ./adduser-script.sh 가 아니라 옵션으로 user와 password를 입력해주어야 합니다.

 

이제 sh 실행을 위해서 shell 모듈 사용 태스크를 사용하여 playbook을 작성해봅니다.

~/my-ansible/failed-when-1.yml

---
- hosts: tnode1

  tasks:
    - name: Run user add script
      ansible.builtin.shell: /home/ubuntu/adduser-script.sh
      register: command_result

    - name: Print msg
      ansible.builtin.debug:
        msg: "{{ command_result.stdout }}"

playbook을 실행해보면 ok=2 changed=1 (ok는 changed를 포함합니다.)라는 결과가 나오는데, msg로 stdout결과를 확인해보면 Run user add script 작업은 정상적으로 실행된 것이 아니어도 앤서블은 실행만 정상적으로 진행이 되었다면 ok로 분류하게 됩니다.

playbook이 실행된 후 user가 생성되었는지 확인해보면, 생성되지 않은 것을 확인할 수 있습니다.

command_result.stdout 변수 'Please input user id and password'라는 문자열이 있으면 작업을 실패(fail)로 처리하겠다는 의미의 failed_when 조건식을 추가한 playbook을 작성해봅니다.

~/my-ansible/failed-when-2.yml

---
- hosts: tnode1

  tasks:
    - name: Run user add script
      ansible.builtin.shell: /home/ubuntu/adduser-script.sh
      register: command_result
      failed_when: "'Please input user id and password' in command_result.stdout"

    - name: Print msg
      ansible.builtin.debug:
        msg: "{{ command_result.stdout }}"

playbook을 실행하면 해당 작업이 실패로 처리되는 것을 확인할 수 있습니다. 다만 이렇게까지 해서 실패상황을 걸러내는 것은 앤서블에서는 복잡한 작업을 야기할 수 있으니 최대한 셸 스크립트보다는 모듈을 활용하는 것이 앤서블 사용에 더 효율적일 것입니다.

 

1.5. 앤서블 블록 및 오류 처리

앤서블은 블록(block)이라는 오류를 제어하는 문법을 제공합니다. 블록은 작업을 논리적으로 그룹화하는 절이며, 작업 실행 방법을 제어하는 데 사용할 수 있습니다. 또한 블록을 통해 rescue 문 always 문을 함께 사용함으로써 오류를 처리할 수 있습니다.

  • block : 실행할 기본 작업을 정의합니다.
  • rescure : block 절에 정의된 작업이 실패할 경우 실행할 작업을 정의합니다.
  • always : block 및 rescue 절에 정의된 작업의 성공 또는 실패 여부와 관계 없이 항상 실행되는 작업을 정의합니다.

블록 문법을 사용하여 playbook을 생성해봅니다.

  • block 구문에서 failed_when 구문을 사용하여 result.msg 변수에 'Not all paths' 메시지 발견되면 실패 처리합니다.
  • rescue 구문은 block 정의 작업이 실패 시 실행되며, 해당 디렉터리가 없는 경우 생성합니다.
  • always 구문은 항상 실행되며, 여기서는 로그 파일을 생성합니다.

~/my-ansible/block-example.yml

---
- hosts: tnode2
  vars:
    logdir: /var/log/daily_log
    logfile: todays.log

  tasks:
    - name: Configure Log Env
      block:
        - name: Find Directory
          ansible.builtin.find:
            paths: "{{ logdir }}"
          register: result
          failed_when: "'Not all paths' in result.msg"

      rescue:
        - name: Make Directory when Not found Directory
          ansible.builtin.file:
            path: "{{ logdir }}"
            state: directory
            mode: '0755'

      always:
        - name: Create File
          ansible.builtin.file:
            path: "{{ logdir }}/{{ logfile }}"
            state: touch
            mode: '0644'

playbook을 실행해보면 block 구문이 실패하여 rescued 구문과 always 구문이 실행된 것을 확인할 수 있습니다.

해당 디렉토리와 파일이 생성되었는지 aws ad-hoc 명령을 통해 확인할 수 있습니다.

 

2. 롤과 콘텐츠 컬렉션

2.1. 롤 구조 소개 및 사용법

은 "플레이북 내용을 기능 단위로 나누어 공통 부품으로 관리/재사용하기 위한 구조"

  • 플레이북에서 전달된 변수를 사용할 수 있습니다. 변수 미설정 시 기본값을 롤의 해당 변수에 설정하기도 합니다.
  • 콘텐츠를 그룹화하여 코드를 다른 사용자와 쉽게 공유할 수 있습니다.
  • 웹 서버, 데이터베이스 서버 또는 깃(Git) 레파지토리와 같은 시스템 유형의 필수 요소를 정의할 수 있습니다.
  • 대규모 프로젝트를 쉽게 관리할 수 있습니다.
  • 다른 사용자와 동시에 개발할 수 있습니다.
  • 잘 작성한 롤은 앤서블 갤럭시를 통해 공유하거나 다른 사람이 공유한 롤을 가져올 수도 있습니다.

롤은 하위 디렉터리 및 파일의 표준화된 구조에 의해 정의됩니다.

  • 최상위 디렉토리는 롤 이름을 의미하고, 그 안은 tasks 및 handlers 등 롤에서 목적에 따라 정의된 하위 디렉토리로 구성됩니다.
  • 아래 표는 롤의 최상의 디렉토리 아래에 있는 하위 디렉토리의 이름과 기능을 설명한 것 입니다.
하위 디렉토리 기능
defaults main.yml 파일에는 롤이 사용될 때 덮어쓸 수 있는 롤 변수의 기본값이 포함되어 있습니다.
이러한 변수는 우선순위가 낮으며 플레이에서 변경할 수 있습니다.
files 롤 작업에서 참조한 정적 파일이 있습니다.
handlers main.yml 파일에는 롤의 핸들러 정의가 포함되어 있습니다.
meta main.yml 파일에는 작성자, 라이센스, 플랫폼 및 옵션, 롤 종속성을 포함한 롤에 대한 정보가 들어있습니다.
tasks main.yml 파일에는 롤의 작업 정의가 포함되어 있습니다.
templates 롤 작업에서 참조할 Jinja2 템플릿이 있습니다.
tests 롤을 테스트하는 데 사용할 수 있는 인벤토리와 test.yml 플레이북이 포함될 수 있습니다.
vars main.yml 파일은 롤의 변수 값을 정의합니다. 종종 이러한 변수는 롤 내에서 내부 목적으로 사용됩니다.
또한 우선순위가 높으며 플레이북에서 사용될 때 변경되지 않습니다.

 

2.2. 롤을 이용한 플레이북 개발

롤을 생성하고 디렉토리를 확인합니다.

ansible-galaxy role init my-role

 

 

이제 간단한 롤 플레이북을 개발해 봅니다.

프로세스는 각 구조에 맞게 태스크를 작성해야 합니다.

  • 롤이 호출되면 현재 호스트의 운영체제 버전이 지원 운영체제 목록에 포함되는지 확인해야 합니다.
  • 운영체제가 CentOS나 레드햇이면 httpd 관련 패키지를 dnf 모듈을 이용해 설치합니다.
  • 설치가 끝나면 제어 노드의 files 디렉터리 안에 있는 index.html 파일을 관리 노드의 /var/www/html 디렉터리에 복사합니다.
  • 파일 복사가 끝나면 httpd 서비스를 재시작합니다.

롤 구조는 다음과 같습니다.

  • 롤 이름 : my-role
  • tasks (메인 태스크)
    • install service : httpd 관련 패키지 설치
    • copy html file : index.html 파일 복사
  • files (정적 파일)
    • index.html
  • handlers (핸들러)
    • restart service : httpd 서비스 재시작
  • defaults (가변 변수) : 메인 태스크에서 사용된 변수 선언
    • service_title
  • vars (불변 변수) : 메인 태스크와 핸들러에서 사용된 변수 선언
    • service_name : 서비스명
    • src_file_path : 복사할 파일 경로
    • dest_file_path : 파일이 복사될 디렉터리 경로
    • httpd_packages : httpd 관련 패키지 목록
    • supported_distros : 지원 OS 목록

위의 프로세스에 맞춰 메인 태스크를 작성합니다.

첫 번째 태스크인 install service에는 플레이북에서 변수로 정의한 서비스명을 함께 출력합니다. 그리고 ansible.builtin.apt 모듈을 이용하여 httpd 관련 패키지를 설치합니다. 이때 관련 패키지는 여러 개이며 loop 문을 사용합니다.

서비스 설치가 끝나면 ansible.builtin.copy 모듈을 이용하여 파일을 복사하고, 복사가 끝나면 restart servie라는 핸들러를 호출합니다.

~/my-ansible/my-role/tasks/main.yml

---
# tasks file for my-role

- name: install service {{ service_title }}
  ansible.builtin.apt:
    name: "{{ item }}"
    state: latest
  loop: "{{ httpd_packages }}"
  when: ansible_facts.distribution in supported_distros

- name: copy conf file
  ansible.builtin.copy:
    src: "{{ src_file_path }}"
    dest: "{{ dest_file_path }}"
  notify: 
    - restart service

 

index.html 정적 파일을 간단하게 생성합니다.

~/my-ansible/my-role/files/index.html

echo "Hello! Ansible" > files/index.html
 
특정 태스크가 끝나고 그 다음에 수행해야 하는 태스크, service 모듈을 이용하여 서비스를 재시작하는 핸들러를 작성합니다.

~/my-ansible/my-role/handlers/main.yml

---
# handlers file for my-role

- name: restart service
  ansible.builtin.service:
    name: "{{ service_name }}"
    state: restarted
 

 

service_title을 외부에서 받아 수정 가능하도록 하는 defaults(가변 변수)를 작성합니다. 가변 변수는 외부로부터 재정의 될 수 있습니다.

~/my-ansible/my-role/defaults/main.yml

echo 'service_title: "Apache Web Server"' >> defaults/main.yml

 

vars(불변 변수)를 작성합니다. 불변 변수는 한번 정의되면 외부로부터 변수 값을 수정 할 수 없어 롤 내의 플레이북에서만 사용되는 변수로 정의하는 것이 좋습니다.

my-ansible/my-role/vars/main.yml

---
# vars file for my-role

service_name: apache2
src_file_path: ../files/index.html
dest_file_path: /var/www/html
httpd_packages:
  - apache2
  - apache2-doc

supported_distros:
  - Ubuntu

 

2.3. 플레이북에 롤 추가

이제 플레이북에 롤을 추가할 수 있습니다.

  • 플레이북에 롤을 추가하려면 ansible.builtin.import_role과 ansible.builtin.include_role 모듈 2가지 방법이 있습니다.
  • ansible.builtin.import_role은 롤을 정적으로 추가하며, ansible.builtin.include_role는 롤을 동적으로 추가합니다.
  • 정적으로 롤을 추가한다는 건 고정된 롤을 추가하겠다는 의미이며, 동적으로 추가한다는 건 반복문이나 조건문에 의해 롤이 변경될 수 있다는 의미입니다.

롤 디렉토리 상위로 올라와서 플레이북을 생성합니다.

~/my-ansible/role-example.yml

---
- hosts: tnode1

  tasks:
    - name: Print start play
      ansible.builtin.debug:
        msg: "Let's start role play"

    - name: Install Service by role
      ansible.builtin.import_role:
        name: my-role

 

Print 태스트 후 my-role 내의 각 태스트와 핸들러가 순차적으로 수행하는지 플레이북을 실행해봅니다.

curl 명령을 통해 웹서버가 제대로 동작하는지 확인할 수 있습니다.

 

default/main.yml 파일에 정의된 defaults(가변 변수)인 service_title을 롤을 호출하는 곳에서 재정의하는 플레이북으로 수정한 후에 변수가 변경되는지 확인해봅니다.

~/my-ansible/role-example.yml

---
- hosts: tnode1

  tasks:
    - name: Print start play
      ansible.builtin.debug:
        msg: "Let's start role play"

    - name: Install Service by role
      ansible.builtin.import_role:
        name: my-role
      vars:
        service_title: Httpd

플레이북을 실행해보면 Apache Web Server였던 가변 변수가 Httpd로 변경된 결과를 확인할 수 있습니다.

정적 변수는 변경하면 어떻게 결과가 달라지는지 확인해보겠습니다.

echo "Hello! CloudNet@" > my-role/files/index.html

플레이북을 실행하면 ok였던 my-role 태스크에 변화가 생겨 changed로 상태가 변경된 결과를 확인할 수 있습니다.

마찬가지로 웹서버에도 제대로 반영되었습니다.

 

2.4. 플레이북에서 Roles 섹션 사용

롤을 추가하는 또 다른 방법은 roles 섹션에 롤을 나열하는 것입니다. roles 섹션은 tasks 섹션과 매우 유사하나 작업 목록이 아닌 롤 목록으로 구성되어 있습니다.

 

실습을 위해서 tnode1에 접속해서 방화벽 설치 후 기본 설정을 진행합니다.

# tnode1 진입
ssh tnode1

# firewalld 설치
sudo apt install firewalld -y
systemctl status firewalld

 

방화벽의 기본 정책의 service와 port를 확인합니다. 현재에는 아무 port도 열려있지 않습니다.

 

8080포트를 추가하는 명령을 실행한뒤 방화벽을 reload 해줘야 해당 명령이 적용됩니다.

sudo firewall-cmd --permanent --zone=public --add-port=8080/tcp

방화벽이 제대로 설정되었는지 확인하기 위해 tnode1에서는 정상 접속되는 웹서버를 다른 서버에서 접속해보면 확인할 수 있습니다.

 

 

롤로 방화벽 정책을 적용할 수 있는 시나리오를 작성하기 위해 새로운 롤을 생성합니다.

 

firewalld 방화벽 서비스에 http 서비스를 추가하는 태스크와 reload 태스크를 추가하는 메인 태스크를 작성합니다.

~/my-ansible/my-role2/tasks/main.yml

---
# tasks file for my-role2

- name: Config firewalld
  ansible.posix.firewalld:
    service: "{{ item }}"
    permanent: true
    state: enabled
  loop: "{{ service_port }}"

- name: Reload firewalld
  ansible.builtin.service:
    name: firewalld
    state: reloaded

 

http 포트와 https 포트를 정의하는 vars (불변 변수)를 작성합니다.

my-ansible/my-role2/vars/main.yml

---
# defaults file for my-role2

service_port: 
  - http
  - https

 

해당 롤을 사용하는 플레이북을 작성합니다.

my-ansible/role-example-2.yml

---

- hosts: tnode1

  roles:
    - my-role
    - my-role2

  tasks:
    - name: Print finish role play
      ansible.builtin.debug:
        msg: "Finish role play"

 

ansible-playbook --check 명령으로 실제 실행이 이루어지기 전에 미리 시뮬레이션을 해볼 수 있습니다.

curl 명령을 실행해보면 정상적으로 웹서버에 접근되는 결과를 확인할 수 있습니다. firewalld 정책도 함께 살펴보면, 전에 없던 http와 https 서비스가 롤로 실행된 플레이북에 의해 추가된 것을 확인할 수 있습니다. 해당 서비스가 추가되어 웹서버 접근이 가능해진 것입니다.

2.5. 특수 작업 섹션

  • pre_tasks 섹션은 tasks와 유사한 작업 목록이지만 roles 섹션의 롤보다 먼저 실행됩니다. 또한 pre-tasks 섹션은 작업을 핸들러에 알리면 해당 핸들러 작업이 롤 또는 일반 태스크 전에 실행됩니다.
  • post_tasks 섹션은 tasks 및 tasks에서 알림을 받은 핸들러 다음에 실행됩니다.

특수 작업 섹션인 pre_tasks, post_tasks 포함하여 roles 섹션과 tasks 섹션을 포함하는 플레이북을 작성해봅니다.

~/my-ansible/special_role.yml

---

- hosts: tnode1

  pre_tasks:
    - name: Print Start role
      ansible.builtin.debug:
        msg: "Let's start role play"
 
  roles:
    - role: my-role
    - role: my-role2
 
  tasks:
    - name: Curl test
      ansible.builtin.uri:
        url: http://tnode1
        return_content: true
      register: curl_result
      notify: Print result
      changed_when: true

  post_tasks:
    - name: Print Finish role
      ansible.builtin.debug:
        msg: "Finish role play"

  handlers:
    - name: Print result
      ansible.builtin.debug:
        msg: "{{ curl_result.content }}"

 

플레이북을 실행해보면 pre_tasks가 제일 먼저, post_tasks가 제일 나중에 실행된 결과를 확인할 수 있습니다.

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함