Bootstrap app container in 2 steps

In this topic @sam said:

Internally when we deploy we use a “pre-bootstrapped” image as our base image and then simply run “assets:precompile” and “rake db:migrate” as our only bit of custom bootstrapping code.

Is there an official template for doing that? It seems that there isn’t.

To achieve it I dismembered the web.template.yml in web.template.validate.yml, web.template.build.yml and web.template.run.yml.

The validate image only do some validations of env variables and the like (they are at the beginning of the original template).

The build image I use only for bootstrap. It downloads the discourse repository from git, install the ruby dependencies and plugins, aside from creating some files.

The run image is launched with the rebuild option. It references the image generated by the bootstrap with the build image (it can reference a local image as well as a remote one, in a registry like docker hub). The template will execute the rake tasks to make the db migration and to precompile the assets, as well as creating the stateful directory (/shared) and subdirectories.

The way it is now, doing everything in one step, the app.yml line:

 - "templates/web.template.yml"

Would be now equivalent to:

 - "templates/web.template.validate.yml"
 - "templates/web.template.build.yml"
 - "templates/web.template.run.yml"

The files I use instead are:

app-build.yml

 - "templates/web.template.build.yml"

(has only the above template)

app-run.yml

 - "templates/web.template.validate.yml"
 - "templates/web.template.run.yml"

(has other templates, like postgres, redis, letsencrypt, and so on)

If you see the new templates, they are basically the original template, but divided in parts, having nothing more nor less (the 2 rake tasks are run last, but I don’t think that affect the overall script, because the other tasks are basically creation of files. The order of the creation of the /shared directory is also changed, but I don’t see a problem, because it isn’t used when pulling the repositories and installing the ruby dependencies)

Show full files

(I use variables that come from Ansible in the containers files to define the values dynamically based on some environment variables, that’s why tere are variables between {{ }}, but I think it’s clear what the files are about. The templates files don’t use Ansible variables, they are just raw yaml files)

containers/app-build.yml

## this is the all-in-one, standalone Discourse Docker build image template
##
## BE *VERY* CAREFUL WHEN EDITING!
## YAML FILES ARE SUPER SUPER SENSITIVE TO MISTAKES IN WHITESPACE OR ALIGNMENT!
## visit http://www.yamllint.com/ to validate this file as needed

templates:
  - "templates/web.template.build.yml"
params:
  db_default_text_search_config: "{{ text_search_config | default('pg_catalog.english', true) }}"

  ## Set db_shared_buffers to a max of 25% of the total memory.
  ## will be set automatically by bootstrap based on detected RAM, or you can override
  db_shared_buffers: "{{ db_shared_buffers | default('128MB', true) }}"

  ## can improve sorting performance, but adds memory usage per-connection
  #db_work_mem: "40MB"

  ## Which Git revision should this container use? (default: tests-passed)
  version: {{ version | default('tests-passed', true) }}

## Plugins go here
## see https://meta.discourse.org/t/19157 for details
hooks:
  after_code:
    - exec:
        cd: $home/plugins
        cmd:
          - git clone https://github.com/discourse/docker_manager.git

## Any custom commands to run after building
run:
  - exec: echo "Beginning of custom commands"
  ## If you want to set the 'From' email address for your first registration, uncomment and change:
  ## After getting the first signup email, re-comment the line. It only needs to run once.
  #- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
  - exec: echo "End of custom commands"

containers/app-run.yml

## this is the all-in-one, standalone Discourse Docker container template
##
## After making changes to this file, you MUST rebuild
## /var/discourse/launcher rebuild app
##
## BE *VERY* CAREFUL WHEN EDITING!
## YAML FILES ARE SUPER SUPER SENSITIVE TO MISTAKES IN WHITESPACE OR ALIGNMENT!
## visit http://www.yamllint.com/ to validate this file as needed

base_image: "{{ base_image | default('local_discourse/app-build') }}"

templates:
  - "templates/postgres.template.yml"
  - "templates/redis.template.yml"
  - "templates/web.template.validate.yml"
  - "templates/web.template.run.yml"
  - "templates/web.ratelimited.template.yml"
## Uncomment these two lines if you wish to add Lets Encrypt (https)
  {{ use_ssl | ternary('', '#') }}- "templates/web.ssl.template.yml"
  {{ use_ssl | ternary('', '#') }}- "templates/web.letsencrypt.ssl.template.yml"

## which TCP/IP ports should this container expose?
## If you want Discourse to share a port with another webserver like Apache or nginx,
## see https://meta.discourse.org/t/17247 for details
expose:
  - "{{ http_port | default(80, true) }}:80"   # http
  - "{{ https_port | default(443, true) }}:443" # https

env:
  LANG: {{ lang | default('en_US.UTF-8', true) }}
  DISCOURSE_DEFAULT_LOCALE: {{ locale | default('en', true) }}

  ## How many concurrent web requests are supported? Depends on memory and CPU cores.
  ## will be set automatically by bootstrap based on detected CPUs, or you can override
  UNICORN_WORKERS: {{ workers | default(2, true) }}

  ## TODO: The domain name this Discourse instance will respond to
  ## Required. Discourse will not work with a bare IP number.
  DISCOURSE_HOSTNAME: {{ hostname }}

  ## Uncomment if you want the container to be started with the same
  ## hostname (-h option) as specified above (default "$hostname-$config")
  #DOCKER_USE_HOSTNAME: true

  ## TODO: List of comma delimited emails that will be made admin and developer
  ## on initial signup example 'user1@example.com,user2@example.com'
  DISCOURSE_DEVELOPER_EMAILS: '{{ email }}'

  ## TODO: The SMTP mail server used to validate new accounts and send notifications
  # SMTP ADDRESS, username, and password are required
  # WARNING the char '#' in SMTP password can cause problems!
  DISCOURSE_SMTP_ADDRESS: {{ smtp_address }}
  DISCOURSE_SMTP_PORT: {{ smtp_port }}
  DISCOURSE_SMTP_USER_NAME: {{ smtp_user_name }}
  DISCOURSE_SMTP_PASSWORD: "{{ smtp_pass }}"
  DISCOURSE_SMTP_ENABLE_START_TLS: {{ start_tls | default('true', true) }}

  ## If you added the Lets Encrypt template, uncomment below to get a free SSL certificate
  {{ use_ssl | ternary('', '#') }}LETSENCRYPT_ACCOUNT_EMAIL: {{ ssl_email | default('', true) }}

  ## The CDN address for this Discourse instance (configured to pull)
  ## see https://meta.discourse.org/t/14857 for details
  {{ use_cdn | ternary('', '#') }}DISCOURSE_CDN_URL: {{ cdn_url | default('', true) }}

## The Docker container is stateless; all data is stored in /shared
volumes:
  - volume:
      host: {{ host_shared_volume | default('/var/discourse/shared/app', true) }}
      guest: /shared
  - volume:
      host: {{ host_log_volume | default('/var/discourse/shared/app/log/var-log', true) }}
      guest: /var/log

## Any custom commands to run after building
run:
  - exec: echo "Beginning of custom commands"
  ## If you want to set the 'From' email address for your first registration, uncomment and change:
  ## After getting the first signup email, re-comment the line. It only needs to run once.
  #- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
  - exec: echo "End of custom commands"

templates/web.template.validate.yml

run:
  - exec: thpoff echo "thpoff is installed!"
  - exec: /usr/local/bin/ruby -e 'if ENV["DISCOURSE_SMTP_ADDRESS"] == "smtp.example.com"; puts "Aborting! Mail is not configured!"; exit 1; end'
  - exec: /usr/local/bin/ruby -e 'if ENV["DISCOURSE_HOSTNAME"] == "discourse.example.com"; puts "Aborting! Domain is not configured!"; exit 1; end'
  - exec: /usr/local/bin/ruby -e 'if (ENV["DISCOURSE_CDN_URL"] || "")[0..2] == "//"; puts "Aborting! CDN must have a protocol specified. Once fixed you should rebake your posts now to correct all posts."; exit 1; end'

templates/web.template.build.yml

env:
  # You can have redis on a different box
  RAILS_ENV: 'production'
  UNICORN_WORKERS: 3
  UNICORN_SIDEKIQS: 1
  # this gives us very good cache coverage, 96 -> 99
  # in practice it is 1-2% perf improvement
  RUBY_GLOBAL_METHOD_CACHE_SIZE: 131072
  # stop heap doubling in size so aggressively, this conserves memory
  RUBY_GC_HEAP_GROWTH_MAX_SLOTS: 40000
  RUBY_GC_HEAP_INIT_SLOTS: 400000
  RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR: 1.5
  
params:
  # SSH key is required for remote access into the container
  version: tests-passed

  home: /var/www/discourse
  upload_size: 10m

run:
  - exec: chown -R discourse /home/discourse
  # TODO: move to base image (anacron can not be fired up using rc.d)
  - exec: rm -f /etc/cron.d/anacron
  - file:
     path: /etc/cron.d/anacron
     contents: |
        SHELL=/bin/sh
        PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

        30 7    * * *   root	/usr/sbin/anacron -s >/dev/null
  - file:
     path: /etc/runit/1.d/copy-env
     chmod: "+x"
     contents: |
        #!/bin/bash
        env > ~/boot_env
        conf=/var/www/discourse/config/discourse.conf

        # find DISCOURSE_ env vars, strip the leader, lowercase the key
        /usr/local/bin/ruby -e 'ENV.each{|k,v| puts "#{$1.downcase} = '\''#{v}'\''" if k =~ /^DISCOURSE_(.*)/}' > $conf

  - file:
     path: /etc/service/unicorn/run
     chmod: "+x"
     contents: |
        #!/bin/bash
        exec 2>&1
        # redis
        # postgres
        cd $home
        chown -R discourse:www-data /shared/log/rails
        LD_PRELOAD=$RUBY_ALLOCATOR HOME=/home/discourse USER=discourse exec thpoff chpst -u discourse:www-data -U discourse:www-data bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb

  - file:
     path: /etc/service/nginx/run
     chmod: "+x"
     contents: |
        #!/bin/sh
        exec 2>&1
        exec /usr/sbin/nginx

  - file:
     path: /etc/runit/3.d/01-nginx
     chmod: "+x"
     contents: |
       #!/bin/bash
       sv stop nginx

  - file:
     path: /etc/runit/3.d/02-unicorn
     chmod: "+x"
     contents: |
       #!/bin/bash
       sv stop unicorn

  - exec:
      cd: $home
      hook: code
      cmd:
        - git reset --hard
        - git clean -f
        - git remote set-branches --add origin master
        - git pull
        - git fetch origin $version
        - git checkout $version
        - mkdir -p tmp/pids
        - mkdir -p tmp/sockets
        - touch tmp/.gitkeep

  - exec:
      cmd:
        - "cp $home/config/nginx.sample.conf /etc/nginx/conf.d/discourse.conf"
        - "rm /etc/nginx/sites-enabled/default"
        - "mkdir -p /var/nginx/cache"

  - replace:
      filename: /etc/nginx/nginx.conf
      from: pid /run/nginx.pid;
      to: daemon off;

  - replace:
      filename: "/etc/nginx/conf.d/discourse.conf"
      from: /upstream[^\}]+\}/m
      to: "upstream discourse {
        server 127.0.0.1:3000;
      }"

  - replace:
      filename: "/etc/nginx/conf.d/discourse.conf"
      from: /server_name.+$/
      to: server_name _ ;

  - replace:
      filename: "/etc/nginx/conf.d/discourse.conf"
      from: /client_max_body_size.+$/
      to: client_max_body_size $upload_size ;

  - exec:
      cmd: echo "done configuring web"
      hook: web_config

  - exec:
      cd: $home
      hook: web
      cmd:
        # ensure we are on latest bundler
        - gem update bundler
        - find $home ! -user discourse -exec chown discourse {} \+

  - exec:
      cd: $home
      hook: bundle_exec
      cmd:
        - su discourse -c 'bundle install --deployment --retry 3 --jobs 4 --verbose --without test development'
        
  - file:
     path: /usr/local/bin/discourse
     chmod: +x
     contents: |
       #!/bin/bash
       (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec script/discourse "$@")

  - file:
     path: /usr/local/bin/rails
     chmod: +x
     contents: |
       #!/bin/bash
       # If they requested a console, load pry instead
       if [ "$*" == "c" -o "$*" == "console" ]
       then
        (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec pry -r ./config/environment)
       else
        (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec script/rails "$@")
       fi

  - file:
     path: /usr/local/bin/rake
     chmod: +x
     contents: |
       #!/bin/bash
       (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec bin/rake "$@")

  - file:
     path: /usr/local/bin/rbtrace
     chmod: +x
     contents: |
       #!/bin/bash
       (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec rbtrace "$@")

  - file:
     path: /usr/local/bin/stackprof
     chmod: +x
     contents: |
       #!/bin/bash
       (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec stackprof "$@")

  - file:
     path: /etc/update-motd.d/10-web
     chmod: +x
     contents: |
       #!/bin/bash
       echo
       echo Use: rails, rake or discourse to execute commands in production
       echo

  - file:
     path: /etc/logrotate.d/rails
     contents: |
        /shared/log/rails/*.log
        {
                rotate 7
                dateext
                daily
                missingok
                delaycompress
                compress
                postrotate
                sv 1 unicorn
                endscript
        }

  - file:
     path: /etc/logrotate.d/nginx
     contents: |
        /var/log/nginx/*.log {
          daily
          missingok
          rotate 7
          compress
          delaycompress
          create 0644 www-data www-data
          sharedscripts
          postrotate
            sv 1 nginx
          endscript
        }

  # move state out of the container this fancy is done to support rapid rebuilds of containers,
  # we store anacron and logrotate state outside the container to ensure its maintained across builds
  # later move this snipped into an intialization script
  # we also ensure all the symlinks we need to /shared are in place in the correct structure
  # this allows us to bootstrap on one machine and then run on another
  - file:
      path: /etc/runit/1.d/00-ensure-links
      chmod: +x
      contents: |
        #!/bin/bash
        if [[ ! -L /var/lib/logrotate ]]; then
          rm -fr /var/lib/logrotate
          mkdir -p /shared/state/logrotate
          ln -s /shared/state/logrotate /var/lib/logrotate
        fi
        if [[ ! -L /var/spool/anacron ]]; then
          rm -fr /var/spool/anacron
          mkdir -p /shared/state/anacron-spool
          ln -s /shared/state/anacron-spool /var/spool/anacron
        fi
        if [[ ! -d /shared/log/rails ]]; then
          mkdir -p /shared/log/rails
          chown -R discourse:www-data /shared/log/rails
        fi
        if [[ ! -d /shared/uploads ]]; then
          mkdir -p /shared/uploads
          chown -R discourse:www-data /shared/uploads
        fi
        if [[ ! -d /shared/backups ]]; then
          mkdir -p /shared/backups
          chown -R discourse:www-data /shared/backups
        fi

        rm -rf /shared/tmp/{backups,restores}
        mkdir -p /shared/tmp/{backups,restores}
        chown -R discourse:www-data /shared/tmp/{backups,restores}

  # change login directory to Discourse home
  - file:
     path: /root/.bash_profile
     chmod: 644
     contents: |
        cd $home

templates/web.template.run.yml

env:
  # You can have redis on a different box
  RAILS_ENV: 'production'
  UNICORN_WORKERS: 3
  UNICORN_SIDEKIQS: 1
  # this gives us very good cache coverage, 96 -> 99
  # in practice it is 1-2% perf improvement
  RUBY_GLOBAL_METHOD_CACHE_SIZE: 131072
  # stop heap doubling in size so aggressively, this conserves memory
  RUBY_GC_HEAP_GROWTH_MAX_SLOTS: 40000
  RUBY_GC_HEAP_INIT_SLOTS: 400000
  RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR: 1.5

  DISCOURSE_DB_SOCKET: /var/run/postgresql
  DISCOURSE_DB_HOST:
  DISCOURSE_DB_PORT:

params:
  home: /var/www/discourse

run:
  - exec:
      cd: $home
      cmd:
        - mkdir -p                    /shared/log/rails
        - bash -c "touch -a           /shared/log/rails/{production,production_errors,unicorn.stdout,unicorn.stderr,sidekiq}.log"
        - bash -c "ln    -s           /shared/log/rails/{production,production_errors,unicorn.stdout,unicorn.stderr,sidekiq}.log $home/log"
        - bash -c "mkdir -p           /shared/{uploads,backups}"
        - bash -c "ln    -s           /shared/{uploads,backups} $home/public"
        - bash -c "mkdir -p           /shared/tmp/{backups,restores}"
        - bash -c "ln    -s           /shared/tmp/{backups,restores} $home/tmp"
        - chown -R discourse:www-data /shared/log/rails /shared/uploads /shared/backups /shared/tmp

  - exec:
      cd: $home
      hook: db_migrate
      cmd:
        - su discourse -c 'bundle exec rake db:migrate'
        
  - exec:
      cd: $home
      hook: assets_precompile
      cmd:
        - su discourse -c 'bundle exec rake assets:precompile'

After setting up the files containers/app-build.yml and containers/app-run.yml I can start / rebuild discourse with just:

cd /var/discourse
./launcher bootstrap app-build
./launcher rebuild app-run

To add plugins I can include them the app-build.yml file and run the above commands.

Another approach is to bootstrap the image of app-build (it can be done even locally) and push to a registry, and then reference the image in app-run.yml. To rebuild would be just:

cd /var/discourse
./launcher rebuild app-run

(assuming the base image is in the correct version)


The pros of running in 2 steps (it will run in only one container in the end, so for the end user it would be transparent) instead of only 1 is that there will be less downtime because the steps that download the git repository and install the ruby dependences (that are in the build step) can be run before stopping the server (it reduced the downtime in a $5 droplet I tested from 6 minutes to 4, the precompile assets task being the main culprit of the 4 minutes, because, unfortunately, it depends on the database and must be run after the db migration).

The pros of running remotely in 2 steps instead of locally (both have the pro of the reduced downtime) is that you can use the same image for different environments, like staging and production, as well as different clients (if they use the same plugins), because the environment variables are used only in the 2nd step.

The cons (of remote bootstrap vs local) is that you mustn’t upgrade discourse through the UI, and when including plugins you should first push the new image with the plugin in the repository. Although I don’t actually see this as a con because I would first test either the new discourse version as well as new plugins in a staging environment to avoid unexpected surprises. Using the same remote image for staging and productions avoid cases liking testing in staging and having errors in production because the discourse repository or some plugin had a new commit in the meantime (the image would have the exact same files, so that could avoid a lot of edge cases between staging and production due to version (or commit) mismatch).


What I would like:

Although I don’t see the templates changing frequently in the discourse_docker repo, I would like to request the discourse team to create templates (could even use the ones I declared above, or I could even do a PR) so as to provide an official support for deploying the app container in 2 steps (I could adapt it for multicontainer step, but my request is only for the 1 container setup).

The reasons are:

  1. To have less downtime when rebuilding discourse.
  2. To use the same base image in staging and production to avoid problems caused by different commits in the discourse repository or in the plugins directory when updating discourse

I don’t know how the discourse team will see this request, but I hope to have conveyed my thoughts, as well as the pros of the change (it doesn’t need to change the default way that is done, in only 1 step, just have this way in 2 steps supported). I also haven’t added nor removed tasks in the web template, just divided it in parts so that it wouldn’t be a big change from the way it currently is done.

9개의 좋아요

매우 타당한 기능 요청인 것 같습니다. 그 사이로 진행 상황이 있나요? 예를 들어 GitHub에서 관련 활동이 있나요?

여기 내부에서는 다운타임을 최소화하기 위해 여러 컨테이너를 사용하는 방대한 멀티사이트 구성을 갖추고 있습니다. 셀프호스터가 다운타임을 최소화하는 가장 쉬운 방법은 두 개의 컨테이너를 설치하는 것입니다(기존 컨테이너를 중지하고 새 컨테이너를 시작하기 전에 새로 업데이트된 컨테이너를 부트스트랩할 수 있습니다). 이렇게 하면 사이트의 기술적 복잡성이 상당히 증가하므로 모든 사람에게 적합하지는 않습니다.

1개의 좋아요

Discourse 팀에서 저에게 연락이 오지 않았습니다(이 기능이 프로젝트의 복잡성을 증가시킬 수 있지만, 저는 그 정도가 매우 적을 것이라고 생각합니다). 따라서 현재로서는 아무것도 없습니다(하지만 위 방법은 공식 discourse_docker 저장소가 아니더라도 작동해야 합니다).

@justin 사실 제 주요 이유는 다운타임을 최소화하는 것이 아니라, 모든 루비 젬, 플러그인 및 discourse 저장소가 이미 설치된 기본 이미지를 유지하는 것입니다. 이 이미지는 스테이징과 프로덕션 같은 다른 환경, 그리고 동일한 플러그인을 사용하는 경우 다른 프로젝트에서도 사용할 수 있으며, 대상 머신에서 실행해야 하는 db 마이그레이션과 자산 프리컴파일 작업만 실행하면 됩니다. 이렇게 하면 discourse 업그레이드 시 발생할 수 있는 문제를 방지할 수 있습니다.

감소된 다운타임은 더 부수적인 이점, 즉 환영할 만한 부수 효과에 가깝습니다. 그렇더라도, 이 기능이 포함될지는 결국 Discourse 팀의 결정에 달려 있지만, 포함된다면 매우 기쁠 것입니다.

초보 시스템 관리자를 지원하는 데 엄청난 시간을 보내는 사람으로서, 저는 이 기능이 복잡성을 매우 크게 증가시킨다고 생각합니다. 단순하고 foolproof(오류 방지)인 설정 하나만 지원하는 데도 막대한 노력이 필요합니다. 2개 컨테이너 설치를 관리하고 app을 web_only로 대체해야 한다는 것을 아는 것(물론 데이터 컨테이너를 어떻게 그리고 언제 업데이트해야 하는지 아는 것도 포함하여)만으로도 복잡성은 최소 4배 이상 증가합니다.

이 부분이 제가 생각하는 가장 큰 장점입니다.

2개의 좋아요

@pfaffman 제가 만든 변경 사항을 확인해 주시면, discourse_docker의 웹 템플릿 지침 자체는 변경하지 않았고, 단지 이를 3개의 파일로 분리했을 뿐입니다. 그리고 이것이 제가 실제로 요청한 유일한 사항입니다: 첫 번째 파일은 환경에 무관한 작업(예: 루비 젬 설치, discourse 및 플러그인 저장소 클론 등)을 수행하는 지침을 실행하고, 두 번째 파일은 일부 검증 작업을 수행하며, 세 번째 파일은 데이터베이스 마이그레이션이나 자산 사전 컴파일과 같은 환경 특화 작업을 수행합니다.

제가 생각하는 복잡성은, 이러한 파일에 새로운 항목이 추가될 때 discourse 팀이 해당 항목이 환경 특화(예: 데이터베이스에 대한 의존성)인지 아닌지를 고려해야 하고, 그에 따라 어느 파일에 포함해야 하는지 정의해야 한다는 점입니다.

공식 설치 방식은 현재와 동일한 방식으로 작동할 것입니다. 다만 app.yml 파일에 3개의 템플릿을 포함하거나(가능하다면 templates/web.template.yml 파일이 이 3개를 포함하도록 하면, 이 경우 사용자 관점에서는 아무것도 변경되지 않습니다), 여러 단계로 나누어 수행하는 대신 이렇게 처리하게 됩니다(2단계 설치 방식의 이점은 없지만, 현재와 정확히 동일한 방식으로 작동하므로 깨지는 변경 사항은 없습니다).

시스템 관리 관련 작업이 매우 복잡할 수 있다는 점은 알고 있지만, 현재 방식에 기반하여 최대한 직관적인 방식으로 만들려고 노력했습니다(복잡성을 줄이기 위한 것입니다. k8s, docker-compose 파일로 서비스 분리 등 큰 변경 사항이 필요한 내용은 언급하지 않았습니다. 대신 공식 설치 방식에 기반했습니다).

5개의 좋아요