在 2 个步骤中引导应用容器

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 实际上,我的主要目的甚至不是减少停机时间,而是维护一个包含所有 Ruby gem、插件和 Discourse 仓库的基准镜像,该镜像可用于不同环境(如预发布环境和生产环境),甚至可用于不同项目(如果它们使用相同的插件)。只需在目标机器上运行数据库迁移和资产预编译任务即可,这样可以避免升级 Discourse 时出现的问题。

减少停机时间更像是一个附带的好处,令人欢迎。话虽如此,最终是否包含此功能仍由 Discourse 团队决定,但我会非常高兴。

作为一名花费大量时间支持新手系统管理员的人,我认为这会极大地增加复杂性。仅仅支持一个简单到极致的防错配置,就需要付出巨大的工作量。甚至管理双容器安装,并知道将 app 替换为 web_only(更不用说知道如何以及何时更新数据容器),都会使复杂性至少增加四倍。

这一点,我认为才是最大的优势。

2 个赞

@pfaffman 如果你查看我所做的更改,你会发现我并没有修改 discourse_docker 中 Web 模板的指令,只是将其分成了三个文件。实际上,我唯一的要求就是这一点:其中一个文件运行与环境无关的指令,例如安装 Ruby gems、Discourse 及插件仓库等;另一个文件执行一些验证;第三个文件则处理特定于环境的任务,例如数据库迁移和预编译资源。

我认为这带来的复杂性在于:当这些文件中有新增内容时,Discourse 团队需要判断该内容是否与环境相关(例如是否依赖数据库中的内容),并据此决定应将其包含在哪个文件中

官方安装方式将保持与现在相同,只是会在 app.yml 文件中包含这三个模板(或者,如果可行的话,让 templates/web.template.yml 文件包含这三个模板,在这种情况下,从用户角度来看,不会有任何变化),而不是分多个步骤进行(虽然这样无法获得两步安装的优势,但会完全按照当前的方式运行,因此不会引入任何破坏性变更)。

我知道系统管理相关工作可能非常复杂,但我已尽量以最直接的方式实现,并参考当前的做法,以避免增加过多复杂性(这也是我没有提及 Kubernetes、在 docker-compose 文件中分离服务或类似方案的原因,因为这些需要较大的改动;相反,我基于官方安装的方式进行设计)。

5 个赞